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 GrailsDataTckSpecExample 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/CreatedDate.java b/grails-datamapping-core/src/main/groovy/grails/gorm/annotation/CreatedDate.java
new file mode 100644
index 00000000000..5e50fec9abb
--- /dev/null
+++ b/grails-datamapping-core/src/main/groovy/grails/gorm/annotation/CreatedDate.java
@@ -0,0 +1,36 @@
+/*
+ * 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 apply auto-timestamping on a field
+ * upon gorm insert events. This is an alias for @AutoTimestamp(EventType.CREATED).
+ *
+ * @author Scott Murphy Heiberg
+ * @since 7.0
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target({ElementType.FIELD})
+public @interface CreatedDate {
+}
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/grails/gorm/annotation/LastModifiedDate.java b/grails-datamapping-core/src/main/groovy/grails/gorm/annotation/LastModifiedDate.java
new file mode 100644
index 00000000000..c30918d9acb
--- /dev/null
+++ b/grails-datamapping-core/src/main/groovy/grails/gorm/annotation/LastModifiedDate.java
@@ -0,0 +1,36 @@
+/*
+ * 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 apply auto-timestamping on a field
+ * upon gorm insert and update events. This is an alias for @AutoTimestamp(EventType.UPDATED).
+ *
+ * @author Scott Murphy Heiberg
+ * @since 7.0
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target({ElementType.FIELD})
+public @interface LastModifiedDate {
+}
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 8da88a0b34e..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
@@ -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;
@@ -30,13 +29,17 @@
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 grails.gorm.annotation.AutoTimestamp;
+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;
+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;
@@ -45,18 +48,19 @@
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;
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,16 +224,14 @@ 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 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) {
@@ -190,14 +245,15 @@ 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());
- if (field != null && 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);
+ } else if (timestampType == AutoTimestampType.CREATED_BY) {
+ storeAuditorAvailability(entitiesWithCreatedBy, persistentEntity, property);
+ } else if (timestampType == AutoTimestampType.UPDATED_BY) {
+ storeAuditorAvailability(entitiesWithUpdatedBy, persistentEntity, property);
}
}
}
@@ -219,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);
}
@@ -231,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-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..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
@@ -48,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;
@@ -305,6 +306,11 @@ protected boolean isConstrainableProperty(PersistentProperty persistentProperty,
return NameUtils.isNotConfigurational(propertyName);
}
else {
+ // Check if property has @CreatedDate or @LastModifiedDate annotations
+ if (AutoTimestampUtils.hasAutoTimestampAnnotation(persistentProperty)) {
+ return false;
+ }
+
return !propertyName.equals(GormProperties.VERSION) &&
!propertyName.equals(GormProperties.DATE_CREATED) &&
!propertyName.equals(GormProperties.LAST_UPDATED) &&
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..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
@@ -137,6 +137,36 @@ 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 @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
+ */
+ NONE
+ }
protected void setUniquenessGroup(List uniquenessGroup) {
this.uniquenessGroup = uniquenessGroup
@@ -161,6 +191,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..5df1893f952
--- /dev/null
+++ b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/model/AutoTimestampUtils.java
@@ -0,0 +1,202 @@
+/*
+ * 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 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.
+ *
+ * @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";
+ 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.
+ *
+ * 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) ||
+ CREATED_DATE_SPRING_ANNOTATION.equals(annotationName)) {
+ return AutoTimestampType.CREATED;
+ } 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 {
+ 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 or auditing annotation.
+ *
+ * @param persistentProperty The persistent property to check
+ * @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;
+ }
+
+ /**
+ * 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
+ */
+ public static boolean isCreatedTimestamp(PersistentProperty> persistentProperty) {
+ return getAutoTimestampType(persistentProperty) == AutoTimestampType.CREATED;
+ }
+
+ /**
+ * 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
+ */
+ 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;
+ }
+}
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..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
@@ -27,6 +27,7 @@ import org.springframework.beans.factory.annotation.Autowired
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
@@ -107,13 +108,83 @@ 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 (AutoTimestampUtils.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
}
/**
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..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
@@ -64,6 +64,15 @@ class GrailsExtension {
*/
boolean importJavaTime = false
+ /**
+ * 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 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 2d831789e9b..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
@@ -226,17 +226,50 @@ 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 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'
+ }
+ 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}
}
}
- '''
+ """
}
}