This is an automated email from the ASF dual-hosted git repository.

paulk-asert pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/groovy.git


The following commit(s) were added to refs/heads/master by this push:
     new aaec60ca4a GROOVY-12015: Provide a nested copyWith capability
aaec60ca4a is described below

commit aaec60ca4a7e26feb757ba0a08512e70f6e1d06f
Author: Paul King <[email protected]>
AuthorDate: Sat May 16 19:06:52 2026 +1000

    GROOVY-12015: Provide a nested copyWith capability
---
 src/main/java/groovy/transform/ImmutableBase.java  |  33 +++
 src/main/java/groovy/transform/RecordOptions.java  |   9 +
 .../org/apache/groovy/ast/tools/CopyWithUtils.java |  89 +++++++
 .../transform/copywith/CopyWithRecorder.java       | 105 +++++++++
 .../transform/copywith/NestedCopyWithSupport.java  | 119 ++++++++++
 .../transform/ImmutableASTTransformation.java      |   5 +
 .../RecordCompletionASTTransformation.java         |   6 +
 .../transform/RecordTypeASTTransformation.java     |  33 ++-
 .../transform/ImmutableNestedCopyWithTest.groovy   | 256 +++++++++++++++++++++
 .../transform/RecordNestedCopyWithTest.groovy      | 104 +++++++++
 10 files changed, 758 insertions(+), 1 deletion(-)

diff --git a/src/main/java/groovy/transform/ImmutableBase.java 
b/src/main/java/groovy/transform/ImmutableBase.java
index 1171f26019..c71c78ecd3 100644
--- a/src/main/java/groovy/transform/ImmutableBase.java
+++ b/src/main/java/groovy/transform/ImmutableBase.java
@@ -70,6 +70,39 @@ public @interface ImmutableBase {
      * </pre>
      * Unknown keys in the map are ignored, and if the values would not change
      * the object, then the original object is returned.
+     * <p>
+     * A key may also be a dotted <em>nested path</em> &mdash; {@code copyWith}
+     * then returns a new instance with that nested value updated, reusing the
+     * untouched branches (identity is preserved transitively):
+     * <pre class="language-groovy">
+     * {@code @groovy.transform.Immutable}(copyWith = true) class Address { 
String city, zip }
+     * {@code @groovy.transform.Immutable}(copyWith = true) class Person  { 
String name; Address address }
+     *
+     * def p = new Person('Alice', new Address('NYC', '10001'))
+     * def q = p.copyWith('address.city': 'Boston')
+     * assert q.address.city == 'Boston'
+     * assert q.address.zip  == '10001'
+     * </pre>
+     * Every node on a nested path must itself provide {@code copyWith(Map)}
+     * (e.g. an {@code @Immutable}/{@code @RecordType} declared with
+     * {@code copyWith=true}); otherwise a clear error is raised.
+     * <p>
+     * A transactional block form is also generated, sugar over the map form.
+     * Within it, {@code old} is the original (pre-state) object — aligning
+     * with {@code old} in {@code @Ensures}/{@code @Contract} — so values may
+     * be derived from it; {@code prop.modify { }} is a shorthand for the
+     * common transform-this-same-field case:
+     * <pre class="language-groovy">
+     * def q = p.copyWith {
+     *     name = 'Bob'                              // plain set
+     *     address.city = old.address.city.reverse() // derive from pre-state
+     *     loginCount.modify { it + 1 }              // single-field shorthand
+     * }
+     * </pre>
+     * On a navigated path {@code modify} is reserved by the block, so use the
+     * {@code old} form ({@code x = old.x.modify { ... }}) to call a real
+     * same-named method on the value type — or simply when {@code .modify}
+     * would read confusingly.
      *
      * If a method called {@code copyWith} that takes a single parameter 
already
      * exists in the class, then this setting is ignored, and no method is 
generated.
diff --git a/src/main/java/groovy/transform/RecordOptions.java 
b/src/main/java/groovy/transform/RecordOptions.java
index c1c602088f..d9151c1fe0 100644
--- a/src/main/java/groovy/transform/RecordOptions.java
+++ b/src/main/java/groovy/transform/RecordOptions.java
@@ -137,6 +137,15 @@ public @interface RecordOptions {
      * </pre>
      * Unknown keys in the map are ignored, and if the values would not change
      * the object, then the original object is returned.
+     * <p>
+     * A key may also be a dotted <em>nested path</em> (e.g.
+     * {@code copyWith('address.city': 'NYC')}); every node on the path must
+     * itself provide {@code copyWith(Map)}. A transactional block form is also
+     * generated, in which {@code old} is the original object and
+     * {@code prop.modify { }} is a single-field shorthand:
+     * {@code copyWith { name = old.name.toUpperCase(); address.city = 'y' }}.
+     * Use the {@code old} form to reach a real same-named {@code modify}
+     * method on a value type, or simply for clarity.
      *
      * If a method called {@code copyWith} that takes a single parameter 
already
      * exists in the class, then this setting is ignored, and no method is 
generated.
diff --git a/src/main/java/org/apache/groovy/ast/tools/CopyWithUtils.java 
b/src/main/java/org/apache/groovy/ast/tools/CopyWithUtils.java
new file mode 100644
index 0000000000..3d629c4219
--- /dev/null
+++ b/src/main/java/org/apache/groovy/ast/tools/CopyWithUtils.java
@@ -0,0 +1,89 @@
+/*
+ *  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.groovy.ast.tools;
+
+import org.apache.groovy.transform.copywith.NestedCopyWithSupport;
+import org.codehaus.groovy.ast.ClassHelper;
+import org.codehaus.groovy.ast.ClassNode;
+import org.codehaus.groovy.ast.Parameter;
+import org.codehaus.groovy.ast.stmt.Statement;
+
+import static org.apache.groovy.ast.tools.ClassNodeUtils.addGeneratedMethod;
+import static org.codehaus.groovy.ast.tools.GeneralUtils.args;
+import static org.codehaus.groovy.ast.tools.GeneralUtils.assignS;
+import static org.codehaus.groovy.ast.tools.GeneralUtils.callX;
+import static org.codehaus.groovy.ast.tools.GeneralUtils.castX;
+import static org.codehaus.groovy.ast.tools.GeneralUtils.hasDeclaredMethod;
+import static org.codehaus.groovy.ast.tools.GeneralUtils.param;
+import static org.codehaus.groovy.ast.tools.GeneralUtils.params;
+import static org.codehaus.groovy.ast.tools.GeneralUtils.returnS;
+import static org.codehaus.groovy.ast.tools.GeneralUtils.varX;
+import static org.objectweb.asm.Opcodes.ACC_FINAL;
+import static org.objectweb.asm.Opcodes.ACC_PUBLIC;
+
+/**
+ * Shared AST-generation glue for nested-path {@code copyWith}, used by both
+ * the {@code @Immutable} and {@code @RecordType} transforms so the generated
+ * code (and its evolution) stays identical across the two.
+ *
+ * @since 6.0.0
+ */
+public final class CopyWithUtils {
+
+    private static final ClassNode NESTED_COPYWITH_TYPE = 
ClassHelper.make(NestedCopyWithSupport.class);
+
+    private CopyWithUtils() {
+    }
+
+    /**
+     * The leading statement of a nested-aware {@code copyWith(Map)}:
+     * {@code <mapParamName> = NestedCopyWithSupport.flatten(this, 
<mapParamName>)}.
+     * Plain keys pass through unchanged, so flat usage is unaffected.
+     */
+    public static Statement nestedFlattenStmt(final String mapParamName) {
+        return assignS(varX(mapParamName),
+                callX(NESTED_COPYWITH_TYPE, "flatten", args(varX("this"), 
varX(mapParamName))));
+    }
+
+    /**
+     * Backward-compatibility shim: a no-arg {@code copyWith()} with its
+     * historical semantics (no changes &rarr; the same instance). Needed once
+     * a {@code copyWith(Closure)} overload exists, otherwise the historically
+     * supported zero-arg call becomes ambiguous.
+     */
+    public static void addCopyWithIdentityMethod(final ClassNode cNode) {
+        if (hasDeclaredMethod(cNode, "copyWith", 0)) return;
+        addGeneratedMethod(cNode, "copyWith", ACC_PUBLIC | ACC_FINAL,
+                cNode.getPlainNodeReference(), Parameter.EMPTY_ARRAY, 
ClassNode.EMPTY_ARRAY,
+                returnS(varX("this")));
+    }
+
+    /**
+     * The transactional block overload:
+     * {@code copyWith { name = 'x'; address.city = 'y' }}.
+     */
+    public static void addCopyWithBlockMethod(final ClassNode cNode) {
+        Parameter block = 
param(ClassHelper.CLOSURE_TYPE.getPlainNodeReference(), "block");
+        addGeneratedMethod(cNode, "copyWith", ACC_PUBLIC | ACC_FINAL,
+                cNode.getPlainNodeReference(), params(block), 
ClassNode.EMPTY_ARRAY,
+                returnS(castX(cNode.getPlainNodeReference(),
+                        callX(NESTED_COPYWITH_TYPE, "applyBlock",
+                                args(varX("this"), varX(block))))));
+    }
+}
diff --git 
a/src/main/java/org/apache/groovy/transform/copywith/CopyWithRecorder.java 
b/src/main/java/org/apache/groovy/transform/copywith/CopyWithRecorder.java
new file mode 100644
index 0000000000..9c33400a89
--- /dev/null
+++ b/src/main/java/org/apache/groovy/transform/copywith/CopyWithRecorder.java
@@ -0,0 +1,105 @@
+/*
+ *  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.groovy.transform.copywith;
+
+import groovy.lang.Closure;
+import groovy.lang.GroovyObjectSupport;
+import org.apache.groovy.lang.annotation.Incubating;
+import org.codehaus.groovy.runtime.InvokerHelper;
+
+import java.util.Map;
+
+/**
+ * Recording delegate for the transactional {@code copyWith { }} block. It does
+ * not mutate anything: property assignments and {@code prop.modify { }} calls
+ * are recorded as a (possibly dotted) path-keyed map which is then handed to
+ * the nested-aware {@code copyWith(Map)} for reconstruction.
+ * <p>
+ * Reads of a property return a child recorder bound to the extended path, so
+ * {@code address.city = 'NYC'} records {@code 'address.city'}.
+ * <p>
+ * The reserved name {@code old} resolves to the original (root) object —
+ * aligning with the {@code old} of {@code @Ensures}/{@code @Contract} in
+ * design-by-contract — so a value may be derived from the pre-state:
+ * {@code address.city = old.address.city.reverse()} or, cross-field,
+ * {@code name = old.company.name}. The concise single-field shorthand
+ * {@code address.city.modify { it.reverse() }} remains for the common
+ * transform-this-same-field case.
+ * <p>
+ * On a navigated path the recorder intercepts {@code modify}, so a same-named
+ * real method on the value type is shadowed there. {@code old} is the escape
+ * hatch: past {@code old} the RHS is the real object, so a genuine
+ * {@code modify(Closure)} (or any other domain method) runs and its result is
+ * recorded — e.g. {@code balance = old.balance.modify { it + 50 }}. {@code 
old}
+ * may equally be preferred purely for style, when {@code .modify} on a path
+ * could be misread, even if no real method exists.
+ *
+ * @since 6.0.0
+ */
+@Incubating
+public class CopyWithRecorder extends GroovyObjectSupport {
+
+    private final Object self;
+    private final String prefix;
+    private final Map<Object, Object> sink;
+
+    public CopyWithRecorder(final Object self, final String prefix, final 
Map<Object, Object> sink) {
+        this.self = self;
+        this.prefix = prefix;
+        this.sink = sink;
+    }
+
+    private String path(final String name) {
+        return prefix.isEmpty() ? name : prefix + "." + name;
+    }
+
+    @Override
+    public Object getProperty(final String name) {
+        // 'old' is the pre-state root (cf. @Ensures/@Contract old), so RHS
+        // expressions can derive new values from the original object.
+        if ("old".equals(name)) return self;
+        return new CopyWithRecorder(self, path(name), sink);
+    }
+
+    @Override
+    public void setProperty(final String name, final Object value) {
+        sink.put(path(name), value);
+    }
+
+    @Override
+    public Object invokeMethod(final String name, final Object args) {
+        if ("modify".equals(name) && !prefix.isEmpty()) {
+            Object[] a = args instanceof Object[] ? (Object[]) args : new 
Object[]{args};
+            if (a.length == 1 && a[0] instanceof Closure) {
+                sink.put(prefix, ((Closure<?>) a[0]).call(currentValue()));
+                return null;
+            }
+        }
+        // Unknown call: let the closure fall back to its owner 
(DELEGATE_FIRST)
+        return super.invokeMethod(name, args);
+    }
+
+    private Object currentValue() {
+        Object o = self;
+        for (String seg : prefix.split("\\.")) {
+            o = InvokerHelper.getProperty(o, seg);
+        }
+        return o;
+    }
+}
diff --git 
a/src/main/java/org/apache/groovy/transform/copywith/NestedCopyWithSupport.java 
b/src/main/java/org/apache/groovy/transform/copywith/NestedCopyWithSupport.java
new file mode 100644
index 0000000000..70f697b421
--- /dev/null
+++ 
b/src/main/java/org/apache/groovy/transform/copywith/NestedCopyWithSupport.java
@@ -0,0 +1,119 @@
+/*
+ *  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.groovy.transform.copywith;
+
+import groovy.lang.Closure;
+import groovy.lang.GroovyRuntimeException;
+import org.apache.groovy.lang.annotation.Incubating;
+import org.codehaus.groovy.runtime.InvokerHelper;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * Runtime support for nested-path {@code copyWith}. Resolves dotted keys such
+ * as {@code 'address.city'} into a flat map of top-level property to its
+ * replacement value, by recursively applying {@code copyWith} to the affected
+ * nested nodes. Plain (non-dotted) keys pass through unchanged.
+ * <p>
+ * A nested node whose type does not provide {@code copyWith(Map)} fails with
+ * a clear, specific error rather than silent or partial behaviour. Identity
+ * is preserved transitively: an unchanged nested node yields its original
+ * reference, so an unchanged graph yields the original root.
+ *
+ * @since 6.0.0
+ */
+@Incubating
+public final class NestedCopyWithSupport {
+
+    private NestedCopyWithSupport() {
+    }
+
+    /**
+     * Transactional-block form. Runs {@code block} against a recording
+     * delegate that captures plain assignments, nested-path navigation, and
+     * {@code prop.modify { old -> ... }} updates, then delegates to the
+     * (nested-aware) {@code copyWith(Map)}. The block is thus pure sugar over
+     * the map form, inheriting its closed-type-domain and identity guarantees.
+     */
+    public static Object applyBlock(final Object self, final Closure<?> block) 
{
+        Map<Object, Object> sink = new LinkedHashMap<>();
+        Closure<?> c = (Closure<?>) block.clone();
+        c.setResolveStrategy(Closure.DELEGATE_FIRST);
+        c.setDelegate(new CopyWithRecorder(self, "", sink));
+        c.call();
+        if (sink.isEmpty()) return self;
+        return InvokerHelper.invokeMethod(self, "copyWith", new 
Object[]{sink});
+    }
+
+    @SuppressWarnings("unchecked")
+    public static Map<Object, Object> flatten(final Object self, final 
Map<Object, Object> raw) {
+        if (raw == null) return new LinkedHashMap<>();
+        Map<Object, Object> flat = new LinkedHashMap<>();
+        Map<String, Map<Object, Object>> nested = new LinkedHashMap<>();
+
+        for (Map.Entry<Object, Object> e : raw.entrySet()) {
+            Object key = e.getKey();
+            String k = key == null ? null : key.toString();
+            int dot = k == null ? -1 : k.indexOf('.');
+            if (dot < 0) {
+                if (nested.containsKey(k)) {
+                    throw conflict(k);
+                }
+                flat.put(key, e.getValue());
+            } else {
+                String head = k.substring(0, dot);
+                String rest = k.substring(dot + 1);
+                if (flat.containsKey(head)) {
+                    throw conflict(head);
+                }
+                nested.computeIfAbsent(head, h -> new 
LinkedHashMap<>()).put(rest, e.getValue());
+            }
+        }
+
+        for (Map.Entry<String, Map<Object, Object>> e : nested.entrySet()) {
+            String head = e.getKey();
+            Object current = InvokerHelper.getProperty(self, head);
+            if (current == null) {
+                throw new GroovyRuntimeException("copyWith: cannot apply a 
nested update to '"
+                        + head + "' because its current value is null");
+            }
+            // A nested node must itself expose copyWith(Map); fail clearly 
otherwise.
+            // Probe with the actual nested map so a type that only has
+            // copyWith()/copyWith(Closure) does not falsely pass this guard.
+            boolean supported = !InvokerHelper.getMetaClass(current)
+                    .respondsTo(current, "copyWith", new 
Object[]{e.getValue()}).isEmpty();
+            if (!supported) {
+                throw new GroovyRuntimeException("copyWith: nested update of 
'" + head
+                        + "' requires its type (" + 
current.getClass().getName()
+                        + ") to provide a copyWith(Map) method — e.g. an 
@Immutable/@RecordType "
+                        + "declared with copyWith=true. This type is outside 
the supported "
+                        + "nested-copyWith domain.");
+            }
+            Object updated = InvokerHelper.invokeMethod(current, "copyWith", 
new Object[]{e.getValue()});
+            flat.put(head, updated);
+        }
+        return flat;
+    }
+
+    private static GroovyRuntimeException conflict(final String head) {
+        return new GroovyRuntimeException("copyWith: cannot combine a 
whole-value update and a "
+                + "nested update for '" + head + "'");
+    }
+}
diff --git 
a/src/main/java/org/codehaus/groovy/transform/ImmutableASTTransformation.java 
b/src/main/java/org/codehaus/groovy/transform/ImmutableASTTransformation.java
index 53502b75f4..a74d493ac4 100644
--- 
a/src/main/java/org/codehaus/groovy/transform/ImmutableASTTransformation.java
+++ 
b/src/main/java/org/codehaus/groovy/transform/ImmutableASTTransformation.java
@@ -25,6 +25,7 @@ import groovy.transform.CompilationUnitAware;
 import groovy.transform.ImmutableBase;
 import groovy.transform.options.PropertyHandler;
 import org.apache.groovy.ast.tools.AnnotatedNodeUtils;
+import org.apache.groovy.ast.tools.CopyWithUtils;
 import org.apache.groovy.ast.tools.ImmutablePropertyUtils;
 import org.codehaus.groovy.ast.ASTNode;
 import org.codehaus.groovy.ast.AnnotatedNode;
@@ -292,6 +293,7 @@ public class ImmutableASTTransformation extends 
AbstractASTTransformation implem
                 ),
                 returnS(varX("this", cNode))
         ));
+        body.addStatement(CopyWithUtils.nestedFlattenStmt("map"));
         body.addStatement(declS(localVarX("dirty", ClassHelper.boolean_TYPE), 
ConstantExpression.PRIM_FALSE));
         body.addStatement(declS(localVarX("construct", HMAP_TYPE), 
ctorX(HMAP_TYPE)));
 
@@ -317,6 +319,9 @@ public class ImmutableASTTransformation extends 
AbstractASTTransformation implem
         for (PropertyNode pNode : pList) {
             map.addAnnotation(createNamedParam(pNode.getName(), 
pNode.getType(), false));
         }
+
+        CopyWithUtils.addCopyWithIdentityMethod(cNode);
+        CopyWithUtils.addCopyWithBlockMethod(cNode);
     }
 
     /**
diff --git 
a/src/main/java/org/codehaus/groovy/transform/RecordCompletionASTTransformation.java
 
b/src/main/java/org/codehaus/groovy/transform/RecordCompletionASTTransformation.java
index bdd0473506..d7897902f7 100644
--- 
a/src/main/java/org/codehaus/groovy/transform/RecordCompletionASTTransformation.java
+++ 
b/src/main/java/org/codehaus/groovy/transform/RecordCompletionASTTransformation.java
@@ -29,6 +29,7 @@ import org.codehaus.groovy.ast.Parameter;
 import org.codehaus.groovy.ast.PropertyNode;
 import org.codehaus.groovy.ast.expr.ConstructorCallExpression;
 import org.codehaus.groovy.ast.expr.Expression;
+import org.codehaus.groovy.ast.stmt.BlockStatement;
 import org.codehaus.groovy.ast.stmt.ReturnStatement;
 import org.codehaus.groovy.ast.stmt.Statement;
 import org.codehaus.groovy.control.CompilePhase;
@@ -85,6 +86,11 @@ public class RecordCompletionASTTransformation extends 
AbstractASTTransformation
         ConstructorNode consNode = 
cNode.getDeclaredConstructor(params.toArray(Parameter.EMPTY_ARRAY));
         if (consNode != null) {
             Statement code = copyWith.getCode();
+            // nested-aware copyWith wraps the return in a block (the leading
+            // statement resolves nested-path keys); unwrap to the return.
+            if (code instanceof BlockStatement bs && 
!bs.getStatements().isEmpty()) {
+                code = bs.getStatements().get(bs.getStatements().size() - 1);
+            }
             if (code instanceof ReturnStatement rs) {
                 Expression expr = rs.getExpression();
                 if (expr instanceof ConstructorCallExpression) {
diff --git 
a/src/main/java/org/codehaus/groovy/transform/RecordTypeASTTransformation.java 
b/src/main/java/org/codehaus/groovy/transform/RecordTypeASTTransformation.java
index 327588d185..31ce116433 100644
--- 
a/src/main/java/org/codehaus/groovy/transform/RecordTypeASTTransformation.java
+++ 
b/src/main/java/org/codehaus/groovy/transform/RecordTypeASTTransformation.java
@@ -25,6 +25,7 @@ import groovy.transform.RecordBase;
 import groovy.transform.RecordOptions;
 import groovy.transform.RecordTypeMode;
 import groovy.transform.options.PropertyHandler;
+import org.apache.groovy.ast.tools.CopyWithUtils;
 import org.apache.groovy.ast.tools.MethodNodeUtils;
 import org.codehaus.groovy.ast.MethodNode;
 import org.apache.groovy.lang.annotation.Incubating;
@@ -83,6 +84,7 @@ import static org.codehaus.groovy.ast.ClassHelper.int_TYPE;
 import static org.codehaus.groovy.ast.ClassHelper.long_TYPE;
 import static org.codehaus.groovy.ast.ClassHelper.make;
 import static org.codehaus.groovy.ast.ClassHelper.makeWithoutCaching;
+import static org.codehaus.groovy.ast.tools.GeneralUtils.andX;
 import static org.codehaus.groovy.ast.tools.GeneralUtils.args;
 import static org.codehaus.groovy.ast.tools.GeneralUtils.arrayX;
 import static org.codehaus.groovy.ast.tools.GeneralUtils.assignS;
@@ -94,13 +96,21 @@ import static 
org.codehaus.groovy.ast.tools.GeneralUtils.classX;
 import static org.codehaus.groovy.ast.tools.GeneralUtils.constX;
 import static org.codehaus.groovy.ast.tools.GeneralUtils.ctorX;
 import static org.codehaus.groovy.ast.tools.GeneralUtils.declS;
+import static org.codehaus.groovy.ast.tools.GeneralUtils.eqX;
+import static org.codehaus.groovy.ast.tools.GeneralUtils.equalsNullX;
 import static org.codehaus.groovy.ast.tools.GeneralUtils.getInstanceProperties;
 import static org.codehaus.groovy.ast.tools.GeneralUtils.hasDeclaredMethod;
+import static org.codehaus.groovy.ast.tools.GeneralUtils.ifS;
 import static org.codehaus.groovy.ast.tools.GeneralUtils.indexX;
+import static org.codehaus.groovy.ast.tools.GeneralUtils.isTrueX;
 import static org.codehaus.groovy.ast.tools.GeneralUtils.listX;
 import static org.codehaus.groovy.ast.tools.GeneralUtils.mapEntryX;
+import static org.codehaus.groovy.ast.tools.GeneralUtils.localVarX;
 import static org.codehaus.groovy.ast.tools.GeneralUtils.mapX;
+import static org.codehaus.groovy.ast.tools.GeneralUtils.neX;
+import static org.codehaus.groovy.ast.tools.GeneralUtils.notX;
 import static org.codehaus.groovy.ast.tools.GeneralUtils.nullX;
+import static org.codehaus.groovy.ast.tools.GeneralUtils.orX;
 import static org.codehaus.groovy.ast.tools.GeneralUtils.param;
 import static org.codehaus.groovy.ast.tools.GeneralUtils.params;
 import static org.codehaus.groovy.ast.tools.GeneralUtils.plusX;
@@ -466,9 +476,22 @@ public class RecordTypeASTTransformation extends 
AbstractASTTransformation imple
         ArgumentListExpression args = new ArgumentListExpression();
         Parameter mapParam = param(GenericsUtils.nonGeneric(MAP_TYPE), 
NAMED_ARGS);
         Expression mapArg = varX(mapParam);
+        BlockStatement body = new BlockStatement();
+        body.addStatement(CopyWithUtils.nestedFlattenStmt(NAMED_ARGS));
+        // Honour the documented identity contract: an empty/no-op map (after
+        // flattening unknown and unchanged nested keys away) returns the
+        // original instance, so an unchanged graph yields the original root.
+        body.addStatement(ifS(
+                orX(equalsNullX(mapArg), eqX(callX(mapArg, "size"), 
constX(0))),
+                returnS(varX("this"))));
+        body.addStatement(declS(localVarX("dirty", boolean_TYPE), 
ConstantExpression.PRIM_FALSE));
         for (PropertyNode pNode : pList) {
             String name = pNode.getName();
             args.addExpression(ternaryX(callX(mapArg, "containsKey", 
args(constX(name))), propX(mapArg, name), thisPropX(true, name)));
+            body.addStatement(ifS(
+                    andX(callX(mapArg, "containsKey", args(constX(name))),
+                            neX(propX(mapArg, name), thisPropX(true, name))),
+                    assignS(varX("dirty", boolean_TYPE), 
ConstantExpression.TRUE)));
             ClassNode pType = pNode.getType();
             ClassNode type = pType.getPlainNodeReference();
             type.setGenericsPlaceHolder(pType.isGenericsPlaceHolder());
@@ -479,8 +502,16 @@ public class RecordTypeASTTransformation extends 
AbstractASTTransformation imple
             namedParam.addMember("required", constX(false, true));
             mapParam.addAnnotation(namedParam);
         }
-        Statement body = returnS(ctorX(cNode.getPlainNodeReference(), args));
+        // Return the original instance when nothing effectively changed;
+        // otherwise keep the constructor call as the method's final return
+        // expression, exactly as before (static compilation resolves the
+        // constructor target in that position).
+        body.addStatement(ifS(notX(isTrueX(varX("dirty", boolean_TYPE))),
+                returnS(varX("this"))));
+        body.addStatement(returnS(ctorX(cNode.getPlainNodeReference(), args)));
         addGeneratedMethod(cNode, COPY_WITH, ACC_PUBLIC | ACC_FINAL, 
cNode.getPlainNodeReference(), params(mapParam), ClassNode.EMPTY_ARRAY, body);
+
+        CopyWithUtils.addCopyWithBlockMethod(cNode);
     }
 
     private void createRecordToString(ClassNode cNode) {
diff --git 
a/src/test/groovy/org/codehaus/groovy/transform/ImmutableNestedCopyWithTest.groovy
 
b/src/test/groovy/org/codehaus/groovy/transform/ImmutableNestedCopyWithTest.groovy
new file mode 100644
index 0000000000..1710a3cd40
--- /dev/null
+++ 
b/src/test/groovy/org/codehaus/groovy/transform/ImmutableNestedCopyWithTest.groovy
@@ -0,0 +1,256 @@
+/*
+ *  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.codehaus.groovy.transform
+
+import org.junit.jupiter.api.Test
+
+import static groovy.test.GroovyAssert.assertScript
+import static groovy.test.GroovyAssert.shouldFail
+
+/**
+ * Tests for nested-path {@code copyWith} (e.g.
+ * {@code person.copyWith('address.city': 'NYC')}) on {@code @Immutable}
+ * classes declared with {@code copyWith=true}.
+ */
+final class ImmutableNestedCopyWithTest {
+
+    private final GroovyShell shell = GroovyShell.withConfig {
+        imports { star 'groovy.transform' }
+    }
+
+    private static final String GRAPH = '''
+        @Immutable(copyWith = true) class Address { String city; String zip }
+        @Immutable(copyWith = true) class Company { String name; Address 
address }
+        @Immutable(copyWith = true) class Person  { String name; Address 
address; Company company }
+        def p = new Person('Alice',
+                            new Address('NYC', '10001'),
+                            new Company('Acme', new Address('Dock St', 
'07030')))
+    '''
+
+    @Test
+    void single_level_nested_update() {
+        assertScript shell, GRAPH + '''
+            def q = p.copyWith('address.city': 'Boston')
+            assert q.address.city == 'Boston'
+            assert q.address.zip  == '10001'   // sibling preserved
+            assert q.name         == 'Alice'   // unrelated preserved
+            assert p.address.city == 'NYC'     // original untouched
+        '''
+    }
+
+    @Test
+    void multiple_keys_same_nested_node_merged() {
+        assertScript shell, GRAPH + '''
+            def q = p.copyWith('address.city': 'Boston', 'address.zip': 
'02101')
+            assert q.address.city == 'Boston'
+            assert q.address.zip  == '02101'
+        '''
+    }
+
+    @Test
+    void mixed_top_level_and_nested() {
+        assertScript shell, GRAPH + '''
+            def q = p.copyWith(name: 'Bob', 'address.city': 'LA')
+            assert q.name == 'Bob'
+            assert q.address.city == 'LA'
+        '''
+    }
+
+    @Test
+    void deep_two_level_nesting() {
+        assertScript shell, GRAPH + '''
+            def q = p.copyWith('company.address.city': 'Jersey City')
+            assert q.company.address.city == 'Jersey City'
+            assert q.company.address.zip  == '07030'
+            assert q.company.name         == 'Acme'
+            assert p.company.address.city == 'Dock St'
+        '''
+    }
+
+    @Test
+    void identity_preserved_when_nested_value_unchanged() {
+        assertScript shell, GRAPH + '''
+            def q = p.copyWith('address.city': p.address.city)
+            assert q.is(p)   // no change anywhere -> original root returned
+        '''
+    }
+
+    @Test
+    void plain_top_level_copyWith_still_works() {
+        assertScript shell, GRAPH + '''
+            def q = p.copyWith(name: 'Bob')
+            assert q.name == 'Bob'
+            assert q.address.is(p.address)
+        '''
+    }
+
+    @Test
+    void unsupported_nested_type_fails_clearly() {
+        def err = shouldFail shell, '''
+            @Immutable(copyWith = true) class Holder { String label; 
java.awt.Point pt }
+            def h = new Holder('p', new java.awt.Point(1, 2))
+            h.copyWith('pt.x': 9)
+        '''
+        assert err.message.contains('copyWith(Map)')
+        assert err.message.contains('nested-copyWith domain')
+    }
+
+    @Test
+    void null_intermediate_node_fails_clearly() {
+        def err = shouldFail shell, '''
+            @Immutable(copyWith = true) class Address { String city }
+            @Immutable(copyWith = true) class Person { String name; Address 
address }
+            def p = new Person(name: 'Alice')
+            p.copyWith('address.city': 'Boston')
+        '''
+        assert err.message.contains("'address'")
+        assert err.message.contains('null')
+    }
+
+    @Test
+    void whole_and_nested_update_conflict_fails() {
+        def err = shouldFail shell, GRAPH + '''
+            def other = new Address('X', 'Y')
+            p.copyWith(address: other, 'address.city': 'Z')
+        '''
+        assert err.message.contains('whole-value update and a nested update')
+    }
+
+    // === transactional copyWith { } block ===
+
+    @Test
+    void block_plain_and_nested_set() {
+        assertScript shell, GRAPH + '''
+            def q = p.copyWith {
+                name = 'Bob'
+                address.city = 'LA'
+            }
+            assert q.name == 'Bob'
+            assert q.address.city == 'LA'
+            assert q.address.zip == '10001'
+            assert p.name == 'Alice'
+        '''
+    }
+
+    @Test
+    void block_deep_nesting() {
+        assertScript shell, GRAPH + '''
+            def q = p.copyWith { company.address.city = 'Jersey City' }
+            assert q.company.address.city == 'Jersey City'
+            assert q.company.address.zip == '07030'
+            assert q.company.name == 'Acme'
+        '''
+    }
+
+    @Test
+    void block_functional_modify() {
+        assertScript shell, GRAPH + '''
+            def q = p.copyWith {
+                name.modify { it.toUpperCase() }
+                address.city.modify { it.reverse() }
+            }
+            assert q.name == 'ALICE'
+            assert q.address.city == 'CYN'
+            assert q.address.zip == '10001'
+        '''
+    }
+
+    @Test
+    void block_empty_is_identity() {
+        assertScript shell, GRAPH + '''
+            assert p.copyWith { }.is(p)
+        '''
+    }
+
+    @Test
+    void block_unsupported_nested_type_fails_clearly() {
+        def err = shouldFail shell, '''
+            @Immutable class Inner { String x }
+            @Immutable(copyWith = true) class Outer { String label; Inner 
inner }
+            def o = new Outer('l', new Inner('a'))
+            o.copyWith { inner.x = 'b' }
+        '''
+        assert err.message.contains('copyWith(Map)')
+    }
+
+    // === 'old' (root pre-state) in the block, alongside .modify ===
+
+    @Test
+    void block_old_single_field() {
+        assertScript shell, GRAPH + '''
+            def q = p.copyWith { name = old.name.toUpperCase() }
+            assert q.name == 'ALICE'
+            assert q.address.is(p.address)
+        '''
+    }
+
+    @Test
+    void block_old_nested_path() {
+        assertScript shell, GRAPH + '''
+            def q = p.copyWith { address.city = old.address.city.reverse() }
+            assert q.address.city == 'CYN'
+            assert q.address.zip == '10001'
+            assert p.address.city == 'NYC'
+        '''
+    }
+
+    @Test
+    void block_old_cross_field_derivation() {
+        assertScript shell, GRAPH + '''
+            def q = p.copyWith { name = old.company.name + ' / ' + old.name }
+            assert q.name == 'Acme / Alice'
+        '''
+    }
+
+    @Test
+    void block_old_and_modify_together() {
+        assertScript shell, GRAPH + '''
+            def q = p.copyWith {
+                name = old.name.toUpperCase()
+                address.city.modify { it.reverse() }
+            }
+            assert q.name == 'ALICE'
+            assert q.address.city == 'CYN'
+            assert q.address.zip == '10001'
+        '''
+    }
+
+    @Test
+    void old_is_escape_hatch_for_a_real_modify_method() {
+        // The value type has its own modify(Closure). On a navigated path the
+        // recorder's .modify shorthand would shadow it; via 'old' the RHS is
+        // the real object, so the genuine domain method runs and its result
+        // is recorded.
+        assertScript shell, '''
+            @Immutable class Money {
+                int cents
+                Money modify(Closure c) { new Money((int) c(cents)) }
+            }
+            @Immutable(copyWith = true) class Account {
+                String owner
+                Money balance
+            }
+            def a = new Account('Alice', new Money(100))
+            def b = a.copyWith { balance = old.balance.modify { it + 50 } }
+            assert b.balance.cents == 150
+            assert b.owner == 'Alice'
+            assert a.balance.cents == 100
+        '''
+    }
+}
diff --git 
a/src/test/groovy/org/codehaus/groovy/transform/RecordNestedCopyWithTest.groovy 
b/src/test/groovy/org/codehaus/groovy/transform/RecordNestedCopyWithTest.groovy
new file mode 100644
index 0000000000..85aac2ec1b
--- /dev/null
+++ 
b/src/test/groovy/org/codehaus/groovy/transform/RecordNestedCopyWithTest.groovy
@@ -0,0 +1,104 @@
+/*
+ *  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.codehaus.groovy.transform
+
+import org.junit.jupiter.api.Test
+
+import static groovy.test.GroovyAssert.assertScript
+
+/**
+ * Tests for nested-path {@code copyWith} (map form and transactional
+ * block) on {@code @RecordType} classes declared with {@code copyWith=true},
+ * including record-in-record graphs.
+ */
+final class RecordNestedCopyWithTest {
+
+    private final GroovyShell shell = GroovyShell.withConfig {
+        imports { star 'groovy.transform' }
+    }
+
+    private static final String GRAPH = '''
+        @RecordType(copyWith = true) class Address { String city; String zip }
+        @RecordType(copyWith = true) class Company { String name; Address 
address }
+        @RecordType(copyWith = true) class Person  { String name; Address 
address; Company company }
+        def p = new Person('Alice',
+                            new Address('NYC', '10001'),
+                            new Company('Acme', new Address('Dock St', 
'07030')))
+    '''
+
+    @Test
+    void record_nested_map_set() {
+        assertScript shell, GRAPH + '''
+            def q = p.copyWith('address.city': 'Boston')
+            assert q.address.city == 'Boston'
+            assert q.address.zip  == '10001'
+            assert q.name == 'Alice'
+        '''
+    }
+
+    @Test
+    void record_deep_record_in_record() {
+        assertScript shell, GRAPH + '''
+            def q = p.copyWith('company.address.city': 'Jersey City')
+            assert q.company.address.city == 'Jersey City'
+            assert q.company.address.zip  == '07030'
+            assert q.company.name == 'Acme'
+            assert p.company.address.city == 'Dock St'
+        '''
+    }
+
+    @Test
+    void record_block_nested_set() {
+        assertScript shell, GRAPH + '''
+            def q = p.copyWith {
+                name = 'Bob'
+                company.address.zip = '07310'
+            }
+            assert q.name == 'Bob'
+            assert q.company.address.zip == '07310'
+            assert q.company.address.city == 'Dock St'
+        '''
+    }
+
+    @Test
+    void record_block_functional_modify() {
+        assertScript shell, GRAPH + '''
+            def q = p.copyWith { address.city.modify { it.toUpperCase() } }
+            assert q.address.city == 'NYC'.toUpperCase()
+            assert q.address.zip == '10001'
+        '''
+    }
+
+    @Test
+    void record_block_old_nested_path() {
+        assertScript shell, GRAPH + '''
+            def q = p.copyWith { company.address.city = 
old.company.address.city.reverse() }
+            assert q.company.address.city == 'Dock St'.reverse()
+            assert q.company.address.zip == '07030'
+            assert q.name == 'Alice'
+        '''
+    }
+
+    @Test
+    void record_block_empty_is_identity() {
+        assertScript shell, GRAPH + '''
+            assert p.copyWith { }.is(p)
+        '''
+    }
+}


Reply via email to