AutoGeneratedKeyHelper.java

/*
 * Copyright © 2018 spring-data-dynamodb (https://github.com/prasanna0586/spring-data-dynamodb)
 *
 * Licensed 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
 *
 *     http://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.socialsignin.spring.data.dynamodb.core;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.lang.NonNull;
import org.springframework.lang.Nullable;

import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.Date;
import java.util.UUID;

/**
 * Helper class for auto-generating key values for entities in SDK_V1_COMPATIBLE mode.
 *
 * <p>This class provides backward compatibility support for SDK v1's auto-generation annotations:
 * <ul>
 * <li>{@code @DynamoDBAutoGeneratedKey} - Generates UUID for String fields</li>
 * <li>{@code @DynamoDBAutoGeneratedTimestamp} - Generates timestamp for Date/Long fields</li>
 * </ul>
 *
 * <p><b>Important:</b> This helper is only used when {@link MarshallingMode#SDK_V1_COMPATIBLE} is set.
 * For {@link MarshallingMode#SDK_V2_NATIVE}, users should configure {@code AutoGeneratedUuidExtension}
 * and use SDK v2's {@code @DynamoDbAutoGeneratedUuid} annotation.
 * @since 7.0.0
 */
public class AutoGeneratedKeyHelper {

    private static final Logger LOGGER = LoggerFactory.getLogger(AutoGeneratedKeyHelper.class);

    /**
     * Private constructor to prevent instantiation of this utility class.
     * All methods are static and should be accessed directly on the class.
     */
    private AutoGeneratedKeyHelper() {
    }

    private static final String DYNAMODB_AUTO_GENERATED_KEY = "org.socialsignin.spring.data.dynamodb.annotation.DynamoDBAutoGeneratedKey";
    private static final String DYNAMODB_AUTO_GENERATED_TIMESTAMP = "org.socialsignin.spring.data.dynamodb.annotation.DynamoDBAutoGeneratedTimestamp";

    /**
     * Processes an entity and auto-generates values for fields annotated with SDK v1 auto-generation annotations.
     *
     * <p>This method should only be called when {@link MarshallingMode#SDK_V1_COMPATIBLE} is active.
     * @param entity The entity to process
     * @param <T>    The entity type
     */
    public static <T> void processAutoGeneratedKeys(@Nullable T entity) {
        if (entity == null) {
            return;
        }

        Class<?> entityClass = entity.getClass();

        // Process fields
        processFields(entity, entityClass);

        // Process methods (getters/setters)
        processMethods(entity, entityClass);
    }

    private static <T> void processFields(T entity, @NonNull Class<?> clazz) {
        for (Field field : clazz.getDeclaredFields()) {
            try {
                // Check for @DynamoDBAutoGeneratedKey (SDK v1)
                if (hasAnnotation(field, DYNAMODB_AUTO_GENERATED_KEY)) {
                    processUuidField(entity, field);
                }
                // Check for @DynamoDBAutoGeneratedTimestamp (SDK v1)
                else if (hasAnnotation(field, DYNAMODB_AUTO_GENERATED_TIMESTAMP)) {
                    processTimestampField(entity, field);
                }
            } catch (Exception e) {
                LOGGER.warn("Failed to process auto-generated field: {}", field.getName(), e);
            }
        }

        // Process parent class fields
        if (clazz.getSuperclass() != null && clazz.getSuperclass() != Object.class) {
            processFields(entity, clazz.getSuperclass());
        }
    }

    private static <T> void processMethods(@NonNull T entity, @NonNull Class<?> clazz) {
        for (Method method : clazz.getMethods()) {
            try {
                // Only process getters
                if (!isGetter(method)) {
                    continue;
                }

                // Check for SDK v1 annotations on getter
                if (hasAnnotation(method, DYNAMODB_AUTO_GENERATED_KEY)) {
                    processUuidMethod(entity, method);
                } else if (hasAnnotation(method, DYNAMODB_AUTO_GENERATED_TIMESTAMP)) {
                    processTimestampMethod(entity, method);
                }
            } catch (Exception e) {
                LOGGER.warn("Failed to process auto-generated method: {}", method.getName(), e);
            }
        }
    }

    private static <T> void processUuidField(T entity, @NonNull Field field) throws IllegalAccessException {
        field.setAccessible(true);
        Object currentValue = field.get(entity);

        // Only generate if field is null or empty
        if (currentValue == null || (currentValue instanceof String && ((String) currentValue).isEmpty())) {
            if (field.getType() == String.class) {
                String uuid = UUID.randomUUID().toString();
                field.set(entity, uuid);
                LOGGER.debug("Generated UUID for field {}: {}", field.getName(), uuid);
            } else {
                LOGGER.warn("Field {} has @DynamoDBAutoGeneratedKey annotation but is not of type String", field.getName());
            }
        }
    }

    private static <T> void processTimestampField(T entity, @NonNull Field field) throws IllegalAccessException {
        field.setAccessible(true);
        Object currentValue = field.get(entity);

        // Only generate if field is null
        if (currentValue == null) {
            if (field.getType() == Date.class) {
                field.set(entity, new Date());
                LOGGER.debug("Generated Date timestamp for field {}", field.getName());
            } else if (field.getType() == Long.class || field.getType() == long.class) {
                field.set(entity, System.currentTimeMillis());
                LOGGER.debug("Generated Long timestamp for field {}", field.getName());
            } else {
                LOGGER.warn("Field {} has @DynamoDBAutoGeneratedTimestamp annotation but is not of type Date or Long", field.getName());
            }
        }
    }

    private static <T> void processUuidMethod(@NonNull T entity, @NonNull Method getter) throws ReflectiveOperationException {
        Object currentValue = getter.invoke(entity);

        // Only generate if value is null or empty
        if (currentValue == null || (currentValue instanceof String && ((String) currentValue).isEmpty())) {
            if (getter.getReturnType() == String.class) {
                Method setter = findSetter(entity.getClass(), getter);
                if (setter != null) {
                    String uuid = UUID.randomUUID().toString();
                    setter.invoke(entity, uuid);
                    LOGGER.debug("Generated UUID for property {}: {}", getPropertyName(getter), uuid);
                } else {
                    LOGGER.warn("No setter found for getter {} in  processUuidMethod", getter.getName());
                }
            }
        }
    }

    private static <T> void processTimestampMethod(@NonNull T entity, @NonNull Method getter) throws ReflectiveOperationException {
        Object currentValue = getter.invoke(entity);

        // Only generate if value is null
        if (currentValue == null) {
            Method setter = findSetter(entity.getClass(), getter);
            if (setter != null) {
                if (getter.getReturnType() == Date.class) {
                    setter.invoke(entity, new Date());
                    LOGGER.debug("Generated Date timestamp for property {}", getPropertyName(getter));
                } else if (getter.getReturnType() == Long.class || getter.getReturnType() == long.class) {
                    setter.invoke(entity, System.currentTimeMillis());
                    LOGGER.debug("Generated Long timestamp for property {}", getPropertyName(getter));
                }
            } else {
                LOGGER.warn("No setter found for getter {} in  processTimestampMethod", getter.getName());
            }
        }
    }

    private static boolean isGetter(@NonNull Method method) {
        String name = method.getName();
        return (name.startsWith("get") || name.startsWith("is")) &&
               method.getParameterCount() == 0 &&
               method.getReturnType() != void.class &&
               method.getReturnType() != Void.class;
    }

    @Nullable
    private static Method findSetter(@NonNull Class<?> clazz, @NonNull Method getter) {
        String propertyName = getPropertyName(getter);
        String setterName = "set" + Character.toUpperCase(propertyName.charAt(0)) + propertyName.substring(1);

        try {
            return clazz.getMethod(setterName, getter.getReturnType());
        } catch (NoSuchMethodException e) {
            return null;
        }
    }

    @NonNull
    private static String getPropertyName(@NonNull Method getter) {
        String methodName = getter.getName();
        if (methodName.startsWith("get") && methodName.length() > 3) {
            return Character.toLowerCase(methodName.charAt(3)) + methodName.substring(4);
        } else if (methodName.startsWith("is") && methodName.length() > 2) {
            return Character.toLowerCase(methodName.charAt(2)) + methodName.substring(3);
        }
        return methodName;
    }

    private static boolean hasAnnotation(@NonNull Field field, String annotationClassName) {
        return java.util.Arrays.stream(field.getAnnotations())
            .anyMatch(ann -> ann.annotationType().getName().equals(annotationClassName));
    }

    private static boolean hasAnnotation(@NonNull Method method, String annotationClassName) {
        return java.util.Arrays.stream(method.getAnnotations())
            .anyMatch(ann -> ann.annotationType().getName().equals(annotationClassName));
    }
}