You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@groovy.apache.org by pa...@apache.org on 2026/06/29 21:11:41 UTC
(groovy) branch master updated: GROOVY-11807: MapWithDefault can break type safety of key set
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 c1f1e5e957 GROOVY-11807: MapWithDefault can break type safety of key set
c1f1e5e957 is described below
commit c1f1e5e957da602b78db3d5f1f1ba4688a492ece
Author: Paul King <pa...@asert.com.au>
AuthorDate: Mon Jun 29 10:39:21 2026 +1000
GROOVY-11807: MapWithDefault can break type safety of key set
---
.../groovy/runtime/DefaultGroovyMethods.java | 56 ++++++++++++++
src/test/groovy/bugs/Groovy11807.groovy | 90 ++++++++++++++++++++++
2 files changed, 146 insertions(+)
diff --git a/src/main/java/org/codehaus/groovy/runtime/DefaultGroovyMethods.java b/src/main/java/org/codehaus/groovy/runtime/DefaultGroovyMethods.java
index 49b8f2ffc9..1bdc413303 100644
--- a/src/main/java/org/codehaus/groovy/runtime/DefaultGroovyMethods.java
+++ b/src/main/java/org/codehaus/groovy/runtime/DefaultGroovyMethods.java
@@ -19155,6 +19155,62 @@ public class DefaultGroovyMethods extends DefaultGroovyMethodsSupport {
return MapWithDefault.newInstance(self, autoGrow, autoShrink, init);
}
+ /**
+ * Decorates the given Map with a default value supplied by a closure, as per
+ * {@link #withDefault(Map, Closure)}, but the returned map additionally enforces its
+ * key and value types at runtime. Looking up (and so auto-growing for) a key that is
+ * not an instance of {@code keyType} throws {@link ClassCastException} at the point of
+ * misuse, rather than silently inserting a wrong-typed key that only surfaces later
+ * (for example when reading {@code keySet()}). The check is provided by decorating an
+ * {@link #asChecked(Map, Class, Class) asChecked} view, so direct mutation such as
+ * {@code map[key] = value} is guarded too.
+ * <pre class="language-groovy">
+ * def m = [:].withDefault(Number, String){ 'n/a' }
+ * assert m[1] == 'n/a' // a compatible key (and String default) grows the map
+ * // m['x'] // would throw ClassCastException (incompatible key)
+ * // m[2] = 99 // would throw ClassCastException (incompatible value)
+ * </pre>
+ * The {@code keyType} and {@code valueType} arguments are candidates to be supplied
+ * automatically from the map's static type under static compilation (GROOVY-12115);
+ * until then they are passed explicitly and this variant also works in dynamic code.
+ *
+ * @param self a Map
+ * @param keyType the required runtime key type
+ * @param valueType the required runtime value type
+ * @param init a Closure which when passed a {@code key} returns the default value
+ * @return a wrapped Map that enforces key/value types and returns a default value for missing keys
+ * @see #withDefault(Map, Closure)
+ * @see #asChecked(Map, Class, Class)
+ * @since 6.0.0
+ */
+ @Incubating
+ public static <K, V> Map<K, V> withDefault(Map<K, V> self, /* @ClassTag GROOVY-12115 */ Class<K> keyType, /* @ClassTag */ Class<V> valueType, @ClosureParams(FirstParam.FirstGenericType.class) Closure<V> init) {
+ return withDefault(asChecked(self, keyType, valueType), init);
+ }
+
+ /**
+ * Key-checked variant of {@link #withDefault(Map, Class, Class, Closure)} for the common
+ * case (such as a {@code Map<Number, ?>}) where only the key type needs enforcing; values
+ * are left unchecked.
+ * <pre class="language-groovy">
+ * def m = [:].withDefault(Number){ null }
+ * assert m[1] == null
+ * // m['x'] // would throw ClassCastException (incompatible key)
+ * </pre>
+ *
+ * @param self a Map
+ * @param keyType the required runtime key type
+ * @param init a Closure which when passed a {@code key} returns the default value
+ * @return a wrapped Map that enforces the key type and returns a default value for missing keys
+ * @see #withDefault(Map, Class, Class, Closure)
+ * @since 6.0.0
+ */
+ @Incubating
+ @SuppressWarnings("unchecked")
+ public static <K, V> Map<K, V> withDefault(Map<K, V> self, /* @ClassTag GROOVY-12115 */ Class<K> keyType, @ClosureParams(FirstParam.FirstGenericType.class) Closure<V> init) {
+ return withDefault(self, keyType, (Class<V>) (Class<?>) Object.class, init);
+ }
+
/**
* An alias for <code>withLazyDefault</code> which decorates a list allowing
* it to grow when called with index values outside the normal list bounds.
diff --git a/src/test/groovy/bugs/Groovy11807.groovy b/src/test/groovy/bugs/Groovy11807.groovy
new file mode 100644
index 0000000000..a0bb6d307f
--- /dev/null
+++ b/src/test/groovy/bugs/Groovy11807.groovy
@@ -0,0 +1,90 @@
+/*
+ * 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 bugs
+
+import org.junit.jupiter.api.Test
+
+import static groovy.test.GroovyAssert.assertScript
+
+/**
+ * The plain {@code withDefault} decorator grows a map for any looked-up key, which can
+ * silently insert a key incompatible with the map's declared key type — only surfacing
+ * later (e.g. when reading {@code keySet()}). The key-checked {@code withDefault(Map, Class, Closure)}
+ * overload (built over {@code asChecked}) rejects an incompatible key at the point of misuse
+ * (GROOVY-11807). The transparent, auto-supplied-{@code Class} form is tracked as GROOVY-12115.
+ */
+final class Groovy11807 {
+
+ @Test
+ void testKeyCheckedWithDefaultRejectsIncompatibleKey() {
+ assertScript '''
+ Map<Number,?> map = [:].withDefault(Number){ null }
+ assert map[1] == null // a compatible key grows the map
+ assert map.get(2) == null
+ def err = groovy.test.GroovyAssert.shouldFail(ClassCastException) { map.get('x') }
+ assert err.message.contains('Number')
+ assert map.keySet().every { it instanceof Number }
+ Number k = map.keySet()[0] // the original repro: no longer a runtime cast failure
+ assert k == 1
+ '''
+ }
+
+ @Test
+ void testKeyCheckedWithDefaultGuardsDirectPut() {
+ assertScript '''
+ def map = [:].withDefault(Number){ 0 }
+ map[1] = 10
+ groovy.test.GroovyAssert.shouldFail(ClassCastException) { map['x'] = 1 }
+ assert map.keySet().every { it instanceof Number }
+ '''
+ }
+
+ @Test
+ void testKeyAndValueCheckedWithDefault() {
+ assertScript '''
+ def map = [:].withDefault(Number, String){ 'def' }
+ assert map[1] == 'def' // grows with compatible key/value
+ groovy.test.GroovyAssert.shouldFail(ClassCastException) { map.get('x') } // incompatible key
+ groovy.test.GroovyAssert.shouldFail(ClassCastException) { map[2] = 99 } // incompatible value
+ '''
+ }
+
+ @Test
+ void testPlainWithDefaultRemainsLenient() {
+ assertScript '''
+ def map = [:].withDefault{ null }
+ map.get('x') // unchanged behaviour: silently grows, no exception
+ assert map.containsKey('x')
+ '''
+ }
+
+ @Test
+ void testKeyCheckedWithDefaultUnderCompileStatic() {
+ assertScript '''
+ @groovy.transform.CompileStatic
+ String check() {
+ Map<Number,?> map = [:].withDefault(Number){ null }
+ assert map[1] == null
+ groovy.test.GroovyAssert.shouldFail(ClassCastException) { map.get('x') }
+ 'ok'
+ }
+ assert check() == 'ok'
+ '''
+ }
+}