diff --git a/.github/ci-run-neo4j-tests.sh b/.github/ci-run-neo4j-tests.sh new file mode 100644 index 00000000000..14ff9c01f08 --- /dev/null +++ b/.github/ci-run-neo4j-tests.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# 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 +# +# https://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. + +set -euo pipefail + +# The marker is written only after every task has completed, with task failures +# included. Never accept a marker left by an earlier invocation. +rm -f build/ci-build-finished.txt build/ci-build-finished.txt.tmp +set +e +timeout --kill-after=30s 90m ./gradlew bootJar check \ + --init-script .github/ci-exit-after-build.init.gradle \ + --no-daemon --continue --rerun-tasks --stacktrace \ + -PonlyNeo4jTests -PskipCodeStyle "$@" +code=$? +set -e + +if [[ $code == 124 && -f build/ci-build-finished.txt ]] \ + && [[ "$(cat build/ci-build-finished.txt)" == SUCCESS ]]; then + echo 'All Gradle tasks passed; stopped stalled build teardown.' + exit 0 +fi +exit "$code" diff --git a/.github/tests/test_ci_run_neo4j.py b/.github/tests/test_ci_run_neo4j.py new file mode 100644 index 00000000000..5ffb3924520 --- /dev/null +++ b/.github/tests/test_ci_run_neo4j.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +# 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 +# +# https://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. + +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +SCRIPT = Path(__file__).resolve().parents[1] / 'ci-run-neo4j-tests.sh' + + +class Neo4jExitTests(unittest.TestCase): + def test_exit_status_requires_success_from_this_invocation(self): + cases = [ + (0, 'SUCCESS', None, 0), + (0, None, None, 0), + (1, 'FAILURE', None, 1), + (1, 'SUCCESS', None, 1), + (124, 'SUCCESS', None, 0), + (124, 'FAILURE', None, 124), + (124, None, None, 124), + (124, None, 'SUCCESS', 124), + (124, 'OTHER', None, 124), + (137, 'SUCCESS', None, 137), + ] + for code, marker, stale, expected in cases: + with self.subTest(code=code, marker=marker, stale=stale): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / 'build').mkdir() + if stale: + (root / 'build/ci-build-finished.txt').write_text(stale) + timeout = root / 'timeout' + timeout.write_text( + '#!/usr/bin/env python3\n' + 'import os\n' + 'import subprocess\n' + 'import sys\n' + 'subprocess.run(sys.argv[3:], check=True)\n' + 'sys.exit(int(os.environ["GRAILS_CI_TEST_TIMEOUT_EXIT"]))\n' + ) + timeout.chmod(0o755) + gradle = root / 'gradlew' + gradle.write_text( + '#!/usr/bin/env python3\n' + 'import os\n' + 'import sys\n' + 'from pathlib import Path\n' + 'Path("args.txt").write_text("\\n".join(sys.argv[1:]))\n' + 'if "GRAILS_CI_TEST_MARKER" in os.environ:\n' + ' Path("build/ci-build-finished.txt").write_text(' + 'os.environ["GRAILS_CI_TEST_MARKER"])\n' + ) + gradle.chmod(0o755) + env = dict( + os.environ, + PATH=f'{root}:{os.environ["PATH"]}', + GRAILS_CI_TEST_TIMEOUT_EXIT=str(code), + ) + env.pop('GRAILS_CI_TEST_MARKER', None) + if marker is not None: + env['GRAILS_CI_TEST_MARKER'] = marker + result = subprocess.run( + ['bash', str(SCRIPT), '-PgrailsIndy=false'], + cwd=root, env=env, capture_output=True, text=True, + ) + self.assertEqual(result.returncode, expected, result.stderr) + args = (root / 'args.txt').read_text().splitlines() + self.assertIn('--init-script', args) + self.assertIn('-PonlyNeo4jTests', args) + self.assertIn('-PgrailsIndy=false', args) + + +if __name__ == '__main__': + unittest.main() diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 36058c92d7d..8a3b2ab4452 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -870,6 +870,7 @@ jobs: if: ${{ !contains(github.event.head_commit.message, '[skip tests]') }} name: "Neo4j Functional Tests (Java ${{ matrix.java }}, indy=${{ matrix.indy }})" runs-on: ubuntu-24.04 + timeout-minutes: 105 strategy: fail-fast: false matrix: @@ -896,14 +897,9 @@ jobs: - name: "🏃 Run Functional Tests" env: GITHUB_MAVEN_PASSWORD: ${{ secrets.GITHUB_TOKEN }} - run: > - ./gradlew bootJar check - --continue - --rerun-tasks - --stacktrace - -PgrailsIndy=${{ matrix.indy }} - -PonlyNeo4jTests - -PskipCodeStyle + run: | + python3 .github/tests/test_ci_run_neo4j.py + bash .github/ci-run-neo4j-tests.sh -PgrailsIndy=${{ matrix.indy }} publishGradle: if: github.repository_owner == 'apache' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') needs: [ buildGradle ] diff --git a/.github/workflows/groovy-snapshot-canary.yml b/.github/workflows/groovy-snapshot-canary.yml index 32fb72d1e78..9538c5376ab 100644 --- a/.github/workflows/groovy-snapshot-canary.yml +++ b/.github/workflows/groovy-snapshot-canary.yml @@ -59,25 +59,15 @@ jobs: - name: "📝 Derive matching Apache Groovy branch from dependencies.gradle" id: groovy-branch run: | - # Extract the major and minor version of `groovy.version` declared in this - # branch's dependencies.gradle (e.g. '5.0.5' -> '5_0', '4.0.31' -> '4_0', - # '6.0.0-beta-2' -> '6_0'; a pre-release qualifier is ignored) - # and use it to pick the matching Apache Groovy development branch - # (GROOVY___X), or master for the current Groovy development - # line before a maintenance branch exists. - # This keeps `7.0.x` PRs validating against Groovy 4 and `8.0.x` PRs - # validating against Groovy 5 without hard-coding the branch here. - GROOVY_MAJOR_MINOR=$(sed -nE "s/.*'groovy\.version'.*'([0-9]+)\.([0-9]+)\.[0-9]+[^']*'.*/\1_\2/p" dependencies.gradle | head -n 1) + # Extract the main BOM's Groovy release line, ignoring prerelease suffixes + # (e.g. '6.0.0-RC-2' -> '6_0'), and build its matching maintenance branch. + # Groovy master has moved to 7.x; Grails 9 still validates Groovy 6 here. + GROOVY_MAJOR_MINOR=$(sed -nE "s/^[[:space:]]*'groovy\.version'[[:space:]]*:[[:space:]]*'([0-9]+)\.([0-9]+)\.[0-9]+.*/\1_\2/p" dependencies.gradle | head -n 1) if [ -z "$GROOVY_MAJOR_MINOR" ]; then echo "::error::Could not determine Apache Groovy major/minor version from dependencies.gradle" exit 1 fi - GROOVY_MAJOR=${GROOVY_MAJOR_MINOR%%_*} - if [ "$GROOVY_MAJOR" = "6" ]; then - GROOVY_BRANCH="master" - else - GROOVY_BRANCH="GROOVY_${GROOVY_MAJOR_MINOR}_X" - fi + GROOVY_BRANCH="GROOVY_${GROOVY_MAJOR_MINOR}_X" echo "Validating against Apache Groovy branch: $GROOVY_BRANCH" echo "value=$GROOVY_BRANCH" >> $GITHUB_OUTPUT rm dependencies.gradle diff --git a/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GroovydocEnhancerExtension.groovy b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GroovydocEnhancerExtension.groovy index 15d61ca98d6..db008503e39 100644 --- a/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GroovydocEnhancerExtension.groovy +++ b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GroovydocEnhancerExtension.groovy @@ -30,8 +30,8 @@ import org.gradle.api.provider.Property * Extension for configuring the Groovydoc Enhancer convention plugin. * *

This plugin replaces Gradle's built-in Groovydoc task execution with - * a direct AntBuilder invocation of the Groovy {@code org.codehaus.groovy.ant.Groovydoc} - * Ant task. This enables the {@code javaVersion} parameter (added in Groovy 4.0.27, + * an isolated JVM invocation of the Groovy {@code org.codehaus.groovy.ant.Groovydoc} + * Ant task. Each invocation releases its parser memory when the process exits. This enables the {@code javaVersion} parameter (added in Groovy 4.0.27, * GROOVY-11668) which controls the JavaParser language level used when parsing * Java source files.

* @@ -67,9 +67,9 @@ class GroovydocEnhancerExtension { /** * Whether to replace Gradle's built-in Groovydoc task execution with - * AntBuilder invocation. When {@code true} (default), the plugin clears - * the task's actions and replaces them with a {@code doLast} that uses - * AntBuilder. When {@code false}, the plugin only applies property + * an isolated Ant invocation. When {@code true} (default), the plugin clears + * the task's actions and replaces them with a {@code doLast} that runs + * Ant in a separate JVM, honoring the task's Java launcher and maximum memory. When {@code false}, the plugin only applies property * defaults (footer, etc.) and lets Gradle's built-in task run normally. * *

Set to {@code false} when Gradle adds native {@code javaVersion} diff --git a/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GroovydocEnhancerPlugin.groovy b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GroovydocEnhancerPlugin.groovy index 8c87a887ac3..f7c2511ce73 100644 --- a/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GroovydocEnhancerPlugin.groovy +++ b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GroovydocEnhancerPlugin.groovy @@ -18,8 +18,11 @@ */ package org.apache.grails.buildsrc +import javax.inject.Inject + import groovy.transform.CompileDynamic import groovy.transform.CompileStatic +import groovy.xml.MarkupBuilder import org.gradle.api.Plugin import org.gradle.api.Project @@ -29,9 +32,13 @@ import org.gradle.api.attributes.Usage import org.gradle.api.provider.Provider import org.gradle.api.tasks.SourceSetContainer import org.gradle.api.tasks.javadoc.Groovydoc +import org.gradle.process.ExecOperations @CompileStatic -class GroovydocEnhancerPlugin implements Plugin { +abstract class GroovydocEnhancerPlugin implements Plugin { + + @Inject + protected abstract ExecOperations getExecOperations() @Override void apply(Project project) { @@ -40,9 +47,16 @@ class GroovydocEnhancerPlugin implements Plugin { GroovydocEnhancerExtension, project ) + Provider throttle = project.gradle.sharedServices.registerIfAbsent( + 'groovydocMemoryThrottle', GroovydocMemoryThrottle) { + it.maxParallelUsages.set(1) + } + project.tasks.withType(Groovydoc).configureEach { + it.usesService(throttle) + } registerDocumentationConfiguration(project) configureGroovydocDefaults(project, extension) - configureAntBuilderExecution(project, extension) + configureAntBuilderExecution(project, extension, execOperations) } private static void registerDocumentationConfiguration(Project project) { @@ -87,7 +101,8 @@ class GroovydocEnhancerPlugin implements Plugin { } @CompileDynamic - private static void configureAntBuilderExecution(Project project, GroovydocEnhancerExtension extension) { + private static void configureAntBuilderExecution(Project project, GroovydocEnhancerExtension extension, + ExecOperations execOperations) { project.tasks.withType(Groovydoc).configureEach { gdoc -> if (!extension.useAntBuilder.get()) { return @@ -96,6 +111,7 @@ class GroovydocEnhancerPlugin implements Plugin { // The external javadoc mapping changes the generated HTML, so a change to it has to // invalidate the task's output. gdoc.inputs.property('groovydocLinks', project.provider { resolveLinks(gdoc) }) + gdoc.maxMemory.convention('3g') gdoc.actions.clear() gdoc.doLast { @@ -124,12 +140,6 @@ class GroovydocEnhancerPlugin implements Plugin { // those types into external javadoc URLs. def antClasspath = gdoc.classpath ? classpath.plus(gdoc.classpath) : classpath - project.ant.taskdef( - name: 'groovydoc', - classname: 'org.codehaus.groovy.ant.Groovydoc', - classpath: antClasspath.asPath - ) - def links = resolveLinks(gdoc) def sourcepath = sourceDirs .collect { it.absolutePath } @@ -154,11 +164,31 @@ class GroovydocEnhancerPlugin implements Plugin { antArgs.put('javaVersion', extension.javaVersion.get()) } - project.ant.groovydoc(antArgs) { - for (var l in links) { - link(packages: l.packages, href: l.href) + // A fresh process releases parser trees and classloaders after each task. + // Running sequentially inside Gradle still retains enough state to exhaust + // its heap when the aggregate documentation follows the module docs. + File buildFile = new File(gdoc.temporaryDir, 'groovydoc.xml') + buildFile.withWriter('UTF-8') { writer -> + new MarkupBuilder(writer).project(name: 'groovydoc', default: 'docs') { + taskdef(name: 'groovydoc', classname: 'org.codehaus.groovy.ant.Groovydoc') + target(name: 'docs') { + groovydoc(antArgs) { + for (var l in links) { + link(packages: l.packages, href: l.href) + } + } + } } } + execOperations.javaexec { spec -> + spec.executable = gdoc.javaLauncher.get().executablePath.asFile.absolutePath + spec.classpath(antClasspath) + spec.mainClass.set('org.apache.tools.ant.Main') + // Included builds (such as Forge) do not inherit the root JVM settings. + spec.systemProperty('spock.iKnowWhatImDoing.disableGroovyVersionCheck', 'true') + spec.maxHeapSize = gdoc.maxMemory.get() + spec.args('-f', buildFile.absolutePath) + }.assertNormalExitValue() } } } diff --git a/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GroovydocMemoryThrottle.groovy b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GroovydocMemoryThrottle.groovy new file mode 100644 index 00000000000..84dfc33c741 --- /dev/null +++ b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GroovydocMemoryThrottle.groovy @@ -0,0 +1,33 @@ +/* + * 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 + * + * https://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.grails.buildsrc + +import groovy.transform.CompileStatic + +import org.gradle.api.services.BuildService +import org.gradle.api.services.BuildServiceParameters + +/** + * Limits Groovydoc to one task at a time. Its isolated JVMs must not compete + * for runner memory during parallel project execution. + */ +@CompileStatic +abstract class GroovydocMemoryThrottle implements BuildService { +} diff --git a/build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/GroovydocEnhancerPluginSpec.groovy b/build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/GroovydocEnhancerPluginSpec.groovy index c0c29cb7686..9f25ee64b44 100644 --- a/build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/GroovydocEnhancerPluginSpec.groovy +++ b/build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/GroovydocEnhancerPluginSpec.groovy @@ -21,6 +21,8 @@ package org.apache.grails.buildsrc import org.gradle.api.Project import org.gradle.api.tasks.javadoc.Groovydoc import org.gradle.testfixtures.ProjectBuilder +import org.gradle.testkit.runner.GradleRunner +import org.gradle.testkit.runner.TaskOutcome import spock.lang.Specification import spock.lang.TempDir @@ -51,4 +53,110 @@ class GroovydocEnhancerPluginSpec extends Specification { groovydocFiles.any { it.name == runtimeOnlyJar.name } groovydocFiles.any { it.name == compileOnlyJar.name } } + + void 'generates documentation with links and markup in a separate JVM'() { + given: 'a source file and documentation options containing XML-sensitive characters' + new File(projectDir, 'settings.gradle').text = "rootProject.name = 'docs-fixture'" + File source = new File(projectDir, 'src/main/groovy/example/Sample.groovy') + source.parentFile.mkdirs() + source.text = '''package example + /** A documented class. */ + class Sample { + /** Returns a string. */ + String text() { 'example' } + } + ''' + new File(projectDir, 'build.gradle').text = ''' + plugins { + id 'groovy' + id 'org.apache.grails.buildsrc.groovydoc-enhancer' + } + ext.javaVersion = 21 + repositories { mavenCentral() } + dependencies { + implementation 'org.apache.groovy:groovy:6.0.0-RC-2' + runtimeOnly 'org.spockframework:spock-core:2.4-groovy-5.0' + documentation 'org.apache.groovy:groovy-groovydoc:6.0.0-RC-2' + documentation 'org.apache.groovy:groovy-ant:6.0.0-RC-2' + documentation 'org.apache.groovy:groovy-templates:6.0.0-RC-2' + documentation 'com.github.javaparser:javaparser-core:3.28.2' + } + groovydocEnhancer.footer = 'Docs & examples' + tasks.named('groovydoc') { + windowTitle = 'API & examples' + ext.groovydocLinks = [[packages: 'java.', href: 'https://example.org/java/']] + maxMemory = '256m' + } + ''' + + when: 'the public documentation task runs' + def result = GradleRunner.create() + .withProjectDir(projectDir) + .withPluginClasspath() + .withArguments('groovydoc', '--info', '--max-workers=1', + '-Dorg.gradle.jvmargs=-Xmx512m', '--stacktrace') + .build() + + then: 'the isolated Ant invocation generates the configured HTML and external links' + result.task(':groovydoc').outcome == TaskOutcome.SUCCESS + result.output.contains('org.apache.tools.ant.Main -f') + String html = new File(projectDir, 'build/docs/groovydoc/example/Sample.html').text + html.contains('Docs & examples') + html.contains('https://example.org/java/java/lang/String.html') + html.contains('A documented class.') + } + + void 'Groovydoc tasks in different projects do not overlap in a parallel build'() { + given: 'two documentation tasks competing for runner memory' + new File(projectDir, 'settings.gradle').text = "include 'one', 'two'" + ['one', 'two'].each { name -> + File directory = new File(projectDir, name) + directory.mkdirs() + new File(directory, 'Sample.groovy').text = 'class Sample {}' + } + new File(projectDir, 'build.gradle').text = """ + plugins { + id 'org.apache.grails.buildsrc.groovydoc-enhancer' apply false + } + subprojects { + apply plugin: 'groovy' + ext.javaVersion = 21 + apply plugin: 'org.apache.grails.buildsrc.groovydoc-enhancer' + tasks.register('apiDocs', org.gradle.api.tasks.javadoc.Groovydoc) { + source file('Sample.groovy') + classpath = files() + groovyClasspath = files() + destinationDir = layout.buildDirectory.dir('docs').get().asFile + // Observe scheduling without generating a large documentation tree. + actions.clear() + doLast { + File active = rootProject.file('active-documentation-task') + assert active.createNewFile(): 'Groovydoc tasks overlapped' + try { + Thread.sleep(1000) + destinationDir.mkdirs() + new File(destinationDir, 'index.html').text = project.name + } finally { + active.delete() + } + } + } + } + """ + + when: 'Gradle can run both subprojects concurrently' + def result = GradleRunner.create() + .withProjectDir(projectDir) + .withPluginClasspath() + .withArguments('apiDocs', '--parallel', '--max-workers=2', + '-Dorg.gradle.jvmargs=-Xmx512m', '--stacktrace') + .build() + + then: 'both tasks finish without simultaneous documentation work' + result.task(':one:apiDocs').outcome == TaskOutcome.SUCCESS + result.task(':two:apiDocs').outcome == TaskOutcome.SUCCESS + new File(projectDir, 'one/build/docs/index.html').text == 'one' + new File(projectDir, 'two/build/docs/index.html').text == 'two' + } + } diff --git a/dependencies.gradle b/dependencies.gradle index 149e1744b5e..9c788a57da3 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -22,7 +22,7 @@ // These files are split to facilitate separation of build vs application dependencies. These are the application dependencies. ext { gradleBomDependencyVersions = [ - 'ant.version' : '1.10.17', + 'ant.version' : '1.10.18', 'asciidoctor-gradle-jvm.version': '4.0.5', 'asciidoctorj.version' : '3.0.1', 'asm.version' : '9.10.1', @@ -84,11 +84,11 @@ ext { // extended-scalars compatibility. See https://github.com/apache/grails-core/issues/15674 'graphql-java.version' : '25.0', 'graphql-java-extended-scalars.version': '24.0', - 'groovy.version' : '6.0.0-beta-2', + 'groovy.version' : '6.0.0-RC-2', 'guava.version' : '33.7.1-jre', // Security: overrides spring-boot-dependencies (5.4.2). 5.4.3 fixes CVE-2026-54399 (httpcore5) and CVE-2026-54428 (httpcore5-h2). 'httpcore5.version' : '5.4.3', - // Groovy YAML 6.0.0-beta-2 requires Jackson 2.22.x, and 2.22.2 also overrides the + // Groovy YAML 6.0.0-RC-2 requires Jackson 2.22.x, and 2.22.2 also overrides the // 2.21.5 Spring Boot 4.1 manages: it adds the StreamReadConstraints length limit to // GregorianCalendar/Duration (CVE-2026-68497) and restricts the URI schemes // java.nio.file.Path deserialization accepts (CVE-2026-19032). Jackson 2 only releases @@ -194,7 +194,7 @@ ext { 'httpcore5' : "org.apache.httpcomponents.core5:httpcore5:${bomDependencyVersions['httpcore5.version']}", 'httpcore5-h2' : "org.apache.httpcomponents.core5:httpcore5-h2:${bomDependencyVersions['httpcore5.version']}", // Security override of the 2.21.5 spring-boot-dependencies imports through - // com.fasterxml.jackson:jackson-bom - see jackson2.version. groovy-yaml 6.0.0-beta-2 + // com.fasterxml.jackson:jackson-bom - see jackson2.version. groovy-yaml 6.0.0-RC-2 // also pulls jackson-dataformat-yaml, so the BOM must manage it at >= the resolved version. 'jackson2-annotations' : "com.fasterxml.jackson.core:jackson-annotations:${bomDependencyVersions['jackson2-annotations.version']}", 'jackson2-core' : "com.fasterxml.jackson.core:jackson-core:${bomDependencyVersions['jackson2.version']}", diff --git a/gradle.properties b/gradle.properties index e22934ede67..99b7b1868ab 100644 --- a/gradle.properties +++ b/gradle.properties @@ -85,8 +85,7 @@ org.gradle.parallel=true org.gradle.daemon=true # Do NOT turn on due to https://github.com/gradle/gradle/issues/9489 #org.gradle.configureondemand=true -# Note: groovydoc requires almost a doubling of this memory; if it could run in a process isolation, we could reduce this -# This is a future TODO see groovydoc-tool-rewrite branch for experiementations with this +# Groovydoc runs in a separate JVM with its own maximum memory setting. # grails8-groovy6-canary: carry Spock's compile-time Groovy version-check opt-out on the build JVM so # the forked gson/gsp view compiler (AbstractGroovyTemplateCompileTask) can propagate it; Spock's global # AST transform otherwise aborts view compilation under Groovy 6. Remove once Spock ships a groovy-6.0 build. diff --git a/grails-async/plugin/src/main/groovy/org/grails/plugins/web/async/GrailsAsyncContext.groovy b/grails-async/plugin/src/main/groovy/org/grails/plugins/web/async/GrailsAsyncContext.groovy index 5f6a0edaa11..e11418a1273 100644 --- a/grails-async/plugin/src/main/groovy/org/grails/plugins/web/async/GrailsAsyncContext.groovy +++ b/grails-async/plugin/src/main/groovy/org/grails/plugins/web/async/GrailsAsyncContext.groovy @@ -23,6 +23,8 @@ import jakarta.servlet.AsyncListener import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse +import org.springframework.web.context.request.RequestContextHolder + import grails.async.web.AsyncGrailsWebRequest import grails.persistence.support.PersistenceContextInterceptor import org.grails.web.servlet.mvc.GrailsWebRequest @@ -67,8 +69,13 @@ class GrailsAsyncContext implements AsyncContext { for (PersistenceContextInterceptor i in interceptors) { i.destroy() } - webRequest.requestCompleted() - WebUtils.clearGrailsWebRequest() + try { + webRequest.requestCompleted() + } finally { + // Dispatch or completion may already have handed the servlet request back + // to the container. Only unbind this worker; do not change request attributes. + RequestContextHolder.resetRequestAttributes() + } } } } diff --git a/grails-async/plugin/src/test/groovy/org/grails/plugins/web/async/GrailsAsyncContextSpec.groovy b/grails-async/plugin/src/test/groovy/org/grails/plugins/web/async/GrailsAsyncContextSpec.groovy new file mode 100644 index 00000000000..536d104d70f --- /dev/null +++ b/grails-async/plugin/src/test/groovy/org/grails/plugins/web/async/GrailsAsyncContextSpec.groovy @@ -0,0 +1,95 @@ +/* + * 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 + * + * https://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.grails.plugins.web.async + +import jakarta.servlet.AsyncContext + +import org.springframework.mock.web.MockHttpServletRequest +import org.springframework.mock.web.MockHttpServletResponse +import org.springframework.web.context.request.RequestContextHolder + +import spock.lang.Specification + +import org.grails.web.servlet.mvc.GrailsWebRequest +import org.grails.web.util.GrailsApplicationAttributes + +class GrailsAsyncContextSpec extends Specification { + + void cleanup() { + RequestContextHolder.resetRequestAttributes() + } + + void 'worker cleanup does not access a request recycled during completion'() { + given: + def request = new RecyclableRequest() + def response = new MockHttpServletResponse() + AsyncContext delegate = Stub() { + getRequest() >> request + getResponse() >> response + start(_ as Runnable) >> { Runnable worker -> worker.run() } + } + AsyncContext context = new GrailsAsyncContext(delegate, + new GrailsWebRequest(request, response, request.servletContext)) + + when: + context.start { + assert GrailsWebRequest.lookup() != null + request.recycled = true + } + + then: + noExceptionThrown() + RequestContextHolder.requestAttributes == null + } + + void 'worker cleanup preserves the web request installed by an async dispatch'() { + given: + def request = new MockHttpServletRequest() + def response = new MockHttpServletResponse() + def dispatchedRequest = new GrailsWebRequest(request, response, request.servletContext) + AsyncContext delegate = Stub() { + getRequest() >> request + getResponse() >> response + start(_ as Runnable) >> { Runnable worker -> worker.run() } + } + AsyncContext context = new GrailsAsyncContext(delegate, + new GrailsWebRequest(request, response, request.servletContext)) + + when: + context.start { + request.setAttribute(GrailsApplicationAttributes.WEB_REQUEST, dispatchedRequest) + } + + then: + request.getAttribute(GrailsApplicationAttributes.WEB_REQUEST).is(dispatchedRequest) + RequestContextHolder.requestAttributes == null + } + + private static class RecyclableRequest extends MockHttpServletRequest { + boolean recycled + + @Override + void removeAttribute(String name) { + if (recycled) { + throw new IllegalStateException('The request object has been recycled') + } + super.removeAttribute(name) + } + } +} diff --git a/grails-core/src/main/groovy/grails/boot/GrailsBanner.groovy b/grails-core/src/main/groovy/grails/boot/GrailsBanner.groovy index b6bfbbeaa31..d18516b4ca7 100644 --- a/grails-core/src/main/groovy/grails/boot/GrailsBanner.groovy +++ b/grails-core/src/main/groovy/grails/boot/GrailsBanner.groovy @@ -511,7 +511,8 @@ class GrailsBanner implements Banner { /** What an application wrote under the given property, or nothing where it wrote none. */ private static List readVersionOptions(Environment env, String propertyName) { - env.getProperty(propertyName, List, [] as List) + // Groovy 6.0.0-RC-1 (GROOVY-12319): parameterized types are not class literals. + env.getProperty(propertyName, List, [] as List) } /** diff --git a/grails-core/src/main/groovy/grails/util/AbstractTypeConvertingMap.java b/grails-core/src/main/groovy/grails/util/AbstractTypeConvertingMap.java index 70c2cb4cd6a..20e39a5f7a4 100644 --- a/grails-core/src/main/groovy/grails/util/AbstractTypeConvertingMap.java +++ b/grails-core/src/main/groovy/grails/util/AbstractTypeConvertingMap.java @@ -51,9 +51,11 @@ * Expose such a value under a name that is not a JavaBean accessor - for example * {@code GrailsParameterMap.request()} - or under a method that takes an argument. * - * A setter is a weaker case and is allowed: it leaves reads addressing the map, but assignment to - * that one name invokes the setter instead of storing an entry, so such an entry must be written - * with {@link Map#put}. {@code GroovyPageAttributes.setGspTagSyntaxCall(boolean)} is the only one. + * A setter is a weaker case and is allowed: it leaves reads addressing the map, but dotted + * assignment to that one name invokes the setter instead of storing an entry. Groovy 6 + * routes Map subscript assignment through {@link Map#put}, so {@code map['x'] = v} stores + * an entry rather than invoking the setter. Such an entry can also be written with + * {@link Map#put}. {@code GroovyPageAttributes.setGspTagSyntaxCall(boolean)} is the only one. * * @author Graeme Rocher * @author Lari Hotari diff --git a/grails-geb/src/testFixtures/groovy/grails/plugin/geb/WebDriverContainerHolder.groovy b/grails-geb/src/testFixtures/groovy/grails/plugin/geb/WebDriverContainerHolder.groovy index 014e61ab176..fcbd83dc713 100644 --- a/grails-geb/src/testFixtures/groovy/grails/plugin/geb/WebDriverContainerHolder.groovy +++ b/grails-geb/src/testFixtures/groovy/grails/plugin/geb/WebDriverContainerHolder.groovy @@ -465,7 +465,7 @@ class WebDriverContainerHolder { */ static Closure withSystemProperty(Closure target, String key, Object value) { Closure wrapped = { Object... args -> - SysPropScope.withProperty(key, value.toString()) { + ThreadLocalPropertyScope.withProperty(key, value.toString()) { InvokerHelper.invokeClosure(target, args) } } @@ -476,46 +476,5 @@ class WebDriverContainerHolder { } } - @CompileStatic - private static class SysPropScope { - - private static final ThreadLocal> OVERRIDDEN_SYSTEM_PROPERTIES = - ThreadLocal.withInitial { [:] as Map } - - @Lazy // Thread-safe wrapping of system properties - private static Properties propertiesWrappedOnFirstAccess = { - new InterceptingProperties().tap { - putAll(System.getProperties()) - System.setProperties(it) - } - }() - - // Helper method for Groovy 5 static type checking compatibility - private static Map getOverriddenProperties() { - OVERRIDDEN_SYSTEM_PROPERTIES.get() - } - - static T withProperty(String key, String value, Closure body) { - propertiesWrappedOnFirstAccess // Access property to trigger property wrapping - def map = OVERRIDDEN_SYSTEM_PROPERTIES.get() - def prev = map.put(key, value) - try { - return body.call() - } finally { - if (prev == null) map.remove(key) else map[key] = prev - if (map.isEmpty()) OVERRIDDEN_SYSTEM_PROPERTIES.remove() - } - } - - @CompileStatic - private static class InterceptingProperties extends Properties { - @Override - String getProperty(String key) { - Map overrides = getOverriddenProperties() - def v = overrides.get(key) - v != null ? v : super.getProperty(key) - } - } - } } } diff --git a/grails-geb/src/testFixtures/java/grails/plugin/geb/ThreadLocalPropertyScope.java b/grails-geb/src/testFixtures/java/grails/plugin/geb/ThreadLocalPropertyScope.java new file mode 100644 index 00000000000..cdbb4ec8534 --- /dev/null +++ b/grails-geb/src/testFixtures/java/grails/plugin/geb/ThreadLocalPropertyScope.java @@ -0,0 +1,77 @@ +/* + * 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 + * + * https://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 grails.plugin.geb; + +import java.util.HashMap; +import java.util.Map; +import java.util.Properties; +import java.util.function.Supplier; + +/** + * Overrides a system property for a custom browser factory on its calling thread. + * Property lookup must stay in Java: Groovy call-site initialization itself reads + * system properties and would recursively enter a Groovy-backed property lookup. + */ +final class ThreadLocalPropertyScope { + + private static final ThreadLocal> OVERRIDES = ThreadLocal.withInitial(HashMap::new); + private static boolean installed; + + private ThreadLocalPropertyScope() { + } + + static T withProperty(String key, String value, Supplier body) { + installProperties(); + Map overrides = OVERRIDES.get(); + String previous = overrides.put(key, value); + try { + return body.get(); + } + finally { + if (previous == null) { + overrides.remove(key); + } + else { + overrides.put(key, previous); + } + if (overrides.isEmpty()) { + OVERRIDES.remove(); + } + } + } + + private static synchronized void installProperties() { + if (!installed) { + Properties properties = new InterceptingProperties(); + properties.putAll(System.getProperties()); + System.setProperties(properties); + installed = true; + } + } + + private static final class InterceptingProperties extends Properties { + private static final long serialVersionUID = 1L; + + @Override + public String getProperty(String key) { + String value = OVERRIDES.get().get(key); + return value != null ? value : super.getProperty(key); + } + } +} diff --git a/grails-gsp/grails-sitemesh3/src/test/groovy/org/grails/plugins/sitemesh3/Sitemesh3RenderViewMutatorSpec.groovy b/grails-gsp/grails-sitemesh3/src/test/groovy/org/grails/plugins/sitemesh3/Sitemesh3RenderViewMutatorSpec.groovy index e8751917d5d..8cd72a7be4b 100644 --- a/grails-gsp/grails-sitemesh3/src/test/groovy/org/grails/plugins/sitemesh3/Sitemesh3RenderViewMutatorSpec.groovy +++ b/grails-gsp/grails-sitemesh3/src/test/groovy/org/grails/plugins/sitemesh3/Sitemesh3RenderViewMutatorSpec.groovy @@ -37,8 +37,9 @@ class Sitemesh3RenderViewMutatorSpec extends Specification { View innerView = Mock(View) GrailsSiteMeshView siteMeshView() { + DecoratorSelector decoratorSelector = Mock() new GrailsSiteMeshView(innerView, Mock(ContentProcessor), - Mock(DecoratorSelector), Mock(ServletContext), Mock(ViewResolver)) + decoratorSelector, Mock(ServletContext), Mock(ViewResolver)) } void 'unwraps the SiteMesh view for partial renders without an explicit layout'() { diff --git a/grails-gsp/grails-taglib/src/test/groovy/org/grails/taglib/GroovyPageAttributesTests.groovy b/grails-gsp/grails-taglib/src/test/groovy/org/grails/taglib/GroovyPageAttributesTests.groovy index 878561e5bd3..75409c83f56 100644 --- a/grails-gsp/grails-taglib/src/test/groovy/org/grails/taglib/GroovyPageAttributesTests.groovy +++ b/grails-gsp/grails-taglib/src/test/groovy/org/grails/taglib/GroovyPageAttributesTests.groovy @@ -119,9 +119,11 @@ class GroovyPageAttributesTests { } // https://github.com/apache/grails-core/issues/16280 - // gspTagSyntaxCall keeps a real setter, so assigning that one name in dotted form invokes - // the setter rather than storing an entry. That is the Grails 7 behaviour, and TagOutput - // relies on it. Use put() to store an attribute of that name. + // gspTagSyntaxCall keeps a real setter, so dotted assignment to that one name invokes the + // setter rather than storing an entry. Groovy 6 routes Map subscript assignment through + // Map.put, so attrs['gspTagSyntaxCall'] = v stores an attribute. TagOutput and GroovyPage + // call setGspTagSyntaxCall(boolean) directly. Use put() or subscript to store an attribute + // of that name. @Test void testAssigningGspTagSyntaxCallInvokesTheSetter() { def dotted = toGroovyPageAttributes([:]) @@ -130,9 +132,6 @@ class GroovyPageAttributesTests { assertFalse dotted.containsKey('gspTagSyntaxCall') } - // Groovy 6 dispatches subscript assignment on a Map to put(), not to a bean setter, so the - // subscript form stores an attribute of that name and leaves the flag alone. Groovy 5 sent - // it to the setter as well. @Test void testSubscriptAssignmentOfGspTagSyntaxCallStoresAnAttribute() { def subscript = toGroovyPageAttributes([:]) diff --git a/grails-spring-security/oauth2/plugin/grails-app/services/grails/plugin/springsecurity/oauth2/SpringSecurityOauth2BaseService.groovy b/grails-spring-security/oauth2/plugin/grails-app/services/grails/plugin/springsecurity/oauth2/SpringSecurityOauth2BaseService.groovy index d72aa857e97..7e6d64e0ca0 100644 --- a/grails-spring-security/oauth2/plugin/grails-app/services/grails/plugin/springsecurity/oauth2/SpringSecurityOauth2BaseService.groovy +++ b/grails-spring-security/oauth2/plugin/grails-app/services/grails/plugin/springsecurity/oauth2/SpringSecurityOauth2BaseService.groovy @@ -301,7 +301,7 @@ class SpringSecurityOauth2BaseService { * @return The role names for a newly registered user */ def getRoleNames() { - def roleNames = grailsApplication.config.getProperty('grails.plugin.springsecurity.oauth2.registration.roleNames', List, ['ROLE_USER']) + def roleNames = grailsApplication.config.getProperty('grails.plugin.springsecurity.oauth2.registration.roleNames', List, ['ROLE_USER']) return roleNames } } diff --git a/grails-test-examples/geb-gebconfig/src/integration-test/groovy/org/demo/spock/GebConfigSpec.groovy b/grails-test-examples/geb-gebconfig/src/integration-test/groovy/org/demo/spock/GebConfigSpec.groovy index 830a716deac..40276cb57cf 100644 --- a/grails-test-examples/geb-gebconfig/src/integration-test/groovy/org/demo/spock/GebConfigSpec.groovy +++ b/grails-test-examples/geb-gebconfig/src/integration-test/groovy/org/demo/spock/GebConfigSpec.groovy @@ -19,6 +19,9 @@ package org.demo.spock +import java.util.concurrent.CompletableFuture +import java.util.concurrent.TimeUnit + import org.openqa.selenium.remote.RemoteWebDriver import org.demo.spock.pages.HomePage @@ -50,5 +53,14 @@ class GebConfigSpec extends ContainerGebSpec { then: 'the session should be active' driver.sessionId != null + + when: 'a fresh thread reads a system property after the custom driver factory has run' + def propertyRead = new CompletableFuture() + Thread.startDaemon { + propertyRead.complete(System.getProperty('java.version')) + } + + then: 'property lookup does not recursively initialize Groovy call sites' + propertyRead.get(10, TimeUnit.SECONDS) == System.getProperty('java.version') } } diff --git a/grails-testing-support-core/src/main/groovy/org/grails/testing/ParameterizedGrailsUnitTest.groovy b/grails-testing-support-core/src/main/groovy/org/grails/testing/ParameterizedGrailsUnitTest.groovy index 60e1e7e8ee4..866018fbfaf 100755 --- a/grails-testing-support-core/src/main/groovy/org/grails/testing/ParameterizedGrailsUnitTest.groovy +++ b/grails-testing-support-core/src/main/groovy/org/grails/testing/ParameterizedGrailsUnitTest.groovy @@ -49,7 +49,7 @@ trait ParameterizedGrailsUnitTest extends GrailsUnitTest { mockArtefact(cutType) final String beanName = getBeanName(cutType) if (beanName != null && applicationContext.containsBean(beanName)) { - _artefactInstance = applicationContext.getBean(beanName, T) + _artefactInstance = applicationContext.getBean(beanName, cutType) } else { _artefactInstance = cutType.newInstance() applicationContext.autowireCapableBeanFactory.autowireBeanProperties(_artefactInstance, AutowireCapableBeanFactory.AUTOWIRE_BY_NAME, false) diff --git a/grails-testing-support-core/src/test/groovy/grails/testing/services/ServiceUnitTestSpec.groovy b/grails-testing-support-core/src/test/groovy/grails/testing/services/ServiceUnitTestSpec.groovy new file mode 100644 index 00000000000..0244fbb51bd --- /dev/null +++ b/grails-testing-support-core/src/test/groovy/grails/testing/services/ServiceUnitTestSpec.groovy @@ -0,0 +1,68 @@ +/* + * 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 + * + * https://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 grails.testing.services + +import spock.lang.Specification + +import grails.compiler.GrailsCompileStatic + +class ServiceUnitTestSpec extends Specification implements ServiceUnitTest { + + void "service returns the registered bean of the concrete type under test"() { + when: + TypedLookupService instance = service + + then: + instance.is(applicationContext.getBean('typedLookupService', TypedLookupService)) + service.is(instance) + } + + void "service is autowired and retains its state across repeated lookups"() { + given: + defineBeans { + lookupCollaborator(LookupCollaborator) + } + + when: + service.recordCall() + service.recordCall() + + then: + service.lookupCollaborator.is(applicationContext.getBean('lookupCollaborator')) + service.calls == 2 + service.lookupCollaborator.calls == 2 + } +} + +@GrailsCompileStatic +class TypedLookupService { + + LookupCollaborator lookupCollaborator + int calls + + void recordCall() { + calls++ + lookupCollaborator.calls++ + } +} + +class LookupCollaborator { + + int calls +} diff --git a/grails-testing-support-mongodb/src/main/groovy/org/apache/grails/testing/mongo/StartMongoGrailsUnitExtension.groovy b/grails-testing-support-mongodb/src/main/groovy/org/apache/grails/testing/mongo/StartMongoGrailsUnitExtension.groovy index 0007914d761..b1db6d01876 100644 --- a/grails-testing-support-mongodb/src/main/groovy/org/apache/grails/testing/mongo/StartMongoGrailsUnitExtension.groovy +++ b/grails-testing-support-mongodb/src/main/groovy/org/apache/grails/testing/mongo/StartMongoGrailsUnitExtension.groovy @@ -100,7 +100,8 @@ class StartMongoGrailsUnitExtension extends AbstractMongoGrailsExtension impleme Package[] packagesArray = packages.toArray(new Package[packages.size()]) Map configuration = ['grails.mongodb.url': createConnectionString(container.getHost(), container.getMappedPort(DEFAULT_MONGO_PORT))] - def datastore = mongoDatastoreClass.getDeclaredConstructor(Map, Package[]).newInstance(configuration, packagesArray) + // Groovy 6.0.0-RC-1 (GROOVY-12319): parameterized types are not class literals. + def datastore = mongoDatastoreClass.getDeclaredConstructor(Map, Package[]).newInstance(configuration, packagesArray) mongoDatastoreField.writeValue(invocation.sharedInstance, datastore) } } diff --git a/grails-web-databinding/src/main/groovy/grails/web/databinding/GrailsWebDataBinder.groovy b/grails-web-databinding/src/main/groovy/grails/web/databinding/GrailsWebDataBinder.groovy index 8bac58c2d9c..a36cc37072c 100644 --- a/grails-web-databinding/src/main/groovy/grails/web/databinding/GrailsWebDataBinder.groovy +++ b/grails-web-databinding/src/main/groovy/grails/web/databinding/GrailsWebDataBinder.groovy @@ -472,10 +472,7 @@ class GrailsWebDataBinder extends SimpleDataBinder { Class componentType = metaProperty.type.componentType List boundItems = [] ((Collection) val).each { item -> - // Groovy 6.0.0-beta-2: static type checking merges the flow state of a `||` - // inside a closure to void, so the guard is hoisted into a boolean local. - boolean matchesComponentType = item == null || componentType.isAssignableFrom(item.getClass()) - if (matchesComponentType) { + if (item == null || componentType.isAssignableFrom(item.getClass())) { boundItems << item } else if (item instanceof Map || item instanceof DataBindingSource) { DataBindingSource itemBindingSource = item instanceof DataBindingSource ? @@ -548,9 +545,7 @@ class GrailsWebDataBinder extends SimpleDataBinder { } } if (persistentInstance == null) { - // Groovy 6.0.0-beta-2: see the `||` flow-state note on the array branch above. - boolean matchesElementType = item == null || referencedType.isAssignableFrom(item.getClass()) - if (matchesElementType) { + if (item == null || referencedType.isAssignableFrom(item.getClass())) { // Already of the element type, so there is nothing to instantiate // and nothing to bind into. A raw collection always lands here: // Basic#componentType falls back to Object.class when a property @@ -591,9 +586,7 @@ class GrailsWebDataBinder extends SimpleDataBinder { try { Map boundMap = new LinkedHashMap() ((Map) val).each { key, item -> - // Groovy 6.0.0-beta-2: see the `||` flow-state note on the array branch above. - boolean matchesReferencedType = item == null || referencedType.isAssignableFrom(item.getClass()) - if (matchesReferencedType) { + if (item == null || referencedType.isAssignableFrom(item.getClass())) { boundMap[key] = item } else if (item instanceof Map || item instanceof DataBindingSource) { def instance diff --git a/grails-web-databinding/src/test/groovy/grails/web/databinding/CollectionElementBindingSpec.groovy b/grails-web-databinding/src/test/groovy/grails/web/databinding/CollectionElementBindingSpec.groovy new file mode 100644 index 00000000000..7819018bc98 --- /dev/null +++ b/grails-web-databinding/src/test/groovy/grails/web/databinding/CollectionElementBindingSpec.groovy @@ -0,0 +1,84 @@ +/* + * 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 + * + * https://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 grails.web.databinding + +import spock.lang.Specification + +import grails.databinding.SimpleMapDataBindingSource + +class CollectionElementBindingSpec extends Specification { + + void 'list binding preserves existing elements and binds map elements'() { + given: + def existing = new BindingElement(name: 'existing') + def target = new ElementContainer() + + when: + new GrailsWebDataBinder(null).bind(target, new SimpleMapDataBindingSource( + [elements: [existing, [name: 'created']]]), ['elements']) + + then: + target.elements.size() == 2 + target.elements[0].is(existing) + target.elements[1].name == 'created' + } + + void 'array binding preserves null and existing elements while binding maps'() { + given: + def existing = new BindingElement(name: 'existing') + def target = new ElementContainer() + + when: + new GrailsWebDataBinder(null).bind(target, new SimpleMapDataBindingSource( + [array: [existing, null, [name: 'created']]]), ['array']) + + then: + target.array.length == 3 + target.array[0].is(existing) + target.array[1] == null + target.array[2].name == 'created' + } + + void 'map binding preserves null and existing values while binding nested maps'() { + given: + def existing = new BindingElement(name: 'existing') + def target = new ElementContainer() + + when: + new GrailsWebDataBinder(null).bind(target, new SimpleMapDataBindingSource( + [mapped: [first: existing, second: null, third: [name: 'created']]]), ['mapped']) + + then: + target.mapped.size() == 3 + target.mapped.first.is(existing) + target.mapped.containsKey('second') + target.mapped.second == null + target.mapped.third.name == 'created' + } +} + +class ElementContainer { + List elements + BindingElement[] array + Map mapped +} + +class BindingElement { + String name +}