You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@grails.apache.org by bo...@apache.org on 2026/10/06 18:38:19 UTC
(grails-core) 11/14: test: compare the Liquibase snapshot and changelog of generated mode with binder mode
This is an automated email from the ASF dual-hosted git repository.
borinquenkid pushed a commit to branch 9.0.x-h7-improvement-phase3
in repository https://gitbox.apache.org/repos/asf/grails-core.git
commit e1871f83bf6ed264b299aa5037d3a20f9c339aab
Author: Walter Duque de Estrada <wb...@mac.com>
AuthorDate: Tue Oct 6 11:46:14 2026 -0500
test: compare the Liquibase snapshot and changelog of generated mode with binder mode
Co-Authored-By: Claude Sonnet 5.5 <no...@anthropic.com>
---
.../generated/GeneratedModeSnapshotSpec.groovy | 313 +++++++++++++++++++++
1 file changed, 313 insertions(+)
diff --git a/grails-data-hibernate7/dbmigration/src/test/groovy/org/grails/plugins/databasemigration/generated/GeneratedModeSnapshotSpec.groovy b/grails-data-hibernate7/dbmigration/src/test/groovy/org/grails/plugins/databasemigration/generated/GeneratedModeSnapshotSpec.groovy
new file mode 100644
index 0000000000..ec32c4f3d9
--- /dev/null
+++ b/grails-data-hibernate7/dbmigration/src/test/groovy/org/grails/plugins/databasemigration/generated/GeneratedModeSnapshotSpec.groovy
@@ -0,0 +1,313 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.grails.plugins.databasemigration.generated
+
+import grails.gorm.annotation.Entity
+import liquibase.CatalogAndSchema
+import liquibase.Liquibase
+import liquibase.database.Database
+import liquibase.database.core.H2Database
+import liquibase.database.jvm.JdbcConnection
+import liquibase.diff.DiffResult
+import liquibase.diff.compare.CompareControl
+import liquibase.diff.output.DiffOutputControl
+import liquibase.diff.output.changelog.DiffToChangeLog
+import liquibase.resource.ClassLoaderResourceAccessor
+import liquibase.serializer.core.xml.XMLChangeLogSerializer
+import liquibase.snapshot.DatabaseSnapshot
+import liquibase.snapshot.SnapshotControl
+import liquibase.snapshot.SnapshotGeneratorFactory
+import liquibase.structure.DatabaseObject
+import liquibase.structure.core.Column
+import liquibase.structure.core.ForeignKey
+import liquibase.structure.core.Index
+import liquibase.structure.core.PrimaryKey
+import liquibase.structure.core.Sequence
+import liquibase.structure.core.Table
+import liquibase.structure.core.UniqueConstraint
+import org.hibernate.dialect.H2Dialect
+import spock.lang.AutoCleanup
+import spock.lang.Specification
+
+import org.grails.orm.hibernate.HibernateDatastore
+import org.grails.plugins.databasemigration.liquibase.GormDatabase
+
+/**
+ * The Liquibase snapshot and the changelog that dbm-gorm-diff and dbm-generate-gorm-changelog build from the Hibernate
+ * mapping model are the same whether the domain binder or the generated domain classes bound it: tables, columns and
+ * their types, primary keys, foreign keys, unique constraints, indexes and sequences. The differences that remain are
+ * the known ones of the generated mode, each pinned by its own feature.
+ */
+class GeneratedModeSnapshotSpec extends Specification {
+
+ private static final List<Class> BASE = [
+ SnapAuthor, SnapBook, SnapVehicle, SnapCar, SnapAccount, SnapSavings, SnapShape, SnapCircle, SnapSequenced, SnapPlain]
+
+ private static final List<Class> ALL = BASE + [SnapStudent, SnapCourse, SnapEnrollment, SnapNatural]
+
+ @AutoCleanup
+ HibernateDatastore datastore
+
+ private GormDatabase boot(boolean generated, List<Class> classes) {
+ datastore?.close()
+ datastore = new HibernateDatastore([
+ 'dataSource.url' : "jdbc:h2:mem:snapSpec${generated}${System.nanoTime()};LOCK_TIMEOUT=10000;DB_CLOSE_DELAY=-1".toString(),
+ 'dataSource.dialect' : H2Dialect.name,
+ 'dataSource.dbCreate' : 'none',
+ 'hibernate.generatedDomainClasses': generated,
+ ], classes as Class[])
+ return new GormDatabase(new H2Dialect(), datastore)
+ }
+
+ private static List<String> snapshotLines(GormDatabase database) {
+ DatabaseSnapshot snapshot = SnapshotGeneratorFactory.instance.createSnapshot(
+ new CatalogAndSchema(null, null), database,
+ new SnapshotControl(database, Table, Column, PrimaryKey, ForeignKey, UniqueConstraint, Index, Sequence))
+ List<String> lines = []
+ [Table, Column, PrimaryKey, ForeignKey, UniqueConstraint, Index, Sequence].each { Class<? extends DatabaseObject> type ->
+ for (DatabaseObject object : snapshot.get(type)) {
+ Map<String, String> attributes = new TreeMap<String, String>()
+ for (String name : object.attributes) {
+ if (name != 'snapshotId' && name != 'schema' && name != 'name') {
+ Object value = object.getAttribute(name, Object)
+ attributes[name] = value instanceof Collection ?
+ value.collect { String.valueOf(it) }.sort().toString() : String.valueOf(value)
+ }
+ }
+ // Liquibase names the index behind a constraint with four random letters
+ lines << "${type.simpleName} ${object.name} ${attributes}".toString().replaceAll(/_[A-Z]{4}_IX/, '_XXXX_IX')
+ }
+ }
+ return lines.sort()
+ }
+
+ /**
+ * The known differences that the features below pin are hidden: the order of the columns of a primary key and the
+ * name Hibernate gives the unique key of a natural id.
+ */
+ private static List<String> withoutKnownDifferences(List<String> lines) {
+ return lines.collect { String line ->
+ line.replaceAll(/(?i)(on [\w.]+)\(([^)]*)\)/) { List<String> match ->
+ match[1] + '(' + match[2].split(', ').toList().sort().join(', ') + ')'
+ }.replaceAll(/UK[0-9a-z]{20,}/, 'UK<hash>')
+ }.sort()
+ }
+
+ private static String changeLog(GormDatabase reference) {
+ Class.forName('org.h2.Driver')
+ java.sql.Connection connection = java.sql.DriverManager.getConnection("jdbc:h2:mem:snapTarget${System.nanoTime()}", 'SA', '')
+ Database target = new H2Database()
+ target.setConnection(new JdbcConnection(connection))
+ try {
+ Set<Class<? extends DatabaseObject>> types = [Table, Column, PrimaryKey, ForeignKey, UniqueConstraint, Index, Sequence] as Set
+ DiffResult diff = new Liquibase((String) null, new ClassLoaderResourceAccessor(), target)
+ .diff(reference, target, new CompareControl(types))
+ ByteArrayOutputStream out = new ByteArrayOutputStream()
+ new DiffToChangeLog(diff, new DiffOutputControl(false, false, false, null))
+ .print(new PrintStream(out), new XMLChangeLogSerializer())
+ return out.toString('UTF-8')
+ } finally {
+ target.close()
+ }
+ }
+
+ /**
+ * Each change set without its generated id, its lines in sorted order (the order of the columns of a table is a known
+ * difference), and the change sets sorted.
+ */
+ private static List<String> canonicalChangeLog(String xml) {
+ String stripped = xml.replaceAll(/ id="[^"]*"/, '').replaceAll(/_[A-Z]{4}_IX/, '_XXXX_IX')
+ return stripped.split(/<changeSet/).drop(1).collect { String block ->
+ block.readLines()*.trim().findAll { it }.sort().join(' | ')
+ }.sort()
+ }
+
+ def "the Liquibase snapshot of the generated mapping equals the snapshot of the binder mapping"() {
+ when:
+ List<String> binder = snapshotLines(boot(false, ALL))
+ List<String> generated = snapshotLines(boot(true, ALL))
+
+ then: 'the snapshot has every kind of object'
+ ['Table', 'Column', 'PrimaryKey', 'ForeignKey', 'UniqueConstraint', 'Index', 'Sequence'].every { String type ->
+ binder.any { it.startsWith(type + ' ') }
+ }
+ binder.size() > 100
+
+ and: 'nothing differs besides the known differences'
+ withoutKnownDifferences(binder) - withoutKnownDifferences(generated) == []
+ withoutKnownDifferences(generated) - withoutKnownDifferences(binder) == []
+ }
+
+ def "the primary key of a composite identifier lists its columns in the order Hibernate sorts them, and the natural id key has Hibernate's own name"() {
+ when:
+ List<String> binder = snapshotLines(boot(false, ALL))
+ List<String> generated = snapshotLines(boot(true, ALL))
+ Closure<String> primaryKey = { List<String> lines -> lines.find { it.startsWith('PrimaryKey snap_enrollmentPK') } }
+ Closure<String> naturalKey = { List<String> lines ->
+ (lines.find { it.startsWith('UniqueConstraint UK') && it.contains('snap_natural') } =~ /UniqueConstraint (UK\w+)/)[0][1]
+ }
+
+ then: 'the binder keeps the order of the mapping, Hibernate sorts the parts of the identifier'
+ primaryKey(binder).contains('ON HIBERNATE.snap_enrollment(student, course)')
+ primaryKey(generated).contains('ON HIBERNATE.snap_enrollment(course, student)')
+
+ and: 'both name the natural id key from its table and columns'
+ naturalKey(binder) != naturalKey(generated)
+ }
+
+ def "the changelog generated from the generated mapping equals the one generated from the binder mapping"() {
+ when:
+ List<String> binder = canonicalChangeLog(changeLog(boot(false, BASE)))
+ List<String> generated = canonicalChangeLog(changeLog(boot(true, BASE)))
+
+ then:
+ binder.size() > 15
+ binder.any { it.contains('<createSequence') }
+ binder.any { it.contains('autoIncrement="true"') }
+ binder.any { it.contains('<addForeignKeyConstraint') }
+ binder - generated == []
+ generated - binder == []
+ }
+}
+
+enum SnapStatus { OPEN, CLOSED }
+
+class SnapAddress {
+ String street
+ String city
+}
+
+@Entity
+class SnapAuthor {
+ String name
+ SnapStatus status
+ BigDecimal balance
+ SnapAddress home
+ static embedded = ['home']
+ static hasMany = [books: SnapBook]
+ static constraints = {
+ name maxSize: 50, nullable: false
+ balance scale: 2, max: 9999999999.99G
+ }
+ static mapping = {
+ name index: 'snap_author_name_idx'
+ status enumType: 'string'
+ }
+}
+
+@Entity
+class SnapBook {
+ String title
+ String isbn
+ static belongsTo = [author: SnapAuthor]
+ static hasMany = [tags: String]
+ static constraints = {
+ isbn unique: true, nullable: true
+ }
+}
+
+@Entity
+class SnapStudent {
+ String name
+ static hasMany = [courses: SnapCourse]
+}
+
+@Entity
+class SnapCourse {
+ String title
+ static belongsTo = SnapStudent
+ static hasMany = [students: SnapStudent]
+}
+
+@Entity
+class SnapVehicle {
+ String plate
+}
+
+@Entity
+class SnapCar extends SnapVehicle {
+ Integer doors
+}
+
+@Entity
+class SnapAccount {
+ String owner
+ static mapping = {
+ tablePerHierarchy false
+ }
+}
+
+@Entity
+class SnapSavings extends SnapAccount {
+ BigDecimal rate
+}
+
+@Entity
+class SnapShape {
+ String color
+ static mapping = {
+ tablePerHierarchy false
+ tablePerConcreteClass true
+ id generator: 'sequence', params: [sequence_name: 'snap_shape_seq']
+ }
+}
+
+@Entity
+class SnapCircle extends SnapShape {
+ Integer radius
+}
+
+@Entity
+class SnapEnrollment implements Serializable {
+ String student
+ String course
+ Integer grade
+ static mapping = {
+ id composite: ['student', 'course']
+ }
+}
+
+@Entity
+class SnapSequenced {
+ String label
+ static mapping = {
+ id generator: 'sequence', params: [sequence_name: 'snap_seq']
+ }
+}
+
+@Entity
+class SnapNatural {
+ String code
+ String description
+ static mapping = {
+ id natural: 'code'
+ version false
+ }
+}
+
+@Entity
+class SnapPlain {
+ String first
+ String last
+ String full
+ static mapping = {
+ full formula: "first || ' ' || last"
+ version false
+ }
+}