diff --git a/afterburner-tests/README.md b/afterburner-tests/README.md new file mode 100644 index 00000000..1c165ab4 --- /dev/null +++ b/afterburner-tests/README.md @@ -0,0 +1,105 @@ +# jackson-module-afterburner-tests + +Classpath-mode integration tests for `jackson-module-afterburner`. **This module is +test-only and is never published.** + +## Why this module exists + +Afterburner's most valuable optimizations come from bytecode injection: +`PropertyMutatorCollector`, `PropertyAccessorCollector`, and `CreatorOptimizer` +generate custom mutator / accessor / instantiator classes in the *target bean's +package* and install them on the bean's `ClassLoader`. This only works when the +target package is not sealed. + +In Jackson 3.x every module descriptor — including afterburner's own test module +— is a named JPMS module, and the JVM automatically seals every package inside a +named module. As a result, every POJO defined inside `tools.jackson.module.afterburner` +test sources fails the `MyClassLoader.canAddClassInPackageOf` check, and afterburner +silently falls back to plain reflection. That means **none** of afterburner's +injection paths are exercised by the in-tree afterburner test suite — the optimized +code path is effectively dead weight in CI even though the tests appear to pass. + +This module closes that gap. It lives alongside the afterburner module but +deliberately: + +- has **no `module-info.java`** in either main or test sources, +- is configured (`useModulePath=false`) to run its tests on the classpath, +- and therefore loads its test POJOs into the **unnamed module**, whose packages + are not sealed. + +Afterburner's injection pipeline runs on those POJOs normally, and the tests here +verify via reflection on databind internals that each property was actually +replaced with an `OptimizedSettableBeanProperty` / `OptimizedBeanPropertyWriter` +and each default-creator POJO got an `OptimizedValueInstantiator`. + +## Do not add a `module-info.java` here + +Adding a module descriptor would put these test POJOs back into a named module, +re-seal their packages, and silently turn this whole module into dead weight — +same as the in-tree afterburner tests. + +If a future cleanup pass wants to "unify" the project layout by making every +module JPMS-consistent, this one is the exception. Read this file before doing +that. + +## Do not publish this module + +Several parent-POM plugin bindings are unbound or skipped in `pom.xml` so that +`mvn install` on this module produces no jar, no SBOM, no Gradle module metadata, +and no OSGi bundle. The module exists purely to run tests. If you rename an +execution override in `pom.xml`, re-verify with: + +``` +./mvnw help:effective-pom -pl afterburner-tests +``` + +A typo in an execution id turns into silent dead config and leaves stale +artifacts in `target/` rather than a build error. + +## What is covered + +- `PropertyMutatorCollector` — int, long, boolean, and reference-type + specializations for both public-field and setter-method access + (`MutatorSpecializationsTest`). +- `PropertyAccessorCollector` — writer replacement on the serializer side + (`SerializerInjectionTest`). +- `CreatorOptimizer` — default-constructor and static-factory positive cases, + plus property-based `@JsonCreator` negative case (`CreatorOptimizerTest`). +- `MyClassLoader.defineClass` — transitively exercised by all of the above. + +## What is *not* covered + +- Private-class guard (`_classLoader != null && isPrivate(beanClass)`). +- `setUseValueClassLoader(false)` toggle. +- Concurrent deserializer construction. +- Fallback path when afterburner correctly refuses to optimize (e.g. a bean + whose package really is sealed — the in-tree afterburner tests inadvertently + cover this). +- GraalVM native-image disable path in `AfterburnerModule.setupModule`. + +## Known findings from this module + +### Afterburner parent-classloader caching silently broken on Java 9+ (#348) + +While writing `GeneratedClassCachingTest`, we discovered that Afterburner's +parent-classloader cache — the mechanism that's supposed to make two +independent `ObjectMapper` instances share a single generated mutator class +per POJO — does not work on Java 9+ unless the JVM is launched with +`--add-opens java.base/java.lang=ALL-UNNAMED`. `MyClassLoader` reflects into +`ClassLoader#findLoadedClass` and `ClassLoader#defineClass`, both protected +members of `java.lang.ClassLoader`; on modern JDKs the reflective access +throws `InaccessibleObjectException`, which Afterburner catches silently and +falls back to defining each generated class in a fresh throwaway loader. + +That's why this module's `pom.xml` sets +`--add-opens java.base/java.lang=ALL-UNNAMED` on the surefire `argLine`. +Without it, `testSameBeanAcrossMappersReusesSameMutatorClass` fails with two +distinct `Class` instances carrying identical fully-qualified names — the +exact fingerprint of the leak. This is **not a test-environment quirk**; it +affects every real Afterburner user on Java 9+. See issue #348 for the fix +plan. + +## References + +- PR #347: rationale for the module's structure and initial coverage +- Issue #348: afterburner classloader caching regression (surfaced here) diff --git a/afterburner-tests/pom.xml b/afterburner-tests/pom.xml new file mode 100644 index 00000000..d6abb965 --- /dev/null +++ b/afterburner-tests/pom.xml @@ -0,0 +1,150 @@ + + 4.0.0 + + tools.jackson.module + jackson-modules-base + 3.2.0-SNAPSHOT + + + jackson-module-afterburner-tests + Jackson module: Afterburner integration tests + jar + + Classpath-mode integration tests for jackson-module-afterburner. This module +intentionally has no module-info.java so that its test POJOs live in the unnamed module, where +their packages are not sealed-by-module. That lets Afterburner exercise its bytecode injection +paths (PropertyMutatorCollector, CreatorOptimizer, PropertyAccessorCollector) — paths that are +gated off when the target bean sits in a named JPMS module. This module is test-only and is +never published. + + + + tools.jackson.module + jackson-module-afterburner + ${project.version} + + + + + + + + org.apache.maven.plugins + maven-jar-plugin + + + default-jar + none + + + + + org.apache.maven.plugins + maven-install-plugin + + true + + + + org.apache.maven.plugins + maven-deploy-plugin + + true + + + + + org.apache.felix + maven-bundle-plugin + + + bundle-manifest + none + + + default-install + none + + + + + + org.cyclonedx + cyclonedx-maven-plugin + + true + + + + + org.gradlex + gradle-module-metadata-maven-plugin + + + default + none + + + + + + org.jacoco + jacoco-maven-plugin + + + report + none + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + false + @{argLine} --add-opens java.base/java.lang=ALL-UNNAMED + + + + + diff --git a/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/AfterburnerInjectionTestBase.java b/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/AfterburnerInjectionTestBase.java new file mode 100644 index 00000000..1c759cbb --- /dev/null +++ b/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/AfterburnerInjectionTestBase.java @@ -0,0 +1,183 @@ +package tools.jackson.module.afterburner.inject; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +import tools.jackson.databind.BeanDescription; +import tools.jackson.databind.DeserializationConfig; +import tools.jackson.databind.SerializationConfig; +import tools.jackson.databind.ValueDeserializer; +import tools.jackson.databind.ValueSerializer; +import tools.jackson.databind.deser.SettableBeanProperty; +import tools.jackson.databind.deser.ValueDeserializerModifier; +import tools.jackson.databind.deser.bean.BeanDeserializer; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.module.SimpleModule; +import tools.jackson.databind.ser.BeanPropertyWriter; +import tools.jackson.databind.ser.ValueSerializerModifier; +import tools.jackson.module.afterburner.AfterburnerModule; + +import static org.junit.jupiter.api.Assertions.assertNotNull; + +// Test utilities for verifying that Afterburner's bytecode injection pipeline actually +// ran on a given POJO. Because OptimizedSettableBeanProperty and its serializer-side +// counterpart are package-private inside afterburner, these checks rely on reflection +// and simple-name matching rather than compile-time type references. +abstract class AfterburnerInjectionTestBase +{ + /** Builds a mapper with AfterburnerModule + a capture hook that remembers + * the built deserializer and serializer for each bean class we touch. */ + protected static Harness newHarness() { + return new Harness(); + } + + protected static final class Harness { + private final ConcurrentMap, ValueDeserializer> desers = new ConcurrentHashMap<>(); + private final ConcurrentMap, ValueSerializer> sers = new ConcurrentHashMap<>(); + final JsonMapper mapper; + + Harness() { + SimpleModule capture = new SimpleModule("capture") { + private static final long serialVersionUID = 1L; + @Override + public void setupModule(SetupContext ctxt) { + super.setupModule(ctxt); + ctxt.addDeserializerModifier(new ValueDeserializerModifier() { + private static final long serialVersionUID = 1L; + @Override + public ValueDeserializer modifyDeserializer( + DeserializationConfig cfg, BeanDescription.Supplier ref, + ValueDeserializer d) { + desers.put(ref.getBeanClass(), d); + return d; + } + }); + ctxt.addSerializerModifier(new ValueSerializerModifier() { + private static final long serialVersionUID = 1L; + @Override + public ValueSerializer modifySerializer( + SerializationConfig cfg, BeanDescription.Supplier ref, + ValueSerializer s) { + sers.put(ref.getBeanClass(), s); + return s; + } + }); + } + }; + this.mapper = JsonMapper.builder() + .addModule(new AfterburnerModule()) + .addModule(capture) + .build(); + } + + ValueDeserializer deserFor(Class cls) { + ValueDeserializer d = desers.get(cls); + assertNotNull(d, "no deserializer captured for " + cls.getName()); + return d; + } + + ValueSerializer serFor(Class cls) { + ValueSerializer s = sers.get(cls); + assertNotNull(s, "no serializer captured for " + cls.getName()); + return s; + } + } + + /** Returns the `_propsByIndex` array from a bean deserializer. */ + protected static SettableBeanProperty[] propsOf(ValueDeserializer deser) { + if (!(deser instanceof BeanDeserializer)) { + throw new AssertionError("not a BeanDeserializer: " + deser.getClass().getName()); + } + return (SettableBeanProperty[]) reflectField(deser, "_propsByIndex"); + } + + /** Returns the BeanPropertyWriter[] from a bean serializer, as a list. */ + protected static List writersOf(ValueSerializer ser) { + BeanPropertyWriter[] arr = (BeanPropertyWriter[]) reflectField(ser, "_props"); + List out = new ArrayList<>(arr.length); + for (BeanPropertyWriter w : arr) { + out.add(w); + } + return out; + } + + /** Walks the class chain of {@code instance} looking for a declared field + * named {@code fieldName}, sets it accessible, and returns its value. + * Throws a descriptive AssertionError if the field isn't found, picking + * the hypothesis (databind rename vs caller passed the wrong receiver) + * based on the class's package. */ + protected static Object reflectField(Object instance, String fieldName) { + Class origClass = instance.getClass(); + Class c = origClass; + while (c != null) { + try { + Field f = c.getDeclaredField(fieldName); + f.setAccessible(true); + return f.get(instance); + } catch (NoSuchFieldException ignore) { + c = c.getSuperclass(); + } catch (IllegalAccessException e) { + throw new AssertionError("cannot read field '" + fieldName + "' on " + + origClass.getName(), e); + } + } + // Field genuinely not found anywhere in the class chain. Give the caller + // a hypothesis to start from rather than a bare "not found". + String pkg = origClass.getPackageName(); + String hint; + if (pkg.startsWith("tools.jackson.databind") + || pkg.startsWith("tools.jackson.module.afterburner")) { + hint = "databind or afterburner may have renamed or removed it;" + + " update " + AfterburnerInjectionTestBase.class.getSimpleName() + + " to match."; + } else { + hint = "this looks like the wrong receiver type — '" + fieldName + + "' is an internal Jackson field and the caller passed an" + + " instance of " + origClass.getName() + "."; + } + throw new AssertionError("field '" + fieldName + "' not found on " + + origClass.getName() + " (walked up full class chain) — " + hint); + } + + /** True if `prop`'s class chain contains Afterburner's OptimizedSettableBeanProperty. */ + protected static boolean isOptimizedProperty(SettableBeanProperty prop) { + return afterburnerClassChainIncludes(prop.getClass(), "OptimizedSettableBeanProperty"); + } + + /** True if `writer`'s class chain contains Afterburner's OptimizedBeanPropertyWriter. */ + protected static boolean isOptimizedWriter(BeanPropertyWriter writer) { + return afterburnerClassChainIncludes(writer.getClass(), "OptimizedBeanPropertyWriter"); + } + + /** Walks the superclass chain of {@code cls} looking for a class whose simple + * name is {@code simpleName}. Used to recognize Afterburner's package-private + * optimized types without importing them. */ + protected static boolean classChainIncludes(Class cls, String simpleName) { + Class c = cls; + while (c != null) { + if (simpleName.equals(c.getSimpleName())) { + return true; + } + c = c.getSuperclass(); + } + return false; + } + + /** Like {@link #classChainIncludes} but additionally requires the matched + * class to live inside an afterburner package. Guards against false positives + * from unrelated classes that happen to share a simple name. */ + protected static boolean afterburnerClassChainIncludes(Class cls, String simpleName) { + Class c = cls; + while (c != null) { + if (simpleName.equals(c.getSimpleName()) + && c.getPackageName().startsWith("tools.jackson.module.afterburner")) { + return true; + } + c = c.getSuperclass(); + } + return false; + } +} diff --git a/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/CreatorOptimizerTest.java b/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/CreatorOptimizerTest.java new file mode 100644 index 00000000..4f7c8f27 --- /dev/null +++ b/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/CreatorOptimizerTest.java @@ -0,0 +1,117 @@ +package tools.jackson.module.afterburner.inject; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +import tools.jackson.databind.deser.ValueInstantiator; +import tools.jackson.databind.deser.bean.BeanDeserializer; +import tools.jackson.databind.deser.std.StdValueInstantiator; + +import static org.junit.jupiter.api.Assertions.*; + +// Covers CreatorOptimizer's main branches: +// +// 1. Default public constructor -> creator replaced with generated subclass. +// 2. Public static factory method -> creator replaced with generated subclass +// (the same generated-bytecode path, different stack manipulation). +// 3. Property-based @JsonCreator -> CreatorOptimizer bails out +// (canCreateFromObjectWith() is true); the plain StdValueInstantiator stays. +// +// Together these guard against (a) CreatorOptimizer silently breaking for one +// of its two supported paths, and (b) CreatorOptimizer accidentally running on +// an unsupported shape (which would turn into a deserialization failure at +// runtime rather than a clean fallback). +public class CreatorOptimizerTest extends AfterburnerInjectionTestBase +{ + public static class DefaultCtorBean { + public int a; + public String b; + } + + public static class FactoryMethodBean { + public int a; + public String b; + + // Private default ctor forces databind to pick up the static factory. + // The @JsonCreator annotation with no args is how databind recognizes a + // no-arg static method as a "default creator" — without the annotation + // databind would fall through to looking for a default constructor. + private FactoryMethodBean() { } + + @JsonCreator + public static FactoryMethodBean create() { + return new FactoryMethodBean(); + } + } + + public static class PropertyCreatorBean { + public final int a; + public final String b; + + @JsonCreator + public PropertyCreatorBean(@JsonProperty("a") int a, @JsonProperty("b") String b) { + this.a = a; + this.b = b; + } + } + + private final Harness h = newHarness(); + + @Test + public void testCreatorReplacedForDefaultCtor() throws Exception + { + DefaultCtorBean bean = h.mapper.readValue("{\"a\":1,\"b\":\"hi\"}", DefaultCtorBean.class); + assertEquals(1, bean.a); + assertEquals("hi", bean.b); + + ValueInstantiator inst = instantiatorFor(DefaultCtorBean.class); + // OptimizedValueInstantiator is the abstract base CreatorOptimizer produces + // subclasses of — it can only appear in the chain if CreatorOptimizer ran. + assertTrue(classChainIncludes(inst.getClass(), "OptimizedValueInstantiator"), + "CreatorOptimizer did not replace the ValueInstantiator; got " + + inst.getClass().getName()); + } + + @Test + public void testCreatorReplacedForStaticFactory() throws Exception + { + FactoryMethodBean bean = h.mapper.readValue("{\"a\":1,\"b\":\"hi\"}", + FactoryMethodBean.class); + assertEquals(1, bean.a); + assertEquals("hi", bean.b); + + ValueInstantiator inst = instantiatorFor(FactoryMethodBean.class); + assertTrue(classChainIncludes(inst.getClass(), "OptimizedValueInstantiator"), + "CreatorOptimizer did not replace the ValueInstantiator for a static" + + " factory creator; got " + inst.getClass().getName()); + } + + @Test + public void testCreatorNotReplacedForPropertyBasedCreator() throws Exception + { + // Deserialization must still work end-to-end, just without CreatorOptimizer + // wrapping (it bails out because canCreateFromObjectWith() is true). + PropertyCreatorBean bean = h.mapper.readValue("{\"a\":1,\"b\":\"hi\"}", + PropertyCreatorBean.class); + assertEquals(1, bean.a); + assertEquals("hi", bean.b); + + ValueInstantiator inst = instantiatorFor(PropertyCreatorBean.class); + assertFalse(classChainIncludes(inst.getClass(), "OptimizedValueInstantiator"), + "CreatorOptimizer should NOT wrap a property-based @JsonCreator POJO," + + " but the generated class " + inst.getClass().getName() + + " extends OptimizedValueInstantiator"); + assertEquals(StdValueInstantiator.class, inst.getClass(), + "fallback should be the plain StdValueInstantiator; got " + + inst.getClass().getName()); + } + + private ValueInstantiator instantiatorFor(Class cls) { + BeanDeserializer bd = (BeanDeserializer) h.deserFor(cls); + ValueInstantiator inst = (ValueInstantiator) reflectField(bd, "_valueInstantiator"); + assertNotNull(inst); + return inst; + } +} diff --git a/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/GeneratedClassCachingTest.java b/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/GeneratedClassCachingTest.java new file mode 100644 index 00000000..a71ecfb7 --- /dev/null +++ b/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/GeneratedClassCachingTest.java @@ -0,0 +1,120 @@ +package tools.jackson.module.afterburner.inject; + +import org.junit.jupiter.api.Test; + +import tools.jackson.databind.deser.SettableBeanProperty; + +import static org.junit.jupiter.api.Assertions.*; + +// Caching semantics for Afterburner's generated mutator classes. A regression in +// this area would not break functional behavior but would waste class metaspace +// and pin classloaders — so the only way to catch it is to directly inspect the +// generated-class identity. +// +// What's asserted: +// +// 1. Within a single mapper, all OptimizedSettableBeanProperty instances for +// the *same* POJO share the *same* BeanPropertyMutator class. Afterburner +// generates one mutator class per POJO and reuses it across every +// optimized property of that POJO. +// +// 2. Across two independent mappers (each with its own AfterburnerModule +// instance), the mutator class for the same POJO is the *same* Class +// instance. This proves the generated class is cached on the bean's +// parent classloader and not regenerated per mapper — a regression would +// cause classloader bloat under load as short-lived mappers each leaked a +// distinct generated class. +// +// 3. Two *different* POJOs — even ones with identical field shapes — get +// distinct generated mutator classes. Afterburner keys cache entries on +// the bean class name, not on bytecode shape. A regression that conflated +// them would silently deserialize one POJO's JSON into the other's fields. +public class GeneratedClassCachingTest extends AfterburnerInjectionTestBase +{ + public static class FooBean { + public int a; + public String b; + } + + // Structurally identical to FooBean, intentionally — same field names and + // types. Afterburner must still generate a distinct mutator for it because + // the generated class name is derived from the bean class name. + public static class BarBean { + public int a; + public String b; + } + + @Test + public void testMutatorSharedAcrossPropertiesOfSameBean() throws Exception + { + Harness h = newHarness(); + h.mapper.readValue("{\"a\":1,\"b\":\"x\"}", FooBean.class); + + SettableBeanProperty[] props = propsOf(h.deserFor(FooBean.class)); + assertEquals(2, props.length); + + Object mutatorA = reflectField(props[0], "_propertyMutator"); + Object mutatorB = reflectField(props[1], "_propertyMutator"); + assertNotNull(mutatorA); + assertNotNull(mutatorB); + assertSame(mutatorA.getClass(), mutatorB.getClass(), + "all properties of the same POJO should share one mutator class; got " + + mutatorA.getClass().getName() + " vs " + + mutatorB.getClass().getName()); + } + + @Test + public void testSameBeanAcrossMappersReusesSameMutatorClass() throws Exception + { + Harness h1 = newHarness(); + Harness h2 = newHarness(); + + h1.mapper.readValue("{\"a\":1,\"b\":\"x\"}", FooBean.class); + h2.mapper.readValue("{\"a\":2,\"b\":\"y\"}", FooBean.class); + + Class mutatorClass1 = mutatorClassFor(h1, FooBean.class); + Class mutatorClass2 = mutatorClassFor(h2, FooBean.class); + + assertSame(mutatorClass1, mutatorClass2, + "two independent mappers should share the same cached mutator class " + + "for FooBean; got " + mutatorClass1.getName() + " vs " + + mutatorClass2.getName() + " — afterburner may be regenerating " + + "classes instead of caching them on the parent classloader"); + } + + @Test + public void testDifferentBeansGetDistinctMutatorClasses() throws Exception + { + Harness h = newHarness(); + h.mapper.readValue("{\"a\":1,\"b\":\"x\"}", FooBean.class); + h.mapper.readValue("{\"a\":2,\"b\":\"y\"}", BarBean.class); + + Class fooMutator = mutatorClassFor(h, FooBean.class); + Class barMutator = mutatorClassFor(h, BarBean.class); + + assertNotSame(fooMutator, barMutator, + "structurally identical but distinct POJOs must get distinct mutator " + + "classes (otherwise their property ordering could collide); " + + "both got " + fooMutator.getName()); + + // Extra sanity: the generated class names should reflect the bean class names. + assertTrue(fooMutator.getName().contains("FooBean"), + "expected FooBean's mutator class name to mention FooBean; got " + + fooMutator.getName()); + assertTrue(barMutator.getName().contains("BarBean"), + "expected BarBean's mutator class name to mention BarBean; got " + + barMutator.getName()); + } + + /** Reads the `_propertyMutator` field off the first property of the captured + * deserializer and returns its runtime class. */ + private static Class mutatorClassFor(Harness h, Class beanClass) { + SettableBeanProperty[] props = propsOf(h.deserFor(beanClass)); + assertTrue(props.length > 0, "no properties for " + beanClass.getSimpleName()); + Object mutator = reflectField(props[0], "_propertyMutator"); + assertNotNull(mutator, + "property '" + props[0].getName() + "' has no _propertyMutator — " + + "afterburner did not install a generated mutator"); + return mutator.getClass(); + } +} diff --git a/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/MutatorSpecializationsTest.java b/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/MutatorSpecializationsTest.java new file mode 100644 index 00000000..391f0779 --- /dev/null +++ b/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/MutatorSpecializationsTest.java @@ -0,0 +1,117 @@ +package tools.jackson.module.afterburner.inject; + +import org.junit.jupiter.api.Test; + +import tools.jackson.databind.deser.SettableBeanProperty; + +import static org.junit.jupiter.api.Assertions.*; + +// Verifies each of PropertyMutatorCollector's primitive-type specializations — int, +// long, boolean, and object/String — runs end-to-end for both public-field access +// and setter-method access. A regression in any individual code-generation path +// (e.g. a bad ByteBuddy template, a wrong descriptor, a missing bytecode branch) +// should surface here. +public class MutatorSpecializationsTest extends AfterburnerInjectionTestBase +{ + public static class IntFieldBean { + public int value; + } + public static class LongFieldBean { + public long value; + } + public static class BooleanFieldBean { + public boolean value; + } + public static class StringFieldBean { + public String value; + } + + public static class IntSetterBean { + private int value; + public int getValue() { return value; } + public void setValue(int v) { this.value = v; } + } + public static class LongSetterBean { + private long value; + public long getValue() { return value; } + public void setValue(long v) { this.value = v; } + } + public static class BooleanSetterBean { + private boolean value; + public boolean isValue() { return value; } + public void setValue(boolean v) { this.value = v; } + } + public static class StringSetterBean { + private String value; + public String getValue() { return value; } + public void setValue(String v) { this.value = v; } + } + + private final Harness h = newHarness(); + + @Test + public void testIntField() throws Exception { + IntFieldBean b = h.mapper.readValue("{\"value\":42}", IntFieldBean.class); + assertEquals(42, b.value); + assertAllPropsOptimized(IntFieldBean.class); + } + + @Test + public void testLongField() throws Exception { + LongFieldBean b = h.mapper.readValue("{\"value\":9999999999}", LongFieldBean.class); + assertEquals(9999999999L, b.value); + assertAllPropsOptimized(LongFieldBean.class); + } + + @Test + public void testBooleanField() throws Exception { + BooleanFieldBean b = h.mapper.readValue("{\"value\":true}", BooleanFieldBean.class); + assertTrue(b.value); + assertAllPropsOptimized(BooleanFieldBean.class); + } + + @Test + public void testStringField() throws Exception { + StringFieldBean b = h.mapper.readValue("{\"value\":\"hi\"}", StringFieldBean.class); + assertEquals("hi", b.value); + assertAllPropsOptimized(StringFieldBean.class); + } + + @Test + public void testIntSetter() throws Exception { + IntSetterBean b = h.mapper.readValue("{\"value\":42}", IntSetterBean.class); + assertEquals(42, b.getValue()); + assertAllPropsOptimized(IntSetterBean.class); + } + + @Test + public void testLongSetter() throws Exception { + LongSetterBean b = h.mapper.readValue("{\"value\":9999999999}", LongSetterBean.class); + assertEquals(9999999999L, b.getValue()); + assertAllPropsOptimized(LongSetterBean.class); + } + + @Test + public void testBooleanSetter() throws Exception { + BooleanSetterBean b = h.mapper.readValue("{\"value\":true}", BooleanSetterBean.class); + assertTrue(b.isValue()); + assertAllPropsOptimized(BooleanSetterBean.class); + } + + @Test + public void testStringSetter() throws Exception { + StringSetterBean b = h.mapper.readValue("{\"value\":\"hi\"}", StringSetterBean.class); + assertEquals("hi", b.getValue()); + assertAllPropsOptimized(StringSetterBean.class); + } + + private void assertAllPropsOptimized(Class cls) { + SettableBeanProperty[] props = propsOf(h.deserFor(cls)); + assertTrue(props.length > 0, "no properties for " + cls.getSimpleName()); + for (SettableBeanProperty p : props) { + assertTrue(isOptimizedProperty(p), + cls.getSimpleName() + "." + p.getName() + " not optimized: " + + p.getClass().getName()); + } + } +} diff --git a/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/SerializerInjectionTest.java b/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/SerializerInjectionTest.java new file mode 100644 index 00000000..d7b9afb6 --- /dev/null +++ b/afterburner-tests/src/test/java/tools/jackson/module/afterburner/inject/SerializerInjectionTest.java @@ -0,0 +1,54 @@ +package tools.jackson.module.afterburner.inject; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import tools.jackson.databind.ser.BeanPropertyWriter; + +import static org.junit.jupiter.api.Assertions.*; + +// Verifies Afterburner's serializer-side bytecode injection runs end-to-end: +// PropertyAccessorCollector replaces each BeanPropertyWriter with an +// OptimizedBeanPropertyWriter subclass whose generated accessor reads the field +// directly instead of going through reflection. This is the only test in this +// module that touches the serializer side; deser-side injection is covered by +// MutatorSpecializationsTest. +public class SerializerInjectionTest extends AfterburnerInjectionTestBase +{ + public static class SerPojo { + public int intField; + public long longField; + public boolean boolField; + public String stringField; + + public SerPojo() { } + + public SerPojo(int i, long l, boolean b, String s) { + intField = i; longField = l; boolField = b; stringField = s; + } + } + + @Test + public void testWriterInjectionRuns() throws Exception + { + Harness h = newHarness(); + + // Emit JSON through the optimized serializer and sanity-check the output. + String json = h.mapper.writeValueAsString(new SerPojo(1, 2L, true, "x")); + assertTrue(json.contains("\"intField\":1")); + assertTrue(json.contains("\"longField\":2")); + assertTrue(json.contains("\"boolField\":true")); + assertTrue(json.contains("\"stringField\":\"x\"")); + + // Every writer on the serializer side should be Afterburner-optimized. + List writers = writersOf(h.serFor(SerPojo.class)); + assertEquals(4, writers.size()); + for (BeanPropertyWriter w : writers) { + assertTrue(isOptimizedWriter(w), + "ser writer '" + w.getName() + "' not optimized (is " + + w.getClass().getName() + "); PropertyAccessorCollector" + + " did not run on this POJO"); + } + } +} diff --git a/pom.xml b/pom.xml index 5de73cbd..ea8ef52d 100644 --- a/pom.xml +++ b/pom.xml @@ -24,6 +24,8 @@ not datatype, data format, or JAX-RS provider modules. afterburner + + afterburner-tests android-record blackbird guice diff --git a/release-notes/VERSION b/release-notes/VERSION index 9691ec62..2f172c75 100644 --- a/release-notes/VERSION +++ b/release-notes/VERSION @@ -28,7 +28,10 @@ Active maintainers: `java.lang.IllegalStateException`: Multiple definitions of method getBody found (reported by @bxvs888) (fix by @cowtowncoder, w/ Claude code) -#343: Improve Afterburner testing, access checks +#343: (afterburner) Improve Afterburner testing, access checks + (fix by @cowtowncoder, w/ Claude code) +#347: (afterburner) Added module "afterburner-tests" for better Afterburner-in-classpath + testing (fix by @cowtowncoder, w/ Claude code) - Removed project Android SDK level overrides, use defaults (ASDK 34) (affects `android-records` module's verification; was validating ASDK 26)