From 5f4ef2ffcbaa91f2eba0d560f55ea4b0b1afae69 Mon Sep 17 00:00:00 2001 From: Kurt Niemi Date: Thu, 13 Apr 2023 07:59:28 -0400 Subject: [PATCH] Add support for schema creation using Liquibase. We now support schema creation and schema migration by generating Liquibase changesets from mapped entities. We also support evolution of schema by comparing existing tables with mapped entities to compute differential changesets. Closes #756 Original pull request: #1520 --- pom.xml | 24 ++ spring-data-jdbc/pom.xml | 55 +-- spring-data-r2dbc/pom.xml | 25 -- spring-data-relational/pom.xml | 10 +- .../mapping/RelationalMappingContext.java | 2 +- .../schemasqlgeneration/ColumnModel.java | 47 +++ .../DatabaseTypeMapping.java | 29 ++ .../DefaultDatabaseTypeMapping.java | 47 +++ .../LiquibaseChangeSetGenerator.java | 351 ++++++++++++++++++ .../schemasqlgeneration/SchemaDiff.java | 99 +++++ .../schemasqlgeneration/SchemaModel.java | 83 +++++ .../schemasqlgeneration/TableDiff.java | 21 ++ .../schemasqlgeneration/TableModel.java | 69 ++++ .../relational/core/sql/SchemaModelTests.java | 98 +++++ 14 files changed, 886 insertions(+), 74 deletions(-) create mode 100644 spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/schemasqlgeneration/ColumnModel.java create mode 100644 spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/schemasqlgeneration/DatabaseTypeMapping.java create mode 100644 spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/schemasqlgeneration/DefaultDatabaseTypeMapping.java create mode 100644 spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/schemasqlgeneration/LiquibaseChangeSetGenerator.java create mode 100644 spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/schemasqlgeneration/SchemaDiff.java create mode 100644 spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/schemasqlgeneration/SchemaModel.java create mode 100644 spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/schemasqlgeneration/TableDiff.java create mode 100644 spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/schemasqlgeneration/TableModel.java create mode 100644 spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/SchemaModelTests.java 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; + } + + +}