You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@grails.apache.org by sb...@apache.org on 2026/09/04 07:50:26 UTC

(grails-core) 01/01: Merge remote-tracking branch 'origin/7.2.x' into merge/7.2.x-into-8.0.x

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

sbglasius pushed a commit to branch 8.0.x
in repository https://gitbox.apache.org/repos/asf/grails-core.git

commit def2c9b9bc4f648726fb69101d7d20b1376f6076
Merge: 55d5076aa5 5075ff30b5
Author: SΓΈren Berg Glasius <so...@glasius.dk>
AuthorDate: Fri Sep 4 09:50:10 2026 +0200

    Merge remote-tracking branch 'origin/7.2.x' into merge/7.2.x-into-8.0.x
    
    # Merge conflicts resolved.
    
    # By James Fredley (2) and others
    # Via GitHub (5) and SΓΈren Berg Glasius (2)
    * origin/7.2.x:
      Add test coverage and fix real bugs in grails-datamapping-core's services package (#16156)
      ci: pin every action to an ASF-approved SHA
      Give TemplateLookupCachingSpec its own command fixture
      Fix fields template lookup leak from accessor-keyed memoization
    
    # Conflicts:
    #       .github/workflows/codestyle.yml
    #       .github/workflows/gradle.yml
    #       .github/workflows/groovy-joint-workflow.yml
    #       .github/workflows/vulnerability-scan.yml
    #       grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/services/implementers/AbstractServiceImplementer.groovy
    #       grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/services/DefaultTenantServiceSpec.groovy
    #       grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/services/DefaultTransactionServiceSpec.groovy

 .github/workflows/codestyle.yml                    |   6 +-
 .github/workflows/forge-deploy-aws.yml             |   4 +-
 .github/workflows/gradle.yml                       |  54 +--
 .github/workflows/rat.yml                          |   2 +-
 .github/workflows/release-publish-docs.yml         |   2 +-
 .github/workflows/release-verify.yml               |   2 +-
 .github/workflows/release.yml                      |   6 +-
 .github/workflows/vulnerability-scan.yml           |  14 +-
 ...stractDetachedCriteriaServiceImplementor.groovy |   6 +
 .../implementers/AbstractSaveImplementer.groovy    |   6 +-
 .../implementers/AbstractServiceImplementer.groovy |   7 +-
 .../implementers/AbstractWhereImplementer.groovy   |   3 +
 .../services/implementers/DeleteImplementer.groovy |   6 +-
 .../implementers/DeleteWhereImplementer.groovy     |   2 +-
 .../implementers/FindAllByImplementer.groovy       |   3 +-
 .../implementers/FindOneImplementer.groovy         |   5 +
 .../FindOneStringQueryImplementer.groovy           |   4 +
 .../implementers/InterfaceProjectionBuilder.groovy |  39 +-
 .../IterableInterfaceProjectionBuilder.groovy      |  16 +-
 .../services/implementers/SaveImplementer.groovy   |   2 +-
 .../implementers/UpdateOneImplementer.groovy       |   3 +-
 .../services/ServiceImplementerEdgeCaseSpec.groovy | 413 +++++++++++++++++++++
 .../gorm/services/DefaultTenantServiceSpec.groovy  |   2 +-
 .../services/DefaultTransactionServiceSpec.groovy  |   5 +
 .../formfields/FormFieldsTemplateService.groovy    |  32 +-
 .../formfields/TemplateLookupCachingSpec.groovy    |  38 ++
 26 files changed, 578 insertions(+), 104 deletions(-)

diff --cc .github/workflows/codestyle.yml
index 28fa643859,1cf5a0dad4..a82a9d12a9
--- a/.github/workflows/codestyle.yml
+++ b/.github/workflows/codestyle.yml
@@@ -38,9 -37,9 +38,9 @@@ jobs
          uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
          with:
            distribution: liberica
 -          java-version: 17
 +          java-version: 21
        - name: "🐘 Setup Gradle"
-         uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
+         uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
          with:
            cache-provider: basic # 'basic' uses the MIT-licensed, open-source cache provider; the default 'enhanced' provider (v6+) is proprietary (Gradle commercial Terms of Use)
            develocity-access-key: ${{ secrets.DEVELOCITY_ACCESS_KEY }}
@@@ -72,9 -75,9 +72,9 @@@
          uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
          with:
            distribution: liberica
 -          java-version: 17
 +          java-version: 21
        - name: "🐘 Setup Gradle"
-         uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
+         uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
          with:
            cache-provider: basic # 'basic' uses the MIT-licensed, open-source cache provider; the default 'enhanced' provider (v6+) is proprietary (Gradle commercial Terms of Use)
            develocity-access-key: ${{ secrets.DEVELOCITY_ACCESS_KEY }}
@@@ -111,9 -114,9 +111,9 @@@
          uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
          with:
            distribution: liberica
 -          java-version: 17
 +          java-version: 21
        - name: "🐘 Setup Gradle"
-         uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
+         uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
          with:
            cache-provider: basic # 'basic' uses the MIT-licensed, open-source cache provider; the default 'enhanced' provider (v6+) is proprietary (Gradle commercial Terms of Use)
            develocity-access-key: ${{ secrets.DEVELOCITY_ACCESS_KEY }}
diff --cc .github/workflows/gradle.yml
index aaab2f3f8e,be7aee29ee..37adf34aea
--- a/.github/workflows/gradle.yml
+++ b/.github/workflows/gradle.yml
@@@ -35,9 -35,9 +35,9 @@@ jobs
          uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
          with:
            distribution: liberica
 -          java-version: 17
 +          java-version: 21
        - name: "πŸ—„οΈ Restore dependency jar cache"
-         uses: actions/cache@v4
+         uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
          with:
            # Cache only downloaded dependency jars and wrapper distributions, never Grails build outputs.
            # Keyed by branch version so each release branch maintains its own warm cache.
@@@ -48,10 -48,10 +48,10 @@@
            restore-keys: |
              gradle-deps-${{ runner.os }}-${{ github.base_ref || github.ref_name }}-
        - name: "🐘 Setup Gradle"
-         uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
+         uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
          with:
            cache-provider: basic # 'basic' uses the MIT-licensed, open-source cache provider; the default 'enhanced' provider (v6+) is proprietary (Gradle commercial Terms of Use)
 -          develocity-access-key: ${{ secrets.GRAILS_DEVELOCITY_ACCESS_KEY  }}
 +          develocity-access-key: ${{ secrets.DEVELOCITY_ACCESS_KEY  }}
        - name: "πŸ” Validate grails-core dependency versions"
          run: >
            ./gradlew validateDependencyVersions
@@@ -173,8 -145,7 +173,8 @@@
            distribution: liberica
            java-version: ${{ matrix.java }}
        - name: "πŸ—„οΈ Restore dependency jar cache"
 -        uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
 +        id: dependency-cache
-         uses: actions/cache/restore@v4
++        uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
          with:
            # Cache only downloaded dependency jars and wrapper distributions, never Grails build outputs.
            # Keyed by branch version so each release branch maintains its own warm cache.
@@@ -255,8 -190,7 +255,8 @@@
            distribution: liberica
            java-version: ${{ matrix.java }}
        - name: "πŸ—„οΈ Restore dependency jar cache"
 -        uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
 +        id: dependency-cache
-         uses: actions/cache/restore@v4
++        uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
          with:
            # Cache only downloaded dependency jars and wrapper distributions, never Grails build outputs.
            # Keyed by branch version so each release branch maintains its own warm cache.
@@@ -433,8 -313,7 +433,8 @@@
            distribution: liberica
            java-version: ${{ matrix.java }}
        - name: "πŸ—„οΈ Restore dependency jar cache"
 -        uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
 +        id: dependency-cache
-         uses: actions/cache/restore@v4
++        uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
          with:
            # Cache only downloaded dependency jars and wrapper distributions, never Grails build outputs.
            # Keyed by branch version so each release branch maintains its own warm cache.
@@@ -551,9 -405,9 +551,9 @@@
          uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
          with:
            distribution: liberica
 -          java-version: 17
 +          java-version: 21
        - name: "🐘 Setup Gradle"
-         uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
+         uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
          with:
            cache-provider: basic # 'basic' uses the MIT-licensed, open-source cache provider; the default 'enhanced' provider (v6+) is proprietary (Gradle commercial Terms of Use)
            develocity-access-key: ${{ secrets.DEVELOCITY_ACCESS_KEY  }}
@@@ -679,19 -492,8 +679,19 @@@
            key: gradle-deps-${{ runner.os }}-${{ github.base_ref || github.ref_name }}-${{ hashFiles('**/dependencies.gradle', '**/gradle-wrapper.properties') }}
            restore-keys: |
              gradle-deps-${{ runner.os }}-${{ github.base_ref || github.ref_name }}-
 +      - name: "πŸ—„οΈ Cache the mongod the embedded server runs"
 +        uses: actions/cache@v4
 +        with:
 +          # Kept apart from the dependency jars: this holds an immutable download from
 +          # fastdl.mongodb.org - the archive and the binary extracted out of it - and no database,
 +          # so it belongs to no branch and does not change when a dependency version does.
 +          # Keyed by the version this entry asks for, because the matrix runs more than one and a
 +          # single key would keep whichever job missed first and leave the other restoring 250MB
 +          # of a binary it cannot use. No restore-keys, for that same reason.
 +          path: ~/.embedmongo
 +          key: embedmongo-${{ runner.os }}-${{ matrix.mongodb-version }}
        - name: "🐘 Setup Gradle"
-         uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
+         uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
          with:
            cache-disabled: true # dependency jars are cached by the explicit branch-keyed step above
            develocity-access-key: ${{ secrets.DEVELOCITY_ACCESS_KEY }}
@@@ -881,9 -571,9 +881,9 @@@
          uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
          with:
            distribution: liberica
 -          java-version: 17
 +          java-version: 21
        - name: "πŸ—„οΈ Restore dependency jar cache"
-         uses: actions/cache@v4
+         uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
          with:
            # Cache only downloaded dependency jars and wrapper distributions, never Grails build outputs.
            # Keyed by branch version so each release branch maintains its own warm cache.
@@@ -948,9 -636,9 +948,9 @@@
          uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
          with:
            distribution: liberica
 -          java-version: 17
 +          java-version: 21
        - name: "πŸ—„οΈ Restore dependency jar cache"
-         uses: actions/cache@v4
+         uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
          with:
            # Cache only downloaded dependency jars and wrapper distributions, never Grails build outputs.
            # Keyed by branch version so each release branch maintains its own warm cache.
@@@ -1066,9 -702,9 +1066,9 @@@
          uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
          with:
            distribution: liberica
 -          java-version: 17
 +          java-version: 21
        - name: "πŸ—„οΈ Restore dependency jar cache"
-         uses: actions/cache@v4
+         uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
          with:
            # Cache only downloaded dependency jars and wrapper distributions, never Grails build outputs.
            # Keyed by branch version so each release branch maintains its own warm cache.
@@@ -1177,9 -813,9 +1177,9 @@@
          uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
          with:
            distribution: liberica
 -          java-version: 17
 +          java-version: 21
        - name: "πŸ—„οΈ Restore dependency jar cache"
-         uses: actions/cache@v4
+         uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
          with:
            # Cache only downloaded dependency jars and wrapper distributions, never Grails build outputs.
            # Keyed by branch version so each release branch maintains its own warm cache.
diff --cc .github/workflows/rat.yml
index c4ab66a771,d653be29a9..1aeb13732d
--- a/.github/workflows/rat.yml
+++ b/.github/workflows/rat.yml
@@@ -38,9 -40,9 +38,9 @@@ jobs
          uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
          with:
            distribution: liberica
 -          java-version: 17
 +          java-version: 21
        - name: "🐘 Setup Gradle"
-         uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
+         uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
          with:
            cache-provider: basic # 'basic' uses the MIT-licensed, open-source cache provider; the default 'enhanced' provider (v6+) is proprietary (Gradle commercial Terms of Use)
            develocity-access-key: ${{ secrets.DEVELOCITY_ACCESS_KEY  }}
diff --cc .github/workflows/release-verify.yml
index 34027e88b4,cf78f5da28..1ab12839d8
--- a/.github/workflows/release-verify.yml
+++ b/.github/workflows/release-verify.yml
@@@ -93,18 -71,8 +93,18 @@@ jobs
          with:
            distribution: ${{ steps.sdkmanrc.outputs.java-distribution }}
            java-version: ${{ steps.sdkmanrc.outputs.java-version }}
 +      - name: "β˜•οΈ Export JDK 25 home for the reproducible-build check"
 +        # verify-reproducible.sh switches JAVA_HOME to ${JDK_25_HOME} for the
 +        # Micronaut island pass; mirror the container's ENV JDK_25_HOME here
 +        run: |
 +          if [ -z "${JAVA_HOME_25_X64:-}" ]; then
 +            echo "❌ Expected JAVA_HOME_25_X64 to be exported by setup-java" >&2
 +            exit 1
 +          fi
 +          echo "JDK_25_HOME=${JAVA_HOME_25_X64}" >> "${GITHUB_ENV}"
 +          "${JAVA_HOME_25_X64}/bin/java" -version
        - name: "🐘 Setup Gradle"
-         uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
+         uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
          with:
            # verification must run against a pristine environment, never cached state
            cache-disabled: true
diff --cc .github/workflows/vulnerability-scan.yml
index 55d2225c12,dad6c6a8f9..4ae478ffe1
--- a/.github/workflows/vulnerability-scan.yml
+++ b/.github/workflows/vulnerability-scan.yml
@@@ -44,29 -44,14 +44,29 @@@ jobs
        contents: read
      steps:
        - name: "πŸ“₯ Checkout repository"
-         uses: actions/checkout@v6
+         uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
 +      - name: "β˜•οΈ Determine Java version from .sdkmanrc"
 +        # Read the JDK the build targets out of the file that already declares it, so a
 +        # baseline bump does not leave a stale literal behind here. Only the major is kept:
 +        # a scan is outside the reproducible-build surface, so CI takes the runner's current
 +        # release of that major rather than pinning the patch version.
 +        id: sdkmanrc
 +        run: |
 +          set -euo pipefail
 +          sdkman_java=$(grep -E '^java=' .sdkmanrc | cut -d= -f2 || true)
 +          if [ -z "${sdkman_java}" ]; then
 +            echo "❌ Could not determine the java version from .sdkmanrc" >&2
 +            exit 1
 +          fi
 +          echo "Scanning with JDK ${sdkman_java%%.*} (from .sdkmanrc: ${sdkman_java})"
 +          echo "java-version=${sdkman_java%%.*}" >> "$GITHUB_OUTPUT"
        - name: "β˜•οΈ Setup JDK"
-         uses: actions/setup-java@v4
+         uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
          with:
            distribution: liberica
 -          java-version: 17
 +          java-version: ${{ steps.sdkmanrc.outputs.java-version }}
        - name: "🐘 Setup Gradle"
-         uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
+         uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
          with:
            develocity-access-key: ${{ secrets.DEVELOCITY_ACCESS_KEY }}
        - name: "πŸ” Run OSS Index Vulnerability Scan"
@@@ -98,29 -83,14 +98,29 @@@
        pull-requests: write
      steps:
        - name: "πŸ“₯ Checkout pull request"
-         uses: actions/checkout@v6
+         uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
 +      - name: "β˜•οΈ Determine Java version from .sdkmanrc"
 +        # Read the JDK the build targets out of the file that already declares it, so a
 +        # baseline bump does not leave a stale literal behind here. Only the major is kept:
 +        # a scan is outside the reproducible-build surface, so CI takes the runner's current
 +        # release of that major rather than pinning the patch version.
 +        id: sdkmanrc
 +        run: |
 +          set -euo pipefail
 +          sdkman_java=$(grep -E '^java=' .sdkmanrc | cut -d= -f2 || true)
 +          if [ -z "${sdkman_java}" ]; then
 +            echo "❌ Could not determine the java version from .sdkmanrc" >&2
 +            exit 1
 +          fi
 +          echo "Scanning with JDK ${sdkman_java%%.*} (from .sdkmanrc: ${sdkman_java})"
 +          echo "java-version=${sdkman_java%%.*}" >> "$GITHUB_OUTPUT"
        - name: "β˜•οΈ Setup JDK"
-         uses: actions/setup-java@v4
+         uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
          with:
            distribution: liberica
 -          java-version: 17
 +          java-version: ${{ steps.sdkmanrc.outputs.java-version }}
        - name: "🐘 Setup Gradle"
-         uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
+         uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
          with:
            develocity-access-key: ${{ secrets.DEVELOCITY_ACCESS_KEY }}
        - name: "πŸ” Run OSS Index Vulnerability Scan"
diff --cc grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/services/implementers/AbstractServiceImplementer.groovy
index d41cc8b068,c1b7576c5b..6e23e300a7
--- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/services/implementers/AbstractServiceImplementer.groovy
+++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/services/implementers/AbstractServiceImplementer.groovy
@@@ -45,13 -42,11 +45,12 @@@ import org.grails.datastore.mapping.tra
  
  import static org.codehaus.groovy.ast.ClassHelper.make
  import static org.codehaus.groovy.ast.tools.GeneralUtils.args
 +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.classX
- import static org.codehaus.groovy.ast.tools.GeneralUtils.param
  import static org.codehaus.groovy.ast.tools.GeneralUtils.propX
 -import static org.codehaus.groovy.ast.tools.GeneralUtils.varX
  import static org.grails.datastore.gorm.transform.AstMethodDispatchUtils.callD
 +import static org.grails.datastore.mapping.reflect.AstUtils.varThis
  
  /**
   * Abstract implementation of the {@link ServiceImplementer} interface
@@@ -149,7 -135,7 +148,7 @@@ abstract class AbstractServiceImplement
       * @return The datastore expression
       */
      protected Expression multiTenantDatastore() {
-         return castX(ClassHelper.make(MultiTenantCapableDatastore), datastore())
 -        return castX(make(MultiTenantCapableDatastore), propX(varX('this'), 'targetDatastore'))
++        return castX(make(MultiTenantCapableDatastore), datastore())
      }
  
      /**
diff --cc grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/services/DefaultTenantServiceSpec.groovy
index 865b2004e7,65f39221b2..decfaf3022
--- a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/services/DefaultTenantServiceSpec.groovy
+++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/services/DefaultTenantServiceSpec.groovy
@@@ -24,99 -23,100 +24,99 @@@ import org.grails.datastore.mapping.mod
  import org.grails.datastore.mapping.multitenancy.AllTenantsResolver
  import org.grails.datastore.mapping.multitenancy.MultiTenancySettings
  import org.grails.datastore.mapping.multitenancy.MultiTenantCapableDatastore
 -import org.grails.datastore.mapping.multitenancy.TenantResolver
  import spock.lang.Specification
  
 +/**
 + * {@code TenantServiceSpec} in the separate grails-datamapping-core-test module already exercises
 + * this class end-to-end through a real transformed service, but that module's test run doesn't
 + * count toward grails-datamapping-core's own JaCoCo report (a different Gradle module/test task
 + * entirely) - so from this module's own coverage perspective, this whole class had 0% coverage.
 + * Every method here delegates to the extensively-tested {@code Tenants}/{@code CurrentTenantHolder}
 + * static API (see {@code TenantsSpec}, item 8 of this plan), so these tests focus on
 + * DefaultTenantService's own logic - the mode guard, the datastore-capability check, and (new in
 + * this PR) the {@code RESOLVING} reentrancy guard - not on re-verifying Tenants' own behaviour.
 + */
  class DefaultTenantServiceSpec extends Specification {
  
 -    DefaultTenantService tenantService = new DefaultTenantService()
 +    DefaultTenantService service = new DefaultTenantService()
  
 -    void 'multiTenantDatastore throws when the datastore is not multi-tenant capable'() {
 +    void "getDatastore/setDatastore round-trip"() {
          given:
 -        tenantService.datastore = Mock(Datastore)
 +        def ds = Mock(Datastore)
  
          when:
 -        tenantService.eachTenant {}
 +        service.setDatastore(ds)
  
          then:
 -        DatastoreConfigurationException e = thrown(DatastoreConfigurationException)
 -        e.message.contains('not Multi-Tenant capable')
 +        service.getDatastore().is(ds)
      }
  
 -    void 'currentId throws when multi tenancy mode is NONE'() {
 +    void "multiTenantDatastore throws DatastoreConfigurationException for a non-multi-tenant-capable datastore"() {
          given:
 -        MultiTenantCapableDatastore datastore = Mock(MultiTenantCapableDatastore)
 -        datastore.multiTenancyMode >> MultiTenancySettings.MultiTenancyMode.NONE
 -        tenantService.datastore = datastore
 +        service.setDatastore(Mock(Datastore))
  
          when:
-         service.eachTenant { }
 -        tenantService.currentId()
++        service.eachTenant {}
  
          then:
 -        DatastoreConfigurationException e = thrown(DatastoreConfigurationException)
 -        e.message.contains('not configured for Multi-Tenancy')
 +        def e = thrown(DatastoreConfigurationException)
 +        e.message.contains('is not Multi-Tenant capable')
      }
  
 -    void 'currentId resolves the tenant id from the tenant resolver'() {
 -        given:
 -        TenantResolver tenantResolver = Mock(TenantResolver) {
 -            resolveTenantIdentifier() >> 'tenant1'
 +    private MultiTenantCapableDatastore discriminatorDatastore(List tenantIds = ['tenant1']) {
 +        Mock(MultiTenantCapableDatastore) {
 +            getMultiTenancyMode() >> MultiTenancySettings.MultiTenancyMode.DISCRIMINATOR
 +            getTenantResolver() >> Mock(AllTenantsResolver) {
 +                resolveTenantIds() >> tenantIds
 +            }
          }
 -        MultiTenantCapableDatastore datastore = Mock(MultiTenantCapableDatastore)
 -        datastore.multiTenancyMode >> MultiTenancySettings.MultiTenancyMode.DISCRIMINATOR
 -        datastore.tenantResolver >> tenantResolver
 -        tenantService.datastore = datastore
 -
 -        expect:
 -        tenantService.currentId() == 'tenant1'
      }
  
 -    void 'withoutId throws when multi tenancy mode is NONE'() {
 +    void "eachTenant delegates to Tenants.eachTenant for each resolved tenant id"() {
          given:
 -        MultiTenantCapableDatastore datastore = Mock(MultiTenantCapableDatastore)
 -        datastore.multiTenancyMode >> MultiTenancySettings.MultiTenancyMode.NONE
 -        tenantService.datastore = datastore
 +        service.setDatastore(discriminatorDatastore(['tenant1', 'tenant2']))
 +        def seen = []
  
          when:
 -        tenantService.withoutId { 'result' }
 +        service.eachTenant { seen << CurrentTenantHolder.get() }
  
          then:
 -        thrown(DatastoreConfigurationException)
 +        seen == ['tenant1', 'tenant2']
      }
  
 -    void 'withoutId with a shared connection mode executes the callable without a session'() {
 +    void "currentId throws DatastoreConfigurationException when multi tenancy mode is NONE"() {
          given:
 -        MultiTenantCapableDatastore datastore = Mock(MultiTenantCapableDatastore)
 -        datastore.multiTenancyMode >> MultiTenancySettings.MultiTenancyMode.DISCRIMINATOR
 -        tenantService.datastore = datastore
 +        service.setDatastore(Mock(MultiTenantCapableDatastore) {
 +            getMultiTenancyMode() >> MultiTenancySettings.MultiTenancyMode.NONE
 +        })
  
          when:
 -        String result = tenantService.withoutId { -> 'result' }
 +        service.currentId()
  
          then:
 -        result == 'result'
 -        0 * datastore.withSession(_)
 -        0 * datastore.withNewSession(_, _)
 +        def e = thrown(DatastoreConfigurationException)
 +        e.message.contains('is not configured for Multi-Tenancy')
      }
  
 -    void 'withoutId with a non-shared connection mode executes within a new session'() {
 +    void "currentId delegates to Tenants.currentId when multi tenancy is configured"() {
          given:
 -        MultiTenantCapableDatastore datastore = Mock(MultiTenantCapableDatastore)
 -        datastore.multiTenancyMode >> MultiTenancySettings.MultiTenancyMode.DATABASE
 -        datastore.withNewSession(_, _) >> { args -> args[1].call('session') }
 -        tenantService.datastore = datastore
 +        def ds = discriminatorDatastore()
 +        service.setDatastore(ds)
  
          expect:
 -        tenantService.withoutId { 'result' } == 'result'
 +        CurrentTenantHolder.withTenant(ds, 'tenant1') {
 +            service.currentId() == 'tenant1'
 +        }
      }
  
 -    void 'withId throws when multi tenancy mode is NONE'() {
 +    void "withoutId throws DatastoreConfigurationException when multi tenancy mode is NONE"() {
          given:
 -        MultiTenantCapableDatastore datastore = Mock(MultiTenantCapableDatastore)
 -        datastore.multiTenancyMode >> MultiTenancySettings.MultiTenancyMode.NONE
 -        tenantService.datastore = datastore
 +        service.setDatastore(Mock(MultiTenantCapableDatastore) {
 +            getMultiTenancyMode() >> MultiTenancySettings.MultiTenancyMode.NONE
 +        })
  
          when:
 -        tenantService.withId('tenant1') { 'result' }
 +        service.withoutId { -> 'result' }
  
          then:
          thrown(DatastoreConfigurationException)
diff --cc grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/services/DefaultTransactionServiceSpec.groovy
index 57188a0afa,99be3d643e..1a95b2e973
--- a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/services/DefaultTransactionServiceSpec.groovy
+++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/services/DefaultTransactionServiceSpec.groovy
@@@ -18,28 -18,169 +18,33 @@@
   */
  package org.grails.datastore.gorm.services
  
+ import org.springframework.transaction.PlatformTransactionManager
+ import org.springframework.transaction.TransactionDefinition
+ import org.springframework.transaction.TransactionStatus
+ import org.springframework.transaction.TransactionSystemException
+ 
  import org.grails.datastore.mapping.core.Datastore
 -import org.grails.datastore.mapping.transactions.CustomizableRollbackTransactionAttribute
 -import org.grails.datastore.mapping.transactions.TransactionCapableDatastore
  import spock.lang.Specification
  
 +/**
 + * The diff only adds the explicit {@code datastore}/{@code getDatastore()}/{@code setDatastore()}
 + * {@code Service} contract (previously implicit/inherited) - the rest of this class (the
 + * {@code withTransaction}/{@code withRollback}/{@code withNewTransaction} overloads) predates this
 + * PR and is already exercised via real {@code TransactionService} usage in the separate
 + * {@code grails-datamapping-core-test} module (which doesn't count toward this module's own
 + * coverage - see item 14's cross-module attribution note), so out of scope here.
 + */
  class DefaultTransactionServiceSpec extends Specification {
  
 -    DefaultTransactionService transactionService = new DefaultTransactionService()
 -
 -    PlatformTransactionManager transactionManager = Mock(PlatformTransactionManager) {
 -        getTransaction(_) >> Mock(TransactionStatus)
 -    }
 -
 -    TransactionCapableDatastore transactionCapableDatastore = Mock(TransactionCapableDatastore) {
 -        getTransactionManager() >> transactionManager
 -    }
 -
 -    void 'withTransaction(Closure) executes the callable within a transaction'() {
 -        given:
 -        transactionService.datastore = transactionCapableDatastore
 -
 -        when:
 -        String result = transactionService.withTransaction { status -> 'done' }
 -
 -        then:
 -        result == 'done'
 -        1 * transactionManager.commit(_)
 -    }
 -
 -    void 'withTransaction(Closure) throws when the datastore does not support transactions'() {
 -        given:
 -        transactionService.datastore = Mock(Datastore)
 -
 -        when:
 -        transactionService.withTransaction { status -> 'done' }
 -
 -        then:
 -        TransactionSystemException e = thrown(TransactionSystemException)
 -        e.message.contains('does not support transactions')
 -    }
 -
 -    void 'withRollback(Closure) executes the callable and rolls back'() {
 -        given:
 -        transactionService.datastore = transactionCapableDatastore
 -
 -        expect:
 -        transactionService.withRollback { status -> 'done' } == 'done'
 -    }
 -
 -    void 'withRollback(Closure) throws when the datastore does not support transactions'() {
 -        given:
 -        transactionService.datastore = Mock(Datastore)
 -
 -        when:
 -        transactionService.withRollback { status -> 'done' }
 -
 -        then:
 -        thrown(TransactionSystemException)
 -    }
 -
 -    void 'withNewTransaction(Closure) executes the callable with PROPAGATION_REQUIRES_NEW'() {
 -        given:
 -        transactionService.datastore = transactionCapableDatastore
 -
 -        expect:
 -        transactionService.withNewTransaction { status -> 'done' } == 'done'
 -    }
 -
 -    void 'withNewTransaction(Closure) throws when the datastore does not support transactions'() {
 -        given:
 -        transactionService.datastore = Mock(Datastore)
 -
 -        when:
 -        transactionService.withNewTransaction { status -> 'done' }
 -
 -        then:
 -        thrown(TransactionSystemException)
 -    }
 -
 -    void 'withTransaction(TransactionDefinition, Closure) executes the callable with the given definition'() {
 -        given:
 -        transactionService.datastore = transactionCapableDatastore
 -        TransactionDefinition definition = new CustomizableRollbackTransactionAttribute()
 -
 -        expect:
 -        transactionService.withTransaction(definition) { status -> 'done' } == 'done'
 -    }
 -
 -    void 'withTransaction(TransactionDefinition, Closure) throws when the datastore does not support transactions'() {
 -        given:
 -        transactionService.datastore = Mock(Datastore)
 -        TransactionDefinition definition = new CustomizableRollbackTransactionAttribute()
 -
 -        when:
 -        transactionService.withTransaction(definition) { status -> 'done' }
 -
 -        then:
 -        thrown(TransactionSystemException)
 -    }
 -
 -    void 'withTransaction(Map, Closure) builds a transaction definition from the map'() {
 -        given:
 -        transactionService.datastore = transactionCapableDatastore
 -
 -        expect:
 -        transactionService.withTransaction([readOnly: true]) { status -> 'done' } == 'done'
 -    }
 -
 -    void 'withTransaction(Map, Closure) throws when the datastore does not support transactions'() {
 -        given:
 -        transactionService.datastore = Mock(Datastore)
 -
 -        when:
 -        transactionService.withTransaction([readOnly: true]) { status -> 'done' }
 -
 -        then:
 -        thrown(TransactionSystemException)
 -    }
 -
 -    void 'withRollback(TransactionDefinition, Closure) executes the callable and rolls back'() {
 -        given:
 -        transactionService.datastore = transactionCapableDatastore
 -        TransactionDefinition definition = new CustomizableRollbackTransactionAttribute()
 -
 -        expect:
 -        transactionService.withRollback(definition) { status -> 'done' } == 'done'
 -    }
 -
 -    void 'withRollback(TransactionDefinition, Closure) throws when the datastore does not support transactions'() {
 -        given:
 -        transactionService.datastore = Mock(Datastore)
 -        TransactionDefinition definition = new CustomizableRollbackTransactionAttribute()
 -
 -        when:
 -        transactionService.withRollback(definition) { status -> 'done' }
 -
 -        then:
 -        thrown(TransactionSystemException)
 -    }
 -
 -    void 'withNewTransaction(TransactionDefinition, Closure) forces PROPAGATION_REQUIRES_NEW'() {
 -        given:
 -        transactionService.datastore = transactionCapableDatastore
 -        TransactionDefinition definition = new CustomizableRollbackTransactionAttribute()
 -
 -        expect:
 -        transactionService.withNewTransaction(definition) { status -> 'done' } == 'done'
 -    }
 -
 -    void 'withNewTransaction(TransactionDefinition, Closure) throws when the datastore does not support transactions'() {
 +    void "getDatastore/setDatastore round-trip"() {
          given:
 -        transactionService.datastore = Mock(Datastore)
 -        TransactionDefinition definition = new CustomizableRollbackTransactionAttribute()
 +        def service = new DefaultTransactionService()
 +        def datastore = Mock(Datastore)
  
          when:
 -        transactionService.withNewTransaction(definition) { status -> 'done' }
 +        service.setDatastore(datastore)
  
          then:
 -        thrown(TransactionSystemException)
 +        service.getDatastore().is(datastore)
      }
  }