diff --git a/pom.xml b/pom.xml index 4a5a0c9822..18b4b66880 100644 --- a/pom.xml +++ b/pom.xml @@ -21,6 +21,7 @@ spring-data-jdbc 3.2.0-SNAPSHOT + 4.21.1 reuseReports @@ -91,6 +92,29 @@ -6 + + ogierke + Oliver Gierke + ogierke(at)pivotal.io + Pivotal Software, Inc. + https://pivotal.io + + Project Contributor + + +1 + + + kurtn718 + Kurt Niemi + kniemi(at)vmware.com + VMware. + https://vmware.com + + Project Contributor + + -5 + + diff --git a/spring-data-jdbc/pom.xml b/spring-data-jdbc/pom.xml index ede9f0390f..54107351bf 100644 --- a/spring-data-jdbc/pom.xml +++ b/spring-data-jdbc/pom.xml @@ -25,53 +25,6 @@ 2017 - - - schauder - Jens Schauder - jschauder(at)pivotal.io - Pivotal Software, Inc. - https://pivotal.io - - Project Lead - - +1 - - - gregturn - Greg L. Turnquist - gturnquist(at)pivotal.io - Pivotal Software, Inc. - https://pivotal.io - - Project Contributor - - -6 - - - ogierke - Oliver Gierke - ogierke(at)pivotal.io - Pivotal Software, Inc. - https://pivotal.io - - Project Contributor - - +1 - - - mpaluch - Mark Paluch - mpaluch(at)pivotal.io - Pivotal Software, Inc. - https://pivotal.io - - Project Contributor - - +1 - - - @@ -269,6 +222,14 @@ test + + org.liquibase + liquibase-core + ${liquibase.version} + compile + true + + diff --git a/spring-data-r2dbc/pom.xml b/spring-data-r2dbc/pom.xml index 98019e0295..4eb3a50392 100644 --- a/spring-data-r2dbc/pom.xml +++ b/spring-data-r2dbc/pom.xml @@ -39,31 +39,6 @@ 2018 - - - mpaluch - Mark Paluch - mpaluch(at)pivotal.io - Pivotal Software, Inc. - https://pivotal.io - - Project Lead - - +1 - - - ogierke - Oliver Gierke - ogierke(at)pivotal.io - Pivotal Software, Inc. - https://pivotal.io - - Project Lead - - +1 - - - diff --git a/spring-data-relational/pom.xml b/spring-data-relational/pom.xml index 57b9d707a6..557e9af671 100644 --- a/spring-data-relational/pom.xml +++ b/spring-data-relational/pom.xml @@ -50,6 +50,14 @@ spring-core + + org.liquibase + liquibase-core + ${liquibase.version} + compile + true + + com.google.code.findbugs jsr305 @@ -97,6 +105,6 @@ test - + diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/RelationalMappingContext.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/RelationalMappingContext.java index 1c70375cc3..fbdf3e0c05 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/RelationalMappingContext.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/RelationalMappingContext.java @@ -128,5 +128,5 @@ protected void applyDefaults(BasicRelationalPersistentProperty persistentPropert persistentProperty.setForceQuote(isForceQuote()); persistentProperty.setExpressionEvaluator(this.expressionEvaluator); } - + } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/schemasqlgeneration/ColumnModel.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/schemasqlgeneration/ColumnModel.java new file mode 100644 index 0000000000..6a6c1c128f --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/schemasqlgeneration/ColumnModel.java @@ -0,0 +1,47 @@ +/* + * Copyright 2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.relational.core.mapping.schemasqlgeneration; + +import org.springframework.data.relational.core.sql.SqlIdentifier; + +import java.util.Objects; + + +/** + * Models a Column for generating SQL for Schema generation. + * + * @author Kurt Niemi + * @since 3.2 + */ +public record ColumnModel(String name, String type, boolean nullable, boolean identityColumn) { + + public ColumnModel(String name, String type) { + this(name, type, false, false); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ColumnModel that = (ColumnModel) o; + return Objects.equals(name, that.name); + } + + @Override + public int hashCode() { + return Objects.hash(name); + } +} diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/schemasqlgeneration/DatabaseTypeMapping.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/schemasqlgeneration/DatabaseTypeMapping.java new file mode 100644 index 0000000000..bd99e4ce27 --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/schemasqlgeneration/DatabaseTypeMapping.java @@ -0,0 +1,29 @@ +/* + * Copyright 2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.relational.core.mapping.schemasqlgeneration; + +/** + * Interface for mapping a Java type to a Database type. + * + * To customize the mapping an instance of a class implementing {@link DatabaseTypeMapping} interface + * can be set on the {@link SchemaModel} class. + * + * @author Kurt Niemi + * @since 3.2 + */ +public interface DatabaseTypeMapping { + public String databaseTypeFromClass(Class type); +} \ No newline at end of file diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/schemasqlgeneration/DefaultDatabaseTypeMapping.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/schemasqlgeneration/DefaultDatabaseTypeMapping.java new file mode 100644 index 0000000000..c4ebe81b51 --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/schemasqlgeneration/DefaultDatabaseTypeMapping.java @@ -0,0 +1,47 @@ +/* + * Copyright 2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.relational.core.mapping.schemasqlgeneration; + +import java.util.HashMap; + + +/** + * Class that provides a default implementation of mapping Java type to a Database type. + * + * To customize the mapping an instance of a class implementing {@link DatabaseTypeMapping} interface + * can be set on the {@link SchemaModel} class + * + * @author Kurt Niemi + * @since 3.2 + */ +public class DefaultDatabaseTypeMapping implements DatabaseTypeMapping { + + final HashMap,String> mapClassToDatabaseType = new HashMap,String>(); + + public DefaultDatabaseTypeMapping() { + + mapClassToDatabaseType.put(String.class, "VARCHAR(255 BYTE)"); + mapClassToDatabaseType.put(Boolean.class, "TINYINT"); + mapClassToDatabaseType.put(Double.class, "DOUBLE"); + mapClassToDatabaseType.put(Float.class, "FLOAT"); + mapClassToDatabaseType.put(Integer.class, "INT"); + mapClassToDatabaseType.put(Long.class, "BIGINT"); + } + public String databaseTypeFromClass(Class type) { + + return mapClassToDatabaseType.get(type); + } +} diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/schemasqlgeneration/LiquibaseChangeSetGenerator.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/schemasqlgeneration/LiquibaseChangeSetGenerator.java new file mode 100644 index 0000000000..e496985492 --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/schemasqlgeneration/LiquibaseChangeSetGenerator.java @@ -0,0 +1,351 @@ +/* + * Copyright 2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.data.relational.core.mapping.schemasqlgeneration; + +import liquibase.CatalogAndSchema; +import liquibase.change.AddColumnConfig; +import liquibase.change.ColumnConfig; +import liquibase.change.ConstraintsConfig; +import liquibase.change.core.AddColumnChange; +import liquibase.change.core.CreateTableChange; +import liquibase.change.core.DropColumnChange; +import liquibase.change.core.DropTableChange; +import liquibase.changelog.ChangeLogChild; +import liquibase.changelog.ChangeLogParameters; +import liquibase.changelog.ChangeSet; +import liquibase.changelog.DatabaseChangeLog; +import liquibase.database.Database; +import liquibase.exception.ChangeLogParseException; +import liquibase.exception.DatabaseException; +import liquibase.parser.core.yaml.YamlChangeLogParser; +import liquibase.resource.DirectoryResourceAccessor; +import liquibase.serializer.ChangeLogSerializer; +import liquibase.serializer.core.yaml.YamlChangeLogSerializer; +import liquibase.snapshot.DatabaseSnapshot; +import liquibase.snapshot.InvalidExampleException; +import liquibase.snapshot.SnapshotControl; +import liquibase.snapshot.SnapshotGeneratorFactory; +import liquibase.structure.core.Column; +import liquibase.structure.core.Table; +import org.springframework.core.io.Resource; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.function.Predicate; + +/** + * Use this class to generate Liquibase change sets. + * + * First create a {@link SchemaModel} instance passing in a RelationalContext to have + * a model that represents the Table(s)/Column(s) that the code expects to exist. + * + * And then optionally create a Liquibase database object that points to an existing database + * if one desires to create a changeset that could be applied to that database. + * + * If a Liquibase database object is not used, then the change set created would be + * something that could be applied to an empty database to make it match the state of the code. + * + * Prior to applying the changeset one should review and make adjustments appropriately. + * + * @author Kurt Niemi + * @since 3.2 + */ +public class LiquibaseChangeSetGenerator { + + private final SchemaModel sourceModel; + private final Database targetDatabase; + + /** + * If there should ever be future Liquibase tables that should not be deleted (removed), this + * predicate should be modified + */ + private final Predicate liquibaseTables = table -> ( table.startsWith("DATABASECHANGELOG") ); + + /** + * By default existing tables in the target database are never deleted + */ + public Predicate userApplicationTables = table -> ( true ); + + /** + * By default existing columns in the target database are never deleted. + * Columns will be passed into the predicate in the format TableName.ColumnName + */ + public Predicate userApplicationTableColumns = table -> ( true ); + + /** + * Use this to generate a ChangeSet that can be used on an empty database + * + * @author Kurt Niemi + * @since 3.2 + * + * @param sourceModel - Model representing table(s)/column(s) as existing in code + */ + public LiquibaseChangeSetGenerator(SchemaModel sourceModel) { + + this.sourceModel = sourceModel; + this.targetDatabase = null; + } + + /** + * Use this to generate a ChangeSet against an existing database + * + * @author Kurt Niemi + * @since 3.2 + * + * @param sourceModel - Model representing table(s)/column(s) as existing in code + * @param targetDatabase - Existing Liquibase database + */ + public LiquibaseChangeSetGenerator(SchemaModel sourceModel, Database targetDatabase) { + + this.sourceModel = sourceModel; + this.targetDatabase = targetDatabase; + } + + /** + * Generates a Liquibase Changeset + * + * @author Kurt Niemi + * @since 3.2 + * + * @param changeLogResource - Resource that changeset will be written to (or append to an existing ChangeSet file) + * @throws InvalidExampleException + * @throws DatabaseException + * @throws IOException + * @throws ChangeLogParseException + */ + public void generateLiquibaseChangeset(Resource changeLogResource) throws InvalidExampleException, DatabaseException, IOException, ChangeLogParseException { + + String changeSetId = Long.toString(System.currentTimeMillis()); + generateLiquibaseChangeset(changeLogResource, changeSetId, "Spring Data JDBC"); + } + + /** + * Generates a Liquibase Changeset + * + * @author Kurt Niemi + * @since 3.2 + * + * @param changeLogResource - Resource that changeset will be written to (or append to an existing ChangeSet file) + * @param changeSetId - A unique value to identify the changeset + * @param changeSetAuthor - Author information to be written to changeset file. + * @throws InvalidExampleException + * @throws DatabaseException + * @throws IOException + * @throws ChangeLogParseException + */ + public void generateLiquibaseChangeset(Resource changeLogResource, String changeSetId, String changeSetAuthor) throws InvalidExampleException, DatabaseException, IOException, ChangeLogParseException { + + SchemaDiff difference; + + if (targetDatabase != null) { + SchemaModel liquibaseModel = getLiquibaseModel(); + difference = new SchemaDiff(sourceModel,liquibaseModel); + } else { + difference = new SchemaDiff(sourceModel, new SchemaModel()); + } + + DatabaseChangeLog databaseChangeLog = getDatabaseChangeLog(changeLogResource.getFile()); + + ChangeSet changeSet = new ChangeSet(changeSetId, changeSetAuthor, false, false, "", "", "" , databaseChangeLog); + + generateTableAdditionsDeletions(changeSet, difference); + generateTableModifications(changeSet, difference); + + +// File changeLogFile = new File(changeLogFilePath); + writeChangeSet(databaseChangeLog, changeSet, changeLogResource.getFile()); + } + + private void generateTableAdditionsDeletions(ChangeSet changeSet, SchemaDiff difference) { + + for (TableModel table : difference.getTableAdditions()) { + CreateTableChange newTable = createAddTableChange(table); + changeSet.addChange(newTable); + } + + for (TableModel table : difference.getTableDeletions()) { + // Do not delete/drop table if it is an external application table + if (!userApplicationTables.test(table.name())) { + DropTableChange dropTable = createDropTableChange(table); + changeSet.addChange(dropTable); + } + } + } + + private void generateTableModifications(ChangeSet changeSet, SchemaDiff difference) { + + for (TableDiff table : difference.getTableDiff()) { + + if (table.addedColumns().size() > 0) { + AddColumnChange addColumnChange = new AddColumnChange(); + addColumnChange.setSchemaName(table.tableModel().schema()); + addColumnChange.setTableName(table.tableModel().name()); + + for (ColumnModel column : table.addedColumns()) { + AddColumnConfig addColumn = createAddColumnChange(column); + addColumnChange.addColumn(addColumn); + } + + changeSet.addChange(addColumnChange); + } + + ArrayList deletedColumns = new ArrayList<>(); + for (ColumnModel columnModel : table.deletedColumns()) { + String fullName = table.tableModel().name() + "." + columnModel.name(); + + if (!userApplicationTableColumns.test(fullName)) { + deletedColumns.add(columnModel); + } + } + + if (deletedColumns.size() > 0) { + DropColumnChange dropColumnChange = new DropColumnChange(); + dropColumnChange.setSchemaName(table.tableModel().schema()); + dropColumnChange.setTableName(table.tableModel().name()); + + List dropColumns = new ArrayList(); + for (ColumnModel column : table.deletedColumns()) { + ColumnConfig config = new ColumnConfig(); + config.setName(column.name()); + dropColumns.add(config); + } + dropColumnChange.setColumns(dropColumns); + changeSet.addChange(dropColumnChange); + } + } + } + + private DatabaseChangeLog getDatabaseChangeLog(File changeLogFile) { + + DatabaseChangeLog databaseChangeLog = null; + + try { + YamlChangeLogParser parser = new YamlChangeLogParser(); + File parentDirectory = changeLogFile.getParentFile(); + if (parentDirectory == null) { + parentDirectory = new File("./"); + } + DirectoryResourceAccessor resourceAccessor = new DirectoryResourceAccessor(parentDirectory); + ChangeLogParameters parameters = new ChangeLogParameters(); + databaseChangeLog = parser.parse(changeLogFile.getName(), parameters, resourceAccessor); + } catch (Exception ex) { + databaseChangeLog = new DatabaseChangeLog(changeLogFile.getAbsolutePath()); + } + return databaseChangeLog; + } + + private void writeChangeSet(DatabaseChangeLog databaseChangeLog, ChangeSet changeSet, File changeLogFile) throws FileNotFoundException, IOException { + + ChangeLogSerializer serializer = new YamlChangeLogSerializer(); + List changes = new ArrayList(); + for (ChangeSet change : databaseChangeLog.getChangeSets()) { + changes.add(change); + } + changes.add(changeSet); + FileOutputStream fos = new FileOutputStream(changeLogFile); + serializer.write(changes, fos); + } + + private SchemaModel getLiquibaseModel() throws DatabaseException, InvalidExampleException { + SchemaModel liquibaseModel = new SchemaModel(); + + CatalogAndSchema[] schemas = new CatalogAndSchema[] { targetDatabase.getDefaultSchema() }; + SnapshotControl snapshotControl = new SnapshotControl(targetDatabase); + + DatabaseSnapshot snapshot = SnapshotGeneratorFactory.getInstance().createSnapshot(schemas, targetDatabase, snapshotControl); + Set tables = snapshot.get(liquibase.structure.core.Table.class); + + for (int i=0; i < sourceModel.getTableData().size(); i++) { + TableModel currentModel = sourceModel.getTableData().get(i); + if (currentModel.schema() == null || currentModel.schema().isEmpty()) { + TableModel newModel = new TableModel(targetDatabase.getDefaultSchema().getCatalogName(), + currentModel.name(), currentModel.columns(), currentModel.keyColumns()); + sourceModel.getTableData().set(i, newModel); + } + } + + for (liquibase.structure.core.Table table : tables) { + + // Exclude internal Liquibase tables from comparison + if (liquibaseTables.test(table.getName())) { + continue; + } + + TableModel tableModel = new TableModel(table.getSchema().getCatalogName(), table.getName()); + liquibaseModel.getTableData().add(tableModel); + + List columns = table.getColumns(); + for (liquibase.structure.core.Column column : columns) { + String type = column.getType().toString(); + boolean nullable = column.isNullable(); + ColumnModel columnModel = new ColumnModel(column.getName(), type, nullable, false); + tableModel.columns().add(columnModel); + } + } + + return liquibaseModel; + } + + private AddColumnConfig createAddColumnChange(ColumnModel column) { + + AddColumnConfig config = new AddColumnConfig(); + config.setName(column.name()); + config.setType(column.type()); + + if (column.identityColumn()) { + config.setAutoIncrement(true); + } + return config; + } + + private CreateTableChange createAddTableChange(TableModel table) { + + CreateTableChange change = new CreateTableChange(); + change.setSchemaName(table.schema()); + change.setTableName(table.name()); + + for (ColumnModel column : table.columns()) { + ColumnConfig columnConfig = new ColumnConfig(); + columnConfig.setName(column.name()); + columnConfig.setType(column.type()); + + if (column.identityColumn()) { + columnConfig.setAutoIncrement(true); + ConstraintsConfig constraints = new ConstraintsConfig(); + constraints.setPrimaryKey(true); + columnConfig.setConstraints(constraints); + } + change.addColumn(columnConfig); + } + + return change; + } + + private DropTableChange createDropTableChange(TableModel table) { + DropTableChange change = new DropTableChange(); + change.setSchemaName(table.schema()); + change.setTableName(table.name()); + change.setCascadeConstraints(true); + + return change; + } +} diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/schemasqlgeneration/SchemaDiff.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/schemasqlgeneration/SchemaDiff.java new file mode 100644 index 0000000000..3a4668335b --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/schemasqlgeneration/SchemaDiff.java @@ -0,0 +1,99 @@ +package org.springframework.data.relational.core.mapping.schemasqlgeneration; + +import java.util.*; + +/** + * This class is created to return the difference between a source and target {@link SchemaModel} + * + * The difference consists of Table Additions, Deletions, and Modified Tables (i.e. table + * exists in both source and target - but has columns to add or delete) + * + * @author Kurt Niemi + * @since 3.2 + */ +public class SchemaDiff { + private final List tableAdditions = new ArrayList(); + private final List tableDeletions = new ArrayList(); + private final List tableDiffs = new ArrayList(); + + private SchemaModel source; + private SchemaModel target; + + /** + * + * Compare two {@link SchemaModel} to identify differences. + * + * @param target - Model reflecting current database state + * @param source - Model reflecting desired database state + */ + public SchemaDiff(SchemaModel target, SchemaModel source) { + + this.source = source; + this.target = target; + + diffTableAdditionDeletion(); + diffTable(); + } + + public List getTableAdditions() { + + return tableAdditions; + } + + public List getTableDeletions() { + + return tableDeletions; + } + public List getTableDiff() { + + return tableDiffs; + } + + private void diffTableAdditionDeletion() { + + Set sourceTableData = new HashSet(source.getTableData()); + Set targetTableData = new HashSet(target.getTableData()); + + // Identify deleted tables + Set deletedTables = new HashSet(sourceTableData); + deletedTables.removeAll(targetTableData); + tableDeletions.addAll(deletedTables); + + // Identify added tables + Set addedTables = new HashSet(targetTableData); + addedTables.removeAll(sourceTableData); + tableAdditions.addAll(addedTables); + } + + private void diffTable() { + + HashMap sourceTablesMap = new HashMap(); + for (TableModel table : source.getTableData()) { + sourceTablesMap.put(table.schema() + "." + table.name(), table); + } + + Set existingTables = new HashSet(target.getTableData()); + existingTables.removeAll(getTableAdditions()); + + for (TableModel table : existingTables) { + TableDiff tableDiff = new TableDiff(table); + tableDiffs.add(tableDiff); + + TableModel sourceTable = sourceTablesMap.get(table.schema() + "." + table.name()); + + Set sourceTableData = new HashSet(sourceTable.columns()); + Set targetTableData = new HashSet(table.columns()); + + // Identify deleted columns + Set deletedColumns = new HashSet(sourceTableData); + deletedColumns.removeAll(targetTableData); + + tableDiff.deletedColumns().addAll(deletedColumns); + + // Identify added columns + Set addedColumns = new HashSet(targetTableData); + addedColumns.removeAll(sourceTableData); + tableDiff.addedColumns().addAll(addedColumns); + } + } +} diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/schemasqlgeneration/SchemaModel.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/schemasqlgeneration/SchemaModel.java new file mode 100644 index 0000000000..209fc2d763 --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/schemasqlgeneration/SchemaModel.java @@ -0,0 +1,83 @@ +/* + * Copyright 2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.relational.core.mapping.schemasqlgeneration; + +import org.springframework.data.annotation.Id; +import org.springframework.data.mapping.PropertyHandler; +import org.springframework.data.relational.core.mapping.*; + +import java.util.*; + +/** + * Model class that contains Table/Column information that can be used + * to generate SQL for Schema generation. + * + * @author Kurt Niemi + * @since 3.2 + */ +public class SchemaModel +{ + private final List tableData = new ArrayList(); + public DatabaseTypeMapping databaseTypeMapping; + + /** + * Create empty model + */ + public SchemaModel() { + + } + + /** + * Create model from a RelationalMappingContext + */ + public SchemaModel(RelationalMappingContext context) { + + if (databaseTypeMapping == null) { + databaseTypeMapping = new DefaultDatabaseTypeMapping(); + } + + for (RelationalPersistentEntity entity : context.getPersistentEntities()) { + TableModel tableModel = new TableModel(entity.getTableName().getReference()); + + Iterator iter = + entity.getPersistentProperties(Id.class).iterator(); + Set setIdentifierColumns = new HashSet(); + while (iter.hasNext()) { + BasicRelationalPersistentProperty p = iter.next(); + setIdentifierColumns.add(p); + } + + entity.doWithProperties((PropertyHandler) handler -> { + BasicRelationalPersistentProperty property = (BasicRelationalPersistentProperty)handler; + + if (property.isEntity() && !property.isEmbedded()) { + return; + } + + ColumnModel columnModel = new ColumnModel(property.getColumnName().getReference(), + databaseTypeMapping.databaseTypeFromClass(property.getActualType()), + true, setIdentifierColumns.contains(property)); + tableModel.columns().add(columnModel); + }); + + tableData.add(tableModel); + } + } + + public List getTableData() { + return tableData; + } +} diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/schemasqlgeneration/TableDiff.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/schemasqlgeneration/TableDiff.java new file mode 100644 index 0000000000..137a5bdf94 --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/schemasqlgeneration/TableDiff.java @@ -0,0 +1,21 @@ +package org.springframework.data.relational.core.mapping.schemasqlgeneration; + +import java.util.ArrayList; +import java.util.List; + +/** + * Used to keep track of columns that have been added or deleted, + * when performing a difference between a source and target {@link SchemaModel} + * + * @author Kurt Niemi + * @since 3.2 + */ +public record TableDiff(TableModel tableModel, + ArrayList addedColumns, + ArrayList deletedColumns) { + + public TableDiff(TableModel tableModel) { + this(tableModel, new ArrayList<>(), new ArrayList<>()); + } + +} diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/schemasqlgeneration/TableModel.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/schemasqlgeneration/TableModel.java new file mode 100644 index 0000000000..795fbb8b23 --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/schemasqlgeneration/TableModel.java @@ -0,0 +1,69 @@ +/* + * Copyright 2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.relational.core.mapping.schemasqlgeneration; + +import org.springframework.data.relational.core.sql.SqlIdentifier; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * Models a Table for generating SQL for Schema generation. + * + * @author Kurt Niemi + * @since 3.2 + */ +public record TableModel(String schema, String name, List columns, List keyColumns) { + public TableModel(String schema, String name) { + this(schema, name, new ArrayList<>(), new ArrayList<>()); + } + + public TableModel(String name) { + this(null, name); + } + + @Override + public boolean equals(Object o) { + + if (this == o) { + return true; + } + + if (o == null || getClass() != o.getClass()) { + return false; + } + + TableModel that = (TableModel) o; + + // If we are missing the schema for either TableModel we will not treat that as being different + if (schema != null && that.schema != null && !schema.isEmpty() && !that.schema.isEmpty()) { + if (!Objects.equals(schema, that.schema)) { + return false; + } + } + if (!name.toUpperCase().equals(that.name.toUpperCase())) { + return false; + } + return true; + } + + @Override + public int hashCode() { + + return Objects.hash(name.toUpperCase()); + } +} diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/SchemaModelTests.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/SchemaModelTests.java new file mode 100644 index 0000000000..fed396222d --- /dev/null +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/SchemaModelTests.java @@ -0,0 +1,98 @@ +/* + * Copyright 2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.relational.core.sql; + +import org.junit.jupiter.api.Test; +import org.springframework.data.relational.core.mapping.Column; +import org.springframework.data.relational.core.mapping.RelationalMappingContext; +import org.springframework.data.relational.core.mapping.Table; +import org.springframework.data.relational.core.mapping.schemasqlgeneration.*; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for the {@link SchemaModel}. + * + * @author Kurt Niemi + */ +public class SchemaModelTests { + + @Test + void testDiffSchema() { + + RelationalMappingContext context = new RelationalMappingContext(); + context.getRequiredPersistentEntity(SchemaModelTests.Table1.class); + context.getRequiredPersistentEntity(SchemaModelTests.Table2.class); + + SchemaModel model = new SchemaModel(context); + + SchemaModel newModel = new SchemaModel(context); + + // Add column to table + ColumnModel newColumn = new ColumnModel("newcol", "VARCHAR(255)"); + newModel.getTableData().get(0).columns().add(newColumn); + + // Remove table + newModel.getTableData().remove(1); + + // Add new table + TableModel newTable = new TableModel(null, "newtable"); + newTable.columns().add(newColumn); + newModel.getTableData().add(newTable); + + SchemaDiff diff = new SchemaDiff(model, newModel); + + // Verify that newtable is an added table in the diff + assertThat(diff.getTableAdditions().size() > 0); + assertThat(diff.getTableAdditions().get(0).name().equals("table1")); + + assertThat(diff.getTableDeletions().size() > 0); + assertThat(diff.getTableDeletions().get(0).name().equals("vader")); + + assertThat(diff.getTableDiff().size() > 0); + assertThat(diff.getTableDiff().get(0).addedColumns().size() > 0); + assertThat(diff.getTableDiff().get(0).deletedColumns().size() > 0); + } + + // Test table classes for performing schema diff + @Table + static class Table1 { + @Column + public String force; + @Column + public String be; + @Column + public String with; + @Column + public String you; + } + + @Table + static class Table2 { + @Column + public String lukeIAmYourFather; + @Column + public Boolean darkSide; + @Column + public Float floater; + @Column + public Double doubleClass; + @Column + public Integer integerClass; + } + + +}