diff --git a/fixedformat4j/src/main/java/com/ancientprogramming/fixedformat4j/format/FormatContext.java b/fixedformat4j/src/main/java/com/ancientprogramming/fixedformat4j/format/FormatContext.java index fabcd95..9a012b6 100644 --- a/fixedformat4j/src/main/java/com/ancientprogramming/fixedformat4j/format/FormatContext.java +++ b/fixedformat4j/src/main/java/com/ancientprogramming/fixedformat4j/format/FormatContext.java @@ -24,9 +24,9 @@ */ public class FormatContext { - private int offset; - private Class dataType; - private Class> formatter; + private final int offset; + private final Class dataType; + private final Class> formatter; /** * Creates a new format context. diff --git a/fixedformat4j/src/main/java/com/ancientprogramming/fixedformat4j/format/impl/ByTypeFormatter.java b/fixedformat4j/src/main/java/com/ancientprogramming/fixedformat4j/format/impl/ByTypeFormatter.java index e972abb..8b11acb 100644 --- a/fixedformat4j/src/main/java/com/ancientprogramming/fixedformat4j/format/impl/ByTypeFormatter.java +++ b/fixedformat4j/src/main/java/com/ancientprogramming/fixedformat4j/format/impl/ByTypeFormatter.java @@ -41,7 +41,7 @@ * @since 1.0.0 */ public class ByTypeFormatter implements FixedFormatter { - private FormatContext context; + private final FormatContext context; private static final Map, Class>> KNOWN_FORMATTERS = new HashMap<>(); diff --git a/fixedformat4j/src/main/java/com/ancientprogramming/fixedformat4j/format/impl/ClassMetadataCache.java b/fixedformat4j/src/main/java/com/ancientprogramming/fixedformat4j/format/impl/ClassMetadataCache.java new file mode 100644 index 0000000..b990534 --- /dev/null +++ b/fixedformat4j/src/main/java/com/ancientprogramming/fixedformat4j/format/impl/ClassMetadataCache.java @@ -0,0 +1,105 @@ +package com.ancientprogramming.fixedformat4j.format.impl; + +import com.ancientprogramming.fixedformat4j.annotation.Field; +import com.ancientprogramming.fixedformat4j.annotation.Fields; +import com.ancientprogramming.fixedformat4j.annotation.Record; +import com.ancientprogramming.fixedformat4j.format.FixedFormatter; +import com.ancientprogramming.fixedformat4j.format.FormatContext; +import com.ancientprogramming.fixedformat4j.format.FormatInstructions; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import static com.ancientprogramming.fixedformat4j.format.FixedFormatUtil.getFixedFormatterInstance; + +/** + * JVM-level cache of per-class field metadata ({@link FieldDescriptor} lists). + * + *

The first call to {@link #get} for a class scans its annotations and builds one + * {@link FieldDescriptor} per effective {@code @Field}. Subsequent calls return the same + * immutable list without re-scanning. + * + *

Thread safety: {@code computeIfAbsent} guarantees that {@link #build} runs at most once per + * class key. Helper objects ({@link AnnotationScanner}, {@link FormatInstructionsBuilder}, + * {@link RepeatingFieldSupport}) are created as local variables inside {@code build} so that + * concurrent builds of different classes never share mutable state. + * + *

Note: this cache is never cleared. In multi-classloader environments + * (e.g. application servers with hot-reload, OSGi containers) old {@link Class} references may + * be retained here after their classloader is discarded, preventing garbage collection. + * In such environments consider using a {@link java.lang.ref.WeakReference}-based map instead. + * + * @author Jacob von Eyben - https://eybenconsult.com + * @since 1.7.1 + */ +class ClassMetadataCache { + + static final ClassMetadataCache INSTANCE = new ClassMetadataCache(); + + private final Map, List> cache = new ConcurrentHashMap<>(); + + List get(Class clazz) { + return cache.computeIfAbsent(clazz, this::build); + } + + private List build(Class clazz) { + AnnotationScanner scanner = new AnnotationScanner(); + FormatInstructionsBuilder instructionsBuilder = new FormatInstructionsBuilder(); + RepeatingFieldSupport repeatingFieldSupport = new RepeatingFieldSupport(); + + List result = new ArrayList<>(); + for (AnnotationTarget target : scanner.scan(clazz)) { + Field fieldAnnotation = target.annotationSource.getAnnotation(Field.class); + Fields fieldsAnnotation = target.annotationSource.getAnnotation(Fields.class); + if (fieldAnnotation != null) { + result.add(buildDescriptor(clazz, target, fieldAnnotation, true, scanner, instructionsBuilder, repeatingFieldSupport)); + } else if (fieldsAnnotation != null) { + Field[] fields = fieldsAnnotation.value(); + for (int i = 0; i < fields.length; i++) { + result.add(buildDescriptor(clazz, target, fields[i], i == 0, scanner, instructionsBuilder, repeatingFieldSupport)); + } + } + } + return Collections.unmodifiableList(result); + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private FieldDescriptor buildDescriptor( + Class clazz, + AnnotationTarget target, + Field fieldAnnotation, + boolean isLoadField, + AnnotationScanner scanner, + FormatInstructionsBuilder instructionsBuilder, + RepeatingFieldSupport repeatingFieldSupport) { + + Class datatype = instructionsBuilder.datatype(target.getter, fieldAnnotation); + repeatingFieldSupport.validateCount(target.getter, fieldAnnotation); + boolean isRepeating = fieldAnnotation.count() > 1; + boolean hasCustomFormatter = fieldAnnotation.formatter() != ByTypeFormatter.class; + boolean isNestedRecord = !isRepeating && !hasCustomFormatter && datatype.getAnnotation(Record.class) != null; + + FormatContext context = isRepeating ? null : instructionsBuilder.context(datatype, fieldAnnotation); + FormatInstructions formatInstructions = isRepeating ? null : instructionsBuilder.build(target.annotationSource, fieldAnnotation, datatype); + FixedFormatter formatter = (isRepeating || isNestedRecord) ? null + : getFixedFormatterInstance(context.getFormatter(), context); + + Method setter = resolveSetter(clazz, target.getter, datatype, scanner); + + return new FieldDescriptor(target, setter, fieldAnnotation, datatype, context, formatInstructions, + formatter, isRepeating, isNestedRecord, isLoadField); + } + + private Method resolveSetter(Class clazz, Method getter, Class datatype, AnnotationScanner scanner) { + String setterName = "set" + scanner.stripMethodPrefix(getter.getName()); + try { + return clazz.getMethod(setterName, datatype); + } catch (NoSuchMethodException e) { + return null; + } + } +} diff --git a/fixedformat4j/src/main/java/com/ancientprogramming/fixedformat4j/format/impl/FieldDescriptor.java b/fixedformat4j/src/main/java/com/ancientprogramming/fixedformat4j/format/impl/FieldDescriptor.java new file mode 100644 index 0000000..61a3ff4 --- /dev/null +++ b/fixedformat4j/src/main/java/com/ancientprogramming/fixedformat4j/format/impl/FieldDescriptor.java @@ -0,0 +1,63 @@ +package com.ancientprogramming.fixedformat4j.format.impl; + +import com.ancientprogramming.fixedformat4j.annotation.Field; +import com.ancientprogramming.fixedformat4j.format.FixedFormatter; +import com.ancientprogramming.fixedformat4j.format.FormatContext; +import com.ancientprogramming.fixedformat4j.format.FormatInstructions; + +import java.lang.reflect.Method; + +/** + * Immutable bundle of all per-field metadata computed once per class and cached for reuse across + * every {@code load()} and {@code export()} call. + * + *

For repeating fields ({@code count > 1}), {@link #context}, {@link #formatInstructions}, and + * {@link #formatter} are {@code null} — the runtime delegates to {@link RepeatingFieldSupport}. + * For fields whose type is itself a {@code @Record}, {@link #formatter} is {@code null} and the + * runtime recurses into {@code FixedFormatManagerImpl}. + * + * @author Jacob von Eyben - https://eybenconsult.com + * @since 1.7.1 + */ +class FieldDescriptor { + + final AnnotationTarget target; + final Method setter; + final Field fieldAnnotation; + final Class datatype; + final FormatContext context; + final FormatInstructions formatInstructions; + final FixedFormatter formatter; + final boolean isRepeating; + final boolean isNestedRecord; + /** + * {@code true} when this descriptor should participate in {@code load()} (i.e. its parsed value + * is written to the POJO via the setter). For plain {@code @Field} annotations this is always + * {@code true}. For {@code @Fields}, only the first annotation in the array is a load field; + * the remainder are export-only. + */ + final boolean isLoadField; + + FieldDescriptor( + AnnotationTarget target, + Method setter, + Field fieldAnnotation, + Class datatype, + FormatContext context, + FormatInstructions formatInstructions, + FixedFormatter formatter, + boolean isRepeating, + boolean isNestedRecord, + boolean isLoadField) { + this.target = target; + this.setter = setter; + this.fieldAnnotation = fieldAnnotation; + this.datatype = datatype; + this.context = context; + this.formatInstructions = formatInstructions; + this.formatter = formatter; + this.isRepeating = isRepeating; + this.isNestedRecord = isNestedRecord; + this.isLoadField = isLoadField; + } +} diff --git a/fixedformat4j/src/main/java/com/ancientprogramming/fixedformat4j/format/impl/FixedFormatManagerImpl.java b/fixedformat4j/src/main/java/com/ancientprogramming/fixedformat4j/format/impl/FixedFormatManagerImpl.java index d008a82..7135838 100644 --- a/fixedformat4j/src/main/java/com/ancientprogramming/fixedformat4j/format/impl/FixedFormatManagerImpl.java +++ b/fixedformat4j/src/main/java/com/ancientprogramming/fixedformat4j/format/impl/FixedFormatManagerImpl.java @@ -17,7 +17,6 @@ import com.ancientprogramming.fixedformat4j.annotation.EnumFormat; import com.ancientprogramming.fixedformat4j.annotation.Field; -import com.ancientprogramming.fixedformat4j.annotation.Fields; import com.ancientprogramming.fixedformat4j.annotation.FixedFormatEnum; import com.ancientprogramming.fixedformat4j.annotation.FixedFormatPattern; import com.ancientprogramming.fixedformat4j.annotation.Record; @@ -32,8 +31,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.lang.annotation.Annotation; -import java.lang.reflect.AnnotatedElement; import java.lang.reflect.Method; import java.util.Arrays; import java.util.Collections; @@ -42,7 +39,6 @@ import java.util.concurrent.ConcurrentHashMap; import static com.ancientprogramming.fixedformat4j.format.FixedFormatUtil.fetchData; -import static com.ancientprogramming.fixedformat4j.format.FixedFormatUtil.getFixedFormatterInstance; import static java.lang.String.format; /** @@ -54,6 +50,7 @@ public class FixedFormatManagerImpl implements FixedFormatManager { private static final Logger LOG = LoggerFactory.getLogger(FixedFormatManagerImpl.class); + /** * JVM-level cache of record classes whose enum-field lengths have already been validated. * Validation is performed at most once per class (on the first {@code load} or {@code export} @@ -67,8 +64,6 @@ public class FixedFormatManagerImpl implements FixedFormatManager { */ private static final Set> VALIDATED_CLASSES = Collections.newSetFromMap(new ConcurrentHashMap<>()); - private final AnnotationScanner annotationScanner = new AnnotationScanner(); - private final FormatInstructionsBuilder instructionsBuilder = new FormatInstructionsBuilder(); private final RecordInstantiator recordInstantiator = new RecordInstantiator(); private final RepeatingFieldSupport repeatingFieldSupport = new RepeatingFieldSupport(); @@ -76,53 +71,45 @@ public class FixedFormatManagerImpl implements FixedFormatManager { * {@inheritDoc} */ public T load(Class fixedFormatRecordClass, String data) { - HashMap foundData = new HashMap(); - HashMap> methodClass = new HashMap>(); getAndAssertRecordAnnotation(fixedFormatRecordClass); validatePatterns(fixedFormatRecordClass); T instance = recordInstantiator.instantiate(fixedFormatRecordClass); - for (AnnotationTarget target : annotationScanner.scan(fixedFormatRecordClass)) { - String methodName = annotationScanner.stripMethodPrefix(target.getter.getName()); - Field fieldAnnotation = target.annotationSource.getAnnotation(Field.class); - Fields fieldsAnnotation = target.annotationSource.getAnnotation(Fields.class); - if (fieldAnnotation != null) { - readFieldData(fixedFormatRecordClass, data, foundData, methodClass, target, methodName, fieldAnnotation); - } else if (fieldsAnnotation != null) { - if (fieldsAnnotation.value() == null || fieldsAnnotation.value().length == 0) { - throw new FixedFormatException(format("%s annotation must contain minimum one %s annotation", Fields.class.getName(), Field.class.getName())); + for (FieldDescriptor desc : ClassMetadataCache.INSTANCE.get(fixedFormatRecordClass)) { + if (!desc.isLoadField) continue; + + Object value; + if (desc.isRepeating) { + value = repeatingFieldSupport.read(fixedFormatRecordClass, data, desc.target.getter, desc.target.annotationSource, desc.fieldAnnotation); + } else { + String dataToParse = fetchData(data, desc.formatInstructions, desc.context); + if (desc.isNestedRecord) { + value = load(desc.datatype, dataToParse); + } else { + try { + value = desc.formatter.parse(dataToParse, desc.formatInstructions); + } catch (RuntimeException e) { + throw new ParseException(data, dataToParse, fixedFormatRecordClass, desc.target.getter, desc.context, desc.formatInstructions, e); + } } - readFieldData(fixedFormatRecordClass, data, foundData, methodClass, target, methodName, fieldsAnnotation.value()[0]); } - } - Set keys = foundData.keySet(); - for (String key : keys) { - String setterMethodName = "set" + key; - Object foundDataObj = foundData.get(key); - if (foundDataObj != null) { - Class datatype = methodClass.get(key); - Method method; - try { - method = fixedFormatRecordClass.getMethod(setterMethodName, datatype); - } catch (NoSuchMethodException e) { - throw new FixedFormatException(format("setter method named %s.%s(%s) does not exist", fixedFormatRecordClass.getName(), setterMethodName, datatype)); - } + if (value != null && desc.setter != null) { try { - method.invoke(instance, foundData.get(key)); + desc.setter.invoke(instance, value); } catch (Exception e) { - throw new FixedFormatException(format("could not invoke method %s.%s(%s)", fixedFormatRecordClass.getName(), setterMethodName, datatype), e); + throw new FixedFormatException( + format("could not invoke method %s.%s(%s)", fixedFormatRecordClass.getName(), desc.setter.getName(), desc.datatype), e); } } + + if (LOG.isDebugEnabled()) { + LOG.debug("the loaded data[{}]", value); + } } - return instance; - } - private void readFieldData(Class fixedFormatRecordClass, String data, HashMap foundData, HashMap> methodClass, AnnotationTarget target, String methodName, Field fieldAnnotation) { - Object loadedData = readDataAccordingFieldAnnotation(fixedFormatRecordClass, data, target.getter, target.annotationSource, fieldAnnotation); - foundData.put(methodName, loadedData); - methodClass.put(methodName, target.getter.getReturnType()); + return instance; } /** @@ -134,20 +121,36 @@ public String export(String template, T fixedFormatRecord) { validatePatterns(fixedFormatRecord.getClass()); HashMap foundData = new HashMap(); - for (AnnotationTarget target : annotationScanner.scan(fixedFormatRecord.getClass())) { - Field fieldAnnotation = target.annotationSource.getAnnotation(Field.class); - Fields fieldsAnnotation = target.annotationSource.getAnnotation(Fields.class); - if (fieldAnnotation != null) { - if (fieldAnnotation.count() > 1) { - repeatingFieldSupport.export(fixedFormatRecord, target, fieldAnnotation, foundData); - } else { - foundData.put(fieldAnnotation.offset(), exportDataAccordingFieldAnnotation(fixedFormatRecord, target, fieldAnnotation)); - } - } else if (fieldsAnnotation != null) { - for (Field field : fieldsAnnotation.value()) { - foundData.put(field.offset(), exportDataAccordingFieldAnnotation(fixedFormatRecord, target, field)); - } + + for (FieldDescriptor desc : ClassMetadataCache.INSTANCE.get(fixedFormatRecord.getClass())) { + if (desc.isRepeating) { + repeatingFieldSupport.export(fixedFormatRecord, desc.target, desc.fieldAnnotation, foundData); + continue; + } + + Object valueObject; + try { + valueObject = desc.target.getter.invoke(fixedFormatRecord); + } catch (Exception e) { + throw new FixedFormatException( + format("could not invoke method %s.%s(%s)", fixedFormatRecord.getClass().getName(), desc.target.getter.getName(), desc.datatype), e); + } + + String formatted; + if (valueObject != null && valueObject.getClass().getAnnotation(Record.class) != null) { + formatted = export(valueObject); + } else if (desc.isNestedRecord) { + throw new FixedFormatException( + format("cannot export null value for nested @Record field %s.%s()", + fixedFormatRecord.getClass().getName(), desc.target.getter.getName())); + } else { + formatted = ((FixedFormatter) desc.formatter).format(valueObject, desc.formatInstructions); + } + + if (LOG.isDebugEnabled()) { + LOG.debug(format("exported %s ", formatted)); } + foundData.put(desc.fieldAnnotation.offset(), formatted); } for (Integer offset : foundData.keySet()) { @@ -173,24 +176,16 @@ private void validatePatterns(Class recordClass) { if (VALIDATED_CLASSES.contains(recordClass)) { return; } - for (AnnotationTarget target : annotationScanner.scan(recordClass)) { - Field fieldAnnotation = target.annotationSource.getAnnotation(Field.class); - Fields fieldsAnnotation = target.annotationSource.getAnnotation(Fields.class); - if (fieldAnnotation != null) { - validateFieldPattern(target, fieldAnnotation); - validateEnumFieldLength(target, fieldAnnotation); - } else if (fieldsAnnotation != null) { - for (Field field : fieldsAnnotation.value()) { - validateFieldPattern(target, field); - validateEnumFieldLength(target, field); - } - } + for (FieldDescriptor desc : ClassMetadataCache.INSTANCE.get(recordClass)) { + validateFieldPattern(desc.target, desc.fieldAnnotation); + validateEnumFieldLength(desc.target, desc.fieldAnnotation); } VALIDATED_CLASSES.add(recordClass); } @SuppressWarnings({"unchecked", "rawtypes"}) private void validateEnumFieldLength(AnnotationTarget target, Field fieldAnnotation) { + FormatInstructionsBuilder instructionsBuilder = new FormatInstructionsBuilder(); Class datatype = instructionsBuilder.datatype(target.getter, fieldAnnotation); if (!datatype.isEnum()) { return; @@ -219,6 +214,7 @@ private void validateEnumFieldLength(AnnotationTarget target, Field fieldAnnotat } private void validateFieldPattern(AnnotationTarget target, Field fieldAnnotation) { + FormatInstructionsBuilder instructionsBuilder = new FormatInstructionsBuilder(); Class datatype = instructionsBuilder.datatype(target.getter, fieldAnnotation); FixedFormatPattern patternAnnotation = target.annotationSource.getAnnotation(FixedFormatPattern.class); String pattern; @@ -254,65 +250,38 @@ private Record getAndAssertRecordAnnotation(Class fixedFormatRecordClass) return recordAnno; } + /** + * Reads a single non-repeating field from {@code data} and returns the parsed value. + * Protected for backward-compatibility with subclasses; the main load path uses the + * {@link ClassMetadataCache} directly. + * + * @deprecated Internal use only. Will be made private in a future release. + */ + @Deprecated @SuppressWarnings({"unchecked"}) - protected Object readDataAccordingFieldAnnotation(Class clazz, String data, Method getter, AnnotatedElement annotationSource, Field fieldAnno) throws ParseException { + protected Object readDataAccordingFieldAnnotation(Class clazz, String data, Method getter, java.lang.reflect.AnnotatedElement annotationSource, Field fieldAnno) throws ParseException { repeatingFieldSupport.validateCount(getter, fieldAnno); if (fieldAnno.count() > 1) { return repeatingFieldSupport.read(clazz, data, getter, annotationSource, fieldAnno); } + FormatInstructionsBuilder instructionsBuilder = new FormatInstructionsBuilder(); Class datatype = instructionsBuilder.datatype(getter, fieldAnno); - FormatContext context = instructionsBuilder.context(datatype, fieldAnno); - FixedFormatter formatter = getFixedFormatterInstance(context.getFormatter(), context); + FixedFormatter formatter = com.ancientprogramming.fixedformat4j.format.FixedFormatUtil.getFixedFormatterInstance(context.getFormatter(), context); FormatInstructions formatdata = instructionsBuilder.build(annotationSource, fieldAnno, datatype); String dataToParse = fetchData(data, formatdata, context); - Object loadedData; - - Annotation recordAnno = datatype.getAnnotation(Record.class); + java.lang.annotation.Annotation recordAnno = datatype.getAnnotation(Record.class); if (recordAnno != null) { - loadedData = load(datatype, dataToParse); - } else { - try { - loadedData = formatter.parse(dataToParse, formatdata); - } catch (RuntimeException e) { - throw new ParseException(data, dataToParse, clazz, getter, context, formatdata, e); - } - } - if (LOG.isDebugEnabled()) { - LOG.debug("the loaded data[{}]", loadedData); + return load(datatype, dataToParse); } - return loadedData; - } - - @SuppressWarnings({"unchecked"}) - private String exportDataAccordingFieldAnnotation(T fixedFormatRecord, AnnotationTarget target, Field fieldAnno) { - repeatingFieldSupport.validateCount(target.getter, fieldAnno); - - Class datatype = instructionsBuilder.datatype(target.getter, fieldAnno); - - FormatContext context = instructionsBuilder.context(datatype, fieldAnno); - FixedFormatter formatter = getFixedFormatterInstance(context.getFormatter(), context); - FormatInstructions formatdata = instructionsBuilder.build(target.annotationSource, fieldAnno, datatype); - Object valueObject; try { - valueObject = target.getter.invoke(fixedFormatRecord); - } catch (Exception e) { - throw new FixedFormatException(format("could not invoke method %s.%s(%s)", fixedFormatRecord.getClass().getName(), target.getter.getName(), datatype), e); - } - - String result; - if (valueObject != null && valueObject.getClass().getAnnotation(Record.class) != null) { - result = export(valueObject); - } else { - result = ((FixedFormatter) formatter).format(valueObject, formatdata); - } - if (LOG.isDebugEnabled()) { - LOG.debug(format("exported %s ", result)); + return formatter.parse(dataToParse, formatdata); + } catch (RuntimeException e) { + throw new ParseException(data, dataToParse, clazz, getter, context, formatdata, e); } - return result; } } diff --git a/fixedformat4j/src/test/java/com/ancientprogramming/fixedformat4j/format/impl/TestClassMetadataCache.java b/fixedformat4j/src/test/java/com/ancientprogramming/fixedformat4j/format/impl/TestClassMetadataCache.java new file mode 100644 index 0000000..cc524e8 --- /dev/null +++ b/fixedformat4j/src/test/java/com/ancientprogramming/fixedformat4j/format/impl/TestClassMetadataCache.java @@ -0,0 +1,114 @@ +package com.ancientprogramming.fixedformat4j.format.impl; + +import com.ancientprogramming.fixedformat4j.annotation.Field; +import com.ancientprogramming.fixedformat4j.annotation.Record; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.*; + +class TestClassMetadataCache { + + @Test + void cacheReturnsSameListInstanceOnSecondCall() { + ClassMetadataCache cache = new ClassMetadataCache(); + List first = cache.get(MyRecord.class); + List second = cache.get(MyRecord.class); + assertSame(first, second); + } + + @Test + void myRecordProducesTenDescriptors() { + ClassMetadataCache cache = new ClassMetadataCache(); + assertEquals(10, cache.get(MyRecord.class).size()); + } + + @Test + void eachSimpleFieldDescriptorHasNonNullMetadata() { + ClassMetadataCache cache = new ClassMetadataCache(); + for (FieldDescriptor d : cache.get(MyRecord.class)) { + String name = d.target.getter.getName(); + assertNotNull(d.target, name + ": target"); + assertNotNull(d.setter, name + ": setter"); + assertNotNull(d.fieldAnnotation, name + ": fieldAnnotation"); + assertNotNull(d.datatype, name + ": datatype"); + assertNotNull(d.context, name + ": context"); + assertNotNull(d.formatInstructions, name + ": formatInstructions"); + assertNotNull(d.formatter, name + ": formatter"); + } + } + + @Test + void allSimpleFieldDescriptorsAreMarkedAsLoadFields() { + ClassMetadataCache cache = new ClassMetadataCache(); + for (FieldDescriptor d : cache.get(MyRecord.class)) { + assertTrue(d.isLoadField, d.target.getter.getName() + " should be a load field"); + } + } + + @Test + void fieldsAnnotationExpandsIntoMultipleDescriptors() { + ClassMetadataCache cache = new ClassMetadataCache(); + List descriptors = cache.get(MultibleFieldsRecord.class); + long count = descriptors.stream() + .filter(d -> d.target.getter.getName().equals("getDateData")) + .count(); + assertEquals(2, count); + } + + @Test + void onlyFirstFieldsAnnotationDescriptorIsLoadField() { + ClassMetadataCache cache = new ClassMetadataCache(); + List dateDescriptors = cache.get(MultibleFieldsRecord.class).stream() + .filter(d -> d.target.getter.getName().equals("getDateData")) + .collect(Collectors.toList()); + assertEquals(2, dateDescriptors.size()); + assertTrue(dateDescriptors.get(0).isLoadField, "first @Fields descriptor should be load field"); + assertFalse(dateDescriptors.get(1).isLoadField, "second @Fields descriptor should not be load field"); + } + + @Test + void repeatingFieldDescriptorHasNullContextAndInstructions() { + ClassMetadataCache cache = new ClassMetadataCache(); + FieldDescriptor repeating = cache.get(RepeatingFieldRecord.class).stream() + .filter(d -> d.isRepeating) + .findFirst() + .orElseThrow(() -> new AssertionError("no repeating descriptor found")); + assertNull(repeating.context); + assertNull(repeating.formatInstructions); + assertNull(repeating.formatter); + } + + @Test + void nestedRecordFieldDescriptorHasNullFormatter() { + ClassMetadataCache cache = new ClassMetadataCache(); + FieldDescriptor nested = cache.get(NestedRecordHolder.class).stream() + .filter(d -> d.isNestedRecord) + .findFirst() + .orElseThrow(() -> new AssertionError("no nested record descriptor found")); + assertNull(nested.formatter); + assertNotNull(nested.context); + assertNotNull(nested.formatInstructions); + } + + @Test + void fieldWithCustomFormatterIsNotMarkedAsNestedRecord() { + ClassMetadataCache cache = new ClassMetadataCache(); + List descriptors = cache.get(MyOtherRecord.class); + assertEquals(1, descriptors.size()); + FieldDescriptor d = descriptors.get(0); + assertFalse(d.isNestedRecord, "custom formatter field should not be isNestedRecord"); + assertNotNull(d.formatter, "custom formatter field should have a cached formatter"); + } + + @Record + static class NestedRecordHolder { + private MyRecord inner; + + @Field(offset = 1, length = 70) + public MyRecord getInner() { return inner; } + public void setInner(MyRecord inner) { this.inner = inner; } + } +} diff --git a/fixedformat4j/src/test/java/com/ancientprogramming/fixedformat4j/format/impl/TestClassMetadataCacheConcurrency.java b/fixedformat4j/src/test/java/com/ancientprogramming/fixedformat4j/format/impl/TestClassMetadataCacheConcurrency.java new file mode 100644 index 0000000..501ef86 --- /dev/null +++ b/fixedformat4j/src/test/java/com/ancientprogramming/fixedformat4j/format/impl/TestClassMetadataCacheConcurrency.java @@ -0,0 +1,92 @@ +package com.ancientprogramming.fixedformat4j.format.impl; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.*; + +class TestClassMetadataCacheConcurrency { + + private static final int THREAD_COUNT = 20; + + @Test + void sameConcurrentAccessReturnsSameListInstance() throws Exception { + ClassMetadataCache cache = new ClassMetadataCache(); + CyclicBarrier barrier = new CyclicBarrier(THREAD_COUNT); + ExecutorService executor = Executors.newFixedThreadPool(THREAD_COUNT); + List>> futures = new ArrayList<>(); + + for (int i = 0; i < THREAD_COUNT; i++) { + futures.add(executor.submit(() -> { + barrier.await(); + return cache.get(MyRecord.class); + })); + } + + executor.shutdown(); + assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)); + + List reference = futures.get(0).get(); + assertEquals(10, reference.size(), "MyRecord should have 10 descriptors"); + for (Future> future : futures) { + assertSame(reference, future.get(), "all threads must receive the same cached list instance"); + } + } + + @Test + void eachDescriptorHasNonNullFormatterUnderConcurrentAccess() throws Exception { + ClassMetadataCache cache = new ClassMetadataCache(); + CyclicBarrier barrier = new CyclicBarrier(THREAD_COUNT); + ExecutorService executor = Executors.newFixedThreadPool(THREAD_COUNT); + List> futures = new ArrayList<>(); + + for (int i = 0; i < THREAD_COUNT; i++) { + futures.add(executor.submit(() -> { + barrier.await(); + return cache.get(MyRecord.class).stream().allMatch(d -> d.formatter != null); + })); + } + + executor.shutdown(); + assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)); + + for (Future future : futures) { + assertTrue(future.get(), "every simple-field descriptor must have a non-null formatter"); + } + } + + @Test + void differentClassConcurrentBuildsDoNotCrossContaminate() throws Exception { + Class[] classes = {MyRecord.class, MultibleFieldsRecord.class, RepeatingFieldRecord.class}; + int[] expectedCounts = {10, 4, 2}; + + ClassMetadataCache cache = new ClassMetadataCache(); + CyclicBarrier barrier = new CyclicBarrier(THREAD_COUNT); + ExecutorService executor = Executors.newFixedThreadPool(THREAD_COUNT); + List> futures = new ArrayList<>(); + + for (int i = 0; i < THREAD_COUNT; i++) { + final int idx = i % classes.length; + futures.add(executor.submit(() -> { + barrier.await(); + return new int[]{idx, cache.get(classes[idx]).size()}; + })); + } + + executor.shutdown(); + assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)); + + for (Future future : futures) { + int[] result = future.get(); + assertEquals(expectedCounts[result[0]], result[1], + "wrong descriptor count for " + classes[result[0]].getSimpleName()); + } + } +} diff --git a/fixedformat4j/src/test/java/com/ancientprogramming/fixedformat4j/format/impl/TestFixedFormatManagerConcurrency.java b/fixedformat4j/src/test/java/com/ancientprogramming/fixedformat4j/format/impl/TestFixedFormatManagerConcurrency.java new file mode 100644 index 0000000..6d21f71 --- /dev/null +++ b/fixedformat4j/src/test/java/com/ancientprogramming/fixedformat4j/format/impl/TestFixedFormatManagerConcurrency.java @@ -0,0 +1,95 @@ +package com.ancientprogramming.fixedformat4j.format.impl; + +import com.ancientprogramming.fixedformat4j.annotation.Align; +import com.ancientprogramming.fixedformat4j.annotation.Sign; +import com.ancientprogramming.fixedformat4j.format.FixedFormatManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.List; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static com.ancientprogramming.fixedformat4j.format.impl.TestFixedFormatManagerImpl.MY_RECORD_DATA; +import static org.junit.jupiter.api.Assertions.*; + +class TestFixedFormatManagerConcurrency { + + private static final int THREAD_COUNT = 20; + + private FixedFormatManager manager; + private MyRecord myRecord; + + @BeforeEach + void setUp() { + manager = new FixedFormatManagerImpl(); + + Calendar someDay = Calendar.getInstance(); + someDay.set(2008, 4, 14, 0, 0, 0); + someDay.set(Calendar.MILLISECOND, 0); + + myRecord = new MyRecord(); + myRecord.setBooleanData(true); + myRecord.setCharData('C'); + myRecord.setDateData(someDay.getTime()); + myRecord.setDoubleData(10.35); + myRecord.setFloatData(20.56F); + myRecord.setLongData(11L); + myRecord.setIntegerData(123); + myRecord.setStringData("some text "); + myRecord.setBigDecimalData(new BigDecimal(-12.012)); + myRecord.setSimpleFloatData(20.56F); + } + + @Test + void concurrentLoadProducesCorrectRecords() throws Exception { + CyclicBarrier barrier = new CyclicBarrier(THREAD_COUNT); + ExecutorService executor = Executors.newFixedThreadPool(THREAD_COUNT); + List> futures = new ArrayList<>(); + + for (int i = 0; i < THREAD_COUNT; i++) { + futures.add(executor.submit(() -> { + barrier.await(); + return manager.load(MyRecord.class, MY_RECORD_DATA); + })); + } + + executor.shutdown(); + assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)); + + for (Future future : futures) { + MyRecord loaded = future.get(); + assertEquals("some text ", loaded.getStringData()); + assertTrue(loaded.isBooleanData()); + assertEquals(123, loaded.getIntegerData()); + assertEquals(11L, loaded.getLongData()); + } + } + + @Test + void concurrentExportProducesCorrectString() throws Exception { + CyclicBarrier barrier = new CyclicBarrier(THREAD_COUNT); + ExecutorService executor = Executors.newFixedThreadPool(THREAD_COUNT); + List> futures = new ArrayList<>(); + + for (int i = 0; i < THREAD_COUNT; i++) { + futures.add(executor.submit(() -> { + barrier.await(); + return manager.export(myRecord); + })); + } + + executor.shutdown(); + assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)); + + for (Future future : futures) { + assertEquals(MY_RECORD_DATA, future.get()); + } + } +}