diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 27b85d6a0..9d3563e63 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -18,9 +18,11 @@ jobs: strategy: fail-fast: false matrix: - java_version: ['17', '21', '24'] + java_version: ['17', '21', '24', '25'] include: - - java_version: '17' + # Release/deploy runs on 25 so the blackbird module (profile-gated + # to JDK 25+) is part of the deployed reactor. + - java_version: '25' release_build: 'R' env: JAVA_OPTS: "-XX:+TieredCompilation -XX:TieredStopAtLevel=1" diff --git a/blackbird-jpms-tests/pom.xml b/blackbird-jpms-tests/pom.xml new file mode 100644 index 000000000..2d237365c --- /dev/null +++ b/blackbird-jpms-tests/pom.xml @@ -0,0 +1,54 @@ + + + 4.0.0 + + tools.jackson.module + jackson-modules-base + 3.3.0-SNAPSHOT + + jackson-module-blackbird-jpms-tests + Jackson module: Blackbird JPMS integration tests + jar + Module-path integration tests: a foreign named module that +requires blackbird, exercising codec generation for its own non-public +classes. Not published. + + + 25 + 25 + 25 + + true + true + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + true + + + + + + + + tools.jackson.module + jackson-module-blackbird + ${project.version} + + + org.junit.jupiter + junit-jupiter + test + + + diff --git a/blackbird-jpms-tests/src/main/java/blackbird/jpms/test/beans/Beans.java b/blackbird-jpms-tests/src/main/java/blackbird/jpms/test/beans/Beans.java new file mode 100644 index 000000000..d83f90e4c --- /dev/null +++ b/blackbird-jpms-tests/src/main/java/blackbird/jpms/test/beans/Beans.java @@ -0,0 +1,16 @@ +package blackbird.jpms.test.beans; + +import java.lang.invoke.MethodHandles; + +/** Hands tests this module's lookup and its non-public bean type. */ +public final class Beans { + private Beans() {} + + public static MethodHandles.Lookup lookup() { + return MethodHandles.lookup(); + } + + public static Class pkgBeanClass() { + return PkgBean.class; + } +} diff --git a/blackbird-jpms-tests/src/main/java/blackbird/jpms/test/beans/PkgBean.java b/blackbird-jpms-tests/src/main/java/blackbird/jpms/test/beans/PkgBean.java new file mode 100644 index 000000000..73d5b2090 --- /dev/null +++ b/blackbird-jpms-tests/src/main/java/blackbird/jpms/test/beans/PkgBean.java @@ -0,0 +1,14 @@ +package blackbird.jpms.test.beans; + +// Deliberately package-private: the codec for this bean must be defined in +// this module's package context, which only works when the generated class +// can resolve its supertype from here. +class PkgBean { + private int count; + private String name; + + public int getCount() { return count; } + public void setCount(int v) { count = v; } + public String getName() { return name; } + public void setName(String v) { name = v; } +} diff --git a/blackbird-jpms-tests/src/main/java/module-info.java b/blackbird-jpms-tests/src/main/java/module-info.java new file mode 100644 index 000000000..7310c03ad --- /dev/null +++ b/blackbird-jpms-tests/src/main/java/module-info.java @@ -0,0 +1,14 @@ +// A foreign named module from Blackbird's point of view: it requires the +// blackbird module and holds non-public beans plus a lookup of its own, the +// exact arrangement a modular application has. +module blackbird.jpms.test +{ + requires tools.jackson.databind; + requires tools.jackson.module.blackbird; + + exports blackbird.jpms.test.beans; + // What a modular application must do for stock databind to construct + // non-public beans reflectively. Deliberately NOT opened to blackbird: + // the accelerated path uses the lookup this module hands over instead. + opens blackbird.jpms.test.beans to tools.jackson.databind; +} diff --git a/blackbird-jpms-tests/src/test/java/blackbird/jpms/test/beans/JpmsPackagePrivateTest.java b/blackbird-jpms-tests/src/test/java/blackbird/jpms/test/beans/JpmsPackagePrivateTest.java new file mode 100644 index 000000000..fcdaa70e3 --- /dev/null +++ b/blackbird-jpms-tests/src/test/java/blackbird/jpms/test/beans/JpmsPackagePrivateTest.java @@ -0,0 +1,91 @@ +package blackbird.jpms.test.beans; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.lang.invoke.MethodHandles; +import java.nio.charset.StandardCharsets; +import java.util.function.Supplier; + +import org.junit.jupiter.api.Test; + +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.module.blackbird.BlackbirdModule; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Runs on the module path as the named module blackbird.jpms.test, which + * requires blackbird: the arrangement a modular application has. A + * package-private bean of this module must accelerate when the application + * supplies its own lookup, because the generated codec - defined in this + * module's package context - can resolve its supertype from blackbird's + * exported internal package. Without a usable lookup the bean demotes to the + * stock path and behavior is unchanged. + * + * Engagement is asserted through the blackbird.debug.codegen diagnostic + * stream: byte-identical output makes generated and stock otherwise + * indistinguishable from out here. + */ +public class JpmsPackagePrivateTest +{ + static { + // Before any Blackbird class initializes: the factories cache this. + System.setProperty("blackbird.debug.codegen", "true"); + } + + private static final String DOC = "{\"count\":7,\"name\":\"x\"}"; + + @Test + public void packagePrivateBeanAcceleratesWithModuleLookup() throws Exception { + ObjectMapper mapper = JsonMapper.builder() + .addModule(new BlackbirdModule() { + private static final long serialVersionUID = 1L; + @Override + protected Supplier findLookupSupplier() { + return Beans::lookup; + } + }) + .build(); + String err = captureErr(() -> { + PkgBean bean = mapper.readValue(DOC, PkgBean.class); + assertEquals(7, bean.getCount()); + assertEquals("x", bean.getName()); + }); + assertTrue(err.contains("BBReader_PkgBean"), + "expected a generated codec for PkgBean; diagnostics:\n" + err); + assertFalse(err.contains("null (gated)"), + "PkgBean was gated instead of accelerated; diagnostics:\n" + err); + } + + @Test + public void defaultLookupDemotesToStock() throws Exception { + ObjectMapper mapper = JsonMapper.builder() + .addModule(new BlackbirdModule()) + .build(); + String err = captureErr(() -> { + PkgBean bean = mapper.readValue(DOC, PkgBean.class); + assertEquals(7, bean.getCount()); + assertEquals("x", bean.getName()); + }); + // Blackbird's own lookup cannot reach this module's package, so the + // bean stays stock - correctly, and without an error. + assertFalse(err.contains("BBReader_PkgBean"), err); + } + + private interface Body { + void run() throws Exception; + } + + private static synchronized String captureErr(Body body) throws Exception { + PrintStream original = System.err; + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + System.setErr(new PrintStream(buffer, true, StandardCharsets.UTF_8)); + try { + body.run(); + } finally { + System.setErr(original); + } + return buffer.toString(StandardCharsets.UTF_8); + } +} diff --git a/blackbird-tests/src/test/java/tools/jackson/module/blackbird/inject/BlackbirdInjectionTestBase.java b/blackbird-tests/src/test/java/tools/jackson/module/blackbird/inject/BlackbirdInjectionTestBase.java index 1f3575043..037d11cf8 100644 --- a/blackbird-tests/src/test/java/tools/jackson/module/blackbird/inject/BlackbirdInjectionTestBase.java +++ b/blackbird-tests/src/test/java/tools/jackson/module/blackbird/inject/BlackbirdInjectionTestBase.java @@ -1,8 +1,5 @@ package tools.jackson.module.blackbird.inject; -import java.lang.reflect.Field; -import java.util.ArrayList; -import java.util.List; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; @@ -11,25 +8,30 @@ 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.blackbird.BlackbirdModule; import static org.junit.jupiter.api.Assertions.assertNotNull; -// Test utilities for verifying that Blackbird's lambda-based injection pipeline -// actually ran on a given POJO. Because Blackbird's optimized property and writer -// classes (SettableIntProperty, etc.; IntPropertyWriter, etc.) are package-private -// inside the blackbird module, these checks rely on reflection and simple-name -// matching rather than compile-time type references. Mirrors the afterburner-tests -// harness; see that module's README for the broader rationale. +// Test utilities for verifying that Blackbird's codec generation engaged for a +// given POJO loaded from the unnamed module (classpath). Blackbird installs a +// per-bean placeholder (BBReaderPlaceholder / BBWriterPlaceholder) that +// resolves to a generated hidden-class codec; the placeholder types are +// package-private inside the blackbird module, so these checks match by simple +// name and package rather than compile-time type references. Mirrors the +// afterburner-tests harness; see that module's README for the broader +// rationale. abstract class BlackbirdInjectionTestBase { + static { + // Strict mode: a codec-generation failure fails the test instead of + // demoting to the stock path (see CodegenFallbacks). + System.setProperty("tools.jackson.module.blackbird.failOnCodegenError", "true"); + } + protected static Harness newHarness() { return new Harness(); } @@ -67,9 +69,11 @@ public ValueSerializer modifySerializer( }); } }; + // Modifiers run in reverse registration order, so the capture + // module registers first to observe what Blackbird installed. this.mapper = JsonMapper.builder() - .addModule(new BlackbirdModule()) .addModule(capture) + .addModule(new BlackbirdModule()) .build(); } @@ -86,86 +90,20 @@ ValueSerializer serFor(Class cls) { } } - /** 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"); + /** True if Blackbird installed its deserializer codec for the captured value. */ + protected static boolean isBlackbirdReader(ValueDeserializer deser) { + return blackbirdClassChainIncludes(deser.getClass(), "BBReaderPlaceholder"); } - /** 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}. Picks the error message hypothesis based on the - * class's package — databind/blackbird fields usually mean a rename, - * anything else means the caller passed the wrong receiver. */ - 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); - } - } - String pkg = origClass.getPackageName(); - String hint; - if (pkg.startsWith("tools.jackson.databind") - || pkg.startsWith("tools.jackson.module.blackbird")) { - hint = "databind or blackbird may have renamed or removed it;" - + " update " + BlackbirdInjectionTestBase.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 Blackbird's OptimizedSettableBeanProperty. */ - protected static boolean isOptimizedProperty(SettableBeanProperty prop) { - return blackbirdClassChainIncludes(prop.getClass(), "OptimizedSettableBeanProperty"); - } - - /** True if `writer`'s class chain contains Blackbird's OptimizedBeanPropertyWriter. */ - protected static boolean isOptimizedWriter(BeanPropertyWriter writer) { - return blackbirdClassChainIncludes(writer.getClass(), "OptimizedBeanPropertyWriter"); - } - - /** Walks the superclass chain of {@code cls} looking for a class whose simple - * name is {@code simpleName}. Used to recognize Blackbird'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; + /** True if Blackbird installed its serializer codec for the captured value. */ + protected static boolean isBlackbirdWriter(ValueSerializer ser) { + return blackbirdClassChainIncludes(ser.getClass(), "BBWriterPlaceholder"); } - /** Like {@link #classChainIncludes} but additionally requires the matched - * class to live inside a blackbird package. Guards against false positives - * from unrelated classes that happen to share a simple name. */ + /** Walks the superclass chain of {@code cls} looking for a class whose + * simple name is {@code simpleName} and that lives inside a blackbird + * package. Recognizes Blackbird's package-private types without importing + * them, and guards against unrelated classes sharing a simple name. */ protected static boolean blackbirdClassChainIncludes(Class cls, String simpleName) { Class c = cls; while (c != null) { diff --git a/blackbird-tests/src/test/java/tools/jackson/module/blackbird/inject/CrossLoaderAccessTest.java b/blackbird-tests/src/test/java/tools/jackson/module/blackbird/inject/CrossLoaderAccessTest.java deleted file mode 100644 index b775e7599..000000000 --- a/blackbird-tests/src/test/java/tools/jackson/module/blackbird/inject/CrossLoaderAccessTest.java +++ /dev/null @@ -1,88 +0,0 @@ -package tools.jackson.module.blackbird.inject; - -import org.junit.jupiter.api.Test; - -import tools.jackson.databind.deser.SettableBeanProperty; - -import static org.junit.jupiter.api.Assertions.*; - -// Documents a finding about Blackbird's CrossLoaderAccess code path: -// on JDK 9+ with a bean in the unnamed module (classpath), the companion -// `$$JacksonBlackbirdAccess` class is **never** defined. -// -// Why: BBDeserializerModifier.updateBuilder obtains a lookup via -// MethodHandles.lookup() inside BlackbirdModule (full privilege on blackbird's -// own class), then calls ReflectionHack.privateLookupIn(beanClass, lookup). -// For an unnamed-module bean, privateLookupIn always succeeds and returns a -// lookup with hasFullPrivilegeAccess() == true over the bean's package. Then -// CrossLoaderAccess.grantAccess short-circuits on `hasFullAccess(lookup)` and -// returns the lookup unchanged, skipping the slow path that would define a -// $$JacksonBlackbirdAccess companion class via Lookup.defineClass(byte[]). -// -// That slow path exists for historical / edge-case scenarios (JDK 8 when -// DEFINE_CLASS is null, or a future JDK change that lowers privileges on -// privateLookupIn). It is not exercised by either Blackbird's in-tree test -// suite (same-module case is also full-privilege) nor by this classpath -// test module. -// -// This test pins the current behavior: the fast path must win, and the -// companion class must not be introduced as a side effect of deserialization. -// If Blackbird's CrossLoaderAccess logic changes such that the slow path -// starts firing for classpath POJOs, this test will catch it — at which -// point the maintainer should decide whether that's intended (and update the -// assertion) or accidental (and revert). -public class CrossLoaderAccessTest extends BlackbirdInjectionTestBase -{ - public static class XLoaderBean { - private int a; - private String b; - public int getA() { return a; } - public void setA(int a) { this.a = a; } - public String getB() { return b; } - public void setB(String b) { this.b = b; } - } - - private static final String COMPANION_CLASS_NAME = - XLoaderBean.class.getPackage().getName() + ".$$JacksonBlackbirdAccess"; - - @Test - public void testFastPathWins_NoCompanionClassDefined() throws Exception - { - Harness h = newHarness(); - - // Trigger Blackbird's modifier chain for XLoaderBean. - XLoaderBean bean = h.mapper.readValue("{\"a\":1,\"b\":\"hi\"}", XLoaderBean.class); - assertEquals(1, bean.getA()); - assertEquals("hi", bean.getB()); - - // Optimization still ran — setters are Blackbird-optimized. - SettableBeanProperty[] props = propsOf(h.deserFor(XLoaderBean.class)); - assertEquals(2, props.length); - for (SettableBeanProperty p : props) { - assertTrue(isOptimizedProperty(p), - "XLoaderBean." + p.getName() + " not optimized: " - + p.getClass().getName()); - } - - // But the $$JacksonBlackbirdAccess companion class must NOT have been - // defined — CrossLoaderAccess.grantAccess short-circuits for a lookup - // with hasFullPrivilegeAccess() == true, which is what privateLookupIn - // returns for an unnamed-module target. - assertFalse(companionClassExists(), - "CrossLoaderAccess unexpectedly defined " + COMPANION_CLASS_NAME - + " for an unnamed-module bean — the grantAccess fast path" - + " should have short-circuited. If Blackbird has started" - + " taking the slow path (e.g. because privateLookupIn" - + " semantics changed), update this test."); - } - - private static boolean companionClassExists() { - try { - Class.forName(COMPANION_CLASS_NAME, false, - XLoaderBean.class.getClassLoader()); - return true; - } catch (ClassNotFoundException e) { - return false; - } - } -} diff --git a/blackbird-tests/src/test/java/tools/jackson/module/blackbird/inject/FieldAccessNotOptimizedTest.java b/blackbird-tests/src/test/java/tools/jackson/module/blackbird/inject/FieldAccessNotOptimizedTest.java deleted file mode 100644 index f36ee1cf9..000000000 --- a/blackbird-tests/src/test/java/tools/jackson/module/blackbird/inject/FieldAccessNotOptimizedTest.java +++ /dev/null @@ -1,53 +0,0 @@ -package tools.jackson.module.blackbird.inject; - -import org.junit.jupiter.api.Test; - -import tools.jackson.databind.deser.SettableBeanProperty; - -import static org.junit.jupiter.api.Assertions.*; - -// Documents a known design limitation of Blackbird: direct public-field access -// is NOT optimized. BBDeserializerModifier.nextProperty only handles properties -// whose backing JDK member is a Method (setter); if it's a Field, the method -// returns early and the property is left as a plain FieldProperty. Afterburner -// optimizes both setter and field access; Blackbird deliberately doesn't. -// -// The point of this test is to pin that contract. If Blackbird ever grows -// field-access support, this test will start failing — which is the correct -// signal to update it. -public class FieldAccessNotOptimizedTest extends BlackbirdInjectionTestBase -{ - public static class FieldOnlyBean { - public int intField; - public long longField; - public boolean boolField; - public String stringField; - } - - private final Harness h = newHarness(); - - @Test - public void testFieldPropsAreNotReplacedWithOptimizedVersions() throws Exception - { - // End-to-end deserialization must still work — Blackbird just delegates - // to databind's plain FieldProperty for these. - FieldOnlyBean bean = h.mapper.readValue( - "{\"intField\":1,\"longField\":2,\"boolField\":true,\"stringField\":\"x\"}", - FieldOnlyBean.class); - assertEquals(1, bean.intField); - assertEquals(2L, bean.longField); - assertTrue(bean.boolField); - assertEquals("x", bean.stringField); - - // None of the properties should be Blackbird-optimized. - SettableBeanProperty[] props = propsOf(h.deserFor(FieldOnlyBean.class)); - assertEquals(4, props.length); - for (SettableBeanProperty p : props) { - assertFalse(isOptimizedProperty(p), - "Blackbird unexpectedly optimized field property '" + p.getName() - + "' (is " + p.getClass().getName() + "). If Blackbird" - + " has grown field-access support, update this test to" - + " assert the positive case instead."); - } - } -} diff --git a/blackbird-tests/src/test/java/tools/jackson/module/blackbird/inject/FieldAccessTest.java b/blackbird-tests/src/test/java/tools/jackson/module/blackbird/inject/FieldAccessTest.java new file mode 100644 index 000000000..91365b579 --- /dev/null +++ b/blackbird-tests/src/test/java/tools/jackson/module/blackbird/inject/FieldAccessTest.java @@ -0,0 +1,37 @@ +package tools.jackson.module.blackbird.inject; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +// Public-field access is generated: a bean whose properties are all public +// fields engages a Blackbird codec that stores through putfield (read side) and +// getfield (write side), the same as a setter/getter POJO. Values must match +// stock databind. +public class FieldAccessTest extends BlackbirdInjectionTestBase +{ + public static class FieldOnlyBean { + public int intField; + public long longField; + public boolean boolField; + public String stringField; + } + + private final Harness h = newHarness(); + + @Test + public void testFieldBackedBeanEngagesCodec() throws Exception + { + FieldOnlyBean bean = h.mapper.readValue( + "{\"intField\":1,\"longField\":2,\"boolField\":true,\"stringField\":\"x\"}", + FieldOnlyBean.class); + assertEquals(1, bean.intField); + assertEquals(2L, bean.longField); + assertTrue(bean.boolField); + assertEquals("x", bean.stringField); + + assertTrue(isBlackbirdReader(h.deserFor(FieldOnlyBean.class)), + "FieldOnlyBean did not engage a Blackbird codec: " + + h.deserFor(FieldOnlyBean.class).getClass().getName()); + } +} diff --git a/blackbird-tests/src/test/java/tools/jackson/module/blackbird/inject/SerializerInjectionTest.java b/blackbird-tests/src/test/java/tools/jackson/module/blackbird/inject/SerializerInjectionTest.java index 41f6ada69..431480fab 100644 --- a/blackbird-tests/src/test/java/tools/jackson/module/blackbird/inject/SerializerInjectionTest.java +++ b/blackbird-tests/src/test/java/tools/jackson/module/blackbird/inject/SerializerInjectionTest.java @@ -1,19 +1,15 @@ package tools.jackson.module.blackbird.inject; -import java.util.List; - import org.junit.jupiter.api.Test; -import tools.jackson.databind.ser.BeanPropertyWriter; +import tools.jackson.databind.json.JsonMapper; import static org.junit.jupiter.api.Assertions.*; -// Verifies Blackbird's serializer-side optimization runs end-to-end for all -// accessor-method specializations, and documents the parallel limitation with -// the deserializer side: direct public-field writers are not optimized either. -// BBSerializerModifier.createProperty skips properties whose backing JDK -// member isn't an AnnotatedMethod (see BBSerializerModifier.java lines -// 103-109), so public fields stay as plain BeanPropertyWriter instances. +// Verifies Blackbird's serializer codec generation engages end-to-end on the +// classpath and produces output identical to stock databind, for a getter POJO +// and for a public-field POJO (whose values are read through a generated +// getfield). public class SerializerInjectionTest extends BlackbirdInjectionTestBase { public static class GetterSerPojo { @@ -44,44 +40,32 @@ public FieldSerPojo() { } } @Test - public void testAllGetterBasedWritersOptimized() throws Exception + public void testGetterPojoEngagesGeneratedWriter() throws Exception { Harness h = newHarness(); + GetterSerPojo pojo = new GetterSerPojo(1, 2L, true, "x", java.util.Arrays.asList("a", "b")); - String json = h.mapper.writeValueAsString( - new GetterSerPojo(1, 2L, true, "x", java.util.Arrays.asList("a", "b"))); - assertTrue(json.contains("\"intProp\":1"), json); - assertTrue(json.contains("\"longProp\":2"), json); - assertTrue(json.contains("\"boolProp\":true"), json); - assertTrue(json.contains("\"stringProp\":\"x\""), json); - assertTrue(json.contains("\"objectProp\":[\"a\",\"b\"]"), json); + String json = h.mapper.writeValueAsString(pojo); + assertEquals(new JsonMapper().writeValueAsString(pojo), json, + "generated writer output differs from stock databind"); - List writers = writersOf(h.serFor(GetterSerPojo.class)); - assertEquals(5, writers.size(), "expected 5 writers, got " + writers); - for (BeanPropertyWriter w : writers) { - assertTrue(isOptimizedWriter(w), - "ser writer '" + w.getName() + "' not optimized (is " - + w.getClass().getName() + "); BBSerializerModifier" - + " did not replace it with a Blackbird-generated writer"); - } + assertTrue(isBlackbirdWriter(h.serFor(GetterSerPojo.class)), + "GetterSerPojo did not engage a Blackbird writer codec: " + + h.serFor(GetterSerPojo.class).getClass().getName()); } @Test - public void testFieldBackedWriterNotOptimized() throws Exception + public void testFieldBackedWriterEngagesCodec() throws Exception { Harness h = newHarness(); + FieldSerPojo pojo = new FieldSerPojo(42); - // Serialization still works end-to-end, just not via a Blackbird-generated accessor. - String json = h.mapper.writeValueAsString(new FieldSerPojo(42)); - assertTrue(json.contains("\"value\":42"), json); + String json = h.mapper.writeValueAsString(pojo); + assertEquals(new JsonMapper().writeValueAsString(pojo), json, + "field-backed output differs from stock databind"); - List writers = writersOf(h.serFor(FieldSerPojo.class)); - assertEquals(1, writers.size()); - BeanPropertyWriter w = writers.get(0); - assertFalse(isOptimizedWriter(w), - "Blackbird unexpectedly optimized a field-backed writer '" + w.getName() - + "' (is " + w.getClass().getName() + "). If BBSerializerModifier" - + " has grown field-access support, update this test to assert" - + " the positive case instead."); + assertTrue(isBlackbirdWriter(h.serFor(FieldSerPojo.class)), + "FieldSerPojo did not engage a Blackbird writer codec: " + + h.serFor(FieldSerPojo.class).getClass().getName()); } } diff --git a/blackbird-tests/src/test/java/tools/jackson/module/blackbird/inject/SetterOptimizationTest.java b/blackbird-tests/src/test/java/tools/jackson/module/blackbird/inject/SetterOptimizationTest.java index 93db3d552..05fa1f8d1 100644 --- a/blackbird-tests/src/test/java/tools/jackson/module/blackbird/inject/SetterOptimizationTest.java +++ b/blackbird-tests/src/test/java/tools/jackson/module/blackbird/inject/SetterOptimizationTest.java @@ -2,17 +2,12 @@ import org.junit.jupiter.api.Test; -import tools.jackson.databind.deser.SettableBeanProperty; - import static org.junit.jupiter.api.Assertions.*; -// End-to-end verification that Blackbird's setter optimization runs on POJOs -// loaded from the unnamed module (classpath). The specializations tested here -// correspond to BBDeserializerModifier's four primitive branches (int, long, -// boolean, object/String) plus the object/non-String branch. -// -// Note: Blackbird does NOT optimize direct public-field access — only setter -// methods. See FieldAccessNotOptimizedTest for the documented negative case. +// End-to-end verification that Blackbird's codec generation engages for setter +// POJOs loaded from the unnamed module (classpath). The beans cover the +// generator's scalar kinds (int, long, boolean, String) plus a non-scalar +// setter that rides the codec's stock-property arm. public class SetterOptimizationTest extends BlackbirdInjectionTestBase { public static class IntSetterBean { @@ -47,28 +42,28 @@ public static class ObjectSetterBean { public void testIntSetter() throws Exception { IntSetterBean b = h.mapper.readValue("{\"value\":42}", IntSetterBean.class); assertEquals(42, b.getValue()); - assertAllPropsOptimized(IntSetterBean.class); + assertCodecEngaged(IntSetterBean.class); } @Test public void testLongSetter() throws Exception { LongSetterBean b = h.mapper.readValue("{\"value\":9999999999}", LongSetterBean.class); assertEquals(9999999999L, b.getValue()); - assertAllPropsOptimized(LongSetterBean.class); + assertCodecEngaged(LongSetterBean.class); } @Test public void testBooleanSetter() throws Exception { BooleanSetterBean b = h.mapper.readValue("{\"value\":true}", BooleanSetterBean.class); assertTrue(b.isValue()); - assertAllPropsOptimized(BooleanSetterBean.class); + assertCodecEngaged(BooleanSetterBean.class); } @Test public void testStringSetter() throws Exception { StringSetterBean b = h.mapper.readValue("{\"value\":\"hi\"}", StringSetterBean.class); assertEquals("hi", b.getValue()); - assertAllPropsOptimized(StringSetterBean.class); + assertCodecEngaged(StringSetterBean.class); } @Test @@ -76,17 +71,12 @@ public void testObjectSetter() throws Exception { ObjectSetterBean b = h.mapper.readValue("{\"value\":[\"a\",\"b\"]}", ObjectSetterBean.class); assertEquals(2, b.getValue().size()); assertEquals("a", b.getValue().get(0)); - assertAllPropsOptimized(ObjectSetterBean.class); + assertCodecEngaged(ObjectSetterBean.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() - + " — BBDeserializerModifier did not install a SettableXProperty"); - } + private void assertCodecEngaged(Class cls) { + assertTrue(isBlackbirdReader(h.deserFor(cls)), + cls.getSimpleName() + " did not engage a Blackbird codec: " + + h.deserFor(cls).getClass().getName()); } } diff --git a/blackbird/README.md b/blackbird/README.md index 1d1c26ddc..d543416fb 100644 --- a/blackbird/README.md +++ b/blackbird/README.md @@ -1,38 +1,34 @@ # Jackson Blackbird -_Upgrade your Afterburner for your Java 11+ Environment 🚀_ - -The [Afterburner](https://github.com/FasterXML/jackson-modules-base/tree/master/afterburner) -has long been your engine of choice for maximum Jackson performance. -But in the brave new Java 11 world, the trusty Afterburner is showing its age. -It uses horrifying bytecode manipulation and cracks `Unsafe.defineClass` which will -[stop working soon](https://github.com/FasterXML/jackson-modules-base/issues/37). - -Blackbird is Afterburner Mk II - powered by 100% renewable Lambda based fuel. - -The [LambdaMetafactory](https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/lang/invoke/LambdaMetafactory.html) -introduces a standard Java API for dynamically instantiating function objects. -The current OpenJDK implementation generates anonymous classes in a somewhat similar fashion -to the classic Afterburner. While the metafactory cannot generate comparably specialized -implementations, we can write needed adapters as simple Java code and use the metafactory -to create distinct call sites for every needed access path. This should allow each accessor to -have a monomorphic call profile and easily inline for maximum performance. - -> **Warning** -> The Java implementation of lambdas associates each generated lambda object strongly with the -> ClassLoader of the target class. This means that each Blackbird instance will add dynamic accessors -> to your classes that cannot be unloaded until the entire ClassLoader is garbage collected. -> Therefore, Blackbird is not appropriate if you instantiate many ObjectMappers, since -> you will eventually run out of class space and OOM. +Blackbird accelerates Jackson databind with generated per-bean codecs. For +each bean type it defines one hidden class with the JDK ClassFile API +(JEP 484) and installs it as that bean's deserializer. The generated codec +matches property names with the parser's `PropertyNameMatcher`, dispatches on +the match index with a `tableswitch`, and reads each property with direct, +monomorphic code. The JIT sees one inlinable region per bean type instead of +the stock dispatch chain. + +This is the second Blackbird engine. The first (Jackson 2.x, and 3.x before +this version) built accessor lambdas with `LambdaMetafactory` inside the stock +deserializer loop. The current engine replaces the loop itself. Blackbird +2.x continues to serve Jackson 2.x users. + +## Requirements + +- Jackson 3.x. +- JDK 25 or later (the module is compiled with `--release 25`). ## Status [![Maven Central](https://img.shields.io/maven-central/v/tools.jackson.module/jackson-module-blackbird.svg?label=Maven%20Central)](https://central.sonatype.com/artifact/tools.jackson.module/jackson-module-blackbird) -Blackbird is new and not as mature as Afterburner, but has been tested and runs well. -The code is written to fail-fast and explode on the tarmac rather than later at runtime. +The module passes the full Blackbird test suite, and generated codecs are +verified byte-identical to stock databind on the benchmark corpus. -Blackbird passes all the original Afterburner tests (except a couple that didn't make sense anymore). +Generated codecs unload with the mapper: the hidden classes are anchored only +by the codec instances in the mapper's caches. The old engine's lambdas stayed +alive with the target ClassLoader, so applications that created many mappers +ran out of metaspace. That limitation is gone. ## Usage @@ -47,9 +43,9 @@ Blackbird is available on Maven Central: ``` -### Registering module +### Registering the module -To use the Module in Jackson, simply register it with the ObjectMapper instance: +Register the module with the mapper: ```java ObjectMapper mapper = JsonMapper.builder() @@ -57,46 +53,70 @@ ObjectMapper mapper = JsonMapper.builder() .build(); ``` -after which you just do data-binding as usual: +then do data binding as usual: ```java Value val = mapper.readValue(jsonSource, Value.class); mapper.writeValue(new File("result.json"), val); ``` -If you're really brave and running with a modulepath, you may need to grant Blackbird access to your classes. -This is done by calling the caller sensitive `MethodHandles.lookup()`. The module constructor allows you to customize -which lookup instances are used for what classes. - -### What is optimized? - -Following things are optimized: - -* For serialization (POJOs to JSON): - * Getter methods are inlined using generated lambdas instead of reflection - * Serializers for small number of 'primitive' types (`int`, `long`, `boolean`, `String`) are replaced with lambda-specialized generators, instead of getting delegated to `JsonSerializer`s -* For deserialization (JSON to POJOs): - * Calls to default (no-argument) constructors are lambda-fied instead of using reflection - * Calls to @JsonCreate factory methods and constructors with arguments get a slightly less efficient lambda based implementation - - This is new in Blackbird; Afterburner never did this - * Setter methods are called using lambdas instead of reflection - * Deserializers for small number of 'primitive' types (`int`, `long`, `boolean`, `String`) are replaced with lambda-specialized parsers, instead of getting delegated to `JsonDeserializer`s - -### ... and what is not? - -* Any sort of direct field access. This may be possible to support someday but requires additional JDK support. Use methods instead. -* Streaming parser/generator access -* Tree Model: there isn't much that can be done at this level +On the module path, grant Blackbird access to your classes by supplying a +`MethodHandles.Lookup` from your own module. Override `findLookup()` or +`findLookupSupplier()` on `BlackbirdModule` to customize which lookup is used +for which class. Record deserialization needs a lookup with access to the +record's canonical constructor, and non-public classes accelerate only with +a lookup that can reach them (the codec for a non-public class is defined in +that class's package context; without an eligible lookup the class stays on +the stock path). The `tools.jackson.module.blackbird.internal` package is +exported only so such codecs can resolve their supertypes; it is not API. + +## What is optimized? + +Deserialization (JSON to POJOs): + +- Setter-based POJOs: direct construction and direct setter calls. +- Field-backed properties: a public non-final field is stored through a direct + putfield; a non-public field is reached through a lookup-derived handle, the + same as a non-public setter, so it needs a `Lookup` for that class. Old + Blackbird could not write fields at all. Final fields keep stock behavior. +- Records: typed locals in canonical-constructor order and a single + constructor call, replacing the generic creator buffering. This is the + largest measured win. +- Builder-based beans (for example Immutables types): fluent setter calls + inlined, build method called through a constant MethodHandle. +- Scalar properties (`String`, `int`, `long`, `boolean`, enums) read with + direct parser calls. Nested beans call the child codec directly. Lists of + strings and of beans use inline loops. + +Serialization (POJOs to JSON): straight-line writers with pre-encoded name +constants, direct getter or public-field reads, and inline list loops. +Property writes go through per-type generated helpers sized to compile as +standalone units, which avoids a C2 code-quality penalty for inlined copies of +hot jackson-core methods inside looping writer bodies. + +Properties and beans outside this coverage keep stock behavior by +construction. A property with a custom deserializer, non-default coercion or +null handling, polymorphic typing, or injection routes through the stock +property implementation inside the generated codec. Beans with features the +generator does not cover keep the stock `BeanDeserializer` outright. + +## What is not? + +- Streaming parser and generator access. +- The tree model. + +## Debugging + +With `-Dblackbird.debug.codegen=true`, the module prints each generation +decision (engaged, or which gate demoted the bean) to standard error. With +`-Dblackbird.debug.dumpDir=`, the module writes each generated class's +bytes to that directory as `-reader.class` or +`-writer.class`, for inspection with javap or a decompiler. ## Performance -Blackbird has been lightly performance tested using `jmh` on openjdk 11 and 12. -For reading and writing a moderately complex bean, Blackbird performance seems -to be almost exactly on par with Afterburner - up to 20% better than vanilla Jackson in some cases. -At that point the method invocation overhead disappears behind the heat generated -from the long parser and UTF8 decoder. - -I used [jitwatch](https://github.com/AdoptOpenJDK/jitwatch) with `hsdis` to verify the generated code at a -superficial level. The generated call sites do seem to be good targets for inlining, although unfortunately -[jitwatch currently can't analyze lambdas very well](https://github.com/AdoptOpenJDK/jitwatch/issues/282) so -more advanced analysis will have to come later. +Measured with paired JMH runs on JDK 25/26, aarch64 and x86_64, against the +previous Blackbird engine: setter POJOs +7-9%, record graphs +48-49%, builder +beans +19-20%. Against vanilla databind, record graphs measure up to +124%. +Record and builder acceleration is new; the previous engine left both on the +stock path. diff --git a/blackbird/pom.xml b/blackbird/pom.xml index e673dd2e3..1fe7d448d 100644 --- a/blackbird/pom.xml +++ b/blackbird/pom.xml @@ -27,6 +27,11 @@ that uses LambdaMetafactory based code generation to replace reflection calls. + 25 + + 25 + 25 tools/jackson/module/blackbird ${project.groupId}.blackbird diff --git a/blackbird/src/main/java/module-info.java b/blackbird/src/main/java/module-info.java index cbe9b3422..814b083a4 100644 --- a/blackbird/src/main/java/module-info.java +++ b/blackbird/src/main/java/module-info.java @@ -7,6 +7,9 @@ requires transitive tools.jackson.databind; exports tools.jackson.module.blackbird; + // Internal supertypes of generated hidden classes; exported so codecs + // defined in user-module contexts can resolve them. Not API. + exports tools.jackson.module.blackbird.internal; provides tools.jackson.databind.JacksonModule with tools.jackson.module.blackbird.BlackbirdModule; diff --git a/blackbird/src/main/java/tools/jackson/module/blackbird/BlackbirdModule.java b/blackbird/src/main/java/tools/jackson/module/blackbird/BlackbirdModule.java index 01e109943..e5e3a6499 100644 --- a/blackbird/src/main/java/tools/jackson/module/blackbird/BlackbirdModule.java +++ b/blackbird/src/main/java/tools/jackson/module/blackbird/BlackbirdModule.java @@ -96,9 +96,8 @@ public void setupModule(SetupContext context) return; } Function, Lookup> lookup = findLookup(); - CrossLoaderAccess openSesame = new CrossLoaderAccess(); - context.addDeserializerModifier(new BBDeserializerModifier(lookup, openSesame)); - context.addSerializerModifier(new BBSerializerModifier(lookup, openSesame)); + context.addDeserializerModifier(new BBDeserializerModifier(lookup)); + context.addSerializerModifier(new BBSerializerModifier(lookup)); } @Override diff --git a/blackbird/src/main/java/tools/jackson/module/blackbird/CrossLoaderAccess.java b/blackbird/src/main/java/tools/jackson/module/blackbird/CrossLoaderAccess.java deleted file mode 100644 index 2c064276d..000000000 --- a/blackbird/src/main/java/tools/jackson/module/blackbird/CrossLoaderAccess.java +++ /dev/null @@ -1,184 +0,0 @@ -package tools.jackson.module.blackbird; - -import java.io.ByteArrayOutputStream; -import java.io.DataOutputStream; -import java.io.IOException; -import java.lang.invoke.MethodHandle; -import java.lang.invoke.MethodHandles; -import java.lang.invoke.MethodType; -import java.util.function.UnaryOperator; - -class CrossLoaderAccess implements UnaryOperator { - private static final MethodHandle DEFINE_CLASS, HAS_FULL_ACCESS; - - /** - * @deprecated Since 3.2. Only referenced by {@link #accessClassIn}, which - * is dead code on JDK 9+ for all known inputs. Scheduled for removal - * together with the companion-class slow path. - */ - @Deprecated(since = "3.2", forRemoval = true) - private static final String CLASS_NAME = "$$JacksonBlackbirdAccess"; - - // Pre-compiled Java 8 bytecode: - // package ; - // public class BlackbirdAccess { - // public static final MethodHandles.Lookup LOOKUP = MethodHandles.lookup(); - // } - - /** - * @deprecated Since 3.2. See {@link #accessClassIn}. - */ - @Deprecated(since = "3.2", forRemoval = true) - private static final int[] HEADER = new int[] { - 0xca, 0xfe, 0xba, 0xbe, 0x00, 0x00, 0x00, 0x34, 0x00, 0x1c, 0x0a, 0x00, - 0x02, 0x00, 0x03, 0x07, 0x00, 0x04, 0x0c, 0x00, 0x05, 0x00, 0x06, 0x01, - 0x00, 0x10, 0x6a, 0x61, 0x76, 0x61, 0x2f, 0x6c, 0x61, 0x6e, 0x67, 0x2f, - 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x01, 0x00, 0x06, 0x3c, 0x69, 0x6e, - 0x69, 0x74, 0x3e, 0x01, 0x00, 0x03, 0x28, 0x29, 0x56, 0x0a, 0x00, 0x08, - 0x00, 0x09, 0x07, 0x00, 0x0a, 0x0c, 0x00, 0x0b, 0x00, 0x0c, 0x01, 0x00, - 0x1e, 0x6a, 0x61, 0x76, 0x61, 0x2f, 0x6c, 0x61, 0x6e, 0x67, 0x2f, 0x69, - 0x6e, 0x76, 0x6f, 0x6b, 0x65, 0x2f, 0x4d, 0x65, 0x74, 0x68, 0x6f, 0x64, - 0x48, 0x61, 0x6e, 0x64, 0x6c, 0x65, 0x73, 0x01, 0x00, 0x06, 0x6c, 0x6f, - 0x6f, 0x6b, 0x75, 0x70, 0x01, 0x00, 0x29, 0x28, 0x29, 0x4c, 0x6a, 0x61, - 0x76, 0x61, 0x2f, 0x6c, 0x61, 0x6e, 0x67, 0x2f, 0x69, 0x6e, 0x76, 0x6f, - 0x6b, 0x65, 0x2f, 0x4d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x48, 0x61, 0x6e, - 0x64, 0x6c, 0x65, 0x73, 0x24, 0x4c, 0x6f, 0x6f, 0x6b, 0x75, 0x70, 0x3b, - 0x09, 0x00, 0x0e, 0x00, 0x0f, 0x07, 0x00, 0x10, 0x0c, 0x00, 0x11, 0x00, - 0x12, 0x01 - }; - /** - * @deprecated Since 3.2. See {@link #accessClassIn}. - */ - @Deprecated(since = "3.2", forRemoval = true) - private static final int[] FOOTER = new int[] { - 0x01, 0x00, 0x06, 0x4c, 0x4f, 0x4f, 0x4b, 0x55, 0x50, 0x01, 0x00, 0x27, - 0x4c, 0x6a, 0x61, 0x76, 0x61, 0x2f, 0x6c, 0x61, 0x6e, 0x67, 0x2f, 0x69, - 0x6e, 0x76, 0x6f, 0x6b, 0x65, 0x2f, 0x4d, 0x65, 0x74, 0x68, 0x6f, 0x64, - 0x48, 0x61, 0x6e, 0x64, 0x6c, 0x65, 0x73, 0x24, 0x4c, 0x6f, 0x6f, 0x6b, - 0x75, 0x70, 0x3b, 0x01, 0x00, 0x04, 0x43, 0x6f, 0x64, 0x65, 0x01, 0x00, - 0x0f, 0x4c, 0x69, 0x6e, 0x65, 0x4e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x54, - 0x61, 0x62, 0x6c, 0x65, 0x01, 0x00, 0x08, 0x3c, 0x63, 0x6c, 0x69, 0x6e, - 0x69, 0x74, 0x3e, 0x01, 0x00, 0x0a, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, - 0x46, 0x69, 0x6c, 0x65, 0x01, 0x00, 0x14, 0x42, 0x6c, 0x61, 0x63, 0x6b, - 0x62, 0x69, 0x72, 0x64, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x2e, 0x6a, - 0x61, 0x76, 0x61, 0x01, 0x00, 0x0c, 0x49, 0x6e, 0x6e, 0x65, 0x72, 0x43, - 0x6c, 0x61, 0x73, 0x73, 0x65, 0x73, 0x07, 0x00, 0x1a, 0x01, 0x00, 0x25, - 0x6a, 0x61, 0x76, 0x61, 0x2f, 0x6c, 0x61, 0x6e, 0x67, 0x2f, 0x69, 0x6e, - 0x76, 0x6f, 0x6b, 0x65, 0x2f, 0x4d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x48, - 0x61, 0x6e, 0x64, 0x6c, 0x65, 0x73, 0x24, 0x4c, 0x6f, 0x6f, 0x6b, 0x75, - 0x70, 0x01, 0x00, 0x06, 0x4c, 0x6f, 0x6f, 0x6b, 0x75, 0x70, 0x00, 0x21, - 0x00, 0x0e, 0x00, 0x02, 0x00, 0x00, 0x00, 0x01, 0x00, 0x19, 0x00, 0x11, - 0x00, 0x12, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0x00, 0x05, 0x00, 0x06, - 0x00, 0x01, 0x00, 0x13, 0x00, 0x00, 0x00, 0x1d, 0x00, 0x01, 0x00, 0x01, - 0x00, 0x00, 0x00, 0x05, 0x2a, 0xb7, 0x00, 0x01, 0xb1, 0x00, 0x00, 0x00, - 0x01, 0x00, 0x14, 0x00, 0x00, 0x00, 0x06, 0x00, 0x01, 0x00, 0x00, 0x00, - 0x01, 0x00, 0x08, 0x00, 0x15, 0x00, 0x06, 0x00, 0x01, 0x00, 0x13, 0x00, - 0x00, 0x00, 0x1f, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x07, 0xb8, - 0x00, 0x07, 0xb3, 0x00, 0x0d, 0xb1, 0x00, 0x00, 0x00, 0x01, 0x00, 0x14, - 0x00, 0x00, 0x00, 0x06, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x02, - 0x00, 0x16, 0x00, 0x00, 0x00, 0x02, 0x00, 0x17, 0x00, 0x18, 0x00, 0x00, - 0x00, 0x0a, 0x00, 0x01, 0x00, 0x19, 0x00, 0x08, 0x00, 0x1b, 0x00, 0x19 - }; - - static { - MethodHandle defineClass = null, hasFullAccess = null; - try { - defineClass = MethodHandles.lookup().findVirtual(MethodHandles.Lookup.class, "defineClass", - MethodType.methodType(Class.class, byte[].class)); - hasFullAccess = MethodHandles.lookup().findVirtual(MethodHandles.Lookup.class, "hasFullPrivilegeAccess", - MethodType.methodType(boolean.class)); - } catch (ReflectiveOperationException ign) { } - DEFINE_CLASS = defineClass; - HAS_FULL_ACCESS = hasFullAccess; - } - - @Override - public MethodHandles.Lookup apply(MethodHandles.Lookup lookup) { - try { - return grantAccess(lookup); - } catch (IOException | ReflectiveOperationException | RuntimeException e) { - e.printStackTrace(); - return lookup; - } - } - - private static MethodHandles.Lookup grantAccess(MethodHandles.Lookup lookup) throws IOException, ReflectiveOperationException { - // Fast path: on JDK 9+, privateLookupIn always yields a full-privilege - // lookup for any bean class Blackbird can see, so this branch is taken - // for every real-world call and the companion-class slow path below - // is effectively unreachable. Verified by `blackbird-tests` - // CrossLoaderAccessTest; see that test's javadoc for the full rationale. - if (DEFINE_CLASS == null || hasFullAccess(lookup)) { - return lookup; - } - // Legacy slow path, retained while we finish verifying it has no live - // callers in any supported JDK / lookup configuration. Scheduled for - // removal — see `accessClassIn` javadoc. - return (MethodHandles.Lookup) accessClassIn(lookup).getField("LOOKUP").get(null); - } - - private static boolean hasFullAccess(MethodHandles.Lookup lookup) { - try { - return HAS_FULL_ACCESS != null && (boolean) HAS_FULL_ACCESS.invokeExact(lookup); - } catch (RuntimeException | Error e) { - throw e; - } catch (Throwable e) { - throw new RuntimeException(e); - } - } - - /** - * Defines a {@code $$JacksonBlackbirdAccess} companion class in the same - * package as {@code lookup.lookupClass()} and returns it. The companion - * class exposes a {@code public static final MethodHandles.Lookup LOOKUP} - * field (initialized from a class-initializer calling - * {@link MethodHandles#lookup()}), which callers then use as a - * full-privilege lookup for operations in that package. - * - * @deprecated Since 3.2. This is legacy Java 8 / pre-{@code privateLookupIn} - * fallback logic that is no longer reachable for any known input on - * JDK 9+. The {@link #grantAccess} fast path always wins because - * {@link MethodHandles#privateLookupIn} returns a lookup with - * {@link MethodHandles.Lookup#hasFullPrivilegeAccess()} {@code == true} - * for any bean class Blackbird can see. The - * {@code jackson-module-blackbird-tests} module - * {@code CrossLoaderAccessTest} pins this behavior. If a real live - * caller for this slow path is identified before 4.0, remove the - * {@link Deprecated} marker and keep the method; otherwise, delete - * along with {@link #CLASS_NAME}, {@link #HEADER}, and {@link #FOOTER}. - */ - @Deprecated(since = "3.2", forRemoval = true) - private static Class accessClassIn(MethodHandles.Lookup lookup) throws IOException, ReflectiveOperationException { - Package pkg = lookup.lookupClass().getPackage(); - final String pkgName = pkg.getName(); - String accessClassName = pkgName.isEmpty() ? CLASS_NAME : (pkgName + "." + CLASS_NAME); - ClassLoader lookupClassLoader = lookup.lookupClass().getClassLoader(); - try { - return Class.forName(accessClassName, true, lookupClassLoader); - } catch (ClassNotFoundException ign) { } - synchronized (CrossLoaderAccess.class) { - try { - return Class.forName(accessClassName, true, lookupClassLoader); - } catch (ClassNotFoundException ign) { } - String fqcn = pkgName.isEmpty() ? CLASS_NAME - : (pkgName.replace('.', '/') + "/" + CLASS_NAME); - - ByteArrayOutputStream classBytes = new ByteArrayOutputStream(HEADER.length + FOOTER.length + fqcn.length() + 16); - DataOutputStream dataOut = new DataOutputStream(classBytes); - for (int b : HEADER) { - dataOut.writeByte(b); - } - dataOut.writeUTF(fqcn); - for (int b : FOOTER) { - dataOut.writeByte(b); - } - try { - return (Class) DEFINE_CLASS.invokeExact(lookup, classBytes.toByteArray()); - } catch (RuntimeException | Error | IOException | ReflectiveOperationException e) { - throw e; - } catch (Throwable e) { - throw new RuntimeException(e); - } - } - } -} diff --git a/blackbird/src/main/java/tools/jackson/module/blackbird/codegen/BeanReaderGenerator.java b/blackbird/src/main/java/tools/jackson/module/blackbird/codegen/BeanReaderGenerator.java new file mode 100644 index 000000000..6cb59dee8 --- /dev/null +++ b/blackbird/src/main/java/tools/jackson/module/blackbird/codegen/BeanReaderGenerator.java @@ -0,0 +1,1180 @@ +package tools.jackson.module.blackbird.codegen; + +import java.lang.classfile.ClassFile; +import java.lang.classfile.CodeBuilder; +import java.lang.classfile.Label; +import java.lang.classfile.attribute.MethodParameterInfo; +import java.lang.classfile.attribute.MethodParametersAttribute; +import java.lang.classfile.instruction.SwitchCase; +import java.lang.constant.ClassDesc; +import java.lang.constant.ConstantDescs; +import java.lang.constant.DynamicConstantDesc; +import java.lang.constant.MethodTypeDesc; +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.Optional; + +import tools.jackson.core.sym.PropertyNameMatcher; +import tools.jackson.databind.ValueDeserializer; +import tools.jackson.databind.deser.SettableBeanProperty; +import tools.jackson.databind.deser.bean.BeanDeserializerBase; +import tools.jackson.databind.util.ClassUtil; +import tools.jackson.module.blackbird.internal.GeneratedReaderBase; + +/** + * Emits a hidden-class deserializer for one bean: a loop on nextNameMatch, a + * tableswitch on the property index, inlined scalar reads with direct setter + * calls for eligible properties, and the stock SettableBeanProperty (a named + * classData constant) for everything else. The matcher and the per-property + * payloads travel as a name-addressed classData map; the fallback + * deserializer is a constructor argument consumed by + * {@link GeneratedReaderBase}. + */ +public final class BeanReaderGenerator +{ + public enum Kind { STRING, INT, LONG, BOOLEAN, CHILD, STOCK } + + // How a generated codec treats an active view. NONE: views cannot affect + // this bean (the stock (de)serializer would ignore them too), no view code. + // DELEGATE: an active view hands the whole call to the stock fallback + // (beans with more than 64 properties). MASK: the codec resolves a cached + // per-view visibility bitmask and each property arm tests its bit, so + // view-active calls keep the generated fast path. + public enum ViewStrategy { NONE, DELEGATE, MASK } + + // setter applies to POJO and builder modes (null when setterHandle carries + // a non-public setter, or when field carries a public field, instead); + // type is the record component type in record mode and the child value + // type for CHILD; child is the linked generated codec for CHILD; field is + // set for a public field stored through putfield. + public record GenProp(String name, Kind kind, Method setter, SettableBeanProperty stock, + Class type, GeneratedReaderBase child, MethodHandle setterHandle, Field field) { + public GenProp(String name, Kind kind, Method setter, SettableBeanProperty stock) { + this(name, kind, setter, stock, null, null, null, null); + } + + public GenProp(String name, Kind kind, Method setter, SettableBeanProperty stock, + Class type) { + this(name, kind, setter, stock, type, null, null, null); + } + + public GenProp(String name, Kind kind, Method setter, SettableBeanProperty stock, + Class type, GeneratedReaderBase child, MethodHandle setterHandle) { + this(name, kind, setter, stock, type, child, setterHandle, null); + } + } + + private static final ClassDesc CD_JSON_PARSER = ClassDesc.of("tools.jackson.core.JsonParser"); + private static final ClassDesc CD_JSON_TOKEN = ClassDesc.of("tools.jackson.core.JsonToken"); + private static final ClassDesc CD_DESER_CONTEXT = ClassDesc.of("tools.jackson.databind.DeserializationContext"); + private static final ClassDesc CD_NAME_MATCHER = ClassDesc.of("tools.jackson.core.sym.PropertyNameMatcher"); + private static final ClassDesc CD_SETTABLE_PROP = ClassDesc.of("tools.jackson.databind.deser.SettableBeanProperty"); + // Derived from the class literal rather than a name: the test build + // compiles main sources into target/test-classes through --patch-module, + // and javac emits only compile-time-referenced classes there. The class + // literal makes sure that GeneratedReaderBase.class is present in the test + // module, which shadows target/classes at run time. Every same-module + // class that generated code names only as a string needs such a + // compile-time reference. + private static final ClassDesc CD_BASE = + GeneratedReaderBase.class.describeConstable().orElseThrow(); + private static final ClassDesc CD_BEAN_DESER_BASE = ClassDesc.of("tools.jackson.databind.deser.bean.BeanDeserializerBase"); + private static final ClassDesc CD_ISE = ClassDesc.of("java.lang.IllegalStateException"); + + private static final MethodTypeDesc MTD_NEXT_NAME_MATCH = + MethodTypeDesc.of(ConstantDescs.CD_int, CD_NAME_MATCHER); + private static final MethodTypeDesc MTD_NEXT_TOKEN = MethodTypeDesc.of(CD_JSON_TOKEN); + private static final MethodTypeDesc MTD_GET_STRING = MethodTypeDesc.of(ConstantDescs.CD_String); + private static final MethodTypeDesc MTD_GET_INT = MethodTypeDesc.of(ConstantDescs.CD_int); + private static final MethodTypeDesc MTD_GET_LONG = MethodTypeDesc.of(ConstantDescs.CD_long); + private static final MethodTypeDesc MTD_GET_BOOLEAN = MethodTypeDesc.of(ConstantDescs.CD_boolean); + private static final MethodTypeDesc MTD_ASSIGN_CURRENT = + MethodTypeDesc.of(ConstantDescs.CD_void, ConstantDescs.CD_Object); + private static final MethodTypeDesc MTD_DESERIALIZE_AND_SET = MethodTypeDesc.of( + ConstantDescs.CD_void, CD_JSON_PARSER, CD_DESER_CONTEXT, ConstantDescs.CD_Object); + private static final MethodTypeDesc MTD_DESERIALIZE = + MethodTypeDesc.of(ConstantDescs.CD_Object, CD_JSON_PARSER, CD_DESER_CONTEXT); + private static final MethodTypeDesc MTD_CTOR = + MethodTypeDesc.of(ConstantDescs.CD_void, CD_BEAN_DESER_BASE); + private static final ClassDesc CD_SET = java.util.Set.class.describeConstable().orElseThrow(); + private static final MethodTypeDesc MTD_CTOR4 = MethodTypeDesc.of(ConstantDescs.CD_void, + CD_BEAN_DESER_BASE, ConstantDescs.CD_boolean, CD_SET, CD_SET); + private static final MethodTypeDesc MTD_HANDLE_UNKNOWN = MethodTypeDesc.of( + ConstantDescs.CD_void, CD_JSON_PARSER, CD_DESER_CONTEXT, ConstantDescs.CD_Object); + private static final MethodTypeDesc MTD_CHECK_SEEN = MethodTypeDesc.of( + ConstantDescs.CD_void, CD_DESER_CONTEXT, ConstantDescs.CD_long, ConstantDescs.CD_int); + private static final MethodTypeDesc MTD_GET_ACTIVE_VIEW = + MethodTypeDesc.of(ConstantDescs.CD_Class); + private static final MethodTypeDesc MTD_VIEW_MASK = + MethodTypeDesc.of(ConstantDescs.CD_long, ConstantDescs.CD_Class); + private static final MethodTypeDesc MTD_HIDDEN_VIEW = MethodTypeDesc.of( + ConstantDescs.CD_void, CD_JSON_PARSER, CD_DESER_CONTEXT, CD_SETTABLE_PROP); + private static final MethodTypeDesc MTD_VISIBLE_IN_VIEW = + MethodTypeDesc.of(ConstantDescs.CD_boolean, ConstantDescs.CD_Class); + private static final ClassDesc CD_EXCEPTION = ClassDesc.of("java.lang.Exception"); + private static final MethodTypeDesc MTD_PROP_WRAP = MethodTypeDesc.of( + ClassDesc.of("java.lang.RuntimeException"), ClassDesc.of("java.lang.Throwable"), + ConstantDescs.CD_Object, CD_SETTABLE_PROP, CD_DESER_CONTEXT); + + // Generated code resolves class-data entries by NAME through the + // classDataEntry bootstrap on the base class (the JDK classDataAt + // bootstrap rejects any condy name but "_"); see dataName for the naming. + private static final java.lang.constant.DirectMethodHandleDesc BSM_DATA_ENTRY = + ConstantDescs.ofConstantBootstrap(CD_BASE, "classDataEntry", ConstantDescs.CD_Object); + + private BeanReaderGenerator() {} + + static void ldcData(CodeBuilder cob, String name, ClassDesc type) { + cob.ldc(DynamicConstantDesc.ofNamed(BSM_DATA_ENTRY, name, type)); + } + + // Condy names are JVM unqualified names ('.', ';', '[', '/' forbidden) and + // JSON property names are arbitrary, so sanitize and dedupe. + static String dataName(Map data, String base) { + StringBuilder sb = new StringBuilder(base.length()); + for (int i = 0; i < base.length(); i++) { + char c = base.charAt(i); + sb.append(c == '.' || c == ';' || c == '[' || c == '/' ? '$' : c); + } + String name = sb.toString(); + String candidate = name; + for (int n = 2; data.containsKey(candidate); n++) { + candidate = name + "$" + n; + } + return candidate; + } + + public static ValueDeserializer generate(Class beanClass, List props, + PropertyNameMatcher matcher, BeanDeserializerBase fallback, + MethodHandles.Lookup defineLookup, ViewStrategy views, Ignorals ignorals, + int[] aliasArms) + throws ReflectiveOperationException { + return generate(beanClass, props, matcher, fallback, null, null, defineLookup, views, + ignorals, aliasArms); + } + + // recordCtor non-null selects record mode: props are in canonical + // constructor order, values collect into typed locals, and the + // constructor MethodHandle (exact component signature) builds the value. + public static ValueDeserializer generate(Class beanClass, List props, + PropertyNameMatcher matcher, BeanDeserializerBase fallback, MethodHandle recordCtor, + MethodHandles.Lookup defineLookup, ViewStrategy views, Ignorals ignorals, + int[] aliasArms) + throws ReflectiveOperationException { + return generate(beanClass, props, matcher, fallback, recordCtor, null, defineLookup, views, + ignorals, aliasArms); + } + + // builderSupport non-null selects builder mode: values apply to a builder + // instance created by the stock ValueInstantiator, fluent setter returns + // replace the builder local, and the build MethodHandle (asType'd to + // (Object)Object) produces the value. + public record BuilderSupport(tools.jackson.databind.deser.ValueInstantiator instantiator, + MethodHandle buildMethod, Class builderClass) {} + + @SuppressWarnings("unchecked") + public static ValueDeserializer generate(Class beanClass, List props, + PropertyNameMatcher matcher, BeanDeserializerBase fallback, MethodHandle recordCtor, + BuilderSupport builder, MethodHandles.Lookup defineLookup, ViewStrategy views, + Ignorals ignorals, int[] aliasArms) + throws ReflectiveOperationException { + Map classData = new LinkedHashMap<>(); + classData.put("matcher", matcher); + // Tier-A properties also carry their SettableBeanProperty: their + // VALUE_NULL branch runs through it, since null handling is + // configuration-dependent per property. + String[] stockName = new String[props.size()]; + String[] childName = new String[props.size()]; + String[] setterName = new String[props.size()]; + for (int i = 0; i < props.size(); i++) { + GenProp gp = props.get(i); + stockName[i] = dataName(classData, gp.name() + "Prop"); + classData.put(stockName[i], gp.stock()); + if (gp.child() != null) { + childName[i] = dataName(classData, gp.name() + "Codec"); + classData.put(childName[i], gp.child()); + } + if (gp.setterHandle() != null) { + setterName[i] = dataName(classData, gp.name() + "Setter"); + classData.put(setterName[i], gp.setterHandle()); + } + } + if (recordCtor != null) { + classData.put("constructor", recordCtor); + } + if (builder != null) { + classData.put("instantiator", builder.instantiator()); + classData.put("buildMethod", builder.buildMethod()); + } + + // Non-public beans define in the bean's package context (the caller + // supplies a privateLookupIn of the bean class), which makes the + // generated new/invokevirtual/putfield instructions legal in-package. + // Public beans keep the module's own context. + MethodHandles.Lookup definer = + (defineLookup != null) ? defineLookup : MethodHandles.lookup(); + byte[] bytes = buildClass(definer.lookupClass().getPackageName(), beanClass, props, + stockName, childName, setterName, recordCtor != null, + builder == null ? null : builder.builderClass(), views, aliasArms); + CodegenDump.dump(beanClass, "reader", bytes); + // No ClassOption.STRONG: the codec instance held by the mapper's + // deserializer cache anchors the class, so codecs unload with the + // mapper instead of pinning metaspace for the loader's lifetime. + MethodHandles.Lookup hidden; + try { + hidden = definer.defineHiddenClassWithClassData( + bytes, Map.copyOf(classData), true); + } catch (IllegalAccessException | SecurityException | LinkageError e) { + if (defineLookup == null) { + // Module-context defines never legitimately fail: a generator bug. + if (e instanceof IllegalAccessException iae) { + throw iae; + } + if (e instanceof RuntimeException re) { + throw re; + } + throw (LinkageError) e; + } + // Bean-context defines can fail where the bean's module does not + // read this module at all (the exported .internal package holds + // the supertype): an environment gate, stock path. + return null; + } + MethodHandle ctor = hidden.findConstructor(hidden.lookupClass(), + MethodType.methodType(void.class, BeanDeserializerBase.class, + boolean.class, Set.class, Set.class)); + Ignorals ig = (ignorals == null) ? Ignorals.NONE : ignorals; + try { + return (ValueDeserializer) ctor.invoke(fallback, + ig.ignoreAllUnknown(), ig.ignorableProps(), ig.includableProps()); + } catch (Throwable t) { + throw new IllegalStateException("cannot instantiate generated codec", t); + } + } + + /** Ignoral configuration carried from the modifier into the codec. */ + public record Ignorals(boolean ignoreAllUnknown, Set ignorableProps, + Set includableProps) { + public static final Ignorals NONE = new Ignorals(false, null, null); + } + + private static byte[] buildClass(String targetPackage, Class beanClass, + List props, + String[] stockName, String[] childName, String[] setterName, + boolean recordMode, Class builderClass, + ViewStrategy views, int[] aliasArms) { + // A hidden class must be named in its define context's package. + ClassDesc thisClass = ClassDesc.of( + targetPackage + ".BBReader_" + beanClass.getSimpleName()); + return ClassFile.of().build(thisClass, clb -> { + clb.withFlags(ClassFile.ACC_PUBLIC | ClassFile.ACC_FINAL | ClassFile.ACC_SUPER); + clb.withSuperclass(CD_BASE); + clb.withMethod(ConstantDescs.INIT_NAME, MTD_CTOR4, ClassFile.ACC_PUBLIC, + mb -> mb.with(params("fallback", "ignoreAllUnknown", "ignorableProps", + "includableProps")) + .withCode(cob -> cob.aload(0).aload(1).iload(2).aload(3).aload(4) + .invokespecial(CD_BASE, ConstantDescs.INIT_NAME, MTD_CTOR4) + .return_())); + clb.withMethod("deserialize", MTD_DESERIALIZE, ClassFile.ACC_PUBLIC, + mb -> mb.with(params("p", "ctxt")).withCode(cob -> { + if (builderClass != null) { + buildBuilderDeserialize(cob, builderClass, props, stockName, + childName, views, aliasArms); + } else if (!recordMode) { + buildDeserialize(cob, beanClass, props, stockName, childName, + setterName, views, aliasArms); + } else { + buildRecordDeserialize(cob, beanClass, props, stockName, childName, + views, aliasArms); + } + })); + if (views == ViewStrategy.MASK) { + emitComputeViewMask(clb, props, stockName); + } + }); + } + + // Debug metadata: parameter names and named locals make dumped codecs + // (the blackbird.debug.dumpDir output) read like source in a decompiler; + // the verifier ignores both attributes. + static MethodParametersAttribute params(String... names) { + MethodParameterInfo[] infos = new MethodParameterInfo[names.length]; + for (int i = 0; i < names.length; i++) { + infos[i] = MethodParameterInfo.ofParameter(Optional.of(names[i]), 0); + } + return MethodParametersAttribute.of(infos); + } + + // Overrides GeneratedReaderBase._computeViewMask: bit i set when arm i's + // stock property is visible in the view, which keeps visibility semantics + // (matchers, default-view inclusion) exactly the stock ones. + private static void emitComputeViewMask(java.lang.classfile.ClassBuilder clb, + List props, String[] stockName) { + clb.withMethod("_computeViewMask", MTD_VIEW_MASK, ClassFile.ACC_PROTECTED, + mb -> mb.with(params("activeView")).withCode(cob -> { + final int maskSlot = 2; + Label scopeStart = cob.newBoundLabel(); + cob.lconst_0().lstore(maskSlot); + for (int i = 0; i < props.size(); i++) { + Label skip = cob.newLabel(); + ldcData(cob, stockName[i], CD_SETTABLE_PROP); + cob.aload(1); + cob.invokevirtual(CD_SETTABLE_PROP, "visibleInView", MTD_VISIBLE_IN_VIEW); + cob.ifeq(skip); + cob.lload(maskSlot); + cob.loadConstant(1L << i); + cob.lor(); + cob.lstore(maskSlot); + cob.labelBinding(skip); + } + cob.lload(maskSlot).lreturn(); + Label scopeEnd = cob.newBoundLabel(); + cob.localVariable(1, "activeView", ConstantDescs.CD_Class, scopeStart, scopeEnd); + cob.localVariable(maskSlot, "mask", ConstantDescs.CD_long, scopeStart, scopeEnd); + })); + } + + // MASK strategy: resolves the visibility bitmask for the call (all-ones + // when no view is active) into maskSlot. + private static void emitViewMask(CodeBuilder cob, int ctxt, int maskSlot) { + Label nullView = cob.newLabel(); + Label haveMask = cob.newLabel(); + cob.aload(ctxt).invokevirtual(CD_DESER_CONTEXT, "getActiveView", MTD_GET_ACTIVE_VIEW); + cob.dup(); + cob.ifnull(nullView); + cob.aload(0); + cob.swap(); + cob.invokevirtual(CD_BASE, "_viewMask", MTD_VIEW_MASK); + cob.lstore(maskSlot); + cob.goto_(haveMask); + cob.labelBinding(nullView); + cob.pop(); + cob.loadConstant(-1L); + cob.lstore(maskSlot); + cob.labelBinding(haveMask); + } + + // MASK strategy, start of each arm: a hidden property consumes its value + // with stock semantics (advance to the value token, then the base helper + // reports or skips) and continues the loop without touching the bean. + private static void emitArmViewCheck(CodeBuilder cob, int parser, int ctxt, int maskSlot, + int matcherSlot, int ixSlot, int armIndex, String stockName, Label loop) { + Label visible = cob.newLabel(); + cob.lload(maskSlot); + cob.loadConstant(1L << armIndex); + cob.land(); + cob.lconst_0(); + cob.lcmp(); + cob.ifne(visible); + cob.aload(0).aload(parser).aload(ctxt); + ldcData(cob, stockName, CD_SETTABLE_PROP); + cob.invokevirtual(CD_BASE, "_hiddenView", MTD_HIDDEN_VIEW); + nextNameMatch(cob, parser, matcherSlot, ixSlot); + cob.goto_(loop); + cob.labelBinding(visible); + } + + private static void buildDeserialize(CodeBuilder cob, Class beanClass, + List props, String[] stockName, String[] childName, String[] setterName, + ViewStrategy views, int[] aliasArms) { + final int parser = 1; + final int ctxt = 2; + final int beanSlot = 3; + final int matcherSlot = 4; + final int ixSlot = 5; + final int propSlot = 6; + final int excSlot = 7; + final int maskSlot = 8; + Label propHandler = cob.newLabel(); + + ClassDesc beanDesc = beanClass.describeConstable().orElseThrow(); + + Label scopeStart = cob.newBoundLabel(); + Label delegate = emitEntryGuard(cob, parser, ctxt, views); + if (views == ViewStrategy.MASK) { + emitViewMask(cob, ctxt, maskSlot); + } + + cob.new_(beanDesc).dup() + .invokespecial(beanDesc, ConstantDescs.INIT_NAME, ConstantDescs.MTD_void) + .astore(beanSlot); + cob.aload(parser).aload(beanSlot) + .invokevirtual(CD_JSON_PARSER, "assignCurrentValue", MTD_ASSIGN_CURRENT); + + ldcData(cob, "matcher", CD_NAME_MATCHER); + cob.astore(matcherSlot); + + Label loop = cob.newLabel(); + Label switchPart = cob.newLabel(); + Label endObject = cob.newLabel(); + Label unknown = cob.newLabel(); + Label oddToken = cob.newLabel(); + Label defaultCase = cob.newLabel(); + + emitFirstMatch(cob, parser, matcherSlot, ixSlot); + + cob.labelBinding(loop); + cob.iload(ixSlot).ifge(switchPart); + cob.iload(ixSlot).iconst_m1().if_icmpeq(endObject); + cob.iload(ixSlot).ldc(-2).if_icmpeq(unknown); + cob.goto_(oddToken); + + cob.labelBinding(switchPart); + List cases = new ArrayList<>(props.size()); + Label[] caseLabels = new Label[props.size()]; + for (int i = 0; i < props.size(); i++) { + caseLabels[i] = cob.newLabel(); + cases.add(SwitchCase.of(i, caseLabels[i])); + } + // Alias matcher indexes follow the primaries and share their arms. + int totalNames = props.size() + ((aliasArms == null) ? 0 : aliasArms.length); + for (int k = props.size(); k < totalNames; k++) { + cases.add(SwitchCase.of(k, caseLabels[aliasArms[k - props.size()]])); + } + cob.iload(ixSlot); + cob.tableswitch(0, totalNames - 1, defaultCase, cases); + + for (int i = 0; i < props.size(); i++) { + cob.labelBinding(caseLabels[i]); + if (views == ViewStrategy.MASK) { + emitArmViewCheck(cob, parser, ctxt, maskSlot, matcherSlot, ixSlot, i, + stockName[i], loop); + } + Label armEnd = beginArm(cob, stockName[i], propSlot, propHandler); + GenProp prop = props.get(i); + switch (prop.kind()) { + case STRING -> emitScalar(cob, beanSlot, parser, ctxt, beanDesc, prop, + "getString", MTD_GET_STRING, ConstantDescs.CD_String, + stockName[i], setterName[i], "VALUE_STRING"); + case INT -> emitScalar(cob, beanSlot, parser, ctxt, beanDesc, prop, + "getIntValue", MTD_GET_INT, ConstantDescs.CD_int, + stockName[i], setterName[i], "VALUE_NUMBER_INT"); + case LONG -> emitScalar(cob, beanSlot, parser, ctxt, beanDesc, prop, + "getLongValue", MTD_GET_LONG, ConstantDescs.CD_long, + stockName[i], setterName[i], "VALUE_NUMBER_INT"); + case BOOLEAN -> emitScalar(cob, beanSlot, parser, ctxt, beanDesc, prop, + "getBooleanValue", MTD_GET_BOOLEAN, ConstantDescs.CD_boolean, + stockName[i], setterName[i], null); + case CHILD -> { + Label childStock = cob.newLabel(); + Label childDone = cob.newLabel(); + ClassDesc childType = prop.type().describeConstable().orElseThrow(); + cob.aload(parser).invokevirtual(CD_JSON_PARSER, "currentToken", MTD_NEXT_TOKEN); + cob.getstatic(CD_JSON_TOKEN, "START_OBJECT", CD_JSON_TOKEN); + cob.if_acmpne(childStock); + cob.aload(beanSlot); + emitChildCall(cob, parser, ctxt, childName[i]); + cob.checkcast(childType); + emitStore(cob, beanDesc, prop, childType); + cob.goto_(childDone); + cob.labelBinding(childStock); + emitStockSet(cob, parser, ctxt, beanSlot, stockName[i]); + cob.labelBinding(childDone); + } + case STOCK -> { + emitStockSet(cob, parser, ctxt, beanSlot, stockName[i]); + } + } + cob.labelBinding(armEnd); + nextNameMatch(cob, parser, matcherSlot, ixSlot); + cob.goto_(loop); + } + + cob.labelBinding(defaultCase); + throwIse(cob, "bad property index"); + + cob.labelBinding(endObject); + cob.aload(beanSlot).areturn(); + + cob.labelBinding(unknown); + cob.aload(0).aload(parser).aload(ctxt); + cob.aload(beanSlot); + cob.invokevirtual(CD_BASE, "_handleUnknown", MTD_HANDLE_UNKNOWN); + nextNameMatch(cob, parser, matcherSlot, ixSlot); + cob.goto_(loop); + + cob.labelBinding(oddToken); + cob.aload(0).aload(parser).aload(ctxt) + .invokevirtual(CD_BASE, "_unexpectedToken", MTD_DESERIALIZE) + .areturn(); + + emitPropertyHandler(cob, propHandler, ctxt, propSlot, excSlot, beanSlot); + emitDelegateTail(cob, parser, ctxt, delegate); + + Label scopeEnd = cob.newBoundLabel(); + cob.localVariable(parser, "p", CD_JSON_PARSER, scopeStart, scopeEnd); + cob.localVariable(ctxt, "ctxt", CD_DESER_CONTEXT, scopeStart, scopeEnd); + cob.localVariable(beanSlot, "bean", beanDesc, scopeStart, scopeEnd); + cob.localVariable(matcherSlot, "matcher", CD_NAME_MATCHER, scopeStart, scopeEnd); + cob.localVariable(ixSlot, "ix", ConstantDescs.CD_int, scopeStart, scopeEnd); + cob.localVariable(propSlot, "prop", CD_SETTABLE_PROP, scopeStart, scopeEnd); + cob.localVariable(excSlot, "e", CD_EXCEPTION, scopeStart, scopeEnd); + if (views == ViewStrategy.MASK) { + cob.localVariable(maskSlot, "viewMask", ConstantDescs.CD_long, scopeStart, scopeEnd); + } + } + + private static final MethodTypeDesc MTD_PROP_DESERIALIZE = + MethodTypeDesc.of(ConstantDescs.CD_Object, CD_JSON_PARSER, CD_DESER_CONTEXT); + private static final ClassDesc CD_VALUE_INSTANTIATOR = + ClassDesc.of("tools.jackson.databind.deser.ValueInstantiator"); + private static final MethodTypeDesc MTD_CREATE_DEFAULT = + MethodTypeDesc.of(ConstantDescs.CD_Object, CD_DESER_CONTEXT); + private static final MethodTypeDesc MTD_DESER_SET_RETURN = MethodTypeDesc.of( + ConstantDescs.CD_Object, CD_JSON_PARSER, CD_DESER_CONTEXT, ConstantDescs.CD_Object); + private static final MethodTypeDesc MTD_BUILD_INVOKE = + MethodTypeDesc.of(ConstantDescs.CD_Object, ConstantDescs.CD_Object); + + private static void buildBuilderDeserialize(CodeBuilder cob, Class builderClass, + List props, String[] stockName, String[] childName, + ViewStrategy views, int[] aliasArms) { + final int parser = 1; + final int ctxt = 2; + final int builderSlot = 3; + final int matcherSlot = 4; + final int ixSlot = 5; + final int propSlot = 6; + final int excSlot = 7; + final int maskSlot = 8; + Label propHandler = cob.newLabel(); + + ClassDesc builderDesc = builderClass.describeConstable().orElseThrow(); + + Label scopeStart = cob.newBoundLabel(); + Label delegate = emitEntryGuard(cob, parser, ctxt, views); + if (views == ViewStrategy.MASK) { + emitViewMask(cob, ctxt, maskSlot); + } + + ldcData(cob, "instantiator", CD_VALUE_INSTANTIATOR); + cob.aload(ctxt); + cob.invokevirtual(CD_VALUE_INSTANTIATOR, "createUsingDefault", MTD_CREATE_DEFAULT); + cob.checkcast(builderDesc); + cob.astore(builderSlot); + + ldcData(cob, "matcher", CD_NAME_MATCHER); + cob.astore(matcherSlot); + + Label loop = cob.newLabel(); + Label switchPart = cob.newLabel(); + Label endObject = cob.newLabel(); + Label unknown = cob.newLabel(); + Label oddToken = cob.newLabel(); + Label defaultCase = cob.newLabel(); + + emitFirstMatch(cob, parser, matcherSlot, ixSlot); + + cob.labelBinding(loop); + cob.iload(ixSlot).ifge(switchPart); + cob.iload(ixSlot).iconst_m1().if_icmpeq(endObject); + cob.iload(ixSlot).ldc(-2).if_icmpeq(unknown); + cob.goto_(oddToken); + + cob.labelBinding(switchPart); + List cases = new ArrayList<>(props.size()); + Label[] caseLabels = new Label[props.size()]; + for (int i = 0; i < props.size(); i++) { + caseLabels[i] = cob.newLabel(); + cases.add(SwitchCase.of(i, caseLabels[i])); + } + // Alias matcher indexes follow the primaries and share their arms. + int totalNames = props.size() + ((aliasArms == null) ? 0 : aliasArms.length); + for (int k = props.size(); k < totalNames; k++) { + cases.add(SwitchCase.of(k, caseLabels[aliasArms[k - props.size()]])); + } + cob.iload(ixSlot); + cob.tableswitch(0, totalNames - 1, defaultCase, cases); + + for (int i = 0; i < props.size(); i++) { + cob.labelBinding(caseLabels[i]); + if (views == ViewStrategy.MASK) { + emitArmViewCheck(cob, parser, ctxt, maskSlot, matcherSlot, ixSlot, i, + stockName[i], loop); + } + Label armEnd = beginArm(cob, stockName[i], propSlot, propHandler); + GenProp prop = props.get(i); + switch (prop.kind()) { + case STRING -> emitBuilderScalar(cob, parser, ctxt, builderSlot, builderDesc, prop, + "getString", MTD_GET_STRING, ConstantDescs.CD_String, stockName[i], + "VALUE_STRING"); + case INT -> emitBuilderScalar(cob, parser, ctxt, builderSlot, builderDesc, prop, + "getIntValue", MTD_GET_INT, ConstantDescs.CD_int, stockName[i], + "VALUE_NUMBER_INT"); + case LONG -> emitBuilderScalar(cob, parser, ctxt, builderSlot, builderDesc, prop, + "getLongValue", MTD_GET_LONG, ConstantDescs.CD_long, stockName[i], + "VALUE_NUMBER_INT"); + case BOOLEAN -> emitBuilderScalar(cob, parser, ctxt, builderSlot, builderDesc, prop, + "getBooleanValue", MTD_GET_BOOLEAN, ConstantDescs.CD_boolean, stockName[i], + null); + case CHILD -> { + Label childStock = cob.newLabel(); + Label childDone = cob.newLabel(); + ClassDesc childType = prop.type().describeConstable().orElseThrow(); + cob.aload(parser).invokevirtual(CD_JSON_PARSER, "currentToken", MTD_NEXT_TOKEN); + cob.getstatic(CD_JSON_TOKEN, "START_OBJECT", CD_JSON_TOKEN); + cob.if_acmpne(childStock); + cob.aload(builderSlot); + emitChildCall(cob, parser, ctxt, childName[i]); + cob.checkcast(childType); + Class childRet = prop.setter().getReturnType(); + MethodTypeDesc childSetter = MethodTypeDesc.of( + childRet == void.class ? ConstantDescs.CD_void + : childRet.describeConstable().orElseThrow(), + childType); + cob.invokevirtual(builderDesc, prop.setter().getName(), childSetter); + if (childRet != void.class) { + cob.astore(builderSlot); + } + cob.goto_(childDone); + cob.labelBinding(childStock); + emitStockSetReturn(cob, parser, ctxt, builderSlot, builderDesc, stockName[i]); + cob.labelBinding(childDone); + } + case STOCK -> { + emitStockSetReturn(cob, parser, ctxt, builderSlot, builderDesc, stockName[i]); + } + } + cob.labelBinding(armEnd); + nextNameMatch(cob, parser, matcherSlot, ixSlot); + cob.goto_(loop); + } + + cob.labelBinding(defaultCase); + throwIse(cob, "bad property index"); + + cob.labelBinding(endObject); + ldcData(cob, "buildMethod", ConstantDescs.CD_MethodHandle); + cob.aload(builderSlot); + cob.invokevirtual(ConstantDescs.CD_MethodHandle, "invokeExact", MTD_BUILD_INVOKE); + cob.areturn(); + + cob.labelBinding(unknown); + cob.aload(0).aload(parser).aload(ctxt); + cob.aload(builderSlot); + cob.invokevirtual(CD_BASE, "_handleUnknown", MTD_HANDLE_UNKNOWN); + nextNameMatch(cob, parser, matcherSlot, ixSlot); + cob.goto_(loop); + + cob.labelBinding(oddToken); + cob.aload(0).aload(parser).aload(ctxt) + .invokevirtual(CD_BASE, "_unexpectedToken", MTD_DESERIALIZE) + .areturn(); + + emitPropertyHandler(cob, propHandler, ctxt, propSlot, excSlot, builderSlot); + emitDelegateTail(cob, parser, ctxt, delegate); + + Label scopeEnd = cob.newBoundLabel(); + cob.localVariable(parser, "p", CD_JSON_PARSER, scopeStart, scopeEnd); + cob.localVariable(ctxt, "ctxt", CD_DESER_CONTEXT, scopeStart, scopeEnd); + cob.localVariable(builderSlot, "builder", builderDesc, scopeStart, scopeEnd); + cob.localVariable(matcherSlot, "matcher", CD_NAME_MATCHER, scopeStart, scopeEnd); + cob.localVariable(ixSlot, "ix", ConstantDescs.CD_int, scopeStart, scopeEnd); + cob.localVariable(propSlot, "prop", CD_SETTABLE_PROP, scopeStart, scopeEnd); + cob.localVariable(excSlot, "e", CD_EXCEPTION, scopeStart, scopeEnd); + if (views == ViewStrategy.MASK) { + cob.localVariable(maskSlot, "viewMask", ConstantDescs.CD_long, scopeStart, scopeEnd); + } + } + + private static void emitBuilderScalar(CodeBuilder cob, int parser, int ctxt, int builderSlot, + ClassDesc builderDesc, GenProp prop, String getter, MethodTypeDesc getterType, + ClassDesc valueDesc, String stockName, String expectedToken) { + Label useStock = cob.newLabel(); + Label done = cob.newLabel(); + emitExpectedTokenCheck(cob, parser, expectedToken, useStock); + cob.aload(builderSlot); + cob.aload(parser).invokevirtual(CD_JSON_PARSER, getter, getterType); + Class ret = prop.setter().getReturnType(); + MethodTypeDesc setterType = MethodTypeDesc.of( + ret == void.class ? ConstantDescs.CD_void : ret.describeConstable().orElseThrow(), + valueDesc); + cob.invokevirtual(builderDesc, prop.setter().getName(), setterType); + if (ret != void.class) { + cob.astore(builderSlot); + } + cob.goto_(done); + cob.labelBinding(useStock); + emitStockSetReturn(cob, parser, ctxt, builderSlot, builderDesc, stockName); + cob.labelBinding(done); + } + + // Builder properties apply through deserializeSetAndReturn: fluent + // builders may return a replacement instance, which becomes the new + // builder local. + private static void emitStockSetReturn(CodeBuilder cob, int parser, int ctxt, int builderSlot, + ClassDesc builderDesc, String stockName) { + ldcData(cob, stockName, CD_SETTABLE_PROP); + cob.aload(parser).aload(ctxt).aload(builderSlot); + cob.invokevirtual(CD_SETTABLE_PROP, "deserializeSetAndReturn", MTD_DESER_SET_RETURN); + cob.checkcast(builderDesc); + cob.astore(builderSlot); + } + + private static void buildRecordDeserialize(CodeBuilder cob, Class beanClass, + List props, String[] stockName, String[] childName, + ViewStrategy views, int[] aliasArms) { + final int parser = 1; + final int ctxt = 2; + + int next = 3; + int[] componentSlot = new int[props.size()]; + for (int i = 0; i < props.size(); i++) { + componentSlot[i] = next; + Class t = props.get(i).type(); + next += (t == long.class || t == double.class) ? 2 : 1; + } + final int matcherSlot = next++; + final int ixSlot = next++; + final int seenSlot = next; + final int propSlot = next + 2; + final int excSlot = next + 3; + final int maskSlot = next + 4; + final int nullMaskSlot = next + 6; + Label propHandler = cob.newLabel(); + + ClassDesc recordDesc = beanClass.describeConstable().orElseThrow(); + + Label scopeStart = cob.newBoundLabel(); + Label delegate = emitEntryGuard(cob, parser, ctxt, views); + if (views == ViewStrategy.MASK) { + emitViewMask(cob, ctxt, maskSlot); + } + + for (int i = 0; i < props.size(); i++) { + Class t = props.get(i).type(); + if (t == long.class) { + cob.lconst_0().lstore(componentSlot[i]); + } else if (t == double.class) { + cob.dconst_0().dstore(componentSlot[i]); + } else if (t == float.class) { + cob.fconst_0().fstore(componentSlot[i]); + } else if (t.isPrimitive()) { + cob.iconst_0().istore(componentSlot[i]); + } else { + cob.aconst_null().astore(componentSlot[i]); + } + } + cob.lconst_0().lstore(seenSlot); + + ldcData(cob, "matcher", CD_NAME_MATCHER); + cob.astore(matcherSlot); + + Label loop = cob.newLabel(); + Label switchPart = cob.newLabel(); + Label endObject = cob.newLabel(); + Label unknown = cob.newLabel(); + Label oddToken = cob.newLabel(); + Label defaultCase = cob.newLabel(); + + emitFirstMatch(cob, parser, matcherSlot, ixSlot); + + cob.labelBinding(loop); + cob.iload(ixSlot).ifge(switchPart); + cob.iload(ixSlot).iconst_m1().if_icmpeq(endObject); + cob.iload(ixSlot).ldc(-2).if_icmpeq(unknown); + cob.goto_(oddToken); + + cob.labelBinding(switchPart); + List cases = new ArrayList<>(props.size()); + Label[] caseLabels = new Label[props.size()]; + for (int i = 0; i < props.size(); i++) { + caseLabels[i] = cob.newLabel(); + cases.add(SwitchCase.of(i, caseLabels[i])); + } + // Alias matcher indexes follow the primaries and share their arms. + int totalNames = props.size() + ((aliasArms == null) ? 0 : aliasArms.length); + for (int k = props.size(); k < totalNames; k++) { + cases.add(SwitchCase.of(k, caseLabels[aliasArms[k - props.size()]])); + } + cob.iload(ixSlot); + cob.tableswitch(0, totalNames - 1, defaultCase, cases); + + for (int i = 0; i < props.size(); i++) { + cob.labelBinding(caseLabels[i]); + if (views == ViewStrategy.MASK) { + // A hidden component stays unseen, so required and + // missing-property reporting treat it exactly like an absent + // property, matching the stock creator path. + emitArmViewCheck(cob, parser, ctxt, maskSlot, matcherSlot, ixSlot, i, + stockName[i], loop); + } + Label armEnd = beginArm(cob, stockName[i], propSlot, propHandler); + GenProp prop = props.get(i); + Class t = prop.type(); + switch (prop.kind()) { + case STRING -> emitRecordScalar(cob, parser, ctxt, componentSlot[i], t, + "getString", MTD_GET_STRING, stockName[i], "VALUE_STRING"); + case INT -> emitRecordScalar(cob, parser, ctxt, componentSlot[i], t, + "getIntValue", MTD_GET_INT, stockName[i], "VALUE_NUMBER_INT"); + case LONG -> emitRecordScalar(cob, parser, ctxt, componentSlot[i], t, + "getLongValue", MTD_GET_LONG, stockName[i], "VALUE_NUMBER_INT"); + case BOOLEAN -> emitRecordScalar(cob, parser, ctxt, componentSlot[i], t, + "getBooleanValue", MTD_GET_BOOLEAN, stockName[i], null); + case CHILD -> { + Label childStock = cob.newLabel(); + Label childDone = cob.newLabel(); + cob.aload(parser).invokevirtual(CD_JSON_PARSER, "currentToken", MTD_NEXT_TOKEN); + cob.getstatic(CD_JSON_TOKEN, "START_OBJECT", CD_JSON_TOKEN); + cob.if_acmpne(childStock); + emitChildCall(cob, parser, ctxt, childName[i]); + cob.checkcast(t.describeConstable().orElseThrow()); + storeLocal(cob, t, componentSlot[i]); + cob.goto_(childDone); + cob.labelBinding(childStock); + emitStockValueToLocal(cob, parser, ctxt, componentSlot[i], t, stockName[i]); + cob.labelBinding(childDone); + } + case STOCK -> { + emitStockValueToLocal(cob, parser, ctxt, componentSlot[i], t, stockName[i]); + } + } + cob.labelBinding(armEnd); + cob.lload(seenSlot); + cob.loadConstant(1L << i); + cob.lor(); + cob.lstore(seenSlot); + nextNameMatch(cob, parser, matcherSlot, ixSlot); + cob.goto_(loop); + } + + cob.labelBinding(defaultCase); + throwIse(cob, "bad property index"); + + cob.labelBinding(endObject); + // Missing components: the cold helper mirrors PropertyValueBuffer's + // required / FAIL_ON_MISSING_CREATOR_PROPERTIES reporting. + long allSeen = (props.size() == 64) ? -1L : (1L << props.size()) - 1; + Label allPresent = cob.newLabel(); + cob.lload(seenSlot); + cob.loadConstant(allSeen); + cob.lcmp(); + cob.ifeq(allPresent); + cob.aload(0).aload(ctxt).lload(seenSlot); + cob.loadConstant(props.size()); + cob.invokevirtual(CD_BASE, "_checkRecordSeen", MTD_CHECK_SEEN); + cob.labelBinding(allPresent); + // Reference components that ended null (missing or explicit) feed the + // cold FAIL_ON_NULL_CREATOR_PROPERTIES check, mirroring + // PropertyValueBuffer; primitives never do, matching stock defaults. + cob.lconst_0().lstore(nullMaskSlot); + for (int i = 0; i < props.size(); i++) { + if (props.get(i).type().isPrimitive()) { + continue; + } + Label nonNull = cob.newLabel(); + cob.aload(componentSlot[i]); + cob.ifnonnull(nonNull); + cob.lload(nullMaskSlot); + cob.loadConstant(1L << i); + cob.lor(); + cob.lstore(nullMaskSlot); + cob.labelBinding(nonNull); + } + Label noNulls = cob.newLabel(); + cob.lload(nullMaskSlot); + cob.lconst_0(); + cob.lcmp(); + cob.ifeq(noNulls); + cob.aload(0).aload(ctxt).lload(nullMaskSlot); + cob.loadConstant(props.size()); + cob.invokevirtual(CD_BASE, "_checkRecordNulls", MTD_CHECK_SEEN); + cob.labelBinding(noNulls); + ldcData(cob, "constructor", ConstantDescs.CD_MethodHandle); + ClassDesc[] paramDescs = new ClassDesc[props.size()]; + for (int i = 0; i < props.size(); i++) { + Class t = props.get(i).type(); + paramDescs[i] = t.describeConstable().orElseThrow(); + if (t == long.class) { + cob.lload(componentSlot[i]); + } else if (t == double.class) { + cob.dload(componentSlot[i]); + } else if (t == float.class) { + cob.fload(componentSlot[i]); + } else if (t.isPrimitive()) { + cob.iload(componentSlot[i]); + } else { + cob.aload(componentSlot[i]); + } + } + cob.invokevirtual(ConstantDescs.CD_MethodHandle, "invokeExact", + MethodTypeDesc.of(recordDesc, paramDescs)); + cob.areturn(); + + cob.labelBinding(unknown); + cob.aload(0).aload(parser).aload(ctxt); + cob.aconst_null(); + cob.invokevirtual(CD_BASE, "_handleUnknown", MTD_HANDLE_UNKNOWN); + nextNameMatch(cob, parser, matcherSlot, ixSlot); + cob.goto_(loop); + + cob.labelBinding(oddToken); + cob.aload(0).aload(parser).aload(ctxt) + .invokevirtual(CD_BASE, "_unexpectedToken", MTD_DESERIALIZE) + .areturn(); + + emitPropertyHandler(cob, propHandler, ctxt, propSlot, excSlot, -1); + emitDelegateTail(cob, parser, ctxt, delegate); + + Label scopeEnd = cob.newBoundLabel(); + cob.localVariable(parser, "p", CD_JSON_PARSER, scopeStart, scopeEnd); + cob.localVariable(ctxt, "ctxt", CD_DESER_CONTEXT, scopeStart, scopeEnd); + for (int i = 0; i < props.size(); i++) { + cob.localVariable(componentSlot[i], props.get(i).name(), + props.get(i).type().describeConstable().orElseThrow(), + scopeStart, scopeEnd); + } + cob.localVariable(matcherSlot, "matcher", CD_NAME_MATCHER, scopeStart, scopeEnd); + cob.localVariable(ixSlot, "ix", ConstantDescs.CD_int, scopeStart, scopeEnd); + cob.localVariable(seenSlot, "seen", ConstantDescs.CD_long, scopeStart, scopeEnd); + cob.localVariable(propSlot, "prop", CD_SETTABLE_PROP, scopeStart, scopeEnd); + cob.localVariable(excSlot, "e", CD_EXCEPTION, scopeStart, scopeEnd); + if (views == ViewStrategy.MASK) { + cob.localVariable(maskSlot, "viewMask", ConstantDescs.CD_long, scopeStart, scopeEnd); + } + } + + private static void emitRecordScalar(CodeBuilder cob, int parser, int ctxt, int slot, + Class type, String getter, MethodTypeDesc getterType, String stockName, + String expectedToken) { + Label useStock = cob.newLabel(); + Label done = cob.newLabel(); + emitExpectedTokenCheck(cob, parser, expectedToken, useStock); + cob.aload(parser).invokevirtual(CD_JSON_PARSER, getter, getterType); + storeLocal(cob, type, slot); + cob.goto_(done); + cob.labelBinding(useStock); + emitStockValueToLocal(cob, parser, ctxt, slot, type, stockName); + cob.labelBinding(done); + } + + // Reads the whole value through the stock property (parser positioned on + // the value token), then converts the boxed result to the component type. + private static void emitStockValueToLocal(CodeBuilder cob, int parser, int ctxt, int slot, + Class type, String stockName) { + ldcData(cob, stockName, CD_SETTABLE_PROP); + cob.aload(parser).aload(ctxt); + cob.invokevirtual(CD_SETTABLE_PROP, "deserialize", MTD_PROP_DESERIALIZE); + if (type.isPrimitive()) { + Class box = ClassUtil.wrapperType(type); + ClassDesc boxDesc = box.describeConstable().orElseThrow(); + cob.checkcast(boxDesc); + cob.invokevirtual(boxDesc, type.getName() + "Value", + MethodTypeDesc.of(type.describeConstable().orElseThrow())); + } else { + cob.checkcast(type.describeConstable().orElseThrow()); + } + storeLocal(cob, type, slot); + } + + private static void storeLocal(CodeBuilder cob, Class type, int slot) { + if (type == long.class) { + cob.lstore(slot); + } else if (type == double.class) { + cob.dstore(slot); + } else if (type == float.class) { + cob.fstore(slot); + } else if (type.isPrimitive()) { + cob.istore(slot); + } else { + cob.astore(slot); + } + } + + // The null branch enters deserializeAndSet with the parser already on the + // VALUE_NULL token, which is the position that method expects. A + // non-public setter arrives as a classData MethodHandle instead of a + // direct call. + private static void emitScalar(CodeBuilder cob, int beanSlot, int parser, int ctxt, + ClassDesc beanDesc, GenProp prop, String getter, MethodTypeDesc getterType, + ClassDesc valueDesc, String stockName, String setterName, String expectedToken) { + Label useStock = cob.newLabel(); + Label done = cob.newLabel(); + emitExpectedTokenCheck(cob, parser, expectedToken, useStock); + if (setterName != null) { + ldcData(cob, setterName, ConstantDescs.CD_MethodHandle); + cob.aload(beanSlot); + cob.aload(parser).invokevirtual(CD_JSON_PARSER, getter, getterType); + cob.invokevirtual(ConstantDescs.CD_MethodHandle, "invokeExact", + MethodTypeDesc.of(ConstantDescs.CD_void, beanDesc, valueDesc)); + } else { + cob.aload(beanSlot); + cob.aload(parser).invokevirtual(CD_JSON_PARSER, getter, getterType); + emitStore(cob, beanDesc, prop, valueDesc); + } + cob.goto_(done); + cob.labelBinding(useStock); + emitStockSet(cob, parser, ctxt, beanSlot, stockName); + cob.labelBinding(done); + } + + // Stores a value already on the stack (receiver, then value) into the bean: + // a putfield for a public field, otherwise the direct setter call. + private static void emitStore(CodeBuilder cob, ClassDesc beanDesc, GenProp prop, + ClassDesc valueDesc) { + if (prop.field() != null) { + ClassDesc owner = prop.field().getDeclaringClass().describeConstable().orElseThrow(); + cob.putfield(owner, prop.field().getName(), valueDesc); + } else { + invokeSetter(cob, beanDesc, prop, valueDesc); + } + } + + private static void emitChildCall(CodeBuilder cob, int parser, int ctxt, String childName) { + ldcData(cob, childName, CD_BASE); + cob.aload(parser).aload(ctxt); + cob.invokevirtual(CD_BASE, "deserialize", MTD_DESERIALIZE); + } + + private static void emitStockSet(CodeBuilder cob, int parser, int ctxt, int beanSlot, + String stockName) { + ldcData(cob, stockName, CD_SETTABLE_PROP); + cob.aload(parser).aload(ctxt).aload(beanSlot); + cob.invokevirtual(CD_SETTABLE_PROP, "deserializeAndSet", MTD_DESERIALIZE_AND_SET); + } + + private static void invokeSetter(CodeBuilder cob, ClassDesc beanDesc, GenProp prop, ClassDesc valueDesc) { + MethodTypeDesc setter = prop.setter().getReturnType() == void.class + ? MethodTypeDesc.of(ConstantDescs.CD_void, valueDesc) + : MethodTypeDesc.of(prop.setter().getReturnType().describeConstable().orElseThrow(), valueDesc); + cob.invokevirtual(beanDesc, prop.setter().getName(), setter); + if (prop.setter().getReturnType() != void.class) { + cob.pop(); + } + } + + // Guards the generated loop's entry: anything the loop does not model - a + // stream positioned on neither START_OBJECT nor PROPERTY_NAME (the + // AsProperty polymorphic path hands subtype deserializers a stream on the + // property after the type id) and, under the DELEGATE strategy only, an + // active view - branches to the returned label, whose stock-delegation + // tail the caller emits at the end of the method so the main body + // decompiles un-nested. The MASK strategy keeps view-active calls on the + // generated path through the visibility bitmask. + private static Label emitEntryGuard(CodeBuilder cob, int parser, int ctxt, + ViewStrategy views) { + Label delegate = cob.newLabel(); + Label entryOk = cob.newLabel(); + cob.aload(parser).invokevirtual(CD_JSON_PARSER, "currentToken", MTD_NEXT_TOKEN); + cob.getstatic(CD_JSON_TOKEN, "START_OBJECT", CD_JSON_TOKEN); + cob.if_acmpeq(entryOk); + cob.aload(parser).invokevirtual(CD_JSON_PARSER, "currentToken", MTD_NEXT_TOKEN); + cob.getstatic(CD_JSON_TOKEN, "PROPERTY_NAME", CD_JSON_TOKEN); + cob.if_acmpne(delegate); + cob.labelBinding(entryOk); + if (views == ViewStrategy.DELEGATE) { + cob.aload(ctxt).invokevirtual(CD_DESER_CONTEXT, "getActiveView", + MTD_GET_ACTIVE_VIEW); + cob.ifnonnull(delegate); + } + return delegate; + } + + private static void emitDelegateTail(CodeBuilder cob, int parser, int ctxt, Label delegate) { + cob.labelBinding(delegate); + cob.aload(0).getfield(CD_BASE, "_fallback", CD_BEAN_DESER_BASE); + cob.aload(parser).aload(ctxt); + cob.invokevirtual(CD_BEAN_DESER_BASE, "deserialize", MTD_DESERIALIZE); + cob.areturn(); + } + + // Advances to the value token and branches to useStock unless it is the + // token the inline read expects. Null, quoted scalars, and mismatched + // shapes all take the stock property, which owns coercion and null + // handling. expectedToken null selects the boolean pair. + private static void emitExpectedTokenCheck(CodeBuilder cob, int parser, + String expectedToken, Label useStock) { + cob.aload(parser).invokevirtual(CD_JSON_PARSER, "currentToken", MTD_NEXT_TOKEN); + if (expectedToken != null) { + cob.getstatic(CD_JSON_TOKEN, expectedToken, CD_JSON_TOKEN); + cob.if_acmpne(useStock); + } else { + Label isTrue = cob.newLabel(); + Label fast = cob.newLabel(); + cob.dup(); + cob.getstatic(CD_JSON_TOKEN, "VALUE_TRUE", CD_JSON_TOKEN); + cob.if_acmpeq(isTrue); + cob.getstatic(CD_JSON_TOKEN, "VALUE_FALSE", CD_JSON_TOKEN); + cob.if_acmpne(useStock); + cob.goto_(fast); + cob.labelBinding(isTrue); + cob.pop(); + cob.labelBinding(fast); + } + } + + // Loads the arm's stock property into propSlot and opens its exception + // region: any Exception from a property arm is rethrown with the property + // reference prepended, like the stock loop's wrapAndThrow. + private static Label beginArm(CodeBuilder cob, String stockName, int propSlot, Label handler) { + ldcData(cob, stockName, CD_SETTABLE_PROP); + cob.astore(propSlot); + Label armStart = cob.newLabel(); + Label armEnd = cob.newLabel(); + cob.exceptionCatch(armStart, armEnd, handler, CD_EXCEPTION); + cob.labelBinding(armStart); + return armEnd; + } + + // receiverSlot < 0 selects aconst_null (record mode: no instance yet). + private static void emitPropertyHandler(CodeBuilder cob, Label handler, int ctxt, + int propSlot, int excSlot, int receiverSlot) { + cob.labelBinding(handler); + cob.astore(excSlot); + cob.aload(0); + cob.aload(excSlot); + if (receiverSlot < 0) { + cob.aconst_null(); + } else { + cob.aload(receiverSlot); + } + cob.aload(propSlot); + cob.aload(ctxt); + cob.invokevirtual(CD_BASE, "_propertyException", MTD_PROP_WRAP); + cob.athrow(); + } + + private static void nextNameMatch(CodeBuilder cob, int parser, int matcherSlot, int ixSlot) { + cob.aload(parser).aload(matcherSlot) + .invokevirtual(CD_JSON_PARSER, "nextNameMatchAndToken", MTD_NEXT_NAME_MATCH) + .istore(ixSlot); + } + + // First match of the loop. The entry guard admits START_OBJECT and + // PROPERTY_NAME; a name entry matches the CURRENT name, mirroring stock + // BeanDeserializer's currentNameMatch loop head. The fused arms consume + // the current token, so a matched name entry advances to its value here; + // fusing the entry itself would double-advance (the current name is + // already consumed). Negative results stay on the name, as the unknown + // arm expects. + private static void emitFirstMatch(CodeBuilder cob, int parser, int matcherSlot, int ixSlot) { + Label nameEntry = cob.newLabel(); + Label done = cob.newLabel(); + cob.aload(parser).invokevirtual(CD_JSON_PARSER, "currentToken", MTD_NEXT_TOKEN); + cob.getstatic(CD_JSON_TOKEN, "PROPERTY_NAME", CD_JSON_TOKEN); + cob.if_acmpeq(nameEntry); + nextNameMatch(cob, parser, matcherSlot, ixSlot); + cob.goto_(done); + cob.labelBinding(nameEntry); + cob.aload(parser).aload(matcherSlot) + .invokevirtual(CD_JSON_PARSER, "currentNameMatch", MTD_NEXT_NAME_MATCH) + .istore(ixSlot); + cob.iload(ixSlot).iflt(done); + cob.aload(parser).invokevirtual(CD_JSON_PARSER, "nextToken", MTD_NEXT_TOKEN).pop(); + cob.labelBinding(done); + } + + private static void throwIse(CodeBuilder cob, String message) { + cob.new_(CD_ISE).dup().ldc(message) + .invokespecial(CD_ISE, ConstantDescs.INIT_NAME, + MethodTypeDesc.of(ConstantDescs.CD_void, ConstantDescs.CD_String)) + .athrow(); + } +} diff --git a/blackbird/src/main/java/tools/jackson/module/blackbird/codegen/BeanWriterGenerator.java b/blackbird/src/main/java/tools/jackson/module/blackbird/codegen/BeanWriterGenerator.java new file mode 100644 index 000000000..faf147514 --- /dev/null +++ b/blackbird/src/main/java/tools/jackson/module/blackbird/codegen/BeanWriterGenerator.java @@ -0,0 +1,452 @@ +package tools.jackson.module.blackbird.codegen; + +import java.lang.classfile.ClassFile; +import java.lang.classfile.CodeBuilder; +import java.lang.classfile.Label; +import java.lang.classfile.attribute.MethodParametersAttribute; +import java.lang.constant.ClassDesc; +import java.lang.constant.ConstantDescs; +import java.lang.constant.DynamicConstantDesc; +import java.lang.constant.MethodTypeDesc; +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import tools.jackson.core.SerializableString; +import tools.jackson.databind.ValueSerializer; +import tools.jackson.databind.ser.PropertyWriter; +import tools.jackson.databind.ser.bean.BeanSerializerBase; +import tools.jackson.module.blackbird.codegen.BeanReaderGenerator.ViewStrategy; +import tools.jackson.module.blackbird.internal.GeneratedWriterBase; + +/** + * Emits a hidden-class serializer for one bean: a straight-line sequence of + * writeName/write-value pairs with pre-encoded name constants for eligible + * properties and the stock PropertyWriter (a named classData constant) for + * everything else. Beans that declare views resolve a per-view visibility + * bitmask and each property tests its bit, so view-active writes stay on the + * generated path; beans with more than 64 properties delegate view-active + * calls to the stock serializer instead. + */ +public final class BeanWriterGenerator +{ + public enum WKind { STRING, INT, LONG, BOOLEAN, CHILD, STOCK } + + // getter carries a public getter; field carries a public field read + // through getfield instead. Exactly one of the two is set for a generated + // (non-STOCK) property. + public record GenWProp(WKind kind, Method getter, PropertyWriter stock, + SerializableString name, GeneratedWriterBase child, Field field) { + public GenWProp(WKind kind, Method getter, PropertyWriter stock, + SerializableString name, GeneratedWriterBase child) { + this(kind, getter, stock, name, child, null); + } + } + + private static final ClassDesc CD_JSON_GENERATOR = ClassDesc.of("tools.jackson.core.JsonGenerator"); + private static final ClassDesc CD_SER_CONTEXT = ClassDesc.of("tools.jackson.databind.SerializationContext"); + private static final ClassDesc CD_SERIALIZABLE_STRING = ClassDesc.of("tools.jackson.core.SerializableString"); + private static final ClassDesc CD_PROPERTY_WRITER = ClassDesc.of("tools.jackson.databind.ser.PropertyWriter"); + private static final ClassDesc CD_WRITER_BASE = + GeneratedWriterBase.class.describeConstable().orElseThrow(); + private static final ClassDesc CD_BEAN_SER_BASE = ClassDesc.of("tools.jackson.databind.ser.bean.BeanSerializerBase"); + + private static final MethodTypeDesc MTD_SERIALIZE = MethodTypeDesc.of(ConstantDescs.CD_void, + ConstantDescs.CD_Object, CD_JSON_GENERATOR, CD_SER_CONTEXT); + private static final MethodTypeDesc MTD_WRITE_NAME = + MethodTypeDesc.of(CD_JSON_GENERATOR, CD_SERIALIZABLE_STRING); + private static final MethodTypeDesc MTD_WRITE_STRING = + MethodTypeDesc.of(CD_JSON_GENERATOR, ConstantDescs.CD_String); + private static final MethodTypeDesc MTD_WRITE_INT = + MethodTypeDesc.of(CD_JSON_GENERATOR, ConstantDescs.CD_int); + private static final MethodTypeDesc MTD_WRITE_LONG = + MethodTypeDesc.of(CD_JSON_GENERATOR, ConstantDescs.CD_long); + private static final MethodTypeDesc MTD_WRITE_BOOLEAN = + MethodTypeDesc.of(CD_JSON_GENERATOR, ConstantDescs.CD_boolean); + private static final MethodTypeDesc MTD_WRITE_NULL = MethodTypeDesc.of(CD_JSON_GENERATOR); + private static final MethodTypeDesc MTD_WRITE_START_OBJECT = + MethodTypeDesc.of(CD_JSON_GENERATOR, ConstantDescs.CD_Object); + private static final MethodTypeDesc MTD_WRITE_END = MethodTypeDesc.of(CD_JSON_GENERATOR); + private static final MethodTypeDesc MTD_SERIALIZE_AS_PROPERTY = MethodTypeDesc.of( + ConstantDescs.CD_void, ConstantDescs.CD_Object, CD_JSON_GENERATOR, CD_SER_CONTEXT); + private static final MethodTypeDesc MTD_CTOR = + MethodTypeDesc.of(ConstantDescs.CD_void, CD_BEAN_SER_BASE); + private static final MethodTypeDesc MTD_GET_ACTIVE_VIEW = + MethodTypeDesc.of(ConstantDescs.CD_Class); + private static final MethodTypeDesc MTD_VIEW_MASK = + MethodTypeDesc.of(ConstantDescs.CD_long, ConstantDescs.CD_Class); + private static final MethodTypeDesc MTD_PROP_VISIBLE = MethodTypeDesc.of( + ConstantDescs.CD_boolean, CD_PROPERTY_WRITER, ConstantDescs.CD_Class, + ConstantDescs.CD_boolean); + + // Named class-data entries through the writer base's classDataEntry + // bootstrap; rationale in BeanReaderGenerator. + private static final java.lang.constant.DirectMethodHandleDesc BSM_DATA_ENTRY = + ConstantDescs.ofConstantBootstrap(CD_WRITER_BASE, "classDataEntry", + ConstantDescs.CD_Object); + + private BeanWriterGenerator() {} + + private static void ldcData(CodeBuilder cob, String name, ClassDesc type) { + cob.ldc(DynamicConstantDesc.ofNamed(BSM_DATA_ENTRY, name, type)); + } + + @SuppressWarnings("unchecked") + public static ValueSerializer generate(Class beanClass, List props, + BeanSerializerBase fallback, MethodHandles.Lookup defineLookup, + ViewStrategy views, boolean includeByDefault) + throws ReflectiveOperationException { + Map classData = new LinkedHashMap<>(); + String[] stockName = new String[props.size()]; + String[] nameName = new String[props.size()]; + String[] childName = new String[props.size()]; + for (int i = 0; i < props.size(); i++) { + GenWProp p = props.get(i); + String base = p.stock().getName(); + stockName[i] = BeanReaderGenerator.dataName(classData, base + "Writer"); + classData.put(stockName[i], p.stock()); + if (p.name() != null) { + nameName[i] = BeanReaderGenerator.dataName(classData, base + "Name"); + classData.put(nameName[i], p.name()); + } + if (p.child() != null) { + childName[i] = BeanReaderGenerator.dataName(classData, base + "Codec"); + classData.put(childName[i], p.child()); + } + } + + // Rationale in BeanReaderGenerator: non-public beans define in the + // bean's package context; a define failure there is an environment + // gate (possible only when the bean's module does not read blackbird). + MethodHandles.Lookup definer = + (defineLookup != null) ? defineLookup : MethodHandles.lookup(); + byte[] bytes = buildClass(definer.lookupClass().getPackageName(), beanClass, props, + stockName, nameName, childName, views, includeByDefault); + CodegenDump.dump(beanClass, "writer", bytes); + MethodHandles.Lookup hidden; + try { + hidden = definer.defineHiddenClassWithClassData( + bytes, Map.copyOf(classData), true); + } catch (IllegalAccessException | SecurityException | LinkageError e) { + if (defineLookup == null) { + if (e instanceof IllegalAccessException iae) { + throw iae; + } + if (e instanceof RuntimeException re) { + throw re; + } + throw (LinkageError) e; + } + return null; + } + MethodHandle ctor = hidden.findConstructor(hidden.lookupClass(), + MethodType.methodType(void.class, BeanSerializerBase.class)); + try { + return (ValueSerializer) ctor.invoke(fallback); + } catch (Throwable t) { + throw new IllegalStateException("cannot instantiate generated writer", t); + } + } + + // C2 refuses to inline a hot method whose bytecode exceeds FreqInlineSize + // (default 325). The scalar helpers are padded past that limit so each + // compiles standalone: on aarch64, inlined copies of writeName and the + // NumberOutput digit paths inside a large generated body run 2-3x slower + // than their standalone compilations, which costs number-heavy shapes ~24%. + // Out-of-line helper calls measure at parity with the best inlined layout. + private static final int INLINE_PAD = 384; + + private static final MethodTypeDesc MTD_HELP_STRING = MethodTypeDesc.of(ConstantDescs.CD_void, + CD_JSON_GENERATOR, CD_SERIALIZABLE_STRING, ConstantDescs.CD_String); + private static final MethodTypeDesc MTD_HELP_INT = MethodTypeDesc.of(ConstantDescs.CD_void, + CD_JSON_GENERATOR, CD_SERIALIZABLE_STRING, ConstantDescs.CD_int); + private static final MethodTypeDesc MTD_HELP_LONG = MethodTypeDesc.of(ConstantDescs.CD_void, + CD_JSON_GENERATOR, CD_SERIALIZABLE_STRING, ConstantDescs.CD_long); + private static final MethodTypeDesc MTD_HELP_BOOLEAN = MethodTypeDesc.of(ConstantDescs.CD_void, + CD_JSON_GENERATOR, CD_SERIALIZABLE_STRING, ConstantDescs.CD_boolean); + private static final MethodTypeDesc MTD_HELP_NAME = MethodTypeDesc.of(ConstantDescs.CD_void, + CD_JSON_GENERATOR, CD_SERIALIZABLE_STRING); + + private static byte[] buildClass(String targetPackage, Class beanClass, + List props, + String[] stockName, String[] nameName, String[] childName, + ViewStrategy views, boolean includeByDefault) { + // A hidden class must be named in its define context's package. + ClassDesc thisClass = ClassDesc.of( + targetPackage + ".BBWriter_" + beanClass.getSimpleName()); + return ClassFile.of().build(thisClass, clb -> { + clb.withFlags(ClassFile.ACC_PUBLIC | ClassFile.ACC_FINAL | ClassFile.ACC_SUPER); + clb.withSuperclass(CD_WRITER_BASE); + clb.withMethod(ConstantDescs.INIT_NAME, MTD_CTOR, ClassFile.ACC_PUBLIC, + mb -> mb.with(BeanReaderGenerator.params("fallback")) + .withCode(cob -> cob.aload(0).aload(1) + .invokespecial(CD_WRITER_BASE, ConstantDescs.INIT_NAME, MTD_CTOR) + .return_())); + clb.withMethod("serialize", MTD_SERIALIZE, ClassFile.ACC_PUBLIC, + mb -> mb.with(BeanReaderGenerator.params("value", "g", "ctxt")) + .withCode(cob -> buildSerialize(cob, thisClass, beanClass, props, + stockName, nameName, childName, views))); + emitHelpers(clb, thisClass, props); + if (views == ViewStrategy.MASK) { + emitComputeViewMask(clb, props, stockName, includeByDefault); + } + }); + } + + private static void emitHelpers(java.lang.classfile.ClassBuilder clb, ClassDesc thisClass, + List props) { + boolean needName = false; + boolean[] kinds = new boolean[WKind.values().length]; + for (GenWProp p : props) { + kinds[p.kind().ordinal()] = true; + if (p.kind() == WKind.CHILD) { + needName = true; + } + } + if (kinds[WKind.STRING.ordinal()]) { + emitPadded(clb, "$str", MTD_HELP_STRING, cob -> { + cob.aload(0).aload(1) + .invokevirtual(CD_JSON_GENERATOR, "writeName", MTD_WRITE_NAME).pop(); + Label isNull = cob.newLabel(); + Label done = cob.newLabel(); + cob.aload(2).ifnull(isNull); + cob.aload(0).aload(2) + .invokevirtual(CD_JSON_GENERATOR, "writeString", MTD_WRITE_STRING).pop(); + cob.goto_(done); + cob.labelBinding(isNull); + cob.aload(0).invokevirtual(CD_JSON_GENERATOR, "writeNull", MTD_WRITE_NULL).pop(); + cob.labelBinding(done); + cob.return_(); + }); + } + if (kinds[WKind.INT.ordinal()]) { + emitPadded(clb, "$int", MTD_HELP_INT, cob -> cob.aload(0).aload(1) + .invokevirtual(CD_JSON_GENERATOR, "writeName", MTD_WRITE_NAME).pop() + .aload(0).iload(2) + .invokevirtual(CD_JSON_GENERATOR, "writeNumber", MTD_WRITE_INT).pop() + .return_()); + } + if (kinds[WKind.LONG.ordinal()]) { + emitPadded(clb, "$long", MTD_HELP_LONG, cob -> cob.aload(0).aload(1) + .invokevirtual(CD_JSON_GENERATOR, "writeName", MTD_WRITE_NAME).pop() + .aload(0).lload(2) + .invokevirtual(CD_JSON_GENERATOR, "writeNumber", MTD_WRITE_LONG).pop() + .return_()); + } + if (kinds[WKind.BOOLEAN.ordinal()]) { + emitPadded(clb, "$bool", MTD_HELP_BOOLEAN, cob -> cob.aload(0).aload(1) + .invokevirtual(CD_JSON_GENERATOR, "writeName", MTD_WRITE_NAME).pop() + .aload(0).iload(2) + .invokevirtual(CD_JSON_GENERATOR, "writeBoolean", MTD_WRITE_BOOLEAN).pop() + .return_()); + } + if (needName) { + emitPadded(clb, "$name", MTD_HELP_NAME, cob -> cob.aload(0).aload(1) + .invokevirtual(CD_JSON_GENERATOR, "writeName", MTD_WRITE_NAME).pop() + .return_()); + } + } + + private static void emitPadded(java.lang.classfile.ClassBuilder clb, String name, + MethodTypeDesc type, java.util.function.Consumer body) { + MethodParametersAttribute helperParams = (type.parameterCount() == 2) + ? BeanReaderGenerator.params("g", "name") + : BeanReaderGenerator.params("g", "name", "value"); + clb.withMethod(name, type, ClassFile.ACC_PRIVATE | ClassFile.ACC_STATIC, + mb -> mb.with(helperParams).withCode(cob -> { + for (int i = 0; i < INLINE_PAD; i++) { + cob.nop(); + } + body.accept(cob); + })); + } + + private static void buildSerialize(CodeBuilder cob, ClassDesc thisClass, Class beanClass, + List props, String[] stockName, String[] nameName, String[] childName, + ViewStrategy views) { + final int gen = 2; + final int ctxt = 3; + final int beanSlot = 4; + final int refSlot = 5; + final int maskSlot = 6; + + ClassDesc beanDesc = beanClass.describeConstable().orElseThrow(); + final boolean itf = beanClass.isInterface(); + + Label scopeStart = cob.newBoundLabel(); + Label delegate = null; + if (views == ViewStrategy.DELEGATE) { + // View-active calls (declared views with more than 64 properties, + // or the write-nothing inclusion-off case) go to the stock + // serializer, whose tail sits after the body so the main path + // decompiles un-nested. + delegate = cob.newLabel(); + cob.aload(ctxt).invokevirtual(CD_SER_CONTEXT, "getActiveView", MTD_GET_ACTIVE_VIEW); + cob.ifnonnull(delegate); + } else if (views == ViewStrategy.MASK) { + Label nullView = cob.newLabel(); + Label haveMask = cob.newLabel(); + cob.aload(ctxt).invokevirtual(CD_SER_CONTEXT, "getActiveView", MTD_GET_ACTIVE_VIEW); + cob.dup(); + cob.ifnull(nullView); + cob.aload(0); + cob.swap(); + cob.invokevirtual(CD_WRITER_BASE, "_viewMask", MTD_VIEW_MASK); + cob.lstore(maskSlot); + cob.goto_(haveMask); + cob.labelBinding(nullView); + cob.pop(); + cob.loadConstant(-1L); + cob.lstore(maskSlot); + cob.labelBinding(haveMask); + } + + cob.aload(1).checkcast(beanDesc).astore(beanSlot); + cob.aload(gen).aload(beanSlot) + .invokevirtual(CD_JSON_GENERATOR, "writeStartObject", MTD_WRITE_START_OBJECT).pop(); + + for (int i = 0; i < props.size(); i++) { + GenWProp prop = props.get(i); + Label hidden = null; + if (views == ViewStrategy.MASK) { + hidden = cob.newLabel(); + cob.lload(maskSlot); + cob.loadConstant(1L << i); + cob.land(); + cob.lconst_0(); + cob.lcmp(); + cob.ifeq(hidden); + } + switch (prop.kind()) { + case INT -> emitScalar(cob, thisClass, gen, beanSlot, beanDesc, itf, prop, + nameName[i], "$int", MTD_HELP_INT, ConstantDescs.CD_int); + case LONG -> emitScalar(cob, thisClass, gen, beanSlot, beanDesc, itf, prop, + nameName[i], "$long", MTD_HELP_LONG, ConstantDescs.CD_long); + case BOOLEAN -> emitScalar(cob, thisClass, gen, beanSlot, beanDesc, itf, prop, + nameName[i], "$bool", MTD_HELP_BOOLEAN, ConstantDescs.CD_boolean); + case STRING -> emitScalar(cob, thisClass, gen, beanSlot, beanDesc, itf, prop, + nameName[i], "$str", MTD_HELP_STRING, ConstantDescs.CD_String); + case CHILD -> { + Class childRaw = prop.field() != null + ? prop.field().getType() : prop.getter().getReturnType(); + ClassDesc childType = childRaw.describeConstable().orElseThrow(); + cob.aload(gen); + ldcData(cob, nameName[i], CD_SERIALIZABLE_STRING); + cob.invokestatic(thisClass, "$name", MTD_HELP_NAME); + cob.aload(beanSlot); + emitLoad(cob, itf, beanDesc, prop, childType); + cob.astore(refSlot); + final String childEntry = childName[i]; + emitNullableRef(cob, gen, refSlot, () -> { + ldcData(cob, childEntry, CD_WRITER_BASE); + cob.aload(refSlot).aload(gen).aload(ctxt); + cob.invokevirtual(CD_WRITER_BASE, "serialize", MTD_SERIALIZE); + }); + } + case STOCK -> { + ldcData(cob, stockName[i], CD_PROPERTY_WRITER); + cob.aload(beanSlot).aload(gen).aload(ctxt); + cob.invokevirtual(CD_PROPERTY_WRITER, "serializeAsProperty", + MTD_SERIALIZE_AS_PROPERTY); + } + } + if (hidden != null) { + cob.labelBinding(hidden); + } + } + + cob.aload(gen).invokevirtual(CD_JSON_GENERATOR, "writeEndObject", MTD_WRITE_END).pop(); + cob.return_(); + if (delegate != null) { + cob.labelBinding(delegate); + cob.aload(0).getfield(CD_WRITER_BASE, "_fallback", CD_BEAN_SER_BASE); + cob.aload(1).aload(gen).aload(ctxt); + cob.invokevirtual(CD_BEAN_SER_BASE, "serialize", MTD_SERIALIZE); + cob.return_(); + } + + Label scopeEnd = cob.newBoundLabel(); + cob.localVariable(1, "value", ConstantDescs.CD_Object, scopeStart, scopeEnd); + cob.localVariable(gen, "g", CD_JSON_GENERATOR, scopeStart, scopeEnd); + cob.localVariable(ctxt, "ctxt", CD_SER_CONTEXT, scopeStart, scopeEnd); + cob.localVariable(beanSlot, "bean", beanDesc, scopeStart, scopeEnd); + // Slot entries must stay within max_locals, so name the child slot + // only when some CHILD arm actually stores it. + if (props.stream().anyMatch(pr -> pr.kind() == WKind.CHILD)) { + cob.localVariable(refSlot, "child", ConstantDescs.CD_Object, scopeStart, scopeEnd); + } + if (views == ViewStrategy.MASK) { + cob.localVariable(maskSlot, "viewMask", ConstantDescs.CD_long, scopeStart, scopeEnd); + } + } + + // Overrides GeneratedWriterBase._computeViewMask: bit i set when property + // i is visible in the view, per the same rule the stock factory uses to + // build the filtered writer array. + private static void emitComputeViewMask(java.lang.classfile.ClassBuilder clb, + List props, String[] stockName, boolean includeByDefault) { + clb.withMethod("_computeViewMask", MTD_VIEW_MASK, ClassFile.ACC_PROTECTED, + mb -> mb.with(BeanReaderGenerator.params("activeView")).withCode(cob -> { + final int maskSlot = 2; + Label scopeStart = cob.newBoundLabel(); + cob.lconst_0().lstore(maskSlot); + for (int i = 0; i < props.size(); i++) { + Label skip = cob.newLabel(); + ldcData(cob, stockName[i], CD_PROPERTY_WRITER); + cob.aload(1); + cob.loadConstant(includeByDefault ? 1 : 0); + cob.invokestatic(CD_WRITER_BASE, "_propVisible", MTD_PROP_VISIBLE); + cob.ifeq(skip); + cob.lload(maskSlot); + cob.loadConstant(1L << i); + cob.lor(); + cob.lstore(maskSlot); + cob.labelBinding(skip); + } + cob.lload(maskSlot).lreturn(); + Label scopeEnd = cob.newBoundLabel(); + cob.localVariable(1, "activeView", ConstantDescs.CD_Class, scopeStart, scopeEnd); + cob.localVariable(maskSlot, "mask", ConstantDescs.CD_long, scopeStart, scopeEnd); + })); + } + + private static void emitScalar(CodeBuilder cob, ClassDesc thisClass, int gen, int beanSlot, + ClassDesc beanDesc, boolean itf, GenWProp prop, String nameEntry, String helper, + MethodTypeDesc helperType, ClassDesc valDesc) { + cob.aload(gen); + ldcData(cob, nameEntry, CD_SERIALIZABLE_STRING); + cob.aload(beanSlot); + emitLoad(cob, itf, beanDesc, prop, valDesc); + cob.invokestatic(thisClass, helper, helperType); + } + + // Loads the property value from the bean already on the stack: a getfield + // for a public field, otherwise the getter call. + private static void emitLoad(CodeBuilder cob, boolean itf, ClassDesc beanDesc, + GenWProp prop, ClassDesc valDesc) { + if (prop.field() != null) { + ClassDesc owner = prop.field().getDeclaringClass().describeConstable().orElseThrow(); + cob.getfield(owner, prop.field().getName(), valDesc); + } else if (itf) { + cob.invokeinterface(beanDesc, prop.getter().getName(), MethodTypeDesc.of(valDesc)); + } else { + cob.invokevirtual(beanDesc, prop.getter().getName(), MethodTypeDesc.of(valDesc)); + } + } + + private static void emitNullableRef(CodeBuilder cob, int gen, int slot, Runnable nonNull) { + Label isNull = cob.newLabel(); + Label done = cob.newLabel(); + cob.aload(slot).ifnull(isNull); + nonNull.run(); + cob.goto_(done); + cob.labelBinding(isNull); + cob.aload(gen).invokevirtual(CD_JSON_GENERATOR, "writeNull", MTD_WRITE_NULL).pop(); + cob.labelBinding(done); + } +} diff --git a/blackbird/src/main/java/tools/jackson/module/blackbird/codegen/CodecAccess.java b/blackbird/src/main/java/tools/jackson/module/blackbird/codegen/CodecAccess.java new file mode 100644 index 000000000..6ad4ba0bd --- /dev/null +++ b/blackbird/src/main/java/tools/jackson/module/blackbird/codegen/CodecAccess.java @@ -0,0 +1,56 @@ +package tools.jackson.module.blackbird.codegen; + +import java.lang.invoke.MethodHandles; +import java.lang.reflect.Modifier; +import java.util.function.Function; + +/** + * Access decisions shared by the deserializer and serializer factories: where + * a generated hidden class may be defined, and which members its bytecode may + * reference directly. + */ +public final class CodecAccess +{ + private CodecAccess() {} + + /** + * Public beans keep the module's define context (returned as null); + * non-public beans need the bean's package context, reached through + * privateLookupIn with the user-supplied lookup (or the module's own, + * which suffices on the classpath where everything shares the unnamed + * module). No reachable context means the bean stays on the stock path. + */ + public static MethodHandles.Lookup defineContext(Class beanClass, + Function, MethodHandles.Lookup> lookups) { + if (Modifier.isPublic(beanClass.getModifiers()) + || beanClass.getPackageName().isEmpty()) { + return null; + } + MethodHandles.Lookup caller = lookups.apply(beanClass); + if (caller == null) { + caller = MethodHandles.lookup(); + } + try { + return MethodHandles.privateLookupIn(beanClass, caller); + } catch (IllegalAccessException | SecurityException e) { + return null; + } + } + + /** + * A member the generated code may reference directly: public on a public + * class always, and otherwise a non-private member of a class in the same + * runtime package as the define context (anchor null = module context). + */ + public static boolean directlyAccessible(int memberMods, Class declaring, + Class anchor) { + if (Modifier.isPublic(memberMods) && Modifier.isPublic(declaring.getModifiers())) { + return true; + } + return anchor != null + && !Modifier.isPrivate(memberMods) + && !Modifier.isPrivate(declaring.getModifiers()) + && declaring.getPackageName().equals(anchor.getPackageName()) + && declaring.getClassLoader() == anchor.getClassLoader(); + } +} diff --git a/blackbird/src/main/java/tools/jackson/module/blackbird/codegen/CodegenDump.java b/blackbird/src/main/java/tools/jackson/module/blackbird/codegen/CodegenDump.java new file mode 100644 index 000000000..ebcac291b --- /dev/null +++ b/blackbird/src/main/java/tools/jackson/module/blackbird/codegen/CodegenDump.java @@ -0,0 +1,39 @@ +package tools.jackson.module.blackbird.codegen; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Debug aid: when the {@code blackbird.debug.dumpDir} system property names a + * directory, every generated class's bytes are written there as + * {@code -.class} before the hidden class is defined, + * for inspection with javap or a decompiler. A dump failure never affects + * generation. + */ +final class CodegenDump +{ + private static final Path DIR = resolveDir(); + + private CodegenDump() {} + + private static Path resolveDir() { + String dir = System.getProperty("blackbird.debug.dumpDir"); + return (dir == null) ? null : Path.of(dir); + } + + static void dump(Class beanClass, String kind, byte[] bytes) { + if (DIR == null) { + return; + } + try { + Files.createDirectories(DIR); + Files.write(DIR.resolve(beanClass.getName() + "-" + kind + ".class"), bytes); + } catch (IOException e) { + Logger.getLogger("tools.jackson.module.blackbird").log(Level.FINE, + "Generated-class dump failed for " + beanClass.getName(), e); + } + } +} diff --git a/blackbird/src/main/java/tools/jackson/module/blackbird/codegen/CodegenFallbacks.java b/blackbird/src/main/java/tools/jackson/module/blackbird/codegen/CodegenFallbacks.java new file mode 100644 index 000000000..75d59e0d8 --- /dev/null +++ b/blackbird/src/main/java/tools/jackson/module/blackbird/codegen/CodegenFallbacks.java @@ -0,0 +1,63 @@ +package tools.jackson.module.blackbird.codegen; + +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Reports codec-generation fallbacks so demotion to the stock path never hides + * a problem silently. Gate failures are expected world diversity (exotic + * classes make reflective gate checks throw) and log a warning once per type. + * Generation failures are Blackbird bugs: the type passed every gate and the + * generator still failed. They log severe once per type, and with the + * {@code tools.jackson.module.blackbird.failOnCodegenError} system property + * set they are rethrown instead of demoted, which the Blackbird test suites + * enable so a masked generation bug fails the build. + */ +public final class CodegenFallbacks +{ + private static final Logger LOGGER = Logger.getLogger("tools.jackson.module.blackbird"); + + // Keyed by class name, not Class, so this static set never pins a foreign + // classloader (the leak class this module just fixed). + private static final Set REPORTED = ConcurrentHashMap.newKeySet(); + + public static final String FAIL_ON_ERROR_PROPERTY = + "tools.jackson.module.blackbird.failOnCodegenError"; + + private CodegenFallbacks() {} + + public static void gateFailure(Class beanClass, Throwable cause) { + if (REPORTED.add("gate:" + beanClass.getName())) { + LOGGER.log(Level.WARNING, "Blackbird gate check failed for {0}; the stock" + + " (de)serializer stays in place. Cause: {1}. Later reports for" + + " this type log at FINE.", + new Object[] { beanClass.getName(), cause }); + } else { + LOGGER.log(Level.FINE, "Blackbird gate check failed for " + beanClass.getName(), cause); + } + } + + public static void generationFailure(Class beanClass, Throwable cause) { + if (Boolean.getBoolean(FAIL_ON_ERROR_PROPERTY)) { + if (cause instanceof RuntimeException re) { + throw re; + } + if (cause instanceof Error err) { + throw err; + } + throw new IllegalStateException( + "Blackbird codec generation failed for " + beanClass.getName(), cause); + } + if (REPORTED.add("gen:" + beanClass.getName())) { + LOGGER.log(Level.SEVERE, "Blackbird codec generation failed for " + + beanClass.getName() + "; the stock (de)serializer stays in place." + + " This is likely a jackson-module-blackbird bug; please report it.", + cause); + } else { + LOGGER.log(Level.FINE, + "Blackbird codec generation failed for " + beanClass.getName(), cause); + } + } +} diff --git a/blackbird/src/main/java/tools/jackson/module/blackbird/deser/BBDeserializerModifier.java b/blackbird/src/main/java/tools/jackson/module/blackbird/deser/BBDeserializerModifier.java index 7ab360529..4b89689e0 100644 --- a/blackbird/src/main/java/tools/jackson/module/blackbird/deser/BBDeserializerModifier.java +++ b/blackbird/src/main/java/tools/jackson/module/blackbird/deser/BBDeserializerModifier.java @@ -1,282 +1,186 @@ package tools.jackson.module.blackbird.deser; -import java.lang.invoke.LambdaConversionException; -import java.lang.invoke.LambdaMetafactory; -import java.lang.invoke.MethodHandle; +import java.io.IOException; +import java.io.ObjectInputStream; import java.lang.invoke.MethodHandles; -import java.lang.invoke.MethodType; -import java.lang.invoke.MethodHandles.Lookup; -import java.lang.reflect.Member; -import java.lang.reflect.Method; import java.lang.reflect.Modifier; -import java.util.*; -import java.util.function.BiConsumer; -import java.util.function.BiFunction; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; import java.util.function.Function; -import java.util.function.ObjIntConsumer; -import java.util.function.ObjLongConsumer; -import java.util.function.UnaryOperator; - -import tools.jackson.databind.*; -import tools.jackson.databind.deser.*; -import tools.jackson.databind.deser.impl.MethodProperty; -import tools.jackson.databind.deser.std.StdValueInstantiator; -import tools.jackson.databind.introspect.*; -import tools.jackson.databind.util.ClassUtil; -import tools.jackson.module.blackbird.util.ReflectionHack; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonIncludeProperties; + +import tools.jackson.databind.BeanDescription; +import tools.jackson.databind.DeserializationConfig; +import tools.jackson.databind.MapperFeature; +import tools.jackson.databind.PropertyName; +import tools.jackson.databind.ValueDeserializer; +import tools.jackson.databind.deser.ValueDeserializerModifier; +import tools.jackson.databind.deser.BeanDeserializerBuilder; +import tools.jackson.databind.deser.bean.BeanDeserializer; +import tools.jackson.databind.deser.bean.BeanDeserializerBase; +import tools.jackson.databind.deser.bean.BuilderBasedDeserializer; +import tools.jackson.databind.introspect.AnnotatedMethod; +import tools.jackson.databind.introspect.BeanPropertyDefinition; +import tools.jackson.module.blackbird.codegen.BeanReaderGenerator; +import tools.jackson.module.blackbird.codegen.CodegenFallbacks; + +/** + * Wraps eligible stock bean deserializers in a placeholder that generates a + * per-bean codec at resolve time (ClassFile API hidden class). Every gate that + * fails leaves the stock deserializer in place, so behavior never changes for + * beans the generator does not fully understand. + */ public class BBDeserializerModifier extends ValueDeserializerModifier { private static final long serialVersionUID = 1L; - private static final MethodHandle TRAMPOLINE, BOOLEAN_TRAMPOLINE, LONG_TRAMPOLINE, INT_TRAMPOLINE; + // Reserved for member access beyond public API (private setters, creators + // in non-exported packages); the v1 generator only touches public members. + private final Function, MethodHandles.Lookup> _lookups; - static { - try { - TRAMPOLINE = MethodHandles.lookup().findStatic(BBDeserializerModifier.class, "trampoline", - MethodType.methodType(void.class, BiFunction.class, Object.class, Object.class)); - BOOLEAN_TRAMPOLINE = MethodHandles.lookup().findStatic(BBDeserializerModifier.class, "booleanTrampoline", - MethodType.methodType(void.class, ObjBooleanBiFunction.class, Object.class, boolean.class)); - LONG_TRAMPOLINE = MethodHandles.lookup().findStatic(BBDeserializerModifier.class, "longTrampoline", - MethodType.methodType(void.class, ObjLongBiFunction.class, Object.class, long.class)); - INT_TRAMPOLINE = MethodHandles.lookup().findStatic(BBDeserializerModifier.class, "intTrampoline", - MethodType.methodType(void.class, ObjIntBiFunction.class, Object.class, int.class)); - } catch (Exception e) { - throw new ExceptionInInitializerError(e); - } - } - private final Function, Lookup> _lookups; - private final UnaryOperator _accessGrant; + // The build method is only reachable from updateBuilder; the factory calls + // updateBuilder and modifyDeserializer for the same bean back to back on + // one thread, so a ThreadLocal hands it across. Not final: readObject + // recreates it, since transient fields deserialize as null. + private transient ThreadLocal _pendingBuildMethod = new ThreadLocal<>(); - public BBDeserializerModifier(Function, MethodHandles.Lookup> lookups, UnaryOperator accessGrant) - { + public BBDeserializerModifier(Function, MethodHandles.Lookup> lookups) { _lookups = lookups; - _accessGrant = accessGrant; } - /* - /********************************************************************** - /* BeanDeserializerModifier methods - /********************************************************************** - */ + private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException { + in.defaultReadObject(); + _pendingBuildMethod = new ThreadLocal<>(); + } @Override public BeanDeserializerBuilder updateBuilder(DeserializationConfig config, - BeanDescription.Supplier beanDescRef, BeanDeserializerBuilder builder) - { - final Class beanClass = beanDescRef.getBeanClass(); - MethodHandles.Lookup lookup = _lookups.apply(beanClass); - if (lookup == null) { - return builder; - } - try { - lookup = ReflectionHack.privateLookupIn(beanClass, lookup); - } catch (IllegalAccessException e) { - throw new RuntimeException(e); - } - /* Hmmh. Can we access stuff from private classes? - * Possibly, if we can use parent class loader. - * (should probably skip all non-public?) - */ - if (Modifier.isPrivate(beanClass.getModifiers())) { // TODO?? - return builder; - } - lookup = _accessGrant.apply(lookup); - List> newProps = findOptimizableProperties( - lookup, config, builder.getProperties()); - // and if we found any, create mutator proxy, replace property objects - if (!newProps.isEmpty()) { - for (OptimizedSettableBeanProperty prop : newProps) { - builder.addOrReplaceProperty(prop, true); - } - } - // Second thing: see if we could (re)generate Creator(s): - ValueInstantiator inst = builder.getValueInstantiator(); - /* Hmmh. Probably better to require exact default implementation - * and not sub-class; chances are sub-class uses its own - * construction anyway. - */ - if (inst.getClass() == StdValueInstantiator.class) { - // also, only override if using default creator (no-arg ctor, no-arg static factory) - if (inst.canCreateUsingDefault() || inst.canCreateFromObjectWith()) { - inst = new CreatorOptimizer(beanClass, lookup, (StdValueInstantiator) inst).createOptimized(); - if (inst != null) { - builder.setValueInstantiator(inst); - } - } - } - - // also: may want to replace actual BeanDeserializer as well? For this, need to replace builder - // (but only if builder is the original standard one; don't want to break other impls) - if (builder.getClass() == BeanDeserializerBuilder.class) { - return new SuperSonicDeserializerBuilder(builder); - } + BeanDescription.Supplier beanDescRef, BeanDeserializerBuilder builder) { + _pendingBuildMethod.set(builder.getBuildMethod()); return builder; } - /* - /********************************************************************** - /* Internal methods - /********************************************************************** - */ - - protected List> findOptimizableProperties( - Lookup lookup, DeserializationConfig config, - Iterator propIterator) + @Override + public ValueDeserializer modifyDeserializer(DeserializationConfig config, + BeanDescription.Supplier beanDescRef, ValueDeserializer deserializer) { - ArrayList> newProps = new ArrayList>(); - - // Ok, then, find any properties for which we could generate accessors - while (propIterator.hasNext()) { - try { - nextProperty(propIterator.next(), lookup, newProps); - } catch (Throwable e) { - if (e instanceof Error) { - throw (Error) e; - } - if (e instanceof RuntimeException) { - throw (RuntimeException) e; - } - throw new RuntimeException(e); - } + // Gate failures of any kind leave the stock deserializer in place. The + // reflective gates can throw for exotic classes (a bean from a foreign + // classloader with inconsistent InnerClasses metadata raises + // IncompatibleClassChangeError from getEnclosingClass), and an + // acceleration modifier must never break a bean stock databind handles. + try { + return doModify(config, beanDescRef, deserializer); + } catch (RuntimeException | LinkageError e) { + CodegenFallbacks.gateFailure(beanDescRef.getBeanClass(), e); + return deserializer; } - return newProps; } - @SuppressWarnings("unchecked") - private void nextProperty(SettableBeanProperty prop, - Lookup lookup, - ArrayList> newProps) throws Throwable + private ValueDeserializer doModify(DeserializationConfig config, + BeanDescription.Supplier beanDescRef, ValueDeserializer deserializer) { - AnnotatedMember member = prop.getMember(); - Member jdkMember = member.getMember(); - - // if we ever support virtual properties, this would be null, so check, skip - if (jdkMember == null) { - return; + AnnotatedMethod buildMethod = _pendingBuildMethod.get(); + _pendingBuildMethod.remove(); + boolean builderBased = deserializer.getClass() == BuilderBasedDeserializer.class + && buildMethod != null; + if (!builderBased && deserializer.getClass() != BeanDeserializer.class) { + return deserializer; } - // First: we can't access private fields or methods.... - if (Modifier.isPrivate(jdkMember.getModifiers())) { - return; + if (Boolean.TRUE.equals(config.getDefaultMergeable())) { + return deserializer; } - // (although, interestingly enough, can seem to access private classes...) - - // 30-Jul-2012, tatu: [module-afterburner#6]: Needs to skip custom deserializers, if any. - if (prop.hasValueDeserializer()) { - if (!isDefaultDeserializer(prop.getValueDeserializer())) { - return; + BeanDescription beanDesc = beanDescRef.get(); + Class beanClass = beanDesc.getBeanClass(); + if (Modifier.isPrivate(beanClass.getModifiers()) + || (beanClass.getEnclosingClass() != null + && !Modifier.isStatic(beanClass.getModifiers()))) { + return deserializer; + } + // Records and builder beans skip the constructor checks: they create + // through the canonical constructor and the instantiator. Creator + // strictness needs no gate - the generated record path enforces + // required, FAIL_ON_MISSING, and FAIL_ON_NULL creator semantics per + // call, and builder beans here have no creator properties (the + // factory requires the default-creating instantiator). + if (!builderBased && !beanClass.isRecord()) { + if (Modifier.isAbstract(beanClass.getModifiers())) { + return deserializer; + } + try { + if (Modifier.isPrivate(beanClass.getDeclaredConstructor().getModifiers())) { + return deserializer; + } + } catch (NoSuchMethodException e) { + return deserializer; } } - - MethodHandle setter; - Class type; - if (jdkMember instanceof Method && prop instanceof MethodProperty) { - final Method method = (Method) jdkMember; - setter = lookup.unreflect(method); - type = ((AnnotatedMethod) member).getRawParameterType(0); - } else { - return; - //setter = lookup.unreflectGetter((Field) jdkMember); + if (beanDesc.findAnySetterAccessor() != null) { + return deserializer; } - - if (type.isPrimitive()) { - if (type == Integer.TYPE) { - newProps.add(new SettableIntProperty(prop, - createSetter(lookup, ObjIntConsumer.class, ObjIntBiFunction.class, - INT_TRAMPOLINE, int.class, setter))); - } else if (type == Long.TYPE) { - newProps.add(new SettableLongProperty(prop, - createSetter(lookup, ObjLongConsumer.class, ObjLongBiFunction.class, - LONG_TRAMPOLINE, long.class, setter))); - } else if (type == Boolean.TYPE) { - newProps.add(new SettableBooleanProperty(prop, - createSetter(lookup, ObjBooleanConsumer.class, ObjBooleanBiFunction.class, - BOOLEAN_TRAMPOLINE, boolean.class, setter))); + // Injected values arrive outside the property loop, which the + // generated codec does not model. + Map injectables = beanDesc.findInjectables(); + if (injectables != null && !injectables.isEmpty()) { + return deserializer; + } + // Ignored and included property sets ride into the codec, whose + // unknown arm consults them in the stock loop's exact order. + JsonIgnoreProperties.Value ignorals = + config.getDefaultPropertyIgnorals(beanClass, beanDesc.getClassInfo()); + JsonIncludeProperties.Value inclusions = + config.getDefaultPropertyInclusions(beanClass, beanDesc.getClassInfo()); + boolean ignoreAllUnknown = ignorals != null && ignorals.getIgnoreUnknown(); + Set ignorable = new HashSet<>(); + if (ignorals != null) { + ignorable.addAll(ignorals.getIgnored()); + } + ignorable.addAll(beanDesc.getIgnoredPropertyNames()); + Set includable = (inclusions == null) ? null : inclusions.getIncluded(); + BeanReaderGenerator.Ignorals codecIgnorals = + (!ignoreAllUnknown && ignorable.isEmpty() && includable == null) + ? BeanReaderGenerator.Ignorals.NONE + : new BeanReaderGenerator.Ignorals(ignoreAllUnknown, + ignorable.isEmpty() ? null : Set.copyOf(ignorable), + includable); + // Effective case-insensitivity, the way the stock builder computes it: + // the per-class format override wins, the mapper feature is baseline. + Boolean formatCI = beanDescRef.findExpectedFormat(null) + .getFeature(JsonFormat.Feature.ACCEPT_CASE_INSENSITIVE_PROPERTIES); + boolean caseInsensitive = (formatCI == null) + ? config.isEnabled(MapperFeature.ACCEPT_CASE_INSENSITIVE_PROPERTIES) + : formatCI.booleanValue(); + boolean declaresViews = config.getAnnotationIntrospector() + .findViews(config, beanDesc.getClassInfo()) != null; + Map> aliases = null; + for (BeanPropertyDefinition def : beanDesc.findProperties()) { + if (def.findViews() != null) { + declaresViews = true; } - } else { - if (type == String.class) { - newProps.add(new SettableStringProperty(prop, - createSetter(lookup, BiConsumer.class, BiFunction.class, TRAMPOLINE, Object.class, setter))); - } else { - newProps.add(new SettableObjectProperty(prop, - createSetter(lookup, BiConsumer.class, BiFunction.class, TRAMPOLINE, Object.class, setter))); + List defAliases = def.findAliases(); + if (!defAliases.isEmpty()) { + if (aliases == null) { + aliases = new HashMap<>(); + } + aliases.put(def.getName(), defAliases); + } + if (def.getPrimaryMember() != null + && config.getAnnotationIntrospector() + .findUnwrappingNameTransformer(config, def.getPrimaryMember()) != null) { + return deserializer; + } + if (def.getMetadata() != null && def.getMetadata().getMergeInfo() != null) { + return deserializer; } } - } - - private T createSetter(Lookup lookup, Class iface, Class thunkType, MethodHandle trampoline, Class valueType, MethodHandle setter) - throws Throwable, LambdaConversionException { - if (setter.type().returnType() == void.class) { - return iface.cast(LambdaMetafactory.metafactory( - lookup, - "accept", - MethodType.methodType(iface), - MethodType.methodType(void.class, Object.class, valueType), - setter, - setter.type()) - .getTarget().invoke()); - } - Object builtThunk = LambdaMetafactory.metafactory( - lookup, - "apply", - MethodType.methodType(thunkType), - MethodType.methodType(Object.class, Object.class, valueType), - setter, - setter.type()) - .getTarget().invoke(); - return iface.cast(LambdaMetafactory.metafactory( - MethodHandles.lookup(), - "accept", - MethodType.methodType(iface, thunkType), - MethodType.methodType(void.class, Object.class, valueType), - trampoline, - MethodType.methodType(void.class, Object.class, valueType)) - .getTarget().invoke(builtThunk)); - } - - /** - * Helper method used to check whether given deserializer is the default - * deserializer implementation: this is necessary to avoid overriding other - * kinds of deserializers. - */ - protected boolean isDefaultDeserializer(ValueDeserializer deser) { - return ClassUtil.isJacksonStdImpl(deser) - // 07-May-2018, tatu: Probably can't happen but just in case - || (deser instanceof SuperSonicBeanDeserializer); - - } - - // These trampolines adapt BiFunction-like setters (i.e. returns a value) - // to BiConsumer style through a generated thunk lambda. - - @FunctionalInterface - public interface ObjIntBiFunction { - Object apply(Object bean, int value); - } - - @FunctionalInterface - public interface ObjLongBiFunction { - Object apply(Object bean, long value); - } - - @FunctionalInterface - public interface ObjBooleanBiFunction { - Object apply(Object bean, boolean value); - } - - static void intTrampoline(ObjIntBiFunction thunk, Object bean, int value) { - thunk.apply(bean, value); - } - - static void longTrampoline(ObjLongBiFunction thunk, Object bean, long value) { - thunk.apply(bean, value); - } - - static void booleanTrampoline(ObjBooleanBiFunction thunk, Object bean, boolean value) { - thunk.apply(bean, value); - } - - static void trampoline(BiFunction thunk, Object bean, Object value) { - thunk.apply(bean, value); + return new BBReaderPlaceholder((BeanDeserializerBase) deserializer, _lookups, + builderBased ? buildMethod : null, declaresViews, codecIgnorals, aliases, + caseInsensitive); } } diff --git a/blackbird/src/main/java/tools/jackson/module/blackbird/deser/BBReaderFactory.java b/blackbird/src/main/java/tools/jackson/module/blackbird/deser/BBReaderFactory.java new file mode 100644 index 000000000..59d0769d0 --- /dev/null +++ b/blackbird/src/main/java/tools/jackson/module/blackbird/deser/BBReaderFactory.java @@ -0,0 +1,557 @@ +package tools.jackson.module.blackbird.deser; + +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.lang.reflect.RecordComponent; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; + +import tools.jackson.core.sym.PropertyNameMatcher; +import tools.jackson.core.util.Named; +import tools.jackson.databind.DeserializationContext; +import tools.jackson.databind.MapperFeature; +import tools.jackson.databind.PropertyName; +import tools.jackson.databind.ValueDeserializer; +import tools.jackson.databind.deser.CreatorProperty; +import tools.jackson.databind.deser.SettableBeanProperty; +import tools.jackson.databind.deser.bean.BeanDeserializerBase; +import tools.jackson.databind.introspect.AnnotatedConstructor; +import tools.jackson.databind.introspect.AnnotatedField; +import tools.jackson.databind.introspect.AnnotatedMethod; +import tools.jackson.module.blackbird.codegen.BeanReaderGenerator.GenProp; +import tools.jackson.module.blackbird.internal.GeneratedReaderBase; +import tools.jackson.module.blackbird.codegen.BeanReaderGenerator.Kind; +import tools.jackson.module.blackbird.codegen.BeanReaderGenerator.ViewStrategy; +import tools.jackson.module.blackbird.codegen.BeanReaderGenerator; +import tools.jackson.module.blackbird.codegen.CodecAccess; +import tools.jackson.module.blackbird.codegen.CodegenFallbacks; + +/** + * Builds a generated codec from a resolved stock bean deserializer, or returns + * null when the bean does not qualify. Qualification is conservative: every + * gate that cannot be verified cheaply demotes a property to the stock path or + * rejects the bean entirely, so semantics never drift from databind. + */ +final class BBReaderFactory +{ + private static final Set STOCK_SCALAR_DESERS = Set.of( + "tools.jackson.databind.deser.jdk.StringDeserializer", + "tools.jackson.databind.deser.jdk.NumberDeserializers$IntegerDeserializer", + "tools.jackson.databind.deser.jdk.NumberDeserializers$LongDeserializer", + "tools.jackson.databind.deser.jdk.NumberDeserializers$BooleanDeserializer"); + + private BBReaderFactory() {} + + private static final boolean DEBUG = Boolean.getBoolean("blackbird.debug.codegen"); + + static ValueDeserializer tryGenerate(BeanDeserializerBase delegate, + DeserializationContext ctxt, + Function, MethodHandles.Lookup> lookups, + AnnotatedMethod buildMethod, boolean declaresViews, + BeanReaderGenerator.Ignorals ignorals, Map> aliases, + boolean caseInsensitive) { + try { + ValueDeserializer codec = generate(delegate, ctxt, lookups, buildMethod, + declaresViews, ignorals, aliases, caseInsensitive); + if (DEBUG) { + System.err.println("bbdebug tryGenerate " + delegate.handledType().getName() + + " -> " + (codec == null ? "null (gated)" : codec.getClass().getName())); + } + return codec; + } catch (Throwable t) { + if (DEBUG) { + System.err.println("bbdebug tryGenerate " + delegate.handledType().getName() + " threw:"); + t.printStackTrace(); + } + CodegenFallbacks.generationFailure(delegate.handledType(), t); + return null; + } + } + + private static ValueDeserializer generate(BeanDeserializerBase delegate, + DeserializationContext ctxt, + Function, MethodHandles.Lookup> lookups, + AnnotatedMethod buildMethod, boolean declaresViews, + BeanReaderGenerator.Ignorals ignorals, Map> aliases, + boolean caseInsensitive) + throws ReflectiveOperationException { + // Deliberately no delegate.hasViews() gate: 3.x disables + // DEFAULT_VIEW_INCLUSION by default, which marks every bean as needing + // view processing; the generated codec instead checks + // ctxt.getActiveView() per call and delegates when a view is active. + if (delegate.getObjectIdReader(ctxt) != null) { + if (DEBUG) System.err.println("bbdebug gate: objectId"); + return null; + } + Class beanClass = delegate.handledType(); + if (Modifier.isPrivate(beanClass.getModifiers()) + || Modifier.isAbstract(beanClass.getModifiers())) { + if (DEBUG) System.err.println("bbdebug gate: class modifiers"); + return null; + } + // Generated code refers to the bean class by name, which the hidden + // class resolves through this module's loader. A bean from a foreign + // classloader would resolve to a different (or no) class, so it stays + // on the stock path. + if (!visibleToGenerator(beanClass)) { + if (DEBUG) System.err.println("bbdebug gate: foreign classloader"); + return null; + } + MethodHandles.Lookup defineLookup = CodecAccess.defineContext(beanClass, lookups); + boolean gatedContext = !Modifier.isPublic(beanClass.getModifiers()); + if (gatedContext && defineLookup == null) { + if (DEBUG) System.err.println("bbdebug gate: define context"); + return null; + } + if (buildMethod != null) { + return generateBuilder(delegate, ctxt, beanClass, lookups, buildMethod, defineLookup, declaresViews, ignorals, aliases, caseInsensitive); + } + if (beanClass.isRecord()) { + return generateRecord(delegate, ctxt, beanClass, lookups, defineLookup, declaresViews, ignorals, aliases, caseInsensitive); + } + if (!delegate.getValueInstantiator().canCreateUsingDefault()) { + if (DEBUG) System.err.println("bbdebug gate: instantiator"); + return null; + } + // The generated POJO codec constructs with a direct `new`, which is + // only equivalent when the instantiator's default creator IS the + // no-arg constructor. A no-arg @JsonCreator factory (or a custom + // instantiator) also reports canCreateUsingDefault and must construct + // through the instantiator, so such beans stay on the stock path. + if (!(delegate.getValueInstantiator().getDefaultCreator() + instanceof AnnotatedConstructor)) { + if (DEBUG) System.err.println("bbdebug gate: default creator is not the constructor"); + return null; + } + try { + if (!CodecAccess.directlyAccessible( + beanClass.getDeclaredConstructor().getModifiers(), beanClass, + defineLookup == null ? null : defineLookup.lookupClass())) { + if (DEBUG) System.err.println("bbdebug gate: ctor modifiers"); + return null; + } + } catch (NoSuchMethodException e) { + if (DEBUG) System.err.println("bbdebug gate: no ctor"); + return null; + } + + List props = new ArrayList<>(); + List names = new ArrayList<>(); + for (Iterator it = delegate.properties(); it.hasNext(); ) { + SettableBeanProperty prop = it.next(); + if (prop instanceof CreatorProperty + || prop.getMetadata().getMergeInfo() != null) { + if (DEBUG) System.err.println("bbdebug gate: prop " + prop.getName()); + return null; + } + props.add(classify(prop, beanClass, lookups, + defineLookup == null ? null : defineLookup.lookupClass())); + names.add(Named.fromString(prop.getName())); + } + if (props.isEmpty()) { + if (DEBUG) System.err.println("bbdebug gate: no props"); + return null; + } + int[] aliasArms = appendAliases(names, props, aliases); + PropertyNameMatcher matcher = matcher(ctxt, names, caseInsensitive); + return BeanReaderGenerator.generate(beanClass, props, matcher, delegate, defineLookup, + viewStrategy(ctxt, props, declaresViews), ignorals, aliasArms); + } + + // Beans that declare no @JsonView anywhere (read from the property + // definitions at modify time; the resolved matchers cannot distinguish a + // declared view from the empty set that disabled DEFAULT_VIEW_INCLUSION + // forces onto every unannotated property) emit no per-arm view code: + // with inclusion on, views cannot affect them at all (NONE); with it off, + // an active view hides every property, so the degenerate view-active call + // DELEGATEs to stock and the no-view hot path stays free of mask tests. + // Declared views take MASK (the fast path under views) up to 64 + // properties, DELEGATE beyond. Mask visibility is read per property from + // SettableBeanProperty.visibleInView, so semantics stay exactly stock. + private static ViewStrategy viewStrategy(DeserializationContext ctxt, + List props, boolean declaresViews) { + if (!declaresViews) { + return ctxt.isEnabled(MapperFeature.DEFAULT_VIEW_INCLUSION) + ? ViewStrategy.NONE : ViewStrategy.DELEGATE; + } + return (props.size() > 64) ? ViewStrategy.DELEGATE : ViewStrategy.MASK; + } + + // Builder-based beans: the stock ValueInstantiator creates the builder, + // properties apply to it (tier-A fluent setters must return void or the + // builder class exactly, or they demote to the stock path), and the build + // method - unreflected through the user lookup and asType'd to + // (Object)Object - produces the value. + private static ValueDeserializer generateBuilder(BeanDeserializerBase delegate, + DeserializationContext ctxt, Class beanClass, + Function, MethodHandles.Lookup> lookups, AnnotatedMethod buildMethod, + MethodHandles.Lookup defineLookup, boolean declaresViews, + BeanReaderGenerator.Ignorals ignorals, Map> aliases, + boolean caseInsensitive) + throws ReflectiveOperationException { + Method build = buildMethod.getAnnotated(); + Class builderClass = build.getDeclaringClass(); + if (!delegate.getValueInstantiator().canCreateUsingDefault() + || Modifier.isPrivate(builderClass.getModifiers()) + || !visibleToGenerator(builderClass) + || Modifier.isPrivate(build.getModifiers()) + || build.getParameterCount() != 0) { + if (DEBUG) System.err.println("bbdebug gate: builder shape"); + return null; + } + // The generated code casts to and calls setters on the builder class, + // so a non-public builder needs a define context in its own package. + if (!Modifier.isPublic(builderClass.getModifiers()) + && (defineLookup == null || !builderClass.getPackageName().equals( + defineLookup.lookupClass().getPackageName()))) { + defineLookup = CodecAccess.defineContext(builderClass, lookups); + if (defineLookup == null) { + if (DEBUG) System.err.println("bbdebug gate: builder context"); + return null; + } + } + Class anchor = defineLookup == null ? null : defineLookup.lookupClass(); + MethodHandles.Lookup lookup = + (defineLookup != null) ? defineLookup : lookups.apply(builderClass); + if (lookup == null) { + if (DEBUG) System.err.println("bbdebug gate: builder lookup"); + return null; + } + List props = new ArrayList<>(); + List names = new ArrayList<>(); + for (Iterator it = delegate.properties(); it.hasNext(); ) { + SettableBeanProperty prop = it.next(); + if (prop instanceof CreatorProperty + || prop.getMetadata().getMergeInfo() != null) { + if (DEBUG) System.err.println("bbdebug gate: builder prop " + prop.getName()); + return null; + } + props.add(classifyBuilder(prop, builderClass, anchor)); + names.add(Named.fromString(prop.getName())); + } + if (props.isEmpty()) { + if (DEBUG) System.err.println("bbdebug gate: builder no props"); + return null; + } + MethodHandle buildMH; + try { + buildMH = lookup.unreflect(build) + .asType(MethodType.methodType(Object.class, Object.class)); + } catch (IllegalAccessException e) { + if (DEBUG) System.err.println("bbdebug gate: build method access"); + return null; + } + int[] aliasArms = appendAliases(names, props, aliases); + PropertyNameMatcher matcher = matcher(ctxt, names, caseInsensitive); + return BeanReaderGenerator.generate(beanClass, props, matcher, delegate, null, + new BeanReaderGenerator.BuilderSupport( + delegate.getValueInstantiator(), buildMH, builderClass), defineLookup, + viewStrategy(ctxt, props, declaresViews), ignorals, aliasArms); + } + + private static GenProp classifyBuilder(SettableBeanProperty prop, Class builderClass, + Class anchor) { + Class raw = prop.getType().getRawClass(); + Method setter = setterOf(prop, raw); + if (setter == null + || !CodecAccess.directlyAccessible(setter.getModifiers(), builderClass, anchor) + || setter.getDeclaringClass() != builderClass + || (setter.getReturnType() != void.class + && setter.getReturnType() != builderClass)) { + return stock(prop); + } + ValueDeserializer valueDeser = prop.getValueDeserializer(); + if (valueDeser instanceof GeneratedReaderBase child) { + return new GenProp(prop.getName(), Kind.CHILD, setter, prop, raw, child, null); + } + Kind kind = scalarKind(raw); + if (kind == null || valueDeser == null + || !STOCK_SCALAR_DESERS.contains(valueDeser.getClass().getName())) { + return stock(prop); + } + return new GenProp(prop.getName(), kind, setter, prop); + } + + // Records collect components into typed locals and construct through the + // canonical constructor; every property must be a CreatorProperty whose + // creator index and type line up with the record components. + private static ValueDeserializer generateRecord(BeanDeserializerBase delegate, + DeserializationContext ctxt, Class beanClass, + Function, MethodHandles.Lookup> lookups, MethodHandles.Lookup defineLookup, + boolean declaresViews, BeanReaderGenerator.Ignorals ignorals, + Map> aliases, boolean caseInsensitive) + throws ReflectiveOperationException { + if (!delegate.getValueInstantiator().canCreateFromObjectWith()) { + if (DEBUG) System.err.println("bbdebug gate: record instantiator"); + return null; + } + // The generated code constructs through the canonical constructor. A + // @JsonCreator factory (or a custom instantiator) must construct + // through the instantiator instead, so such records stay on the stock + // path. An AnnotatedConstructor that also passes the per-component + // index and type match below is the canonical constructor: a second + // constructor with the same signature cannot exist. + if (!(delegate.getValueInstantiator().getWithArgsCreator() + instanceof AnnotatedConstructor)) { + if (DEBUG) System.err.println("bbdebug gate: record creator is not the constructor"); + return null; + } + RecordComponent[] comps = beanClass.getRecordComponents(); + // The seen-component mask in the generated code is one long. + if (comps.length > 64) { + if (DEBUG) System.err.println("bbdebug gate: record size"); + return null; + } + SettableBeanProperty[] byIndex = new SettableBeanProperty[comps.length]; + int count = 0; + for (Iterator it = delegate.properties(); it.hasNext(); ) { + SettableBeanProperty prop = it.next(); + if (!(prop instanceof CreatorProperty) + || prop.getMetadata().getMergeInfo() != null + // A missing injectable component takes its value from the + // context, which the typed-locals loop does not model. + || prop.getInjectionDefinition() != null) { + if (DEBUG) System.err.println("bbdebug gate: record prop " + prop.getName()); + return null; + } + int idx = prop.getCreatorIndex(); + if (idx < 0 || idx >= comps.length || byIndex[idx] != null + || prop.getType().getRawClass() != comps[idx].getType()) { + if (DEBUG) System.err.println("bbdebug gate: record index " + prop.getName()); + return null; + } + byIndex[idx] = prop; + count++; + } + if (count != comps.length || count == 0) { + if (DEBUG) System.err.println("bbdebug gate: record count"); + return null; + } + MethodHandles.Lookup lookup = + (defineLookup != null) ? defineLookup : lookups.apply(beanClass); + if (lookup == null) { + if (DEBUG) System.err.println("bbdebug gate: record lookup"); + return null; + } + Class[] paramTypes = new Class[comps.length]; + List props = new ArrayList<>(comps.length); + List names = new ArrayList<>(comps.length); + for (int i = 0; i < comps.length; i++) { + paramTypes[i] = comps[i].getType(); + SettableBeanProperty prop = byIndex[i]; + ValueDeserializer valueDeser = prop.getValueDeserializer(); + if (valueDeser instanceof GeneratedReaderBase child) { + props.add(new GenProp(prop.getName(), Kind.CHILD, null, prop, + paramTypes[i], child, null)); + names.add(Named.fromString(prop.getName())); + continue; + } + Kind kind = scalarKind(paramTypes[i]); + if (kind == null || valueDeser == null + || !STOCK_SCALAR_DESERS.contains(valueDeser.getClass().getName())) { + kind = Kind.STOCK; + } + props.add(new GenProp(prop.getName(), kind, null, prop, paramTypes[i])); + names.add(Named.fromString(prop.getName())); + } + MethodHandle recordCtor; + try { + recordCtor = lookup.findConstructor(beanClass, + MethodType.methodType(void.class, paramTypes)); + } catch (IllegalAccessException e) { + // The supplied lookup cannot reach the canonical constructor: an + // environment gate, same as no lookup at all. + if (DEBUG) System.err.println("bbdebug gate: record ctor access"); + return null; + } + int[] aliasArms = appendAliases(names, props, aliases); + PropertyNameMatcher matcher = matcher(ctxt, names, caseInsensitive); + return BeanReaderGenerator.generate(beanClass, props, matcher, delegate, recordCtor, + defineLookup, viewStrategy(ctxt, props, declaresViews), ignorals, aliasArms); + } + + // Appends alias entries after the primary names, each mapping back to its + // primary arm - the same layout stock BeanPropertyMap.initMatcher builds - + // and returns the arm index per alias entry. + private static int[] appendAliases(List names, List props, + Map> aliases) { + if (aliases == null || aliases.isEmpty()) { + return null; + } + List arms = new ArrayList<>(); + for (int i = 0; i < props.size(); i++) { + List propAliases = aliases.get(props.get(i).name()); + if (propAliases == null) { + continue; + } + for (PropertyName alias : propAliases) { + names.add(Named.fromString(alias.getSimpleName())); + arms.add(i); + } + } + if (arms.isEmpty()) { + return null; + } + int[] out = new int[arms.size()]; + for (int i = 0; i < out.length; i++) { + out[i] = arms.get(i); + } + return out; + } + + // The matcher kind mirrors stock BeanPropertyMap.initMatcher: the + // case-insensitive variant when the mapper enables + // ACCEPT_CASE_INSENSITIVE_PROPERTIES, with the configured locale. + private static PropertyNameMatcher matcher(DeserializationContext ctxt, List names, + boolean caseInsensitive) { + if (caseInsensitive) { + return ctxt.tokenStreamFactory().constructCINameMatcher(names, true, + ctxt.getConfig().getLocale()); + } + return ctxt.tokenStreamFactory().constructNameMatcher(names, true); + } + + private static GenProp classify(SettableBeanProperty prop, Class beanClass, + Function, MethodHandles.Lookup> lookups, Class anchor) { + Class raw = prop.getType().getRawClass(); + ValueDeserializer valueDeser = prop.getValueDeserializer(); + Method setter = setterOf(prop, raw); + if (setter != null) { + return classifySetter(prop, beanClass, lookups, raw, valueDeser, setter, anchor); + } + Field field = fieldOf(prop, raw); + if (field != null) { + return classifyField(prop, beanClass, lookups, raw, valueDeser, field, anchor); + } + return stock(prop); + } + + private static GenProp classifySetter(SettableBeanProperty prop, Class beanClass, + Function, MethodHandles.Lookup> lookups, Class raw, + ValueDeserializer valueDeser, Method setter, Class anchor) { + boolean direct = CodecAccess.directlyAccessible(setter.getModifiers(), + setter.getDeclaringClass(), anchor); + if (valueDeser instanceof GeneratedReaderBase child && direct) { + return new GenProp(prop.getName(), Kind.CHILD, setter, prop, raw, child, null); + } + Kind kind = scalarKind(raw); + if (kind == null || valueDeser == null + || !STOCK_SCALAR_DESERS.contains(valueDeser.getClass().getName())) { + return stock(prop); + } + if (direct) { + return new GenProp(prop.getName(), kind, setter, prop); + } + // Non-public setter: reach it through the user-supplied lookup; any + // access failure keeps the property on the stock path. + try { + MethodHandles.Lookup lookup = lookups.apply(beanClass); + if (lookup == null) { + return stock(prop); + } + MethodHandle mh = lookup.unreflect(setter) + .asType(MethodType.methodType(void.class, beanClass, raw)); + return new GenProp(prop.getName(), kind, null, prop, raw, null, mh); + } catch (ReflectiveOperationException | RuntimeException e) { + return stock(prop); + } + } + + // Field-backed properties store through a generated putfield (public, + // non-final field on a public declaring class) or, for a non-public field, + // an unreflected setter handle reached through the user lookup - the same + // fallback the non-public-setter path uses. Final fields stay on the stock + // path so their (stock-defined) behavior is preserved exactly. + private static GenProp classifyField(SettableBeanProperty prop, Class beanClass, + Function, MethodHandles.Lookup> lookups, Class raw, + ValueDeserializer valueDeser, Field field, Class anchor) { + if (Modifier.isFinal(field.getModifiers())) { + return stock(prop); + } + boolean direct = CodecAccess.directlyAccessible(field.getModifiers(), + field.getDeclaringClass(), anchor); + if (valueDeser instanceof GeneratedReaderBase child && direct) { + return new GenProp(prop.getName(), Kind.CHILD, null, prop, raw, child, null, field); + } + Kind kind = scalarKind(raw); + if (kind == null || valueDeser == null + || !STOCK_SCALAR_DESERS.contains(valueDeser.getClass().getName())) { + return stock(prop); + } + if (direct) { + return new GenProp(prop.getName(), kind, null, prop, raw, null, null, field); + } + try { + MethodHandles.Lookup lookup = lookups.apply(beanClass); + if (lookup == null) { + return stock(prop); + } + MethodHandle mh = lookup.unreflectSetter(field) + .asType(MethodType.methodType(void.class, beanClass, raw)); + return new GenProp(prop.getName(), kind, null, prop, raw, null, mh); + } catch (ReflectiveOperationException | RuntimeException e) { + return stock(prop); + } + } + + private static Field fieldOf(SettableBeanProperty prop, Class raw) { + if (!(prop.getMember() instanceof AnnotatedField af)) { + return null; + } + Field field = af.getAnnotated(); + if (field == null || Modifier.isStatic(field.getModifiers()) + || field.getType() != raw) { + return null; + } + return field; + } + + private static Method setterOf(SettableBeanProperty prop, Class raw) { + if (!(prop.getMember() instanceof AnnotatedMethod am)) { + return null; + } + Method setter = am.getAnnotated(); + if (setter == null || setter.getParameterCount() != 1 + || setter.getParameterTypes()[0] != raw) { + return null; + } + return setter; + } + + private static GenProp stock(SettableBeanProperty prop) { + return new GenProp(prop.getName(), Kind.STOCK, null, prop); + } + + static boolean visibleToGenerator(Class cls) { + try { + return Class.forName(cls.getName(), false, + GeneratedReaderBase.class.getClassLoader()) == cls; + } catch (ClassNotFoundException | LinkageError e) { + return false; + } + } + + private static Kind scalarKind(Class raw) { + if (raw == String.class) { + return Kind.STRING; + } + if (raw == int.class) { + return Kind.INT; + } + if (raw == long.class) { + return Kind.LONG; + } + if (raw == boolean.class) { + return Kind.BOOLEAN; + } + return null; + } +} diff --git a/blackbird/src/main/java/tools/jackson/module/blackbird/deser/BBReaderPlaceholder.java b/blackbird/src/main/java/tools/jackson/module/blackbird/deser/BBReaderPlaceholder.java new file mode 100644 index 000000000..cfcb7d5a0 --- /dev/null +++ b/blackbird/src/main/java/tools/jackson/module/blackbird/deser/BBReaderPlaceholder.java @@ -0,0 +1,164 @@ +package tools.jackson.module.blackbird.deser; + +import java.lang.invoke.MethodHandles; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import tools.jackson.core.JsonParser; +import tools.jackson.databind.BeanProperty; +import tools.jackson.databind.DeserializationConfig; +import tools.jackson.databind.PropertyName; +import tools.jackson.databind.DeserializationContext; +import tools.jackson.databind.ValueDeserializer; +import tools.jackson.databind.deser.SettableBeanProperty; +import tools.jackson.databind.deser.bean.BeanDeserializerBase; +import tools.jackson.databind.introspect.AnnotatedMethod; +import tools.jackson.databind.jsontype.TypeDeserializer; +import tools.jackson.databind.type.LogicalType; +import tools.jackson.databind.util.AccessPattern; +import tools.jackson.databind.util.NameTransformer; +import tools.jackson.module.blackbird.codegen.BeanReaderGenerator; + +/** + * Stands in for a stock bean deserializer until resolution: the codec needs + * the resolved property list and the context's TokenStreamFactory, both of + * which exist only at resolve time. After resolve, contextualization hands out + * the generated codec directly, so steady-state call sites reference the codec + * with no extra hop; anything that keeps the placeholder still works through + * delegation. + */ +final class BBReaderPlaceholder extends ValueDeserializer +{ + private final BeanDeserializerBase _delegate; + + private final Function, MethodHandles.Lookup> _lookups; + + private final AnnotatedMethod _buildMethod; + + // Whether the bean or any property declares @JsonView explicitly, read + // from the property definitions at modify time (the resolved properties + // cannot distinguish a declared view from the empty view set that + // disabled DEFAULT_VIEW_INCLUSION forces onto unannotated properties). + private final boolean _declaresViews; + private final BeanReaderGenerator.Ignorals _ignorals; + private final Map> _aliases; + private final boolean _caseInsensitive; + + private volatile ValueDeserializer _codec; + + BBReaderPlaceholder(BeanDeserializerBase delegate, + Function, MethodHandles.Lookup> lookups, + AnnotatedMethod buildMethod, boolean declaresViews, + BeanReaderGenerator.Ignorals ignorals, Map> aliases, + boolean caseInsensitive) { + _delegate = delegate; + _lookups = lookups; + _buildMethod = buildMethod; + _declaresViews = declaresViews; + _ignorals = ignorals; + _aliases = aliases; + _caseInsensitive = caseInsensitive; + } + + @Override + public void resolve(DeserializationContext ctxt) { + if (Boolean.getBoolean("blackbird.debug.codegen")) { + System.err.println("bbdebug resolve " + _delegate.handledType().getName()); + } + _delegate.resolve(ctxt); + _codec = BBReaderFactory.tryGenerate(_delegate, ctxt, _lookups, _buildMethod, + _declaresViews, _ignorals, _aliases, _caseInsensitive); + } + + @Override + public ValueDeserializer createContextual(DeserializationContext ctxt, BeanProperty property) { + ValueDeserializer contextual = _delegate.createContextual(ctxt, property); + if (contextual != _delegate) { + return contextual; + } + ValueDeserializer codec = _codec; + return (codec != null) ? codec : this; + } + + @Override + public Object deserialize(JsonParser p, DeserializationContext ctxt) { + ValueDeserializer codec = _codec; + return (codec != null) ? codec.deserialize(p, ctxt) : _delegate.deserialize(p, ctxt); + } + + @Override + public Object deserialize(JsonParser p, DeserializationContext ctxt, Object intoValue) { + return _delegate.deserialize(p, ctxt, intoValue); + } + + @Override + public Object deserializeWithType(JsonParser p, DeserializationContext ctxt, + TypeDeserializer typeDeserializer) { + return _delegate.deserializeWithType(p, ctxt, typeDeserializer); + } + + @Override + public Object getNullValue(DeserializationContext ctxt) { + return _delegate.getNullValue(ctxt); + } + + @Override + public Object getEmptyValue(DeserializationContext ctxt) { + return _delegate.getEmptyValue(ctxt); + } + + @Override + public Object getAbsentValue(DeserializationContext ctxt) { + return _delegate.getAbsentValue(ctxt); + } + + @Override + public AccessPattern getNullAccessPattern() { + return _delegate.getNullAccessPattern(); + } + + @Override + public AccessPattern getEmptyAccessPattern() { + return _delegate.getEmptyAccessPattern(); + } + + @Override + public Collection getKnownPropertyNames() { + return _delegate.getKnownPropertyNames(); + } + + @Override + public SettableBeanProperty findBackReference(String refName) { + return _delegate.findBackReference(refName); + } + + @Override + public Boolean supportsUpdate(DeserializationConfig config) { + return _delegate.supportsUpdate(config); + } + + @Override + public Class handledType() { + return _delegate.handledType(); + } + + @Override + public LogicalType logicalType() { + return _delegate.logicalType(); + } + + @Override + public boolean isCachable() { + return _delegate.isCachable(); + } + + // Unwrapping must produce the stock unwrapping variant, never the codec + // (rationale in GeneratedReaderBase.unwrappingDeserializer). + @Override + public ValueDeserializer unwrappingDeserializer(DeserializationContext ctxt, + NameTransformer unwrapper) { + return _delegate.unwrappingDeserializer(ctxt, unwrapper); + } +} diff --git a/blackbird/src/main/java/tools/jackson/module/blackbird/deser/CreatorOptimizer.java b/blackbird/src/main/java/tools/jackson/module/blackbird/deser/CreatorOptimizer.java deleted file mode 100644 index 67911d35f..000000000 --- a/blackbird/src/main/java/tools/jackson/module/blackbird/deser/CreatorOptimizer.java +++ /dev/null @@ -1,140 +0,0 @@ -package tools.jackson.module.blackbird.deser; - -import java.lang.invoke.LambdaMetafactory; -import java.lang.invoke.MethodHandle; -import java.lang.invoke.MethodHandles; -import java.lang.reflect.AnnotatedElement; -import java.lang.reflect.Constructor; -import java.lang.reflect.Method; -import java.lang.reflect.Modifier; -import java.util.function.Function; -import java.util.function.Supplier; -import java.util.stream.Stream; - -import tools.jackson.databind.deser.ValueInstantiator; -import tools.jackson.databind.deser.std.StdValueInstantiator; -import tools.jackson.databind.introspect.AnnotatedWithParams; -import tools.jackson.module.blackbird.util.Unchecked; - -import static java.lang.invoke.MethodHandles.lookup; -import static java.lang.invoke.MethodType.methodType; - -/** - * Helper class that tries to generate {@link ValueInstantiator} class - * that calls constructors and/or factory methods directly, instead - * of using Reflection. - */ -public class CreatorOptimizer -{ - protected final Class _valueClass; - - protected final StdValueInstantiator _originalInstantiator; - - private final MethodHandles.Lookup _lookup; - - public CreatorOptimizer(Class valueClass, MethodHandles.Lookup lookup, StdValueInstantiator orig) - { - _valueClass = valueClass; - _originalInstantiator = orig; - _lookup = lookup; - } - - public ValueInstantiator createOptimized() - { - /* [Issue#11]: Need to avoid optimizing if we use delegate-based creators. */ - if (_originalInstantiator.canCreateUsingDelegate()) { - return null; - } - - if (_lookup == null) { - return null; - } - - AnnotatedWithParams argsCreator = _originalInstantiator.getWithArgsCreator(); - Function optimizedArgsCreator = null; - if (argsCreator != null) { - // The LambdaMetafactory requires a specifying interface, which is not possible to provide - // for methods with arbitrary parameter lists. So we have to use a spread invoker instead, - // which is not a valid target for the metafactory. Instead, we wrap it in a trampoline to - // avoid creating megamorphic code paths and hope that code inlining covers up our reflective sins. - MethodHandle argsCreatorHandle = directHandle(argsCreator.getAnnotated()); - if (argsCreatorHandle != null) { - optimizedArgsCreator = Unchecked.supplier(() -> - (Function) LambdaMetafactory.metafactory( - _lookup, - "apply", - methodType(Function.class, MethodHandle.class), - methodType(Object.class, Object.class), - lookup().findStatic( - CreatorOptimizer.class, - "invokeTrampoline", - methodType(Object.class, MethodHandle.class, Object[].class)), - methodType(Object.class, Object[].class)) - .getTarget().invokeExact( - argsCreatorHandle - .asFixedArity() - .asSpreader( - Object[].class, - argsCreatorHandle.type().parameterCount()) - .asType(methodType(Object.class, Object[].class)))) - .get(); - } - } - - AnnotatedWithParams defaultCreator = _originalInstantiator.getDefaultCreator(); - Supplier optimizedFactory = null; - if (defaultCreator != null) { - MethodHandle defaultCreatorHandle = directHandle(defaultCreator.getAnnotated()); - if (defaultCreatorHandle != null) { - optimizedFactory = Unchecked.supplier(() -> - (Supplier) LambdaMetafactory.metafactory( - _lookup, - "get", - methodType(Supplier.class), - methodType(Object.class), - defaultCreatorHandle, - defaultCreatorHandle.type()) - .getTarget().invokeExact()) - .get(); - } - } - if (optimizedArgsCreator != null || optimizedFactory != null) { - return new OptimizedValueInstantiator(_originalInstantiator, optimizedFactory, optimizedArgsCreator); - } - return null; - } - - private MethodHandle directHandle(AnnotatedElement element) { - return Stream.concat( - Stream.of(element) - .filter(Constructor.class::isInstance) - .map(Constructor.class::cast) - .filter(c -> !Modifier.isPrivate(c.getModifiers())) - .flatMap(t -> { - try { - return Stream.of(_lookup.unreflectConstructor(t)); - } catch (IllegalAccessException e) { - return Stream.empty(); - } - }), - Stream.of(element) - .filter(Method.class::isInstance) - .map(Method.class::cast) - .filter(m -> { - int mods = m.getModifiers(); - return Modifier.isStatic(mods) && !Modifier.isPrivate(mods); - }) - .flatMap(t -> { - try { - return Stream.of(_lookup.unreflect(t)); - } catch (IllegalAccessException e) { - return Stream.empty(); - } - })) - .findFirst().orElse(null); - } - - public static Object invokeTrampoline(MethodHandle delegate, Object[] args) throws Throwable { - return delegate.invokeExact(args); - } -} diff --git a/blackbird/src/main/java/tools/jackson/module/blackbird/deser/ObjBooleanConsumer.java b/blackbird/src/main/java/tools/jackson/module/blackbird/deser/ObjBooleanConsumer.java deleted file mode 100644 index 43411f9e4..000000000 --- a/blackbird/src/main/java/tools/jackson/module/blackbird/deser/ObjBooleanConsumer.java +++ /dev/null @@ -1,6 +0,0 @@ -package tools.jackson.module.blackbird.deser; - -@FunctionalInterface -public interface ObjBooleanConsumer { - void accept(Object bean, boolean value); -} diff --git a/blackbird/src/main/java/tools/jackson/module/blackbird/deser/OptimizedSettableBeanProperty.java b/blackbird/src/main/java/tools/jackson/module/blackbird/deser/OptimizedSettableBeanProperty.java deleted file mode 100644 index 4fd8f642f..000000000 --- a/blackbird/src/main/java/tools/jackson/module/blackbird/deser/OptimizedSettableBeanProperty.java +++ /dev/null @@ -1,162 +0,0 @@ -package tools.jackson.module.blackbird.deser; - -import java.util.logging.Level; -import java.util.logging.Logger; - -import tools.jackson.core.*; - -import tools.jackson.databind.*; -import tools.jackson.databind.deser.*; -import tools.jackson.databind.deser.impl.NullsConstantProvider; -import tools.jackson.databind.util.ClassUtil; - -/** - * Base class for concrete type-specific {@link SettableBeanProperty} - * implementations. - */ -abstract class OptimizedSettableBeanProperty> - extends SettableBeanProperty.Delegating -{ - private static final long serialVersionUID = 1L; - - /** - * @since 2.9 - */ - final protected boolean _skipNulls; - - /** - * Marker that we set if mutator turns out to be broken in a systematic - * way that we can handle by redirecting it back to standard one. - */ - private volatile boolean broken = false; - - /* - /********************************************************************** - /* Life-cycle - /********************************************************************** - */ - - protected OptimizedSettableBeanProperty(SettableBeanProperty src) - { - super(src); - _skipNulls = NullsConstantProvider.isSkipper(_nullProvider); - } - - // Base impl of `withName()` fine as-is: - // public SettableBeanProperty withName(PropertyName name); - - // But value deserializer handling needs some more care - @Override - public final SettableBeanProperty withValueDeserializer(ValueDeserializer deser) { - SettableBeanProperty newDelegate = delegate.withValueDeserializer(deser); - if (newDelegate == delegate) { - return this; - } - if (!_isDefaultDeserializer(deser)) { - return newDelegate; - } - return withDelegate(newDelegate); - } - - @Override - protected abstract SettableBeanProperty withDelegate(SettableBeanProperty d); - - /* - /********************************************************************** - /* Deserialization, regular - /********************************************************************** - */ - - @Override - public abstract void deserializeAndSet(JsonParser p, DeserializationContext ctxt, Object arg2) - throws JacksonException; - - @Override - public abstract void set(DeserializationContext ctxt, Object bean, Object value); - - /* - /********************************************************************** - /* Deserialization, builders - /********************************************************************** - */ - - /* !!! 19-Feb-2012, tatu: - * - * We do not yet generate code for these methods: it would not be hugely - * difficult to add them, but let's first see if anyone needs them... - * (it is non-trivial, adds code etc, so not without cost). - * - * That is: we'll use Reflection fallback for Builder-based deserialization, - * so it will not be significantly faster. - */ - - @Override - public abstract Object deserializeSetAndReturn(JsonParser p, - DeserializationContext ctxt, Object instance) - throws JacksonException; - - - @Override - public Object setAndReturn(DeserializationContext ctxt, Object instance, Object value) { - return delegate.setAndReturn(ctxt, instance, value); - } - - /* - /********************************************************************** - /* Error handling - /********************************************************************** - */ - - /** - * Helper method called when an exception is throw from mutator, to figure - * out what to do. - */ - protected void _reportProblem(DeserializationContext ctxt, Object bean, Object value, Throwable e) - throws JacksonException - { - if ((e instanceof IllegalAccessError) - || (e instanceof SecurityException)) { - synchronized (this) { - // yes, double-locking, so not guaranteed; but all we want here is to reduce likelihood - // of multiple logging of same underlying problem. Not guaranteed, just improved. - if (!broken) { - broken = true; - String msg = String.format("Disabling Blackbird deserialization for %s (field %s; mutator %s), due to access error (type %s, message=%s)%n", - bean.getClass(), _propName, getClass().getName(), - e.getClass().getName(), e.getMessage()); - Logger.getLogger(getClass().getName()).log(Level.WARNING, msg, e); - } - } - delegate.set(ctxt, bean, value); - return; - } - if (e instanceof JacksonException) { - throw (JacksonException) e; - } - if (e instanceof Error) { - throw (Error) e; - } - if (e instanceof RuntimeException) { - throw (RuntimeException) e; - } - // how could this ever happen? - throw new RuntimeException(e); - } - - /* - /********************************************************************** - /* Helper methods - /********************************************************************** - */ - - /** - * Helper method used to check whether given deserializer is the default - * deserializer implementation: this is necessary to avoid overriding custom - * deserializers. - */ - protected boolean _isDefaultDeserializer(ValueDeserializer deser) { - return (deser == null) - || (deser instanceof SuperSonicBeanDeserializer) - || ClassUtil.isJacksonStdImpl(deser); - } -} diff --git a/blackbird/src/main/java/tools/jackson/module/blackbird/deser/OptimizedValueInstantiator.java b/blackbird/src/main/java/tools/jackson/module/blackbird/deser/OptimizedValueInstantiator.java deleted file mode 100644 index 37c555c32..000000000 --- a/blackbird/src/main/java/tools/jackson/module/blackbird/deser/OptimizedValueInstantiator.java +++ /dev/null @@ -1,55 +0,0 @@ -package tools.jackson.module.blackbird.deser; - -import java.util.function.Function; -import java.util.function.Supplier; - -import tools.jackson.core.JacksonException; -import tools.jackson.databind.DeserializationContext; -import tools.jackson.databind.deser.std.StdValueInstantiator; - -class OptimizedValueInstantiator extends StdValueInstantiator -{ - private final Supplier _optimizedDefaultCreator; - private final Function _optimizedArgsCreator; - - protected OptimizedValueInstantiator(StdValueInstantiator original, - Supplier defaultCreator, Function argsCreator) { - super(original); - this._optimizedDefaultCreator = defaultCreator; - this._optimizedArgsCreator = argsCreator; - } - - @Override - public boolean canCreateUsingDefault() { - return _optimizedDefaultCreator != null || super.canCreateUsingDefault(); - } - - @Override - public boolean canCreateFromObjectWith() { - return _optimizedArgsCreator != null || super.canCreateFromObjectWith(); - } - - @Override - public Object createUsingDefault(DeserializationContext ctxt) throws JacksonException { - if (_optimizedDefaultCreator != null) { - try { - return _optimizedDefaultCreator.get(); - } catch (Exception e) { - return ctxt.handleInstantiationProblem(_valueClass, null, e); - } - } - return super.createUsingDefault(ctxt); - } - - @Override - public Object createFromObjectWith(DeserializationContext ctxt, Object[] args) throws JacksonException { - if (_optimizedArgsCreator != null) { - try { - return _optimizedArgsCreator.apply(args); - } catch (Exception e) { - return ctxt.handleInstantiationProblem(_valueClass, args, e); - } - } - return super.createFromObjectWith(ctxt, args); - } -} diff --git a/blackbird/src/main/java/tools/jackson/module/blackbird/deser/SettableBooleanProperty.java b/blackbird/src/main/java/tools/jackson/module/blackbird/deser/SettableBooleanProperty.java deleted file mode 100644 index 0c69807f3..000000000 --- a/blackbird/src/main/java/tools/jackson/module/blackbird/deser/SettableBooleanProperty.java +++ /dev/null @@ -1,78 +0,0 @@ -package tools.jackson.module.blackbird.deser; - -import tools.jackson.core.*; - -import tools.jackson.databind.*; -import tools.jackson.databind.deser.SettableBeanProperty; - -final class SettableBooleanProperty - extends OptimizedSettableBeanProperty -{ - private static final long serialVersionUID = 1L; - private ObjBooleanConsumer _optimizedSetter; - - public SettableBooleanProperty(SettableBeanProperty src, ObjBooleanConsumer optimizedSetter) - { - super(src); - _optimizedSetter = optimizedSetter; - } - - @Override - protected SettableBeanProperty withDelegate(SettableBeanProperty del) { - return new SettableBooleanProperty(del, _optimizedSetter); - } - - /* - /********************************************************************** - /* Deserialization - /********************************************************************** - */ - - @Override - public void deserializeAndSet(JsonParser p, DeserializationContext ctxt, Object bean) - throws JacksonException - { - boolean b; - JsonToken t = p.currentToken(); - if (t == JsonToken.VALUE_TRUE) { - b = true; - } else if (t == JsonToken.VALUE_FALSE) { - b = false; - } else { - delegate.deserializeAndSet(p, ctxt, bean); - return; - } - try { - _optimizedSetter.accept(bean, b); - } catch (Throwable e) { - _reportProblem(ctxt, bean, b, e); - } - } - - @Override - public Object deserializeSetAndReturn(JsonParser p, - DeserializationContext ctxt, Object instance) - throws JacksonException - { - JsonToken t = p.currentToken(); - if (t == JsonToken.VALUE_TRUE) { - return setAndReturn(ctxt, instance, Boolean.TRUE); - } - if (t == JsonToken.VALUE_FALSE) { - return setAndReturn(ctxt, instance, Boolean.FALSE); - } - return delegate.deserializeSetAndReturn(p, ctxt, instance); - } - - @Override - public void set(DeserializationContext ctxt, Object bean, Object value) - { - // not optimal (due to boxing), but better than using reflection: - final boolean b = ((Boolean) value).booleanValue(); - try { - _optimizedSetter.accept(bean, b); - } catch (Throwable e) { - _reportProblem(ctxt, bean, b, e); - } - } -} diff --git a/blackbird/src/main/java/tools/jackson/module/blackbird/deser/SettableIntProperty.java b/blackbird/src/main/java/tools/jackson/module/blackbird/deser/SettableIntProperty.java deleted file mode 100644 index efbcc49f3..000000000 --- a/blackbird/src/main/java/tools/jackson/module/blackbird/deser/SettableIntProperty.java +++ /dev/null @@ -1,70 +0,0 @@ -package tools.jackson.module.blackbird.deser; - -import java.util.function.ObjIntConsumer; - -import tools.jackson.core.*; - -import tools.jackson.databind.*; -import tools.jackson.databind.deser.SettableBeanProperty; - -final class SettableIntProperty - extends OptimizedSettableBeanProperty -{ - private static final long serialVersionUID = 1L; - private ObjIntConsumer _optimizedSetter; - - public SettableIntProperty(SettableBeanProperty src, ObjIntConsumer optimizedSetter) - { - super(src); - _optimizedSetter = optimizedSetter; - } - - @Override - protected SettableBeanProperty withDelegate(SettableBeanProperty del) { - return new SettableIntProperty(del, _optimizedSetter); - } - - /* - /********************************************************************** - /* Deserialization - /********************************************************************** - */ - - @Override - public void deserializeAndSet(JsonParser p, DeserializationContext ctxt, Object bean) - throws JacksonException - { - if (!p.isExpectedNumberIntToken()) { - delegate.deserializeAndSet(p, ctxt, bean); - return; - } - final int v = p.getIntValue(); - try { - _optimizedSetter.accept(bean, v); - } catch (Throwable e) { - _reportProblem(ctxt, bean, v, e); - } - } - - @Override - public Object deserializeSetAndReturn(JsonParser p, - DeserializationContext ctxt, Object instance) - throws JacksonException - { - if (p.isExpectedNumberIntToken()) { - return setAndReturn(ctxt, instance, p.getIntValue()); - } - return delegate.deserializeSetAndReturn(p, ctxt, instance); - } - - @Override - public void set(DeserializationContext ctxt, Object bean, Object value) { - // not optimal (due to boxing), but better than using reflection: - int v = ((Number) value).intValue(); - try { - _optimizedSetter.accept(bean, v); - } catch (Throwable e) { - _reportProblem(ctxt, bean, v, e); - } - } -} diff --git a/blackbird/src/main/java/tools/jackson/module/blackbird/deser/SettableLongProperty.java b/blackbird/src/main/java/tools/jackson/module/blackbird/deser/SettableLongProperty.java deleted file mode 100644 index e66b4c134..000000000 --- a/blackbird/src/main/java/tools/jackson/module/blackbird/deser/SettableLongProperty.java +++ /dev/null @@ -1,70 +0,0 @@ -package tools.jackson.module.blackbird.deser; - -import java.util.function.ObjLongConsumer; - -import tools.jackson.core.*; -import tools.jackson.databind.*; -import tools.jackson.databind.deser.SettableBeanProperty; - -final class SettableLongProperty - extends OptimizedSettableBeanProperty -{ - private static final long serialVersionUID = 1L; - private ObjLongConsumer _optimizedSetter; - - public SettableLongProperty(SettableBeanProperty src, ObjLongConsumer optimizedSetter) - { - super(src); - _optimizedSetter = optimizedSetter; - } - - @Override - protected SettableBeanProperty withDelegate(SettableBeanProperty del) { - return new SettableLongProperty(del, _optimizedSetter); - } - - /* - /********************************************************************** - /* Deserialization - /********************************************************************** - */ - - @Override - public void deserializeAndSet(JsonParser p, DeserializationContext ctxt, Object bean) - throws JacksonException - { - if (!p.isExpectedNumberIntToken()) { - delegate.deserializeAndSet(p, ctxt, bean); - return; - } - final long v = p.getLongValue(); - try { - _optimizedSetter.accept(bean, v); - } catch (Throwable e) { - _reportProblem(ctxt, bean, v, e); - } - } - - @Override - public Object deserializeSetAndReturn(JsonParser p, - DeserializationContext ctxt, Object instance) - throws JacksonException - { - if (p.isExpectedNumberIntToken()) { - return setAndReturn(ctxt, instance, p.getLongValue()); - } - return delegate.deserializeSetAndReturn(p, ctxt, instance); - } - - @Override - public void set(DeserializationContext ctxt, Object bean, Object value) - { - // not optimal (due to boxing), but better than using reflection: - final long v = ((Number) value).longValue(); - try { - _optimizedSetter.accept(bean, v); - } catch (Throwable e) { - _reportProblem(ctxt, bean, v, e); - } - } -} diff --git a/blackbird/src/main/java/tools/jackson/module/blackbird/deser/SettableObjectProperty.java b/blackbird/src/main/java/tools/jackson/module/blackbird/deser/SettableObjectProperty.java deleted file mode 100644 index cdca67f31..000000000 --- a/blackbird/src/main/java/tools/jackson/module/blackbird/deser/SettableObjectProperty.java +++ /dev/null @@ -1,92 +0,0 @@ -package tools.jackson.module.blackbird.deser; - -import java.util.function.BiConsumer; - -import tools.jackson.core.*; -import tools.jackson.databind.*; -import tools.jackson.databind.deser.SettableBeanProperty; - -final class SettableObjectProperty - extends OptimizedSettableBeanProperty -{ - private static final long serialVersionUID = 1L; - private BiConsumer _optimizedSetter; - - public SettableObjectProperty(SettableBeanProperty src, BiConsumer optimizedSetter) - { - super(src); - this._optimizedSetter = optimizedSetter; - } - - @Override - protected SettableBeanProperty withDelegate(SettableBeanProperty del) { - return new SettableObjectProperty(del, _optimizedSetter); - } - - /* - /********************************************************************** - /* Deserialization - /********************************************************************** - */ - - @Override - public void deserializeAndSet(JsonParser p, DeserializationContext ctxt, Object bean) - throws JacksonException - { - Object value; - if (p.hasToken(JsonToken.VALUE_NULL)) { - if (_skipNulls) { - return; - } - value = _nullProvider.getNullValue(ctxt); - } else if (_valueTypeDeserializer == null) { - value = _valueDeserializer.deserialize(p, ctxt); - // 04-May-2018, tatu: [databind#2023] Coercion from String (mostly) can give null - if (value == null) { - if (_skipNulls) { - return; - } - value = _nullProvider.getNullValue(ctxt); - } - } else { - value = _valueDeserializer.deserializeWithType(p, ctxt, _valueTypeDeserializer); - } - set(ctxt, bean, value); - } - - @Override - public Object deserializeSetAndReturn(JsonParser p, - DeserializationContext ctxt, Object instance) - throws JacksonException - { - Object value; - if (p.hasToken(JsonToken.VALUE_NULL)) { - if (_skipNulls) { - return instance; - } - value = _nullProvider.getNullValue(ctxt); - } else if (_valueTypeDeserializer == null) { - value = _valueDeserializer.deserialize(p, ctxt); - // 04-May-2018, tatu: [databind#2023] Coercion from String (mostly) can give null - if (value == null) { - if (_skipNulls) { - return instance; - } - value = _nullProvider.getNullValue(ctxt); - } - } else { - value = _valueDeserializer.deserializeWithType(p, ctxt, _valueTypeDeserializer); - } - return setAndReturn(ctxt, instance, value); - } - - @Override - public void set(DeserializationContext ctxt, Object bean, Object v) - { - try { - _optimizedSetter.accept(bean, v); - } catch (Throwable e) { - _reportProblem(ctxt, bean, v, e); - } - } -} diff --git a/blackbird/src/main/java/tools/jackson/module/blackbird/deser/SettableStringProperty.java b/blackbird/src/main/java/tools/jackson/module/blackbird/deser/SettableStringProperty.java deleted file mode 100644 index 4af3fb878..000000000 --- a/blackbird/src/main/java/tools/jackson/module/blackbird/deser/SettableStringProperty.java +++ /dev/null @@ -1,62 +0,0 @@ -package tools.jackson.module.blackbird.deser; - -import java.util.function.BiConsumer; - -import tools.jackson.core.*; -import tools.jackson.databind.*; -import tools.jackson.databind.deser.SettableBeanProperty; - -final class SettableStringProperty - extends OptimizedSettableBeanProperty -{ - private static final long serialVersionUID = 1L; - private final BiConsumer _optimizedSetter; - - public SettableStringProperty(SettableBeanProperty src, BiConsumer optimizedSetter) - { - super(src); - _optimizedSetter = optimizedSetter; - } - - @Override - protected SettableBeanProperty withDelegate(SettableBeanProperty del) { - return new SettableStringProperty(del, _optimizedSetter); - } - - /* - /********************************************************************** - /* Deserialization - /********************************************************************** - */ - - // Copied from StdDeserializer.StringDeserializer: - @Override - public void deserializeAndSet(JsonParser p, DeserializationContext ctxt, Object bean) - throws JacksonException - { - if (!p.hasToken(JsonToken.VALUE_STRING)) { - delegate.deserializeAndSet(p, ctxt, bean); - return; - } - set(ctxt, bean, p.getString()); - } - - @Override - public Object deserializeSetAndReturn(JsonParser p, DeserializationContext ctxt, Object instance) - throws JacksonException - { - if (p.hasToken(JsonToken.VALUE_STRING)) { - return setAndReturn(ctxt, instance, p.getString()); - } - return delegate.deserializeSetAndReturn(p, ctxt, instance); - } - - @Override - public void set(DeserializationContext ctxt, Object bean, Object value) { - try { - _optimizedSetter.accept(bean, (String) value); - } catch (Throwable e) { - _reportProblem(ctxt, bean, value, e); - } - } -} diff --git a/blackbird/src/main/java/tools/jackson/module/blackbird/deser/SuperSonicBeanDeserializer.java b/blackbird/src/main/java/tools/jackson/module/blackbird/deser/SuperSonicBeanDeserializer.java deleted file mode 100644 index 6b7e34117..000000000 --- a/blackbird/src/main/java/tools/jackson/module/blackbird/deser/SuperSonicBeanDeserializer.java +++ /dev/null @@ -1,342 +0,0 @@ -package tools.jackson.module.blackbird.deser; - -import java.util.*; - -import tools.jackson.core.*; -import tools.jackson.core.io.SerializedString; - -import tools.jackson.databind.*; -import tools.jackson.databind.deser.*; -import tools.jackson.databind.deser.bean.BeanDeserializer; -import tools.jackson.databind.deser.bean.BeanPropertyMap; -import tools.jackson.databind.deser.bean.PropertyBasedCreator; -import tools.jackson.databind.deser.impl.UnwrappedPropertyHandler; -import tools.jackson.databind.util.NameTransformer; - -final class SuperSonicBeanDeserializer extends BeanDeserializer -{ - /** - * Names of properties being deserialized, in ordered they are - * expected to have been written (as per serialization settings); - * used for speculative order-based optimizations - */ - protected final SerializedString[] _orderedPropertyNames; - - /** - * Properties matching names in {@code _orderedPropertyNames}, - * assigned after resolution when property instances are finalized. - */ - protected SettableBeanProperty[] _orderedProperties; - - /* - /********************************************************************** - /* Life-cycle, construction, initialization - /********************************************************************** - */ - - public SuperSonicBeanDeserializer(BeanDeserializer src, List props) - { - super(src); - final int len = props.size(); - _orderedPropertyNames = new SerializedString[len]; - for (int i = 0; i < len; ++i) { - _orderedPropertyNames[i] = new SerializedString(props.get(i).getName()); - } - } - - protected SuperSonicBeanDeserializer(SuperSonicBeanDeserializer src, - UnwrappedPropertyHandler unwrapHandler, PropertyBasedCreator propertyBasedCreator, - BeanPropertyMap renamedProperties, boolean ignoreAllUnknown) - { - super(src, unwrapHandler, propertyBasedCreator, renamedProperties, ignoreAllUnknown); - _orderedPropertyNames = src._orderedPropertyNames; - _orderedProperties = src._orderedProperties; - } - - @Override - public ValueDeserializer unwrappingDeserializer(DeserializationContext ctxt, - NameTransformer transformer) - { - // 17-Dec-2020, tatu: Was like so: -// return new SuperSonicBeanDeserializer(this, unwrapper); - // but Afterburner 3.0 had this: - - // NOTE: copied verbatim from `BeanDeserializer` - - if (_currentlyTransforming == transformer) { // from [databind#383] - return this; - } - _currentlyTransforming = transformer; - try { - UnwrappedPropertyHandler uwHandler = _unwrappedPropertyHandler; - if (uwHandler != null) { - uwHandler = uwHandler.renameAll(ctxt, transformer); - } - PropertyBasedCreator pbCreator = _propertyBasedCreator; - if (pbCreator != null) { - pbCreator = pbCreator.renameAll(ctxt, transformer); - } - return new SuperSonicBeanDeserializer(this, uwHandler, pbCreator, - _beanProperties.renameAll(ctxt, transformer), true); - } finally { _currentlyTransforming = null; } - } - - // // Others, let's just leave as is; will not be optimized? - - //public BeanDeserializer withObjectIdReader(ObjectIdReader oir) { - - //public BeanDeserializer withIgnorableProperties(HashSet ignorableProps) - - //protected BeanDeserializerBase asArrayDeserializer() - - /* - /********************************************************************** - /* BenaDeserializer overrides - /********************************************************************** - */ - - /** - * This method is overridden as we need to know expected order of - * properties. - */ - @Override - public void resolve(DeserializationContext ctxt) - { - super.resolve(ctxt); - // Ok, now; need to find actual property instances to go with order - // defined based on property names. - - // 20-Sep-2014, tatu: As per [afterburner#43], use of `JsonTypeInfo.As.EXTERNAL_PROPERTY` - // will "hide" matching property, leading to no match below. - // But since we don't use optimized path if that case, let's just bail out. - if (_externalTypeIdHandler != null || _unwrappedPropertyHandler != null) { - // should we assign empty array or... ? - return; - } - - int len = _orderedPropertyNames.length; - ArrayList props = new ArrayList(len); - int i = 0; - for (; i < len; ++i) { - SettableBeanProperty prop = _beanProperties.findDefinition(_orderedPropertyNames[i].toString()); - if (prop != null) { - props.add(prop); - } - } - // should usually get at least one property; let's for now consider it an error if not - // (may need to revisit in future) - if (i == 0) { - throw new IllegalStateException("Blackbird internal error: BeanDeserializer for " - +_beanType+" has no properties that match expected ordering (should have "+len+") -- can not create optimized deserializer"); - } - _orderedProperties = props.toArray(new SettableBeanProperty[0]); - } - - @Override - public Object deserialize(JsonParser p, DeserializationContext ctxt) - throws JacksonException - { - if (!_vanillaProcessing || (_objectIdReader != null)) { - // should we ever get here? Just in case - return super.deserialize(p, ctxt); - } - // common case first: - if (!p.isExpectedStartObjectToken()) { - return _deserializeOther(p, ctxt, p.currentToken()); - } - if (_nonStandardCreation) { - p.nextToken(); - if (_unwrappedPropertyHandler != null) { - return deserializeWithUnwrapped(p, ctxt); - } - if (_externalTypeIdHandler != null) { - return deserializeWithExternalTypeId(p, ctxt); - } - Object bean = deserializeFromObjectUsingNonDefault(p, ctxt); - if (_injectables != null) { - injectValues(ctxt, bean); - } - return bean; - } - final Object bean = _valueInstantiator.createUsingDefault(ctxt); - // [databind#631]: Assign current value, to be accessible by custom serializers - p.assignCurrentValue(bean); - if (p.canReadObjectId()) { - Object id = p.getObjectId(); - if (id != null) { - _handleTypedObjectId(p, ctxt, bean, id); - } - } - if (_injectables != null) { - injectValues(ctxt, bean); - } - for (int i = 0, len = _orderedProperties.length; i < len; ++i) { - SettableBeanProperty prop = _orderedProperties[i]; - if (!p.nextName(_orderedPropertyNames[i])) { // miss... - if (p.currentToken() == JsonToken.END_OBJECT) { - return bean; - } - // we likely point to PROPERTY_NAME, so can just call parent impl - return super.deserialize(p, ctxt, bean); - } - p.nextToken(); // skip field, returns value token - try { - prop.deserializeAndSet(p, ctxt, bean); - } catch (Exception e) { - wrapAndThrow(e, bean, prop.getName(), ctxt); - } - } - // also, need to ensure we get closing END_OBJECT... - if (p.nextToken() != JsonToken.END_OBJECT) { - return super.deserialize(p, ctxt, bean); - } - return bean; - } - - // much of below is cut'n pasted from BeanSerializer - @Override - public final Object deserialize(JsonParser p, DeserializationContext ctxt, Object bean) - throws JacksonException - { - // [modules-base#339]: Need to delegate to base implementation for - // proper @JsonView handling during deserialization - if (_needViewProcesing) { - return super.deserialize(p, ctxt, bean); - } - // [databind#631]: Assign current value, to be accessible by custom deserializers - p.assignCurrentValue(bean); - if (_injectables != null) { - injectValues(ctxt, bean); - } - if (_unwrappedPropertyHandler != null) { - return deserializeWithUnwrapped(p, ctxt, bean); - } - if (_externalTypeIdHandler != null) { - return deserializeWithExternalTypeId(p, ctxt, bean); - } - SettableBeanProperty prop = _orderedProperties[0]; - // First: verify that first name is expected - if (p.isExpectedStartObjectToken()) { - if (!p.nextName(_orderedPropertyNames[0])) { - return super.deserialize(p, ctxt, bean); - } - } else if (!p.hasToken(JsonToken.PROPERTY_NAME) - || !prop.getName().equals(p.currentName())) { - // no, something funky, use base impl for special cases - return super.deserialize(p, ctxt, bean); - } - p.nextToken(); - try { - prop.deserializeAndSet(p, ctxt, bean); - } catch (Exception e) { - wrapAndThrow(e, bean, prop.getName(), ctxt); - } - // then rest of properties - for (int i = 1, len = _orderedProperties.length; i < len; ++i) { - if (!p.nextName(_orderedPropertyNames[i])) { // miss... - if (p.hasToken(JsonToken.END_OBJECT)) { - return bean; - } - // we likely point to PROPERTY_NAME, so can just call parent impl - return super.deserialize(p, ctxt, bean); - } - prop = _orderedProperties[i]; - p.nextToken(); // skip field, returns value token - try { - prop.deserializeAndSet(p, ctxt, bean); - } catch (Exception e) { - wrapAndThrow(e, bean, prop.getName(), ctxt); - } - } - // also, need to ensure we get closing END_OBJECT... - if (p.nextToken() != JsonToken.END_OBJECT) { - return super.deserialize(p, ctxt, bean); - } - return bean; - } - - // much of below is cut'n pasted from BeanSerializer - @Override - public final Object deserializeFromObject(JsonParser p, DeserializationContext ctxt) - throws JacksonException - { - // [modules-base#339]: Need to delegate to base implementation for - // proper @JsonView handling during deserialization - if (_needViewProcesing) { - return super.deserializeFromObject(p, ctxt); - } - // See BeanDeserializer.deserializeFromObject [databind#622] - // Allow Object Id references to come in as JSON Objects as well... - if ((_objectIdReader != null) && _objectIdReader.maySerializeAsObject()) { - if (p.hasTokenId(JsonTokenId.ID_PROPERTY_NAME) - && _objectIdReader.isValidReferencePropertyName(p.currentName(), p)) { - return deserializeFromObjectId(p, ctxt); - } - } - if (_nonStandardCreation) { - if (_unwrappedPropertyHandler != null) { - return deserializeWithUnwrapped(p, ctxt); - } - if (_externalTypeIdHandler != null) { - return deserializeWithExternalTypeId(p, ctxt); - } - Object bean = deserializeFromObjectUsingNonDefault(p, ctxt); - if (_injectables != null) { - injectValues(ctxt, bean); - } - return bean; - } - final Object bean = _valueInstantiator.createUsingDefault(ctxt); - // [databind#631]: Assign current value, to be accessible by custom serializers - p.assignCurrentValue(bean); - if (p.canReadObjectId()) { - Object id = p.getObjectId(); - if (id != null) { - _handleTypedObjectId(p, ctxt, bean, id); - } - } - if (_injectables != null) { - injectValues(ctxt, bean); - } - SettableBeanProperty prop = _orderedProperties[0]; - // First: verify that first name is expected - if (p.isExpectedStartObjectToken()) { - if (!p.nextName(_orderedPropertyNames[0])) { - return super.deserialize(p, ctxt, bean); - } - } else if (!p.hasToken(JsonToken.PROPERTY_NAME) - || !prop.getName().equals(p.currentName())) { - return super.deserialize(p, ctxt, bean); - } - // and deserialize - p.nextToken(); - try { - prop.deserializeAndSet(p, ctxt, bean); - } catch (Exception e) { - wrapAndThrow(e, bean, prop.getName(), ctxt); - } - - // then rest of properties - for (int i = 1, len = _orderedProperties.length; i < len; ++i) { - prop = _orderedProperties[i]; - if (!p.nextName(_orderedPropertyNames[i])) { // miss... - if (p.hasToken(JsonToken.END_OBJECT)) { - return bean; - } - // we likely point to PROPERTY_NAME, so can just call parent impl - return super.deserialize(p, ctxt, bean); - } - p.nextToken(); // skip field, returns value token - try { - prop.deserializeAndSet(p, ctxt, bean); - } catch (Exception e) { - wrapAndThrow(e, bean, prop.getName(), ctxt); - } - } - // also, need to ensure we get closing END_OBJECT... - if (p.nextToken() != JsonToken.END_OBJECT) { - return super.deserialize(p, ctxt, bean); - } - return bean; - } -} diff --git a/blackbird/src/main/java/tools/jackson/module/blackbird/deser/SuperSonicDeserializerBuilder.java b/blackbird/src/main/java/tools/jackson/module/blackbird/deser/SuperSonicDeserializerBuilder.java deleted file mode 100644 index e56cf341c..000000000 --- a/blackbird/src/main/java/tools/jackson/module/blackbird/deser/SuperSonicDeserializerBuilder.java +++ /dev/null @@ -1,35 +0,0 @@ -package tools.jackson.module.blackbird.deser; - -import java.util.*; - -import tools.jackson.databind.ValueDeserializer; -import tools.jackson.databind.deser.*; -import tools.jackson.databind.deser.bean.BeanDeserializer; - -class SuperSonicDeserializerBuilder extends BeanDeserializerBuilder -{ - public SuperSonicDeserializerBuilder(BeanDeserializerBuilder base) { - super(base); - } - - @Override - public ValueDeserializer build() - { - BeanDeserializer deser = (BeanDeserializer) super.build(); - // only create custom one, if existing one is standard deserializer; - if (deser.getClass() == BeanDeserializer.class) { - BeanDeserializer beanDeser = (BeanDeserializer) deser; - Iterator it = getProperties(); - // also: only build custom one for non-empty beans: - if (it.hasNext()) { - // So let's find actual order of properties, necessary for optimal access - ArrayList props = new ArrayList(); - do { - props.add(it.next()); - } while (it.hasNext()); - return new SuperSonicBeanDeserializer(beanDeser, props); - } - } - return deser; - } -} diff --git a/blackbird/src/main/java/tools/jackson/module/blackbird/internal/GeneratedReaderBase.java b/blackbird/src/main/java/tools/jackson/module/blackbird/internal/GeneratedReaderBase.java new file mode 100644 index 000000000..050d4e751 --- /dev/null +++ b/blackbird/src/main/java/tools/jackson/module/blackbird/internal/GeneratedReaderBase.java @@ -0,0 +1,318 @@ +package tools.jackson.module.blackbird.internal; + +import java.lang.constant.ConstantDescs; +import java.lang.invoke.MethodHandles; +import java.util.Collection; +import java.util.Iterator; +import java.util.Map; +import java.util.Set; + +import tools.jackson.core.JsonParser; +import tools.jackson.databind.DeserializationConfig; +import tools.jackson.databind.DeserializationContext; +import tools.jackson.databind.DeserializationFeature; +import tools.jackson.databind.ValueDeserializer; +import tools.jackson.databind.deser.SettableBeanProperty; +import tools.jackson.databind.exc.IgnoredPropertyException; +import tools.jackson.databind.util.IgnorePropertiesUtil; +import tools.jackson.databind.deser.bean.BeanDeserializerBase; +import tools.jackson.databind.jsontype.TypeDeserializer; +import tools.jackson.databind.type.LogicalType; +import tools.jackson.databind.util.AccessPattern; +import tools.jackson.databind.util.ClassUtil; +import tools.jackson.databind.util.NameTransformer; + +/** + * Superclass for generated codecs: everything except the plain-object + * deserialize loop forwards to the stock deserializer the codec replaced, so + * typed (polymorphic) entry, null handling, and update-value reads keep stock + * semantics. + */ +public abstract class GeneratedReaderBase extends ValueDeserializer +{ + protected final BeanDeserializerBase _fallback; + + // Ignoral configuration mirrored from the stock deserializer's build: + // consulted by _handleUnknown exactly the way the stock loop consults + // _ignoreAllUnknown / _ignorableProps / _includableProps. + private final boolean _ignoreAllUnknown; + private final Set _ignorableProps; + private final Set _includableProps; + + protected GeneratedReaderBase(BeanDeserializerBase fallback) { + this(fallback, false, null, null); + } + + protected GeneratedReaderBase(BeanDeserializerBase fallback, boolean ignoreAllUnknown, + Set ignorableProps, Set includableProps) { + _fallback = fallback; + _ignoreAllUnknown = ignoreAllUnknown; + _ignorableProps = ignorableProps; + _includableProps = includableProps; + } + + @Override + public Object deserializeWithType(JsonParser p, DeserializationContext ctxt, + TypeDeserializer typeDeserializer) { + return _fallback.deserializeWithType(p, ctxt, typeDeserializer); + } + + @Override + public Object deserialize(JsonParser p, DeserializationContext ctxt, Object intoValue) { + return _fallback.deserialize(p, ctxt, intoValue); + } + + @Override + public Object getNullValue(DeserializationContext ctxt) { + return _fallback.getNullValue(ctxt); + } + + @Override + public Object getEmptyValue(DeserializationContext ctxt) { + return _fallback.getEmptyValue(ctxt); + } + + @Override + public Object getAbsentValue(DeserializationContext ctxt) { + return _fallback.getAbsentValue(ctxt); + } + + @Override + public AccessPattern getNullAccessPattern() { + return _fallback.getNullAccessPattern(); + } + + @Override + public AccessPattern getEmptyAccessPattern() { + return _fallback.getEmptyAccessPattern(); + } + + @Override + public Collection getKnownPropertyNames() { + return _fallback.getKnownPropertyNames(); + } + + @Override + public SettableBeanProperty findBackReference(String refName) { + return _fallback.findBackReference(refName); + } + + @Override + public Boolean supportsUpdate(DeserializationConfig config) { + return _fallback.supportsUpdate(config); + } + + @Override + public Class handledType() { + return _fallback.handledType(); + } + + @Override + public LogicalType logicalType() { + return _fallback.logicalType(); + } + + @Override + public boolean isCachable() { + return _fallback.isCachable(); + } + + // Unwrapping must produce the stock unwrapping variant: the generated loop + // reads one JSON object, which is not the shape an unwrapped value has. + @Override + public ValueDeserializer unwrappingDeserializer(DeserializationContext ctxt, + NameTransformer unwrapper) { + return _fallback.unwrappingDeserializer(ctxt, unwrapper); + } + + // Called by generated code when a property name was expected but the + // stream holds something else; reports through the context the way the + // stock deserializer does. + protected final Object _unexpectedToken(JsonParser p, DeserializationContext ctxt) { + return ctxt.handleUnexpectedToken(_fallback.handledType(), p); + } + + // Called from the generated exception handler that covers every property + // arm: mirrors the stock loop's wrapAndThrow so property errors carry the + // reference path. Always throws; the return type only satisfies the + // generated athrow. + protected final RuntimeException _propertyException(Throwable t, Object beanOrNull, + SettableBeanProperty prop, DeserializationContext ctxt) { + Object ref = (beanOrNull == null) ? _fallback.handledType() : beanOrNull; + throw _fallback.wrapAndThrow(t, ref, prop.getName(), ctxt); + } + + // Called by generated code for a name the matcher does not know: the + // stock loop's exact order - ignore-all skips silently, an explicitly + // ignored (or not-included) name goes through ignored-property handling, + // and everything else runs the problem handlers and honors + // FAIL_ON_UNKNOWN_PROPERTIES through the context. beanOrBuilder is null + // in record mode, where no instance exists yet. + protected final void _handleUnknown(JsonParser p, DeserializationContext ctxt, + Object beanOrBuilder) { + String name = p.currentName(); + p.nextToken(); + if (_ignoreAllUnknown) { + p.skipChildren(); + return; + } + Object ref = (beanOrBuilder == null) ? _fallback.handledType() : beanOrBuilder; + if (IgnorePropertiesUtil.shouldIgnore(name, _ignorableProps, _includableProps)) { + if (ctxt.isEnabled(DeserializationFeature.FAIL_ON_IGNORED_PROPERTIES)) { + throw IgnoredPropertyException.from(p, ref, name, + _fallback.getKnownPropertyNames()); + } + p.skipChildren(); + return; + } + ctxt.handleUnknownProperty(p, _fallback, ref, name); + } + + // Called by generated record codecs when the document ended with unseen + // components; mirrors PropertyValueBuffer's required and + // FAIL_ON_MISSING_CREATOR_PROPERTIES reporting. + protected final void _checkRecordSeen(DeserializationContext ctxt, long seen, int count) { + for (int i = 0; i < count; i++) { + if ((seen & (1L << i)) != 0) { + continue; + } + SettableBeanProperty prop = _creatorPropByIndex(i); + if (prop == null) { + continue; + } + if (prop.isRequired()) { + ctxt.reportInputMismatch(prop, + "Missing required creator property '%s' (index %d)", + prop.getName(), i); + } + if (ctxt.isEnabled(DeserializationFeature.FAIL_ON_MISSING_CREATOR_PROPERTIES)) { + ctxt.reportInputMismatch(prop, + "Missing creator property '%s' (index %d); `DeserializationFeature.FAIL_ON_MISSING_CREATOR_PROPERTIES` enabled", + prop.getName(), i); + } + } + } + + // Called by generated record codecs when any reference-typed component is + // null at construction (missing or explicit null): mirrors + // PropertyValueBuffer's FAIL_ON_NULL_CREATOR_PROPERTIES reporting. + protected final void _checkRecordNulls(DeserializationContext ctxt, long nullMask, int count) { + if (!ctxt.isEnabled(DeserializationFeature.FAIL_ON_NULL_CREATOR_PROPERTIES)) { + return; + } + for (int i = 0; i < count; i++) { + if ((nullMask & (1L << i)) == 0) { + continue; + } + SettableBeanProperty prop = _creatorPropByIndex(i); + if (prop != null) { + ctxt.reportInputMismatch(prop, + "Null value for creator property '%s' (index %d); `DeserializationFeature.FAIL_ON_NULL_CREATOR_PROPERTIES` enabled", + prop.getName(), i); + } + } + } + + private SettableBeanProperty _creatorPropByIndex(int index) { + for (Iterator it = _fallback.properties(); it.hasNext(); ) { + SettableBeanProperty prop = it.next(); + if (prop.getCreatorIndex() == index) { + return prop; + } + } + return null; + } + + /* + /********************************************************************** + /* Support for generated code + /********************************************************************** + */ + + // Constant bootstrap for generated code: resolves one named entry of the + // hidden class's class data, a Map built at generation time. The JDK's + // classDataAt bootstrap requires the condy name to be "_", so named + // entries need this owner, which generated classes can always resolve + // (the package is exported exactly for their supertype needs). A condy + // links once and then constant-folds the same as classDataAt; the names + // exist for dump readability and to remove positional-index bookkeeping + // from the generators. + public static Object classDataEntry(MethodHandles.Lookup lookup, String name, Class type) + throws IllegalAccessException { + Map data = MethodHandles.classData(lookup, ConstantDescs.DEFAULT_NAME, Map.class); + Object value = data.get(name); + if (value == null) { + throw new IllegalStateException("no class data entry named " + name); + } + return type.cast(value); + } + + /* + /********************************************************************** + /* View support: per-view visibility masks + /********************************************************************** + */ + + private static final Class[] NO_VIEWS = new Class[0]; + private static final long[] NO_MASKS = new long[0]; + + // Copy-on-write cache of view -> arm-visibility bitmask. View sets are + // small and stable per application, so lookups are a reference scan. + // Instance state only: the cached view classes unload with the codec and + // its mapper. + private volatile Class[] _maskViews = NO_VIEWS; + private volatile long[] _masks = NO_MASKS; + + // Called by generated code once per view-active call. + protected final long _viewMask(Class view) { + Class[] views = _maskViews; + for (int i = 0; i < views.length; i++) { + if (views[i] == view) { + return _masks[i]; + } + } + return _addViewMask(view); + } + + private synchronized long _addViewMask(Class view) { + Class[] views = _maskViews; + for (int i = 0; i < views.length; i++) { + if (views[i] == view) { + return _masks[i]; + } + } + long mask = _computeViewMask(view); + Class[] newViews = new Class[views.length + 1]; + long[] newMasks = new long[views.length + 1]; + System.arraycopy(views, 0, newViews, 0, views.length); + System.arraycopy(_masks, 0, newMasks, 0, views.length); + newViews[views.length] = view; + newMasks[views.length] = mask; + _masks = newMasks; + _maskViews = newViews; + return mask; + } + + // Overridden by generated codecs that filter per view: bit i reports + // whether property arm i is visible in the given view. Codecs that + // delegate view-active calls whole (more than 64 properties) never call + // the mask machinery. + protected long _computeViewMask(Class view) { + throw new UnsupportedOperationException("codec has no view mask"); + } + + // Called by generated code for an arm hidden in the active view, with the + // parser advanced to the value token; mirrors the stock loop's + // handleUnexpectedView (message included), then consumes the value. + protected final void _hiddenView(JsonParser p, DeserializationContext ctxt, + SettableBeanProperty prop) { + if (ctxt.isEnabled(DeserializationFeature.FAIL_ON_UNEXPECTED_VIEW_PROPERTIES)) { + ctxt.reportInputMismatch(_fallback.handledType(), + "Input mismatch while deserializing %s. Property '%s' is not part of current active view '%s'" + + " (disable 'DeserializationFeature.FAIL_ON_UNEXPECTED_VIEW_PROPERTIES' to allow)", + ClassUtil.nameOf(_fallback.handledType()), prop.getName(), + ctxt.getActiveView().getName()); + } + p.skipChildren(); + } +} diff --git a/blackbird/src/main/java/tools/jackson/module/blackbird/internal/GeneratedWriterBase.java b/blackbird/src/main/java/tools/jackson/module/blackbird/internal/GeneratedWriterBase.java new file mode 100644 index 000000000..ad2d47e30 --- /dev/null +++ b/blackbird/src/main/java/tools/jackson/module/blackbird/internal/GeneratedWriterBase.java @@ -0,0 +1,155 @@ +package tools.jackson.module.blackbird.internal; + +import java.lang.constant.ConstantDescs; +import java.lang.invoke.MethodHandles; +import java.util.Map; + +import tools.jackson.core.JsonGenerator; +import tools.jackson.databind.JavaType; +import tools.jackson.databind.SerializationContext; +import tools.jackson.databind.ValueSerializer; +import tools.jackson.databind.jsonFormatVisitors.JsonFormatVisitorWrapper; +import tools.jackson.databind.jsontype.TypeSerializer; +import tools.jackson.databind.ser.BeanPropertyWriter; +import tools.jackson.databind.ser.PropertyWriter; +import tools.jackson.databind.ser.bean.BeanSerializerBase; +import tools.jackson.databind.util.NameTransformer; + +/** + * Superclass for generated serializers: everything except the plain-object + * serialize path forwards to the stock serializer the codec replaced. + */ +public abstract class GeneratedWriterBase extends ValueSerializer +{ + protected final BeanSerializerBase _fallback; + + protected GeneratedWriterBase(BeanSerializerBase fallback) { + _fallback = fallback; + } + + @Override + public void serializeWithType(Object value, JsonGenerator g, SerializationContext ctxt, + TypeSerializer typeSer) { + _fallback.serializeWithType(value, g, ctxt, typeSer); + } + + @Override + public boolean isEmpty(SerializationContext ctxt, Object value) { + return _fallback.isEmpty(ctxt, value); + } + + @Override + public boolean usesObjectId() { + return _fallback.usesObjectId(); + } + + @Override + public Class handledType() { + return _fallback.handledType(); + } + + // Unwrapping must produce the stock unwrapping variant: the generated + // writer emits one JSON object, which is not the shape an unwrapped value + // has. + @Override + public ValueSerializer unwrappingSerializer(NameTransformer unwrapper) { + return _fallback.unwrappingSerializer(unwrapper); + } + + @Override + public void acceptJsonFormatVisitor(JsonFormatVisitorWrapper visitor, JavaType type) { + _fallback.acceptJsonFormatVisitor(visitor, type); + } + + /* + /********************************************************************** + /* Support for generated code + /********************************************************************** + */ + + // Constant bootstrap for generated code: resolves one named entry of the + // hidden class's class data, a Map built at generation time. The JDK's + // classDataAt bootstrap requires the condy name to be "_", so named + // entries need this owner, which generated classes can always resolve + // (the package is exported exactly for their supertype needs). A condy + // links once and then constant-folds the same as classDataAt; the names + // exist for dump readability and to remove positional-index bookkeeping + // from the generators. + public static Object classDataEntry(MethodHandles.Lookup lookup, String name, Class type) + throws IllegalAccessException { + Map data = MethodHandles.classData(lookup, ConstantDescs.DEFAULT_NAME, Map.class); + Object value = data.get(name); + if (value == null) { + throw new IllegalStateException("no class data entry named " + name); + } + return type.cast(value); + } + + /* + /********************************************************************** + /* View support: per-view visibility masks + /********************************************************************** + */ + + private static final Class[] NO_VIEWS = new Class[0]; + private static final long[] NO_MASKS = new long[0]; + + // Copy-on-write cache of view -> property-visibility bitmask; same shape + // and rationale as GeneratedReaderBase. Instance state only, so cached view + // classes unload with the writer and its mapper. + private volatile Class[] _maskViews = NO_VIEWS; + private volatile long[] _masks = NO_MASKS; + + protected final long _viewMask(Class view) { + Class[] views = _maskViews; + for (int i = 0; i < views.length; i++) { + if (views[i] == view) { + return _masks[i]; + } + } + return _addViewMask(view); + } + + private synchronized long _addViewMask(Class view) { + Class[] views = _maskViews; + for (int i = 0; i < views.length; i++) { + if (views[i] == view) { + return _masks[i]; + } + } + long mask = _computeViewMask(view); + Class[] newViews = new Class[views.length + 1]; + long[] newMasks = new long[views.length + 1]; + System.arraycopy(views, 0, newViews, 0, views.length); + System.arraycopy(_masks, 0, newMasks, 0, views.length); + newViews[views.length] = view; + newMasks[views.length] = mask; + _masks = newMasks; + _maskViews = newViews; + return mask; + } + + // Overridden by generated writers that filter per view: bit i reports + // whether property i is visible in the given view. + protected long _computeViewMask(Class view) { + throw new UnsupportedOperationException("writer has no view mask"); + } + + // Visibility of one property in a view, matching the rule the stock + // factory uses to build the filtered writer array: no view annotations + // means DEFAULT_VIEW_INCLUSION decides (captured at generation time), + // otherwise any declared view assignable from the active view. + protected static boolean _propVisible(PropertyWriter w, Class view, + boolean includeByDefault) { + Class[] views = (w instanceof BeanPropertyWriter bpw) ? bpw.getViews() : null; + if (views == null || views.length == 0) { + return includeByDefault; + } + for (Class v : views) { + if (v.isAssignableFrom(view)) { + return true; + } + } + return false; + } +} diff --git a/blackbird/src/main/java/tools/jackson/module/blackbird/internal/package-info.java b/blackbird/src/main/java/tools/jackson/module/blackbird/internal/package-info.java new file mode 100644 index 000000000..11a8124ec --- /dev/null +++ b/blackbird/src/main/java/tools/jackson/module/blackbird/internal/package-info.java @@ -0,0 +1,8 @@ +/** + * Internal supertypes of Blackbird's generated hidden classes. This package + * is exported only because a hidden codec defined in a user module's context + * must resolve its supertype from there; it is internal API with no + * compatibility guarantees, and user code must not extend or call these + * types. + */ +package tools.jackson.module.blackbird.internal; diff --git a/blackbird/src/main/java/tools/jackson/module/blackbird/ser/BBSerializerModifier.java b/blackbird/src/main/java/tools/jackson/module/blackbird/ser/BBSerializerModifier.java index 4644a0aee..1d19bd837 100644 --- a/blackbird/src/main/java/tools/jackson/module/blackbird/ser/BBSerializerModifier.java +++ b/blackbird/src/main/java/tools/jackson/module/blackbird/ser/BBSerializerModifier.java @@ -1,168 +1,65 @@ package tools.jackson.module.blackbird.ser; -import java.lang.invoke.LambdaMetafactory; -import java.lang.invoke.MethodHandle; import java.lang.invoke.MethodHandles; -import java.lang.invoke.MethodHandles.Lookup; -import java.lang.reflect.Member; -import java.lang.reflect.Method; import java.lang.reflect.Modifier; -import java.util.*; import java.util.function.Function; -import java.util.function.ToIntFunction; -import java.util.function.ToLongFunction; -import java.util.function.UnaryOperator; - -import tools.jackson.databind.*; -import tools.jackson.databind.annotation.JacksonStdImpl; -import tools.jackson.databind.introspect.AnnotatedMember; -import tools.jackson.databind.introspect.AnnotatedMethod; -import tools.jackson.databind.ser.*; -import tools.jackson.module.blackbird.util.ReflectionHack; -import tools.jackson.module.blackbird.util.Unchecked; - -import static java.lang.invoke.MethodType.*; +import tools.jackson.databind.BeanDescription; +import tools.jackson.databind.SerializationConfig; +import tools.jackson.databind.ValueSerializer; +import tools.jackson.databind.ser.BeanSerializer; +import tools.jackson.databind.ser.UnrolledBeanSerializer; +import tools.jackson.databind.ser.ValueSerializerModifier; +import tools.jackson.databind.ser.bean.BeanSerializerBase; +import tools.jackson.module.blackbird.codegen.CodegenFallbacks; + +/** + * Wraps eligible stock bean serializers in a placeholder that generates a + * per-bean writer at resolve time. Every gate that fails leaves the stock + * serializer in place. + */ public class BBSerializerModifier extends ValueSerializerModifier { private static final long serialVersionUID = 1L; - private final Function, Lookup> _lookups; - private final UnaryOperator _accessGrant; + // Reserved for member access beyond public API; the v1 writer generator + // only touches public getters. + private final Function, MethodHandles.Lookup> _lookups; - public BBSerializerModifier(Function, MethodHandles.Lookup> lookups, UnaryOperator accessGrant) - { + public BBSerializerModifier(Function, MethodHandles.Lookup> lookups) { _lookups = lookups; - _accessGrant = accessGrant; } @Override - public List changeProperties(SerializationConfig config, - BeanDescription.Supplier beanDescRef, List beanProperties) + public ValueSerializer modifySerializer(SerializationConfig config, + BeanDescription.Supplier beanDescRef, ValueSerializer serializer) { - final Class beanClass = beanDescRef.getBeanClass(); - - /* Hmmh. Can we access stuff from private classes? - * Possibly, if we can use parent class loader. - * (should probably skip all non-public?) - */ - if (Modifier.isPrivate(beanClass.getModifiers())) { // TODO? - return beanProperties; + // Gate failures of any kind leave the stock serializer in place. The + // reflective gates can throw for exotic classes (a bean from a foreign + // classloader with inconsistent InnerClasses metadata raises + // IncompatibleClassChangeError from getEnclosingClass), and an + // acceleration modifier must never break a bean stock databind handles. + try { + return doModify(beanDescRef, serializer); + } catch (RuntimeException | LinkageError e) { + CodegenFallbacks.gateFailure(beanDescRef.getBeanClass(), e); + return serializer; } - - findProperties(beanClass, config, beanProperties); - return beanProperties; } - protected void findProperties(Class beanClass, - SerializationConfig config, List beanProperties) + private ValueSerializer doModify(BeanDescription.Supplier beanDescRef, + ValueSerializer serializer) { - MethodHandles.Lookup lookup = _lookups.apply(beanClass); - if (lookup == null) { - return; - } - - ListIterator it = beanProperties.listIterator(); - while (it.hasNext()) { - Unchecked.runnable(() -> - createProperty(it, ReflectionHack.privateLookupIn(beanClass, lookup), config)) - .run(); - } - } - - protected void createProperty(ListIterator it, Lookup lookup, SerializationConfig config) throws Throwable { - BeanPropertyWriter bpw = it.next(); - AnnotatedMember member = bpw.getMember(); - - Member jdkMember = member.getMember(); - // 11-Sep-2015, tatu: Let's skip virtual members (related to #57) - if (jdkMember == null) { - return; - } - // We can't access private fields or methods, skip: - if (Modifier.isPrivate(jdkMember.getModifiers())) { - return; - } - // (although, interestingly enough, can seem to access private classes...) - - // 30-Jul-2012, tatu: [#6]: Needs to skip custom serializers, if any. - if (bpw.hasSerializer() && !SerializerUtil.isDefaultSerializer(bpw.getSerializer())) { - return; - } - // [#9]: also skip unwrapping stuff... - if (bpw.isUnwrapping()) { - return; + if (serializer.getClass() != BeanSerializer.class + && serializer.getClass() != UnrolledBeanSerializer.class) { + return serializer; } - - if (!bpw.getClass().isAnnotationPresent(JacksonStdImpl.class)) { - return; - } - - Class type = bpw.getMember().getRawType(); - MethodHandle getter; - if (member instanceof AnnotatedMethod) { - getter = lookup.unreflect((Method) member.getMember()); - } else { - // TODO: currently VarHandles aren't considered direct MH - return; - //getter = lookup.unreflectGetter((Field) member.getMember()); - } - lookup = _accessGrant.apply(lookup); - - if (type.isPrimitive()) { - if (type == Integer.TYPE) { - ToIntFunction accessor = (ToIntFunction) LambdaMetafactory.metafactory( - lookup, - "applyAsInt", - methodType(ToIntFunction.class), - methodType(int.class, Object.class), - getter, - getter.type()) - .getTarget().invokeExact(); - it.set(new IntPropertyWriter(bpw, accessor, null)); - } else if (type == Long.TYPE) { - ToLongFunction accessor = (ToLongFunction) LambdaMetafactory.metafactory( - lookup, - "applyAsLong", - methodType(ToLongFunction.class), - methodType(long.class, Object.class), - getter, - getter.type()) - .getTarget().invokeExact(); - it.set(new LongPropertyWriter(bpw, accessor, null)); - } else if (type == Boolean.TYPE) { - ToBooleanFunction accessor = (ToBooleanFunction) LambdaMetafactory.metafactory( - lookup, - "applyAsBoolean", - methodType(ToBooleanFunction.class), - methodType(boolean.class, Object.class), - getter, - getter.type()) - .getTarget().invokeExact(); - it.set(new BooleanPropertyWriter(bpw, accessor, null)); - } - } else { - if (type == String.class) { - Function accessor = (Function) LambdaMetafactory.metafactory( - lookup, - "apply", - methodType(Function.class), - methodType(Object.class, Object.class), - getter, - getter.type()) - .getTarget().invokeExact(); - it.set(new StringPropertyWriter(bpw, accessor, null)); - } else { - Function accessor = (Function) LambdaMetafactory.metafactory( - lookup, - "apply", - methodType(Function.class), - methodType(Object.class, Object.class), - getter, - getter.type()) - .getTarget().invokeExact(); - it.set(new ObjectPropertyWriter(bpw, accessor, null)); - } + Class beanClass = beanDescRef.getBeanClass(); + if (Modifier.isPrivate(beanClass.getModifiers()) + || (beanClass.getEnclosingClass() != null + && !Modifier.isStatic(beanClass.getModifiers()))) { + return serializer; } + return new BBWriterPlaceholder((BeanSerializerBase) serializer, _lookups); } } diff --git a/blackbird/src/main/java/tools/jackson/module/blackbird/ser/BBWriterFactory.java b/blackbird/src/main/java/tools/jackson/module/blackbird/ser/BBWriterFactory.java new file mode 100644 index 000000000..6d704ecf3 --- /dev/null +++ b/blackbird/src/main/java/tools/jackson/module/blackbird/ser/BBWriterFactory.java @@ -0,0 +1,215 @@ +package tools.jackson.module.blackbird.ser; + +import java.lang.invoke.MethodHandles; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Set; +import java.util.function.Function; + +import tools.jackson.databind.SerializationContext; +import tools.jackson.databind.ValueSerializer; +import tools.jackson.databind.introspect.AnnotatedField; +import tools.jackson.databind.introspect.AnnotatedMethod; +import tools.jackson.databind.ser.BeanPropertyWriter; +import tools.jackson.databind.ser.PropertyWriter; +import tools.jackson.databind.ser.bean.BeanSerializerBase; +import tools.jackson.databind.MapperFeature; +import tools.jackson.module.blackbird.codegen.BeanReaderGenerator.ViewStrategy; +import tools.jackson.module.blackbird.codegen.BeanWriterGenerator; +import tools.jackson.module.blackbird.codegen.CodecAccess; +import tools.jackson.module.blackbird.codegen.BeanWriterGenerator.GenWProp; +import tools.jackson.module.blackbird.codegen.BeanWriterGenerator.WKind; +import tools.jackson.module.blackbird.internal.GeneratedWriterBase; +import tools.jackson.module.blackbird.codegen.CodegenFallbacks; + +/** + * Builds a generated serializer from a resolved stock bean serializer, or + * returns null when the bean does not qualify. Same philosophy as the + * deserializer side: anything not cheaply verifiable rides the stock + * PropertyWriter from generated code. + */ +final class BBWriterFactory +{ + private static final Set STOCK_SCALAR_SERS = Set.of( + "tools.jackson.databind.ser.jdk.StringSerializer", + "tools.jackson.databind.ser.jdk.NumberSerializers$IntegerSerializer", + "tools.jackson.databind.ser.jdk.NumberSerializers$LongSerializer", + "tools.jackson.databind.ser.jdk.BooleanSerializer"); + + private BBWriterFactory() {} + + static ValueSerializer tryGenerate(BeanSerializerBase delegate, + SerializationContext ctxt, Function, MethodHandles.Lookup> lookups) { + try { + return generate(delegate, ctxt, lookups); + } catch (Throwable t) { + CodegenFallbacks.generationFailure(delegate.handledType(), t); + return null; + } + } + + private static ValueSerializer generate(BeanSerializerBase delegate, + SerializationContext ctxt, Function, MethodHandles.Lookup> lookups) + throws ReflectiveOperationException { + if (delegate.usesObjectId() || delegate.getFilterId() != null) { + return null; + } + Class beanClass = delegate.handledType(); + if (Modifier.isPrivate(beanClass.getModifiers())) { + return null; + } + if (!visibleToGenerator(beanClass)) { + return null; + } + MethodHandles.Lookup defineLookup = CodecAccess.defineContext(beanClass, lookups); + if (!Modifier.isPublic(beanClass.getModifiers()) && defineLookup == null) { + return null; + } + Class anchor = defineLookup == null ? null : defineLookup.lookupClass(); + List props = new ArrayList<>(); + for (Iterator it = delegate.properties(); it.hasNext(); ) { + PropertyWriter writer = it.next(); + props.add(classify(writer, beanClass, anchor)); + } + if (props.isEmpty()) { + return null; + } + boolean includeByDefault = ctxt.isEnabled(MapperFeature.DEFAULT_VIEW_INCLUSION); + return BeanWriterGenerator.generate(beanClass, props, delegate, defineLookup, + viewStrategy(props, includeByDefault), includeByDefault); + } + + // Matches the stock filtered-writer-array rule. No property declares a + // view: with default inclusion on, stock ignores views entirely (NONE, no + // view code); with it off, an active view hides every property, so the + // rare view-active call DELEGATEs to stock's write-nothing path and the + // no-view hot path stays free of per-property tests. Properties with + // views take MASK (fast path under views) up to 64 properties, DELEGATE + // beyond. Visibility is read per property from BeanPropertyWriter.getViews. + private static ViewStrategy viewStrategy(List props, boolean includeByDefault) { + boolean viewsFound = false; + for (GenWProp p : props) { + if (p.stock() instanceof BeanPropertyWriter bpw + && bpw.getViews() != null && bpw.getViews().length > 0) { + viewsFound = true; + break; + } + } + if (!viewsFound) { + return includeByDefault ? ViewStrategy.NONE : ViewStrategy.DELEGATE; + } + return (props.size() > 64) ? ViewStrategy.DELEGATE : ViewStrategy.MASK; + } + + private static GenWProp classify(PropertyWriter writer, Class beanClass, + Class anchor) { + if (writer.getClass() != BeanPropertyWriter.class) { + return stock(writer); + } + BeanPropertyWriter bpw = (BeanPropertyWriter) writer; + if (bpw.willSuppressNulls() || SuppressionProbe.hasSuppressableValue(bpw)) { + return stock(writer); + } + ValueSerializer valueSer = bpw.getSerializer(); + if (bpw.getMember() instanceof AnnotatedMethod am) { + return classifyGetter(writer, beanClass, bpw, am.getAnnotated(), valueSer, anchor); + } + if (bpw.getMember() instanceof AnnotatedField af) { + return classifyField(writer, bpw, af.getAnnotated(), valueSer, anchor); + } + return stock(writer); + } + + private static GenWProp classifyGetter(PropertyWriter writer, Class beanClass, + BeanPropertyWriter bpw, Method getter, ValueSerializer valueSer, + Class anchor) { + if (getter == null || getter.getParameterCount() != 0 + || !CodecAccess.directlyAccessible(getter.getModifiers(), + getter.getDeclaringClass(), anchor) + || getter.getDeclaringClass() != beanClass) { + return stock(writer); + } + if (valueSer instanceof GeneratedWriterBase child + && !getter.getReturnType().isPrimitive()) { + return new GenWProp(WKind.CHILD, getter, writer, bpw.getSerializedName(), child); + } + WKind kind = scalarKind(getter.getReturnType()); + if (kind == null || valueSer == null + || !STOCK_SCALAR_SERS.contains(valueSer.getClass().getName())) { + return stock(writer); + } + return new GenWProp(kind, getter, writer, bpw.getSerializedName(), null); + } + + // Public fields (any finality - reads are unrestricted) load through a + // generated getfield. Non-public fields have no generated read path on the + // serializer side and stay on the stock writer, the same limitation + // non-public getters have. + private static GenWProp classifyField(PropertyWriter writer, BeanPropertyWriter bpw, + Field field, ValueSerializer valueSer, Class anchor) { + if (field == null || Modifier.isStatic(field.getModifiers()) + || !CodecAccess.directlyAccessible(field.getModifiers(), + field.getDeclaringClass(), anchor)) { + return stock(writer); + } + Class raw = field.getType(); + if (valueSer instanceof GeneratedWriterBase child && !raw.isPrimitive()) { + return new GenWProp(WKind.CHILD, null, writer, bpw.getSerializedName(), child, field); + } + WKind kind = scalarKind(raw); + if (kind == null || valueSer == null + || !STOCK_SCALAR_SERS.contains(valueSer.getClass().getName())) { + return stock(writer); + } + return new GenWProp(kind, null, writer, bpw.getSerializedName(), null, field); + } + + // BeanPropertyWriter keeps its include filter in a protected field with no + // accessor; the copy constructor carries it into this probe, whose own + // inherited field is readable. Custom includes (JsonInclude.Include.CUSTOM + // and friends) must ride the stock writer. + private static final class SuppressionProbe extends BeanPropertyWriter { + private SuppressionProbe(BeanPropertyWriter base) { + super(base); + } + + static boolean hasSuppressableValue(BeanPropertyWriter base) { + return new SuppressionProbe(base)._suppressableValue != null; + } + } + + private static GenWProp stock(PropertyWriter writer) { + return new GenWProp(WKind.STOCK, null, writer, null, null); + } + + // Rationale in BBReaderFactory.visibleToGenerator: generated code refers to + // the bean class by name, resolved through this module's loader. + private static boolean visibleToGenerator(Class cls) { + try { + return Class.forName(cls.getName(), false, + GeneratedWriterBase.class.getClassLoader()) == cls; + } catch (ClassNotFoundException | LinkageError e) { + return false; + } + } + + private static WKind scalarKind(Class raw) { + if (raw == String.class) { + return WKind.STRING; + } + if (raw == int.class) { + return WKind.INT; + } + if (raw == long.class) { + return WKind.LONG; + } + if (raw == boolean.class) { + return WKind.BOOLEAN; + } + return null; + } +} diff --git a/blackbird/src/main/java/tools/jackson/module/blackbird/ser/BBWriterPlaceholder.java b/blackbird/src/main/java/tools/jackson/module/blackbird/ser/BBWriterPlaceholder.java new file mode 100644 index 000000000..147841118 --- /dev/null +++ b/blackbird/src/main/java/tools/jackson/module/blackbird/ser/BBWriterPlaceholder.java @@ -0,0 +1,93 @@ +package tools.jackson.module.blackbird.ser; + +import java.lang.invoke.MethodHandles; +import java.util.function.Function; + +import tools.jackson.core.JsonGenerator; +import tools.jackson.databind.BeanProperty; +import tools.jackson.databind.JavaType; +import tools.jackson.databind.SerializationContext; +import tools.jackson.databind.ValueSerializer; +import tools.jackson.databind.jsontype.TypeSerializer; +import tools.jackson.databind.jsonFormatVisitors.JsonFormatVisitorWrapper; +import tools.jackson.databind.ser.bean.BeanSerializerBase; +import tools.jackson.databind.util.NameTransformer; + +/** + * Stands in for a stock bean serializer until resolution, then hands out the + * generated writer through contextualization; anything that keeps the + * placeholder still works through delegation. + */ +final class BBWriterPlaceholder extends ValueSerializer +{ + private final BeanSerializerBase _delegate; + + private final Function, MethodHandles.Lookup> _lookups; + + private volatile ValueSerializer _codec; + + BBWriterPlaceholder(BeanSerializerBase delegate, + Function, MethodHandles.Lookup> lookups) { + _delegate = delegate; + _lookups = lookups; + } + + @Override + public void resolve(SerializationContext ctxt) { + _delegate.resolve(ctxt); + _codec = BBWriterFactory.tryGenerate(_delegate, ctxt, _lookups); + } + + @Override + public ValueSerializer createContextual(SerializationContext ctxt, BeanProperty property) { + ValueSerializer contextual = _delegate.createContextual(ctxt, property); + if (contextual != _delegate) { + return contextual; + } + ValueSerializer codec = _codec; + return (codec != null) ? codec : this; + } + + @Override + public void serialize(Object value, JsonGenerator g, SerializationContext ctxt) { + ValueSerializer codec = _codec; + if (codec != null) { + codec.serialize(value, g, ctxt); + } else { + _delegate.serialize(value, g, ctxt); + } + } + + @Override + public void serializeWithType(Object value, JsonGenerator g, SerializationContext ctxt, + TypeSerializer typeSer) { + _delegate.serializeWithType(value, g, ctxt, typeSer); + } + + @Override + public boolean isEmpty(SerializationContext ctxt, Object value) { + return _delegate.isEmpty(ctxt, value); + } + + @Override + public boolean usesObjectId() { + return _delegate.usesObjectId(); + } + + @Override + public Class handledType() { + return _delegate.handledType(); + } + + // Unwrapping must produce the stock unwrapping variant, never the codec + // (rationale in GeneratedWriterBase.unwrappingSerializer). + @Override + public ValueSerializer unwrappingSerializer(NameTransformer unwrapper) { + return _delegate.unwrappingSerializer(unwrapper); + } + + @Override + public void acceptJsonFormatVisitor(JsonFormatVisitorWrapper visitor, JavaType type) { + _delegate.acceptJsonFormatVisitor(visitor, type); + } +} diff --git a/blackbird/src/main/java/tools/jackson/module/blackbird/ser/BooleanPropertyWriter.java b/blackbird/src/main/java/tools/jackson/module/blackbird/ser/BooleanPropertyWriter.java deleted file mode 100644 index 7ebdf15b2..000000000 --- a/blackbird/src/main/java/tools/jackson/module/blackbird/ser/BooleanPropertyWriter.java +++ /dev/null @@ -1,97 +0,0 @@ -package tools.jackson.module.blackbird.ser; - -import tools.jackson.core.JsonGenerator; -import tools.jackson.databind.ValueSerializer; -import tools.jackson.databind.PropertyName; -import tools.jackson.databind.SerializationContext; -import tools.jackson.databind.ser.BeanPropertyWriter; - -final class BooleanPropertyWriter - extends OptimizedBeanPropertyWriter -{ - private static final long serialVersionUID = 1L; - - private final boolean _suppressableSet; - private final boolean _suppressableBoolean; - - private final ToBooleanFunction _acc; - - public BooleanPropertyWriter(BeanPropertyWriter src, ToBooleanFunction acc, ValueSerializer ser) { - super(src, ser); - _acc = acc; - - if (_suppressableValue instanceof Boolean) { - _suppressableBoolean = ((Boolean)_suppressableValue).booleanValue(); - _suppressableSet = true; - } else { - _suppressableBoolean = false; - _suppressableSet = false; - } - } - - protected BooleanPropertyWriter(BooleanPropertyWriter base, PropertyName name) { - super(base, name); - _suppressableSet = base._suppressableSet; - _suppressableBoolean = base._suppressableBoolean; - _acc = base._acc; - } - - @Override - protected BeanPropertyWriter _new(PropertyName newName) { - return new BooleanPropertyWriter(this, newName); - } - - @Override - public BeanPropertyWriter withSerializer(ValueSerializer ser) { - return new BooleanPropertyWriter(this, _acc, ser); - } - - /* - /********************************************************************** - /* Overrides - /********************************************************************** - */ - - @Override - public final void serializeAsProperty(Object bean, JsonGenerator g, SerializationContext ctxt) - throws Exception - { - if (broken) { - fallbackWriter.serializeAsProperty(bean, g, ctxt); - return; - } - boolean value; - try { - value = _acc.applyAsBoolean(bean); - } catch (Throwable t) { - _handleProblem(bean, g, ctxt, t, false); - return; - } - if (!_suppressableSet || _suppressableBoolean != value) { - g.writeName(_fastName); - g.writeBoolean(value); - } - } - - @Override - public final void serializeAsElement(Object bean, JsonGenerator g, SerializationContext ctxt) - throws Exception - { - if (broken) { - fallbackWriter.serializeAsElement(bean, g, ctxt); - return; - } - boolean value; - try { - value = _acc.applyAsBoolean(bean); - } catch (Throwable t) { - _handleProblem(bean, g, ctxt, t, true); - return; - } - if (!_suppressableSet || _suppressableBoolean != value) { - g.writeBoolean(value); - } else { // important: MUST output a placeholder - serializeAsOmittedElement(bean, g, ctxt); - } - } -} diff --git a/blackbird/src/main/java/tools/jackson/module/blackbird/ser/IntPropertyWriter.java b/blackbird/src/main/java/tools/jackson/module/blackbird/ser/IntPropertyWriter.java deleted file mode 100644 index 078d80035..000000000 --- a/blackbird/src/main/java/tools/jackson/module/blackbird/ser/IntPropertyWriter.java +++ /dev/null @@ -1,99 +0,0 @@ -package tools.jackson.module.blackbird.ser; - -import java.util.function.ToIntFunction; - -import tools.jackson.core.JsonGenerator; -import tools.jackson.databind.ValueSerializer; -import tools.jackson.databind.PropertyName; -import tools.jackson.databind.SerializationContext; -import tools.jackson.databind.ser.BeanPropertyWriter; - -final class IntPropertyWriter - extends OptimizedBeanPropertyWriter -{ - private static final long serialVersionUID = 1L; - - private final int _suppressableInt; - private final boolean _suppressableIntSet; - - private final ToIntFunction _acc; - - public IntPropertyWriter(BeanPropertyWriter src, ToIntFunction acc, ValueSerializer ser) { - super(src, ser); - _acc = acc; - - if (_suppressableValue instanceof Integer) { - _suppressableInt = (Integer)_suppressableValue; - _suppressableIntSet = true; - } else { - _suppressableInt = 0; - _suppressableIntSet = false; - } - } - - protected IntPropertyWriter(IntPropertyWriter base, PropertyName name) { - super(base, name); - _suppressableInt = base._suppressableInt; - _suppressableIntSet = base._suppressableIntSet; - _acc = base._acc; - } - - @Override - protected BeanPropertyWriter _new(PropertyName newName) { - return new IntPropertyWriter(this, newName); - } - - @Override - public BeanPropertyWriter withSerializer(ValueSerializer ser) { - return new IntPropertyWriter(this, _acc, ser); - } - - /* - /********************************************************************** - /* Overrides - /********************************************************************** - */ - - @Override - public final void serializeAsProperty(Object bean, JsonGenerator g, SerializationContext ctxt) - throws Exception - { - if (broken) { - fallbackWriter.serializeAsProperty(bean, g, ctxt); - return; - } - int value; - try { - value = _acc.applyAsInt(bean); - } catch (Throwable t) { - _handleProblem(bean, g, ctxt, t, false); - return; - } - if (!_suppressableIntSet || _suppressableInt != value) { - g.writeName(_fastName); - g.writeNumber(value); - } - } - - @Override - public final void serializeAsElement(Object bean, JsonGenerator g, SerializationContext ctxt) - throws Exception - { - if (broken) { - fallbackWriter.serializeAsElement(bean, g, ctxt); - return; - } - int value; - try { - value = _acc.applyAsInt(bean); - } catch (Throwable t) { - _handleProblem(bean, g, ctxt, t, true); - return; - } - if (!_suppressableIntSet || _suppressableInt != value) { - g.writeNumber(value); - } else { // important: MUST output a placeholder - serializeAsOmittedElement(bean, g, ctxt); - } - } -} diff --git a/blackbird/src/main/java/tools/jackson/module/blackbird/ser/LongPropertyWriter.java b/blackbird/src/main/java/tools/jackson/module/blackbird/ser/LongPropertyWriter.java deleted file mode 100644 index 02db15de6..000000000 --- a/blackbird/src/main/java/tools/jackson/module/blackbird/ser/LongPropertyWriter.java +++ /dev/null @@ -1,99 +0,0 @@ -package tools.jackson.module.blackbird.ser; - -import java.util.function.ToLongFunction; - -import tools.jackson.core.JsonGenerator; -import tools.jackson.databind.ValueSerializer; -import tools.jackson.databind.PropertyName; -import tools.jackson.databind.SerializationContext; -import tools.jackson.databind.ser.BeanPropertyWriter; - -final class LongPropertyWriter - extends OptimizedBeanPropertyWriter -{ - private static final long serialVersionUID = 1L; - - private final long _suppressableLong; - private final boolean _suppressableSet; - - private final ToLongFunction _acc; - - public LongPropertyWriter(BeanPropertyWriter src, ToLongFunction acc, ValueSerializer ser) { - super(src, ser); - _acc = acc; - - if (_suppressableValue instanceof Long) { - _suppressableLong = (Long)_suppressableValue; - _suppressableSet = true; - } else { - _suppressableLong = 0L; - _suppressableSet = false; - } - } - - protected LongPropertyWriter(LongPropertyWriter base, PropertyName name) { - super(base, name); - _suppressableSet = base._suppressableSet; - _suppressableLong = base._suppressableLong; - _acc = base._acc; - } - - @Override - protected BeanPropertyWriter _new(PropertyName newName) { - return new LongPropertyWriter(this, newName); - } - - @Override - public BeanPropertyWriter withSerializer(ValueSerializer ser) { - return new LongPropertyWriter(this, _acc, ser); - } - - /* - /********************************************************************** - /* Overrides - /********************************************************************** - */ - - @Override - public final void serializeAsProperty(Object bean, JsonGenerator g, SerializationContext ctxt) - throws Exception - { - if (broken) { - fallbackWriter.serializeAsProperty(bean, g, ctxt); - return; - } - long value; - try { - value = _acc.applyAsLong(bean); - } catch (Throwable t) { - _handleProblem(bean, g, ctxt, t, false); - return; - } - if (!_suppressableSet || _suppressableLong != value) { - g.writeName(_fastName); - g.writeNumber(value); - } - } - - @Override - public final void serializeAsElement(Object bean, JsonGenerator g, SerializationContext ctxt) - throws Exception - { - if (broken) { - fallbackWriter.serializeAsElement(bean, g, ctxt); - return; - } - long value; - try { - value = _acc.applyAsLong(bean); - } catch (Throwable t) { - _handleProblem(bean, g, ctxt, t, false); - return; - } - if (!_suppressableSet || _suppressableLong != value) { - g.writeNumber(value); - } else { // important: MUST output a placeholder - serializeAsOmittedElement(bean, g, ctxt); - } - } -} diff --git a/blackbird/src/main/java/tools/jackson/module/blackbird/ser/ObjectPropertyWriter.java b/blackbird/src/main/java/tools/jackson/module/blackbird/ser/ObjectPropertyWriter.java deleted file mode 100644 index 2a0a63c6f..000000000 --- a/blackbird/src/main/java/tools/jackson/module/blackbird/ser/ObjectPropertyWriter.java +++ /dev/null @@ -1,162 +0,0 @@ -package tools.jackson.module.blackbird.ser; - -import java.util.function.Function; - -import tools.jackson.core.JsonGenerator; -import tools.jackson.databind.ValueSerializer; -import tools.jackson.databind.PropertyName; -import tools.jackson.databind.SerializationContext; -import tools.jackson.databind.ser.BeanPropertyWriter; -import tools.jackson.databind.ser.impl.PropertySerializerMap; - -final class ObjectPropertyWriter - extends OptimizedBeanPropertyWriter -{ - private static final long serialVersionUID = 1L; - private final Function _acc; - - public ObjectPropertyWriter(BeanPropertyWriter src, Function acc, ValueSerializer ser) { - super(src, ser); - _acc = acc; - } - - protected ObjectPropertyWriter(ObjectPropertyWriter base, PropertyName name) { - super(base, name); - _acc = base._acc; - } - - @Override - protected BeanPropertyWriter _new(PropertyName newName) { - return new ObjectPropertyWriter(this, newName); - } - - @Override - public BeanPropertyWriter withSerializer(ValueSerializer ser) { - return new ObjectPropertyWriter(this, _acc, ser); - } - - /* - /********************************************************************** - /* Overrides - /********************************************************************** - */ - - @Override - public final void serializeAsProperty(Object bean, JsonGenerator g, SerializationContext ctxt) - throws Exception - { - if (broken) { - fallbackWriter.serializeAsProperty(bean, g, ctxt); - return; - } - Object value; - try { - value = _acc.apply(bean); - } catch (Throwable t) { - _handleProblem(bean, g, ctxt, t, false); - return; - } - // Null (etc) handling; copied from super-class impl - if (value == null) { - // 20-Jun-2022, tatu: Defer checking of null, see [databind#3481] - if ((_suppressableValue != null) - && ctxt.includeFilterSuppressNulls(_suppressableValue)) { - return; - } - if (_nullSerializer != null) { - g.writeName(_fastName); - _nullSerializer.serialize(null, g, ctxt); - } - return; - } - ValueSerializer ser = _serializer; - if (ser == null) { - Class cls = value.getClass(); - PropertySerializerMap map = _dynamicSerializers; - ser = map.serializerFor(cls); - if (ser == null) { - ser = _findAndAddDynamic(map, cls, ctxt); - } - } - if (_suppressableValue != null) { - if (MARKER_FOR_EMPTY == _suppressableValue) { - if (ser.isEmpty(ctxt, value)) { - return; - } - } else if (_suppressableValue.equals(value)) { - return; - } - } - if (value == bean) { - // three choices: exception; handled by call; or pass-through - if (_handleSelfReference(bean, g, ctxt, ser)) { - return; - } - } - g.writeName(_fastName); - if (_typeSerializer == null) { - ser.serialize(value, g, ctxt); - } else { - ser.serializeWithType(value, g, ctxt, _typeSerializer); - } - } - - @Override - public final void serializeAsElement(Object bean, JsonGenerator g, SerializationContext ctxt) - throws Exception - { - if (broken) { - fallbackWriter.serializeAsElement(bean, g, ctxt); - return; - } - Object value; - try { - value = _acc.apply(bean); - } catch (Throwable t) { - _handleProblem(bean, g, ctxt, t, true); - return; - } - // Null (etc) handling; copied from super-class impl - if (value == null) { - if (_nullSerializer != null) { - _nullSerializer.serialize(null, g, ctxt); - } else if (_suppressNulls) { - serializeAsOmittedElement(bean, g, ctxt); - } else { - ctxt.defaultSerializeNullValue(g); - } - return; - } - ValueSerializer ser = _serializer; - if (ser == null) { - Class cls = value.getClass(); - PropertySerializerMap map = _dynamicSerializers; - ser = map.serializerFor(cls); - if (ser == null) { - ser = _findAndAddDynamic(map, cls, ctxt); - } - } - if (_suppressableValue != null) { - if (MARKER_FOR_EMPTY == _suppressableValue) { - if (ser.isEmpty(ctxt, value)) { - serializeAsOmittedElement(bean, g, ctxt); - return; - } - } else if (_suppressableValue.equals(value)) { - serializeAsOmittedElement(bean, g, ctxt); - return; - } - } - if (value == bean) { - // three choices: exception; handled by call; or pass-through - if (_handleSelfReference(bean, g, ctxt, ser)) { - return; - } - } - if (_typeSerializer == null) { - ser.serialize(value, g, ctxt); - } else { - ser.serializeWithType(value, g, ctxt, _typeSerializer); - } - } -} diff --git a/blackbird/src/main/java/tools/jackson/module/blackbird/ser/OptimizedBeanPropertyWriter.java b/blackbird/src/main/java/tools/jackson/module/blackbird/ser/OptimizedBeanPropertyWriter.java deleted file mode 100644 index ada422a1e..000000000 --- a/blackbird/src/main/java/tools/jackson/module/blackbird/ser/OptimizedBeanPropertyWriter.java +++ /dev/null @@ -1,130 +0,0 @@ -package tools.jackson.module.blackbird.ser; - -import java.util.logging.Level; -import java.util.logging.Logger; - -import tools.jackson.core.JsonGenerator; -import tools.jackson.core.SerializableString; - -import tools.jackson.databind.*; -import tools.jackson.databind.jsontype.TypeSerializer; -import tools.jackson.databind.ser.BeanPropertyWriter; - -/** - * Intermediate base class that is used for concrete - * per-type implementations - */ -@SuppressWarnings("serial") -abstract class OptimizedBeanPropertyWriter> - extends BeanPropertyWriter -{ - /** - * Locally stored version of efficiently serializable name. - * Used to work around earlier problems with typing between - * interface, implementation - */ - protected final SerializableString _fastName; - - protected final BeanPropertyWriter fallbackWriter; - // Not volatile to prevent overhead, worst case is we trip the exception a few extra times - protected boolean broken = false; - - protected OptimizedBeanPropertyWriter(BeanPropertyWriter src, ValueSerializer ser) - { - super(src); - this.fallbackWriter = unwrapFallbackWriter(src); - // either use the passed on serializer or the original one - _serializer = (ser != null) ? ser : src.getSerializer(); - _fastName = src.getSerializedName(); - } - - protected OptimizedBeanPropertyWriter(OptimizedBeanPropertyWriter base, PropertyName name) { - super(base, name); - // 24-Oct-2017, tatu: Should this be changed too? - fallbackWriter = base.fallbackWriter; - _serializer = base._serializer; - _fastName = getSerializedName(); - } - - private BeanPropertyWriter unwrapFallbackWriter(BeanPropertyWriter srcIn) - { - while (srcIn instanceof OptimizedBeanPropertyWriter) { - srcIn = ((OptimizedBeanPropertyWriter)srcIn).fallbackWriter; - } - return srcIn; - } - - // Ensure it gets defined - @Override - protected abstract BeanPropertyWriter _new(PropertyName newName); - - @Override - public void assignTypeSerializer(TypeSerializer typeSer) { - super.assignTypeSerializer(typeSer); - if (fallbackWriter != null) { - fallbackWriter.assignTypeSerializer(typeSer); - } - // 04-Oct-2015, tatu: Should we handle this wrt [module-afterburner#59]? - // Seems unlikely, as String/long/int/boolean are final types; and for - // basic 'Object' we delegate to deserializer as expected - } - - @Override - public void assignSerializer(ValueSerializer ser) { - super.assignSerializer(ser); - if (fallbackWriter != null) { - fallbackWriter.assignSerializer(ser); - } - // 04-Oct-2015, tatu: To fix [module-afterburner#59], need to disable use of - // fully optimized variant - if (!SerializerUtil.isDefaultSerializer(ser)) { - broken = true; - } - } - - @Override - public void assignNullSerializer(ValueSerializer nullSer) { - super.assignNullSerializer(nullSer); - if (fallbackWriter != null) { - fallbackWriter.assignNullSerializer(nullSer); - } - } - - public abstract BeanPropertyWriter withSerializer(ValueSerializer ser); - - @Override - public abstract void serializeAsProperty(Object bean, JsonGenerator g, SerializationContext ctxt) throws Exception; - - @Override - public abstract void serializeAsElement(Object bean, JsonGenerator g, SerializationContext ctxt) throws Exception; - - // note: synchronized used to try to minimize race conditions; also, should NOT - // be a performance problem - protected synchronized void _handleProblem(Object bean, JsonGenerator g, SerializationContext ctxt, - Throwable t, boolean element) throws Exception - { - if ((t instanceof IllegalAccessError) - || (t instanceof SecurityException)) { - _reportProblem(bean, t); - if (element) { - fallbackWriter.serializeAsElement(bean, g, ctxt); - } else { - fallbackWriter.serializeAsProperty(bean, g, ctxt); - } - return; - } - if (t instanceof Error) { - throw (Error) t; - } - throw (Exception) t; - } - - protected void _reportProblem(Object bean, Throwable e) - { - broken = true; - String msg = String.format("Disabling Blackbird serialization for %s (field %s; mutator %s), due to access error (type %s, message=%s)%n", - bean.getClass(), _name, getClass().getName(), - e.getClass().getName(), e.getMessage()); - Logger.getLogger(OptimizedBeanPropertyWriter.class.getName()).log(Level.WARNING, msg, e); - } -} diff --git a/blackbird/src/main/java/tools/jackson/module/blackbird/ser/SerializerUtil.java b/blackbird/src/main/java/tools/jackson/module/blackbird/ser/SerializerUtil.java deleted file mode 100644 index 74aa75657..000000000 --- a/blackbird/src/main/java/tools/jackson/module/blackbird/ser/SerializerUtil.java +++ /dev/null @@ -1,35 +0,0 @@ -package tools.jackson.module.blackbird.ser; - -import tools.jackson.databind.ValueSerializer; -import tools.jackson.databind.ser.std.ToStringSerializer; -import tools.jackson.databind.util.ClassUtil; - -/** - * Helper class that contains utility methods needed by various other classes - * in this package. - * - * @since 2.12 - */ -class SerializerUtil -{ - /** - * Helper method used to check whether given serializer is the default - * serializer implementation: this is necessary to avoid overriding other - * kinds of serializers. - */ - public static boolean isDefaultSerializer(ValueSerializer ser) - { - if (ser == null) { - return true; - } - if (ClassUtil.isJacksonStdImpl(ser)) { - // 20-Nov-2020, tatu: As per [modules-base#117], need to consider - // one standard serializer that should not be replaced... - if (ser instanceof ToStringSerializer) { - return false; - } - return true; - } - return false; - } -} diff --git a/blackbird/src/main/java/tools/jackson/module/blackbird/ser/StringPropertyWriter.java b/blackbird/src/main/java/tools/jackson/module/blackbird/ser/StringPropertyWriter.java deleted file mode 100644 index 33b274019..000000000 --- a/blackbird/src/main/java/tools/jackson/module/blackbird/ser/StringPropertyWriter.java +++ /dev/null @@ -1,123 +0,0 @@ -package tools.jackson.module.blackbird.ser; - -import java.util.function.Function; - -import tools.jackson.core.JsonGenerator; -import tools.jackson.databind.ValueSerializer; -import tools.jackson.databind.PropertyName; -import tools.jackson.databind.SerializationContext; -import tools.jackson.databind.ser.BeanPropertyWriter; - -final class StringPropertyWriter - extends OptimizedBeanPropertyWriter -{ - private static final long serialVersionUID = 1L; - - private final Function _acc; - - public StringPropertyWriter(BeanPropertyWriter src, Function acc, ValueSerializer ser) { - super(src, ser); - _acc = acc; - } - - protected StringPropertyWriter(StringPropertyWriter base, PropertyName name) { - super(base, name); - _acc = base._acc; - } - - @Override - protected BeanPropertyWriter _new(PropertyName newName) { - return new StringPropertyWriter(this, newName); - } - - @Override - public BeanPropertyWriter withSerializer(ValueSerializer ser) { - return new StringPropertyWriter(this, _acc, ser); - } - - /* - /********************************************************************** - /* Overrides - /********************************************************************** - */ - - @Override - public final void serializeAsProperty(Object bean, JsonGenerator g, SerializationContext ctxt) - throws Exception - { - if (broken) { - fallbackWriter.serializeAsProperty(bean, g, ctxt); - return; - } - String value; - try { - value = _acc.apply(bean); - } catch (Throwable t) { - _handleProblem(bean, g, ctxt, t, false); - return; - } - // Null (etc) handling; copied from super-class impl - if (value == null) { - // 20-Jun-2022, tatu: Defer checking of null, see [databind#3481] - if ((_suppressableValue != null) - && ctxt.includeFilterSuppressNulls(_suppressableValue)) { - return; - } - if (_nullSerializer != null) { - g.writeName(_fastName); - _nullSerializer.serialize(null, g, ctxt); - } - return; - } - if (_suppressableValue != null) { - if (MARKER_FOR_EMPTY == _suppressableValue) { - if (value.length() == 0) { - return; - } - } else if (_suppressableValue.equals(value)) { - return; - } - } - g.writeName(_fastName); - g.writeString(value); - } - - @Override - public final void serializeAsElement(Object bean, JsonGenerator g, SerializationContext ctxt) - throws Exception - { - if (broken) { - fallbackWriter.serializeAsElement(bean, g, ctxt); - return; - } - - String value; - try { - value = _acc.apply(bean); - } catch (Throwable t) { - _handleProblem(bean, g, ctxt, t, true); - return; - } - // Null (etc) handling; copied from super-class impl - if (value == null) { - if (_suppressNulls) { - serializeAsOmittedElement(bean, g, ctxt); - } else { - ctxt.defaultSerializeNullValue(g); - } - return; - } - if (_suppressableValue != null) { - if (MARKER_FOR_EMPTY == _suppressableValue) { - if (value.length() == 0) { - serializeAsOmittedElement(bean, g, ctxt); - return; - } - } else if (_suppressableValue.equals(value)) { - serializeAsOmittedElement(bean, g, ctxt); - return; - } - } - g.writeString(value); - } -} diff --git a/blackbird/src/main/java/tools/jackson/module/blackbird/ser/ToBooleanFunction.java b/blackbird/src/main/java/tools/jackson/module/blackbird/ser/ToBooleanFunction.java deleted file mode 100644 index be50ab935..000000000 --- a/blackbird/src/main/java/tools/jackson/module/blackbird/ser/ToBooleanFunction.java +++ /dev/null @@ -1,6 +0,0 @@ -package tools.jackson.module.blackbird.ser; - -@FunctionalInterface -public interface ToBooleanFunction { - boolean applyAsBoolean(Object bean); -} diff --git a/blackbird/src/main/java/tools/jackson/module/blackbird/util/CheckedFunction.java b/blackbird/src/main/java/tools/jackson/module/blackbird/util/CheckedFunction.java deleted file mode 100644 index 53d45863c..000000000 --- a/blackbird/src/main/java/tools/jackson/module/blackbird/util/CheckedFunction.java +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright (c), Data Geekery GmbH, contact@datageekery.com - * - * 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 tools.jackson.module.blackbird.util; - -@FunctionalInterface -public interface CheckedFunction { - T apply(X x) throws Throwable; -} diff --git a/blackbird/src/main/java/tools/jackson/module/blackbird/util/CheckedSupplier.java b/blackbird/src/main/java/tools/jackson/module/blackbird/util/CheckedSupplier.java deleted file mode 100644 index c6b3c8b39..000000000 --- a/blackbird/src/main/java/tools/jackson/module/blackbird/util/CheckedSupplier.java +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright (c), Data Geekery GmbH, contact@datageekery.com - * - * 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 tools.jackson.module.blackbird.util; - -@FunctionalInterface -public interface CheckedSupplier { - T get() throws Throwable; -} diff --git a/blackbird/src/main/java/tools/jackson/module/blackbird/util/ReflectionHack.java b/blackbird/src/main/java/tools/jackson/module/blackbird/util/ReflectionHack.java deleted file mode 100644 index 2817f384e..000000000 --- a/blackbird/src/main/java/tools/jackson/module/blackbird/util/ReflectionHack.java +++ /dev/null @@ -1,87 +0,0 @@ -package tools.jackson.module.blackbird.util; - -import java.lang.invoke.MethodHandle; -import java.lang.invoke.MethodHandles; -import java.lang.invoke.MethodHandles.Lookup; -import java.lang.reflect.Constructor; -import java.util.logging.Level; -import java.util.logging.Logger; - -import tools.jackson.module.blackbird.BlackbirdModule; - -import java.lang.invoke.MethodType; - -/** - * Allow private access to fields across using a JVM version-appropriate strategy - * without having a compile-time dependency on Java 9+. - * - * This entire class can be replaced by a direct call to JDK 9+ - * {@link MethodHandles}{@code .privateLookupIn} - * once Jackson targets Java >= 9. - */ -public class ReflectionHack { - @SuppressWarnings("synthetic-access") - public static Lookup privateLookupIn(Class lookup, MethodHandles.Lookup orig) throws IllegalAccessException { - if (Java9Up.FACTORY != null) { - return Java9Up.privateLookupIn(lookup, orig); - } - return Java8.privateLookupIn(lookup); - } - - static class Java9Up { - static final MethodHandle FACTORY; - - static { - FACTORY = init(); - } - - private static MethodHandle init() { - try { - return MethodHandles.lookup().findStatic( - MethodHandles.class, - "privateLookupIn", - MethodType.methodType(Lookup.class, Class.class, MethodHandles.Lookup.class)); - } catch (ReflectiveOperationException e) { - Logger l = Logger.getLogger(BlackbirdModule.class.getName()); - // 02-Nov-2022, tatu: as per [modules-base#187] only log exception at trace - l.log(Level.WARNING, - "Unable to find Java 9+ MethodHandles.privateLookupIn. Blackbird is not performing optimally!"); - l.log(Level.FINE, - "Failure reason for `MethodHandles.privateLookupIn()` not being found: "+e.getMessage(), e); - return null; - } - } - - public static Lookup privateLookupIn(Class lookup, Lookup orig) { - try { - return (Lookup) FACTORY.invokeExact(lookup, orig); - } catch (ReflectiveOperationException e) { - return orig; - } catch (Throwable t) { - throw Sneaky.throwAnyway(t); - } - } - } - - static class Java8 { - private static final Constructor FACTORY; - - static { - try { - FACTORY = MethodHandles.Lookup.class.getDeclaredConstructor(Class.class); - } catch (ReflectiveOperationException e) { - throw new RuntimeException( - "Unable to use private Lookup constructor", - e); - } - - if (!FACTORY.isAccessible()) { - FACTORY.setAccessible(true); - } - } - - public static Lookup privateLookupIn(Class lookup) { - return Unchecked.function(FACTORY::newInstance).apply(lookup); - } - } -} diff --git a/blackbird/src/main/java/tools/jackson/module/blackbird/util/Sneaky.java b/blackbird/src/main/java/tools/jackson/module/blackbird/util/Sneaky.java deleted file mode 100644 index 75cdd6713..000000000 --- a/blackbird/src/main/java/tools/jackson/module/blackbird/util/Sneaky.java +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright (c), Data Geekery GmbH, contact@datageekery.com - * - * 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 tools.jackson.module.blackbird.util; - -import java.lang.reflect.InvocationTargetException; - -public class Sneaky { - private Sneaky() { - throw new RuntimeException(); - } - - /** - * Will always throw an exception, so the caller should also always throw the dummy return value to make sure the control flow remains clear. - */ - public static RuntimeException throwAnyway(Throwable t) { - if (t instanceof InterruptedException) { - Thread.currentThread().interrupt(); - } - - if (t instanceof InvocationTargetException) { - throw throwAnyway(t.getCause()); - } - - throwEvadingChecks(t); - - // never reached - return null; - } - - @SuppressWarnings("unchecked") - private static void throwEvadingChecks(Throwable throwable) throws E { - throw (E) throwable; - } -} diff --git a/blackbird/src/main/java/tools/jackson/module/blackbird/util/Unchecked.java b/blackbird/src/main/java/tools/jackson/module/blackbird/util/Unchecked.java deleted file mode 100644 index 76ea1186e..000000000 --- a/blackbird/src/main/java/tools/jackson/module/blackbird/util/Unchecked.java +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright (c), Data Geekery GmbH, contact@datageekery.com - * - * 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 tools.jackson.module.blackbird.util; - -import java.util.concurrent.Callable; -import java.util.function.Function; -import java.util.function.Supplier; - -public class Unchecked { - private Unchecked() { - throw new RuntimeException(); - } - - @SuppressWarnings("PMD.DoNotUseThreads") - public static Runnable runnable(CheckedRunnable checkedRunnable) { - return () -> { - try { - checkedRunnable.run(); - } catch (Throwable t) { - throw Sneaky.throwAnyway(t); - } - }; - } - - public static Supplier supplier(CheckedSupplier checkedSupplier) { - return () -> { - try { - return checkedSupplier.get(); - } catch (Throwable t) { - throw Sneaky.throwAnyway(t); - } - }; - } - - public static Function function(CheckedFunction checkedFunction) { - return x -> { - try { - return checkedFunction.apply(x); - } catch (Throwable t) { - throw Sneaky.throwAnyway(t); - } - }; - } - - public interface SneakyCallable extends Callable { - @Override - T call(); // no 'throws Exception' - } - - public interface CheckedRunnable { - void run() throws Throwable; - } -} diff --git a/blackbird/src/test/java/module-info.java b/blackbird/src/test/java/module-info.java index bd51578dc..c87883b4f 100644 --- a/blackbird/src/test/java/module-info.java +++ b/blackbird/src/test/java/module-info.java @@ -36,7 +36,6 @@ opens tools.jackson.module.blackbird.roundtrip; opens tools.jackson.module.blackbird.ser; opens tools.jackson.module.blackbird.ser.filter; - opens tools.jackson.module.blackbird.tofix; opens tools.jackson.module.blackbird.testutil; opens tools.jackson.module.blackbird.testutil.failure; diff --git a/blackbird/src/test/java/tools/jackson/module/blackbird/BlackbirdTestBase.java b/blackbird/src/test/java/tools/jackson/module/blackbird/BlackbirdTestBase.java index f2bbc4c87..62ce7fb2e 100644 --- a/blackbird/src/test/java/tools/jackson/module/blackbird/BlackbirdTestBase.java +++ b/blackbird/src/test/java/tools/jackson/module/blackbird/BlackbirdTestBase.java @@ -11,6 +11,7 @@ import tools.jackson.databind.json.JsonMapper; import tools.jackson.module.blackbird.testutil.NoCheckSubTypeValidator; +import tools.jackson.module.blackbird.codegen.CodegenFallbacks; import static org.junit.jupiter.api.Assertions.*; @@ -213,6 +214,13 @@ protected static JsonMapper.Builder blackbirdMapperBuilder() { return mapperBuilder(); } + static { + // The suite runs strict: a codec-generation failure fails the test + // instead of demoting to the stock path, so the lenient production + // fallback cannot mask a generator bug here. + System.setProperty(CodegenFallbacks.FAIL_ON_ERROR_PROPERTY, "true"); + } + protected static JsonMapper.Builder mapperBuilder() { return JsonMapper.builder() .polymorphicTypeValidator(new NoCheckSubTypeValidator()) diff --git a/blackbird/src/test/java/tools/jackson/module/blackbird/deser/BBCodecCompatibilityTest.java b/blackbird/src/test/java/tools/jackson/module/blackbird/deser/BBCodecCompatibilityTest.java new file mode 100644 index 000000000..087770a0b --- /dev/null +++ b/blackbird/src/test/java/tools/jackson/module/blackbird/deser/BBCodecCompatibilityTest.java @@ -0,0 +1,261 @@ +package tools.jackson.module.blackbird.deser; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.util.ArrayList; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonUnwrapped; + +import org.junit.jupiter.api.Test; + +import tools.jackson.core.JsonParser; +import tools.jackson.databind.DeserializationContext; +import tools.jackson.databind.DeserializationFeature; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.ValueDeserializer; +import tools.jackson.databind.deser.DeserializationProblemHandler; +import tools.jackson.databind.exc.MismatchedInputException; +import tools.jackson.databind.exc.UnrecognizedPropertyException; +import tools.jackson.module.blackbird.BlackbirdTestBase; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Behavior-parity regression tests against stock databind: quoted-scalar + * coercion, unknown-property handling under per-call config and problem + * handlers, record required and missing components, {@code @JsonUnwrapped} + * with codec-eligible children, PROPERTY_NAME entry, and modifier + * serializability. Every case compares against a vanilla mapper. + */ +public class BBCodecCompatibilityTest extends BlackbirdTestBase +{ + public static class ScalarBean { + public int i; + public long l; + public boolean b; + public String s; + } + + public record ReqRecord(@JsonProperty(required = true) String name, int count) { } + + public record PlainRecord(String name, int count) { } + + public static class Child { + public int value; + public String tag; + } + + public static class UnwrappedParent { + public int a; + @JsonUnwrapped + public Child child; + } + + public static class PrefixedParent { + public int a; + @JsonUnwrapped(prefix = "foo.") + public Child child; + } + + @JsonIgnoreProperties({ "skipMe" }) + public static class IgnoralBean { + public int i; + } + + private final ObjectMapper mapper = newObjectMapper(); + private final ObjectMapper vanilla = newVanillaJSONMapper(); + + @Test + public void testQuotedScalarCoercion() throws Exception { + String doc = a2q("{'i':'123','l':'456','b':'true','s':'x'}"); + ScalarBean exp = vanilla.readValue(doc, ScalarBean.class); + ScalarBean act = mapper.readValue(doc, ScalarBean.class); + assertEquals(exp.i, act.i); + assertEquals(exp.l, act.l); + assertEquals(exp.b, act.b); + assertEquals(exp.s, act.s); + } + + @Test + public void testFloatToIntCoercion() throws Exception { + String doc = a2q("{'i':1.5,'l':2.5}"); + ScalarBean exp = vanilla.readValue(doc, ScalarBean.class); + ScalarBean act = mapper.readValue(doc, ScalarBean.class); + assertEquals(exp.i, act.i); + assertEquals(exp.l, act.l); + } + + @Test + public void testScalarFromNumberToken() throws Exception { + // String property fed a number token: stock coerces, so must we. + String doc = a2q("{'s':42,'b':true,'i':7}"); + ScalarBean exp = vanilla.readValue(doc, ScalarBean.class); + ScalarBean act = mapper.readValue(doc, ScalarBean.class); + assertEquals(exp.s, act.s); + assertEquals(exp.b, act.b); + assertEquals(exp.i, act.i); + } + + @Test + public void testPerCallFailOnUnknownProperties() throws Exception { + String doc = a2q("{'i':1,'nope':{'x':2},'s':'y'}"); + // Default (disabled): unknown skips, other properties land. + ScalarBean ok = mapper.readValue(doc, ScalarBean.class); + assertEquals(1, ok.i); + assertEquals("y", ok.s); + // Per-call enable must fail, exactly like vanilla. + assertThrows(UnrecognizedPropertyException.class, () -> vanilla + .readerFor(ScalarBean.class) + .with(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .readValue(doc)); + assertThrows(UnrecognizedPropertyException.class, () -> mapper + .readerFor(ScalarBean.class) + .with(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .readValue(doc)); + } + + @Test + public void testProblemHandlerSeesUnknownProperty() throws Exception { + List seen = new ArrayList<>(); + ObjectMapper m = mapperBuilder().addHandler(new DeserializationProblemHandler() { + @Override + public boolean handleUnknownProperty(DeserializationContext ctxt, JsonParser p, + ValueDeserializer deserializer, Object beanOrClass, String propertyName) { + seen.add(propertyName); + p.skipChildren(); + return true; + } + }).build(); + ScalarBean bean = m.readValue(a2q("{'i':1,'mystery':[1,2]}"), ScalarBean.class); + assertEquals(1, bean.i); + assertEquals(List.of("mystery"), seen); + } + + @Test + public void testIgnoredPropertiesKeepStockSemantics() throws Exception { + String doc = a2q("{'i':1,'skipMe':{'x':2}}"); + IgnoralBean exp = vanilla.readerFor(IgnoralBean.class) + .with(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES).readValue(doc); + IgnoralBean act = mapper.readerFor(IgnoralBean.class) + .with(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES).readValue(doc); + assertEquals(exp.i, act.i); + assertThrows(UnrecognizedPropertyException.class, () -> mapper + .readerFor(IgnoralBean.class) + .with(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .readValue(a2q("{'i':1,'other':2}"))); + } + + @Test + public void testRequiredRecordComponentMissing() throws Exception { + String doc = a2q("{'count':3}"); + MismatchedInputException expEx = assertThrows(MismatchedInputException.class, + () -> vanilla.readValue(doc, ReqRecord.class)); + MismatchedInputException actEx = assertThrows(MismatchedInputException.class, + () -> mapper.readValue(doc, ReqRecord.class)); + assertTrue(expEx.getMessage().contains("Missing required creator property 'name'"), + expEx.getMessage()); + assertTrue(actEx.getMessage().contains("Missing required creator property 'name'"), + actEx.getMessage()); + // Present case unaffected. + assertEquals(vanilla.readValue(a2q("{'name':'x','count':3}"), ReqRecord.class), + mapper.readValue(a2q("{'name':'x','count':3}"), ReqRecord.class)); + } + + @Test + public void testPerCallFailOnMissingCreatorProperties() throws Exception { + String doc = a2q("{'name':'x'}"); + // Default: missing component takes its default value. + assertEquals(vanilla.readValue(doc, PlainRecord.class), + mapper.readValue(doc, PlainRecord.class)); + assertThrows(MismatchedInputException.class, () -> vanilla + .readerFor(PlainRecord.class) + .with(DeserializationFeature.FAIL_ON_MISSING_CREATOR_PROPERTIES) + .readValue(doc)); + assertThrows(MismatchedInputException.class, () -> mapper + .readerFor(PlainRecord.class) + .with(DeserializationFeature.FAIL_ON_MISSING_CREATOR_PROPERTIES) + .readValue(doc)); + } + + @Test + public void testUnwrappedChildWithCodec() throws Exception { + UnwrappedParent p = new UnwrappedParent(); + p.a = 1; + p.child = new Child(); + p.child.value = 3; + p.child.tag = "t"; + String expJson = vanilla.writeValueAsString(p); + assertEquals(expJson, mapper.writeValueAsString(p)); + + UnwrappedParent exp = vanilla.readValue(expJson, UnwrappedParent.class); + UnwrappedParent act = mapper.readValue(expJson, UnwrappedParent.class); + assertNotNull(act.child); + assertEquals(exp.a, act.a); + assertEquals(exp.child.value, act.child.value); + assertEquals(exp.child.tag, act.child.tag); + } + + @Test + public void testPrefixedUnwrappedChildWithCodec() throws Exception { + PrefixedParent p = new PrefixedParent(); + p.a = 1; + p.child = new Child(); + p.child.value = 3; + p.child.tag = "t"; + String expJson = vanilla.writeValueAsString(p); + assertEquals(expJson, mapper.writeValueAsString(p)); + + PrefixedParent exp = vanilla.readValue(expJson, PrefixedParent.class); + PrefixedParent act = mapper.readValue(expJson, PrefixedParent.class); + assertNotNull(act.child); + assertEquals(exp.a, act.a); + assertEquals(exp.child.value, act.child.value); + assertEquals(exp.child.tag, act.child.tag); + } + + @Test + public void testPropertyNameEntry() throws Exception { + String doc = a2q("{'i':7,'l':8,'b':true,'s':'y'}"); + ScalarBean exp; + try (JsonParser p = vanilla.createParser(doc)) { + p.nextToken(); + p.nextToken(); + exp = vanilla.readValue(p, ScalarBean.class); + } + ScalarBean act; + try (JsonParser p = mapper.createParser(doc)) { + p.nextToken(); + p.nextToken(); + act = mapper.readValue(p, ScalarBean.class); + } + assertEquals(exp.i, act.i); + assertEquals(exp.l, act.l); + assertEquals(exp.b, act.b); + assertEquals(exp.s, act.s); + } + + @Test + public void testMapperUsableAfterJdkSerialization() throws Exception { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (ObjectOutputStream out = new ObjectOutputStream(bytes)) { + out.writeObject(newObjectMapper()); + } + ObjectMapper revived; + try (ObjectInputStream in = new ObjectInputStream( + new ByteArrayInputStream(bytes.toByteArray()))) { + revived = (ObjectMapper) in.readObject(); + } + // Building a deserializer runs the modifier's updateBuilder, which + // uses the transient ThreadLocal that deserialization nulled out. + ScalarBean bean = revived.readValue(a2q("{'i':5,'s':'z'}"), ScalarBean.class); + assertEquals(5, bean.i); + assertEquals("z", bean.s); + assertEquals(newObjectMapper().writeValueAsString(bean), + revived.writeValueAsString(bean)); + } +} diff --git a/blackbird/src/test/java/tools/jackson/module/blackbird/deser/BBCodecCreatorTest.java b/blackbird/src/test/java/tools/jackson/module/blackbird/deser/BBCodecCreatorTest.java new file mode 100644 index 000000000..f0c6e60cb --- /dev/null +++ b/blackbird/src/test/java/tools/jackson/module/blackbird/deser/BBCodecCreatorTest.java @@ -0,0 +1,61 @@ +package tools.jackson.module.blackbird.deser; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +import tools.jackson.databind.ObjectMapper; +import tools.jackson.module.blackbird.BlackbirdTestBase; + +import static org.junit.jupiter.api.Assertions.*; + +// The value instantiator's default creator is not always the no-arg +// constructor: a no-arg @JsonCreator factory takes its place, and databind +// then constructs through the factory. Generated codecs must construct the +// same way, not through a direct `new` that bypasses the factory. +public class BBCodecCreatorTest extends BlackbirdTestBase +{ + public static class FactoryBean { + public String name; + public boolean viaFactory; + + public FactoryBean() { } + + @JsonCreator + public static FactoryBean create() { + FactoryBean bean = new FactoryBean(); + bean.viaFactory = true; + return bean; + } + } + + public record FactoryRec(String name, int count) { + @JsonCreator + public static FactoryRec create(@JsonProperty("name") String name, + @JsonProperty("count") int count) { + return new FactoryRec(name + "!", count); + } + } + + @Test + public void testRecordFactoryCreatorIsUsed() throws Exception { + ObjectMapper vanilla = newVanillaJSONMapper(); + FactoryRec expected = vanilla.readValue("{\"name\":\"a\",\"count\":2}", FactoryRec.class); + + FactoryRec actual = newBlackbirdMapper().readValue("{\"name\":\"a\",\"count\":2}", FactoryRec.class); + assertEquals(expected, actual, + "record construction path differs from stock (creator is the factory)"); + } + + @Test + public void testNoArgFactoryCreatorIsUsed() throws Exception { + ObjectMapper vanilla = newVanillaJSONMapper(); + FactoryBean expected = vanilla.readValue("{\"name\":\"a\"}", FactoryBean.class); + + FactoryBean actual = newBlackbirdMapper().readValue("{\"name\":\"a\"}", FactoryBean.class); + assertEquals("a", actual.name); + assertEquals(expected.viaFactory, actual.viaFactory, + "construction path differs from stock (default creator is the factory)"); + } +} diff --git a/blackbird/src/test/java/tools/jackson/module/blackbird/deser/BBCodecDemotionTest.java b/blackbird/src/test/java/tools/jackson/module/blackbird/deser/BBCodecDemotionTest.java new file mode 100644 index 000000000..f8c90f087 --- /dev/null +++ b/blackbird/src/test/java/tools/jackson/module/blackbird/deser/BBCodecDemotionTest.java @@ -0,0 +1,162 @@ +package tools.jackson.module.blackbird.deser; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import com.fasterxml.jackson.annotation.JacksonInject; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonIdentityInfo; +import com.fasterxml.jackson.annotation.JsonMerge; +import com.fasterxml.jackson.annotation.ObjectIdGenerators; + +import org.junit.jupiter.api.Test; + +import tools.jackson.databind.BeanDescription; +import tools.jackson.databind.DeserializationConfig; +import tools.jackson.databind.InjectableValues; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.ValueDeserializer; +import tools.jackson.databind.deser.ValueDeserializerModifier; +import tools.jackson.databind.module.SimpleModule; +import tools.jackson.module.blackbird.BlackbirdModule; +import tools.jackson.module.blackbird.BlackbirdTestBase; +import tools.jackson.databind.json.JsonMapper; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Documented demotions: beans whose features the generator does not model + * (object ids, per-property merge, any-setter, injected values) stay on the + * stock deserializer and behave exactly like vanilla. These pins keep the + * demotions deliberate; if the generator grows support, the engagement + * assertion flips. + */ +public class BBCodecDemotionTest extends BlackbirdTestBase +{ + @JsonIdentityInfo(generator = ObjectIdGenerators.IntSequenceGenerator.class) + public static class IdBean { + public int value; + public IdBean next; + } + + public static class MergeTarget { + public int a = 1; + public int b = 2; + } + + public static class MergeBean { + @JsonMerge + public MergeTarget child = new MergeTarget(); + } + + public static class AnySetterBean { + public int known; + public Map rest = new LinkedHashMap<>(); + + @JsonAnySetter + public void any(String name, Object value) { + rest.put(name, value); + } + } + + public static class InjectBean { + public int i; + @JacksonInject("who") + public String who; + } + + private final ObjectMapper vanilla = newVanillaJSONMapper(); + + private static ObjectMapper capturingMapper(Map, ValueDeserializer> seen) { + 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) { + seen.put(ref.getBeanClass(), d); + return d; + } + }); + } + }; + return JsonMapper.builder() + .addModule(capture) + .addModule(new BlackbirdModule()) + .build(); + } + + // Stock comes in two shapes: the modifier demoted (raw stock captured), + // or the factory gated at resolve time (placeholder captured, codec never + // generated). Both mean every call runs the stock deserializer. + private static void assertStock(Map, ValueDeserializer> seen, Class cls) + throws Exception { + ValueDeserializer d = seen.get(cls); + assertNotNull(d, "no deserializer captured for " + cls); + if (d instanceof BBReaderPlaceholder placeholder) { + java.lang.reflect.Field f = BBReaderPlaceholder.class.getDeclaredField("_codec"); + f.setAccessible(true); + assertNull(f.get(placeholder), + cls.getSimpleName() + " unexpectedly generated a codec; if support" + + " was added deliberately, flip this pin to assert engagement"); + } + } + + @Test + public void testObjectIdBeanStaysStock() throws Exception { + Map, ValueDeserializer> seen = new ConcurrentHashMap<>(); + ObjectMapper mapper = capturingMapper(seen); + String doc = a2q("{'@id':1,'value':5,'next':1}"); + IdBean v = vanilla.readValue(doc, IdBean.class); + IdBean m = mapper.readValue(doc, IdBean.class); + assertStock(seen, IdBean.class); + assertEquals(v.value, m.value); + assertSame(m, m.next); + } + + @Test + public void testMergePropertyBeanStaysStock() throws Exception { + Map, ValueDeserializer> seen = new ConcurrentHashMap<>(); + ObjectMapper mapper = capturingMapper(seen); + String doc = a2q("{'child':{'a':9}}"); + MergeBean v = vanilla.readValue(doc, MergeBean.class); + MergeBean m = mapper.readValue(doc, MergeBean.class); + assertStock(seen, MergeBean.class); + assertEquals(v.child.a, m.child.a); + assertEquals(v.child.b, m.child.b); + assertEquals(2, m.child.b); + } + + @Test + public void testAnySetterBeanStaysStock() throws Exception { + Map, ValueDeserializer> seen = new ConcurrentHashMap<>(); + ObjectMapper mapper = capturingMapper(seen); + String doc = a2q("{'known':1,'x':'a','y':2}"); + AnySetterBean v = vanilla.readValue(doc, AnySetterBean.class); + AnySetterBean m = mapper.readValue(doc, AnySetterBean.class); + assertStock(seen, AnySetterBean.class); + assertEquals(v.known, m.known); + assertEquals(v.rest, m.rest); + assertEquals(2, m.rest.size()); + } + + @Test + public void testInjectableBeanStaysStock() throws Exception { + Map, ValueDeserializer> seen = new ConcurrentHashMap<>(); + ObjectMapper base = capturingMapper(seen); + InjectableValues inject = + new InjectableValues.Std().addValue("who", "injected"); + String doc = a2q("{'i':7}"); + InjectBean v = vanilla.reader(inject).forType(InjectBean.class).readValue(doc); + InjectBean m = base.reader(inject).forType(InjectBean.class).readValue(doc); + assertStock(seen, InjectBean.class); + assertEquals(v.i, m.i); + assertEquals(v.who, m.who); + assertEquals("injected", m.who); + } +} diff --git a/blackbird/src/test/java/tools/jackson/module/blackbird/deser/BBCodecEngagementTest.java b/blackbird/src/test/java/tools/jackson/module/blackbird/deser/BBCodecEngagementTest.java new file mode 100644 index 000000000..a36443989 --- /dev/null +++ b/blackbird/src/test/java/tools/jackson/module/blackbird/deser/BBCodecEngagementTest.java @@ -0,0 +1,220 @@ +package tools.jackson.module.blackbird.deser; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.exc.MismatchedInputException; + +import tools.jackson.module.blackbird.BlackbirdTestBase; +import tools.jackson.databind.annotation.JsonDeserialize; +import tools.jackson.databind.annotation.JsonPOJOBuilder; +import tools.jackson.databind.deser.ValueDeserializerModifier; +import tools.jackson.databind.BeanDescription; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.module.SimpleModule; +import tools.jackson.module.blackbird.BlackbirdModule; + +import static org.junit.jupiter.api.Assertions.*; + +public class BBCodecEngagementTest extends BlackbirdTestBase +{ + public static class PublicBean { + private String name; + private int count; + private long total; + private boolean active; + private List tags; + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public int getCount() { return count; } + public void setCount(int count) { this.count = count; } + public long getTotal() { return total; } + public void setTotal(long total) { this.total = total; } + public boolean isActive() { return active; } + public void setActive(boolean active) { this.active = active; } + public List getTags() { return tags; } + public void setTags(List tags) { this.tags = tags; } + } + + public record PublicRec(String name, int count, long total, boolean active, List tags) {} + + @JsonDeserialize(builder = BuilderBean.Builder.class) + public static class BuilderBean { + private final String name; + private final int count; + private final List tags; + + private BuilderBean(String name, int count, List tags) { + this.name = name; + this.count = count; + this.tags = tags; + } + + public String getName() { return name; } + public int getCount() { return count; } + public List getTags() { return tags; } + + @JsonPOJOBuilder(withPrefix = "") + public static class Builder { + private String name; + private int count; + private List tags; + + public Builder name(String name) { this.name = name; return this; } + public Builder count(int count) { this.count = count; return this; } + public Builder tags(List tags) { this.tags = tags; return this; } + public BuilderBean build() { return new BuilderBean(name, count, tags); } + } + } + + public static class PublicParent { + private String label; + private PublicBean child; + private PublicRec rec; + + public String getLabel() { return label; } + public void setLabel(String label) { this.label = label; } + public PublicBean getChild() { return child; } + public void setChild(PublicBean child) { this.child = child; } + public PublicRec getRec() { return rec; } + public void setRec(PublicRec rec) { this.rec = rec; } + } + + private final ObjectMapper MAPPER = newObjectMapper(); + + @Test + public void testNestedChildCodecs() throws Exception { + String doc = "{\"label\":\"p\",\"child\":{\"name\":\"c\",\"count\":2}," + + "\"rec\":{\"name\":\"r\",\"count\":5,\"total\":6,\"active\":true,\"tags\":[]}}"; + PublicParent parent = MAPPER.readValue(doc, PublicParent.class); + assertEquals("p", parent.getLabel()); + assertEquals("c", parent.getChild().getName()); + assertEquals(2, parent.getChild().getCount()); + assertEquals(new PublicRec("r", 5, 6L, true, List.of()), parent.getRec()); + + PublicParent nulls = MAPPER.readValue("{\"child\":null,\"rec\":null}", PublicParent.class); + assertNull(nulls.getChild()); + assertNull(nulls.getRec()); + + PublicParent vanillaParent = newVanillaJSONMapper().readValue(doc, PublicParent.class); + assertEquals(vanillaParent.getRec(), parent.getRec()); + assertEquals(vanillaParent.getChild().getName(), parent.getChild().getName()); + } + + @Test + public void testGeneratedRecordCodecValues() throws Exception { + PublicRec rec = MAPPER.readValue( + "{\"name\":\"a\",\"count\":3,\"total\":9000000000,\"active\":true,\"tags\":[\"x\"]}", + PublicRec.class); + assertEquals(new PublicRec("a", 3, 9000000000L, true, List.of("x")), rec); + + PublicRec reversed = MAPPER.readValue( + "{\"tags\":[\"x\"],\"active\":true,\"total\":9000000000,\"count\":3,\"name\":\"a\"}", + PublicRec.class); + assertEquals(rec, reversed); + } + + @Test + public void testGeneratedRecordCodecNullsUnknownsMissing() throws Exception { + ObjectMapper vanilla = newVanillaJSONMapper(); + String doc = "{\"name\":null,\"junk\":{\"a\":[1]},\"count\":3}"; + assertEquals(vanilla.readValue(doc, PublicRec.class), + MAPPER.readValue(doc, PublicRec.class)); + String primNull = "{\"count\":null,\"name\":\"n\"}"; + assertThrows(MismatchedInputException.class, + () -> vanilla.readValue(primNull, PublicRec.class)); + assertThrows(MismatchedInputException.class, + () -> MAPPER.readValue(primNull, PublicRec.class)); + } + + @Test + public void testGeneratedBuilderCodecValues() throws Exception { + String doc = "{\"name\":\"b\",\"junk\":[{}],\"count\":7,\"tags\":[\"t\"]}"; + BuilderBean bean = MAPPER.readValue(doc, BuilderBean.class); + assertEquals("b", bean.getName()); + assertEquals(7, bean.getCount()); + assertEquals(List.of("t"), bean.getTags()); + BuilderBean vanillaBean = newVanillaJSONMapper().readValue(doc, BuilderBean.class); + assertEquals(vanillaBean.getName(), bean.getName()); + assertEquals(vanillaBean.getCount(), bean.getCount()); + assertEquals(vanillaBean.getTags(), bean.getTags()); + } + + // A non-object where a bean is expected reports MismatchedInputException + // from the generated codecs, same as stock. + @Test + public void testOddTokenMatchesStock() throws Exception { + for (Class type : new Class[] { + PublicBean.class, PublicRec.class, BuilderBean.class }) { + assertThrows(MismatchedInputException.class, + () -> MAPPER.readValue("[1]", type)); + assertThrows(MismatchedInputException.class, + () -> newVanillaJSONMapper().readValue("[1]", type)); + } + } + + @Test + public void testEngineEngagesForAllShapes() throws Exception { + java.util.Map, tools.jackson.databind.ValueDeserializer> seen = + new java.util.concurrent.ConcurrentHashMap<>(); + tools.jackson.databind.deser.ValueDeserializerModifier capture = + new ValueDeserializerModifier() { + private static final long serialVersionUID = 1L; + + @Override + public tools.jackson.databind.ValueDeserializer modifyDeserializer( + tools.jackson.databind.DeserializationConfig config, + BeanDescription.Supplier beanDescRef, + tools.jackson.databind.ValueDeserializer deserializer) { + seen.put(beanDescRef.getBeanClass(), deserializer); + return deserializer; + } + }; + ObjectMapper m = JsonMapper.builder() + .addModule(new SimpleModule("capture") + .setDeserializerModifier(capture)) + .addModule(new BlackbirdModule()) + .build(); + m.readValue("{\"name\":\"a\"}", PublicBean.class); + m.readValue("{\"name\":\"a\"}", PublicRec.class); + m.readValue("{\"name\":\"a\"}", BuilderBean.class); + assertEquals("BBReaderPlaceholder", + seen.get(PublicBean.class).getClass().getSimpleName()); + assertEquals("BBReaderPlaceholder", + seen.get(PublicRec.class).getClass().getSimpleName()); + // Builder flow keys the modifier by the builder class. + assertEquals("BBReaderPlaceholder", + seen.get(BuilderBean.Builder.class).getClass().getSimpleName()); + } + + @Test + public void testGeneratedCodecValues() throws Exception { + PublicBean bean = MAPPER.readValue( + "{\"name\":\"a\",\"count\":3,\"total\":9000000000,\"active\":true,\"tags\":[\"x\",\"y\"]}", + PublicBean.class); + assertEquals("a", bean.getName()); + assertEquals(3, bean.getCount()); + assertEquals(9000000000L, bean.getTotal()); + assertTrue(bean.isActive()); + assertEquals(List.of("x", "y"), bean.getTags()); + } + + @Test + public void testGeneratedCodecNullsAndUnknowns() throws Exception { + PublicBean bean = MAPPER.readValue( + "{\"name\":null,\"junk\":{\"deep\":[1,2]},\"active\":false}", + PublicBean.class); + assertNull(bean.getName()); + assertEquals(0, bean.getCount()); + assertFalse(bean.isActive()); + assertNull(bean.getTags()); + assertThrows(MismatchedInputException.class, + () -> MAPPER.readValue("{\"count\":null}", PublicBean.class)); + assertThrows(MismatchedInputException.class, + () -> newVanillaJSONMapper().readValue("{\"count\":null}", PublicBean.class)); + } + +} diff --git a/blackbird/src/test/java/tools/jackson/module/blackbird/deser/BBCodecFieldTest.java b/blackbird/src/test/java/tools/jackson/module/blackbird/deser/BBCodecFieldTest.java new file mode 100644 index 000000000..17af6a427 --- /dev/null +++ b/blackbird/src/test/java/tools/jackson/module/blackbird/deser/BBCodecFieldTest.java @@ -0,0 +1,195 @@ +package tools.jackson.module.blackbird.deser; + +import java.lang.invoke.MethodHandles; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Supplier; + +import org.junit.jupiter.api.Test; + +import tools.jackson.databind.BeanDescription; +import tools.jackson.databind.DeserializationConfig; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.ValueDeserializer; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import tools.jackson.databind.deser.ValueDeserializerModifier; +import tools.jackson.databind.exc.MismatchedInputException; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.module.SimpleModule; + +import tools.jackson.module.blackbird.BlackbirdModule; +import tools.jackson.module.blackbird.BlackbirdTestBase; + +import static org.junit.jupiter.api.Assertions.*; + +// Field-backed properties: the generator stores through putfield / reads +// through getfield for public fields, and reaches a non-public field through a +// lookup-derived handle. Behavior must match stock databind on every path. +public class BBCodecFieldTest extends BlackbirdTestBase +{ + @JsonPropertyOrder({ "name", "count", "total", "active", "tags", "child" }) + public static class FieldBean { + public String name; + public int count; + public long total; + public boolean active; + public List tags; + public FieldChild child; + } + + public static class FieldChild { + public String label; + } + + public static class FinalFieldBean { + public final String name; + public int count; + + public FinalFieldBean() { + this("", 0); + } + + @JsonCreator + public FinalFieldBean( + @JsonProperty("name") String name, + @JsonProperty("count") int count) { + this.name = name; + this.count = count; + } + } + + public static class MixedBean { + public String field; + private int viaSetter; + + public int getViaSetter() { return viaSetter; } + public void setViaSetter(int v) { viaSetter = v; } + } + + public static class PrivateFieldBean { + @JsonProperty("name") + private String name; + @JsonProperty("count") + private int count; + + public String name() { return name; } + public int count() { return count; } + } + + private final ObjectMapper MAPPER = newObjectMapper(); + private final ObjectMapper VANILLA = newVanillaJSONMapper(); + + @Test + public void testPublicFieldValues() throws Exception { + String json = a2q("{'name':'a','count':3,'total':9000000000,'active':true," + + "'tags':['x','y'],'child':{'label':'c'}}"); + FieldBean bean = MAPPER.readValue(json, FieldBean.class); + assertEquals("a", bean.name); + assertEquals(3, bean.count); + assertEquals(9000000000L, bean.total); + assertTrue(bean.active); + assertEquals(List.of("x", "y"), bean.tags); + assertEquals("c", bean.child.label); + } + + @Test + public void testPublicFieldReversedOrder() throws Exception { + String json = a2q("{'child':{'label':'c'},'tags':['x'],'active':true," + + "'total':7,'count':2,'name':'a'}"); + FieldBean bean = MAPPER.readValue(json, FieldBean.class); + FieldBean vanilla = VANILLA.readValue(json, FieldBean.class); + assertEquals(vanilla.name, bean.name); + assertEquals(vanilla.count, bean.count); + assertEquals(vanilla.child.label, bean.child.label); + } + + @Test + public void testPublicFieldNullsAndUnknowns() throws Exception { + String json = a2q("{'name':null,'extra':{'deep':1},'count':5," + + "'tags':null,'child':null}"); + FieldBean bean = MAPPER.readValue(json, FieldBean.class); + assertNull(bean.name); + assertEquals(5, bean.count); + assertNull(bean.tags); + assertNull(bean.child); + } + + @Test + public void testNullToPrimitiveFieldThrows() { + assertThrows(MismatchedInputException.class, + () -> MAPPER.readValue(a2q("{'count':null}"), FieldBean.class)); + } + + @Test + public void testPublicFieldRoundTripMatchesVanilla() throws Exception { + FieldBean bean = MAPPER.readValue(a2q("{'name':'a','count':3,'total':7," + + "'active':true,'tags':['x','y'],'child':{'label':'c'}}"), FieldBean.class); + assertEquals(VANILLA.writeValueAsString(bean), MAPPER.writeValueAsString(bean)); + } + + @Test + public void testFinalFieldMatchesVanilla() throws Exception { + String json = a2q("{'name':'a','count':3}"); + FinalFieldBean bean = MAPPER.readValue(json, FinalFieldBean.class); + assertEquals("a", bean.name); + assertEquals(3, bean.count); + assertEquals(VANILLA.writeValueAsString(bean), MAPPER.writeValueAsString(bean)); + } + + @Test + public void testMixedFieldAndSetter() throws Exception { + MixedBean bean = MAPPER.readValue(a2q("{'field':'a','viaSetter':7}"), MixedBean.class); + assertEquals("a", bean.field); + assertEquals(7, bean.getViaSetter()); + assertEquals(VANILLA.writeValueAsString(bean), MAPPER.writeValueAsString(bean)); + } + + @Test + public void testPrivateFieldWithLookupAccelerates() throws Exception { + Supplier here = MethodHandles::lookup; + ObjectMapper m = JsonMapper.builder() + .addModule(new BlackbirdModule() { + @Override + protected Supplier findLookupSupplier() { + return here; + } + }) + .build(); + PrivateFieldBean bean = m.readValue(a2q("{'name':'a','count':3}"), PrivateFieldBean.class); + assertEquals("a", bean.name()); + assertEquals(3, bean.count()); + } + + @Test + public void testPrivateFieldWithoutLookupStillWorks() throws Exception { + // The default lookup cannot reach the private field, so the property + // demotes to the stock path; the value must still round-trip. + PrivateFieldBean bean = MAPPER.readValue(a2q("{'name':'a','count':3}"), + PrivateFieldBean.class); + assertEquals("a", bean.name()); + assertEquals(3, bean.count()); + } + + @Test + public void testFieldBeanEngagesCodec() throws Exception { + Map, ValueDeserializer> seen = new ConcurrentHashMap<>(); + ValueDeserializerModifier capture = new ValueDeserializerModifier() { + private static final long serialVersionUID = 1L; + @Override + public ValueDeserializer modifyDeserializer(DeserializationConfig config, + BeanDescription.Supplier ref, ValueDeserializer deser) { + seen.put(ref.getBeanClass(), deser); + return deser; + } + }; + ObjectMapper m = JsonMapper.builder() + .addModule(new SimpleModule("capture").setDeserializerModifier(capture)) + .addModule(new BlackbirdModule()) + .build(); + m.readValue(a2q("{'name':'a'}"), FieldBean.class); + assertEquals("BBReaderPlaceholder", seen.get(FieldBean.class).getClass().getSimpleName()); + } +} diff --git a/blackbird/src/test/java/tools/jackson/module/blackbird/deser/BBCodecIgnoralsTest.java b/blackbird/src/test/java/tools/jackson/module/blackbird/deser/BBCodecIgnoralsTest.java new file mode 100644 index 000000000..e704cba3d --- /dev/null +++ b/blackbird/src/test/java/tools/jackson/module/blackbird/deser/BBCodecIgnoralsTest.java @@ -0,0 +1,198 @@ +package tools.jackson.module.blackbird.deser; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonIncludeProperties; + +import org.junit.jupiter.api.Test; + +import tools.jackson.databind.BeanDescription; +import tools.jackson.databind.DeserializationConfig; +import tools.jackson.databind.DeserializationFeature; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.ValueDeserializer; +import tools.jackson.databind.deser.ValueDeserializerModifier; +import tools.jackson.databind.exc.IgnoredPropertyException; +import tools.jackson.databind.module.SimpleModule; +import tools.jackson.module.blackbird.BlackbirdModule; +import tools.jackson.module.blackbird.BlackbirdTestBase; +import tools.jackson.databind.json.JsonMapper; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Ignoral parity: beans with {@code @JsonIgnoreProperties}, + * {@code @JsonIncludeProperties}, or {@code @JsonIgnore} now engage codecs, + * and the unknown arm consults the ignoral sets in the stock loop's exact + * order. Every case compares against vanilla. + */ +public class BBCodecIgnoralsTest extends BlackbirdTestBase +{ + @JsonIgnoreProperties(ignoreUnknown = true) + public static class LenientBean { + public int i; + public String s; + } + + @JsonIgnoreProperties({ "secret" }) + public static class IgnoredSetBean { + public int i; + public String s; + } + + @JsonIncludeProperties({ "i" }) + public static class IncludedSetBean { + public int i; + public String s; + } + + public static class JsonIgnoreBean { + public int i; + @JsonIgnore + public String hidden; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record LenientRecord(String name, int count) { } + + private final ObjectMapper vanilla = newVanillaJSONMapper(); + + private static String message(Exception e) { + String m = e.getMessage(); + int nl = m.indexOf('\n'); + return (nl < 0) ? m : m.substring(0, nl); + } + + private static ObjectMapper capturingMapper(Map, ValueDeserializer> seen) { + 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) { + seen.put(ref.getBeanClass(), d); + return d; + } + }); + } + }; + return JsonMapper.builder() + .addModule(capture) + .addModule(new BlackbirdModule()) + .build(); + } + + private static void assertEngaged(Map, ValueDeserializer> seen, Class cls) { + ValueDeserializer d = seen.get(cls); + assertNotNull(d, "no deserializer captured for " + cls); + assertEquals("BBReaderPlaceholder", d.getClass().getSimpleName(), + cls.getSimpleName() + " did not engage a codec"); + } + + @Test + public void testIgnoreUnknownSkips() throws Exception { + Map, ValueDeserializer> seen = new ConcurrentHashMap<>(); + ObjectMapper mapper = capturingMapper(seen); + String doc = a2q("{'i':1,'nope':{'deep':[1,2]},'s':'x','more':true}"); + LenientBean v = vanilla.readValue(doc, LenientBean.class); + LenientBean m = mapper.readValue(doc, LenientBean.class); + assertEngaged(seen, LenientBean.class); + assertEquals(v.i, m.i); + assertEquals(v.s, m.s); + } + + @Test + public void testIgnoreUnknownBeatsStrictPerCall() throws Exception { + // Stock skips silently under ignoreUnknown even when + // FAIL_ON_UNKNOWN_PROPERTIES is enabled per call. + Map, ValueDeserializer> seen = new ConcurrentHashMap<>(); + ObjectMapper mapper = capturingMapper(seen); + String doc = a2q("{'i':1,'nope':true}"); + LenientBean v = vanilla.reader(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .forType(LenientBean.class).readValue(doc); + LenientBean m = mapper.reader(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .forType(LenientBean.class).readValue(doc); + assertEngaged(seen, LenientBean.class); + assertEquals(v.i, m.i); + } + + @Test + public void testIgnoredSetSkipsAndFailOnIgnored() throws Exception { + Map, ValueDeserializer> seen = new ConcurrentHashMap<>(); + ObjectMapper mapper = capturingMapper(seen); + String doc = a2q("{'i':1,'secret':'boo','s':'x'}"); + IgnoredSetBean v = vanilla.readValue(doc, IgnoredSetBean.class); + IgnoredSetBean m = mapper.readValue(doc, IgnoredSetBean.class); + assertEngaged(seen, IgnoredSetBean.class); + assertEquals(v.i, m.i); + assertEquals(v.s, m.s); + + IgnoredPropertyException ve = assertThrows(IgnoredPropertyException.class, + () -> vanilla.reader(DeserializationFeature.FAIL_ON_IGNORED_PROPERTIES) + .forType(IgnoredSetBean.class).readValue(doc)); + IgnoredPropertyException me = assertThrows(IgnoredPropertyException.class, + () -> mapper.reader(DeserializationFeature.FAIL_ON_IGNORED_PROPERTIES) + .forType(IgnoredSetBean.class).readValue(doc)); + assertEquals(message(ve), message(me)); + } + + @Test + public void testIncludedSetIgnoresOthers() throws Exception { + Map, ValueDeserializer> seen = new ConcurrentHashMap<>(); + ObjectMapper mapper = capturingMapper(seen); + String doc = a2q("{'i':1,'s':'x'}"); + IncludedSetBean v = vanilla.readValue(doc, IncludedSetBean.class); + IncludedSetBean m = mapper.readValue(doc, IncludedSetBean.class); + assertEngaged(seen, IncludedSetBean.class); + assertEquals(v.i, m.i); + assertEquals(v.s, m.s); + assertNull(m.s); + } + + @Test + public void testJsonIgnoredNameInDocument() throws Exception { + Map, ValueDeserializer> seen = new ConcurrentHashMap<>(); + ObjectMapper mapper = capturingMapper(seen); + String doc = a2q("{'i':1,'hidden':'boo'}"); + JsonIgnoreBean v = vanilla.readValue(doc, JsonIgnoreBean.class); + JsonIgnoreBean m = mapper.readValue(doc, JsonIgnoreBean.class); + assertEngaged(seen, JsonIgnoreBean.class); + assertEquals(v.i, m.i); + assertNull(m.hidden); + } + + @Test + public void testUnknownStillReportedWithIgnoredSet() throws Exception { + // An unknown name outside the ignored set still runs the stock + // unknown handling (throws under per-call FAIL_ON_UNKNOWN). + Map, ValueDeserializer> seen = new ConcurrentHashMap<>(); + ObjectMapper mapper = capturingMapper(seen); + String doc = a2q("{'i':1,'other':true}"); + Exception ve = assertThrows(Exception.class, + () -> vanilla.reader(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .forType(IgnoredSetBean.class).readValue(doc)); + Exception me = assertThrows(Exception.class, + () -> mapper.reader(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .forType(IgnoredSetBean.class).readValue(doc)); + assertEngaged(seen, IgnoredSetBean.class); + assertEquals(ve.getClass(), me.getClass()); + assertEquals(message(ve), message(me)); + } + + @Test + public void testIgnoreUnknownRecord() throws Exception { + Map, ValueDeserializer> seen = new ConcurrentHashMap<>(); + ObjectMapper mapper = capturingMapper(seen); + String doc = a2q("{'name':'a','junk':[1,{'x':2}],'count':3}"); + assertEquals(vanilla.readValue(doc, LenientRecord.class), + mapper.readValue(doc, LenientRecord.class)); + assertEngaged(seen, LenientRecord.class); + } +} diff --git a/blackbird/src/test/java/tools/jackson/module/blackbird/deser/BBCodecMatcherTest.java b/blackbird/src/test/java/tools/jackson/module/blackbird/deser/BBCodecMatcherTest.java new file mode 100644 index 000000000..75a285cfe --- /dev/null +++ b/blackbird/src/test/java/tools/jackson/module/blackbird/deser/BBCodecMatcherTest.java @@ -0,0 +1,171 @@ +package tools.jackson.module.blackbird.deser; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import com.fasterxml.jackson.annotation.JsonAlias; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonProperty; + +import org.junit.jupiter.api.Test; + +import tools.jackson.databind.BeanDescription; +import tools.jackson.databind.DeserializationConfig; +import tools.jackson.databind.MapperFeature; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.ValueDeserializer; +import tools.jackson.databind.deser.ValueDeserializerModifier; +import tools.jackson.databind.exc.MismatchedInputException; +import tools.jackson.databind.module.SimpleModule; +import tools.jackson.module.blackbird.BlackbirdModule; +import tools.jackson.module.blackbird.BlackbirdTestBase; +import tools.jackson.databind.json.JsonMapper; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Name-matching parity: case-insensitive mappers use the core CI matcher + * instead of demoting, and property aliases map to the same generated arm the + * way stock BeanPropertyMap appends them. Every case compares against vanilla. + */ +public class BBCodecMatcherTest extends BlackbirdTestBase +{ + public static class AliasBean { + @JsonAlias({ "identifier", "the_id" }) + public int id; + public String name; + } + + public record AliasRecord(@JsonAlias("nm") @JsonProperty(required = true) String name, + int count) { } + + public static class PlainBean { + public int count; + public String label; + } + + @JsonFormat(with = JsonFormat.Feature.ACCEPT_CASE_INSENSITIVE_PROPERTIES) + public static class FormatCIBean { + public int count; + public String label; + } + + private final ObjectMapper vanilla = newVanillaJSONMapper(); + + private static ObjectMapper capturingMapper(Map, ValueDeserializer> seen, + boolean caseInsensitive) { + 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) { + seen.put(ref.getBeanClass(), d); + return d; + } + }); + } + }; + JsonMapper.Builder b = + JsonMapper.builder() + .addModule(capture) + .addModule(new BlackbirdModule()); + if (caseInsensitive) { + b = b.enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_PROPERTIES); + } + return b.build(); + } + + private static void assertEngaged(Map, ValueDeserializer> seen, Class cls) { + ValueDeserializer d = seen.get(cls); + assertNotNull(d, "no deserializer captured for " + cls); + assertEquals("BBReaderPlaceholder", d.getClass().getSimpleName(), + cls.getSimpleName() + " did not engage a codec"); + } + + @Test + public void testCaseInsensitiveMapper() throws Exception { + Map, ValueDeserializer> seen = new ConcurrentHashMap<>(); + ObjectMapper mapper = capturingMapper(seen, true); + ObjectMapper vci = JsonMapper.builder() + .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_PROPERTIES) + .build(); + String doc = a2q("{'COUNT':7,'Label':'x'}"); + PlainBean v = vci.readValue(doc, PlainBean.class); + PlainBean m = mapper.readValue(doc, PlainBean.class); + assertEngaged(seen, PlainBean.class); + assertEquals(v.count, m.count); + assertEquals(v.label, m.label); + assertEquals(7, m.count); + } + + @Test + public void testCaseInsensitiveUnknownStillUnknown() throws Exception { + Map, ValueDeserializer> seen = new ConcurrentHashMap<>(); + ObjectMapper mapper = capturingMapper(seen, true); + String doc = a2q("{'COUNT':7,'zzz':1}"); + PlainBean m = mapper.readValue(doc, PlainBean.class); + assertEquals(7, m.count); + assertEngaged(seen, PlainBean.class); + } + + @Test + public void testAliasNamesHitTheSameArm() throws Exception { + Map, ValueDeserializer> seen = new ConcurrentHashMap<>(); + ObjectMapper mapper = capturingMapper(seen, false); + for (String key : new String[] { "id", "identifier", "the_id" }) { + String doc = a2q("{'" + key + "':42,'name':'x'}"); + AliasBean v = vanilla.readValue(doc, AliasBean.class); + AliasBean m = mapper.readValue(doc, AliasBean.class); + assertEquals(v.id, m.id, "via " + key); + assertEquals(42, m.id, "via " + key); + assertEquals("x", m.name); + } + assertEngaged(seen, AliasBean.class); + } + + @Test + public void testAliasSatisfiesRequiredRecordComponent() throws Exception { + Map, ValueDeserializer> seen = new ConcurrentHashMap<>(); + ObjectMapper mapper = capturingMapper(seen, false); + String doc = a2q("{'nm':'a','count':3}"); + assertEquals(vanilla.readValue(doc, AliasRecord.class), + mapper.readValue(doc, AliasRecord.class)); + assertEngaged(seen, AliasRecord.class); + + assertThrows(MismatchedInputException.class, + () -> mapper.readValue(a2q("{'count':3}"), AliasRecord.class)); + } + + @Test + public void testAliasUnderCaseInsensitiveMapper() throws Exception { + Map, ValueDeserializer> seen = new ConcurrentHashMap<>(); + ObjectMapper mapper = capturingMapper(seen, true); + ObjectMapper vci = JsonMapper.builder() + .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_PROPERTIES) + .build(); + String doc = a2q("{'IDENTIFIER':42,'NAME':'x'}"); + AliasBean v = vci.readValue(doc, AliasBean.class); + AliasBean m = mapper.readValue(doc, AliasBean.class); + assertEngaged(seen, AliasBean.class); + assertEquals(v.id, m.id); + assertEquals(v.name, m.name); + } + + @Test + public void testClassLevelFormatCaseInsensitivity() throws Exception { + // Class-level @JsonFormat CI arrives through createContextual; the + // placeholder falls back to the stock contextual instance, so behavior + // must match vanilla whichever path serves it. + ObjectMapper mapper = newObjectMapper(); + String doc = a2q("{'CoUnT':5,'LABEL':'y'}"); + FormatCIBean v = vanilla.readValue(doc, FormatCIBean.class); + FormatCIBean m = mapper.readValue(doc, FormatCIBean.class); + assertEquals(v.count, m.count); + assertEquals(v.label, m.label); + } +} diff --git a/blackbird/src/test/java/tools/jackson/module/blackbird/deser/BBCodecStrictFeaturesTest.java b/blackbird/src/test/java/tools/jackson/module/blackbird/deser/BBCodecStrictFeaturesTest.java new file mode 100644 index 000000000..5874ea4f5 --- /dev/null +++ b/blackbird/src/test/java/tools/jackson/module/blackbird/deser/BBCodecStrictFeaturesTest.java @@ -0,0 +1,212 @@ +package tools.jackson.module.blackbird.deser; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import org.junit.jupiter.api.Test; + +import tools.jackson.databind.BeanDescription; +import tools.jackson.databind.DeserializationConfig; +import tools.jackson.databind.DeserializationFeature; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.ValueDeserializer; +import tools.jackson.databind.deser.ValueDeserializerModifier; +import tools.jackson.databind.exc.MismatchedInputException; +import tools.jackson.databind.exc.UnrecognizedPropertyException; +import tools.jackson.databind.module.SimpleModule; +import tools.jackson.module.blackbird.BlackbirdModule; +import tools.jackson.module.blackbird.BlackbirdTestBase; +import tools.jackson.databind.json.JsonMapper; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Strict-feature parity with vanilla, both per-call and enabled at build: + * FAIL_ON_UNKNOWN_PROPERTIES, FAIL_ON_MISSING_CREATOR_PROPERTIES, and + * FAIL_ON_NULL_CREATOR_PROPERTIES. Per-call cases warm the codec with a + * default read first, so the strict read runs through the cached generated + * codec rather than deciding generation. Every case compares against vanilla. + */ +public class BBCodecStrictFeaturesTest extends BlackbirdTestBase +{ + public static class SimpleBean { + public int i; + public String s; + } + + public record RefRecord(String name, int count) { } + + public record ReqRecord(@JsonProperty(required = true) String name, int count) { } + + private final ObjectMapper vanilla = newVanillaJSONMapper(); + + private static String message(Exception e) { + String m = e.getMessage(); + int nl = m.indexOf('\n'); + return (nl < 0) ? m : m.substring(0, nl); + } + + /** Blackbird mapper with a capture modifier, so engagement is assertable. */ + private static ObjectMapper capturingMapper(Map, ValueDeserializer> seen, + DeserializationFeature... enable) { + 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) { + seen.put(ref.getBeanClass(), d); + return d; + } + }); + } + }; + JsonMapper.Builder b = + JsonMapper.builder() + .addModule(capture) + .addModule(new BlackbirdModule()); + for (DeserializationFeature f : enable) { + b = b.enable(f); + } + return b.build(); + } + + private static void assertEngaged(Map, ValueDeserializer> seen, Class cls) { + ValueDeserializer d = seen.get(cls); + assertNotNull(d, "no deserializer captured for " + cls); + assertEquals("BBReaderPlaceholder", d.getClass().getSimpleName(), + cls.getSimpleName() + " did not engage a codec"); + } + + // FAIL_ON_UNKNOWN_PROPERTIES enabled at build time: the bean still + // engages a codec, and the unknown property throws exactly like vanilla. + + @Test + public void testBuildTimeFailOnUnknown() throws Exception { + Map, ValueDeserializer> seen = new ConcurrentHashMap<>(); + ObjectMapper strict = capturingMapper(seen, + DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); + ObjectMapper vstrict = JsonMapper.builder() + .enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .build(); + + SimpleBean ok = strict.readValue(a2q("{'i':1,'s':'x'}"), SimpleBean.class); + assertEquals(1, ok.i); + assertEngaged(seen, SimpleBean.class); + + String doc = a2q("{'i':1,'nope':true,'s':'x'}"); + UnrecognizedPropertyException ve = assertThrows(UnrecognizedPropertyException.class, + () -> vstrict.readValue(doc, SimpleBean.class)); + UnrecognizedPropertyException me = assertThrows(UnrecognizedPropertyException.class, + () -> strict.readValue(doc, SimpleBean.class)); + assertEquals(message(ve), message(me)); + assertEquals(ve.getPath().toString(), me.getPath().toString()); + } + + // FAIL_ON_MISSING_CREATOR_PROPERTIES + + @Test + public void testBuildTimeFailOnMissingCreator() throws Exception { + Map, ValueDeserializer> seen = new ConcurrentHashMap<>(); + ObjectMapper strict = capturingMapper(seen, + DeserializationFeature.FAIL_ON_MISSING_CREATOR_PROPERTIES); + String doc = a2q("{'name':'x'}"); + MismatchedInputException ve = assertThrows(MismatchedInputException.class, + () -> vanilla.reader(DeserializationFeature.FAIL_ON_MISSING_CREATOR_PROPERTIES) + .forType(RefRecord.class).readValue(doc)); + MismatchedInputException me = assertThrows(MismatchedInputException.class, + () -> strict.readValue(doc, RefRecord.class)); + assertEquals(ve.getClass(), me.getClass()); + assertEngaged(seen, RefRecord.class); + } + + @Test + public void testPerCallFailOnMissingCreator() throws Exception { + Map, ValueDeserializer> seen = new ConcurrentHashMap<>(); + ObjectMapper mapper = capturingMapper(seen); + // Warm: the codec generates under default config and stays cached. + assertEquals(new RefRecord("a", 1), + mapper.readValue(a2q("{'name':'a','count':1}"), RefRecord.class)); + assertEngaged(seen, RefRecord.class); + + String doc = a2q("{'name':'x'}"); + MismatchedInputException ve = assertThrows(MismatchedInputException.class, + () -> vanilla.reader(DeserializationFeature.FAIL_ON_MISSING_CREATOR_PROPERTIES) + .forType(RefRecord.class).readValue(doc)); + MismatchedInputException me = assertThrows(MismatchedInputException.class, + () -> mapper.reader(DeserializationFeature.FAIL_ON_MISSING_CREATOR_PROPERTIES) + .forType(RefRecord.class).readValue(doc)); + assertEquals(ve.getClass(), me.getClass()); + } + + // FAIL_ON_NULL_CREATOR_PROPERTIES + + @Test + public void testPerCallFailOnNullCreatorExplicitNull() throws Exception { + Map, ValueDeserializer> seen = new ConcurrentHashMap<>(); + ObjectMapper mapper = capturingMapper(seen); + assertEquals(new RefRecord("a", 1), + mapper.readValue(a2q("{'name':'a','count':1}"), RefRecord.class)); + assertEngaged(seen, RefRecord.class); + + String doc = a2q("{'name':null,'count':3}"); + MismatchedInputException ve = assertThrows(MismatchedInputException.class, + () -> vanilla.reader(DeserializationFeature.FAIL_ON_NULL_CREATOR_PROPERTIES) + .forType(RefRecord.class).readValue(doc)); + MismatchedInputException me = assertThrows(MismatchedInputException.class, + () -> mapper.reader(DeserializationFeature.FAIL_ON_NULL_CREATOR_PROPERTIES) + .forType(RefRecord.class).readValue(doc)); + assertEquals(message(ve), message(me)); + } + + @Test + public void testPerCallFailOnNullCreatorMissingComponent() throws Exception { + Map, ValueDeserializer> seen = new ConcurrentHashMap<>(); + ObjectMapper mapper = capturingMapper(seen); + assertEquals(new RefRecord("a", 1), + mapper.readValue(a2q("{'name':'a','count':1}"), RefRecord.class)); + assertEngaged(seen, RefRecord.class); + + String doc = a2q("{'count':3}"); + MismatchedInputException ve = assertThrows(MismatchedInputException.class, + () -> vanilla.reader(DeserializationFeature.FAIL_ON_NULL_CREATOR_PROPERTIES) + .forType(RefRecord.class).readValue(doc)); + MismatchedInputException me = assertThrows(MismatchedInputException.class, + () -> mapper.reader(DeserializationFeature.FAIL_ON_NULL_CREATOR_PROPERTIES) + .forType(RefRecord.class).readValue(doc)); + assertEquals(message(ve), message(me)); + } + + @Test + public void testFailOnNullCreatorOffMatchesVanilla() throws Exception { + ObjectMapper mapper = newObjectMapper(); + String doc = a2q("{'name':null,'count':3}"); + assertEquals(vanilla.readValue(doc, RefRecord.class), + mapper.readValue(doc, RefRecord.class)); + } + + @Test + public void testBuildTimeFailOnNullCreator() throws Exception { + Map, ValueDeserializer> seen = new ConcurrentHashMap<>(); + ObjectMapper strict = capturingMapper(seen, + DeserializationFeature.FAIL_ON_NULL_CREATOR_PROPERTIES); + assertThrows(MismatchedInputException.class, + () -> strict.readValue(a2q("{'name':null,'count':3}"), RefRecord.class)); + assertEngaged(seen, RefRecord.class); + } + + // Required stays enforced regardless of features + + @Test + public void testRequiredComponentStillEnforced() throws Exception { + ObjectMapper mapper = newObjectMapper(); + assertThrows(MismatchedInputException.class, + () -> mapper.readValue(a2q("{'count':3}"), ReqRecord.class)); + } +} diff --git a/blackbird/src/test/java/tools/jackson/module/blackbird/deser/BBCodecViewTest.java b/blackbird/src/test/java/tools/jackson/module/blackbird/deser/BBCodecViewTest.java new file mode 100644 index 000000000..9601c4e65 --- /dev/null +++ b/blackbird/src/test/java/tools/jackson/module/blackbird/deser/BBCodecViewTest.java @@ -0,0 +1,277 @@ +package tools.jackson.module.blackbird.deser; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import com.fasterxml.jackson.annotation.JsonView; + +import org.junit.jupiter.api.Test; + +import tools.jackson.databind.BeanDescription; +import tools.jackson.databind.DeserializationConfig; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.ValueDeserializer; +import tools.jackson.databind.deser.ValueDeserializerModifier; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.module.SimpleModule; +import tools.jackson.databind.MapperFeature; +import tools.jackson.module.blackbird.BlackbirdModule; +import tools.jackson.module.blackbird.BlackbirdTestBase; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * View-parity regression tests: with an active view the generated codecs stay + * on the fast path and test a per-view visibility bitmask per property + * (visibility read from the stock property, so DEFAULT_VIEW_INCLUSION and + * matcher semantics are exactly stock); a hidden property consumes its value + * or is omitted like stock. Beans with more than 64 properties instead + * delegate the whole view-active call to the stock fallback. Every case + * compares against a vanilla mapper, in both directions, with and without an + * active view, so view semantics come from stock rather than hardcoded + * expectations. + */ +public class BBCodecViewTest extends BlackbirdTestBase +{ + public static class ViewA { } + public static class ViewB { } + + public static class ViewBean { + private String a; + private int b; + private String plain; + + @JsonView(ViewA.class) + public String getA() { return a; } + public void setA(String v) { a = v; } + + @JsonView(ViewB.class) + public int getB() { return b; } + public void setB(int v) { b = v; } + + public String getPlain() { return plain; } + public void setPlain(String v) { plain = v; } + } + + public static class ViewFieldBean { + @JsonView(ViewA.class) + public String a; + @JsonView(ViewB.class) + public int b; + } + + public record ViewRecord( + @JsonView(ViewA.class) String a, + @JsonView(ViewB.class) int b) { } + + public static class Parent { + private ViewBean child; + // Visible in both views, so the child itself deserializes and its own + // per-property view filtering is what the test exercises. + @JsonView({ ViewA.class, ViewB.class }) + public ViewBean getChild() { return child; } + public void setChild(ViewBean c) { child = c; } + } + + private static final ObjectMapper VANILLA = new JsonMapper(); + private static final ObjectMapper MODULE = newObjectMapper(); + + private static final String DOC = "{\"a\":\"x\",\"b\":7,\"plain\":\"p\"}"; + + private static ViewBean bean() { + ViewBean bean = new ViewBean(); + bean.setA("x"); + bean.setB(7); + bean.setPlain("p"); + return bean; + } + + @Test + public void testSerializeMatchesVanillaPerView() throws Exception { + for (Class view : new Class[] { ViewA.class, ViewB.class }) { + assertEquals(VANILLA.writerWithView(view).writeValueAsString(bean()), + MODULE.writerWithView(view).writeValueAsString(bean()), + "view " + view.getSimpleName()); + } + assertEquals(VANILLA.writeValueAsString(bean()), MODULE.writeValueAsString(bean()), + "no active view"); + } + + @Test + public void testDeserializeMatchesVanillaPerView() throws Exception { + for (Class view : new Class[] { ViewA.class, ViewB.class }) { + ViewBean expected = VANILLA.readerWithView(view).forType(ViewBean.class).readValue(DOC); + ViewBean actual = MODULE.readerWithView(view).forType(ViewBean.class).readValue(DOC); + assertEquals(expected.getA(), actual.getA(), "a under " + view.getSimpleName()); + assertEquals(expected.getB(), actual.getB(), "b under " + view.getSimpleName()); + assertEquals(expected.getPlain(), actual.getPlain(), + "plain under " + view.getSimpleName()); + } + } + + @Test + public void testViewReadConsumesSkippedValues() throws Exception { + // Non-visible properties must be consumed exactly as stock consumes + // them: a top-level array derails if the skip leaves the stream + // mispositioned. + String doc = "[" + DOC + "," + DOC + "]"; + List beans = MODULE.readerWithView(ViewA.class) + .forType(MODULE.getTypeFactory() + .constructCollectionType(List.class, ViewBean.class)) + .readValue(doc); + assertEquals(2, beans.size()); + assertEquals("x", beans.get(1).getA()); + assertEquals(0, beans.get(1).getB(), + "@JsonView(ViewB) property must stay default with ViewA active"); + } + + @Test + public void testFieldBeanMatchesVanillaPerView() throws Exception { + ViewFieldBean bean = new ViewFieldBean(); + bean.a = "x"; + bean.b = 7; + String doc = "{\"a\":\"x\",\"b\":7}"; + for (Class view : new Class[] { ViewA.class, ViewB.class }) { + assertEquals(VANILLA.writerWithView(view).writeValueAsString(bean), + MODULE.writerWithView(view).writeValueAsString(bean)); + ViewFieldBean expected = VANILLA.readerWithView(view) + .forType(ViewFieldBean.class).readValue(doc); + ViewFieldBean actual = MODULE.readerWithView(view) + .forType(ViewFieldBean.class).readValue(doc); + assertEquals(expected.a, actual.a); + assertEquals(expected.b, actual.b); + } + } + + @Test + public void testRecordMatchesVanillaPerView() throws Exception { + String doc = "{\"a\":\"x\",\"b\":7}"; + for (Class view : new Class[] { ViewA.class, ViewB.class }) { + ViewRecord expected = VANILLA.readerWithView(view) + .forType(ViewRecord.class).readValue(doc); + ViewRecord actual = MODULE.readerWithView(view) + .forType(ViewRecord.class).readValue(doc); + assertEquals(expected, actual, "record under " + view.getSimpleName()); + assertEquals(VANILLA.writerWithView(view).writeValueAsString(expected), + MODULE.writerWithView(view).writeValueAsString(actual)); + } + } + + @Test + public void testNestedCodecChildMatchesVanillaPerView() throws Exception { + Parent parent = new Parent(); + parent.setChild(bean()); + String doc = "{\"child\":" + DOC + "}"; + for (Class view : new Class[] { ViewA.class, ViewB.class }) { + assertEquals(VANILLA.writerWithView(view).writeValueAsString(parent), + MODULE.writerWithView(view).writeValueAsString(parent)); + Parent expected = VANILLA.readerWithView(view).forType(Parent.class).readValue(doc); + Parent actual = MODULE.readerWithView(view).forType(Parent.class).readValue(doc); + assertEquals(expected.getChild().getA(), actual.getChild().getA()); + assertEquals(expected.getChild().getB(), actual.getChild().getB()); + } + } + + @Test + public void testViewBeansEngageCodecs() throws Exception { + // The cases above only pin view parity if the codecs actually engage + // for these beans when no view is active. + Map, ValueDeserializer> seen = new ConcurrentHashMap<>(); + ValueDeserializerModifier capture = new ValueDeserializerModifier() { + private static final long serialVersionUID = 1L; + @Override + public ValueDeserializer modifyDeserializer(DeserializationConfig config, + BeanDescription.Supplier beanDescRef, ValueDeserializer deserializer) { + seen.put(beanDescRef.getBeanClass(), deserializer); + return deserializer; + } + }; + ObjectMapper m = JsonMapper.builder() + .addModule(new SimpleModule("capture").setDeserializerModifier(capture)) + .addModule(new BlackbirdModule()) + .build(); + m.readValue(DOC, ViewBean.class); + m.readValue("{\"a\":\"x\",\"b\":7}", ViewRecord.class); + assertEquals("BBReaderPlaceholder", seen.get(ViewBean.class).getClass().getSimpleName()); + assertEquals("BBReaderPlaceholder", seen.get(ViewRecord.class).getClass().getSimpleName()); + } + + // A plain bean with no @JsonView annotations: with DEFAULT_VIEW_INCLUSION + // off, stock hides every property under any active view on the write side + // and keeps them all on the read side (the deser matcher is null). The + // codec must reproduce both, which forces the MASK strategy for a bean + // that carries no view annotations at all. + public static class PlainBean { + private String a; + private int b; + public String getA() { return a; } + public void setA(String v) { a = v; } + public int getB() { return b; } + public void setB(int v) { b = v; } + } + + @Test + public void testDefaultViewInclusionOffMatchesVanilla() throws Exception { + ObjectMapper vanilla = JsonMapper.builder() + .disable(MapperFeature.DEFAULT_VIEW_INCLUSION) + .build(); + ObjectMapper module = JsonMapper.builder() + .disable(MapperFeature.DEFAULT_VIEW_INCLUSION) + .addModule(new BlackbirdModule()) + .build(); + PlainBean bean = new PlainBean(); + bean.setA("x"); + bean.setB(7); + String doc = "{\"a\":\"x\",\"b\":7}"; + assertEquals(vanilla.writerWithView(ViewA.class).writeValueAsString(bean), + module.writerWithView(ViewA.class).writeValueAsString(bean), + "write, inclusion off, active view: both should omit all"); + PlainBean ev = vanilla.readerWithView(ViewA.class).forType(PlainBean.class).readValue(doc); + PlainBean am = module.readerWithView(ViewA.class).forType(PlainBean.class).readValue(doc); + assertEquals(ev.getA(), am.getA()); + assertEquals(ev.getB(), am.getB()); + } + + // More than 64 properties: the mask cannot fit, so an active view delegates + // the whole call. Output must still match vanilla in both directions. + public static class WideViewBean { + public int p0, p1, p2, p3, p4, p5, p6, p7, p8, p9, p10, p11, p12, p13, p14, p15; + public int p16, p17, p18, p19, p20, p21, p22, p23, p24, p25, p26, p27, p28, p29, p30, p31; + public int p32, p33, p34, p35, p36, p37, p38, p39, p40, p41, p42, p43, p44, p45, p46, p47; + public int p48, p49, p50, p51, p52, p53, p54, p55, p56, p57, p58, p59, p60, p61, p62, p63; + @JsonView(ViewA.class) + public int p64; + public int p65; + } + + @Test + public void testWideBeanDelegatesUnderView() throws Exception { + WideViewBean bean = new WideViewBean(); + bean.p64 = 64; + bean.p65 = 65; + for (Class view : new Class[] { ViewA.class, ViewB.class }) { + assertEquals(VANILLA.writerWithView(view).writeValueAsString(bean), + MODULE.writerWithView(view).writeValueAsString(bean), + "wide bean under " + view.getSimpleName()); + } + assertEquals(VANILLA.writeValueAsString(bean), MODULE.writeValueAsString(bean), + "wide bean, no view"); + } + + // A codec whose bean has 64 or fewer properties keeps view-active calls on + // the generated mask path (only the wide bean above delegates). This + // check pins that the mask read is byte-faithful to stock: the ViewB + // property is hidden under ViewA, the visible ones match, and the skipped + // value leaves the stream correctly positioned for the trailing property. + @Test + public void testViewActiveMaskMatchesVanilla() throws Exception { + ViewBean expected = VANILLA.readerWithView(ViewA.class) + .forType(ViewBean.class).readValue(DOC); + ViewBean actual = MODULE.readerWithView(ViewA.class) + .forType(ViewBean.class).readValue(DOC); + assertEquals(expected.getA(), actual.getA()); + assertEquals(expected.getB(), actual.getB()); + assertEquals(expected.getPlain(), actual.getPlain()); + } +} diff --git a/blackbird/src/test/java/tools/jackson/module/blackbird/deser/BBReaderEntryTest.java b/blackbird/src/test/java/tools/jackson/module/blackbird/deser/BBReaderEntryTest.java new file mode 100644 index 000000000..50cb96b19 --- /dev/null +++ b/blackbird/src/test/java/tools/jackson/module/blackbird/deser/BBReaderEntryTest.java @@ -0,0 +1,214 @@ +package tools.jackson.module.blackbird.deser; + +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; + +import org.junit.jupiter.api.Test; + +import tools.jackson.core.JsonParser; +import tools.jackson.core.JsonToken; +import tools.jackson.databind.BeanDescription; +import tools.jackson.databind.DeserializationConfig; +import tools.jackson.databind.DeserializationContext; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.ValueDeserializer; +import tools.jackson.databind.deser.ValueDeserializerModifier; +import tools.jackson.databind.exc.MismatchedInputException; +import tools.jackson.databind.deser.std.DelegatingDeserializer; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.module.SimpleModule; +import tools.jackson.module.blackbird.BlackbirdTestBase; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * PROPERTY_NAME entry through generated readers. The AsProperty polymorphic + * path hands a subtype deserializer a stream positioned on the property after + * the type id (or a buffered-replay sequence starting on one), so generated + * readers accept that entry natively instead of delegating. Every behavior + * case compares against a vanilla mapper. + */ +public class BBReaderEntryTest extends BlackbirdTestBase +{ + @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "type") + @JsonSubTypes({ + @JsonSubTypes.Type(value = Dog.class, name = "dog"), + @JsonSubTypes.Type(value = RecCat.class, name = "cat") + }) + public interface Animal { } + + public static class Dog implements Animal { + public String name; + public int age; + public boolean good; + } + + public record RecCat(@JsonProperty(required = true) String name, int lives) + implements Animal { } + + public static class Plain { + public String s; + public int i; + } + + private final ObjectMapper mapper = newObjectMapper(); + private final ObjectMapper vanilla = newVanillaJSONMapper(); + + // Pins the databind contract the native entry depends on: the AsProperty + // type deserializer invokes the subtype deserializer with the stream on + // PROPERTY_NAME, for the id-first (live stream) and id-last (buffered + // sequence) orders both. Runs against vanilla, no Blackbird. + @Test + public void databindHandsPropertyNameEntry() throws Exception + { + ConcurrentMap entries = new ConcurrentHashMap<>(); + ObjectMapper spied = JsonMapper.builder() + .addModule(new SimpleModule("spy") { + 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 config, + BeanDescription.Supplier beanDescRef, + ValueDeserializer deser) { + if (beanDescRef.getBeanClass() != Dog.class) { + return deser; + } + return new EntrySpy(deser, entries); + } + }); + } + }) + .build(); + + spied.readValue(a2q("{'type':'dog','name':'Rex','age':3,'good':true}"), Animal.class); + assertEquals(JsonToken.PROPERTY_NAME, entries.get("entry"), "id-first entry"); + + spied.readValue(a2q("{'name':'Rex','age':3,'good':true,'type':'dog'}"), Animal.class); + assertEquals(JsonToken.PROPERTY_NAME, entries.get("entry"), "id-last (buffered) entry"); + } + + @Test + public void polymorphicTypeIdFirst() throws Exception + { + String doc = a2q("{'type':'dog','name':'Rex','age':3,'good':true}"); + Dog d = (Dog) mapper.readValue(doc, Animal.class); + Dog v = (Dog) vanilla.readValue(doc, Animal.class); + assertEquals(v.name, d.name); + assertEquals(v.age, d.age); + assertEquals(v.good, d.good); + } + + @Test + public void polymorphicTypeIdLast() throws Exception + { + String doc = a2q("{'name':'Rex','age':3,'good':true,'type':'dog'}"); + Dog d = (Dog) mapper.readValue(doc, Animal.class); + Dog v = (Dog) vanilla.readValue(doc, Animal.class); + assertEquals(v.name, d.name); + assertEquals(v.age, d.age); + assertEquals(v.good, d.good); + } + + @Test + public void polymorphicRecordSubtype() throws Exception + { + String doc = a2q("{'type':'cat','name':'Mia','lives':9}"); + assertEquals(vanilla.readValue(doc, Animal.class), mapper.readValue(doc, Animal.class)); + + String middle = a2q("{'name':'Mia','type':'cat','lives':9}"); + assertEquals(vanilla.readValue(middle, Animal.class), + mapper.readValue(middle, Animal.class)); + + // Required component missing: same failure both stacks, through the + // PROPERTY_NAME entry. + String missing = a2q("{'type':'cat','lives':9}"); + MismatchedInputException expected = assertThrows(MismatchedInputException.class, + () -> vanilla.readValue(missing, Animal.class)); + MismatchedInputException actual = assertThrows(MismatchedInputException.class, + () -> mapper.readValue(missing, Animal.class)); + assertEquals(expected.getClass(), actual.getClass()); + } + + @Test + public void polymorphicIdOnlyEmptyRemainder() throws Exception + { + // Nothing after the type id: the subtype deserializer enters at + // END_OBJECT, which stays on the stock delegation path. + String doc = a2q("{'type':'dog'}"); + Dog d = (Dog) mapper.readValue(doc, Animal.class); + Dog v = (Dog) vanilla.readValue(doc, Animal.class); + assertEquals(v.name, d.name); + assertEquals(v.age, d.age); + } + + @Test + public void readValueAtPropertyName() throws Exception + { + String doc = a2q("{'s':'x','i':7}"); + Plain p1 = readPositioned(mapper, doc); + Plain p2 = readPositioned(vanilla, doc); + assertEquals(p2.s, p1.s); + assertEquals(p2.i, p1.i); + } + + @Test + public void unknownFirstNameAtEntry() throws Exception + { + String doc = a2q("{'mystery':[1,2,{'x':3}],'s':'x','i':7}"); + Plain p1 = readPositioned(mapper, doc); + Plain p2 = readPositioned(vanilla, doc); + assertEquals(p2.s, p1.s); + assertEquals(p2.i, p1.i); + } + + @Test + public void polymorphicList() throws Exception + { + String doc = a2q("[{'type':'dog','name':'a','age':1,'good':false}," + + "{'type':'cat','name':'b','lives':2}]"); + List got = mapper.readerForListOf(Animal.class).readValue(doc); + List want = vanilla.readerForListOf(Animal.class).readValue(doc); + assertEquals(want.size(), got.size()); + assertEquals(((Dog) want.get(0)).name, ((Dog) got.get(0)).name); + assertEquals(want.get(1), got.get(1)); + } + + static class EntrySpy extends DelegatingDeserializer { + private final ConcurrentMap entries; + + EntrySpy(ValueDeserializer d, ConcurrentMap entries) { + super(d); + this.entries = entries; + } + + @Override + protected ValueDeserializer newDelegatingInstance(ValueDeserializer newDelegatee) { + return new EntrySpy(newDelegatee, entries); + } + + @Override + public Object deserialize(JsonParser p, DeserializationContext ctxt) { + entries.put("entry", p.currentToken()); + return super.deserialize(p, ctxt); + } + } + + private static Plain readPositioned(ObjectMapper m, String doc) throws Exception + { + try (JsonParser p = m.createParser(doc)) { + assertEquals(JsonToken.START_OBJECT, p.nextToken()); + assertEquals(JsonToken.PROPERTY_NAME, p.nextToken()); + return m.readValue(p, Plain.class); + } + } +} diff --git a/blackbird/src/test/java/tools/jackson/module/blackbird/deser/BBReaderUpdatingTest.java b/blackbird/src/test/java/tools/jackson/module/blackbird/deser/BBReaderUpdatingTest.java new file mode 100644 index 000000000..19d51c98f --- /dev/null +++ b/blackbird/src/test/java/tools/jackson/module/blackbird/deser/BBReaderUpdatingTest.java @@ -0,0 +1,60 @@ +package tools.jackson.module.blackbird.deser; + +import org.junit.jupiter.api.Test; + +import tools.jackson.databind.ObjectMapper; +import tools.jackson.module.blackbird.BlackbirdTestBase; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * readerForUpdating parity: the generated reader forwards update-value reads + * to the stock deserializer, so updating must behave exactly like vanilla. + */ +public class BBReaderUpdatingTest extends BlackbirdTestBase +{ + public static class Mutable { + private int count; + private String name; + private String keep; + + public int getCount() { return count; } + public void setCount(int c) { count = c; } + public String getName() { return name; } + public void setName(String n) { name = n; } + public String getKeep() { return keep; } + public void setKeep(String k) { keep = k; } + } + + private final ObjectMapper mapper = newObjectMapper(); + private final ObjectMapper vanilla = newVanillaJSONMapper(); + + @Test + public void testUpdatingReadMatchesVanilla() throws Exception { + String doc = a2q("{'count':42,'name':'updated'}"); + + Mutable mine = new Mutable(); + mine.setKeep("original"); + mine.setName("old"); + Object result = mapper.readerForUpdating(mine).readValue(doc); + assertSame(mine, result); + + Mutable theirs = new Mutable(); + theirs.setKeep("original"); + theirs.setName("old"); + vanilla.readerForUpdating(theirs).readValue(doc); + + assertEquals(theirs.getCount(), mine.getCount()); + assertEquals(theirs.getName(), mine.getName()); + assertEquals(theirs.getKeep(), mine.getKeep()); + assertEquals("original", mine.getKeep()); + } + + @Test + public void testPlainReadStillGenerated() throws Exception { + Mutable m = mapper.readValue(a2q("{'count':7,'name':'n','keep':'k'}"), Mutable.class); + assertEquals(7, m.getCount()); + assertEquals("n", m.getName()); + assertEquals("k", m.getKeep()); + } +} diff --git a/blackbird/src/test/java/tools/jackson/module/blackbird/deser/OptionalDeser355Test.java b/blackbird/src/test/java/tools/jackson/module/blackbird/deser/OptionalDeser355Test.java index 0c8948940..7cf119641 100644 --- a/blackbird/src/test/java/tools/jackson/module/blackbird/deser/OptionalDeser355Test.java +++ b/blackbird/src/test/java/tools/jackson/module/blackbird/deser/OptionalDeser355Test.java @@ -112,35 +112,35 @@ public ValueDeserializer modifyDeserializer(DeserializationConfig config, return deserializer; } - public SettableBeanProperty propertyFor(Class beanType, String propertyName) { + public ValueDeserializer deserializerFor(Class beanType) { ValueDeserializer deserializer = deserializers.get(beanType); - BeanDeserializerBase beanDeserializer = assertInstanceOf( - BeanDeserializerBase.class, deserializer); - Iterator properties = beanDeserializer.properties(); - while (properties.hasNext()) { - SettableBeanProperty property = properties.next(); - if (property.getName().equals(propertyName)) { - return property; - } - } - return fail("No property '"+propertyName+"' found for "+beanType.getName()); + assertNotNull(deserializer, "No deserializer captured for " + beanType.getName()); + return deserializer; } } + // The new engine never swaps individual properties: eligible beans - + // these package-private ones included - get a whole generated codec, and + // every Optional-typed property rides its stock SettableBeanProperty + // inside it (the issue-355 revert cannot recur by construction). The + // original per-property assertions named the old engine's classes; their + // equivalent here is codec engagement plus behavior. @Test - public void testKeepsOptimizedPropertyForBuiltInOptionalDeserializer() throws Exception { + public void testOptionalPropertyWithBuiltInDeserializer() throws Exception { DeserializerCapture capture = new DeserializerCapture(); ObjectMapper mapper = mapperWithCapture(capture); OptionalBean bean = mapper.readValue("{\"value\":\"test\"}", OptionalBean.class); assertEquals(Optional.of("test"), bean.getValue()); - assertInstanceOf(SettableObjectProperty.class, - capture.propertyFor(OptionalBean.class, "value")); + assertEquals("BBReaderPlaceholder", + capture.deserializerFor(OptionalBean.class).getClass().getSimpleName()); + assertEquals(Optional.empty(), + mapper.readValue("{\"value\":null}", OptionalBean.class).getValue()); } @Test - public void testFallsBackForCustomOptionalDeserializer() throws Exception { + public void testOptionalPropertyWithCustomDeserializer() throws Exception { DeserializerCapture capture = new DeserializerCapture(); ObjectMapper mapper = mapperWithCapture(capture); @@ -149,18 +149,19 @@ public void testFallsBackForCustomOptionalDeserializer() throws Exception { assertEquals(Optional.of("test"), bean.getValue()); assertEquals(Optional.of(42), bean.getNumber()); - assertInstanceOf(MethodProperty.class, - capture.propertyFor(CustomOptionalBean.class, "value")); - assertInstanceOf(MethodProperty.class, - capture.propertyFor(CustomOptionalBean.class, "number")); + // The custom-deserializer properties ride the codec's stock arms. + assertEquals("BBReaderPlaceholder", + capture.deserializerFor(CustomOptionalBean.class).getClass().getSimpleName()); } private ObjectMapper mapperWithCapture(DeserializerCapture capture) { SimpleModule captureModule = new SimpleModule() .setDeserializerModifier(capture); + // Modifiers run in reverse registration order: the capture module + // registers first so it observes what Blackbird installed. return JsonMapper.builder() - .addModule(new BlackbirdModule()) .addModule(captureModule) + .addModule(new BlackbirdModule()) .build(); } } diff --git a/blackbird/src/test/java/tools/jackson/module/blackbird/deser/PackagePrivateBeanTest.java b/blackbird/src/test/java/tools/jackson/module/blackbird/deser/PackagePrivateBeanTest.java new file mode 100644 index 000000000..d722207d8 --- /dev/null +++ b/blackbird/src/test/java/tools/jackson/module/blackbird/deser/PackagePrivateBeanTest.java @@ -0,0 +1,201 @@ +package tools.jackson.module.blackbird.deser; + +import java.lang.reflect.Field; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import org.junit.jupiter.api.Test; + +import tools.jackson.databind.BeanDescription; +import tools.jackson.databind.DeserializationConfig; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.SerializationConfig; +import tools.jackson.databind.ValueDeserializer; +import tools.jackson.databind.ValueSerializer; +import tools.jackson.databind.deser.ValueDeserializerModifier; +import tools.jackson.databind.exc.MismatchedInputException; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.module.SimpleModule; +import tools.jackson.databind.ser.ValueSerializerModifier; +import tools.jackson.module.blackbird.BlackbirdModule; +import tools.jackson.module.blackbird.BlackbirdTestBase; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Non-public classes accelerate: package-private and protected static nested + * beans (and their non-private members) get generated codecs defined in the + * bean's package context, restoring the coverage the old engine had. Private + * classes stay on the stock path. Every behavioral check compares against a + * vanilla mapper. + */ +public class PackagePrivateBeanTest extends BlackbirdTestBase +{ + static class PkgBean { + private int count; + private String name; + + public int getCount() { return count; } + public void setCount(int v) { count = v; } + public String getName() { return name; } + public void setName(String v) { name = v; } + } + + static class PkgMembersBean { + int direct; + private long viaSetter; + + int getDirect() { return direct; } + void setDirect(int v) { direct = v; } + long getViaSetter() { return viaSetter; } + void setViaSetter(long v) { viaSetter = v; } + } + + private static class PrivateBean { + public int x; + } + + protected static class ProtectedBean { + public int x; + public String s; + } + + record PkgRecord(@JsonProperty(required = true) String id, int qty) { } + + static class Capture { + final Map, ValueDeserializer> desers = new ConcurrentHashMap<>(); + final Map, ValueSerializer> sers = new ConcurrentHashMap<>(); + } + + private static ObjectMapper mapperWith(Capture capture) { + SimpleModule captureModule = 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) { + capture.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) { + capture.sers.put(ref.getBeanClass(), s); + return s; + } + }); + } + }; + return JsonMapper.builder() + .addModule(captureModule) + .addModule(new BlackbirdModule()) + .build(); + } + + // The placeholder resolves its codec at resolve time; a null codec means + // the generator gated the bean, so the assertion distinguishes "engaged + // and generated" from "engaged but silently demoted". + private static void assertCodecGenerated(Object placeholder) throws Exception { + assertEquals("BBReaderPlaceholder", placeholder.getClass().getSimpleName(), + "expected the deserializer placeholder, got " + + placeholder.getClass().getName()); + Field codec = placeholder.getClass().getDeclaredField("_codec"); + codec.setAccessible(true); + assertNotNull(codec.get(placeholder), "placeholder engaged but no codec generated"); + } + + private static void assertWriterGenerated(Object placeholder) throws Exception { + assertEquals("BBWriterPlaceholder", placeholder.getClass().getSimpleName(), + "expected the serializer placeholder, got " + + placeholder.getClass().getName()); + Field codec = placeholder.getClass().getDeclaredField("_codec"); + codec.setAccessible(true); + assertNotNull(codec.get(placeholder), "placeholder engaged but no writer generated"); + } + + @Test + public void testPackagePrivateBeanAccelerates() throws Exception { + Capture capture = new Capture(); + ObjectMapper mapper = mapperWith(capture); + ObjectMapper vanilla = newVanillaJSONMapper(); + + String doc = a2q("{'count':7,'name':'x'}"); + PkgBean act = mapper.readValue(doc, PkgBean.class); + PkgBean exp = vanilla.readValue(doc, PkgBean.class); + assertEquals(exp.getCount(), act.getCount()); + assertEquals(exp.getName(), act.getName()); + assertEquals(vanilla.writeValueAsString(exp), mapper.writeValueAsString(act)); + + assertCodecGenerated(capture.desers.get(PkgBean.class)); + assertWriterGenerated(capture.sers.get(PkgBean.class)); + } + + @Test + public void testPackagePrivateMembersAccelerate() throws Exception { + Capture capture = new Capture(); + ObjectMapper mapper = mapperWith(capture); + ObjectMapper vanilla = newVanillaJSONMapper(); + + String doc = a2q("{'direct':3,'viaSetter':9000000000}"); + PkgMembersBean act = mapper.readValue(doc, PkgMembersBean.class); + PkgMembersBean exp = vanilla.readValue(doc, PkgMembersBean.class); + assertEquals(exp.getDirect(), act.getDirect()); + assertEquals(exp.getViaSetter(), act.getViaSetter()); + assertEquals(vanilla.writeValueAsString(exp), mapper.writeValueAsString(act)); + + assertCodecGenerated(capture.desers.get(PkgMembersBean.class)); + } + + @Test + public void testPrivateClassStaysStock() throws Exception { + Capture capture = new Capture(); + ObjectMapper mapper = mapperWith(capture); + + PrivateBean bean = mapper.readValue(a2q("{'x':5}"), PrivateBean.class); + assertEquals(5, bean.x); + assertNotEquals("BBReaderPlaceholder", + capture.desers.get(PrivateBean.class).getClass().getSimpleName(), + "private classes must stay on the stock deserializer"); + } + + @Test + public void testProtectedNestedBeanAccelerates() throws Exception { + Capture capture = new Capture(); + ObjectMapper mapper = mapperWith(capture); + ObjectMapper vanilla = newVanillaJSONMapper(); + + String doc = a2q("{'x':1,'s':'y'}"); + ProtectedBean act = mapper.readValue(doc, ProtectedBean.class); + assertEquals(1, act.x); + assertEquals("y", act.s); + assertEquals(vanilla.writeValueAsString(act), mapper.writeValueAsString(act)); + + assertCodecGenerated(capture.desers.get(ProtectedBean.class)); + } + + @Test + public void testPackagePrivateRecord() throws Exception { + Capture capture = new Capture(); + ObjectMapper mapper = mapperWith(capture); + ObjectMapper vanilla = newVanillaJSONMapper(); + + String doc = a2q("{'id':'a','qty':2}"); + assertEquals(vanilla.readValue(doc, PkgRecord.class), + mapper.readValue(doc, PkgRecord.class)); + assertCodecGenerated(capture.desers.get(PkgRecord.class)); + + MismatchedInputException e = assertThrows(MismatchedInputException.class, + () -> mapper.readValue(a2q("{'qty':2}"), PkgRecord.class)); + assertTrue(e.getMessage().contains("Missing required creator property 'id'"), + e.getMessage()); + } +} diff --git a/blackbird/src/test/java/tools/jackson/module/blackbird/ser/BBWriterPolymorphicTest.java b/blackbird/src/test/java/tools/jackson/module/blackbird/ser/BBWriterPolymorphicTest.java new file mode 100644 index 000000000..2ccff7627 --- /dev/null +++ b/blackbird/src/test/java/tools/jackson/module/blackbird/ser/BBWriterPolymorphicTest.java @@ -0,0 +1,117 @@ +package tools.jackson.module.blackbird.ser; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; + +import org.junit.jupiter.api.Test; + +import tools.jackson.databind.ObjectMapper; +import tools.jackson.module.blackbird.BlackbirdTestBase; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Polymorphic serialization parity: databind calls serializeWithType on the + * subtype's (generated) serializer, which forwards whole-call to the stock + * serializer, so output must equal vanilla for every inclusion mechanism. + */ +public class BBWriterPolymorphicTest extends BlackbirdTestBase +{ + @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "@type") + @JsonSubTypes({ + @JsonSubTypes.Type(value = Cat.class, name = "cat"), + @JsonSubTypes.Type(value = Dog.class, name = "dog") + }) + public static abstract class Animal { + public String name; + } + + public static class Cat extends Animal { + public int lives; + public boolean indoor; + + static Cat of(String name, int lives, boolean indoor) { + Cat c = new Cat(); + c.name = name; + c.lives = lives; + c.indoor = indoor; + return c; + } + } + + public static class Dog extends Animal { + public long barks; + } + + @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.WRAPPER_OBJECT) + @JsonSubTypes({ + @JsonSubTypes.Type(value = WrappedLeaf.class, name = "leaf") + }) + public static abstract class Wrapped { + } + + public static class WrappedLeaf extends Wrapped { + public String label; + public int size; + + static WrappedLeaf of(String label, int size) { + WrappedLeaf w = new WrappedLeaf(); + w.label = label; + w.size = size; + return w; + } + } + + public static class Zoo { + public String city; + public Animal star; + } + + private final ObjectMapper mapper = newObjectMapper(); + private final ObjectMapper vanilla = newVanillaJSONMapper(); + + @Test + public void testPropertyInclusionRootWrite() throws Exception { + Animal cat = Cat.of("mia", 9, true); + assertEquals(vanilla.writerFor(Animal.class).writeValueAsString(cat), + mapper.writerFor(Animal.class).writeValueAsString(cat)); + } + + @Test + public void testPropertyInclusionNestedWrite() throws Exception { + Zoo zoo = new Zoo(); + zoo.city = "sf"; + zoo.star = Cat.of("mia", 9, false); + assertEquals(vanilla.writeValueAsString(zoo), mapper.writeValueAsString(zoo)); + } + + @Test + public void testPropertyInclusionListWrite() throws Exception { + List animals = List.of(Cat.of("a", 1, true), new Dog()); + assertEquals(vanilla.writerFor(vanilla.getTypeFactory() + .constructCollectionType(List.class, Animal.class)) + .writeValueAsString(animals), + mapper.writerFor(mapper.getTypeFactory() + .constructCollectionType(List.class, Animal.class)) + .writeValueAsString(animals)); + } + + @Test + public void testWrapperObjectInclusionWrite() throws Exception { + Wrapped w = WrappedLeaf.of("x", 3); + assertEquals(vanilla.writerFor(Wrapped.class).writeValueAsString(w), + mapper.writerFor(Wrapped.class).writeValueAsString(w)); + } + + @Test + public void testPolymorphicRoundTrip() throws Exception { + Animal cat = Cat.of("mia", 9, true); + String doc = mapper.writerFor(Animal.class).writeValueAsString(cat); + Animal back = mapper.readValue(doc, Animal.class); + assertInstanceOf(Cat.class, back); + assertEquals("mia", back.name); + assertEquals(9, ((Cat) back).lives); + } +} diff --git a/blackbird/src/test/java/tools/jackson/module/blackbird/tofix/TestBBClassloaders.java b/blackbird/src/test/java/tools/jackson/module/blackbird/ser/ChildClassloaderTest.java similarity index 61% rename from blackbird/src/test/java/tools/jackson/module/blackbird/tofix/TestBBClassloaders.java rename to blackbird/src/test/java/tools/jackson/module/blackbird/ser/ChildClassloaderTest.java index 296e9a4ab..abb1ef405 100644 --- a/blackbird/src/test/java/tools/jackson/module/blackbird/tofix/TestBBClassloaders.java +++ b/blackbird/src/test/java/tools/jackson/module/blackbird/ser/ChildClassloaderTest.java @@ -1,6 +1,5 @@ -package tools.jackson.module.blackbird.tofix; +package tools.jackson.module.blackbird.ser; -import java.io.ByteArrayOutputStream; import java.io.InputStream; import java.lang.reflect.Constructor; @@ -8,31 +7,30 @@ import tools.jackson.databind.ObjectMapper; import tools.jackson.module.blackbird.BlackbirdTestBase; -import tools.jackson.module.blackbird.testutil.failure.JacksonTestFailureExpected; import static org.junit.jupiter.api.Assertions.*; -public class TestBBClassloaders extends BlackbirdTestBase +// Cross-loader regression test (formerly tofix/TestBBClassloaders): a bean +// class redefined in a child classloader serializes correctly with the module +// registered. The engine takes the stock path for such a bean: the modifier +// gates demote it (its InnerClasses metadata raises +// IncompatibleClassChangeError from getEnclosingClass, and generated code +// refers to the bean class by name, which this module's loader would resolve +// to the parent-loaded class), and stock databind handles foreign-loader +// classes reflectively. The observable contract is correct output. +// +// Old Blackbird failed this on the module path only because the test read the +// class bytes through classloader getResource, which JPMS encapsulation nulls; +// Class#getResourceAsStream resolves inside this module and works in both +// modes. +public class ChildClassloaderTest extends BlackbirdTestBase { - protected final String resourceName = - (TestBBClassloaders.class.getName() + "$Data") - .replace('.', '/').concat(".class"); - - // Note: looks this test: passes on JDKs OTHER than 11 for Jackson 2.x, - // but fails for JDK 17+ for Jackson 3.x. @Test - @JacksonTestFailureExpected public void testLoadInChildClassloader() throws Exception { - // 19-Jan-2025, tatu: only fails on JDK 11 specifically, not on JDK 17 or later - // - // So just skip on that - if (System.getProperty("java.version").startsWith("11.")) { - System.out.println("Skipping `testLoadInChildClassloader()` on JDK 11"); - return; - } TestLoader loader = new TestLoader(getClass().getClassLoader()); Class clazz = Class.forName(Data.class.getName(), true, loader); + assertNotSame(Data.class, clazz); ObjectMapper mapper = newObjectMapper(); Constructor constructor = clazz.getConstructor(int.class); Object data = constructor.newInstance(42); @@ -66,13 +64,11 @@ protected Class loadClass(String name, boolean resolve) throws ClassNotFoundE try { Class clazz; if (Data.class.getName().equals(name)) { - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - InputStream in = getResource(resourceName).openStream(); - int i; - while ((i = in.read()) != -1) { - baos.write(i); + byte[] bytes; + try (InputStream in = ChildClassloaderTest.class + .getResourceAsStream("ChildClassloaderTest$Data.class")) { + bytes = in.readAllBytes(); } - byte[] bytes = baos.toByteArray(); clazz = defineClass(name, bytes, 0, bytes.length); } else { clazz = super.loadClass(name, resolve); diff --git a/pom.xml b/pom.xml index ea5feff32..f2970ae8c 100644 --- a/pom.xml +++ b/pom.xml @@ -26,9 +26,7 @@ not datatype, data format, or JAX-RS provider modules. afterburner-tests android-record - blackbird - - blackbird-tests + guice guice7 jakarta-xmlbind @@ -46,6 +44,24 @@ not datatype, data format, or JAX-RS provider modules. spi-subtypes + + + + blackbird-jdk25 + + [25,) + + + blackbird + + blackbird-tests + + blackbird-jpms-tests + + + + https://github.com/FasterXML/jackson-modules-base scm:git:git@github.com:FasterXML/jackson-modules-base.git @@ -58,6 +74,10 @@ not datatype, data format, or JAX-RS provider modules. + + 17 UTF-8