Merge pull request #754 from KyleAure/spec-record-virtual-attributes
feat: add support for virtual attributes on records
diff --git a/src/main/java/org/eclipse/yasson/internal/ClassParser.java b/src/main/java/org/eclipse/yasson/internal/ClassParser.java
index b63dc00..793677a 100644
--- a/src/main/java/org/eclipse/yasson/internal/ClassParser.java
+++ b/src/main/java/org/eclipse/yasson/internal/ClassParser.java
@@ -197,12 +197,16 @@
Method[] declaredMethods = AccessController.doPrivileged((PrivilegedAction<Method[]>) clazz::getDeclaredMethods);
for (Method method : declaredMethods) {
String name = method.getName();
+
//isBridge method filters out methods inherited from interfaces
boolean isAccessorMethod = isSpecialAccessorMethod(method, classProperties)
+ || isVirtualAccessorMethod(method)
|| isPropertyMethod(method);
+
if (!isAccessorMethod || method.isBridge() || isSpecialCaseMethod(clazz, method)) {
continue;
}
+
final String propertyName = method.getDeclaringClass().isRecord()
? name
: toPropertyMethod(name);
@@ -272,6 +276,14 @@
&& classProperties.containsKey(method.getName());
}
+ private static boolean isVirtualAccessorMethod(Method method) {
+ return method.getDeclaringClass().isRecord()
+ && method.getParameterCount() == 0
+ && !void.class.equals(method.getReturnType())
+ && !"hashCode".equals(method.getName())
+ && !"toString".equals(method.getName());
+ }
+
private static void parseFields(JsonbAnnotatedElement<Class<?>> classElement, Map<String, Property> classProperties) {
Field[] declaredFields = AccessController.doPrivileged(
(PrivilegedAction<Field[]>) () -> classElement.getElement().getDeclaredFields());
diff --git a/src/test/java/org/eclipse/yasson/records/RecordTest.java b/src/test/java/org/eclipse/yasson/records/RecordTest.java
index 3c1b72a..526b0d0 100644
--- a/src/test/java/org/eclipse/yasson/records/RecordTest.java
+++ b/src/test/java/org/eclipse/yasson/records/RecordTest.java
@@ -1,5 +1,6 @@
/*
* Copyright (c) 2021, 2025 Oracle and/or its affiliates. All rights reserved.
+ * Copyright (c) 2026 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License v. 2.0 which is available at
@@ -21,7 +22,9 @@
import org.junit.jupiter.api.Test;
import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.not;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -119,4 +122,90 @@
.fromJson(expected, new TestTypeToken<CarWithGenerics<Color>>() {}.getType());
assertThat(deserialized, is(car));
}
+ // -----------------------------------------------------------------
+ // isVirtualAccessorMethod — virtual (computed) record attributes
+ // -----------------------------------------------------------------
+
+ /**
+ * A record's zero-parameter, non-void method whose name does not match any
+ * component must be serialized as a JSON property (virtual attribute).
+ */
+ @Test
+ public void testRecordVirtualAttributeIsIncludedInJson() {
+ RecordWithVirtualAttributes record = new RecordWithVirtualAttributes("skoda", "green");
+ String json = Jsonbs.defaultJsonb.toJson(record);
+
+ assertThat(json, containsString("\"displayName\":\"skoda (green)\""));
+ assertThat(json, containsString("\"componentCount\":2"));
+ }
+
+ /**
+ * The standard record components must still appear alongside the virtual attributes.
+ */
+ @Test
+ public void testRecordVirtualAttributeDoesNotSuppressComponents() {
+ RecordWithVirtualAttributes record = new RecordWithVirtualAttributes("skoda", "green");
+ String json = Jsonbs.defaultJsonb.toJson(record);
+
+ assertThat(json, containsString("\"type\":\"skoda\""));
+ assertThat(json, containsString("\"color\":\"green\""));
+ }
+
+ /**
+ * {@code hashCode()} is explicitly excluded by {@code isVirtualAccessorMethod}
+ * and must not appear in the serialized JSON.
+ */
+ @Test
+ public void testRecordHashCodeMethodIsNotIncludedInJson() {
+ RecordWithVirtualAttributes record = new RecordWithVirtualAttributes("skoda", "green");
+ String json = Jsonbs.defaultJsonb.toJson(record);
+
+ assertThat(json, not(containsString("\"hashCode\"")));
+ }
+
+ /**
+ * {@code toString()} is explicitly excluded by {@code isVirtualAccessorMethod}
+ * and must not appear in the serialized JSON.
+ */
+ @Test
+ public void testRecordToStringMethodIsNotIncludedInJson() {
+ RecordWithVirtualAttributes record = new RecordWithVirtualAttributes("skoda", "green");
+ String json = Jsonbs.defaultJsonb.toJson(record);
+
+ assertThat(json, not(containsString("\"toString\"")));
+ }
+
+ /**
+ * Virtual attributes are read-only; deserialization of the underlying components
+ * must still work correctly even when virtual attribute keys are present in the JSON.
+ * Unknown virtual attribute keys are simply ignored on the way in.
+ */
+ @Test
+ public void testRecordVirtualAttributeIsIgnoredDuringDeserialization() {
+ String json = "{\"type\":\"skoda\",\"color\":\"green\","
+ + "\"displayName\":\"skoda (green)\",\"componentCount\":2}";
+ RecordWithVirtualAttributes deserialized =
+ Jsonbs.defaultJsonb.fromJson(json, RecordWithVirtualAttributes.class);
+
+ assertThat(deserialized.type(), is("skoda"));
+ assertThat(deserialized.color(), is("green"));
+ }
+
+ /**
+ * A virtual accessor method whose name starts with {@code get} must NOT have its
+ * prefix stripped when serialized from a record. JavaBean name-mangling only applies
+ * to regular classes; for records the raw method name is used as the JSON key.
+ * <p>
+ * So {@code getDisplayName()} on a record must serialize as {@code "getDisplayName"},
+ * not as {@code "displayName"}.
+ */
+ @Test
+ public void testRecordGetterStyleVirtualAttributeIsNotStripped() {
+ RecordWithGetterStyleVirtualAttribute record =
+ new RecordWithGetterStyleVirtualAttribute("skoda", "green");
+ String json = Jsonbs.defaultJsonb.toJson(record);
+
+ assertThat(json, containsString("\"getDisplayName\":\"skoda (green)\""));
+ assertThat(json, not(containsString("\"displayName\"")));
+ }
}
diff --git a/src/test/java/org/eclipse/yasson/records/RecordWithGetterStyleVirtualAttribute.java b/src/test/java/org/eclipse/yasson/records/RecordWithGetterStyleVirtualAttribute.java
new file mode 100644
index 0000000..e8fde8b
--- /dev/null
+++ b/src/test/java/org/eclipse/yasson/records/RecordWithGetterStyleVirtualAttribute.java
@@ -0,0 +1,30 @@
+/*
+ * Copyright (c) 2026 Contributors to the Eclipse Foundation
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License v. 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0,
+ * or the Eclipse Distribution License v. 1.0 which is available at
+ * http://www.eclipse.org/org/documents/edl-v10.php.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR BSD-3-Clause
+ */
+
+package org.eclipse.yasson.records;
+
+/**
+ * A record whose virtual accessor method uses a JavaBean-style {@code getX()} naming
+ * convention. Because this is a record (not a JavaBean), the {@code get} prefix must
+ * NOT be stripped — the JSON key must be {@code "getDisplayName"}, not {@code "displayName"}.
+ */
+public record RecordWithGetterStyleVirtualAttribute(String type, String color) {
+
+ /**
+ * Virtual (computed) attribute with a JavaBean-style name.
+ * The JSON key must be the full method name {@code "getDisplayName"},
+ * not the bean-property name {@code "displayName"}.
+ */
+ public String getDisplayName() {
+ return type + " (" + color + ")";
+ }
+}
diff --git a/src/test/java/org/eclipse/yasson/records/RecordWithVirtualAttributes.java b/src/test/java/org/eclipse/yasson/records/RecordWithVirtualAttributes.java
new file mode 100644
index 0000000..dfd65f5
--- /dev/null
+++ b/src/test/java/org/eclipse/yasson/records/RecordWithVirtualAttributes.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright (c) 2026 Contributors to the Eclipse Foundation
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License v. 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0,
+ * or the Eclipse Distribution License v. 1.0 which is available at
+ * http://www.eclipse.org/org/documents/edl-v10.php.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR BSD-3-Clause
+ */
+
+package org.eclipse.yasson.records;
+
+/**
+ * A record that exposes virtual (computed) attributes alongside its regular components.
+ * <p>
+ * <ul>
+ * <li>{@code type} and {@code color} are standard record components.</li>
+ * <li>{@code displayName()} is a virtual attribute: a zero-parameter, non-void method
+ * whose name does not match any component — it should appear in the serialized JSON.</li>
+ * <li>{@code hashCode()} and {@code toString()} are explicitly excluded by
+ * {@code isVirtualAccessorMethod} and must NOT appear in the JSON.</li>
+ * </ul>
+ */
+public record RecordWithVirtualAttributes(String type, String color) {
+
+ /** Virtual (computed) attribute — should be included in JSON output. */
+ public String displayName() {
+ return type + " (" + color + ")";
+ }
+
+ /** Returns a numeric virtual attribute — should be included in JSON output. */
+ public int componentCount() {
+ return 2;
+ }
+}