diff --git a/src/main/java/com/aerospike/mapper/annotations/AerospikeGeneration.java b/src/main/java/com/aerospike/mapper/annotations/AerospikeGeneration.java new file mode 100644 index 0000000..472c165 --- /dev/null +++ b/src/main/java/com/aerospike/mapper/annotations/AerospikeGeneration.java @@ -0,0 +1,38 @@ +package com.aerospike.mapper.annotations; + +import com.aerospike.client.policy.GenerationPolicy; +import com.aerospike.client.policy.WritePolicy; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Mark a field or property to be used for optimistic concurrency control using Aerospike's generation field. + *

+ * The field or property must be of Integer or int type. When reading an object which has a field marked + * with @AerospikeGeneration, the returned record's generation field will be mapped into the @AerospikeGeneration field. + * When writing the record, if the @AerospikeGeneration field is non-zero, the generation will be set in the + * {@link WritePolicy#generation} field and the {@link WritePolicy#generationPolicy} will be set to + * {@link GenerationPolicy#EXPECT_GEN_EQUAL}. + *

+ * Example usage: + *

+ * @AerospikeRecord(namespace = "test", set = "account")
+ * public class Account {
+ *     @AerospikeKey
+ *     private int id;
+ *     @AerospikeBin
+ *     private String name;
+ *     @AerospikeGeneration
+ *     private int generation;
+ * }
+ * 
+ * + * @author timfaulkes + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.FIELD, ElementType.METHOD}) +public @interface AerospikeGeneration { +} diff --git a/src/main/java/com/aerospike/mapper/tools/AeroMapper.java b/src/main/java/com/aerospike/mapper/tools/AeroMapper.java index 3d570d2..d7d27bf 100644 --- a/src/main/java/com/aerospike/mapper/tools/AeroMapper.java +++ b/src/main/java/com/aerospike/mapper/tools/AeroMapper.java @@ -9,6 +9,7 @@ import com.aerospike.client.Record; import com.aerospike.client.Value; import com.aerospike.client.policy.BatchPolicy; +import com.aerospike.client.policy.GenerationPolicy; import com.aerospike.client.policy.Policy; import com.aerospike.client.policy.QueryPolicy; import com.aerospike.client.policy.RecordExistsAction; @@ -121,6 +122,14 @@ private WritePolicy generateWritePolicyFromObject(T object) { if (sendKey != null) { writePolicy.sendKey = sendKey; } + + // #181 Handle @AerospikeGeneration field for optimistic concurrency control + Integer generationValue = entry.getGenerationValue(object); + if (generationValue != null && generationValue > 0) { + writePolicy.generation = generationValue; + writePolicy.generationPolicy = GenerationPolicy.EXPECT_GEN_EQUAL; + } + return writePolicy; } diff --git a/src/main/java/com/aerospike/mapper/tools/ClassCacheEntry.java b/src/main/java/com/aerospike/mapper/tools/ClassCacheEntry.java index ee5b894..b8ca94a 100644 --- a/src/main/java/com/aerospike/mapper/tools/ClassCacheEntry.java +++ b/src/main/java/com/aerospike/mapper/tools/ClassCacheEntry.java @@ -39,6 +39,7 @@ import com.aerospike.mapper.annotations.AerospikeRecord; import com.aerospike.mapper.annotations.AerospikeSetter; import com.aerospike.mapper.annotations.ParamFrom; +import com.aerospike.mapper.annotations.AerospikeGeneration; import com.aerospike.mapper.exceptions.NotAnnotatedClass; import com.aerospike.mapper.tools.configuration.BinConfig; import com.aerospike.mapper.tools.configuration.ClassConfig; @@ -65,6 +66,7 @@ public class ClassCacheEntry { private ValueType key; private String keyName = null; private boolean keyAsBin = true; + private ValueType generationField = null; private final TreeMap values = new TreeMap<>(); private ClassCacheEntry superClazz; private int binCount; @@ -86,18 +88,17 @@ public class ClassCacheEntry { private String factoryClass; private enum FactoryMethodType { - NO_PARAMS, - CLASS, - MAP, - CLASS_MAP + NO_PARAMS, CLASS, MAP, CLASS_MAP } private Method factoryConstructorMethod; private FactoryMethodType factoryConstructorType; /** - * When there are subclasses, we need to store the type information to be able to re-create an instance of the same type. As the - * class name can be verbose, we provide the ability to set a string representing the class name. This string must be unique for all classes. + * When there are subclasses, we need to store the type information to be able + * to re-create an instance of the same type. As the class name can be verbose, + * we provide the ability to set a string representing the class name. This + * string must be unique for all classes. */ private String shortenedClassName; private boolean isChildClass = false; @@ -106,9 +107,8 @@ private enum FactoryMethodType { // package visibility only. ClassCacheEntry(@NotNull Class clazz, IBaseAeroMapper mapper, ClassConfig config, boolean requireRecord, - @NotNull Policy readPolicy, @NotNull WritePolicy writePolicy, - @NotNull BatchPolicy batchPolicy, @NotNull QueryPolicy queryPolicy, - @NotNull ScanPolicy scanPolicy) { + @NotNull Policy readPolicy, @NotNull WritePolicy writePolicy, @NotNull BatchPolicy batchPolicy, + @NotNull QueryPolicy queryPolicy, @NotNull ScanPolicy scanPolicy) { this.clazz = clazz; this.mapper = mapper; this.classConfig = config; @@ -120,8 +120,8 @@ private enum FactoryMethodType { AerospikeRecord recordDescription = clazz.getAnnotation(AerospikeRecord.class); if (requireRecord && recordDescription == null && config == null) { - throw new NotAnnotatedClass(String.format("Class %s is not augmented by the @AerospikeRecord annotation", - clazz.getName())); + throw new NotAnnotatedClass( + String.format("Class %s is not augmented by the @AerospikeRecord annotation", clazz.getName())); } else if (recordDescription != null) { this.setPropertiesFromAerospikeRecord(recordDescription); } @@ -136,7 +136,8 @@ public ClassCacheEntry construct() { if (this.namespace == null || this.namespace.isEmpty()) { List aerospikeInterfaceRecords = this.loadAerospikeRecordsFromInterfaces(this.clazz); - for (int i = 0; (this.namespace == null || this.namespace.isEmpty()) && i < aerospikeInterfaceRecords.size(); i++) { + for (int i = 0; (this.namespace == null || this.namespace.isEmpty()) + && i < aerospikeInterfaceRecords.size(); i++) { this.setPropertiesFromAerospikeRecord(aerospikeInterfaceRecords.get(i)); } } @@ -249,7 +250,7 @@ private List loadAerospikeRecordsFromInterfaces(Class clazz) } return results; } - + private void setPropertiesFromAerospikeRecord(AerospikeRecord recordDescription) { this.namespace = ParserUtils.getInstance().get(recordDescription.namespace()); this.setName = ParserUtils.getInstance().get(recordDescription.set()); @@ -265,12 +266,15 @@ private void setPropertiesFromAerospikeRecord(AerospikeRecord recordDescription) private void checkRecordSettingsAgainstSuperClasses() { if (!StringUtils.isBlank(this.namespace) && !StringUtils.isBlank(this.setName)) { - // This class defines its own namespace + set, it is only a child class if its closest named superclass is the same as ours. + // This class defines its own namespace + set, it is only a child class if its + // closest named superclass is the same as ours. this.isChildClass = false; ClassCacheEntry thisEntry = this.superClazz; while (thisEntry != null) { - if ((!StringUtils.isBlank(thisEntry.getNamespace())) && (!StringUtils.isBlank(thisEntry.getSetName()))) { - if (this.namespace.equals(thisEntry.getNamespace()) && this.setName.equals(thisEntry.getSetName())) { + if ((!StringUtils.isBlank(thisEntry.getNamespace())) + && (!StringUtils.isBlank(thisEntry.getSetName()))) { + if (this.namespace.equals(thisEntry.getNamespace()) + && this.setName.equals(thisEntry.getSetName())) { this.isChildClass = true; } break; @@ -278,11 +282,13 @@ private void checkRecordSettingsAgainstSuperClasses() { thisEntry = thisEntry.superClazz; } } else { - // Otherwise this is a child class, find the set and namespace from the closest highest class + // Otherwise this is a child class, find the set and namespace from the closest + // highest class this.isChildClass = true; ClassCacheEntry thisEntry = this.superClazz; while (thisEntry != null) { - if ((!StringUtils.isBlank(thisEntry.getNamespace())) && (!StringUtils.isBlank(thisEntry.getSetName()))) { + if ((!StringUtils.isBlank(thisEntry.getNamespace())) + && (!StringUtils.isBlank(thisEntry.getSetName()))) { this.namespace = thisEntry.getNamespace(); this.setName = thisEntry.getSetName(); break; @@ -389,8 +395,8 @@ private void formOrdinalsFromValues() { // The ordinals need to be valued from 1.. for (int i = 1; i <= ordinals.size(); i++) { if (!ordinals.containsKey(i)) { - throw new AerospikeException(String.format("Class %s has %d values specifying ordinals." + - " These should be 1..%d, but %d is missing", + throw new AerospikeException(String.format( + "Class %s has %d values specifying ordinals." + " These should be 1..%d, but %d is missing", clazz.getSimpleName(), ordinals.size(), ordinals.size(), i)); } } @@ -398,15 +404,18 @@ private void formOrdinalsFromValues() { } private boolean validateFactoryMethod(Method method) { - if ((method.getModifiers() & Modifier.STATIC) == Modifier.STATIC && this.factoryMethod.equals(method.getName())) { + if ((method.getModifiers() & Modifier.STATIC) == Modifier.STATIC + && this.factoryMethod.equals(method.getName())) { Parameter[] params = method.getParameters(); if (params.length == 0) { return true; } - if (params.length == 1 && ((Class.class.isAssignableFrom(params[0].getType())) || Map.class.isAssignableFrom(params[0].getType()))) { + if (params.length == 1 && ((Class.class.isAssignableFrom(params[0].getType())) + || Map.class.isAssignableFrom(params[0].getType()))) { return true; } - if (params.length == 2 && Class.class.isAssignableFrom(params[0].getType()) && Map.class.isAssignableFrom(params[1].getType())) { + if (params.length == 2 && Class.class.isAssignableFrom(params[0].getType()) + && Map.class.isAssignableFrom(params[1].getType())) { return true; } } @@ -417,12 +426,14 @@ private Method findConstructorFactoryMethod() { if (!StringUtils.isBlank(this.factoryClass) || !StringUtils.isBlank(this.factoryMethod)) { // Both must be specified if (StringUtils.isBlank(this.factoryClass)) { - throw new AerospikeException(String.format("Missing factoryClass definition when factoryMethod is specified on class %s", - clazz.getSimpleName())); + throw new AerospikeException( + String.format("Missing factoryClass definition when factoryMethod is specified on class %s", + clazz.getSimpleName())); } if (StringUtils.isBlank(this.factoryClass)) { - throw new AerospikeException(String.format("Missing factoryMethod definition when factoryClass is specified on class %s", - clazz.getSimpleName())); + throw new AerospikeException( + String.format("Missing factoryMethod definition when factoryClass is specified on class %s", + clazz.getSimpleName())); } // Load the class and check for the method try { @@ -431,20 +442,21 @@ private Method findConstructorFactoryMethod() { for (Method method : factoryClazzType.getDeclaredMethods()) { if (validateFactoryMethod(method)) { if (foundMethod != null) { - throw new AerospikeException(String.format("Factory Class %s defines at least 2 valid " + - "factory methods (%s, %s) as a factory for class %s", + throw new AerospikeException(String.format( + "Factory Class %s defines at least 2 valid " + + "factory methods (%s, %s) as a factory for class %s", this.factoryClass, foundMethod, method, this.clazz.getSimpleName())); } foundMethod = method; } } if (foundMethod == null) { - throw new AerospikeException(String.format("Class %s specified a factory class of %s and a factory" + - " method of %s, but no valid method with that name exists on the class. A valid" + - " method must be static, can take no parameters, a single Class parameter, a single" + - " Map parameter, or a Class and a Map parameter, and must return an object which is" + - " either an ancestor, descendant or equal to %s", - clazz.getSimpleName(), this.factoryClass, this.factoryMethod, clazz.getSimpleName())); + throw new AerospikeException(String.format("Class %s specified a factory class of %s and a factory" + + " method of %s, but no valid method with that name exists on the class. A valid" + + " method must be static, can take no parameters, a single Class parameter, a single" + + " Map parameter, or a Class and a Map parameter, and must return an object which is" + + " either an ancestor, descendant or equal to %s", clazz.getSimpleName(), + this.factoryClass, this.factoryMethod, clazz.getSimpleName())); } return foundMethod; } catch (ClassNotFoundException cnfe) { @@ -456,8 +468,9 @@ private Method findConstructorFactoryMethod() { } /** - * Set up the details of the constructor factory method. The method must be returned from the - * findConstructorFactoryMethod above to ensure it is valid. + * Set up the details of the constructor factory method. The method must be + * returned from the findConstructorFactoryMethod above to ensure + * it is valid. * * @param method The factory method to set. */ @@ -480,8 +493,8 @@ private void setConstructorFactoryMethod(Method method) { private void findConstructor() { Constructor[] constructors = clazz.getDeclaredConstructors(); if (constructors.length == 0) { - throw new AerospikeException(String.format("Class %s has no constructors and hence cannot be mapped to Aerospike", - clazz.getSimpleName())); + throw new AerospikeException(String.format( + "Class %s has no constructors and hence cannot be mapped to Aerospike", clazz.getSimpleName())); } Constructor desiredConstructor = null; Constructor noArgConstructor = null; @@ -495,9 +508,9 @@ private void findConstructor() { AerospikeConstructor aerospikeConstructor = thisConstructor.getAnnotation(AerospikeConstructor.class); if (aerospikeConstructor != null) { if (desiredConstructor != null) { - throw new AerospikeException(String.format("Class %s" + - " has multiple constructors annotated with @AerospikeConstructor." + - " Only one constructor can be so annotated.", clazz.getSimpleName())); + throw new AerospikeException(String + .format("Class %s" + " has multiple constructors annotated with @AerospikeConstructor." + + " Only one constructor can be so annotated.", clazz.getSimpleName())); } else { desiredConstructor = thisConstructor; } @@ -510,8 +523,8 @@ private void findConstructor() { } if (desiredConstructor == null) { - throw new AerospikeException(String.format("Class %s has neither a no-arg constructor, " + - "nor a constructor annotated with @AerospikeConstructor so cannot be mapped to Aerospike.", + throw new AerospikeException(String.format("Class %s has neither a no-arg constructor, " + + "nor a constructor annotated with @AerospikeConstructor so cannot be mapped to Aerospike.", clazz.getSimpleName())); } @@ -527,7 +540,8 @@ private void findConstructor() { } int count = 0; - // Parameters can be either specified by their name (which requires the use of the javac -parameters flag), + // Parameters can be either specified by their name (which requires the use of + // the javac -parameters flag), // or through an @ParamFrom annotation. for (Parameter thisParam : params) { count++; @@ -543,21 +557,23 @@ private void findConstructor() { // Validate that we have such a value if (!allValues.containsKey(binName)) { String valueList = String.join(",", values.keySet()); - String message = String.format("Class %s has a preferred constructor of %s. However, parameter %d is " + - "mapped to bin \"%s\" %s which is not one of the values on the class, which are: %s%s", + String message = String.format("Class %s has a preferred constructor of %s. However, parameter %d is " + + "mapped to bin \"%s\" %s which is not one of the values on the class, which are: %s%s", clazz.getSimpleName(), desiredConstructor, count, binName, - isFromAnnotation ? "via the @ParamFrom annotation" : "via the argument name", - valueList, - (!isFromAnnotation && binName.startsWith("arg")) ? ". Did you forget to specify '-parameters' to javac when building?" : ""); + isFromAnnotation ? "via the @ParamFrom annotation" : "via the argument name", valueList, + (!isFromAnnotation && binName.startsWith("arg")) + ? ". Did you forget to specify '-parameters' to javac when building?" + : ""); throw new AerospikeException(message); } Class type = thisParam.getType(); if (!type.isAssignableFrom(allValues.get(binName).getType())) { - throw new AerospikeException(String.format("Class %s has a preferred constructor of" + - " %s. However, parameter %s" + - " is of type %s but assigned from bin \"%s\" of type %s." + - " These types are incompatible.", - clazz.getSimpleName(), desiredConstructor, count, type, binName, values.get(binName).getType())); + throw new AerospikeException(String.format( + "Class %s has a preferred constructor of" + " %s. However, parameter %s" + + " is of type %s but assigned from bin \"%s\" of type %s." + + " These types are incompatible.", + clazz.getSimpleName(), desiredConstructor, count, type, binName, + values.get(binName).getType())); } constructorParamBins[count - 1] = binName; constructorParamDefaults[count - 1] = PrimitiveDefaults.getDefaultValue(thisParam.getType()); @@ -567,31 +583,30 @@ private void findConstructor() { } private PropertyDefinition getOrCreateProperty(String name, Map properties) { - PropertyDefinition thisProperty = properties.get(name); - if (thisProperty == null) { - thisProperty = new PropertyDefinition(name, mapper); - properties.put(name, thisProperty); - } - return thisProperty; + return properties.computeIfAbsent(name, n -> new PropertyDefinition(n, mapper)); } private void loadPropertiesFromClass() { Map properties = new HashMap<>(); - PropertyDefinition keyProperty = null; KeyConfig keyConfig = config != null ? config.getKey() : null; - for (Method thisMethod : clazz.getDeclaredMethods()) { + PropertyDefinition keyProperty = null; + PropertyDefinition generationProperty = null; + + for (Method thisMethod : clazz.getDeclaredMethods()) { String methodName = thisMethod.getName(); BinConfig getterConfig = getBinFromGetter(methodName); BinConfig setterConfig = getBinFromSetter(methodName); boolean isKey = false; - boolean isKeyViaConfig = keyConfig != null && (keyConfig.isGetter(methodName) || keyConfig.isSetter(methodName)); - if (thisMethod.isAnnotationPresent(AerospikeKey.class) || isKeyViaConfig) { + boolean isKeyViaConfig = keyConfig != null + && (keyConfig.isGetter(methodName) || keyConfig.isSetter(methodName)); + if (thisMethod.isAnnotationPresent(AerospikeKey.class) || isKeyViaConfig) { if (keyProperty == null) { keyProperty = new PropertyDefinition("_key_", mapper); } + if (isKeyViaConfig) { if (keyConfig.isGetter(methodName)) { keyProperty.setGetter(thisMethod); @@ -606,11 +621,48 @@ private void loadPropertiesFromClass() { keyProperty.setGetter(thisMethod); } } + isKey = true; } + // Handle @AerospikeGeneration annotation on methods (getters/setters) + if (thisMethod.isAnnotationPresent(AerospikeGeneration.class)) { + if (generationProperty == null) { + generationProperty = new PropertyDefinition("_generation_", mapper); + } + + // For getter methods, validate return type + if (methodName.startsWith("get")) { + Class returnType = thisMethod.getReturnType(); + if (!returnType.equals(Integer.class) && !returnType.equals(int.class)) { + throw new AerospikeException(String.format( + "@AerospikeGeneration getter %s in class %s must return Integer or int type, but returned %s", + methodName, clazz.getName(), returnType.getSimpleName())); + } + generationProperty.setGetter(thisMethod); + } + + // For setter methods, validate parameter type + else if (methodName.startsWith("set")) { + if (thisMethod.getParameterCount() != 1) { + throw new AerospikeException(String.format( + "@AerospikeGeneration setter %s in class %s must accept a single parameter, but %d were found", + methodName, clazz.getName(), thisMethod.getParameterCount())); + } + Class paramType = thisMethod.getParameterTypes()[0]; + if (!paramType.equals(Integer.class) && !paramType.equals(int.class)) { + throw new AerospikeException(String.format( + "@AerospikeGeneration setter %s in class %s must accept Integer or int type, but accepted %s", + methodName, clazz.getName(), paramType.getSimpleName())); + } + generationProperty.setSetter(thisMethod); + } + } + if (thisMethod.isAnnotationPresent(AerospikeGetter.class) || getterConfig != null) { - String getterName = (getterConfig != null) ? getterConfig.getName() : thisMethod.getAnnotation(AerospikeGetter.class).name(); + String getterName = (getterConfig != null) + ? getterConfig.getName() + : thisMethod.getAnnotation(AerospikeGetter.class).name(); String name = ParserUtils.getInstance().get(ParserUtils.getInstance().get(getterName)); PropertyDefinition thisProperty = getOrCreateProperty(name, properties); @@ -621,7 +673,10 @@ private void loadPropertiesFromClass() { } if (thisMethod.isAnnotationPresent(AerospikeSetter.class) || setterConfig != null) { - String setterName = (setterConfig != null) ? setterConfig.getName() : thisMethod.getAnnotation(AerospikeSetter.class).name(); + String setterName = (setterConfig != null) + ? setterConfig.getName() + : thisMethod.getAnnotation(AerospikeSetter.class).name(); + String name = ParserUtils.getInstance().get(ParserUtils.getInstance().get(setterName)); PropertyDefinition thisProperty = getOrCreateProperty(name, properties); thisProperty.setSetter(thisMethod); @@ -629,16 +684,30 @@ private void loadPropertiesFromClass() { } if (keyProperty != null) { - keyProperty.validate(clazz.getName(), config, true); - if (key != null) { throw new AerospikeException(String.format("Class %s cannot have more than one key", clazz.getName())); } + keyProperty.validate(clazz.getName(), config, true); + AnnotatedType annotatedType = new AnnotatedType(config, keyProperty.getGetter()); TypeMapper typeMapper = TypeUtils.getMapper(keyProperty.getType(), annotatedType, this.mapper); this.key = new ValueType.MethodValue(keyProperty, typeMapper, annotatedType); } + + if (generationProperty != null) { + if (generationField != null) { + throw new AerospikeException( + String.format("Class %s cannot have more than one @AerospikeGeneration field", clazz.getName())); + } + + generationProperty.validate(clazz.getName(), config, false); + + AnnotatedType annotatedType = new AnnotatedType(config, generationProperty.getGetter()); + TypeMapper typeMapper = TypeUtils.getMapper(generationProperty.getType(), annotatedType, this.mapper); + this.generationField = new ValueType.MethodValue(generationProperty, typeMapper, annotatedType); + } + for (String thisPropertyName : properties.keySet()) { PropertyDefinition thisProperty = properties.get(thisPropertyName); thisProperty.validate(clazz.getName(), config, false); @@ -656,82 +725,134 @@ private void loadPropertiesFromClass() { } private void loadFieldsFromClass() { - KeyConfig keyConfig = config != null ? config.getKey() : null; - String keyField = keyConfig == null ? null : keyConfig.getField(); for (Field thisField : this.clazz.getDeclaredFields()) { - boolean isKey = false; BinConfig thisBin = getBinFromField(thisField); if (Modifier.isFinal(thisField.getModifiers()) && Modifier.isStatic(thisField.getModifiers())) { - // We cannot map static final fields - continue; + // We cannot map static final fields + continue; } - if (thisField.isAnnotationPresent(AerospikeKey.class) || (!StringUtils.isBlank(keyField) && keyField.equals(thisField.getName()))) { - if (thisField.isAnnotationPresent(AerospikeExclude.class) || (thisBin != null && thisBin.isExclude() != null && thisBin.isExclude())) { - throw new AerospikeException(String.format("Class %s cannot have a field which is both a key and excluded.", - clazz.getName())); - } + boolean isGenerationField = this.handleGenerationField(thisField, thisBin); + if (thisField.isAnnotationPresent(AerospikeExclude.class) + || (thisBin != null && thisBin.isExclude() != null && thisBin.isExclude())) { + // This field should be excluded from being stored in the database. Even keys + // must be stored + continue; + } + if (isGenerationField) { + // Generation fields should never be stored as bins - they map to Aerospike's + // generation metadata + continue; + } - if (key != null) { - throw new AerospikeException(String.format("Class %s cannot have more than one key", - clazz.getName())); - } - AerospikeKey keyAnnotation = thisField.getAnnotation(AerospikeKey.class); - boolean storeAsBin = keyAnnotation == null || keyAnnotation.storeAsBin(); + if (this.mapAll || thisField.isAnnotationPresent(AerospikeBin.class) || thisBin != null) { + // This field needs to be mapped + boolean isKey = handleKeyField(thisField, thisBin); + mapField(thisField, thisBin, isKey); + } + } + } - if (keyConfig != null && keyConfig.getStoreAsBin() != null) { - storeAsBin = keyConfig.getStoreAsBin(); - } + private boolean handleKeyField(Field thisField, BinConfig thisBin) { + KeyConfig keyConfig = config != null ? config.getKey() : null; + String keyField = keyConfig == null ? null : keyConfig.getField(); + if (thisField.isAnnotationPresent(AerospikeKey.class) + || (!StringUtils.isBlank(keyField) && keyField.equals(thisField.getName()))) { + if (thisField.isAnnotationPresent(AerospikeExclude.class) + || (thisBin != null && thisBin.isExclude() != null && thisBin.isExclude())) { + throw new AerospikeException(String + .format("Class %s cannot have a field which is both a key and excluded.", clazz.getName())); + } - if (!storeAsBin && (this.sendKey == null || !this.sendKey)) { - throw new AerospikeException(String.format("Class %s attempts to store primary key information" + - " inside the aerospike key, but sendKey is not true at the record level", clazz.getName())); - } + if (key != null) { + throw new AerospikeException(String.format("Class %s cannot have more than one key", clazz.getName())); + } - AnnotatedType annotatedType = new AnnotatedType(config, thisField); - TypeMapper typeMapper = TypeUtils.getMapper(thisField.getType(), annotatedType, this.mapper); - this.key = new ValueType.FieldValue(thisField, typeMapper, annotatedType); - this.keyAsBin = storeAsBin; - isKey = true; + AerospikeKey keyAnnotation = thisField.getAnnotation(AerospikeKey.class); + boolean storeAsBin = keyAnnotation == null || keyAnnotation.storeAsBin(); + + if (keyConfig != null && keyConfig.getStoreAsBin() != null) { + storeAsBin = keyConfig.getStoreAsBin(); } - if (thisField.isAnnotationPresent(AerospikeExclude.class) || (thisBin != null && thisBin.isExclude() != null && thisBin.isExclude())) { - // This field should be excluded from being stored in the database. Even keys must be stored - continue; + if (!storeAsBin && (this.sendKey == null || !this.sendKey)) { + throw new AerospikeException(String.format( + "Class %s attempts to store primary key information" + + " inside the aerospike key, but sendKey is not true at the record level", + clazz.getName())); } - if (this.mapAll || thisField.isAnnotationPresent(AerospikeBin.class) || thisBin != null) { - // This field needs to be mapped - AerospikeBin bin = thisField.getAnnotation(AerospikeBin.class); - String binName = bin == null ? null : ParserUtils.getInstance().get(bin.name()); - if (thisBin != null && !StringUtils.isBlank(thisBin.getDerivedName())) { - binName = thisBin.getDerivedName(); - } - String name; - if (StringUtils.isBlank(binName)) { - name = thisField.getName(); - } else { - name = binName; - } - if (isKey) { - this.keyName = name; - } + AnnotatedType annotatedType = new AnnotatedType(config, thisField); + TypeMapper typeMapper = TypeUtils.getMapper(thisField.getType(), annotatedType, this.mapper); + this.key = new ValueType.FieldValue(thisField, typeMapper, annotatedType); + this.keyAsBin = storeAsBin; + return true; + } + return false; + } - if (this.values.get(name) != null) { - throw new AerospikeException(String.format("Class %s cannot define the mapped name %s more than once", - clazz.getName(), name)); - } - if ((bin != null && bin.useAccessors()) || (thisBin != null && thisBin.getUseAccessors() != null && thisBin.getUseAccessors())) { - validateAccessorsForField(name, thisField); - } else { - thisField.setAccessible(true); - AnnotatedType annotatedType = new AnnotatedType(config, thisField); - TypeMapper typeMapper = TypeUtils.getMapper(thisField.getType(), annotatedType, this.mapper); - ValueType valueType = new ValueType.FieldValue(thisField, typeMapper, annotatedType); - values.put(name, valueType); - } + private boolean handleGenerationField(Field thisField, BinConfig thisBin) { + // Handle @AerospikeGeneration annotation or generation configuration + boolean isGenerationField = thisField.isAnnotationPresent(AerospikeGeneration.class) + || (thisBin != null && thisBin.isGeneration() != null && thisBin.isGeneration()); + + if (isGenerationField) { + if (thisField.isAnnotationPresent(AerospikeExclude.class) + || (thisBin != null && thisBin.isExclude() != null && thisBin.isExclude())) { + throw new AerospikeException(String.format( + "Class %s cannot have a field which is both a generation field and excluded.", clazz.getName())); } + + if (generationField != null) { + throw new AerospikeException( + String.format("Class %s cannot have more than one @AerospikeGeneration field", clazz.getName())); + } + + // Validate field type is Integer or int + Class fieldType = thisField.getType(); + if (!fieldType.equals(Integer.class) && !fieldType.equals(int.class)) { + throw new AerospikeException( + String.format("@AerospikeGeneration field %s in class %s must be of Integer or int type, but was %s", + thisField.getName(), clazz.getName(), fieldType.getSimpleName())); + } + + AnnotatedType annotatedType = new AnnotatedType(config, thisField); + TypeMapper typeMapper = TypeUtils.getMapper(thisField.getType(), annotatedType, this.mapper); + this.generationField = new ValueType.FieldValue(thisField, typeMapper, annotatedType); + } + return isGenerationField; + } + + private void mapField(Field thisField, BinConfig thisBin, boolean isKey) { + AerospikeBin bin = thisField.getAnnotation(AerospikeBin.class); + String binName = bin == null ? null : ParserUtils.getInstance().get(bin.name()); + if (thisBin != null && !StringUtils.isBlank(thisBin.getDerivedName())) { + binName = thisBin.getDerivedName(); + } + String name; + if (StringUtils.isBlank(binName)) { + name = thisField.getName(); + } else { + name = binName; + } + if (isKey) { + this.keyName = name; + } + + if (this.values.get(name) != null) { + throw new AerospikeException( + String.format("Class %s cannot define the mapped name %s more than once", clazz.getName(), name)); + } + if ((bin != null && bin.useAccessors()) + || (thisBin != null && thisBin.getUseAccessors() != null && thisBin.getUseAccessors())) { + validateAccessorsForField(name, thisField); + } else { + thisField.setAccessible(true); + AnnotatedType annotatedType = new AnnotatedType(config, thisField); + TypeMapper typeMapper = TypeUtils.getMapper(thisField.getType(), annotatedType, this.mapper); + ValueType valueType = new ValueType.FieldValue(thisField, typeMapper, annotatedType); + values.put(name, valueType); } } @@ -754,11 +875,9 @@ private void validateAccessorsForField(String binName, Field thisField) { Method getter = findMethodWithNameAndParams(getterName); if (getter == null) { throw new AerospikeException(String.format( - "Expected to find getter for field %s on class %s due to it being configured to useAccessors, but no method with the signature \"%s %s()\" was found", - fieldName, - this.clazz.getSimpleName(), - thisField.getType().getSimpleName(), - getterName)); + "Expected to find getter for field %s on class %s due to it being configured to useAccessors," + + " but no method with the signature \"%s %s()\" was found", + fieldName, this.clazz.getSimpleName(), thisField.getType().getSimpleName(), getterName)); } Method setter = findMethodWithNameAndParams(setterName, thisField.getType()); @@ -770,10 +889,9 @@ private void validateAccessorsForField(String binName, Field thisField) { } if (setter == null) { throw new AerospikeException(String.format( - "Expected to find setter for field %s on class %s due to it being configured to useAccessors, but no method with the name \"%s\" was found", - fieldName, - this.clazz.getSimpleName(), - setterName)); + "Expected to find setter for field %s on class %s due to it being configured to useAccessors," + + " but no method with the name \"%s\" was found", + fieldName, this.clazz.getSimpleName(), setterName)); } AnnotatedType annotatedType = new AnnotatedType(config, thisField); @@ -804,8 +922,9 @@ public Object getKey(Object object) { try { Object key = this._getKey(object); if (key == null) { - throw new AerospikeException(String.format("Null key from annotated object of class %s." + - " Did you forget an @AerospikeKey annotation?", this.clazz.getSimpleName())); + throw new AerospikeException(String.format( + "Null key from annotated object of class %s. Did you forget an @AerospikeKey annotation?", + this.clazz.getSimpleName())); } return key; } catch (ReflectiveOperationException re) { @@ -838,9 +957,9 @@ public String getSetName() { } public Integer getTtl() { - if (ttl == null || ttl == Integer.MIN_VALUE) { - return null; - } + if (ttl == null || ttl == Integer.MIN_VALUE) { + return null; + } return ttl; } @@ -852,6 +971,27 @@ public Boolean getDurableDelete() { return durableDelete; } + public ValueType getGenerationField() { + return generationField; + } + + public Integer getGenerationValue(Object instance) { + if (generationField == null) { + return null; + } + try { + Object value = generationField.get(instance); + if (value instanceof Integer) { + return (Integer) value; + } else if (value instanceof Number) { + return ((Number) value).intValue(); + } + return null; + } catch (ReflectiveOperationException e) { + throw new AerospikeException("Failed to get generation field value", e); + } + } + private boolean contains(String[] names, String thisName) { if (names == null || names.length == 0) { return true; @@ -867,7 +1007,7 @@ private boolean contains(String[] names, String thisName) { return false; } - @SuppressWarnings({"unchecked", "rawtypes"}) + @SuppressWarnings({ "unchecked", "rawtypes" }) public Bin[] getBins(Object instance, boolean allowNullBins, String[] binNames) { try { Bin[] bins = new Bin[this.binCount]; @@ -991,7 +1131,8 @@ private T constructAndHydrate(Key key, Record record, Map map) { try { ClassCacheEntry thisClass = this; - // If the object saved in the list was a subclass of the declared type, it must have the type name in the map + // If the object saved in the list was a subclass of the declared type, it must + // have the type name in the map // Note that there is a performance implication of using subclasses. String className = map == null ? record.getString(TYPE_NAME) : (String) map.get(TYPE_NAME); if (className != null) { @@ -1013,8 +1154,8 @@ private T constructAndHydrate(Key key, Record record, Map map) { if (key.userKey != null) { aerospikeValue = key.userKey.getObject(); } else { - throw new AerospikeException(String.format("Key field on class %s was for key %s." + - " Was the record saved passing 'sendKey = true'? ", className, key)); + throw new AerospikeException(String.format("Key field on class %s was for key %s." + + " Was the record saved passing 'sendKey = true'? ", className, key)); } } else { aerospikeValue = record.getValue(name); @@ -1033,6 +1174,15 @@ private T constructAndHydrate(Key key, Record record, Map map) { thisClass = thisClass.superClazz; } + // Set the generation value from the record to the @AerospikeGeneration field if present + if (result != null && generationField != null && record != null) { + try { + generationField.set(result, record.generation); + } catch (ReflectiveOperationException e) { + throw new AerospikeException("Failed to set generation field from record generation", e); + } + } + return result; } catch (ReflectiveOperationException ref) { throw new AerospikeException(ref); @@ -1064,20 +1214,24 @@ private void hydrateFromRecordOrMap(Record record, Map map, Obje } private int setValueByField(String name, int objectVersion, int recordVersion, Object instance, int index, - List list, Map map) throws ReflectiveOperationException { + List list, Map map) throws ReflectiveOperationException { ValueType value = this.values.get(name); TypeMapper typeMapper = value.getTypeMapper(); - // If the version of this value does not exist on this object, simply skip it. For example, + // If the version of this value does not exist on this object, simply skip it. + // For example, // V1 contains {a,b,c} but V2 contains {a,c}, skip field B if (!(value.getMinimumVersion() <= objectVersion && objectVersion <= value.getMaximumVersion())) { - // If the version of this record in the database also contained this value, skip over the value as well as the field + // If the version of this record in the database also contained this value, skip + // over the value as well as the field if (value.getMinimumVersion() <= recordVersion && recordVersion <= value.getMaximumVersion()) { index++; } return index; } - // Otherwise only map the value if it should exist on the record in the database. - if (value.getMinimumVersion() <= recordVersion && recordVersion <= value.getMaximumVersion() && index < list.size()) { + // Otherwise only map the value if it should exist on the record in the + // database. + if (value.getMinimumVersion() <= recordVersion && recordVersion <= value.getMaximumVersion() + && index < list.size()) { Object aerospikeValue = list.get(index++); Object javaValue = aerospikeValue == null ? null : typeMapper.fromAerospikeFormat(aerospikeValue); if (instance == null) { @@ -1100,17 +1254,17 @@ private T constructAndHydrateFromJavaMap(Map javaValuesMap) thro if (factoryConstructorMethod != null) { Object[] args; switch (factoryConstructorType) { - case CLASS: - args = new Object[]{this.clazz}; - break; - case MAP: - args = new Object[]{javaValuesMap}; - break; - case CLASS_MAP: - args = new Object[]{this.clazz, javaValuesMap}; - break; - default: - args = null; + case CLASS: + args = new Object[] { this.clazz }; + break; + case MAP: + args = new Object[] { javaValuesMap }; + break; + case CLASS_MAP: + args = new Object[] { this.clazz, javaValuesMap }; + break; + default: + args = null; } result = (T) factoryConstructorMethod.invoke(null, args); } else { @@ -1125,7 +1279,8 @@ private T constructAndHydrateFromJavaMap(Map javaValuesMap) thro } result = constructor.newInstance(args); } - // Once the object has been created, we need to store it against the current key so that + // Once the object has been created, we need to store it against the current key + // so that // recursive objects resolve correctly LoadedObjectResolver.setObjectForCurrentKey(result); @@ -1177,14 +1332,16 @@ public T constructAndHydrate(List list, boolean skipKey) { for (int i = 1; i <= thisClass.ordinals.size(); i++) { String name = thisClass.ordinals.get(i); if (!skipKey || !isKeyField(name)) { - index = thisClass.setValueByField(name, objectVersion, recordVersion, null, index, list, valueMap); + index = thisClass.setValueByField(name, objectVersion, recordVersion, null, index, list, + valueMap); } } } for (String name : thisClass.values.keySet()) { if (thisClass.fieldsWithOrdinals == null || !thisClass.fieldsWithOrdinals.contains(name)) { if (!skipKey || !isKeyField(name)) { - index = thisClass.setValueByField(name, objectVersion, recordVersion, null, index, list, valueMap); + index = thisClass.setValueByField(name, objectVersion, recordVersion, null, index, list, + valueMap); } } } @@ -1224,14 +1381,16 @@ public void hydrateFromList(List list, Object instance, boolean skipKey) for (int i = 1; i <= ordinals.size(); i++) { String name = ordinals.get(i); if (!skipKey || !isKeyField(name)) { - index = setValueByField(name, objectVersion, recordVersion, instance, index, list, null); + index = setValueByField(name, objectVersion, recordVersion, instance, index, list, + null); } } } for (String name : this.values.keySet()) { if (this.fieldsWithOrdinals == null || !thisClass.fieldsWithOrdinals.contains(name)) { if (!skipKey || !isKeyField(name)) { - index = setValueByField(name, objectVersion, recordVersion, instance, index, list, null); + index = setValueByField(name, objectVersion, recordVersion, instance, index, list, + null); } } } @@ -1250,6 +1409,7 @@ public ValueType getValueFromBinName(String name) { @Override public String toString() { return String.format("ClassCacheEntry<%s> (ns=%s,set=%s,subclass=%b,shortName=%s)", - this.getUnderlyingClass().getSimpleName(), this.namespace, this.setName, this.isChildClass, this.shortenedClassName); + this.getUnderlyingClass().getSimpleName(), this.namespace, this.setName, this.isChildClass, + this.shortenedClassName); } } diff --git a/src/main/java/com/aerospike/mapper/tools/ReactiveAeroMapper.java b/src/main/java/com/aerospike/mapper/tools/ReactiveAeroMapper.java index 10a8617..f18bebd 100644 --- a/src/main/java/com/aerospike/mapper/tools/ReactiveAeroMapper.java +++ b/src/main/java/com/aerospike/mapper/tools/ReactiveAeroMapper.java @@ -6,6 +6,7 @@ import com.aerospike.client.Operation; import com.aerospike.client.Value; import com.aerospike.client.policy.BatchPolicy; +import com.aerospike.client.policy.GenerationPolicy; import com.aerospike.client.policy.Policy; import com.aerospike.client.policy.QueryPolicy; import com.aerospike.client.policy.RecordExistsAction; @@ -107,6 +108,14 @@ private WritePolicy generateWritePolicyFromObject(T object) { if (sendKey != null) { writePolicy.sendKey = sendKey; } + + // #181 Handle @AerospikeGeneration field for optimistic concurrency control + Integer generationValue = entry.getGenerationValue(object); + if (generationValue != null && generationValue > 0) { + writePolicy.generation = generationValue; + writePolicy.generationPolicy = GenerationPolicy.EXPECT_GEN_EQUAL; + } + return writePolicy; } diff --git a/src/main/java/com/aerospike/mapper/tools/configuration/BinConfig.java b/src/main/java/com/aerospike/mapper/tools/configuration/BinConfig.java index 49b8c28..f067a9c 100644 --- a/src/main/java/com/aerospike/mapper/tools/configuration/BinConfig.java +++ b/src/main/java/com/aerospike/mapper/tools/configuration/BinConfig.java @@ -14,6 +14,7 @@ public class BinConfig { private Integer ordinal; private EmbedConfig embed; private ReferenceConfig reference; + private Boolean generation; public String getName() { return name; @@ -51,7 +52,10 @@ public ReferenceConfig getReference() { return reference; } - + public Boolean isGeneration() { + return generation; + } + public void setName(String name) { this.name = name; } @@ -88,6 +92,10 @@ public void setReference(ReferenceConfig reference) { this.reference = reference; } + public void setGeneration(Boolean generation) { + this.generation = generation; + } + public void validate(String className) { if (StringUtils.isBlank(this.name) && StringUtils.isBlank(this.field)) { throw new AerospikeException("Configuration for class " + className + " defines a bin which contains neither a name nor a field"); @@ -129,6 +137,9 @@ public BinConfig merge(BinConfig other) { if (this.reference == null && other.reference != null) { this.reference = other.reference; } + if (this.generation == null && other.generation != null) { + this.generation = other.generation; + } return this; } } diff --git a/src/main/java/com/aerospike/mapper/tools/configuration/ClassConfig.java b/src/main/java/com/aerospike/mapper/tools/configuration/ClassConfig.java index 034e0bf..4075294 100644 --- a/src/main/java/com/aerospike/mapper/tools/configuration/ClassConfig.java +++ b/src/main/java/com/aerospike/mapper/tools/configuration/ClassConfig.java @@ -321,6 +321,11 @@ public Builder beingEmbeddedAs(AerospikeEmbed.EmbedType type, AerospikeEmbed.Emb return this.end(); } + public Builder asGenerationField() { + this.binConfig.setGeneration(true); + return this.end(); + } + public Builder beingEmbeddedAs(AerospikeEmbed.EmbedType type, AerospikeEmbed.EmbedType elementType, boolean saveKey) { EmbedConfig embedConfig = new EmbedConfig(); embedConfig.setType(type); diff --git a/src/test/java/com/aerospike/mapper/AeroMapperBaseTest.java b/src/test/java/com/aerospike/mapper/AeroMapperBaseTest.java index f6b8935..ed11304 100644 --- a/src/test/java/com/aerospike/mapper/AeroMapperBaseTest.java +++ b/src/test/java/com/aerospike/mapper/AeroMapperBaseTest.java @@ -6,6 +6,7 @@ import com.aerospike.client.policy.ClientPolicy; import com.aerospike.client.AerospikeClient; +import com.aerospike.client.Host; import com.aerospike.client.IAerospikeClient; import com.aerospike.mapper.tools.ClassCache; import com.fasterxml.jackson.core.JsonProcessingException; @@ -25,7 +26,8 @@ public static void setupClass() { ClientPolicy policy = new ClientPolicy(); // Set event loops to use in asynchronous commands. policy.eventLoops = new NioEventLoops(1); - client = new AerospikeClient(policy, "localhost", 3000); + Host[] hosts = Host.parseHosts(System.getProperty("test.host", "localhost:3000"), 3000); + client = new AerospikeClient(policy, hosts); } @AfterAll diff --git a/src/test/java/com/aerospike/mapper/GenerationAnnotationTest.java b/src/test/java/com/aerospike/mapper/GenerationAnnotationTest.java new file mode 100644 index 0000000..37251c4 --- /dev/null +++ b/src/test/java/com/aerospike/mapper/GenerationAnnotationTest.java @@ -0,0 +1,312 @@ +package com.aerospike.mapper; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +import com.aerospike.client.AerospikeException; +import com.aerospike.client.policy.GenerationPolicy; +import com.aerospike.client.policy.WritePolicy; +import com.aerospike.mapper.annotations.AerospikeGeneration; +import com.aerospike.mapper.annotations.AerospikeKey; +import com.aerospike.mapper.annotations.AerospikeRecord; +import com.aerospike.mapper.tools.AeroMapper; +import com.aerospike.mapper.tools.configuration.ClassConfig; + +public class GenerationAnnotationTest extends AeroMapperBaseTest { + + @AerospikeRecord(namespace = NAMESPACE, set = "generationTest") + public static class GenerationEntity { + @AerospikeKey + private int id; + private String name; + @AerospikeGeneration + private int generation; + + public GenerationEntity() {} + + public GenerationEntity(int id, String name) { + this.id = id; + this.name = name; + } + + // Getters and setters + public int getId() { return id; } + public void setId(int id) { this.id = id; } + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public int getGeneration() { return generation; } + public void setGeneration(int generation) { this.generation = generation; } + } + + @AerospikeRecord(namespace = NAMESPACE, set = "generationTestInteger") + public static class GenerationEntityWithInteger { + @AerospikeKey + private int id; + private String name; + @AerospikeGeneration + private Integer generation; + + public GenerationEntityWithInteger() {} + + public GenerationEntityWithInteger(int id, String name) { + this.id = id; + this.name = name; + this.generation = 0; + } + + // Getters and setters + public int getId() { return id; } + public void setId(int id) { this.id = id; } + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public Integer getGeneration() { return generation; } + public void setGeneration(Integer generation) { this.generation = generation; } + } + + @AerospikeRecord(namespace = NAMESPACE, set = "generationTestMethods") + public static class GenerationEntityWithMethods { + @AerospikeKey + private int id; + private String name; + private int generation; + + public GenerationEntityWithMethods() {} + + public GenerationEntityWithMethods(int id, String name) { + this.id = id; + this.name = name; + this.generation = 0; + } + + // Getters and setters + public int getId() { return id; } + public void setId(int id) { this.id = id; } + public String getName() { return name; } + public void setName(String name) { this.name = name; } + + @AerospikeGeneration + public int getGeneration() { return generation; } + + @AerospikeGeneration + public void setGeneration(int generation) { this.generation = generation; } + } + + @AerospikeRecord(namespace = NAMESPACE, set = "invalidGenerationTest") + public static class InvalidGenerationEntity { + @AerospikeKey + private int id; + @AerospikeGeneration + private String generation; // Invalid type - should cause error + + public InvalidGenerationEntity() {} + } + + @AerospikeRecord(namespace = NAMESPACE, set = "multipleGenerationTest") + public static class MultipleGenerationEntity { + @AerospikeKey + private int id; + @AerospikeGeneration + private int generation1; + @AerospikeGeneration + private int generation2; // Multiple @Generation fields - should cause error + + public MultipleGenerationEntity() {} + } + + @Test + public void testGenerationFieldMapping() { + AeroMapper mapper = new AeroMapper.Builder(client).build(); + + // Start with a clean slate + mapper.delete(GenerationEntity.class, 1); + + // Create and save entity + GenerationEntity entity = new GenerationEntity(1, "Test Entity"); + mapper.save(entity); + + // Read back the entity + GenerationEntity readEntity = mapper.read(GenerationEntity.class, 1); + + assertNotNull(readEntity); + assertEquals(1, readEntity.getId()); + assertEquals("Test Entity", readEntity.getName()); + assertEquals(1, readEntity.getGeneration()); // Should be 1 after first save + } + + @Test + public void testGenerationFieldMappingWithInteger() { + AeroMapper mapper = new AeroMapper.Builder(client).build(); + // Start with a clean slate + mapper.delete(GenerationEntityWithInteger.class, 2); + + // Create and save entity + GenerationEntityWithInteger entity = new GenerationEntityWithInteger(2, "Test Entity Integer"); + mapper.save(entity); + + // Read back the entity + GenerationEntityWithInteger readEntity = mapper.read(GenerationEntityWithInteger.class, 2); + + assertNotNull(readEntity); + assertEquals(2, readEntity.getId()); + assertEquals("Test Entity Integer", readEntity.getName()); + assertEquals(Integer.valueOf(1), readEntity.getGeneration()); // Should be 1 after first save + } + + @Test + public void testGenerationFieldMappingWithMethods() { + AeroMapper mapper = new AeroMapper.Builder(client).build(); + + // Start with a clean slate + mapper.delete(GenerationEntityWithMethods.class, 3); + + // Create and save entity + GenerationEntityWithMethods entity = new GenerationEntityWithMethods(3, "Test Entity Methods"); + mapper.save(entity); + + // Read back the entity + GenerationEntityWithMethods readEntity = mapper.read(GenerationEntityWithMethods.class, 3); + + assertNotNull(readEntity); + assertEquals(3, readEntity.getId()); + assertEquals("Test Entity Methods", readEntity.getName()); + assertEquals(1, readEntity.getGeneration()); // Should be 1 after first save + } + + @Test + public void testOptimisticConcurrencyControl() { + AeroMapper mapper = new AeroMapper.Builder(client).build(); + + // Start with a clean slate + mapper.delete(GenerationEntity.class, 4); + + // Create and save entity + GenerationEntity entity = new GenerationEntity(4, "Concurrency Test"); + mapper.save(entity); + + // Read the entity to get the current generation + GenerationEntity readEntity1 = mapper.read(GenerationEntity.class, 4); + GenerationEntity readEntity2 = mapper.read(GenerationEntity.class, 4); + + // Both should have the same generation + assertEquals(readEntity1.getGeneration(), readEntity2.getGeneration()); + + // Update first entity + readEntity1.setName("Updated by first"); + mapper.save(readEntity1); + + // Try to update second entity with stale generation - should fail + readEntity2.setName("Updated by second"); + assertThrows(AerospikeException.class, () -> { + mapper.save(readEntity2); + }); + } + + @Test + public void testGenerationIncrementOnUpdate() { + AeroMapper mapper = new AeroMapper.Builder(client).build(); + + // Start with a clean slate + mapper.delete(GenerationEntity.class, 5); + + // Create and save entity + GenerationEntity entity = new GenerationEntity(5, "Generation Increment Test"); + mapper.insert(entity); + + // Read and verify initial generation + GenerationEntity readEntity = mapper.read(GenerationEntity.class, 5); + assertEquals(1, readEntity.getGeneration()); + + // Update and save + readEntity.setName("Updated Name"); + mapper.save(readEntity); + + // Read again and verify generation incremented + GenerationEntity updatedEntity = mapper.read(GenerationEntity.class, 5); + assertEquals(2, updatedEntity.getGeneration()); + assertEquals("Updated Name", updatedEntity.getName()); + } + + @Test + public void testInvalidGenerationFieldType() { + assertThrows(AerospikeException.class, () -> { + AeroMapper mapper = new AeroMapper.Builder(client).build(); + InvalidGenerationEntity entity = new InvalidGenerationEntity(); + entity.id = 6; + mapper.save(entity); + }); + } + + @Test + public void testMultipleGenerationFields() { + assertThrows(AerospikeException.class, () -> { + AeroMapper mapper = new AeroMapper.Builder(client).build(); + MultipleGenerationEntity entity = new MultipleGenerationEntity(); + entity.id = 7; + mapper.save(entity); + }); + } + + @Test + public void testGenerationThroughConfiguration() { + // Test generation field configuration through code + ClassConfig config = new ClassConfig.Builder(GenerationEntity.class) + .withFieldNamed("generation").asGenerationField() + .build(); + + AeroMapper mapper = new AeroMapper.Builder(client) + .withClassConfigurations(config) + .build(); + + // Start with a clean slate + mapper.delete(GenerationEntity.class, 8); + + GenerationEntity entity = new GenerationEntity(8, "Config Test"); + mapper.save(entity); + + GenerationEntity readEntity = mapper.read(GenerationEntity.class, 8); + assertEquals(1, readEntity.getGeneration()); + } + + @Test + public void testWritePolicyGeneration() { + AeroMapper mapper = new AeroMapper.Builder(client).build(); + + // Start with a clean slate + mapper.delete(GenerationEntity.class, 9); + + // Create entity with specific generation + GenerationEntity entity = new GenerationEntity(9, "Policy Test"); + entity.setGeneration(5); // Set a specific generation + + // Get the write policy that would be generated + WritePolicy writePolicy = mapper.getWritePolicy(GenerationEntity.class); + + // The policy should not have generation set by default + assertEquals(0, writePolicy.generation); + assertEquals(GenerationPolicy.NONE, writePolicy.generationPolicy); + + // But when we save an object with a generation > 0, it should set the generation + // This is tested indirectly through the optimistic concurrency control test + } + + @Test + public void testZeroGenerationDoesNotSetGeneration() { + AeroMapper mapper = new AeroMapper.Builder(client).build(); + + // Start with a clean slate + mapper.delete(GenerationEntity.class, 10); + + // Create entity with generation 0 (should not set generation policy) + GenerationEntity entity = new GenerationEntity(10, "Zero Generation Test"); + entity.setGeneration(0); + + // This should work fine as no generation policy is set + mapper.save(entity); + + GenerationEntity readEntity = mapper.read(GenerationEntity.class, 10); + assertEquals(1, readEntity.getGeneration()); // Should be 1 after save + } +} \ No newline at end of file diff --git a/src/test/java/com/aerospike/mapper/GenerationConfigurationTest.java b/src/test/java/com/aerospike/mapper/GenerationConfigurationTest.java new file mode 100644 index 0000000..4189373 --- /dev/null +++ b/src/test/java/com/aerospike/mapper/GenerationConfigurationTest.java @@ -0,0 +1,161 @@ +package com.aerospike.mapper; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +import com.aerospike.mapper.GenerationAnnotationTest.GenerationEntity; +import com.aerospike.mapper.annotations.AerospikeKey; +import com.aerospike.mapper.annotations.AerospikeRecord; +import com.aerospike.mapper.tools.AeroMapper; +import com.aerospike.mapper.tools.configuration.ClassConfig; + +public class GenerationConfigurationTest extends AeroMapperBaseTest { + + @AerospikeRecord(namespace = NAMESPACE, set = "configGenerationTest") + public static class ConfigGenerationEntity { + @AerospikeKey + private int id; + private String name; + private int generation; // No @Generation annotation, will be configured via config + + public ConfigGenerationEntity() {} + + public ConfigGenerationEntity(int id, String name) { + this.id = id; + this.name = name; + this.generation = 0; + } + + // Getters and setters + public int getId() { return id; } + public void setId(int id) { this.id = id; } + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public int getGeneration() { return generation; } + public void setGeneration(int generation) { this.generation = generation; } + } + + @Test + public void testGenerationThroughCodeConfiguration() { + // Configure generation field through code + ClassConfig config = new ClassConfig.Builder(ConfigGenerationEntity.class) + .withFieldNamed("generation").asGenerationField() + .build(); + + AeroMapper mapper = new AeroMapper.Builder(client) + .withClassConfigurations(config) + .build(); + + // Start with a clean slate + mapper.delete(ConfigGenerationEntity.class, 1); + + // Create and save entity + ConfigGenerationEntity entity = new ConfigGenerationEntity(1, "Config Test"); + mapper.save(entity); + + // Read back the entity + ConfigGenerationEntity readEntity = mapper.read(ConfigGenerationEntity.class, 1); + + assertNotNull(readEntity); + assertEquals(1, readEntity.getId()); + assertEquals("Config Test", readEntity.getName()); + assertEquals(1, readEntity.getGeneration()); // Should be 1 after first save + } + + @Test + public void testGenerationThroughYamlConfiguration() throws Exception { + String yamlConfig = "classes:\n" + + " - class: com.aerospike.mapper.GenerationConfigurationTest$ConfigGenerationEntity\n" + + " bins:\n" + + " - field: generation\n" + + " generation: true\n"; + + AeroMapper mapper = new AeroMapper.Builder(client) + .withConfiguration(yamlConfig) + .build(); + + // Start with a clean slate + mapper.delete(ConfigGenerationEntity.class, 2); + + // Create and save entity + ConfigGenerationEntity entity = new ConfigGenerationEntity(2, "YAML Config Test"); + mapper.save(entity); + + // Read back the entity + ConfigGenerationEntity readEntity = mapper.read(ConfigGenerationEntity.class, 2); + + assertNotNull(readEntity); + assertEquals(2, readEntity.getId()); + assertEquals("YAML Config Test", readEntity.getName()); + assertEquals(1, readEntity.getGeneration()); // Should be 1 after first save + } + + @Test + public void testOptimisticConcurrencyWithConfiguration() { + // Configure generation field through code + ClassConfig config = new ClassConfig.Builder(ConfigGenerationEntity.class) + .withFieldNamed("generation").asGenerationField() + .build(); + + AeroMapper mapper = new AeroMapper.Builder(client) + .withClassConfigurations(config) + .build(); + + // Start with a clean slate + mapper.delete(ConfigGenerationEntity.class, 3); + + // Create and save entity + ConfigGenerationEntity entity = new ConfigGenerationEntity(3, "Concurrency Config Test"); + mapper.save(entity); + + // Read the entity to get the current generation + ConfigGenerationEntity readEntity1 = mapper.read(ConfigGenerationEntity.class, 3); + ConfigGenerationEntity readEntity2 = mapper.read(ConfigGenerationEntity.class, 3); + + // Both should have the same generation + assertEquals(readEntity1.getGeneration(), readEntity2.getGeneration()); + + // Update first entity + readEntity1.setName("Updated by first"); + mapper.save(readEntity1); + + // Try to update second entity with stale generation - should fail + readEntity2.setName("Updated by second"); + assertThrows(Exception.class, () -> { + mapper.save(readEntity2); + }); + } + + @Test + public void testGenerationIncrementWithConfiguration() { + // Configure generation field through code + ClassConfig config = new ClassConfig.Builder(ConfigGenerationEntity.class) + .withFieldNamed("generation").asGenerationField() + .build(); + + AeroMapper mapper = new AeroMapper.Builder(client) + .withClassConfigurations(config) + .build(); + + // Start with a clean slate + mapper.delete(ConfigGenerationEntity.class, 4); + + // Create and save entity + ConfigGenerationEntity entity = new ConfigGenerationEntity(4, "Generation Increment Config Test"); + mapper.save(entity); + + // Read and verify initial generation + ConfigGenerationEntity readEntity = mapper.read(ConfigGenerationEntity.class, 4); + assertEquals(1, readEntity.getGeneration()); + + // Update and save + readEntity.setName("Updated Name"); + mapper.save(readEntity); + + // Read again and verify generation incremented + ConfigGenerationEntity updatedEntity = mapper.read(ConfigGenerationEntity.class, 4); + assertEquals(2, updatedEntity.getGeneration()); + assertEquals("Updated Name", updatedEntity.getName()); + } +} \ No newline at end of file diff --git a/src/test/java/com/aerospike/mapper/GenerationNotStoredTest.java b/src/test/java/com/aerospike/mapper/GenerationNotStoredTest.java new file mode 100644 index 0000000..544bc3b --- /dev/null +++ b/src/test/java/com/aerospike/mapper/GenerationNotStoredTest.java @@ -0,0 +1,96 @@ +package com.aerospike.mapper; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +import com.aerospike.client.Bin; +import com.aerospike.mapper.annotations.AerospikeKey; +import com.aerospike.mapper.annotations.AerospikeRecord; +import com.aerospike.mapper.annotations.AerospikeGeneration; +import com.aerospike.mapper.tools.AeroMapper; +import com.aerospike.mapper.tools.ClassCacheEntry; +import com.aerospike.mapper.tools.utils.MapperUtils; + +public class GenerationNotStoredTest extends AeroMapperBaseTest { + + @AerospikeRecord(namespace = NAMESPACE, set = "generationNotStoredTest") + public static class EntityWithGeneration { + @AerospikeKey + private int id; + private String name; + @AerospikeGeneration + private Integer generation = 0; + + public EntityWithGeneration() {} + + public EntityWithGeneration(int id, String name) { + this.id = id; + this.name = name; + } + + public int getId() { + return id; + } + + public void setId(int id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Integer getGeneration() { + return generation; + } + + public void setGeneration(Integer generation) { + this.generation = generation; + } + } + + @Test + public void testGenerationFieldNotStoredAsBin() { + AeroMapper mapper = new AeroMapper.Builder(client).build(); + EntityWithGeneration entity = new EntityWithGeneration(1, "test"); + entity.setGeneration(5); // Set some generation value + + // Get the ClassCacheEntry and the bins that would be stored + ClassCacheEntry entry = MapperUtils.getEntryAndValidateNamespace(EntityWithGeneration.class, mapper); + Bin[] bins = entry.getBins(entity, false, null); + + // Verify that the generation field should NOT be included + // Should only have bins for regular fields (name and potentially id if stored as bin) + + boolean foundName = false; + boolean foundId = false; + boolean foundGeneration = false; + + for (Bin bin : bins) { + String binName = bin.name; + if ("name".equals(binName)) { + foundName = true; + assertEquals("test", bin.value.getObject()); + } else if ("id".equals(binName)) { + foundId = true; + assertEquals(1, bin.value.getObject()); + } else if ("generation".equals(binName)) { + foundGeneration = true; + } + } + + assertTrue(foundName, "Should find 'name' bin"); + assertFalse(foundGeneration, "Should NOT find 'generation' bin - it should not be stored"); + + // Log what bins we actually found for debugging + System.out.println("Found " + bins.length + " bins:"); + for (Bin bin : bins) { + System.out.println(" - " + bin.name + " = " + bin.value.getObject()); + } + } +} \ No newline at end of file diff --git a/src/test/java/com/aerospike/mapper/reactive/ReactiveUsePKColumnInsteadOfBinForKeyTest.java b/src/test/java/com/aerospike/mapper/reactive/ReactiveUsePKColumnInsteadOfBinForKeyTest.java index 6a623e5..98baaba 100644 --- a/src/test/java/com/aerospike/mapper/reactive/ReactiveUsePKColumnInsteadOfBinForKeyTest.java +++ b/src/test/java/com/aerospike/mapper/reactive/ReactiveUsePKColumnInsteadOfBinForKeyTest.java @@ -1,18 +1,22 @@ package com.aerospike.mapper.reactive; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import org.junit.jupiter.api.Test; + import com.aerospike.client.query.KeyRecord; import com.aerospike.mapper.annotations.AerospikeKey; import com.aerospike.mapper.annotations.AerospikeRecord; import com.aerospike.mapper.tools.ReactiveAeroMapper; import com.aerospike.mapper.tools.configuration.ClassConfig; + import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; -import org.junit.jupiter.api.Test; import reactor.core.scheduler.Schedulers; -import static org.junit.jupiter.api.Assertions.*; - public class ReactiveUsePKColumnInsteadOfBinForKeyTest extends ReactiveAeroMapperBaseTest { @Data