From 89d77d5a15560d7c133e4586e0e65c2df70639d5 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Mon, 6 Oct 2025 14:48:03 -0700 Subject: [PATCH 01/15] Introduce CreatedDate and LastModifiedDate annotations --- .../gorm/CustomAutoTimestampSpec.groovy | 77 ++++++++++++++++++- .../grails/gorm/annotation/CreatedDate.java | 36 +++++++++ .../gorm/annotation/LastModifiedDate.java | 36 +++++++++ .../events/AutoTimestampEventListener.java | 18 +++-- 4 files changed, 161 insertions(+), 6 deletions(-) create mode 100644 grails-datamapping-core/src/main/groovy/grails/gorm/annotation/CreatedDate.java create mode 100644 grails-datamapping-core/src/main/groovy/grails/gorm/annotation/LastModifiedDate.java diff --git a/grails-datamapping-core-test/src/test/groovy/org/grails/datastore/gorm/CustomAutoTimestampSpec.groovy b/grails-datamapping-core-test/src/test/groovy/org/grails/datastore/gorm/CustomAutoTimestampSpec.groovy index 2ddb5f1ad6e..a0e6fb7d2bd 100644 --- a/grails-datamapping-core-test/src/test/groovy/org/grails/datastore/gorm/CustomAutoTimestampSpec.groovy +++ b/grails-datamapping-core-test/src/test/groovy/org/grails/datastore/gorm/CustomAutoTimestampSpec.groovy @@ -19,6 +19,8 @@ package org.grails.datastore.gorm import grails.gorm.annotation.AutoTimestamp +import grails.gorm.annotation.CreatedDate +import grails.gorm.annotation.LastModifiedDate import grails.persistence.Entity import org.apache.grails.data.simple.core.GrailsDataCoreTckManager import org.apache.grails.data.testing.tck.base.GrailsDataTckSpec @@ -28,7 +30,7 @@ import static grails.gorm.annotation.AutoTimestamp.EventType.CREATED class CustomAutoTimestampSpec extends GrailsDataTckSpec { void setupSpec() { - manager.domainClasses.addAll([AutoTimestampedChildEntity, AutoTimestampedParentEntity, Image, RecordCustom]) + manager.domainClasses.addAll([AutoTimestampedChildEntity, AutoTimestampedParentEntity, Image, RecordCustom, RecordWithAliases]) } void "Test when the auto timestamp properties are customized, they are correctly set"() { @@ -147,6 +149,69 @@ class CustomAutoTimestampSpec extends GrailsDataTckSpec Date: Mon, 6 Oct 2025 15:04:15 -0700 Subject: [PATCH 02/15] Introduce GrailsExtension importGrailsAnnotations that will auto import @Scaffold and grails.gorm.annotation.* --- .../gradle/plugin/core/GrailsExtension.groovy | 8 ++++ .../plugin/core/GrailsGradlePlugin.groovy | 38 +++++++++++++++++-- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsExtension.groovy b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsExtension.groovy index 75a6c802976..d8721063f32 100644 --- a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsExtension.groovy +++ b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsExtension.groovy @@ -64,6 +64,14 @@ class GrailsExtension { */ boolean importJavaTime = false + /** + * Whether grails annotation packages should be default import packages. + * When enabled, automatically imports: + * - grails.gorm.annotation.* (if grails-datamapping-core is in classpath) + * - grails.plugin.scaffolding.annotation.* (if grails-scaffolding is in classpath) + */ + boolean importGrailsAnnotations = false + /** * Whether the spring dependency management plugin should be applied by default */ diff --git a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsGradlePlugin.groovy b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsGradlePlugin.groovy index 2d831789e9b..ab5996262a2 100644 --- a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsGradlePlugin.groovy +++ b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsGradlePlugin.groovy @@ -226,17 +226,47 @@ class GrailsGradlePlugin extends GroovyPlugin { protected Closure getGroovyCompilerScript(GroovyCompile compile, Project project) { GrailsExtension grails = project.extensions.findByType(GrailsExtension) - if (!grails.importJavaTime) { + + List starImports = [] + + // Add java.time if enabled + if (grails.importJavaTime) { + starImports.add('java.time') + } + + // Add Grails annotation packages if enabled and dependencies are present + if (grails.importGrailsAnnotations) { + // Check for grails-datamapping-core (grails.gorm.annotation.*) + def datamappingCoreDep = project.configurations.getByName('compileClasspath').dependencies.find { Dependency d -> + d.group == 'org.apache.grails.data' && d.name == 'grails-datamapping-core' + } + if (datamappingCoreDep) { + starImports.add('grails.gorm.annotation') + } + + // Check for grails-scaffolding (grails.plugin.scaffolding.annotation.*) + def scaffoldingDep = project.configurations.getByName('compileClasspath').dependencies.find { Dependency d -> + d.group == 'org.apache.grails' && d.name == 'grails-scaffolding' + } + if (scaffoldingDep) { + starImports.add('grails.plugin.scaffolding.annotation') + } + } + + // Return null if no imports are needed + if (starImports.isEmpty()) { return null } + // Build the import statements return { -> - '''withConfig(configuration) { + def importStatements = starImports.collect { pkg -> " star '$pkg'" }.join('\n') + """withConfig(configuration) { imports { - star 'java.time' +${importStatements} } } - ''' + """ } } From f0de080830f69a9ced510bf5a214fc41d6e49f47 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Tue, 7 Oct 2025 22:49:37 -0700 Subject: [PATCH 03/15] Fix for mongodb autotimestamp properties not being marked dirty. Properties will only be marked dirty if other updates exist. This mimics the behavior in hibernate. Fixes #15120 --- .../engine/codecs/PersistentEntityCodec.groovy | 6 ------ .../gorm/events/AutoTimestampEventListener.java | 16 ++++++++++++++++ 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/engine/codecs/PersistentEntityCodec.groovy b/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/engine/codecs/PersistentEntityCodec.groovy index bb0303fd247..20e81e12cd7 100644 --- a/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/engine/codecs/PersistentEntityCodec.groovy +++ b/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/engine/codecs/PersistentEntityCodec.groovy @@ -249,12 +249,6 @@ class PersistentEntityCodec extends BsonPersistentEntityCodec { } } - else { - // schedule lastUpdated if necessary - if (entity.getPropertyByName(GormProperties.LAST_UPDATED) != null) { - dirtyProperties.add(GormProperties.LAST_UPDATED) - } - } for (propertyName in dirtyProperties) { def prop = entity.getPropertyByName(propertyName) diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/events/AutoTimestampEventListener.java b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/events/AutoTimestampEventListener.java index 36dc27ba4e9..a119c221a30 100644 --- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/events/AutoTimestampEventListener.java +++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/events/AutoTimestampEventListener.java @@ -41,6 +41,7 @@ import org.grails.datastore.mapping.config.Entity; import org.grails.datastore.mapping.config.Settings; import org.grails.datastore.mapping.core.Datastore; +import org.grails.datastore.mapping.dirty.checking.DirtyCheckable; import org.grails.datastore.mapping.engine.EntityAccess; import org.grails.datastore.mapping.engine.event.AbstractPersistenceEvent; import org.grails.datastore.mapping.engine.event.AbstractPersistenceEventListener; @@ -150,10 +151,25 @@ private void initializeIfNecessary(PersistentEntity entity, String name) { public boolean beforeUpdate(PersistentEntity entity, EntityAccess ea) { Set props = getLastUpdatedPropertyNames(entity.getName()); if (props != null) { + Object entityObject = ea.getEntity(); + boolean isDirtyCheckable = entityObject instanceof DirtyCheckable; + + // For dirty-checking datastores (e.g., MongoDB), only set autotimestamp if entity has dirty properties + if (isDirtyCheckable) { + List dirtyPropertyNames = ((DirtyCheckable) entityObject).listDirtyPropertyNames(); + if (dirtyPropertyNames.isEmpty()) { + return true; + } + } + for (String prop : props) { Class lastUpdateType = ea.getPropertyType(prop); Object timestamp = timestampProvider.createTimestamp(lastUpdateType); ea.setProperty(prop, timestamp); + // Mark property as dirty for datastores that use dirty checking (e.g., MongoDB) + if (isDirtyCheckable) { + ((DirtyCheckable) entityObject).markDirty(prop); + } } } return true; From 2c91003be4c9a738807c30521abd850341f6cde8 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Tue, 7 Oct 2025 23:25:48 -0700 Subject: [PATCH 04/15] unused import --- .../mapping/mongo/engine/codecs/PersistentEntityCodec.groovy | 1 - 1 file changed, 1 deletion(-) diff --git a/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/engine/codecs/PersistentEntityCodec.groovy b/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/engine/codecs/PersistentEntityCodec.groovy index 20e81e12cd7..24fbd6a9bcd 100644 --- a/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/engine/codecs/PersistentEntityCodec.groovy +++ b/grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/engine/codecs/PersistentEntityCodec.groovy @@ -64,7 +64,6 @@ import org.grails.datastore.mapping.engine.internal.MappingUtils import org.grails.datastore.mapping.model.EmbeddedPersistentEntity import org.grails.datastore.mapping.model.PersistentEntity import org.grails.datastore.mapping.model.PersistentProperty -import org.grails.datastore.mapping.model.config.GormProperties import org.grails.datastore.mapping.model.types.Association import org.grails.datastore.mapping.model.types.Embedded import org.grails.datastore.mapping.model.types.EmbeddedCollection From e935901431a899669ce95c5309605b2648924fd8 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Wed, 8 Oct 2025 01:46:02 -0700 Subject: [PATCH 05/15] Skip null check on AutoTimestamp properties --- .../eval/DefaultConstraintEvaluator.java | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/grails-datamapping-validation/src/main/groovy/org/grails/datastore/gorm/validation/constraints/eval/DefaultConstraintEvaluator.java b/grails-datamapping-validation/src/main/groovy/org/grails/datastore/gorm/validation/constraints/eval/DefaultConstraintEvaluator.java index a4634d8f93d..f2e31b35439 100644 --- a/grails-datamapping-validation/src/main/groovy/org/grails/datastore/gorm/validation/constraints/eval/DefaultConstraintEvaluator.java +++ b/grails-datamapping-validation/src/main/groovy/org/grails/datastore/gorm/validation/constraints/eval/DefaultConstraintEvaluator.java @@ -19,6 +19,7 @@ package org.grails.datastore.gorm.validation.constraints.eval; +import java.lang.reflect.Field; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; @@ -305,6 +306,11 @@ protected boolean isConstrainableProperty(PersistentProperty persistentProperty, return NameUtils.isNotConfigurational(propertyName); } else { + // Check if property has @CreatedDate or @LastModifiedDate annotations + if (hasAutoTimestampAnnotation(persistentProperty)) { + return false; + } + return !propertyName.equals(GormProperties.VERSION) && !propertyName.equals(GormProperties.DATE_CREATED) && !propertyName.equals(GormProperties.LAST_UPDATED) && @@ -315,4 +321,43 @@ protected boolean isConstrainableProperty(PersistentProperty persistentProperty, } + /** + * Checks if a property has @CreatedDate, @LastModifiedDate, or @AutoTimestamp annotation. + * These annotations indicate auto-timestamp properties that should be nullable. + * Uses reflection by annotation name to avoid circular dependency issues. + */ + protected boolean hasAutoTimestampAnnotation(PersistentProperty persistentProperty) { + try { + Field field = getFieldFromHierarchy(persistentProperty.getOwner().getJavaClass(), persistentProperty.getName()); + if (field != null) { + for (java.lang.annotation.Annotation annotation : field.getDeclaredAnnotations()) { + String annotationName = annotation.annotationType().getName(); + if ("grails.gorm.annotation.CreatedDate".equals(annotationName) || + "grails.gorm.annotation.LastModifiedDate".equals(annotationName) || + "grails.gorm.annotation.AutoTimestamp".equals(annotationName)) { + return true; + } + } + } + } catch (Exception e) { + LOG.debug("Unable to check for auto-timestamp annotations on property: " + persistentProperty.getName(), e); + } + return false; + } + + /** + * Gets a field from the class hierarchy, checking superclasses if necessary. + */ + private static Field getFieldFromHierarchy(Class entity, String fieldName) { + Class clazz = entity; + while (clazz != null) { + try { + return clazz.getDeclaredField(fieldName); + } catch (NoSuchFieldException e) { + clazz = clazz.getSuperclass(); + } + } + return null; + } + } From 7fed24cecc0bd7fe2c30713b42cb37a939efb9bd Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Wed, 8 Oct 2025 02:35:09 -0700 Subject: [PATCH 06/15] Hide AutoTimestamp properties from scaffold input/edit views --- .../plugin/formfields/FormFieldsTagLib.groovy | 3 +- .../model/DomainModelServiceImpl.groovy | 111 +++++++++++++++++- 2 files changed, 112 insertions(+), 2 deletions(-) diff --git a/grails-fields/grails-app/taglib/grails/plugin/formfields/FormFieldsTagLib.groovy b/grails-fields/grails-app/taglib/grails/plugin/formfields/FormFieldsTagLib.groovy index 2aa852ee4f4..810db69b241 100644 --- a/grails-fields/grails-app/taglib/grails/plugin/formfields/FormFieldsTagLib.groovy +++ b/grails-fields/grails-app/taglib/grails/plugin/formfields/FormFieldsTagLib.groovy @@ -614,7 +614,8 @@ class FormFieldsTagLib { } } else { properties = list ? domainModelService.getListOutputProperties(domainClass) : domainModelService.getInputProperties(domainClass, - exclusionType == ExclusionType.Input ? exclusionsInput : exclusionsDisplay) + exclusionType == ExclusionType.Input ? exclusionsInput : exclusionsDisplay, + exclusionType == ExclusionType.Input) // If 'except' is not set, but 'list' is, exclude 'id', 'dateCreated' and 'lastUpdated' by default List blacklist = attrs.containsKey('except') ? getList(attrs.except) : (list ? exclusionsList : []) diff --git a/grails-fields/src/main/groovy/org/grails/scaffolding/model/DomainModelServiceImpl.groovy b/grails-fields/src/main/groovy/org/grails/scaffolding/model/DomainModelServiceImpl.groovy index b6138cfd09c..a2a048dbcf6 100644 --- a/grails-fields/src/main/groovy/org/grails/scaffolding/model/DomainModelServiceImpl.groovy +++ b/grails-fields/src/main/groovy/org/grails/scaffolding/model/DomainModelServiceImpl.groovy @@ -19,6 +19,7 @@ package org.grails.scaffolding.model +import java.lang.reflect.Field import java.lang.reflect.Method import groovy.transform.CompileStatic @@ -107,13 +108,121 @@ class DomainModelServiceImpl implements DomainModelService { *
  • version *
  • dateCreated *
  • lastUpdated + *
  • Any properties with @CreatedDate, @LastModifiedDate, or @AutoTimestamp annotations (if excludeAnnotatedTimestamps is true) + *

    + * + * @see {@link DomainModelServiceImpl#getProperties} + * @param domainClass The persistent entity + * @param blackList Custom blacklist (optional) + * @param excludeAnnotatedTimestamps If true, exclude @CreatedDate/@LastModifiedDate/@AutoTimestamp properties + */ + List getInputProperties(PersistentEntity domainClass, List blackList, boolean excludeAnnotatedTimestamps) { + getInputPropertiesInternal(domainClass, new ArrayList<>(blackList ?: ['version', 'dateCreated', 'lastUpdated']), excludeAnnotatedTimestamps) + } + + /** + *

    Blacklist:

      + *
    • version + *
    • dateCreated + *
    • lastUpdated + *
    • Any properties with @CreatedDate, @LastModifiedDate, or @AutoTimestamp annotations *

    * * @see {@link DomainModelServiceImpl#getProperties} * @param domainClass The persistent entity */ List getInputProperties(PersistentEntity domainClass, List blackList = null) { - getProperties(domainClass, new ArrayList<>(blackList ?: ['version', 'dateCreated', 'lastUpdated'])) + getInputProperties(domainClass, blackList, true) + } + + /** + * Internal method that checks for auto-timestamp annotations and adds them to the blacklist + */ + protected List getInputPropertiesInternal(PersistentEntity domainClass, List blacklist, boolean excludeAnnotatedTimestamps) { + List properties = domainClass.persistentProperties.collect { + domainPropertyFactory.build(it) + } + + // Add properties with auto-timestamp annotations to blacklist only if excludeAnnotatedTimestamps is true + if (excludeAnnotatedTimestamps) { + properties.each { DomainProperty property -> + if (hasAutoTimestampAnnotation(property.persistentProperty)) { + if (!blacklist.contains(property.name)) { + blacklist.add(property.name) + } + } + } + } + + Object scaffoldProp = GrailsClassUtils.getStaticPropertyValue(domainClass.javaClass, 'scaffold') + if (scaffoldProp instanceof Map) { + Map scaffold = (Map) scaffoldProp + if (scaffold.containsKey('exclude')) { + if (scaffold.exclude instanceof Collection) { + blacklist.addAll((Collection) scaffold.exclude) + } else if (scaffold.exclude instanceof String) { + blacklist.add((String) scaffold.exclude) + } + } + } + + properties.removeAll { + if (it.name in blacklist) { + return true + } + Constrained constrained = it.constrained + if (constrained && !constrained.display) { + return true + } + if (derivedMethod != null) { + Property property = it.mapping.mappedForm + if (derivedMethod.invoke(property, (Object[]) null)) { + return true + } + } + + false + } + properties.sort() + properties + } + + /** + * Checks if a property has @CreatedDate, @LastModifiedDate, or @AutoTimestamp annotation. + * These annotations indicate auto-timestamp properties that should not be editable. + */ + protected boolean hasAutoTimestampAnnotation(PersistentProperty persistentProperty) { + try { + Field field = getFieldFromHierarchy(persistentProperty.owner.javaClass, persistentProperty.name) + if (field != null) { + for (java.lang.annotation.Annotation annotation : field.declaredAnnotations) { + String annotationName = annotation.annotationType().name + if (annotationName == 'grails.gorm.annotation.CreatedDate' || + annotationName == 'grails.gorm.annotation.LastModifiedDate' || + annotationName == 'grails.gorm.annotation.AutoTimestamp') { + return true + } + } + } + } catch (Exception ignored) { + // If we can't check for annotations, default to false + } + return false + } + + /** + * Gets a field from the class hierarchy, checking superclasses if necessary. + */ + private static Field getFieldFromHierarchy(Class entity, String fieldName) { + Class clazz = entity + while (clazz != null) { + try { + return clazz.getDeclaredField(fieldName) + } catch (NoSuchFieldException e) { + clazz = clazz.superclass + } + } + return null } /** From 00a4f03baee1f78549cb515c810bd505c3d3c504 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Wed, 8 Oct 2025 02:42:04 -0700 Subject: [PATCH 07/15] remove duplicate method --- .../events/AutoTimestampEventListener.java | 15 ++------------- .../eval/DefaultConstraintEvaluator.java | 18 ++---------------- .../model/DomainModelServiceImpl.groovy | 18 ++---------------- 3 files changed, 6 insertions(+), 45 deletions(-) diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/events/AutoTimestampEventListener.java b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/events/AutoTimestampEventListener.java index a119c221a30..68ed02c9e20 100644 --- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/events/AutoTimestampEventListener.java +++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/events/AutoTimestampEventListener.java @@ -32,6 +32,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.context.ApplicationEvent; +import org.springframework.util.ReflectionUtils; import grails.gorm.annotation.AutoTimestamp; import grails.gorm.annotation.CreatedDate; @@ -185,18 +186,6 @@ protected Set getDateCreatedPropertyNames(String entityName) { return properties == null ? null : properties.orElse(null); } - private static Field getFieldFromHierarchy(Class entity, String fieldName) { - Class clazz = entity; - while (clazz != null) { - try { - return clazz.getDeclaredField(fieldName); - } catch (NoSuchFieldException e) { - clazz = clazz.getSuperclass(); - } - } - return null; - } - protected void storeDateCreatedAndLastUpdatedInfo(PersistentEntity persistentEntity) { if (persistentEntity.isInitialized()) { ClassMapping classMapping = persistentEntity.getMapping(); @@ -208,7 +197,7 @@ protected void storeDateCreatedAndLastUpdatedInfo(PersistentEntity persistentEnt } else if (property.getName().equals(DATE_CREATED_PROPERTY)) { storeTimestampAvailability(entitiesWithDateCreated, persistentEntity, property); } else { - Field field = getFieldFromHierarchy(persistentEntity.getJavaClass(), property.getName()); + Field field = ReflectionUtils.findField(persistentEntity.getJavaClass(), property.getName()); if (field != null) { if (field.isAnnotationPresent(CreatedDate.class)) { storeTimestampAvailability(entitiesWithDateCreated, persistentEntity, property); diff --git a/grails-datamapping-validation/src/main/groovy/org/grails/datastore/gorm/validation/constraints/eval/DefaultConstraintEvaluator.java b/grails-datamapping-validation/src/main/groovy/org/grails/datastore/gorm/validation/constraints/eval/DefaultConstraintEvaluator.java index f2e31b35439..de5caf6d82e 100644 --- a/grails-datamapping-validation/src/main/groovy/org/grails/datastore/gorm/validation/constraints/eval/DefaultConstraintEvaluator.java +++ b/grails-datamapping-validation/src/main/groovy/org/grails/datastore/gorm/validation/constraints/eval/DefaultConstraintEvaluator.java @@ -39,6 +39,7 @@ import org.springframework.context.MessageSource; import org.springframework.context.support.StaticMessageSource; +import org.springframework.util.ReflectionUtils; import grails.gorm.validation.Constrained; import grails.gorm.validation.ConstrainedProperty; @@ -328,7 +329,7 @@ protected boolean isConstrainableProperty(PersistentProperty persistentProperty, */ protected boolean hasAutoTimestampAnnotation(PersistentProperty persistentProperty) { try { - Field field = getFieldFromHierarchy(persistentProperty.getOwner().getJavaClass(), persistentProperty.getName()); + Field field = ReflectionUtils.findField(persistentProperty.getOwner().getJavaClass(), persistentProperty.getName()); if (field != null) { for (java.lang.annotation.Annotation annotation : field.getDeclaredAnnotations()) { String annotationName = annotation.annotationType().getName(); @@ -345,19 +346,4 @@ protected boolean hasAutoTimestampAnnotation(PersistentProperty persistentProper return false; } - /** - * Gets a field from the class hierarchy, checking superclasses if necessary. - */ - private static Field getFieldFromHierarchy(Class entity, String fieldName) { - Class clazz = entity; - while (clazz != null) { - try { - return clazz.getDeclaredField(fieldName); - } catch (NoSuchFieldException e) { - clazz = clazz.getSuperclass(); - } - } - return null; - } - } diff --git a/grails-fields/src/main/groovy/org/grails/scaffolding/model/DomainModelServiceImpl.groovy b/grails-fields/src/main/groovy/org/grails/scaffolding/model/DomainModelServiceImpl.groovy index a2a048dbcf6..386ef31ad8b 100644 --- a/grails-fields/src/main/groovy/org/grails/scaffolding/model/DomainModelServiceImpl.groovy +++ b/grails-fields/src/main/groovy/org/grails/scaffolding/model/DomainModelServiceImpl.groovy @@ -25,6 +25,7 @@ import java.lang.reflect.Method import groovy.transform.CompileStatic import org.springframework.beans.factory.annotation.Autowired +import org.springframework.util.ReflectionUtils import grails.util.GrailsClassUtils import org.grails.datastore.mapping.config.Property @@ -193,7 +194,7 @@ class DomainModelServiceImpl implements DomainModelService { */ protected boolean hasAutoTimestampAnnotation(PersistentProperty persistentProperty) { try { - Field field = getFieldFromHierarchy(persistentProperty.owner.javaClass, persistentProperty.name) + Field field = ReflectionUtils.findField(persistentProperty.owner.javaClass, persistentProperty.name) if (field != null) { for (java.lang.annotation.Annotation annotation : field.declaredAnnotations) { String annotationName = annotation.annotationType().name @@ -210,21 +211,6 @@ class DomainModelServiceImpl implements DomainModelService { return false } - /** - * Gets a field from the class hierarchy, checking superclasses if necessary. - */ - private static Field getFieldFromHierarchy(Class entity, String fieldName) { - Class clazz = entity - while (clazz != null) { - try { - return clazz.getDeclaredField(fieldName) - } catch (NoSuchFieldException e) { - clazz = clazz.superclass - } - } - return null - } - /** *

    Blacklist:

      *
    • version From a73aac74a901c485a613d8ec0cb1cf0b0db489c1 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Wed, 15 Oct 2025 11:26:24 -0700 Subject: [PATCH 08/15] Revert setting properties dirty in AutoTimestampEventListener as this is now handled in the EntityPersister --- .../gorm/events/AutoTimestampEventListener.java | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/events/AutoTimestampEventListener.java b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/events/AutoTimestampEventListener.java index 68ed02c9e20..3e45de7632b 100644 --- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/events/AutoTimestampEventListener.java +++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/events/AutoTimestampEventListener.java @@ -42,7 +42,6 @@ import org.grails.datastore.mapping.config.Entity; import org.grails.datastore.mapping.config.Settings; import org.grails.datastore.mapping.core.Datastore; -import org.grails.datastore.mapping.dirty.checking.DirtyCheckable; import org.grails.datastore.mapping.engine.EntityAccess; import org.grails.datastore.mapping.engine.event.AbstractPersistenceEvent; import org.grails.datastore.mapping.engine.event.AbstractPersistenceEventListener; @@ -152,25 +151,10 @@ private void initializeIfNecessary(PersistentEntity entity, String name) { public boolean beforeUpdate(PersistentEntity entity, EntityAccess ea) { Set props = getLastUpdatedPropertyNames(entity.getName()); if (props != null) { - Object entityObject = ea.getEntity(); - boolean isDirtyCheckable = entityObject instanceof DirtyCheckable; - - // For dirty-checking datastores (e.g., MongoDB), only set autotimestamp if entity has dirty properties - if (isDirtyCheckable) { - List dirtyPropertyNames = ((DirtyCheckable) entityObject).listDirtyPropertyNames(); - if (dirtyPropertyNames.isEmpty()) { - return true; - } - } - for (String prop : props) { Class lastUpdateType = ea.getPropertyType(prop); Object timestamp = timestampProvider.createTimestamp(lastUpdateType); ea.setProperty(prop, timestamp); - // Mark property as dirty for datastores that use dirty checking (e.g., MongoDB) - if (isDirtyCheckable) { - ((DirtyCheckable) entityObject).markDirty(prop); - } } } return true; From 809f1d0b551ddc8d7b6d905df2e789ecbe31f671 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Wed, 15 Oct 2025 11:30:55 -0700 Subject: [PATCH 09/15] Deprecate @AutoTimestamp --- .../src/main/groovy/grails/gorm/annotation/AutoTimestamp.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/grails-datamapping-core/src/main/groovy/grails/gorm/annotation/AutoTimestamp.java b/grails-datamapping-core/src/main/groovy/grails/gorm/annotation/AutoTimestamp.java index 1b99b659f47..b99be34a7a0 100644 --- a/grails-datamapping-core/src/main/groovy/grails/gorm/annotation/AutoTimestamp.java +++ b/grails-datamapping-core/src/main/groovy/grails/gorm/annotation/AutoTimestamp.java @@ -29,7 +29,9 @@ * * @author Scott Murphy Heiberg * @since 7.0 + * @deprecated Use {@link CreatedDate} for creation timestamps or {@link LastModifiedDate} for update timestamps instead */ +@Deprecated @Retention(RetentionPolicy.RUNTIME) @Target({ElementType.FIELD}) public @interface AutoTimestamp { From f05cee7e9562aac3036439544cee7a077087839c Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Wed, 15 Oct 2025 17:13:26 -0700 Subject: [PATCH 10/15] Cache annotation lookups when not in development mode --- .../events/AutoTimestampEventListener.java | 24 +-- .../eval/DefaultConstraintEvaluator.java | 29 +--- grails-datastore-core/build.gradle | 3 + .../datastore/mapping/config/Property.groovy | 23 +++ .../mapping/model/AutoTimestampUtils.java | 157 ++++++++++++++++++ .../model/DomainModelServiceImpl.groovy | 28 +--- 6 files changed, 194 insertions(+), 70 deletions(-) create mode 100644 grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/model/AutoTimestampUtils.java diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/events/AutoTimestampEventListener.java b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/events/AutoTimestampEventListener.java index 3e45de7632b..85af0b6b3cc 100644 --- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/events/AutoTimestampEventListener.java +++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/events/AutoTimestampEventListener.java @@ -32,11 +32,9 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.context.ApplicationEvent; -import org.springframework.util.ReflectionUtils; import grails.gorm.annotation.AutoTimestamp; -import grails.gorm.annotation.CreatedDate; -import grails.gorm.annotation.LastModifiedDate; +import org.grails.datastore.mapping.config.Property.AutoTimestampType; import org.grails.datastore.gorm.timestamp.DefaultTimestampProvider; import org.grails.datastore.gorm.timestamp.TimestampProvider; import org.grails.datastore.mapping.config.Entity; @@ -48,6 +46,7 @@ import org.grails.datastore.mapping.engine.event.EventType; import org.grails.datastore.mapping.engine.event.PreInsertEvent; import org.grails.datastore.mapping.engine.event.PreUpdateEvent; +import org.grails.datastore.mapping.model.AutoTimestampUtils; import org.grails.datastore.mapping.model.ClassMapping; import org.grails.datastore.mapping.model.MappingContext; import org.grails.datastore.mapping.model.PersistentEntity; @@ -181,20 +180,11 @@ protected void storeDateCreatedAndLastUpdatedInfo(PersistentEntity persistentEnt } else if (property.getName().equals(DATE_CREATED_PROPERTY)) { storeTimestampAvailability(entitiesWithDateCreated, persistentEntity, property); } else { - Field field = ReflectionUtils.findField(persistentEntity.getJavaClass(), property.getName()); - if (field != null) { - if (field.isAnnotationPresent(CreatedDate.class)) { - storeTimestampAvailability(entitiesWithDateCreated, persistentEntity, property); - } else if (field.isAnnotationPresent(LastModifiedDate.class)) { - storeTimestampAvailability(entitiesWithLastUpdated, persistentEntity, property); - } else if (field.isAnnotationPresent(AutoTimestamp.class)) { - AutoTimestamp autoTimestamp = field.getAnnotation(AutoTimestamp.class); - if (autoTimestamp.value() == AutoTimestamp.EventType.UPDATED) { - storeTimestampAvailability(entitiesWithLastUpdated, persistentEntity, property); - } else { - storeTimestampAvailability(entitiesWithDateCreated, persistentEntity, property); - } - } + AutoTimestampType timestampType = AutoTimestampUtils.getAutoTimestampType(property); + if (timestampType == AutoTimestampType.CREATED) { + storeTimestampAvailability(entitiesWithDateCreated, persistentEntity, property); + } else if (timestampType == AutoTimestampType.UPDATED) { + storeTimestampAvailability(entitiesWithLastUpdated, persistentEntity, property); } } } diff --git a/grails-datamapping-validation/src/main/groovy/org/grails/datastore/gorm/validation/constraints/eval/DefaultConstraintEvaluator.java b/grails-datamapping-validation/src/main/groovy/org/grails/datastore/gorm/validation/constraints/eval/DefaultConstraintEvaluator.java index de5caf6d82e..9dc29a61eec 100644 --- a/grails-datamapping-validation/src/main/groovy/org/grails/datastore/gorm/validation/constraints/eval/DefaultConstraintEvaluator.java +++ b/grails-datamapping-validation/src/main/groovy/org/grails/datastore/gorm/validation/constraints/eval/DefaultConstraintEvaluator.java @@ -19,7 +19,6 @@ package org.grails.datastore.gorm.validation.constraints.eval; -import java.lang.reflect.Field; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; @@ -39,7 +38,6 @@ import org.springframework.context.MessageSource; import org.springframework.context.support.StaticMessageSource; -import org.springframework.util.ReflectionUtils; import grails.gorm.validation.Constrained; import grails.gorm.validation.ConstrainedProperty; @@ -50,6 +48,7 @@ import org.grails.datastore.gorm.validation.constraints.registry.DefaultConstraintRegistry; import org.grails.datastore.mapping.config.Property; import org.grails.datastore.mapping.keyvalue.mapping.config.KeyValueMappingContext; +import org.grails.datastore.mapping.model.AutoTimestampUtils; import org.grails.datastore.mapping.model.MappingContext; import org.grails.datastore.mapping.model.PersistentEntity; import org.grails.datastore.mapping.model.PersistentProperty; @@ -308,7 +307,7 @@ protected boolean isConstrainableProperty(PersistentProperty persistentProperty, } else { // Check if property has @CreatedDate or @LastModifiedDate annotations - if (hasAutoTimestampAnnotation(persistentProperty)) { + if (AutoTimestampUtils.hasAutoTimestampAnnotation(persistentProperty)) { return false; } @@ -322,28 +321,4 @@ protected boolean isConstrainableProperty(PersistentProperty persistentProperty, } - /** - * Checks if a property has @CreatedDate, @LastModifiedDate, or @AutoTimestamp annotation. - * These annotations indicate auto-timestamp properties that should be nullable. - * Uses reflection by annotation name to avoid circular dependency issues. - */ - protected boolean hasAutoTimestampAnnotation(PersistentProperty persistentProperty) { - try { - Field field = ReflectionUtils.findField(persistentProperty.getOwner().getJavaClass(), persistentProperty.getName()); - if (field != null) { - for (java.lang.annotation.Annotation annotation : field.getDeclaredAnnotations()) { - String annotationName = annotation.annotationType().getName(); - if ("grails.gorm.annotation.CreatedDate".equals(annotationName) || - "grails.gorm.annotation.LastModifiedDate".equals(annotationName) || - "grails.gorm.annotation.AutoTimestamp".equals(annotationName)) { - return true; - } - } - } - } catch (Exception e) { - LOG.debug("Unable to check for auto-timestamp annotations on property: " + persistentProperty.getName(), e); - } - return false; - } - } diff --git a/grails-datastore-core/build.gradle b/grails-datastore-core/build.gradle index 7793bf252e4..d3115c6c6bb 100644 --- a/grails-datastore-core/build.gradle +++ b/grails-datastore-core/build.gradle @@ -45,6 +45,9 @@ dependencies { implementation platform(project(':grails-bom')) api project(':grails-common') + api 'org.apache.grails.gradle:grails-gradle-model', { + // api: Environment (for isDevelopmentMode()) + } api 'jakarta.persistence:jakarta.persistence-api', { // api: FlushModeType, AccessType, CascadeType, EnumType, FetchType, LockModeType, JoinType diff --git a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/config/Property.groovy b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/config/Property.groovy index 9e2e42b9b79..d6b3841090a 100644 --- a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/config/Property.groovy +++ b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/config/Property.groovy @@ -137,6 +137,28 @@ class Property implements Cloneable { private List uniquenessGroup = new ArrayList() private String propertyName private EnumType enumType + /** + * The auto-timestamp type for this property, cached to avoid repeated reflection calls + */ + AutoTimestampType autoTimestampType + + /** + * Enum representing the type of auto-timestamp annotation on a property + */ + static enum AutoTimestampType { + /** + * Property has @CreatedDate annotation or @AutoTimestamp(CREATED) + */ + CREATED, + /** + * Property has @LastModifiedDate annotation or @AutoTimestamp(UPDATED) + */ + UPDATED, + /** + * Property has no auto-timestamp annotation + */ + NONE + } protected void setUniquenessGroup(List uniquenessGroup) { this.uniquenessGroup = uniquenessGroup @@ -161,6 +183,7 @@ class Property implements Cloneable { if (inList != null) { cloned.inList = new ArrayList<>(inList) } + cloned.autoTimestampType = this.autoTimestampType return cloned } diff --git a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/model/AutoTimestampUtils.java b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/model/AutoTimestampUtils.java new file mode 100644 index 00000000000..0883575ad3b --- /dev/null +++ b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/model/AutoTimestampUtils.java @@ -0,0 +1,157 @@ +/* + * 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.datastore.mapping.model; + +import java.lang.reflect.Field; + +import org.springframework.util.ReflectionUtils; + +import grails.util.Environment; +import org.grails.datastore.mapping.config.Property; +import org.grails.datastore.mapping.config.Property.AutoTimestampType; + +/** + * Utility class for detecting and caching auto-timestamp annotations on domain properties. + * This avoids repeated reflection calls by storing the annotation type in the Property metadata. + * + *

      Caching is automatically disabled in development mode ({@link Environment#isDevelopmentMode()}) + * to ensure annotation changes are picked up during class reloading.

      + * + * @author Scott Murphy Heiberg + * @since 7.0 + */ +public class AutoTimestampUtils { + + private static final String CREATED_DATE_ANNOTATION = "grails.gorm.annotation.CreatedDate"; + private static final String LAST_MODIFIED_DATE_ANNOTATION = "grails.gorm.annotation.LastModifiedDate"; + private static final String AUTO_TIMESTAMP_ANNOTATION = "grails.gorm.annotation.AutoTimestamp"; + + /** + * Gets the auto-timestamp type for a persistent property, using cached metadata when not in development mode. + * + *

      In development mode, this method will always perform reflection to detect the current + * annotation state, ensuring that annotation changes during class reloading are immediately + * recognized. In production, the result is cached to avoid repeated reflection calls.

      + * + * @param persistentProperty The persistent property to check + * @return The auto-timestamp type (CREATED, UPDATED, or NONE) + */ + public static AutoTimestampType getAutoTimestampType(PersistentProperty persistentProperty) { + Property mappedForm = persistentProperty.getMapping().getMappedForm(); + + // In development mode, always detect fresh to support class reloading + if (Environment.isDevelopmentMode()) { + return detectAutoTimestampType(persistentProperty); + } + + // Return cached value if available + if (mappedForm.getAutoTimestampType() != null) { + return mappedForm.getAutoTimestampType(); + } + + // Detect and cache the annotation type + AutoTimestampType type = detectAutoTimestampType(persistentProperty); + mappedForm.setAutoTimestampType(type); + return type; + } + + /** + * Detects the auto-timestamp annotation type on a property using reflection. + * + *

      When caching is enabled (production mode), this method is called once per property + * and the result is cached. When caching is disabled (development mode), this method + * is called on every access to ensure annotation changes are detected.

      + * + * @param persistentProperty The persistent property to check + * @return The auto-timestamp type (CREATED, UPDATED, or NONE) + */ + private static AutoTimestampType detectAutoTimestampType(PersistentProperty persistentProperty) { + try { + Field field = ReflectionUtils.findField( + persistentProperty.getOwner().getJavaClass(), + persistentProperty.getName() + ); + + if (field != null) { + for (java.lang.annotation.Annotation annotation : field.getDeclaredAnnotations()) { + String annotationName = annotation.annotationType().getName(); + + if (CREATED_DATE_ANNOTATION.equals(annotationName)) { + return AutoTimestampType.CREATED; + } else if (LAST_MODIFIED_DATE_ANNOTATION.equals(annotationName)) { + return AutoTimestampType.UPDATED; + } else if (AUTO_TIMESTAMP_ANNOTATION.equals(annotationName)) { + // For @AutoTimestamp, check the EventType value + try { + Object eventTypeValue = annotation.annotationType() + .getMethod("value") + .invoke(annotation); + + if (eventTypeValue != null) { + String eventTypeName = eventTypeValue.toString(); + if (eventTypeName.equals("UPDATED")) { + return AutoTimestampType.UPDATED; + } else { + return AutoTimestampType.CREATED; + } + } + } catch (Exception e) { + // If we can't read the value, default to CREATED + return AutoTimestampType.CREATED; + } + } + } + } + } catch (Exception ignored) { + // If reflection fails, return NONE + } + + return AutoTimestampType.NONE; + } + + /** + * Checks if a property has any auto-timestamp annotation. + * + * @param persistentProperty The persistent property to check + * @return true if the property has a @CreatedDate, @LastModifiedDate, or @AutoTimestamp annotation + */ + public static boolean hasAutoTimestampAnnotation(PersistentProperty persistentProperty) { + return getAutoTimestampType(persistentProperty) != AutoTimestampType.NONE; + } + + /** + * Checks if a property has a @CreatedDate annotation or @AutoTimestamp(CREATED). + * + * @param persistentProperty The persistent property to check + * @return true if the property represents a creation timestamp + */ + public static boolean isCreatedTimestamp(PersistentProperty persistentProperty) { + return getAutoTimestampType(persistentProperty) == AutoTimestampType.CREATED; + } + + /** + * Checks if a property has a @LastModifiedDate annotation or @AutoTimestamp(UPDATED). + * + * @param persistentProperty The persistent property to check + * @return true if the property represents an update timestamp + */ + public static boolean isUpdatedTimestamp(PersistentProperty persistentProperty) { + return getAutoTimestampType(persistentProperty) == AutoTimestampType.UPDATED; + } +} diff --git a/grails-fields/src/main/groovy/org/grails/scaffolding/model/DomainModelServiceImpl.groovy b/grails-fields/src/main/groovy/org/grails/scaffolding/model/DomainModelServiceImpl.groovy index 386ef31ad8b..1e18fc123a3 100644 --- a/grails-fields/src/main/groovy/org/grails/scaffolding/model/DomainModelServiceImpl.groovy +++ b/grails-fields/src/main/groovy/org/grails/scaffolding/model/DomainModelServiceImpl.groovy @@ -19,16 +19,15 @@ package org.grails.scaffolding.model -import java.lang.reflect.Field import java.lang.reflect.Method import groovy.transform.CompileStatic import org.springframework.beans.factory.annotation.Autowired -import org.springframework.util.ReflectionUtils import grails.util.GrailsClassUtils import org.grails.datastore.mapping.config.Property +import org.grails.datastore.mapping.model.AutoTimestampUtils import org.grails.datastore.mapping.model.PersistentEntity import org.grails.datastore.mapping.model.PersistentProperty import org.grails.datastore.mapping.model.types.Embedded @@ -147,7 +146,7 @@ class DomainModelServiceImpl implements DomainModelService { // Add properties with auto-timestamp annotations to blacklist only if excludeAnnotatedTimestamps is true if (excludeAnnotatedTimestamps) { properties.each { DomainProperty property -> - if (hasAutoTimestampAnnotation(property.persistentProperty)) { + if (AutoTimestampUtils.hasAutoTimestampAnnotation(property.persistentProperty)) { if (!blacklist.contains(property.name)) { blacklist.add(property.name) } @@ -188,29 +187,6 @@ class DomainModelServiceImpl implements DomainModelService { properties } - /** - * Checks if a property has @CreatedDate, @LastModifiedDate, or @AutoTimestamp annotation. - * These annotations indicate auto-timestamp properties that should not be editable. - */ - protected boolean hasAutoTimestampAnnotation(PersistentProperty persistentProperty) { - try { - Field field = ReflectionUtils.findField(persistentProperty.owner.javaClass, persistentProperty.name) - if (field != null) { - for (java.lang.annotation.Annotation annotation : field.declaredAnnotations) { - String annotationName = annotation.annotationType().name - if (annotationName == 'grails.gorm.annotation.CreatedDate' || - annotationName == 'grails.gorm.annotation.LastModifiedDate' || - annotationName == 'grails.gorm.annotation.AutoTimestamp') { - return true - } - } - } - } catch (Exception ignored) { - // If we can't check for annotations, default to false - } - return false - } - /** *

      Blacklist:

        *
      • version From f37dcc16f4c799b55800bf95c3df66257974b968 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Wed, 15 Oct 2025 17:27:31 -0700 Subject: [PATCH 11/15] Add jakarta.validation.constraints.* to common annotation star imports --- .../org/grails/gradle/plugin/core/GrailsExtension.groovy | 5 +++-- .../grails/gradle/plugin/core/GrailsGradlePlugin.groovy | 7 +++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsExtension.groovy b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsExtension.groovy index d8721063f32..1fdeb1b0061 100644 --- a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsExtension.groovy +++ b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsExtension.groovy @@ -65,12 +65,13 @@ class GrailsExtension { boolean importJavaTime = false /** - * Whether grails annotation packages should be default import packages. + * Whether grails annotation packages and common validation annotations should be default import packages. * When enabled, automatically imports: + * - jakarta.validation.constraints.* * - grails.gorm.annotation.* (if grails-datamapping-core is in classpath) * - grails.plugin.scaffolding.annotation.* (if grails-scaffolding is in classpath) */ - boolean importGrailsAnnotations = false + boolean importGrailsCommonAnnotations = false /** * Whether the spring dependency management plugin should be applied by default diff --git a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsGradlePlugin.groovy b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsGradlePlugin.groovy index ab5996262a2..fc4a822127d 100644 --- a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsGradlePlugin.groovy +++ b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsGradlePlugin.groovy @@ -234,8 +234,11 @@ class GrailsGradlePlugin extends GroovyPlugin { starImports.add('java.time') } - // Add Grails annotation packages if enabled and dependencies are present - if (grails.importGrailsAnnotations) { + // Add Grails annotation packages and common validation annotations if enabled + if (grails.importGrailsCommonAnnotations) { + // Always add jakarta.validation.constraints + starImports.add('jakarta.validation.constraints') + // Check for grails-datamapping-core (grails.gorm.annotation.*) def datamappingCoreDep = project.configurations.getByName('compileClasspath').dependencies.find { Dependency d -> d.group == 'org.apache.grails.data' && d.name == 'grails-datamapping-core' From a052ed18ee0711d2f9c926024138dbf350f1f734 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Wed, 15 Oct 2025 21:01:26 -0700 Subject: [PATCH 12/15] null check on persistentProperty --- .../org/grails/datastore/mapping/model/AutoTimestampUtils.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/model/AutoTimestampUtils.java b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/model/AutoTimestampUtils.java index 0883575ad3b..24ec5980e64 100644 --- a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/model/AutoTimestampUtils.java +++ b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/model/AutoTimestampUtils.java @@ -132,7 +132,7 @@ private static AutoTimestampType detectAutoTimestampType(PersistentProperty p * @return true if the property has a @CreatedDate, @LastModifiedDate, or @AutoTimestamp annotation */ public static boolean hasAutoTimestampAnnotation(PersistentProperty persistentProperty) { - return getAutoTimestampType(persistentProperty) != AutoTimestampType.NONE; + return persistentProperty != null && getAutoTimestampType(persistentProperty) != AutoTimestampType.NONE; } /** From 8602be40b29662a08542b27db9e33979d61b0291 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Thu, 16 Oct 2025 21:02:12 -0700 Subject: [PATCH 13/15] remove unused imports --- .../datastore/gorm/events/AutoTimestampEventListener.java | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/events/AutoTimestampEventListener.java b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/events/AutoTimestampEventListener.java index 85af0b6b3cc..d196797d546 100644 --- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/events/AutoTimestampEventListener.java +++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/events/AutoTimestampEventListener.java @@ -18,7 +18,6 @@ */ package org.grails.datastore.gorm.events; -import java.lang.reflect.Field; import java.util.ArrayList; import java.util.Collection; import java.util.HashSet; @@ -33,11 +32,10 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.context.ApplicationEvent; -import grails.gorm.annotation.AutoTimestamp; -import org.grails.datastore.mapping.config.Property.AutoTimestampType; import org.grails.datastore.gorm.timestamp.DefaultTimestampProvider; import org.grails.datastore.gorm.timestamp.TimestampProvider; import org.grails.datastore.mapping.config.Entity; +import org.grails.datastore.mapping.config.Property.AutoTimestampType; import org.grails.datastore.mapping.config.Settings; import org.grails.datastore.mapping.core.Datastore; import org.grails.datastore.mapping.engine.EntityAccess; From df38a803926b118a3c9a46853a38f11f8f1b468e Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Fri, 17 Oct 2025 17:05:56 -0700 Subject: [PATCH 14/15] Support for Spring Data annotations --- .../mapping/model/AutoTimestampUtils.java | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/model/AutoTimestampUtils.java b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/model/AutoTimestampUtils.java index 24ec5980e64..d1d97b8e1f2 100644 --- a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/model/AutoTimestampUtils.java +++ b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/model/AutoTimestampUtils.java @@ -42,6 +42,9 @@ public class AutoTimestampUtils { private static final String LAST_MODIFIED_DATE_ANNOTATION = "grails.gorm.annotation.LastModifiedDate"; private static final String AUTO_TIMESTAMP_ANNOTATION = "grails.gorm.annotation.AutoTimestamp"; + private static final String CREATED_DATE_SPRING_ANNOTATION = "org.springframework.data.annotation.CreatedDate"; + private static final String LAST_MODIFIED_DATE_SPRING_ANNOTATION = "org.springframework.data.annotation.LastModifiedDate"; + /** * Gets the auto-timestamp type for a persistent property, using cached metadata when not in development mode. * @@ -92,9 +95,11 @@ private static AutoTimestampType detectAutoTimestampType(PersistentProperty p for (java.lang.annotation.Annotation annotation : field.getDeclaredAnnotations()) { String annotationName = annotation.annotationType().getName(); - if (CREATED_DATE_ANNOTATION.equals(annotationName)) { + if (CREATED_DATE_ANNOTATION.equals(annotationName) || + CREATED_DATE_SPRING_ANNOTATION.equals(annotationName)) { return AutoTimestampType.CREATED; - } else if (LAST_MODIFIED_DATE_ANNOTATION.equals(annotationName)) { + } else if (LAST_MODIFIED_DATE_ANNOTATION.equals(annotationName) || + LAST_MODIFIED_DATE_SPRING_ANNOTATION.equals(annotationName)) { return AutoTimestampType.UPDATED; } else if (AUTO_TIMESTAMP_ANNOTATION.equals(annotationName)) { // For @AutoTimestamp, check the EventType value @@ -129,14 +134,15 @@ private static AutoTimestampType detectAutoTimestampType(PersistentProperty p * Checks if a property has any auto-timestamp annotation. * * @param persistentProperty The persistent property to check - * @return true if the property has a @CreatedDate, @LastModifiedDate, or @AutoTimestamp annotation + * @return true if the property has a GORM @CreatedDate, @LastModifiedDate, @AutoTimestamp annotation, + * or Spring Data @CreatedDate, @LastModifiedDate annotation */ public static boolean hasAutoTimestampAnnotation(PersistentProperty persistentProperty) { return persistentProperty != null && getAutoTimestampType(persistentProperty) != AutoTimestampType.NONE; } /** - * Checks if a property has a @CreatedDate annotation or @AutoTimestamp(CREATED). + * Checks if a property has a @CreatedDate annotation (GORM or Spring Data) or @AutoTimestamp(CREATED). * * @param persistentProperty The persistent property to check * @return true if the property represents a creation timestamp @@ -146,7 +152,7 @@ public static boolean isCreatedTimestamp(PersistentProperty persistentPropert } /** - * Checks if a property has a @LastModifiedDate annotation or @AutoTimestamp(UPDATED). + * Checks if a property has a @LastModifiedDate annotation (GORM or Spring Data) or @AutoTimestamp(UPDATED). * * @param persistentProperty The persistent property to check * @return true if the property represents an update timestamp From e1597d9f795edca56c374b1b41fef854cf84ff99 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Fri, 17 Oct 2025 19:34:20 -0700 Subject: [PATCH 15/15] @CreatedBy and @LastModifiedBy support --- .../grails/gorm/annotation/CreatedBy.java | 56 +++++++++++ .../gorm/annotation/LastModifiedBy.java | 56 +++++++++++ .../events/AutoTimestampEventListener.java | 95 ++++++++++++++++++- .../gorm/timestamp/AuditorAware.java | 58 +++++++++++ .../datastore/mapping/config/Property.groovy | 8 ++ .../mapping/model/AutoTimestampUtils.java | 47 ++++++++- 6 files changed, 314 insertions(+), 6 deletions(-) create mode 100644 grails-datamapping-core/src/main/groovy/grails/gorm/annotation/CreatedBy.java create mode 100644 grails-datamapping-core/src/main/groovy/grails/gorm/annotation/LastModifiedBy.java create mode 100644 grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/timestamp/AuditorAware.java diff --git a/grails-datamapping-core/src/main/groovy/grails/gorm/annotation/CreatedBy.java b/grails-datamapping-core/src/main/groovy/grails/gorm/annotation/CreatedBy.java new file mode 100644 index 00000000000..4ee94418871 --- /dev/null +++ b/grails-datamapping-core/src/main/groovy/grails/gorm/annotation/CreatedBy.java @@ -0,0 +1,56 @@ +/* + * 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.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * A property annotation used to automatically populate a field with the current auditor + * upon GORM insert events. The current auditor is retrieved from an {@link org.grails.datastore.gorm.timestamp.AuditorAware} + * bean registered in the Spring application context. + * + *

        Example usage:

        + *
        + * class Book {
        + *     @CreatedBy
        + *     String createdBy
        + *
        + *     @CreatedBy
        + *     User creator
        + *
        + *     @CreatedBy
        + *     Long creatorId
        + * }
        + * 
        + * + *

        The field type should match the type parameter of your {@link org.grails.datastore.gorm.timestamp.AuditorAware} + * implementation (e.g., String, Long, User, etc.).

        + * + * @author Scott Murphy Heiberg + * @since 7.0 + * @see org.grails.datastore.gorm.timestamp.AuditorAware + * @see LastModifiedBy + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.FIELD}) +public @interface CreatedBy { +} diff --git a/grails-datamapping-core/src/main/groovy/grails/gorm/annotation/LastModifiedBy.java b/grails-datamapping-core/src/main/groovy/grails/gorm/annotation/LastModifiedBy.java new file mode 100644 index 00000000000..5ddf730350e --- /dev/null +++ b/grails-datamapping-core/src/main/groovy/grails/gorm/annotation/LastModifiedBy.java @@ -0,0 +1,56 @@ +/* + * 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.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * A property annotation used to automatically populate a field with the current auditor + * upon GORM insert and update events. The current auditor is retrieved from an + * {@link org.grails.datastore.gorm.timestamp.AuditorAware} bean registered in the Spring application context. + * + *

        Example usage:

        + *
        + * class Book {
        + *     @LastModifiedBy
        + *     String lastModifiedBy
        + *
        + *     @LastModifiedBy
        + *     User lastModifier
        + *
        + *     @LastModifiedBy
        + *     Long lastModifierId
        + * }
        + * 
        + * + *

        The field type should match the type parameter of your {@link org.grails.datastore.gorm.timestamp.AuditorAware} + * implementation (e.g., String, Long, User, etc.).

        + * + * @author Scott Murphy Heiberg + * @since 7.0 + * @see org.grails.datastore.gorm.timestamp.AuditorAware + * @see CreatedBy + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.FIELD}) +public @interface LastModifiedBy { +} diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/events/AutoTimestampEventListener.java b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/events/AutoTimestampEventListener.java index d196797d546..c8d06672baf 100644 --- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/events/AutoTimestampEventListener.java +++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/events/AutoTimestampEventListener.java @@ -29,9 +29,13 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentLinkedQueue; +import org.springframework.beans.BeansException; import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; import org.springframework.context.ApplicationEvent; +import org.grails.datastore.gorm.timestamp.AuditorAware; import org.grails.datastore.gorm.timestamp.DefaultTimestampProvider; import org.grails.datastore.gorm.timestamp.TimestampProvider; import org.grails.datastore.mapping.config.Entity; @@ -51,12 +55,12 @@ import org.grails.datastore.mapping.model.PersistentProperty; /** - * An event listener that adds support for GORM-style auto-timestamping + * An event listener that adds support for GORM-style auto-timestamping and auditing * * @author Graeme Rocher * @since 1.0 */ -public class AutoTimestampEventListener extends AbstractPersistenceEventListener implements MappingContext.Listener { +public class AutoTimestampEventListener extends AbstractPersistenceEventListener implements MappingContext.Listener, ApplicationContextAware { // if false, will not set timestamp on insert event if value is not null @Value("${" + Settings.SETTING_AUTO_TIMESTAMP_INSERT_OVERWRITE + ":true}") @@ -67,9 +71,12 @@ public class AutoTimestampEventListener extends AbstractPersistenceEventListener protected Map>> entitiesWithDateCreated = new ConcurrentHashMap<>(); protected Map>> entitiesWithLastUpdated = new ConcurrentHashMap<>(); + protected Map>> entitiesWithCreatedBy = new ConcurrentHashMap<>(); + protected Map>> entitiesWithUpdatedBy = new ConcurrentHashMap<>(); protected Collection uninitializedEntities = new ConcurrentLinkedQueue<>(); private TimestampProvider timestampProvider = new DefaultTimestampProvider(); + private AuditorAware auditorAware; public AutoTimestampEventListener(final Datastore datastore) { super(datastore); @@ -84,6 +91,13 @@ protected AutoTimestampEventListener(final MappingContext mappingContext) { initForMappingContext(mappingContext); } + @Override + public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { + try { + this.auditorAware = applicationContext.getBean(AuditorAware.class); + } catch (BeansException ignore) {} + } + protected void initForMappingContext(MappingContext mappingContext) { for (PersistentEntity persistentEntity : mappingContext.getPersistentEntities()) { storeDateCreatedAndLastUpdatedInfo(persistentEntity); @@ -135,6 +149,33 @@ public boolean beforeInsert(PersistentEntity entity, EntityAccess ea) { } } } + + // Handle auditor fields + if (auditorAware != null) { + Optional currentAuditor = auditorAware.getCurrentAuditor(); + if (currentAuditor.isPresent()) { + Object auditor = currentAuditor.get(); + + props = getCreatedByPropertyNames(name); + if (props != null) { + for (String prop : props) { + if (insertOverwrite || ea.getPropertyValue(prop) == null) { + ea.setProperty(prop, auditor); + } + } + } + + props = getUpdatedByPropertyNames(name); + if (props != null) { + for (String prop : props) { + if (insertOverwrite || ea.getPropertyValue(prop) == null) { + ea.setProperty(prop, auditor); + } + } + } + } + } + return true; } @@ -154,6 +195,22 @@ public boolean beforeUpdate(PersistentEntity entity, EntityAccess ea) { ea.setProperty(prop, timestamp); } } + + // Handle auditor fields + if (auditorAware != null) { + Optional currentAuditor = auditorAware.getCurrentAuditor(); + if (currentAuditor.isPresent()) { + Object auditor = currentAuditor.get(); + + props = getUpdatedByPropertyNames(entity.getName()); + if (props != null) { + for (String prop : props) { + ea.setProperty(prop, auditor); + } + } + } + } + return true; } @@ -167,6 +224,16 @@ protected Set getDateCreatedPropertyNames(String entityName) { return properties == null ? null : properties.orElse(null); } + protected Set getCreatedByPropertyNames(String entityName) { + Optional> properties = entitiesWithCreatedBy.get(entityName); + return properties == null ? null : properties.orElse(null); + } + + protected Set getUpdatedByPropertyNames(String entityName) { + Optional> properties = entitiesWithUpdatedBy.get(entityName); + return properties == null ? null : properties.orElse(null); + } + protected void storeDateCreatedAndLastUpdatedInfo(PersistentEntity persistentEntity) { if (persistentEntity.isInitialized()) { ClassMapping classMapping = persistentEntity.getMapping(); @@ -183,6 +250,10 @@ protected void storeDateCreatedAndLastUpdatedInfo(PersistentEntity persistentEnt storeTimestampAvailability(entitiesWithDateCreated, persistentEntity, property); } else if (timestampType == AutoTimestampType.UPDATED) { storeTimestampAvailability(entitiesWithLastUpdated, persistentEntity, property); + } else if (timestampType == AutoTimestampType.CREATED_BY) { + storeAuditorAvailability(entitiesWithCreatedBy, persistentEntity, property); + } else if (timestampType == AutoTimestampType.UPDATED_BY) { + storeAuditorAvailability(entitiesWithUpdatedBy, persistentEntity, property); } } } @@ -204,6 +275,18 @@ protected void storeTimestampAvailability(Map>> tim } } + protected void storeAuditorAvailability(Map>> auditorAvailabilityMap, PersistentEntity persistentEntity, PersistentProperty property) { + if (property != null) { + Optional> auditorProperties = auditorAvailabilityMap.computeIfAbsent(persistentEntity.getName(), k -> Optional.of(new HashSet<>())); + if (auditorProperties.isPresent()) { + auditorProperties.get().add(property.getName()); + } + else { + throw new IllegalStateException("Auditor properties for entity [" + persistentEntity.getName() + "] have been disabled. Cannot add property [" + property.getName() + "]"); + } + } + } + public void persistentEntityAdded(PersistentEntity entity) { storeDateCreatedAndLastUpdatedInfo(entity); } @@ -216,6 +299,14 @@ public void setTimestampProvider(TimestampProvider timestampProvider) { this.timestampProvider = timestampProvider; } + public AuditorAware getAuditorAware() { + return auditorAware; + } + + public void setAuditorAware(AuditorAware auditorAware) { + this.auditorAware = auditorAware; + } + private void processAllEntries(final Set>>> entries, final Runnable runnable) { Map>> originalValues = new LinkedHashMap<>(); for (Map.Entry>> entry: entries) { diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/timestamp/AuditorAware.java b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/timestamp/AuditorAware.java new file mode 100644 index 00000000000..db5332aea62 --- /dev/null +++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/timestamp/AuditorAware.java @@ -0,0 +1,58 @@ +/* + * 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.datastore.gorm.timestamp; + +import java.util.Optional; + +/** + * Interface for components that are aware of the application's current auditor. + * This will be used to populate @CreatedBy and @LastModifiedBy annotated fields + * in domain objects. + * + *

        Implementations should be registered as Spring beans. The type parameter + * should match the type of the auditor field in your domain classes (e.g., User, + * Long, String, etc.).

        + * + *

        Example implementation:

        + *
        + * @Component
        + * public class SpringSecurityAuditorAware implements AuditorAware<String> {
        + *     @Override
        + *     public Optional<String> getCurrentAuditor() {
        + *         return Optional.ofNullable(SecurityContextHolder.getContext())
        + *                 .map(SecurityContext::getAuthentication)
        + *                 .filter(Authentication::isAuthenticated)
        + *                 .map(Authentication::getName);
        + *     }
        + * }
        + * 
        + * + * @param the type of the auditor (e.g., User, Long, String) + * @author Scott Murphy Heiberg + * @since 7.0 + */ +public interface AuditorAware { + + /** + * Returns the current auditor of the application. + * + * @return the current auditor, or {@link Optional#empty()} if no auditor is available + */ + Optional getCurrentAuditor(); +} diff --git a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/config/Property.groovy b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/config/Property.groovy index d6b3841090a..c012d06bb0e 100644 --- a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/config/Property.groovy +++ b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/config/Property.groovy @@ -154,6 +154,14 @@ class Property implements Cloneable { * Property has @LastModifiedDate annotation or @AutoTimestamp(UPDATED) */ UPDATED, + /** + * Property has @CreatedBy annotation - populated with current auditor on insert + */ + CREATED_BY, + /** + * Property has @LastModifiedBy annotation - populated with current auditor on insert and update + */ + UPDATED_BY, /** * Property has no auto-timestamp annotation */ diff --git a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/model/AutoTimestampUtils.java b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/model/AutoTimestampUtils.java index d1d97b8e1f2..5df1893f952 100644 --- a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/model/AutoTimestampUtils.java +++ b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/model/AutoTimestampUtils.java @@ -27,9 +27,18 @@ import org.grails.datastore.mapping.config.Property.AutoTimestampType; /** - * Utility class for detecting and caching auto-timestamp annotations on domain properties. + * Utility class for detecting and caching auto-timestamp and auditing annotations on domain properties. * This avoids repeated reflection calls by storing the annotation type in the Property metadata. * + *

        Supports the following annotations (both GORM and Spring Data variants):

        + *
          + *
        • @CreatedDate / @grails.gorm.annotation.CreatedDate - automatically set on insert
        • + *
        • @LastModifiedDate / @grails.gorm.annotation.LastModifiedDate - automatically set on insert and update
        • + *
        • @CreatedBy / @grails.gorm.annotation.CreatedBy - automatically populated with current auditor on insert
        • + *
        • @LastModifiedBy / @grails.gorm.annotation.LastModifiedBy - automatically populated with current auditor on insert and update
        • + *
        • @AutoTimestamp - GORM-specific annotation for backwards compatibility
        • + *
        + * *

        Caching is automatically disabled in development mode ({@link Environment#isDevelopmentMode()}) * to ensure annotation changes are picked up during class reloading.

        * @@ -41,9 +50,13 @@ public class AutoTimestampUtils { private static final String CREATED_DATE_ANNOTATION = "grails.gorm.annotation.CreatedDate"; private static final String LAST_MODIFIED_DATE_ANNOTATION = "grails.gorm.annotation.LastModifiedDate"; private static final String AUTO_TIMESTAMP_ANNOTATION = "grails.gorm.annotation.AutoTimestamp"; + private static final String CREATED_BY_ANNOTATION = "grails.gorm.annotation.CreatedBy"; + private static final String LAST_MODIFIED_BY_ANNOTATION = "grails.gorm.annotation.LastModifiedBy"; private static final String CREATED_DATE_SPRING_ANNOTATION = "org.springframework.data.annotation.CreatedDate"; private static final String LAST_MODIFIED_DATE_SPRING_ANNOTATION = "org.springframework.data.annotation.LastModifiedDate"; + private static final String CREATED_BY_SPRING_ANNOTATION = "org.springframework.data.annotation.CreatedBy"; + private static final String LAST_MODIFIED_BY_SPRING_ANNOTATION = "org.springframework.data.annotation.LastModifiedBy"; /** * Gets the auto-timestamp type for a persistent property, using cached metadata when not in development mode. @@ -101,6 +114,12 @@ private static AutoTimestampType detectAutoTimestampType(PersistentProperty p } else if (LAST_MODIFIED_DATE_ANNOTATION.equals(annotationName) || LAST_MODIFIED_DATE_SPRING_ANNOTATION.equals(annotationName)) { return AutoTimestampType.UPDATED; + } else if (CREATED_BY_ANNOTATION.equals(annotationName) || + CREATED_BY_SPRING_ANNOTATION.equals(annotationName)) { + return AutoTimestampType.CREATED_BY; + } else if (LAST_MODIFIED_BY_ANNOTATION.equals(annotationName) || + LAST_MODIFIED_BY_SPRING_ANNOTATION.equals(annotationName)) { + return AutoTimestampType.UPDATED_BY; } else if (AUTO_TIMESTAMP_ANNOTATION.equals(annotationName)) { // For @AutoTimestamp, check the EventType value try { @@ -131,11 +150,11 @@ private static AutoTimestampType detectAutoTimestampType(PersistentProperty p } /** - * Checks if a property has any auto-timestamp annotation. + * Checks if a property has any auto-timestamp or auditing annotation. * * @param persistentProperty The persistent property to check - * @return true if the property has a GORM @CreatedDate, @LastModifiedDate, @AutoTimestamp annotation, - * or Spring Data @CreatedDate, @LastModifiedDate annotation + * @return true if the property has any supported annotation (@CreatedDate, @LastModifiedDate, + * @CreatedBy, @LastModifiedBy, or @AutoTimestamp) from either GORM or Spring Data */ public static boolean hasAutoTimestampAnnotation(PersistentProperty persistentProperty) { return persistentProperty != null && getAutoTimestampType(persistentProperty) != AutoTimestampType.NONE; @@ -160,4 +179,24 @@ public static boolean isCreatedTimestamp(PersistentProperty persistentPropert public static boolean isUpdatedTimestamp(PersistentProperty persistentProperty) { return getAutoTimestampType(persistentProperty) == AutoTimestampType.UPDATED; } + + /** + * Checks if a property has a @CreatedBy annotation (GORM or Spring Data). + * + * @param persistentProperty The persistent property to check + * @return true if the property represents a creation auditor + */ + public static boolean isCreatedBy(PersistentProperty persistentProperty) { + return getAutoTimestampType(persistentProperty) == AutoTimestampType.CREATED_BY; + } + + /** + * Checks if a property has a @LastModifiedBy annotation (GORM or Spring Data). + * + * @param persistentProperty The persistent property to check + * @return true if the property represents an update auditor + */ + public static boolean isUpdatedBy(PersistentProperty persistentProperty) { + return getAutoTimestampType(persistentProperty) == AutoTimestampType.UPDATED_BY; + } }