diff --git a/hibernate-core/src/main/java/org/hibernate/cfg/SchemaToolingSettings.java b/hibernate-core/src/main/java/org/hibernate/cfg/SchemaToolingSettings.java index e4ac6b6bdc57..0035ef281abc 100644 --- a/hibernate-core/src/main/java/org/hibernate/cfg/SchemaToolingSettings.java +++ b/hibernate-core/src/main/java/org/hibernate/cfg/SchemaToolingSettings.java @@ -420,6 +420,20 @@ public interface SchemaToolingSettings { */ String HBM2DDL_SKIP_DEFAULT_IMPORT_FILE = "hibernate.hbm2ddl.skip_default_import_file"; + /// Whether {@linkplain org.hibernate.mapping.Index indexes} should be validated when + /// performing {@linkplain org.hibernate.tool.schema.Action#VALIDATE schema validation}. + /// Valid values are defined by [org.hibernate.tool.schema.internal.ConstraintValidationType]. + /// + /// @since 7.3 + String INDEX_VALIDATION = "hibernate.tooling.schema.index_validation"; + + /// Whether {@linkplain org.hibernate.mapping.UniqueKey unique keys} should be validated when + /// performing {@linkplain org.hibernate.tool.schema.Action#VALIDATE schema validation}. + /// Valid values are defined by [org.hibernate.tool.schema.internal.ConstraintValidationType]. + /// + /// @since 7.3 + String UNIQUE_KEY_VALIDATION = "hibernate.tooling.schema.unique_key_validation"; + /** * Specifies whether to automatically create also the database schema/catalog. * The default is false. diff --git a/hibernate-core/src/main/java/org/hibernate/tool/schema/internal/AbstractSchemaValidator.java b/hibernate-core/src/main/java/org/hibernate/tool/schema/internal/AbstractSchemaValidator.java index 25a404c62ba1..c2fbb2c86a1d 100644 --- a/hibernate-core/src/main/java/org/hibernate/tool/schema/internal/AbstractSchemaValidator.java +++ b/hibernate-core/src/main/java/org/hibernate/tool/schema/internal/AbstractSchemaValidator.java @@ -4,7 +4,6 @@ */ package org.hibernate.tool.schema.internal; -import java.util.Locale; import org.hibernate.boot.Metadata; import org.hibernate.boot.model.relational.Namespace; @@ -12,10 +11,13 @@ import org.hibernate.boot.model.relational.internal.SqlStringGenerationContextImpl; import org.hibernate.dialect.Dialect; import org.hibernate.engine.jdbc.env.spi.JdbcEnvironment; +import org.hibernate.internal.util.StringHelper; import org.hibernate.mapping.Column; +import org.hibernate.mapping.Selectable; import org.hibernate.mapping.Table; import org.hibernate.tool.schema.extract.spi.ColumnInformation; import org.hibernate.tool.schema.extract.spi.DatabaseInformation; +import org.hibernate.tool.schema.extract.spi.IndexInformation; import org.hibernate.tool.schema.extract.spi.SequenceInformation; import org.hibernate.tool.schema.extract.spi.TableInformation; import org.hibernate.tool.schema.spi.ContributableMatcher; @@ -27,7 +29,12 @@ import org.jboss.logging.Logger; +import java.util.Objects; + +import static java.util.Locale.ROOT; import static org.hibernate.boot.model.naming.Identifier.toIdentifier; +import static org.hibernate.cfg.SchemaToolingSettings.INDEX_VALIDATION; +import static org.hibernate.cfg.SchemaToolingSettings.UNIQUE_KEY_VALIDATION; import static org.hibernate.tool.schema.internal.ColumnDefinitions.hasMatchingType; import static org.hibernate.tool.schema.internal.Helper.buildDatabaseInformation; @@ -141,6 +148,9 @@ protected void validateTable( validateColumnType( table, column, existingColumn, metadata, dialect ); validateColumnNullability( table, column, existingColumn ); } + + validateIndexes( table, tableInformation, metadata, options, dialect ); + validateUniqueKeys( table, tableInformation, metadata, options, dialect ); } protected void validateColumnType( @@ -156,9 +166,9 @@ protected void validateColumnType( "table [%s]; found [%s (Types#%s)], but expecting [%s (Types#%s)]", column.getName(), table.getQualifiedTableName(), - columnInformation.getTypeName().toLowerCase(Locale.ROOT), + columnInformation.getTypeName().toLowerCase( ROOT), JdbcTypeNameMapper.getTypeName( columnInformation.getTypeCode() ), - column.getSqlType( metadata ).toLowerCase(Locale.ROOT), + column.getSqlType( metadata ).toLowerCase( ROOT), JdbcTypeNameMapper.getTypeName( column.getSqlTypeCode( metadata ) ) ) ); @@ -181,6 +191,138 @@ private void validateColumnNullability(Table table, Column column, ColumnInforma } } + private void validateIndexes( + Table table, + TableInformation tableInformation, + Metadata metadata, + ExecutionOptions options, + Dialect dialect) { + var validationType = ConstraintValidationType.interpret( INDEX_VALIDATION, options.getConfigurationValues() ); + + table.getIndexes().forEach((rawName,index) -> { + assert StringHelper.isNotEmpty( rawName ); + assert Objects.equals( rawName, index.getName() ); + if ( validationType == ConstraintValidationType.NONE ) { + return; + } + else if ( validationType == ConstraintValidationType.NAMED ) { + if ( rawName.startsWith( "IDX" ) ) { + // this is not a great check as the user could very well + // have explicitly chosen a name that starts with this as well, + // but... + return; + } + } + + var name = metadata.getDatabase().toIdentifier( rawName ); + final IndexInformation indexInformation = tableInformation.getIndex( name ); + + if ( indexInformation == null ) { + throw new SchemaManagementException( + String.format( + ROOT, + "Missing index named `%s` on table `%s`", + name.render( dialect ), + tableInformation.getName().render() + ) + ); + } + + var indicesMatch = true; + assert index.getSelectables().size() == index.getColumnSpan(); + if ( index.getColumnSpan() != indexInformation.getIndexedColumns().size() ) { + indicesMatch = false; + } + else { + for ( int i = 0; i < index.getSelectables().size(); i++ ) { + final Selectable column = index.getSelectables().get( i ); + final ColumnInformation columnInfo = indexInformation.getIndexedColumns().get( i ); + if ( !column.getText().equals( columnInfo.getColumnIdentifier().getText() ) ) { + indicesMatch = false; + break; + } + } + } + + if ( !indicesMatch ) { + throw new SchemaManagementException( + String.format( + ROOT, + "Index mismatch - `%s` on table `%s`", + name.render( dialect ), + tableInformation.getName().render() + ) + ); + } + } ); + } + + private void validateUniqueKeys( + Table table, + TableInformation tableInformation, + Metadata metadata, + ExecutionOptions options, + Dialect dialect) { + var validationType = ConstraintValidationType.interpret( UNIQUE_KEY_VALIDATION, options.getConfigurationValues() ); + + table.getUniqueKeys().forEach( (rawName, uk) -> { + assert StringHelper.isNotEmpty( rawName ); + assert Objects.equals( rawName, uk.getName() ); + if ( validationType == ConstraintValidationType.NONE ) { + return; + } + else if ( validationType == ConstraintValidationType.NAMED ) { + if ( rawName.startsWith( "UK" ) ) { + // this is not a great check as the user could very well + // have explicitly chosen a name that starts with this as well, + // but... + return; + } + } + + var name = metadata.getDatabase().toIdentifier( rawName ); + final IndexInformation ukInfo = tableInformation.getIndex( name ); + + if ( ukInfo == null ) { + throw new SchemaManagementException( + String.format( + ROOT, + "Missing unique constraint named `%s` on table `%s`", + name.render( dialect ), + tableInformation.getName().render() + ) + ); + } + + var matches = true; + assert uk.getColumns().size() == uk.getColumnSpan(); + if ( uk.getColumnSpan() != ukInfo.getIndexedColumns().size() ) { + matches = false; + } + else { + for ( int i = 0; i < uk.getColumns().size(); i++ ) { + final Column column = uk.getColumns().get( i ); + final ColumnInformation columnInfo = ukInfo.getIndexedColumns().get( i ); + if ( !column.getName().equals( columnInfo.getColumnIdentifier().getText() ) ) { + matches = false; + break; + } + } + } + + if ( !matches ) { + throw new SchemaManagementException( + String.format( + ROOT, + "Unique-key mismatch - `%s` on table `%s`", + name.render( dialect ), + tableInformation.getName().render() + ) + ); + } + } ); + } + protected void validateSequence(Sequence sequence, SequenceInformation sequenceInformation) { if ( sequenceInformation == null ) { throw new SchemaManagementException( diff --git a/hibernate-core/src/main/java/org/hibernate/tool/schema/internal/ConstraintValidationType.java b/hibernate-core/src/main/java/org/hibernate/tool/schema/internal/ConstraintValidationType.java new file mode 100644 index 000000000000..e03c39161e67 --- /dev/null +++ b/hibernate-core/src/main/java/org/hibernate/tool/schema/internal/ConstraintValidationType.java @@ -0,0 +1,53 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.tool.schema.internal; + + +import java.util.Map; + +/// Used to determine whether a constraint (index, unique key, etc.) +/// should be validated. +/// +/// @implNote Yes, yes, an index is not technically a constraint - this is just +/// for nice simple naming. +/// +/// @see org.hibernate.cfg.SchemaToolingSettings#INDEX_VALIDATION +/// @see org.hibernate.cfg.SchemaToolingSettings#UNIQUE_KEY_VALIDATION +/// +/// @since 7.3 +/// +/// @author Steve Ebersole +public enum ConstraintValidationType { + /// No validation will occur. + NONE, + /// Validation will occur only for explicitly named constraints. + NAMED, + /// Validation will occur for all constraints. + ALL; + + public static ConstraintValidationType interpret( + String name, + Map configurationValues) { + final Object setting = configurationValues.get( name ); + if ( setting == null ) { + return NONE; + } + + if ( setting instanceof ConstraintValidationType type ) { + return type; + } + + var settingName = setting.toString(); + if ( NAMED.name().equalsIgnoreCase( settingName ) ) { + return NAMED; + } + else if ( ALL.name().equalsIgnoreCase( settingName ) ) { + return ALL; + } + else { + return NONE; + } + } +} diff --git a/hibernate-core/src/main/java/org/hibernate/tool/schema/spi/SchemaManagementToolCoordinator.java b/hibernate-core/src/main/java/org/hibernate/tool/schema/spi/SchemaManagementToolCoordinator.java index fd77867d283b..dffd463b148a 100644 --- a/hibernate-core/src/main/java/org/hibernate/tool/schema/spi/SchemaManagementToolCoordinator.java +++ b/hibernate-core/src/main/java/org/hibernate/tool/schema/spi/SchemaManagementToolCoordinator.java @@ -190,7 +190,7 @@ public ExceptionHandler getExceptionHandler() { }; } - private static void performDatabaseAction( + public static void performDatabaseAction( final Action action, Metadata metadata, SchemaManagementTool tool, diff --git a/hibernate-core/src/test/java/org/hibernate/orm/test/schemavalidation/ValidateConstraintTests.java b/hibernate-core/src/test/java/org/hibernate/orm/test/schemavalidation/ValidateConstraintTests.java new file mode 100644 index 000000000000..8e2564abd888 --- /dev/null +++ b/hibernate-core/src/test/java/org/hibernate/orm/test/schemavalidation/ValidateConstraintTests.java @@ -0,0 +1,336 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.orm.test.schemavalidation; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import org.hibernate.annotations.NaturalId; +import org.hibernate.boot.Metadata; +import org.hibernate.boot.MetadataSources; +import org.hibernate.boot.registry.StandardServiceRegistry; +import org.hibernate.boot.registry.StandardServiceRegistryBuilder; +import org.hibernate.engine.config.spi.ConfigurationService; +import org.hibernate.service.spi.ServiceRegistryImplementor; +import org.hibernate.testing.orm.junit.ServiceRegistryFunctionalTesting; +import org.hibernate.testing.orm.junit.ServiceRegistryProducer; +import org.hibernate.testing.orm.junit.ServiceRegistryScope; +import org.hibernate.tool.schema.Action; +import org.hibernate.tool.schema.internal.ConstraintValidationType; +import org.hibernate.tool.schema.internal.HibernateSchemaManagementTool; +import org.hibernate.tool.schema.spi.CommandAcceptanceException; +import org.hibernate.tool.schema.spi.ContributableMatcher; +import org.hibernate.tool.schema.spi.ExceptionHandler; +import org.hibernate.tool.schema.spi.ExecutionOptions; +import org.hibernate.tool.schema.spi.SchemaManagementException; +import org.hibernate.tool.schema.spi.SchemaManagementToolCoordinator; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.params.ParameterizedClass; +import org.junit.jupiter.params.provider.MethodSource; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.fail; +import static org.hibernate.cfg.SchemaToolingSettings.INDEX_VALIDATION; +import static org.hibernate.cfg.SchemaToolingSettings.UNIQUE_KEY_VALIDATION; + +/** + * @author Steve Ebersole + */ +@ParameterizedClass +@MethodSource("validationTypes") +@TestInstance(TestInstance.Lifecycle.PER_METHOD) +@ServiceRegistryFunctionalTesting +public class ValidateConstraintTests implements ServiceRegistryProducer { + public static List validationTypes() { + return List.of( ConstraintValidationType.NONE, ConstraintValidationType.ALL ); + } + + private final ConstraintValidationType validationType; + private Metadata schemaToDrop; + + public ValidateConstraintTests(ConstraintValidationType validationType) { + this.validationType = validationType; + } + + @Override + public StandardServiceRegistry produceServiceRegistry(StandardServiceRegistryBuilder builder) { + return builder.applySetting( INDEX_VALIDATION, validationType ) + .applySetting( UNIQUE_KEY_VALIDATION, validationType ) + .build(); + } + + @AfterEach + void tearDown(ServiceRegistryScope registryScope) { + if ( schemaToDrop == null ) { + return; + } + final ServiceRegistryImplementor registry = (ServiceRegistryImplementor) registryScope.getRegistry(); + + final HibernateSchemaManagementTool schemaTooling = new HibernateSchemaManagementTool(); + schemaTooling.injectServices( registry ); + + dropSchema( schemaToDrop, schemaTooling, registry ); + } + + @Test + void testValidationOfConstraints(ServiceRegistryScope registryScope) { + final ServiceRegistryImplementor registry = (ServiceRegistryImplementor) registryScope.getRegistry(); + + final Metadata schema1 = new MetadataSources( registryScope.getRegistry() ) + .addAnnotatedClasses( Gender.class, Person1.class ) + .buildMetadata(); + final Metadata schema2 = new MetadataSources( registryScope.getRegistry() ) + .addAnnotatedClasses( Gender.class, Person2.class ) + .buildMetadata(); + final Metadata schema3 = new MetadataSources( registryScope.getRegistry() ) + .addAnnotatedClasses( Gender.class, Person3.class ) + .buildMetadata(); + final Metadata schema4 = new MetadataSources( registryScope.getRegistry() ) + .addAnnotatedClasses( Gender.class, Person4.class ) + .buildMetadata(); + final Metadata schema5 = new MetadataSources( registryScope.getRegistry() ) + .addAnnotatedClasses( Gender.class, Person5.class ) + .buildMetadata(); + + final HibernateSchemaManagementTool schemaTooling = new HibernateSchemaManagementTool(); + schemaTooling.injectServices( registry ); + + // create the initial schema + System.out.println( "> Creation" ); + createSchema( schema1, schemaTooling, registry ); + schemaToDrop = schema1; + + // validate the first schema change + System.out.println( "> Migration #1" ); + try { + validateSchema( schema2, schemaTooling, registry ); + if ( validationType == ConstraintValidationType.ALL ) { + fail( "Expecting an exception" ); + } + } + catch (SchemaManagementException expected) { + if ( validationType == ConstraintValidationType.NONE ) { + fail( "Not expecting an exception" ); + } + } + updateSchema( schema2, schemaTooling, registry ); + schemaToDrop = schema2; + + // validate the second schema change + System.out.println( "> Migration #2" ); + try { + validateSchema( schema3, schemaTooling, registry ); + if ( validationType == ConstraintValidationType.ALL ) { + fail( "Expecting an exception" ); + } + } + catch (SchemaManagementException expected) { + if ( validationType == ConstraintValidationType.NONE ) { + fail( "Not expecting an exception" ); + } + } + updateSchema( schema3, schemaTooling, registry ); + schemaToDrop = schema3; + + // validate the 3rd schema change + System.out.println( "> Migration #3" ); + try { + validateSchema( schema4, schemaTooling, registry ); + if ( validationType == ConstraintValidationType.ALL ) { + fail( "Expecting an exception" ); + } + } + catch (SchemaManagementException expected) { + if ( validationType == ConstraintValidationType.NONE ) { + fail( "Not expecting an exception" ); + } + } + updateSchema( schema4, schemaTooling, registry ); + schemaToDrop = schema4; + + // validate the 4th schema change + System.out.println( "> Migration #4" ); + try { + validateSchema( schema5, schemaTooling, registry ); + if ( validationType == ConstraintValidationType.ALL ) { + fail( "Expecting an exception" ); + } + } + catch (SchemaManagementException expected) { + if ( validationType == ConstraintValidationType.NONE ) { + fail( "Not expecting an exception" ); + } + } + updateSchema( schema5, schemaTooling, registry ); + schemaToDrop = schema5; + + } + + private void validateSchema( + Metadata schema, + HibernateSchemaManagementTool schemaTooling, + ServiceRegistryImplementor registry) { + SchemaManagementToolCoordinator.performDatabaseAction( + Action.VALIDATE, + schema, + schemaTooling, + registry, + new Options( registry ), + ContributableMatcher.ALL + ); + } + + private void updateSchema( + Metadata schema, + HibernateSchemaManagementTool schemaTooling, + ServiceRegistryImplementor registry) { + SchemaManagementToolCoordinator.performDatabaseAction( + Action.UPDATE, + schema, + schemaTooling, + registry, + new Options( registry ), + ContributableMatcher.ALL + ); + } + + private static void createSchema( + Metadata schema, + HibernateSchemaManagementTool schemaTooling, + ServiceRegistryImplementor registry) { + SchemaManagementToolCoordinator.performDatabaseAction( + Action.CREATE_ONLY, + schema, + schemaTooling, + registry, + new Options( registry ), + ContributableMatcher.ALL + ); + } + + private static void dropSchema( + Metadata metadata, + HibernateSchemaManagementTool schemaTooling, + ServiceRegistryImplementor registry) { + SchemaManagementToolCoordinator.performDatabaseAction( + Action.DROP, + metadata, + schemaTooling, + registry, + new Options( registry ), + ContributableMatcher.ALL + ); + } + + enum Gender { MALE, FEMALE } + + @Entity(name="Person1") + @Table(name="persons") + public static class Person1 { + @Id + private Integer id; + private String ssn; + private String name; + private Gender gender; + private Instant dob; + } + + @Entity(name="Person2") + @Table(name="persons", + indexes = @Index(columnList = "name")) + public static class Person2 { + @Id + private Integer id; + private String ssn; + private String name; + private Gender gender; + private Instant dob; + } + + @Entity(name="Person3") + @Table(name="persons", + indexes = @Index(columnList = "name")) + public static class Person3 { + @Id + private Integer id; + @NaturalId + private String ssn; + private String name; + private Gender gender; + private Instant dob; + } + + @Entity(name="Person4") + @Table(name="persons", + indexes = { + @Index(columnList = "name"), + @Index(columnList = "gender") + } + ) + public static class Person4 { + @Id + private Integer id; + @NaturalId + private String ssn; + private String name; + private Gender gender; + private Instant dob; + } + + @Entity(name="Person5") + @Table(name="persons", + indexes = { + @Index(name = "person_name", columnList = "name"), + @Index(name = "person_gender", columnList = "gender") + } + ) + public static class Person5 { + @Id + private Integer id; + @NaturalId + private String ssn; + private String name; + private Gender gender; + private Instant dob; + } + + private static class Options implements ExecutionOptions, ExceptionHandler { + private final Map settings; + + public Options(Map settings) { + this.settings = settings; + } + + public Options(ServiceRegistryImplementor registry) { + this( registry.requireService( ConfigurationService.class ).getSettings() ); + } + + @Override + public void handleException(CommandAcceptanceException exception) { + throw exception; + } + + @Override + public Map getConfigurationValues() { + return settings; + } + + @Override + public boolean shouldManageNamespaces() { + return false; + } + + @Override + public ExceptionHandler getExceptionHandler() { + return this; + } + } +}