New posts

This commit is contained in:
2026-11-06 21:43:25 +05:30
parent dfd3a830cd
commit d7d5689f05
40 changed files with 1359 additions and 0 deletions
+92
View File
@@ -0,0 +1,92 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.ankurm</groupId>
<artifactId>annotation-processor</artifactId>
<version>1.0</version>
</parent>
<artifactId>builder-demo</artifactId>
<name>builder-demo</name>
<description>A consumer of @GenerateBuilder, plus the retention / reflection demos.</description>
<dependencies>
<!-- Needed on the compile classpath so javac can resolve the @GenerateBuilder type.
It is SOURCE-retention, so nothing needs it at run time: scope "provided". -->
<dependency>
<groupId>com.ankurm</groupId>
<artifactId>builder-processor</artifactId>
<version>${project.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<!-- The processor is named here, on the PROCESSOR path - not just on the classpath.
On JDK 23+ a processor found only on the classpath is not run by default. -->
<annotationProcessorPaths>
<path>
<groupId>com.ankurm</groupId>
<artifactId>builder-processor</artifactId>
<version>${project.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
</plugin>
</plugins>
</build>
<profiles>
<!-- For the article only: drops <annotationProcessorPaths> so the processor is on the classpath
(it is a "provided" dependency) but NOT on the processor path. Used by scripts/run-all.sh. -->
<profile>
<id>classpath-only</id>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths combine.self="override"/>
</configuration>
</plugin>
</plugins>
</build>
</profile>
<!-- Same, plus an explicit request to run the processors found on the classpath. -->
<profile>
<id>classpath-proc-full</id>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths combine.self="override"/>
<proc>full</proc>
</configuration>
</plugin>
</plugins>
</build>
</profile>
</profiles>
</project>
@@ -0,0 +1,24 @@
package com.ankurm.demo;
/** Uses the generated PersonBuilder and PizzaMaker. Neither class exists in src/main/java. */
public final class BuilderUsageDemo {
public static void main(String[] args) {
Person p = new PersonBuilder()
.name("Asha")
.age(34)
.addToNicknames("Ash")
.addToNicknames("A")
.build();
System.out.println(p);
Pizza pizza = new PizzaMaker()
.withSize("large")
.withExtraCheese(true)
.addToToppings("olives")
.build();
System.out.println(pizza);
// Unset properties take the Java default for their type: null for references, 0 for int.
System.out.println(new PersonBuilder().build());
}
}
@@ -0,0 +1,10 @@
package com.ankurm.demo;
import com.ankurm.builder.GenerateBuilder;
import java.util.List;
/** A record: the builder is generated from its canonical constructor. */
@GenerateBuilder
public record Person(String name, int age, List<String> nicknames) {
}
@@ -0,0 +1,24 @@
package com.ankurm.demo;
import com.ankurm.builder.GenerateBuilder;
import java.util.List;
/** A plain class, with options on the annotation: builder name and a "with" prefix. */
@GenerateBuilder(name = "PizzaMaker", prefix = "with")
public final class Pizza {
private final String size;
private final boolean extraCheese;
private final List<String> toppings;
public Pizza(String size, boolean extraCheese, List<String> toppings) {
this.size = size;
this.extraCheese = extraCheese;
this.toppings = toppings == null ? List.of() : List.copyOf(toppings);
}
@Override
public String toString() {
return "Pizza[size=" + size + ", extraCheese=" + extraCheese + ", toppings=" + toppings + "]";
}
}
@@ -0,0 +1,62 @@
package com.ankurm.demo;
import com.ankurm.demo.annotations.ClassOnly;
import com.ankurm.demo.annotations.Level;
import com.ankurm.demo.annotations.Role;
import com.ankurm.demo.annotations.Roles;
import com.ankurm.demo.annotations.SourceOnly;
import com.ankurm.demo.annotations.Tracked;
import java.util.Arrays;
/** What reflection can and cannot see, plus members, defaults, @Inherited and @Repeatable. */
public final class RetentionDemo {
@SourceOnly @ClassOnly @Tracked
static class Base { }
static class Child extends Base { }
@Role("admin") @Role("auditor")
static class Staff { }
@Role("admin")
static class Solo { }
@Tracked(value = "billing", priority = 9, level = Level.HIGH, tags = {"pci", "money"})
static class Configured { }
public static void main(String[] args) {
System.out.println("-- retention: annotations reflection can see on Base");
System.out.println(Arrays.toString(Base.class.getAnnotations()));
System.out.println("-- members and defaults");
Tracked t = Base.class.getAnnotation(Tracked.class);
System.out.println("default value=" + quote(t.value()) + " priority=" + t.priority()
+ " level=" + t.level() + " tags=" + Arrays.toString(t.tags()));
Tracked c = Configured.class.getAnnotation(Tracked.class);
System.out.println("explicit value=" + quote(c.value()) + " priority=" + c.priority()
+ " level=" + c.level() + " tags=" + Arrays.toString(c.tags()));
System.out.println("-- @Inherited: Child extends Base, Child itself has no annotations");
System.out.println("getAnnotation(Tracked) = " + (Child.class.getAnnotation(Tracked.class) != null));
System.out.println("getDeclaredAnnotation(Tracked) = " + (Child.class.getDeclaredAnnotation(Tracked.class) != null));
System.out.println("-- @Repeatable");
System.out.println("Staff direct getAnnotations().length = " + Staff.class.getAnnotations().length
+ " (" + Staff.class.getAnnotations()[0].annotationType().getSimpleName() + " container)");
System.out.println("Staff getAnnotationsByType(Role) = " + roleValues(Staff.class.getAnnotationsByType(Role.class)));
System.out.println("Solo getAnnotation(Role) != null = " + (Solo.class.getAnnotation(Role.class) != null));
System.out.println("Solo getAnnotation(Roles) != null = " + (Solo.class.getAnnotation(Roles.class) != null));
System.out.println("Solo getAnnotationsByType(Role) = " + roleValues(Solo.class.getAnnotationsByType(Role.class)));
}
private static String quote(String s) {
return "\"" + s + "\"";
}
private static String roleValues(Role[] roles) {
return Arrays.stream(roles).map(Role::value).toList().toString();
}
}
@@ -0,0 +1,37 @@
package com.ankurm.demo;
import com.ankurm.demo.annotations.NotBlank;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.List;
/** The runtime-reflection style: discover annotated fields while the program runs. */
public final class RuntimeValidatorDemo {
static class Signup {
@NotBlank String email;
@NotBlank(message = "name is required") String name;
String note; // not annotated: ignored
Signup(String email, String name, String note) { this.email = email; this.name = name; this.note = note; }
}
static List<String> validate(Object o) throws IllegalAccessException {
List<String> problems = new ArrayList<>();
for (Field f : o.getClass().getDeclaredFields()) {
NotBlank nb = f.getAnnotation(NotBlank.class);
if (nb == null) continue;
f.setAccessible(true);
Object v = f.get(o);
if (v == null || v.toString().isBlank()) {
problems.add(f.getName() + ": " + nb.message());
}
}
return problems;
}
public static void main(String[] args) throws Exception {
System.out.println(validate(new Signup("[email protected]", "Asha", null)));
System.out.println(validate(new Signup("", " ", null)));
}
}
@@ -0,0 +1,9 @@
package com.ankurm.demo.annotations;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
/** CLASS (also the default when @Retention is omitted): in the .class file, invisible to reflection. */
@Retention(RetentionPolicy.CLASS)
public @interface ClassOnly {
}
@@ -0,0 +1,3 @@
package com.ankurm.demo.annotations;
public enum Level { LOW, NORMAL, HIGH }
@@ -0,0 +1,13 @@
package com.ankurm.demo.annotations;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/** Marker for the reflection-based validator in {@code RuntimeValidatorDemo}. */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface NotBlank {
String message() default "must not be blank";
}
@@ -0,0 +1,12 @@
package com.ankurm.demo.annotations;
import java.lang.annotation.Repeatable;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
/** Repeatable: javac wraps two or more of these into one {@link Roles} container. */
@Retention(RetentionPolicy.RUNTIME)
@Repeatable(Roles.class)
public @interface Role {
String value();
}
@@ -0,0 +1,10 @@
package com.ankurm.demo.annotations;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
/** The container annotation that {@link Role} is repeatable into. */
@Retention(RetentionPolicy.RUNTIME)
public @interface Roles {
Role[] value();
}
@@ -0,0 +1,9 @@
package com.ankurm.demo.annotations;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
/** SOURCE: discarded by javac after processing. Not in the .class file at all. */
@Retention(RetentionPolicy.SOURCE)
public @interface SourceOnly {
}
@@ -0,0 +1,23 @@
package com.ankurm.demo.annotations;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Inherited;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* RUNTIME: readable through reflection. Shows member types and defaults, plus
* {@code @Documented}, {@code @Inherited} and {@code @Target}.
*/
@Documented
@Inherited
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD, ElementType.FIELD})
public @interface Tracked {
String value() default ""; // "value" lets you write @Tracked("x")
int priority() default 5;
Level level() default Level.NORMAL;
String[] tags() default {};
}
@@ -0,0 +1,50 @@
package com.ankurm.demo;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** Runtime tests of classes that the annotation processor wrote. They would not compile if it had not run. */
class GeneratedBuilderTest {
@Test
void recordBuilderSetsEveryComponent() {
Person p = new PersonBuilder().name("Asha").age(34).addToNicknames("Ash").addToNicknames("A").build();
assertEquals("Asha", p.name());
assertEquals(34, p.age());
assertEquals(List.of("Ash", "A"), p.nicknames());
}
@Test
void unsetPropertiesTakeJavaDefaults() {
Person p = new PersonBuilder().build();
assertNull(p.name());
assertEquals(0, p.age());
assertNull(p.nicknames());
}
@Test
void prefixAndCustomNameFromAnnotationMembersAreHonoured() {
Pizza pizza = new PizzaMaker().withSize("large").withExtraCheese(true).addToToppings("olives").build();
assertEquals("Pizza[size=large, extraCheese=true, toppings=[olives]]", pizza.toString());
}
@Test
void addToDoesNotMutateAListHandedToTheSetter() {
List<String> original = List.of("x");
Person p = new PersonBuilder().nicknames(original).addToNicknames("y").build();
assertEquals(List.of("x"), original);
assertEquals(List.of("x", "y"), p.nicknames());
}
@Test
void sourceRetentionLeavesNoTraceInTheClassFile() throws Exception {
assertNotNull(Class.forName("com.ankurm.demo.PersonBuilder"));
assertTrue(Person.class.getAnnotations().length == 0);
}
}
@@ -0,0 +1,53 @@
package com.ankurm.demo;
import com.ankurm.demo.annotations.Level;
import com.ankurm.demo.annotations.Role;
import com.ankurm.demo.annotations.Roles;
import com.ankurm.demo.annotations.Tracked;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
class RetentionReflectionTest {
@Test
void onlyRuntimeAnnotationsAreVisibleToReflection() {
var annotations = RetentionDemo.Base.class.getAnnotations();
assertEquals(1, annotations.length);
assertEquals(Tracked.class, annotations[0].annotationType());
}
@Test
void membersFallBackToDefaults() {
Tracked t = RetentionDemo.Base.class.getAnnotation(Tracked.class);
assertEquals("", t.value());
assertEquals(5, t.priority());
assertEquals(Level.NORMAL, t.level());
assertEquals(0, t.tags().length);
}
@Test
void inheritedAnnotationsAreVisibleOnSubclassesButNotDeclaredThere() {
assertNotNull(RetentionDemo.Child.class.getAnnotation(Tracked.class));
assertNull(RetentionDemo.Child.class.getDeclaredAnnotation(Tracked.class));
}
@Test
void repeatedAnnotationsAreWrappedInTheContainer() {
assertEquals(Roles.class, RetentionDemo.Staff.class.getAnnotations()[0].annotationType());
assertNull(RetentionDemo.Staff.class.getAnnotation(Role.class));
assertEquals(List.of("admin", "auditor"),
java.util.Arrays.stream(RetentionDemo.Staff.class.getAnnotationsByType(Role.class)).map(Role::value).toList());
assertNotNull(RetentionDemo.Solo.class.getAnnotation(Role.class)); // a single one is not wrapped
}
@Test
void runtimeValidatorFindsBlankFields() throws Exception {
var s = new RuntimeValidatorDemo.Signup("", " ", null);
assertEquals(List.of("email: must not be blank", "name: name is required"), RuntimeValidatorDemo.validate(s));
}
}