diff --git a/.gitignore b/.gitignore index c95c149..923f5bb 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,7 @@ gradle-plugin-test/gradle/ gradle-plugin-test/gradlew* gradle.properties local.properties +bin/ +.settings/ +.project +.classpath \ No newline at end of file diff --git a/gradle-plugin/build.gradle b/gradle-plugin/build.gradle index 97b2d5e..f893926 100644 --- a/gradle-plugin/build.gradle +++ b/gradle-plugin/build.gradle @@ -13,6 +13,7 @@ sourceCompatibility = 21 dependencies { implementation gradleApi() implementation project(':core') + implementation group: 'org.jooq', name: 'jooq-codegen', version: '3.19.11' } apply from: "${rootProject.projectDir}/common.gradle" diff --git a/gradle-plugin/src/main/java/ch/ergon/adam/gradleplugin/AdamPlugin.java b/gradle-plugin/src/main/java/ch/ergon/adam/gradleplugin/AdamPlugin.java index 0987670..8739356 100644 --- a/gradle-plugin/src/main/java/ch/ergon/adam/gradleplugin/AdamPlugin.java +++ b/gradle-plugin/src/main/java/ch/ergon/adam/gradleplugin/AdamPlugin.java @@ -1,35 +1,45 @@ package ch.ergon.adam.gradleplugin; -import ch.ergon.adam.gradleplugin.tasks.*; +import javax.annotation.Nonnull; + import org.gradle.api.Plugin; import org.gradle.api.Project; import org.gradle.api.Task; -import javax.annotation.Nonnull; +import ch.ergon.adam.gradleplugin.tasks.CleanDbTask; +import ch.ergon.adam.gradleplugin.tasks.ExportGitHistoryTask; +import ch.ergon.adam.gradleplugin.tasks.ExportMigrationScriptsTask; +import ch.ergon.adam.gradleplugin.tasks.ExportTargetVersionTask; +import ch.ergon.adam.gradleplugin.tasks.GenerateJooqMetamodelTask; +import ch.ergon.adam.gradleplugin.tasks.MigrateDBTask; public class AdamPlugin implements Plugin { - public static final String ADAM_EXTENSION = "adam"; + public static final String ADAM_EXTENSION = "adam"; + + @Override + public void apply(@Nonnull Project project) { - @Override - public void apply(@Nonnull Project project) { + project.getExtensions().create(ADAM_EXTENSION, AdamExtension.class, project); - project.getExtensions().create(ADAM_EXTENSION, AdamExtension.class, project); + project.getTasks().create("adamExportMigrationScripts", ExportMigrationScriptsTask.class) + .setGroup(ADAM_EXTENSION); - project.getTasks().create("adamExportMigrationScripts", ExportMigrationScriptsTask.class).setGroup(ADAM_EXTENSION); + project.getTasks().create("adamExportGitHistory", ExportGitHistoryTask.class).setGroup(ADAM_EXTENSION); - project.getTasks().create("adamExportGitHistory", ExportGitHistoryTask.class).setGroup(ADAM_EXTENSION); + project.getTasks().create("adamExportTargetVersion", ExportTargetVersionTask.class).setGroup(ADAM_EXTENSION); - project.getTasks().create("adamExportTargetVersion", ExportTargetVersionTask.class).setGroup(ADAM_EXTENSION); + project.getTasks().create("adamGenerateJooqMetamodel", GenerateJooqMetamodelTask.class) + .setGroup(ADAM_EXTENSION); - Task adamExportTask = project.getTasks().create("adamExport", Task.class); - adamExportTask.setGroup(ADAM_EXTENSION); - adamExportTask.dependsOn("adamExportTargetVersion", "adamExportGitHistory", "adamExportMigrationScripts"); + Task adamExportTask = project.getTasks().create("adamExport", Task.class); + adamExportTask.setGroup(ADAM_EXTENSION); + adamExportTask.dependsOn("adamExportTargetVersion", "adamExportGitHistory", "adamExportMigrationScripts"); - Task migrateDbTask = project.getTasks().create("adamMigrateDb", MigrateDBTask.class); - migrateDbTask.setGroup(ADAM_EXTENSION); - migrateDbTask.dependsOn(adamExportTask); + Task migrateDbTask = project.getTasks().create("adamMigrateDb", MigrateDBTask.class); + migrateDbTask.setGroup(ADAM_EXTENSION); + migrateDbTask.dependsOn(adamExportTask); - project.getTasks().create("adamCleanDb", CleanDbTask.class).setGroup(ADAM_EXTENSION); - } + project.getTasks().create("adamCleanDb", CleanDbTask.class).setGroup(ADAM_EXTENSION); + } } diff --git a/gradle-plugin/src/main/java/ch/ergon/adam/gradleplugin/tasks/GenerateJooqMetamodelTask.java b/gradle-plugin/src/main/java/ch/ergon/adam/gradleplugin/tasks/GenerateJooqMetamodelTask.java new file mode 100644 index 0000000..e7f0a37 --- /dev/null +++ b/gradle-plugin/src/main/java/ch/ergon/adam/gradleplugin/tasks/GenerateJooqMetamodelTask.java @@ -0,0 +1,69 @@ +package ch.ergon.adam.gradleplugin.tasks; + +import java.nio.file.Path; +import java.nio.file.Paths; + +import org.gradle.api.DefaultTask; +import org.gradle.api.file.Directory; +import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.Optional; +import org.gradle.api.tasks.TaskAction; + +import ch.ergon.adam.gradleplugin.util.AdamJooqMetamodelGenerator; + +public class GenerateJooqMetamodelTask extends DefaultTask { + + @Input + @Optional + private String jooqConfig; + + @Input + private String packageName; + + @Input + private String outputPath; + + @Input + private String source; + + @TaskAction + public void generateJooqMetamodel() throws Exception { + Path output = getProject().getLayout().getProjectDirectory().dir(outputPath).getAsFile().toPath(); + AdamJooqMetamodelGenerator generator = new AdamJooqMetamodelGenerator(packageName, output, getSource(), + jooqConfig); + generator.run(); + } + + public String getJooqConfig() { + return jooqConfig; + } + + public void setJooqConfig(String jooqConfig) { + this.jooqConfig = jooqConfig; + } + + public String getPackageName() { + return packageName; + } + + public void setPackageName(String packageName) { + this.packageName = packageName; + } + + public String getOutputPath() { + return outputPath; + } + + public void setOutputPath(String outputPath) { + this.outputPath = outputPath; + } + + public String getSource() { + return source; + } + + public void setSource(String source) { + this.source = source; + } + +} diff --git a/gradle-plugin/src/main/java/ch/ergon/adam/gradleplugin/util/AdamDatabase.java b/gradle-plugin/src/main/java/ch/ergon/adam/gradleplugin/util/AdamDatabase.java new file mode 100644 index 0000000..d943ac9 --- /dev/null +++ b/gradle-plugin/src/main/java/ch/ergon/adam/gradleplugin/util/AdamDatabase.java @@ -0,0 +1,186 @@ +package ch.ergon.adam.gradleplugin.util; + +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; +import java.util.function.Predicate; + +import org.jooq.DSLContext; +import org.jooq.impl.DSL; +import org.jooq.meta.AbstractDatabase; +import org.jooq.meta.ArrayDefinition; +import org.jooq.meta.CatalogDefinition; +import org.jooq.meta.DefaultEnumDefinition; +import org.jooq.meta.DefaultRelations; +import org.jooq.meta.DomainDefinition; +import org.jooq.meta.EnumDefinition; +import org.jooq.meta.PackageDefinition; +import org.jooq.meta.RoutineDefinition; +import org.jooq.meta.SchemaDefinition; +import org.jooq.meta.SequenceDefinition; +import org.jooq.meta.TableDefinition; +import org.jooq.meta.UDTDefinition; +import org.jooq.meta.XMLSchemaCollectionDefinition; + +import ch.ergon.adam.core.db.SourceAndSinkFactory; +import ch.ergon.adam.core.db.schema.Field; +import ch.ergon.adam.core.db.schema.ForeignKey; +import ch.ergon.adam.core.db.schema.Index; +import ch.ergon.adam.core.db.schema.Schema; +import ch.ergon.adam.core.db.schema.Table; + +public class AdamDatabase extends AbstractDatabase { + public static final String SOURCE_PROPERTY = "source"; + private Schema schema; + private SchemaDefinition schemaDefinition; + + @Override + protected DSLContext create0() { + return DSL.using(getConnection()); + } + + @Override + protected void loadPrimaryKeys(DefaultRelations r) throws SQLException { + handleIndexes(i -> i.isPrimary(), i -> { + TableDefinition td = getTable(i.getTable()); + String name = i.getName(); + for (Field field : i.getFields()) { + r.addPrimaryKey(name, td, td.getColumn(field.getName())); + } + }); + } + + @Override + protected void loadUniqueKeys(DefaultRelations r) throws SQLException { + handleIndexes(i -> i.isUnique() && !i.isPrimary(), i -> { + TableDefinition td = getTable(i.getTable()); + String name = i.getName(); + for (Field field : i.getFields()) { + r.addUniqueKey(name, td, td.getColumn(field.getName())); + } + }); + } + + private void handleIndexes(Predicate filter, Consumer consumer) { + for (Table table : schema.getTables()) { + TableDefinition td = getTable(table); + if (td == null) { + // table is excluded from build + continue; + } + table.getIndexes().stream().filter(filter).forEach(consumer); + } + + } + + @Override + protected void loadForeignKeys(DefaultRelations r) throws SQLException { + for (Table table : schema.getTables()) { + TableDefinition td = getTable(table); + if (td == null) { + // table is excluded from build + continue; + } + for (ForeignKey fkey : table.getForeignKeys()) { + TableDefinition target = getTable(fkey.getTargetIndex().getTable()); + if (target != null) { + r.addForeignKey(fkey.getName(), td, td.getColumn(fkey.getField().getName()), + fkey.getTargetIndex().getName(), target); + } + } + } + } + + @Override + protected void loadCheckConstraints(DefaultRelations r) throws SQLException { + // not supported + } + + @Override + protected List getCatalogs0() throws SQLException { + ensureSchema(); + return mutableList(new CatalogDefinition(this, "", "")); + } + + @Override + protected List getSchemata0() throws SQLException { + ensureSchema(); + return mutableList(new SchemaDefinition(this, "", null)); + } + + @Override + protected List getSequences0() throws SQLException { + // not supported + return List.of(); + } + + @Override + protected List getTables0() throws SQLException { + return schema.getTables().stream().map(t -> (TableDefinition) new AdamTableDefinition(schemaDefinition, t)) + .toList(); + } + + @Override + protected List getRoutines0() throws SQLException { + // not supported + return List.of(); + } + + @Override + protected List getPackages0() throws SQLException { + // not supported + return List.of(); + } + + @Override + protected List getEnums0() throws SQLException { + return schema.getEnums().stream().map(e -> { + DefaultEnumDefinition definition = new DefaultEnumDefinition(schemaDefinition, e.getName(), null); + definition.addLiterals(e.getValues()); + return (EnumDefinition) definition; + }).toList(); + } + + @Override + protected List getDomains0() throws SQLException { + // not supported + return List.of(); + } + + @Override + protected List getXMLSchemaCollections0() throws SQLException { + // not supported + return List.of(); + } + + @Override + protected List getUDTs0() throws SQLException { + // not supported + return List.of(); + } + + @Override + protected List getArrays0() throws SQLException { + // not supported + return List.of(); + } + + private TableDefinition getTable(Table table) { + return getTable(schemaDefinition, table.getName()); + } + + private List mutableList(T value) { + List list = new ArrayList<>(); + list.add(value); + return list; + } + + private void ensureSchema() { + if (schema == null) { + String source = (String) getProperties().get(SOURCE_PROPERTY); + schema = SourceAndSinkFactory.getInstance().getSource(source).getSchema(); + schemaDefinition = new SchemaDefinition(this, "", null); + } + } +} diff --git a/gradle-plugin/src/main/java/ch/ergon/adam/gradleplugin/util/AdamJooqMetamodelGenerator.java b/gradle-plugin/src/main/java/ch/ergon/adam/gradleplugin/util/AdamJooqMetamodelGenerator.java new file mode 100644 index 0000000..82409c7 --- /dev/null +++ b/gradle-plugin/src/main/java/ch/ergon/adam/gradleplugin/util/AdamJooqMetamodelGenerator.java @@ -0,0 +1,66 @@ +package ch.ergon.adam.gradleplugin.util; + +import java.io.ByteArrayInputStream; +import java.nio.file.Path; + +import org.jooq.codegen.GenerationTool; +import org.jooq.meta.jaxb.Configuration; +import org.jooq.meta.jaxb.Database; +import org.jooq.meta.jaxb.Generate; +import org.jooq.meta.jaxb.Generator; +import org.jooq.meta.jaxb.Property; +import org.jooq.meta.jaxb.Strategy; +import org.jooq.meta.jaxb.Target; + +public class AdamJooqMetamodelGenerator { + + private String packageName; + private Path outputPath; + private String source; + private String jooqConfig; + + public AdamJooqMetamodelGenerator(String packageName, Path outputPath, String source, String jooqConfig) { + this.packageName = packageName; + this.outputPath = outputPath; + this.source = source; + this.jooqConfig = jooqConfig; + } + + public void run() throws Exception { + Configuration configuration; + if (jooqConfig != null) { + configuration = GenerationTool.load(new ByteArrayInputStream(jooqConfig.getBytes("UTF-8"))); + } else { + configuration = buildConfiguration(); + } + GenerationTool.generate(configuration); + } + + private Configuration buildConfiguration() { + Configuration configuration; + configuration = new Configuration(); + + Generator generator = new Generator(); + Database database = new Database(); + database.setName(AdamDatabase.class.getName()); + database.getProperties().add(new Property().withKey(AdamDatabase.SOURCE_PROPERTY).withValue(source)); + generator.setDatabase(database); + + Strategy strategy = new Strategy(); + strategy.setName(TableSuffixGeneratorStrategy.class.getName()); + generator.setStrategy(strategy); + + Generate generate = new Generate(); + generate.setGlobalObjectReferences(true); + generate.setJavaTimeTypes(true); + generator.setGenerate(generate); + + Target target = new Target(); + target.setPackageName(packageName); + target.setDirectory(outputPath.toString()); + generator.setTarget(target); + configuration.setGenerator(generator); + return configuration; + } + +} diff --git a/gradle-plugin/src/main/java/ch/ergon/adam/gradleplugin/util/AdamTableDefinition.java b/gradle-plugin/src/main/java/ch/ergon/adam/gradleplugin/util/AdamTableDefinition.java new file mode 100644 index 0000000..1d10278 --- /dev/null +++ b/gradle-plugin/src/main/java/ch/ergon/adam/gradleplugin/util/AdamTableDefinition.java @@ -0,0 +1,100 @@ +package ch.ergon.adam.gradleplugin.util; + +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; + +import org.jooq.Name; +import org.jooq.impl.SQLDataType; +import org.jooq.meta.AbstractTableDefinition; +import org.jooq.meta.ColumnDefinition; +import org.jooq.meta.DataTypeDefinition; +import org.jooq.meta.DefaultColumnDefinition; +import org.jooq.meta.DefaultDataTypeDefinition; +import org.jooq.meta.SchemaDefinition; + +import ch.ergon.adam.core.db.schema.DataType; +import ch.ergon.adam.core.db.schema.Field; +import ch.ergon.adam.core.db.schema.Table; + +public class AdamTableDefinition extends AbstractTableDefinition { + + private Table table; + + public AdamTableDefinition(SchemaDefinition schema, Table table) { + super(schema, table.getName(), null); + this.table = table; + } + + @Override + protected List getElements0() throws SQLException { + List result = new ArrayList(); + for (Field field : table.getFields()) { + result.add(new DefaultColumnDefinition(this, field.getName(), field.getIndex(), dataTypeDefinition(field), + isIdentity(field), null)); + } + return result; + } + + private DataTypeDefinition dataTypeDefinition(Field field) { + DataTypeDefinition dataType = new DefaultDataTypeDefinition(getDatabase(), getSchema(), + typeName(field.getDataType()), field.getLength(), field.getPrecision(), field.getScale(), + field.isNullable(), field.getDefaultValue(), (Name) null); + + return dataType; + } + + private String typeName(DataType type) { + var jooqType = switch (type) { + case BIGINT -> SQLDataType.BIGINT; + case BIGINTUNSIGNED -> SQLDataType.BIGINTUNSIGNED; + case BINARY -> SQLDataType.BINARY; + case BIT -> SQLDataType.BIT; + case BLOB -> SQLDataType.BLOB; + case BOOLEAN -> SQLDataType.BOOLEAN; + case CHAR -> SQLDataType.CHAR; + case CLOB -> SQLDataType.CLOB; + case DATE -> SQLDataType.DATE; + case DECIMAL -> SQLDataType.DECIMAL; + case DECIMAL_INTEGER -> SQLDataType.DECIMAL_INTEGER; + case DOUBLE -> SQLDataType.DOUBLE; + case ENUM -> SQLDataType.VARCHAR; + case FLOAT -> SQLDataType.FLOAT; + case INTEGER -> SQLDataType.INTEGER; + case INTEGERUNSIGNED -> SQLDataType.INTEGERUNSIGNED; + case INTERVALDAYTOSECOND -> SQLDataType.INTERVALDAYTOSECOND; + case INTERVALYEARTOMONTH -> SQLDataType.INTERVALYEARTOMONTH; + case INTERVALYEARTOSECOND -> SQLDataType.INTERVAL; + case LOCALDATE -> SQLDataType.LOCALDATE; + case LOCALDATETIME -> SQLDataType.LOCALDATETIME; + case LOCALTIME -> SQLDataType.LOCALTIME; + case LONGNVARCHAR -> SQLDataType.LONGNVARCHAR; + case LONGVARBINARY -> SQLDataType.LONGVARBINARY; + case LONGVARCHAR -> SQLDataType.LONGVARCHAR; + case NCHAR -> SQLDataType.NCHAR; + case NCLOB -> SQLDataType.NCLOB; + case NUMERIC -> SQLDataType.NUMERIC; + case NVARCHAR -> SQLDataType.NVARCHAR; + case OFFSETDATETIME -> SQLDataType.OFFSETDATETIME; + case OFFSETTIME -> SQLDataType.OFFSETTIME; + case REAL -> SQLDataType.REAL; + case SMALLINT -> SQLDataType.SMALLINT; + case SMALLINTUNSIGNED -> SQLDataType.SMALLINTUNSIGNED; + case TIME -> SQLDataType.TIME; + case TIMESTAMP -> SQLDataType.TIMESTAMP; + case TIMESTAMPWITHTIMEZONE -> SQLDataType.TIMESTAMPWITHTIMEZONE; + case TIMEWITHTIMEZONE -> SQLDataType.TIMEWITHTIMEZONE; + case TINYINT -> SQLDataType.TINYINT; + case TINYINTUNSIGNED -> SQLDataType.TINYINTUNSIGNED; + case UUID -> SQLDataType.UUID; + case VARBINARY -> SQLDataType.VARBINARY; + case VARCHAR -> SQLDataType.VARCHAR; + }; + return jooqType.getTypeName(); + } + + private boolean isIdentity(Field field) { + return field.isSequence(); + } + +} diff --git a/gradle-plugin/src/main/java/ch/ergon/adam/gradleplugin/util/TableSuffixGeneratorStrategy.java b/gradle-plugin/src/main/java/ch/ergon/adam/gradleplugin/util/TableSuffixGeneratorStrategy.java new file mode 100644 index 0000000..d249ebc --- /dev/null +++ b/gradle-plugin/src/main/java/ch/ergon/adam/gradleplugin/util/TableSuffixGeneratorStrategy.java @@ -0,0 +1,23 @@ +package ch.ergon.adam.gradleplugin.util; + +import org.jooq.codegen.DefaultGeneratorStrategy; +import org.jooq.meta.Definition; +import org.jooq.meta.TableDefinition; + +/** + * Alters jOOQ's DefaultGeneratorStrategy to name tables with an explicit suffix, + * e.g. {@code PERSON} becomes {@code PersonTable}. + */ +public class TableSuffixGeneratorStrategy extends DefaultGeneratorStrategy { + + @Override + public String getJavaClassName(Definition definition, org.jooq.codegen.GeneratorStrategy.Mode mode) { + String defaultName = super.getJavaClassName(definition, mode); + if (mode == org.jooq.codegen.GeneratorStrategy.Mode.DEFAULT && definition instanceof TableDefinition) { + return defaultName + "Table"; + } else { + return defaultName; + } + } + +} diff --git a/integration-test/build.gradle b/integration-test/build.gradle index 81f58a0..fb380b7 100644 --- a/integration-test/build.gradle +++ b/integration-test/build.gradle @@ -30,6 +30,7 @@ dependencies { testImplementation project(':oracle') testImplementation project(':sqlite') testImplementation project(':integration-test-db') + testImplementation project(':gradle-plugin') testImplementation 'org.junit.jupiter:junit-jupiter-api:5.11.1' testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.11.1' testImplementation group: 'org.hamcrest', name: 'hamcrest', version: '3.0' diff --git a/integration-test/src/test/java/ch/ergon/adam/integrationtest/plugin/GenerateJooqMetamodelTest.java b/integration-test/src/test/java/ch/ergon/adam/integrationtest/plugin/GenerateJooqMetamodelTest.java new file mode 100644 index 0000000..be0d19c --- /dev/null +++ b/integration-test/src/test/java/ch/ergon/adam/integrationtest/plugin/GenerateJooqMetamodelTest.java @@ -0,0 +1,39 @@ +package ch.ergon.adam.integrationtest.plugin; + +import static ch.ergon.adam.core.Adam.DEFAULT_SCHEMA_PACKAGE; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import ch.ergon.adam.gradleplugin.util.AdamJooqMetamodelGenerator; + +public class GenerateJooqMetamodelTest { + + private static Path tempFolder; + + @BeforeAll + public static void setupTempFolder() throws IOException { + tempFolder = Files.createTempDirectory("MetamodelTest"); + tempFolder.toFile().deleteOnExit(); + } + + @Test + public void testMetamodelGeneration() { + AdamJooqMetamodelGenerator generator = new AdamJooqMetamodelGenerator("test", tempFolder, + "yml-classpath://" + DEFAULT_SCHEMA_PACKAGE, null); + try { + generator.run(); + Path generated = tempFolder.resolve("test").resolve("tables").resolve("TestTableTable.java"); + assertTrue(generated.toFile().exists(), "Metamodel for test table must be generated"); + } catch (Exception e) { + e.printStackTrace(); + fail(e.getMessage()); + } + } +}