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> — {@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 → 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)
+ '''
+ }
+}