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;
+    }
+}