diff --git a/record-builder-core/src/main/java/io/soabase/recordbuilder/core/RecordBuilder.java b/record-builder-core/src/main/java/io/soabase/recordbuilder/core/RecordBuilder.java index 34c6c0a6..1e98e3b8 100644 --- a/record-builder-core/src/main/java/io/soabase/recordbuilder/core/RecordBuilder.java +++ b/record-builder-core/src/main/java/io/soabase/recordbuilder/core/RecordBuilder.java @@ -204,6 +204,16 @@ */ boolean useUnmodifiableCollections() default false; + /** + * Adds special handling for record components of type: {@link java.util.List}, {@link java.util.Set}, + * {@link java.util.Map} and {@link java.util.Collection}. When the record is built, any components of these + * types are passed through an added shim method that uses the corresponding immutable collection (e.g. + * {@code List.copyOf(o)}), or {@code null} if the component is {@code null}. If nulls are interpreted (see + * {@link #interpretNotNulls()}), the record component will return {@code null} only if it's NOT annotated by + * any of the not-null annotations (defined by {@link #interpretNotNullsPattern()}). + */ + boolean allowNullableCollections() default false; + /** * When enabled, collection types ({@code List}, {@code Set} and {@code Map}) are handled specially. The setters * for these types now create an internal collection and items are added to that collection. Additionally, diff --git a/record-builder-processor/src/main/java/io/soabase/recordbuilder/processor/CollectionBuilderUtils.java b/record-builder-processor/src/main/java/io/soabase/recordbuilder/processor/CollectionBuilderUtils.java index 5e1ac190..78312b32 100644 --- a/record-builder-processor/src/main/java/io/soabase/recordbuilder/processor/CollectionBuilderUtils.java +++ b/record-builder-processor/src/main/java/io/soabase/recordbuilder/processor/CollectionBuilderUtils.java @@ -20,6 +20,7 @@ import javax.lang.model.element.Modifier; import java.util.*; +import java.util.regex.Pattern; import static io.soabase.recordbuilder.processor.RecordBuilderProcessor.generatedRecordBuilderAnnotation; import static io.soabase.recordbuilder.processor.RecordBuilderProcessor.recordBuilderGeneratedAnnotation; @@ -27,13 +28,23 @@ class CollectionBuilderUtils { private final boolean useImmutableCollections; private final boolean useUnmodifiableCollections; + private final boolean allowNullableCollections; private final boolean addSingleItemCollectionBuilders; private final boolean addClassRetainedGenerated; + + private final boolean interpretNotNulls; + private final Pattern notNullPattern; + private final String listShimName; private final String mapShimName; private final String setShimName; private final String collectionShimName; + private final String nullableListShimName; + private final String nullableMapShimName; + private final String nullableSetShimName; + private final String nullableCollectionShimName; + private final String listMakerMethodName; private final String mapMakerMethodName; private final String setMakerMethodName; @@ -43,6 +54,11 @@ class CollectionBuilderUtils { private boolean needsSetShim; private boolean needsCollectionShim; + private boolean needsNullableListShim; + private boolean needsNullableMapShim; + private boolean needsNullableSetShim; + private boolean needsNullableCollectionShim; + private boolean needsListMutableMaker; private boolean needsMapMutableMaker; private boolean needsSetMutableMaker; @@ -83,14 +99,23 @@ class CollectionBuilderUtils { CollectionBuilderUtils(List recordComponents, RecordBuilder.Options metaData) { useImmutableCollections = metaData.useImmutableCollections(); useUnmodifiableCollections = !useImmutableCollections && metaData.useUnmodifiableCollections(); + allowNullableCollections = metaData.allowNullableCollections(); addSingleItemCollectionBuilders = metaData.addSingleItemCollectionBuilders(); addClassRetainedGenerated = metaData.addClassRetainedGenerated(); + interpretNotNulls = metaData.interpretNotNulls(); + notNullPattern = Pattern.compile(metaData.interpretNotNullsPattern()); + listShimName = disambiguateGeneratedMethodName(recordComponents, "__list", 0); mapShimName = disambiguateGeneratedMethodName(recordComponents, "__map", 0); setShimName = disambiguateGeneratedMethodName(recordComponents, "__set", 0); collectionShimName = disambiguateGeneratedMethodName(recordComponents, "__collection", 0); + nullableListShimName = disambiguateGeneratedMethodName(recordComponents, "__nullableList", 0); + nullableMapShimName = disambiguateGeneratedMethodName(recordComponents, "__nullableMap", 0); + nullableSetShimName = disambiguateGeneratedMethodName(recordComponents, "__nullableSet", 0); + nullableCollectionShimName = disambiguateGeneratedMethodName(recordComponents, "__nullableCollection", 0); + listMakerMethodName = disambiguateGeneratedMethodName(recordComponents, "__ensureListMutable", 0); setMakerMethodName = disambiguateGeneratedMethodName(recordComponents, "__ensureSetMutable", 0); mapMakerMethodName = disambiguateGeneratedMethodName(recordComponents, "__ensureMapMutable", 0); @@ -162,6 +187,16 @@ boolean isImmutableCollection(RecordClassType component) { && (isList(component) || isMap(component) || isSet(component) || isCollection(component)); } + boolean isNullableCollection(RecordClassType component) { + return allowNullableCollections && (!interpretNotNulls || !isNotNullAnnotated(component)) + && (isList(component) || isMap(component) || isSet(component) || isCollection(component)); + } + + private boolean isNotNullAnnotated(RecordClassType component) { + return component.getCanonicalConstructorAnnotations().stream().anyMatch(annotation -> notNullPattern + .matcher(annotation.getAnnotationType().asElement().getSimpleName().toString()).matches()); + } + boolean isList(RecordClassType component) { return component.rawTypeName().equals(listTypeName); } @@ -181,20 +216,40 @@ private boolean isCollection(RecordClassType component) { void addShimCall(CodeBlock.Builder builder, RecordClassType component) { if (useImmutableCollections || useUnmodifiableCollections) { if (isList(component)) { - needsListShim = true; - needsListMutableMaker = true; - builder.add("$L($L)", listShimName, component.name()); + if (isNullableCollection(component)) { + needsNullableListShim = true; + builder.add("$L($L)", nullableListShimName, component.name()); + } else { + needsListShim = true; + needsListMutableMaker = true; + builder.add("$L($L)", listShimName, component.name()); + } } else if (isMap(component)) { - needsMapShim = true; - needsMapMutableMaker = true; - builder.add("$L($L)", mapShimName, component.name()); + if (isNullableCollection(component)) { + needsNullableMapShim = true; + builder.add("$L($L)", nullableMapShimName, component.name()); + } else { + needsMapShim = true; + needsMapMutableMaker = true; + builder.add("$L($L)", mapShimName, component.name()); + } } else if (isSet(component)) { - needsSetShim = true; - needsSetMutableMaker = true; - builder.add("$L($L)", setShimName, component.name()); + if (isNullableCollection(component)) { + needsNullableSetShim = true; + builder.add("$L($L)", nullableSetShimName, component.name()); + } else { + needsSetShim = true; + needsSetMutableMaker = true; + builder.add("$L($L)", setShimName, component.name()); + } } else if (isCollection(component)) { - needsCollectionShim = true; - builder.add("$L($L)", collectionShimName, component.name()); + if (isNullableCollection(component)) { + needsNullableCollectionShim = true; + builder.add("$L($L)", nullableCollectionShimName, component.name()); + } else { + needsCollectionShim = true; + builder.add("$L($L)", collectionShimName, component.name()); + } } else { builder.add("$L", component.name()); } @@ -205,13 +260,13 @@ void addShimCall(CodeBlock.Builder builder, RecordClassType component) { String shimName(RecordClassType component) { if (isList(component)) { - return listShimName; + return isNullableCollection(component) ? nullableListShimName : listShimName; } else if (isMap(component)) { - return mapShimName; + return isNullableCollection(component) ? nullableMapShimName : mapShimName; } else if (isSet(component)) { - return setShimName; + return isNullableCollection(component) ? nullableSetShimName : setShimName; } else if (isCollection(component)) { - return collectionShimName; + return isNullableCollection(component) ? nullableCollectionShimName : collectionShimName; } else { throw new IllegalArgumentException(component + " is not a supported collection type"); } @@ -238,15 +293,33 @@ void addShims(TypeSpec.Builder builder) { builder.addMethod( buildShimMethod(listShimName, listTypeName, collectionType, parameterizedListType, tType)); } + if (needsNullableListShim) { + builder.addMethod(buildNullableShimMethod(nullableListShimName, listTypeName, collectionType, + parameterizedListType, tType)); + } + if (needsSetShim) { builder.addMethod(buildShimMethod(setShimName, setTypeName, collectionType, parameterizedSetType, tType)); } + if (needsNullableSetShim) { + builder.addMethod(buildNullableShimMethod(nullableSetShimName, setTypeName, collectionType, + parameterizedSetType, tType)); + } + if (needsMapShim) { builder.addMethod(buildShimMethod(mapShimName, mapTypeName, mapType, parameterizedMapType, kType, vType)); } + if (needsNullableMapShim) { + builder.addMethod(buildNullableShimMethod(nullableMapShimName, mapTypeName, mapType, parameterizedMapType, + kType, vType)); + } + if (needsCollectionShim) { builder.addMethod(buildCollectionsShimMethod()); } + if (needsNullableCollectionShim) { + builder.addMethod(buildNullableCollectionsShimMethod()); + } } void addMutableMakers(TypeSpec.Builder builder) { @@ -336,7 +409,42 @@ private CodeBlock buildShimMethodBody(TypeName mainType, ParameterizedTypeName p collectionsTypeName, kType, vType, parameterizedType, collectionsTypeName, kType, vType); } - throw new IllegalStateException("Cannot build shim method for" + mainType); + throw new IllegalStateException("Cannot build shim method for " + mainType); + } + + private MethodSpec buildNullableShimMethod(String name, TypeName mainType, Class abstractType, + ParameterizedTypeName parameterizedType, TypeVariableName... typeVariables) { + var code = buildNullableShimMethodBody(mainType, parameterizedType); + + TypeName[] wildCardTypeArguments = parameterizedType.typeArguments.stream().map(WildcardTypeName::subtypeOf) + .toList().toArray(new TypeName[0]); + var extendedParameterizedType = ParameterizedTypeName.get(ClassName.get(abstractType), wildCardTypeArguments); + return MethodSpec.methodBuilder(name).addAnnotation(generatedRecordBuilderAnnotation) + .addModifiers(Modifier.PRIVATE, Modifier.STATIC).addTypeVariables(Arrays.asList(typeVariables)) + .returns(parameterizedType).addParameter(extendedParameterizedType, "o").addStatement(code).build(); + } + + private CodeBlock buildNullableShimMethodBody(TypeName mainType, ParameterizedTypeName parameterizedType) { + if (!useUnmodifiableCollections) { + return CodeBlock.of("return (o != null) ? $T.copyOf(o) : null", mainType); + } + + if (mainType.equals(listTypeName)) { + return CodeBlock.of("return (o != null) ? $T.<$T>unmodifiableList(($T) o) : null", collectionsTypeName, + tType, parameterizedType); + } + + if (mainType.equals(setTypeName)) { + return CodeBlock.of("return (o != null) ? $T.<$T>unmodifiableSet(($T) o) : null", collectionsTypeName, + tType, parameterizedType); + } + + if (mainType.equals(mapTypeName)) { + return CodeBlock.of("return (o != null) ? $T.<$T>unmodifiableMap(($T) o) : null", collectionsTypeName, + tType, parameterizedType); + } + + throw new IllegalStateException("Cannot build shim method for " + mainType); } private MethodSpec buildMutableMakerMethod(String name, String mutableCollectionType, @@ -393,4 +501,28 @@ private CodeBlock buildCollectionShimMethodBody() { .addStatement("return $T.<$T>unmodifiableSet(($T) o)", collectionsTypeName, tType, parameterizedSetType) .endControlFlow().addStatement("return $T.<$T>emptyList()", collectionsTypeName, tType).build(); } + + private MethodSpec buildNullableCollectionsShimMethod() { + var code = buildNullableCollectionShimMethodBody(); + + return MethodSpec.methodBuilder(nullableCollectionShimName).addAnnotation(generatedRecordBuilderAnnotation) + .addModifiers(Modifier.PRIVATE, Modifier.STATIC).addTypeVariable(tType) + .returns(parameterizedCollectionType).addParameter(parameterizedCollectionType, "o").addCode(code) + .build(); + } + + private CodeBlock buildNullableCollectionShimMethodBody() { + if (!useUnmodifiableCollections) { + return CodeBlock.builder().add("if (o instanceof Set) {\n").indent() + .addStatement("return $T.copyOf(o)", setTypeName).unindent().addStatement("}") + .addStatement("return (o != null) ? $T.copyOf(o) : null", listTypeName).build(); + } + + return CodeBlock.builder().beginControlFlow("if (o instanceof $T)", listType) + .addStatement("return $T.<$T>unmodifiableList(($T) o)", collectionsTypeName, tType, + parameterizedListType) + .endControlFlow().beginControlFlow("if (o instanceof $T)", setType) + .addStatement("return $T.<$T>unmodifiableSet(($T) o)", collectionsTypeName, tType, parameterizedSetType) + .endControlFlow().addStatement("return null").build(); + } } diff --git a/record-builder-processor/src/main/java/io/soabase/recordbuilder/processor/RecordBuilderProcessor.java b/record-builder-processor/src/main/java/io/soabase/recordbuilder/processor/RecordBuilderProcessor.java index c3381095..ac5b34f0 100644 --- a/record-builder-processor/src/main/java/io/soabase/recordbuilder/processor/RecordBuilderProcessor.java +++ b/record-builder-processor/src/main/java/io/soabase/recordbuilder/processor/RecordBuilderProcessor.java @@ -198,11 +198,16 @@ private void processRecordBuilder(TypeElement record, RecordBuilder.Options meta private void validateMetaData(RecordBuilder.Options metaData, Element record) { var useImmutableCollections = metaData.useImmutableCollections(); var useUnmodifiableCollections = metaData.useUnmodifiableCollections(); + var allowNullableCollections = metaData.allowNullableCollections(); if (useImmutableCollections && useUnmodifiableCollections) { processingEnv.getMessager().printMessage(Diagnostic.Kind.MANDATORY_WARNING, "Options.useUnmodifiableCollections property is ignored as Options.useImmutableCollections is set to true", record); + } else if (!useImmutableCollections && !useUnmodifiableCollections && allowNullableCollections) { + processingEnv.getMessager().printMessage(Diagnostic.Kind.MANDATORY_WARNING, + "Options.allowNullableCollections property will have no effect as Options.useImmutableCollections and Options.useUnmodifiableCollections are set to false", + record); } } diff --git a/record-builder-test/src/main/java/io/soabase/recordbuilder/test/NonspecializedNullabeCollectionRecord.java b/record-builder-test/src/main/java/io/soabase/recordbuilder/test/NonspecializedNullabeCollectionRecord.java new file mode 100644 index 00000000..1a81cdc3 --- /dev/null +++ b/record-builder-test/src/main/java/io/soabase/recordbuilder/test/NonspecializedNullabeCollectionRecord.java @@ -0,0 +1,33 @@ +/* + * Copyright 2019 The original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.soabase.recordbuilder.test; + +import io.soabase.recordbuilder.core.RecordBuilder; + +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * To test that the compilation warning is emitted for this combination of options + */ +@RecordBuilder +@RecordBuilder.Options(useImmutableCollections = false, useUnmodifiableCollections = false, allowNullableCollections = true) +public record NonspecializedNullabeCollectionRecord(List l, Set s, Map m, + Collection c) implements CollectionRecordBuilder.With { + +} diff --git a/record-builder-test/src/main/java/io/soabase/recordbuilder/test/NullableCollectionRecord.java b/record-builder-test/src/main/java/io/soabase/recordbuilder/test/NullableCollectionRecord.java new file mode 100644 index 00000000..56cb780d --- /dev/null +++ b/record-builder-test/src/main/java/io/soabase/recordbuilder/test/NullableCollectionRecord.java @@ -0,0 +1,30 @@ +/* + * Copyright 2019 The original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.soabase.recordbuilder.test; + +import io.soabase.recordbuilder.core.RecordBuilder; + +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Set; + +@RecordBuilder +@RecordBuilder.Options(useImmutableCollections = true, allowNullableCollections = true) +public record NullableCollectionRecord(List l, Set s, Map m, Collection c) + implements CollectionRecordBuilder.With { + +} diff --git a/record-builder-test/src/main/java/io/soabase/recordbuilder/test/NullableCollectionRecordInterpretingNotNulls.java b/record-builder-test/src/main/java/io/soabase/recordbuilder/test/NullableCollectionRecordInterpretingNotNulls.java new file mode 100644 index 00000000..49764b93 --- /dev/null +++ b/record-builder-test/src/main/java/io/soabase/recordbuilder/test/NullableCollectionRecordInterpretingNotNulls.java @@ -0,0 +1,31 @@ +/* + * Copyright 2019 The original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.soabase.recordbuilder.test; + +import io.soabase.recordbuilder.core.RecordBuilder; + +import javax.validation.constraints.NotNull; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Set; + +@RecordBuilder +@RecordBuilder.Options(useImmutableCollections = true, allowNullableCollections = true, interpretNotNulls = true) +public record NullableCollectionRecordInterpretingNotNulls(@NotNull List l, Set s, + Map m, @NotNull Collection c) implements CollectionRecordBuilder.With { + +} diff --git a/record-builder-test/src/test/java/io/soabase/recordbuilder/test/TestNullableCollectionsBuilder.java b/record-builder-test/src/test/java/io/soabase/recordbuilder/test/TestNullableCollectionsBuilder.java new file mode 100644 index 00000000..2c27c56e --- /dev/null +++ b/record-builder-test/src/test/java/io/soabase/recordbuilder/test/TestNullableCollectionsBuilder.java @@ -0,0 +1,92 @@ +/* + * Copyright 2019 The original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.soabase.recordbuilder.test; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Set; + +public class TestNullableCollectionsBuilder { + + @Test + void testNullableCollectionsBuilder_returnsTheActualValuesIfSet() { + List list = List.of("list"); + Set set = Set.of("set"); + Collection collection = List.of(new Point(10, 20)); + Map map = Map.of("one", new Point(10, 20)); + + NullableCollectionRecordBuilder builder = NullableCollectionRecordBuilder.builder().l(list) + .s(set).c(collection).m(map); + + Assertions.assertEquals(list, builder.l()); + Assertions.assertEquals(set, builder.s()); + Assertions.assertEquals(collection, builder.c()); + Assertions.assertEquals(map, builder.m()); + + var record = builder.build(); + Assertions.assertEquals(list, record.l()); + Assertions.assertEquals(set, record.s()); + Assertions.assertEquals(collection, record.c()); + Assertions.assertEquals(map, record.m()); + } + + @Test + void testNullableCollectionsBuilder_whenNullsAreNotInterpreted_returnsNullForAllComponents_whenNullsAreNotInterpreted() { + NullableCollectionRecordBuilder builder = NullableCollectionRecordBuilder.builder(); + + Assertions.assertNull(builder.l()); + Assertions.assertNull(builder.m()); + Assertions.assertNull(builder.c()); + Assertions.assertNull(builder.s()); + + var record = builder.build(); + Assertions.assertNull(record.l()); + Assertions.assertNull(record.m()); + Assertions.assertNull(record.c()); + Assertions.assertNull(record.s()); + } + + @Test + void testNullableCollectionBuilder_whenNullsAreInterpreted_returnsNullOrEmptyCollectionBasedOnComponentNullability() { + // NotNull - list, collection + // Nullable - set, map + NullableCollectionRecordInterpretingNotNullsBuilder builder = NullableCollectionRecordInterpretingNotNullsBuilder + .builder(); + + Assertions.assertNotNull(builder.l()); + Assertions.assertTrue(builder.l().isEmpty()); + + Assertions.assertNull(builder.m()); + Assertions.assertNull(builder.s()); + + Assertions.assertNotNull(builder.c()); + Assertions.assertTrue(builder.c().isEmpty()); + + var record = builder.build(); + Assertions.assertNotNull(record.l()); + Assertions.assertTrue(record.l().isEmpty()); + + Assertions.assertNull(record.m()); + Assertions.assertNull(record.s()); + + Assertions.assertNotNull(record.c()); + Assertions.assertTrue(record.c().isEmpty()); + } +}