You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@grails.apache.org by jd...@apache.org on 2026/10/02 18:16:53 UTC

(grails-core) 01/01: Restore mapped identifiers in GORM ident()

This is an automated email from the ASF dual-hosted git repository.

jdaugherty pushed a commit to branch fix/composite-ident
in repository https://gitbox.apache.org/repos/asf/grails-core.git

commit 055cc1243fd5b269d165ca039a4f97f6b32402f0
Author: James Daugherty <jd...@jdresources.net>
AuthorDate: Fri Oct 2 14:16:41 2026 -0400

    Restore mapped identifiers in GORM ident()
---
 .../grails/gorm/tests/MappedIdentifierSpec.groovy  | 140 +++++++++++++++++++++
 .../grails/gorm/tests/MappedIdentifierSpec.groovy  | 140 +++++++++++++++++++++
 .../grails/datastore/gorm/GormInstanceApi.groovy   |  22 +++-
 .../datastore/gorm/GormInstanceApiSpec.groovy      |   5 +-
 grails-doc/src/en/ref/Domain Classes/ident.adoc    |  20 ++-
 5 files changed, 322 insertions(+), 5 deletions(-)

diff --git a/grails-data-hibernate5/core/src/test/groovy/grails/gorm/tests/MappedIdentifierSpec.groovy b/grails-data-hibernate5/core/src/test/groovy/grails/gorm/tests/MappedIdentifierSpec.groovy
new file mode 100644
index 0000000000..58c2af5c2e
--- /dev/null
+++ b/grails-data-hibernate5/core/src/test/groovy/grails/gorm/tests/MappedIdentifierSpec.groovy
@@ -0,0 +1,140 @@
+/*
+ *  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 grails.gorm.tests
+
+import groovy.transform.EqualsAndHashCode
+
+import org.hibernate.Hibernate
+
+import grails.gorm.annotation.Entity
+
+class MappedIdentifierSpec extends HibernateGormDatastoreSpec {
+
+    void setupSpec() {
+        manager.registerDomainClasses(MappedIdentOwner, MappedIdentNamedRecord, MappedIdentCompositeRecord)
+    }
+
+    void 'ident returns the generated identifier and null before persistence'() {
+        given:
+        def owner = new MappedIdentOwner(name: 'owner')
+
+        expect:
+        owner.ident() == null
+
+        when:
+        owner.save(flush: true, failOnError: true)
+
+        then:
+        owner.ident() == owner.id
+        owner.ident() != null
+    }
+
+    void 'ident uses the mapped identifier name'() {
+        given:
+        def record = new MappedIdentNamedRecord(code: 'NAMED-1', payload: 'content')
+
+        expect:
+        record.ident() == 'NAMED-1'
+
+        when:
+        record.save(flush: true, failOnError: true)
+        MappedIdentNamedRecord.withSession { it.clear() }
+
+        then:
+        MappedIdentNamedRecord.get(record.ident()).payload == 'content'
+    }
+
+    void 'ident copies only composite key fields into a separate identifier'() {
+        given:
+        def owner = new MappedIdentOwner(name: 'owner')
+        def record = new MappedIdentCompositeRecord(owner: owner, code: 'PARAMETER', payload: 'content')
+
+        when:
+        Serializable identifier = record.ident()
+
+        then:
+        identifier instanceof MappedIdentCompositeRecord
+        !identifier.is(record)
+        identifier.owner.is(owner)
+        identifier.code == 'PARAMETER'
+        identifier.payload == null
+        record.id == null
+    }
+
+    void 'a composite identifier containing an association supports get and lazy load'() {
+        given:
+        def owner = new MappedIdentOwner(name: 'owner').save(failOnError: true)
+        def record = new MappedIdentCompositeRecord(owner: owner, code: 'PARAMETER', payload: 'content')
+                .save(flush: true, failOnError: true)
+        Serializable identifier = record.ident()
+        assert identifier != null
+        MappedIdentCompositeRecord.withSession { it.clear() }
+
+        when:
+        def reloaded = MappedIdentCompositeRecord.get(identifier)
+
+        then:
+        reloaded != null
+        reloaded.code == 'PARAMETER'
+        reloaded.owner.id == owner.id
+        reloaded.payload == 'content'
+
+        when:
+        MappedIdentCompositeRecord.withSession { it.clear() }
+        def proxy = MappedIdentCompositeRecord.load(identifier)
+
+        then:
+        proxy != null
+        !Hibernate.isInitialized(proxy)
+
+        when:
+        String payload = proxy.payload
+
+        then:
+        payload == 'content'
+        Hibernate.isInitialized(proxy)
+    }
+}
+
+@Entity
+class MappedIdentOwner implements Serializable {
+    String name
+}
+
+@Entity
+class MappedIdentNamedRecord implements Serializable {
+    String code
+    String payload
+
+    static mapping = {
+        id name: 'code', generator: 'assigned'
+    }
+}
+
+@Entity
+@EqualsAndHashCode(includes = ['owner', 'code'])
+class MappedIdentCompositeRecord implements Serializable {
+    MappedIdentOwner owner
+    String code
+    String payload
+
+    static mapping = {
+        id composite: ['owner', 'code']
+    }
+}
diff --git a/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/MappedIdentifierSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/MappedIdentifierSpec.groovy
new file mode 100644
index 0000000000..58c2af5c2e
--- /dev/null
+++ b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/MappedIdentifierSpec.groovy
@@ -0,0 +1,140 @@
+/*
+ *  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 grails.gorm.tests
+
+import groovy.transform.EqualsAndHashCode
+
+import org.hibernate.Hibernate
+
+import grails.gorm.annotation.Entity
+
+class MappedIdentifierSpec extends HibernateGormDatastoreSpec {
+
+    void setupSpec() {
+        manager.registerDomainClasses(MappedIdentOwner, MappedIdentNamedRecord, MappedIdentCompositeRecord)
+    }
+
+    void 'ident returns the generated identifier and null before persistence'() {
+        given:
+        def owner = new MappedIdentOwner(name: 'owner')
+
+        expect:
+        owner.ident() == null
+
+        when:
+        owner.save(flush: true, failOnError: true)
+
+        then:
+        owner.ident() == owner.id
+        owner.ident() != null
+    }
+
+    void 'ident uses the mapped identifier name'() {
+        given:
+        def record = new MappedIdentNamedRecord(code: 'NAMED-1', payload: 'content')
+
+        expect:
+        record.ident() == 'NAMED-1'
+
+        when:
+        record.save(flush: true, failOnError: true)
+        MappedIdentNamedRecord.withSession { it.clear() }
+
+        then:
+        MappedIdentNamedRecord.get(record.ident()).payload == 'content'
+    }
+
+    void 'ident copies only composite key fields into a separate identifier'() {
+        given:
+        def owner = new MappedIdentOwner(name: 'owner')
+        def record = new MappedIdentCompositeRecord(owner: owner, code: 'PARAMETER', payload: 'content')
+
+        when:
+        Serializable identifier = record.ident()
+
+        then:
+        identifier instanceof MappedIdentCompositeRecord
+        !identifier.is(record)
+        identifier.owner.is(owner)
+        identifier.code == 'PARAMETER'
+        identifier.payload == null
+        record.id == null
+    }
+
+    void 'a composite identifier containing an association supports get and lazy load'() {
+        given:
+        def owner = new MappedIdentOwner(name: 'owner').save(failOnError: true)
+        def record = new MappedIdentCompositeRecord(owner: owner, code: 'PARAMETER', payload: 'content')
+                .save(flush: true, failOnError: true)
+        Serializable identifier = record.ident()
+        assert identifier != null
+        MappedIdentCompositeRecord.withSession { it.clear() }
+
+        when:
+        def reloaded = MappedIdentCompositeRecord.get(identifier)
+
+        then:
+        reloaded != null
+        reloaded.code == 'PARAMETER'
+        reloaded.owner.id == owner.id
+        reloaded.payload == 'content'
+
+        when:
+        MappedIdentCompositeRecord.withSession { it.clear() }
+        def proxy = MappedIdentCompositeRecord.load(identifier)
+
+        then:
+        proxy != null
+        !Hibernate.isInitialized(proxy)
+
+        when:
+        String payload = proxy.payload
+
+        then:
+        payload == 'content'
+        Hibernate.isInitialized(proxy)
+    }
+}
+
+@Entity
+class MappedIdentOwner implements Serializable {
+    String name
+}
+
+@Entity
+class MappedIdentNamedRecord implements Serializable {
+    String code
+    String payload
+
+    static mapping = {
+        id name: 'code', generator: 'assigned'
+    }
+}
+
+@Entity
+@EqualsAndHashCode(includes = ['owner', 'code'])
+class MappedIdentCompositeRecord implements Serializable {
+    MappedIdentOwner owner
+    String code
+    String payload
+
+    static mapping = {
+        id composite: ['owner', 'code']
+    }
+}
diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormInstanceApi.groovy b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormInstanceApi.groovy
index 9ae2bfa6aa..a05d713683 100644
--- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormInstanceApi.groovy
+++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormInstanceApi.groovy
@@ -35,7 +35,10 @@ import org.grails.datastore.mapping.core.connections.ConnectionSources
 import org.grails.datastore.mapping.core.connections.ConnectionSourcesProvider
 import org.grails.datastore.mapping.dirty.checking.DirtyCheckable
 import org.grails.datastore.mapping.model.MappingContext
+import org.grails.datastore.mapping.model.PersistentEntity
+import org.grails.datastore.mapping.model.PersistentProperty
 import org.grails.datastore.mapping.proxy.EntityProxy
+import org.grails.datastore.mapping.reflect.EntityReflector
 import org.grails.datastore.mapping.transactions.TransactionCapableDatastore
 import org.grails.datastore.mapping.validation.ValidationException
 
@@ -273,7 +276,24 @@ class GormInstanceApi<D> extends AbstractGormApi<D> implements GormInstanceOpera
 
     @Override
     Serializable ident(D instance) {
-        (Serializable)InvokerHelper.getProperty(instance, 'id')
+        PersistentEntity entity = mappingContext.getPersistentEntity(persistentClass.name)
+        PersistentProperty identity = entity.identity
+        if (identity != null) {
+            return (Serializable) InvokerHelper.getProperty(instance, identity.name)
+        }
+
+        PersistentProperty[] idProperties = entity.compositeIdentity
+        if (idProperties != null) {
+            def identifier = entity.newInstance()
+            if (identifier instanceof Serializable) {
+                EntityReflector reflector = entity.reflector
+                for (PersistentProperty property : idProperties) {
+                    reflector.setProperty(identifier, property.name, reflector.getProperty(instance, property.name))
+                }
+                return (Serializable) identifier
+            }
+        }
+        return null
     }
 
     @Override
diff --git a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/GormInstanceApiSpec.groovy b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/GormInstanceApiSpec.groovy
index 7b156ac846..ba9f0f5f10 100644
--- a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/GormInstanceApiSpec.groovy
+++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/GormInstanceApiSpec.groovy
@@ -557,13 +557,12 @@ class GormInstanceApiSpec extends Specification {
         notThrown(Exception)
     }
 
-    void "ident reads the instance's id property"() {
+    void "ident returns the default mapped identifier through the domain API"() {
         given:
-        def api = new GormInstanceApi(GormInstanceApiThing, datastore)
         def saved = new GormInstanceApiThing(name: 'a').save(flush: true)
 
         expect:
-        api.ident(saved) == saved.id
+        saved.ident() == saved.id
     }
 
     void "isAttached reports true for an instance persisted within the current session"() {
diff --git a/grails-doc/src/en/ref/Domain Classes/ident.adoc b/grails-doc/src/en/ref/Domain Classes/ident.adoc
index e04ab8875a..92cbf83bad 100644
--- a/grails-doc/src/en/ref/Domain Classes/ident.adoc	
+++ b/grails-doc/src/en/ref/Domain Classes/ident.adoc	
@@ -25,7 +25,9 @@ under the License.
 === Purpose
 
 
-Returns the value of the identity property of the domain class regardless of the name of the identity property itself
+Returns the value of the mapped identity property of the domain class, regardless of its name. For example, when the mapping uses `id name: 'code'`, `ident()` returns the value of `code`. A generated identifier remains `null` until it has been assigned.
+
+For a composite identity, `ident()` returns a separate serializable instance of the domain class with the mapped key properties copied into it, including any associations that form part of the key. Other properties retain their new-instance defaults. Pass the returned identifier to `get()` or `load()` to retrieve the entity.
 
 
 === Examples
@@ -38,3 +40,19 @@ b.save()
 
 println b.ident()
 ----
+
+For a `BookEdition` mapped with `id composite: ['isbn', 'edition']`:
+
+[source,groovy]
+----
+def bookEdition = new BookEdition(isbn: '9780307743657', edition: 1, title: 'The Shining')
+bookEdition.save(flush: true, failOnError: true)
+
+def identifier = bookEdition.ident()
+assert identifier.isbn == bookEdition.isbn
+assert identifier.edition == bookEdition.edition
+assert !identifier.is(bookEdition)
+
+def reloaded = BookEdition.get(identifier)
+def reference = BookEdition.load(identifier)
+----