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 c604ba3e..595f9cf9 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 @@ -73,6 +73,12 @@ */ String interfaceSuffix() default "Record"; + /** + * Used by {@code RecordTuple}. The generated record will have the same name as the annotated element plus this + * suffix. E.g. if the element name is "Foo", the record will be named "FooTuple". + */ + String tupleSuffix() default "Tuple"; + /** * The name to use for the copy builder */ diff --git a/record-builder-core/src/main/java/io/soabase/recordbuilder/core/RecordTuple.java b/record-builder-core/src/main/java/io/soabase/recordbuilder/core/RecordTuple.java new file mode 100644 index 00000000..7ed341a5 --- /dev/null +++ b/record-builder-core/src/main/java/io/soabase/recordbuilder/core/RecordTuple.java @@ -0,0 +1,29 @@ +/* + * 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.core; + +import java.lang.annotation.*; + +@Retention(RetentionPolicy.SOURCE) +@Target(ElementType.TYPE) +@Inherited +public @interface RecordTuple { + @Retention(RetentionPolicy.SOURCE) + @Target(ElementType.METHOD) + @interface Component { + String value() default ""; + } +} diff --git a/record-builder-processor/src/main/java/io/soabase/recordbuilder/processor/ElementUtils.java b/record-builder-processor/src/main/java/io/soabase/recordbuilder/processor/ElementUtils.java index cb6fcec9..3a657dad 100644 --- a/record-builder-processor/src/main/java/io/soabase/recordbuilder/processor/ElementUtils.java +++ b/record-builder-processor/src/main/java/io/soabase/recordbuilder/processor/ElementUtils.java @@ -24,13 +24,12 @@ import javax.annotation.processing.ProcessingEnvironment; import javax.lang.model.element.*; import javax.lang.model.type.TypeMirror; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.Optional; +import java.util.*; import java.util.stream.Collectors; public class ElementUtils { + private static final Set javaBeanPrefixes = Set.of("get", "is"); + public static Optional findAnnotationMirror(ProcessingEnvironment processingEnv, Element element, String annotationClass) { return processingEnv.getElementUtils().getAllAnnotationMirrors(element).stream() @@ -164,6 +163,14 @@ public static Optional findCanonicalConstructor(TypeElement r }).findFirst(); } + public static Optional stripBeanPrefix(String name) { + return javaBeanPrefixes.stream().filter(prefix -> name.startsWith(prefix) && (name.length() > prefix.length())) + .findFirst().map(prefix -> { + var stripped = name.substring(prefix.length()); + return Character.toLowerCase(stripped.charAt(0)) + stripped.substring(1); + }); + } + private static String getBuilderNamePrefix(Element element) { // prefix enclosing class names if nested in a class if (element instanceof TypeElement) { diff --git a/record-builder-processor/src/main/java/io/soabase/recordbuilder/processor/InternalRecordInterfaceProcessor.java b/record-builder-processor/src/main/java/io/soabase/recordbuilder/processor/InternalRecordInterfaceProcessor.java index b56da57a..add626d7 100644 --- a/record-builder-processor/src/main/java/io/soabase/recordbuilder/processor/InternalRecordInterfaceProcessor.java +++ b/record-builder-processor/src/main/java/io/soabase/recordbuilder/processor/InternalRecordInterfaceProcessor.java @@ -40,8 +40,6 @@ class InternalRecordInterfaceProcessor { private final List recordComponents; private final ClassType recordClassType; - private static final Set javaBeanPrefixes = Set.of("get", "is"); - private record Component(ExecutableElement element, Optional alternateName) { } @@ -154,7 +152,6 @@ private static class IllegalInterface extends RuntimeException { public IllegalInterface(String message) { super(message); } - } private void getRecordComponents(TypeElement iface, Collection components, Set visitedSet, @@ -183,19 +180,12 @@ private void getRecordComponents(TypeElement iface, Collection compon iface.getSimpleName(), element.getSimpleName())); } }).filter(element -> usedNames.add(element.getSimpleName().toString())) - .map(element -> new Component(element, stripBeanPrefix(element.getSimpleName().toString()))) + .map(element -> new Component(element, + ElementUtils.stripBeanPrefix(element.getSimpleName().toString()))) .collect(Collectors.toCollection(() -> components)); iface.getInterfaces().forEach(parentIface -> { TypeElement parentIfaceElement = (TypeElement) processingEnv.getTypeUtils().asElement(parentIface); getRecordComponents(parentIfaceElement, components, visitedSet, usedNames); }); } - - private Optional stripBeanPrefix(String name) { - return javaBeanPrefixes.stream().filter(prefix -> name.startsWith(prefix) && (name.length() > prefix.length())) - .findFirst().map(prefix -> { - var stripped = name.substring(prefix.length()); - return Character.toLowerCase(stripped.charAt(0)) + stripped.substring(1); - }); - } } diff --git a/record-builder-processor/src/main/java/io/soabase/recordbuilder/processor/InternalRecordTupleProcessor.java b/record-builder-processor/src/main/java/io/soabase/recordbuilder/processor/InternalRecordTupleProcessor.java new file mode 100644 index 00000000..a2bacc87 --- /dev/null +++ b/record-builder-processor/src/main/java/io/soabase/recordbuilder/processor/InternalRecordTupleProcessor.java @@ -0,0 +1,203 @@ +/* + * 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.processor; + +import com.squareup.javapoet.*; +import io.soabase.recordbuilder.core.RecordBuilder; +import io.soabase.recordbuilder.core.RecordTuple; + +import javax.annotation.processing.ProcessingEnvironment; +import javax.lang.model.element.ElementKind; +import javax.lang.model.element.ExecutableElement; +import javax.lang.model.element.Modifier; +import javax.lang.model.element.TypeElement; +import javax.lang.model.type.TypeKind; +import javax.tools.Diagnostic; +import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import static io.soabase.recordbuilder.processor.ElementUtils.getBuilderName; +import static io.soabase.recordbuilder.processor.RecordBuilderProcessor.*; + +class InternalRecordTupleProcessor { + private final ProcessingEnvironment processingEnv; + private final String packageName; + private final TypeSpec recordType; + private final List recordComponents; + private final ClassType recordClassType; + private final List typeVariables; + + private record Component(ExecutableElement element, Optional alternateName) { + } + + InternalRecordTupleProcessor(ProcessingEnvironment processingEnv, TypeElement element, + RecordBuilder.Options metaData, Optional packageNameOpt, boolean fromTemplate) { + this.processingEnv = processingEnv; + packageName = packageNameOpt.orElseGet(() -> ElementUtils.getPackageName(element)); + recordComponents = getRecordComponents(element); + + ClassType ifaceClassType = ElementUtils.getClassType(element, element.getTypeParameters()); + recordClassType = ElementUtils.getClassType(packageName, + getBuilderName(element, metaData, ifaceClassType, metaData.tupleSuffix()), element.getTypeParameters()); + typeVariables = element.getTypeParameters().stream().map(TypeVariableName::get).collect(Collectors.toList()); + + TypeSpec.Builder builder = TypeSpec.recordBuilder(recordClassType.name()).addTypeVariables(typeVariables); + if (metaData.addClassRetainedGenerated()) { + builder.addAnnotation(generatedRecordTupleAnnotation); + } + + var actualPackage = ElementUtils.getPackageName(element); + addVisibility(builder, actualPackage.equals(packageName), element.getModifiers()); + + recordComponents.forEach(component -> { + String name = component.alternateName.orElseGet(() -> component.element.getSimpleName().toString()); + FieldSpec parameterSpec = FieldSpec.builder(ClassName.get(component.element.getReturnType()), name).build(); + builder.addTypeVariables(component.element.getTypeParameters().stream().map(TypeVariableName::get) + .collect(Collectors.toList())); + builder.addField(parameterSpec); + }); + + addFromMethod(builder, element, metaData.fromMethodName()); + + recordType = builder.build(); + } + + boolean isValid() { + return !recordComponents.isEmpty(); + } + + TypeSpec recordType() { + return recordType; + } + + String packageName() { + return packageName; + } + + ClassType recordClassType() { + return recordClassType; + } + + private void addFromMethod(TypeSpec.Builder builder, TypeElement element, String fromName) { + MethodSpec.Builder methodBuilder = MethodSpec.methodBuilder(fromName) + .addAnnotation(generatedRecordTupleAnnotation).addModifiers(Modifier.PUBLIC, Modifier.STATIC) + .returns(recordClassType.typeName()).addTypeVariables(typeVariables) + .addParameter(ClassName.get(element.asType()), fromName); + + CodeBlock.Builder codeBuilder = CodeBlock.builder(); + codeBuilder.add("return new $T(", recordClassType.typeName()); + IntStream.range(0, recordComponents.size()).forEach(index -> { + if (index > 0) { + codeBuilder.add(", "); + } + + Component component = recordComponents.get(index); + codeBuilder.add("$L.$L()", fromName, component.element.getSimpleName()); + }); + codeBuilder.addStatement(")"); + + methodBuilder.addCode(codeBuilder.build()); + + builder.addMethod(methodBuilder.build()); + } + + private void addVisibility(TypeSpec.Builder builder, boolean builderIsInRecordPackage, Set modifiers) { + if (builderIsInRecordPackage) { + if (modifiers.contains(Modifier.PUBLIC) || modifiers.contains(Modifier.PRIVATE) + || modifiers.contains(Modifier.PROTECTED)) { + builder.addModifiers(Modifier.PUBLIC); // builders are top level classes - can only be public or + // package-private + } + // is package-private + } else { + builder.addModifiers(Modifier.PUBLIC); + } + } + + private List getRecordComponents(TypeElement iface) { + List components = new ArrayList<>(); + try { + getRecordComponents(iface, components, new HashSet<>(), new HashSet<>()); + if (components.isEmpty()) { + processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, + "Annotated interface has no component methods", iface); + } + } catch (IllegalTuple e) { + processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, e.getMessage(), iface); + components = Collections.emptyList(); + } + return components; + } + + private static class IllegalTuple extends RuntimeException { + public IllegalTuple(String message) { + super(message); + } + } + + private void getRecordComponents(TypeElement iface, Collection components, Set visitedSet, + Set usedNames) { + if (!visitedSet.add(iface.getQualifiedName().toString())) { + return; + } + + iface.getEnclosedElements().forEach(element -> { + RecordTuple.Component component = element.getAnnotation(RecordTuple.Component.class); + + if (component == null) { + return; + } + + if (element.getKind() != ElementKind.METHOD || element.getModifiers().contains(Modifier.STATIC) + || !element.getModifiers().contains(Modifier.PUBLIC)) { + throw new IllegalTuple( + String.format("RecordTuple.Component must be public non-static methods. Bad method: %s.%s()", + iface.getSimpleName(), element.getSimpleName())); + } + + ExecutableElement executableElement = (ExecutableElement) element; + + if (!executableElement.getParameters().isEmpty() + || executableElement.getReturnType().getKind() == TypeKind.VOID) { + throw new IllegalTuple(String.format( + "RecordTuple.Component methods must take no arguments and must return a value. Bad method: %s.%s()", + iface.getSimpleName(), executableElement.getSimpleName())); + } + if (!executableElement.getTypeParameters().isEmpty()) { + throw new IllegalTuple( + String.format("RecordTuple.Component methods cannot have type parameters. Bad method: %s.%s()", + iface.getSimpleName(), element.getSimpleName())); + } + + if (usedNames.add(element.getSimpleName().toString())) { + Optional alternateName; + if (component.value().isEmpty()) { + alternateName = ElementUtils.stripBeanPrefix(element.getSimpleName().toString()); + } else { + alternateName = Optional.of(component.value()); + } + components.add(new Component(executableElement, alternateName)); + } + }); + + iface.getInterfaces().forEach(parentIface -> { + TypeElement parentIfaceElement = (TypeElement) processingEnv.getTypeUtils().asElement(parentIface); + getRecordComponents(parentIfaceElement, components, visitedSet, usedNames); + }); + } + +} 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 8591a064..96ced4c3 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 @@ -20,6 +20,7 @@ import com.squareup.javapoet.TypeSpec; import io.soabase.recordbuilder.core.RecordBuilder; import io.soabase.recordbuilder.core.RecordBuilderGenerated; +import io.soabase.recordbuilder.core.RecordTuple; import io.soabase.recordbuilder.core.RecordInterface; import javax.annotation.processing.AbstractProcessor; @@ -45,11 +46,14 @@ public class RecordBuilderProcessor extends AbstractProcessor { private static final String RECORD_BUILDER_INCLUDE = RecordBuilder.Include.class.getName().replace('$', '.'); private static final String RECORD_INTERFACE = RecordInterface.class.getName(); private static final String RECORD_INTERFACE_INCLUDE = RecordInterface.Include.class.getName().replace('$', '.'); + private static final String RECORD_TUPLE = RecordTuple.class.getName(); static final AnnotationSpec generatedRecordBuilderAnnotation = AnnotationSpec.builder(Generated.class) .addMember("value", "$S", RecordBuilder.class.getName()).build(); static final AnnotationSpec generatedRecordInterfaceAnnotation = AnnotationSpec.builder(Generated.class) .addMember("value", "$S", RecordInterface.class.getName()).build(); + static final AnnotationSpec generatedRecordTupleAnnotation = AnnotationSpec.builder(Generated.class) + .addMember("value", "$S", RecordTuple.class.getName()).build(); static final AnnotationSpec recordBuilderGeneratedAnnotation = AnnotationSpec.builder(RecordBuilderGenerated.class) .build(); @@ -83,6 +87,9 @@ private void process(TypeElement annotation, Element element) { var typeElement = (TypeElement) element; processRecordInterface(typeElement, element.getAnnotation(RecordInterface.class).addRecordBuilder(), getMetaData(typeElement), Optional.empty(), false); + } else if (annotationClass.equals(RECORD_TUPLE)) { + var typeElement = (TypeElement) element; + processRecordTuple(typeElement, getMetaData(typeElement), Optional.empty(), false); } else if (annotationClass.equals(RECORD_BUILDER_INCLUDE) || annotationClass.equals(RECORD_INTERFACE_INCLUDE)) { processIncludes(element, getMetaData(element), annotationClass); } else { @@ -178,6 +185,19 @@ private void processRecordInterface(TypeElement element, boolean addRecordBuilde internalProcessor.recordType(), metaData); } + private void processRecordTuple(TypeElement element, RecordBuilder.Options metaData, Optional packageName, + boolean fromTemplate) { + validateMetaData(metaData, element); + + var internalProcessor = new InternalRecordTupleProcessor(processingEnv, element, metaData, packageName, + fromTemplate); + if (!internalProcessor.isValid()) { + return; + } + writeRecordInterfaceJavaFile(element, internalProcessor.packageName(), internalProcessor.recordClassType(), + internalProcessor.recordType(), metaData); + } + private void processRecordBuilder(TypeElement record, RecordBuilder.Options metaData, Optional packageName) { // we use string based name comparison for the element kind, diff --git a/record-builder-test/pom.xml b/record-builder-test/pom.xml index 1203ee42..2235408f 100644 --- a/record-builder-test/pom.xml +++ b/record-builder-test/pom.xml @@ -28,6 +28,8 @@ ${project.parent.basedir}/src/etc/header.txt + + 21 diff --git a/record-builder-test/src/main/java/io/soabase/recordbuilder/test/deconstruct/Basic.java b/record-builder-test/src/main/java/io/soabase/recordbuilder/test/deconstruct/Basic.java new file mode 100644 index 00000000..4a54df90 --- /dev/null +++ b/record-builder-test/src/main/java/io/soabase/recordbuilder/test/deconstruct/Basic.java @@ -0,0 +1,39 @@ +/* + * 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.deconstruct; + +import io.soabase.recordbuilder.core.RecordTuple; + +@RecordTuple +public class Basic { + private final String name; + private final int qty; + + public Basic(String name, int qty) { + this.name = name; + this.qty = qty; + } + + @RecordTuple.Component + public String getName() { + return name; + } + + @RecordTuple.Component + public int getQty() { + return qty; + } +} diff --git a/record-builder-test/src/main/java/io/soabase/recordbuilder/test/deconstruct/Generic.java b/record-builder-test/src/main/java/io/soabase/recordbuilder/test/deconstruct/Generic.java new file mode 100644 index 00000000..b07ed988 --- /dev/null +++ b/record-builder-test/src/main/java/io/soabase/recordbuilder/test/deconstruct/Generic.java @@ -0,0 +1,46 @@ +/* + * 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.deconstruct; + +import io.soabase.recordbuilder.core.RecordBuilder; +import io.soabase.recordbuilder.core.RecordTuple; + +@RecordTuple +@RecordBuilder.Options(tupleSuffix = "Shim", fromMethodName = "to") +public class Generic { + private final T1 t1; + private final T2 t2; + + public Generic(T1 t1, T2 t2) { + this.t1 = t1; + this.t2 = t2; + } + + @RecordTuple.Component + public String getString() { + return "string"; + } + + @RecordTuple.Component + public T1 t1() { + return t1; + } + + @RecordTuple.Component("kookoo") + public T2 t2() { + return t2; + } +} diff --git a/record-builder-test/src/test/java/io/soabase/recordbuilder/test/deconstruct/TestTuples.java b/record-builder-test/src/test/java/io/soabase/recordbuilder/test/deconstruct/TestTuples.java new file mode 100644 index 00000000..f514eaa7 --- /dev/null +++ b/record-builder-test/src/test/java/io/soabase/recordbuilder/test/deconstruct/TestTuples.java @@ -0,0 +1,41 @@ +/* + * 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.deconstruct; + +import org.junit.jupiter.api.Test; + +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; + +public class TestTuples { + @Test + public void testGeneric() { + var now = Instant.now(); + + assertThat(test(new Generic<>("hey", 12))).isEqualTo("String/Integer:hey:12"); + assertThat(test(new Generic<>(now, true))).isEqualTo("Instant/Boolean:%s:true".formatted(now)); + assertThat(test(new Generic<>(1.0, 2.0))).isEqualTo("dunno"); + } + + private String test(Generic instance) { + return switch (GenericShim.to(instance)) { + case GenericShim(String ignore,String s,Integer i) -> "String/Integer:" + s + ":" + i; + case GenericShim(String ignore,Instant d,Boolean b) -> "Instant/Boolean:" + d + ":" + b; + default -> "dunno"; + }; + } +}