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} } } - ''' + """ } }