From e59ec15d68e162e7ce9b65ace61e8dd3ebaa70e1 Mon Sep 17 00:00:00 2001 From: David Blevins Date: Fri, 10 Jul 2026 15:07:32 -0700 Subject: [PATCH] Java record components should dictate written fields https://issues.apache.org/jira/browse/JOHNZON-432 --- johnzon-mapper/pom.xml | 6 + .../mapper/access/MethodAccessMode.java | 6 + .../johnzon/mapper/reflection/Records.java | 31 +++- .../apache/johnzon/mapper/JavaRecordTest.java | 145 ++++++++++++++++++ 4 files changed, 187 insertions(+), 1 deletion(-) create mode 100644 johnzon-mapper/src/test/java/org/apache/johnzon/mapper/JavaRecordTest.java diff --git a/johnzon-mapper/pom.xml b/johnzon-mapper/pom.xml index 67387f7d9..2241c0005 100644 --- a/johnzon-mapper/pom.xml +++ b/johnzon-mapper/pom.xml @@ -71,6 +71,12 @@ 2.2.220 test + + org.apache.xbean + xbean-asm9-shaded + 4.20 + test + diff --git a/johnzon-mapper/src/main/java/org/apache/johnzon/mapper/access/MethodAccessMode.java b/johnzon-mapper/src/main/java/org/apache/johnzon/mapper/access/MethodAccessMode.java index 4c8aeecab..0288f9530 100644 --- a/johnzon-mapper/src/main/java/org/apache/johnzon/mapper/access/MethodAccessMode.java +++ b/johnzon-mapper/src/main/java/org/apache/johnzon/mapper/access/MethodAccessMode.java @@ -31,6 +31,7 @@ import java.util.Collection; import java.util.HashMap; import java.util.Map; +import java.util.Set; import java.util.stream.Stream; import org.apache.johnzon.mapper.Adapter; @@ -39,6 +40,7 @@ import org.apache.johnzon.mapper.JohnzonRecord; import org.apache.johnzon.mapper.MapperException; import org.apache.johnzon.mapper.ObjectConverter; +import org.apache.johnzon.mapper.reflection.Records; public class MethodAccessMode extends BaseAccessMode { private final boolean supportGetterAsWritter; @@ -52,9 +54,13 @@ public MethodAccessMode(final boolean useConstructor, final boolean acceptHidden public Map doFindReaders(final Class clazz) { final Map readers = new HashMap<>(); if (isRecord(clazz) || Meta.getAnnotation(clazz, JohnzonRecord.class) != null) { + // real records expose exactly their components, only @JohnzonRecord classes + // fall back on "any no-arg instance method" since they carry no component metadata + final Set components = Records.componentNames(clazz); readers.putAll(Stream.of(clazz.getMethods()) .filter(it -> it.getDeclaringClass() != Object.class && it.getParameterCount() == 0) .filter(it -> !Modifier.isStatic(it.getModifiers())) + .filter(it -> components == null || components.contains(it.getName())) .filter(it -> !"toString".equals(it.getName()) && !"hashCode".equals(it.getName())) .filter(it -> !isIgnored(it.getName()) && Meta.getAnnotation(it, JohnzonAny.class) == null) .collect(toMap(m -> extractKey(m.getName(), m, null), it -> new MethodReader(it, it.getGenericReturnType())))); diff --git a/johnzon-mapper/src/main/java/org/apache/johnzon/mapper/reflection/Records.java b/johnzon-mapper/src/main/java/org/apache/johnzon/mapper/reflection/Records.java index 7d8d41510..0e7a74e4e 100644 --- a/johnzon-mapper/src/main/java/org/apache/johnzon/mapper/reflection/Records.java +++ b/johnzon-mapper/src/main/java/org/apache/johnzon/mapper/reflection/Records.java @@ -20,18 +20,28 @@ import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; +import java.util.HashSet; +import java.util.Set; public final class Records { private static final Method IS_RECORD; + private static final Method GET_RECORD_COMPONENTS; + private static final Method GET_NAME; static { Method isRecord = null; + Method getRecordComponents = null; + Method getName = null; try { isRecord = Class.class.getMethod("isRecord"); - } catch (final NoSuchMethodException e) { + getRecordComponents = Class.class.getMethod("getRecordComponents"); + getName = Class.forName("java.lang.reflect.RecordComponent").getMethod("getName"); + } catch (final NoSuchMethodException | ClassNotFoundException e) { // no-op } IS_RECORD = isRecord; + GET_RECORD_COMPONENTS = getRecordComponents; + GET_NAME = getName; } private Records() { @@ -45,4 +55,23 @@ public static boolean isRecord(final Class clazz) { return false; } } + + /** + * @return the record component names of {@code clazz}, or {@code null} if it is not a record. + */ + public static Set componentNames(final Class clazz) { + if (!isRecord(clazz)) { + return null; + } + try { + final Object[] components = Object[].class.cast(GET_RECORD_COMPONENTS.invoke(clazz)); + final Set names = new HashSet<>(components.length); + for (final Object component : components) { + names.add(String.class.cast(GET_NAME.invoke(component))); + } + return names; + } catch (final InvocationTargetException | IllegalAccessException e) { + return null; + } + } } diff --git a/johnzon-mapper/src/test/java/org/apache/johnzon/mapper/JavaRecordTest.java b/johnzon-mapper/src/test/java/org/apache/johnzon/mapper/JavaRecordTest.java new file mode 100644 index 000000000..5d92153bc --- /dev/null +++ b/johnzon-mapper/src/test/java/org/apache/johnzon/mapper/JavaRecordTest.java @@ -0,0 +1,145 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.johnzon.mapper; + +import static org.apache.xbean.asm9.Opcodes.ACC_FINAL; +import static org.apache.xbean.asm9.Opcodes.ACC_PRIVATE; +import static org.apache.xbean.asm9.Opcodes.ACC_PUBLIC; +import static org.apache.xbean.asm9.Opcodes.ACC_RECORD; +import static org.apache.xbean.asm9.Opcodes.ACC_STATIC; +import static org.apache.xbean.asm9.Opcodes.ALOAD; +import static org.apache.xbean.asm9.Opcodes.ARETURN; +import static org.apache.xbean.asm9.Opcodes.GETFIELD; +import static org.apache.xbean.asm9.Opcodes.ILOAD; +import static org.apache.xbean.asm9.Opcodes.INVOKESPECIAL; +import static org.apache.xbean.asm9.Opcodes.IRETURN; +import static org.apache.xbean.asm9.Opcodes.PUTFIELD; +import static org.apache.xbean.asm9.Opcodes.RETURN; +import static org.apache.xbean.asm9.Opcodes.V16; +import static org.junit.Assert.assertEquals; +import static org.junit.Assume.assumeTrue; + +import org.apache.xbean.asm9.ClassWriter; +import org.apache.xbean.asm9.MethodVisitor; +import org.junit.Test; + +/** + * Serialization of real java records (not {@link JohnzonRecord} classes) must only + * expose the record components as properties, not any public no-arg method. + * + * The module compiles at a pre-record language level so the record under test is + * built with asm; the test is skipped when the JVM cannot load record class files. + */ +public class JavaRecordTest { + @Test + public void onlyComponentsAreProperties() throws Exception { + assumeTrue("records require java 16+", Runtime.version().feature() >= 16); + + // a record carrying the extra no-arg methods a Lombok-style builder generates: + // neither toBuilder() (instance) nor builder() (static) is a component so + // neither must serialize + final Class type = definePersonRecord(); + final Object ref = type.getConstructor(String.class, int.class).newInstance("Ada", 36); + + try (final Mapper mapper = new MapperBuilder().setAttributeOrder(String.CASE_INSENSITIVE_ORDER).build()) { + final String expectedJson = "{\"age\":36,\"name\":\"Ada\"}"; + assertEquals(expectedJson, mapper.writeObjectAsString(ref)); + + final Object read = mapper.readObject(expectedJson, type); + assertEquals("Ada", type.getMethod("name").invoke(read)); + assertEquals(36, type.getMethod("age").invoke(read)); + } + } + + /** + * public record Person(String name, int age) { + * public String toBuilder() { return "bogus"; } + * public static String builder() { return "bogus"; } + * } + */ + private Class definePersonRecord() { + final String name = "org.apache.johnzon.mapper.generated.Person"; + final String internalName = name.replace('.', '/'); + + final ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES); + cw.visit(V16, ACC_PUBLIC | ACC_FINAL | ACC_RECORD, internalName, null, "java/lang/Record", null); + + cw.visitRecordComponent("name", "Ljava/lang/String;", null).visitEnd(); + cw.visitRecordComponent("age", "I", null).visitEnd(); + + cw.visitField(ACC_PRIVATE | ACC_FINAL, "name", "Ljava/lang/String;", null, null).visitEnd(); + cw.visitField(ACC_PRIVATE | ACC_FINAL, "age", "I", null, null).visitEnd(); + + // canonical constructor, MethodParameters included as javac emits it + final MethodVisitor ctor = cw.visitMethod(ACC_PUBLIC, "", "(Ljava/lang/String;I)V", null, null); + ctor.visitParameter("name", 0); + ctor.visitParameter("age", 0); + ctor.visitCode(); + ctor.visitVarInsn(ALOAD, 0); + ctor.visitMethodInsn(INVOKESPECIAL, "java/lang/Record", "", "()V", false); + ctor.visitVarInsn(ALOAD, 0); + ctor.visitVarInsn(ALOAD, 1); + ctor.visitFieldInsn(PUTFIELD, internalName, "name", "Ljava/lang/String;"); + ctor.visitVarInsn(ALOAD, 0); + ctor.visitVarInsn(ILOAD, 2); + ctor.visitFieldInsn(PUTFIELD, internalName, "age", "I"); + ctor.visitInsn(RETURN); + ctor.visitMaxs(0, 0); + ctor.visitEnd(); + + final MethodVisitor nameAccessor = cw.visitMethod(ACC_PUBLIC, "name", "()Ljava/lang/String;", null, null); + nameAccessor.visitCode(); + nameAccessor.visitVarInsn(ALOAD, 0); + nameAccessor.visitFieldInsn(GETFIELD, internalName, "name", "Ljava/lang/String;"); + nameAccessor.visitInsn(ARETURN); + nameAccessor.visitMaxs(0, 0); + nameAccessor.visitEnd(); + + final MethodVisitor ageAccessor = cw.visitMethod(ACC_PUBLIC, "age", "()I", null, null); + ageAccessor.visitCode(); + ageAccessor.visitVarInsn(ALOAD, 0); + ageAccessor.visitFieldInsn(GETFIELD, internalName, "age", "I"); + ageAccessor.visitInsn(IRETURN); + ageAccessor.visitMaxs(0, 0); + ageAccessor.visitEnd(); + + final MethodVisitor toBuilder = cw.visitMethod(ACC_PUBLIC, "toBuilder", "()Ljava/lang/String;", null, null); + toBuilder.visitCode(); + toBuilder.visitLdcInsn("bogus"); + toBuilder.visitInsn(ARETURN); + toBuilder.visitMaxs(0, 0); + toBuilder.visitEnd(); + + final MethodVisitor builder = cw.visitMethod(ACC_PUBLIC | ACC_STATIC, "builder", "()Ljava/lang/String;", null, null); + builder.visitCode(); + builder.visitLdcInsn("bogus"); + builder.visitInsn(ARETURN); + builder.visitMaxs(0, 0); + builder.visitEnd(); + + cw.visitEnd(); + final byte[] bytes = cw.toByteArray(); + + return new ClassLoader(JavaRecordTest.class.getClassLoader()) { + Class define() { + return defineClass(name, bytes, 0, bytes.length); + } + }.define(); + } +}