Skip to content

fix: GormEnhancer.allQualifiers() overrides explicit datasource declarations for MultiTenant entities#15393

Merged
jamesfredley merged 5 commits intoapache:7.0.xfrom
jamesfredley:fix/multitenant-datasource-qualifier-routing
Feb 19, 2026
Merged

fix: GormEnhancer.allQualifiers() overrides explicit datasource declarations for MultiTenant entities#15393
jamesfredley merged 5 commits intoapache:7.0.xfrom
jamesfredley:fix/multitenant-datasource-qualifier-routing

Conversation

@jamesfredley
Copy link
Contributor

@jamesfredley jamesfredley commented Feb 17, 2026

Summary

GormEnhancer.allQualifiers() unconditionally expands qualifiers to all connection sources for any MultiTenant entity, even when the entity declares an explicit non-default datasource (e.g., datasource 'secondary'). This causes silent data routing to the wrong database under DISCRIMINATOR multi-tenancy mode.

The fix preserves explicit datasource qualifiers for MultiTenant entities instead of replacing them with DEFAULT + all connections.

Root Cause

In GormEnhancer.allQualifiers(), the MultiTenant check unconditionally triggers qualifier expansion:

if ((MultiTenant.isAssignableFrom(entity.javaClass) || qualifiers.contains(ConnectionSource.ALL))
    && (datastore instanceof ConnectionSourcesProvider)) {
    qualifiers.clear()                        // CLEARS the explicit 'secondary' mapping
    qualifiers.add(ConnectionSource.DEFAULT)   // Replaces with DEFAULT
    // adds all connection sources from ConnectionSourcesProvider
}

This treats MultiTenant the same as ConnectionSource.ALL. It is intended for DATABASE multi-tenancy mode (each tenant = separate connection), but incorrectly fires for DISCRIMINATOR mode where all tenants share one database and the entity has an explicit non-default datasource declaration.

When findTenantId() returns ConnectionSource.DEFAULT in DISCRIMINATOR mode, findStaticApi() resolves to the DEFAULT qualifier - routing all data to the wrong database with no error or warning.

Fix

A single conditional change in GormEnhancer.allQualifiers() (grails-datamapping-core):

boolean isMultiTenant = MultiTenant.isAssignableFrom(entity.javaClass)
boolean hasExplicitAll = qualifiers.contains(ConnectionSource.ALL)
boolean hasExplicitNonDefaultDatasource = isMultiTenant &&
        !hasExplicitAll &&
        qualifiers.size() > 0 &&
        !qualifiers.equals(ConnectionSourcesSupport.DEFAULT_CONNECTION_SOURCE_NAMES)

if ((isMultiTenant || hasExplicitAll) && !hasExplicitNonDefaultDatasource
    && (datastore instanceof ConnectionSourcesProvider)) {

Qualifier expansion now only fires for:

  • MultiTenant entities on the default datasource (DATABASE multi-tenancy)
  • Entities declared with ConnectionSource.ALL

When a MultiTenant entity has an explicit non-default datasource (e.g., datasource 'secondary'), the declared qualifier is preserved.

Steps To Reproduce

  1. Create a Grails 7 app with two H2 datasources (default + secondary)
  2. Configure DISCRIMINATOR multi-tenancy
  3. Create a domain class that implements MultiTenant with datasource 'secondary'
  4. Call .save() on an instance of that domain class
  5. Observe that data is written to the default database, not the secondary one

Standalone reproducer: https://github.com/jamesfredley/grails-multitenant-datasource-bug

Test Coverage

21 tests across 3 levels:

Unit tests - GormEnhancerAllQualifiersSpec (7 tests)

Focused tests for allQualifiers() logic using mock datastores and entities:

  • MultiTenant with explicit non-default datasource - preserves qualifier
  • MultiTenant with default datasource - expands to all qualifiers
  • MultiTenant with ConnectionSource.ALL - expands to all qualifiers
  • Non-MultiTenant with explicit datasource - preserves qualifier
  • Non-MultiTenant with default datasource - keeps default only
  • Non-MultiTenant with ConnectionSource.ALL - expands to all qualifiers
  • MultiTenant with multiple explicit datasources - preserves all qualifiers

Hibernate integration tests - DataServiceMultiTenantMultiDataSourceSpec (7 tests)

Full Hibernate datastore tests with two in-memory H2 databases (default + analytics):

  • Schema creation on correct (analytics) datasource
  • save() routes to analytics with tenant isolation
  • get() retrieves from analytics datasource
  • count() scoped to current tenant on analytics
  • delete() removes from analytics datasource
  • findByName with tenant isolation on analytics
  • GormEnhancer qualifier resolution for unqualified and explicit paths
  • GormEnhancer aggregate HQL routing to analytics

Functional test app - MultiTenantMultiDataSourceSpec (7 tests)

Full Grails application (grails-multitenant-multi-datasource) with @Integration tests:

  • Schema creation on secondary datasource (not default)
  • save() routes to secondary datasource
  • get() by ID routes to secondary datasource
  • count() scoped to current tenant
  • delete() removes from secondary datasource
  • findByName with tenant isolation on secondary
  • findAllByName routes to secondary datasource

Changed Files

File Change
grails-datamapping-core/.../GormEnhancer.groovy Fix: preserve explicit non-default datasource qualifiers for MultiTenant entities
grails-datamapping-core/.../GormEnhancerAllQualifiersSpec.groovy New: 7 unit tests for allQualifiers()
grails-data-hibernate5/.../DataServiceMultiTenantMultiDataSourceSpec.groovy New: 7 Hibernate integration tests
grails-test-examples/.../grails-multitenant-multi-datasource/ New: functional test app (domain, service, config, 7 integration tests)
settings.gradle Register functional test app

Environment Information

  • Grails: 7.0.7
  • Spring Boot: 3.5.10
  • Groovy: 4.0.30
  • JDK: 17+

… in allQualifiers()

GormEnhancer.allQualifiers() unconditionally expanded qualifiers to all
connection sources for MultiTenant entities, even when the entity declared
an explicit non-default datasource (e.g., datasource 'secondary'). This
caused silent data routing to the wrong database under DISCRIMINATOR
multi-tenancy mode.

The fix checks whether the entity has an explicit non-default, non-ALL
datasource declaration before expanding qualifiers. When present, the
explicit mapping is preserved.

Added 7 Spock tests covering all combinations:
- MultiTenant with explicit/default/ALL datasource
- Non-MultiTenant with explicit/default/ALL datasource
- MultiTenant with multiple explicit datasources
Copy link
Contributor

@jdaugherty jdaugherty left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I worry mocking APIs in GORM unit tests will be unmaintainable in the longer run - since you are mocking just that its called in a certain way. You already have a functional test for this scenario, why not add it too?

Copy link
Contributor

@jdaugherty jdaugherty left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought I had submitted a review for this, but I may have gotten it mixed up with so many PRs being opened.

The test is highly relying on mocking, which is not a reliable approach long term. We need to at least add a functional test for this scenario (it sounds like you already have one too).

If we add the functional test, I'm good to merge this with teh mocking, otherwise we need to rework the unit test to not be so fragile.

Copilot AI review requested due to automatic review settings February 17, 2026 19:03
… + secondary datasource

Add 7 Spock tests covering GORM Data Service CRUD methods when both
DISCRIMINATOR multi-tenancy and a non-default datasource are configured
on the same domain entity. This is the exact combination that triggers
the allQualifiers() bug fixed in this branch.

Tests cover: schema creation on correct datasource, save/get/delete/
count routing via @transactional(connection), findByName with tenant
isolation, and GormEnhancer escape-hatch HQL on secondary datasource.

Assisted-by: OpenCode <opencode@opencode.ai>
Assisted-by: Claude Opus 4 <claude@anthropic.com>
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes qualifier expansion in GormEnhancer.allQualifiers() so that MultiTenant domain classes with an explicit non-default datasource mapping (e.g. datasource 'secondary') keep that qualifier instead of being forcibly expanded to DEFAULT + all connections, preventing silent routing to the wrong database in DISCRIMINATOR multi-tenancy mode.

Changes:

  • Update GormEnhancer.allQualifiers() to preserve explicit non-default datasource qualifiers for MultiTenant entities.
  • Add unit-level Spock coverage for allQualifiers() across MultiTenant/non-MultiTenant and explicit/default/ALL mappings.
  • Add an integration-style Hibernate spec intended to exercise Data Services with DISCRIMINATOR multi-tenancy + secondary datasource.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

File Description
grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormEnhancer.groovy Adjusts qualifier expansion logic to avoid overriding explicit non-default datasource mappings for MultiTenant entities.
grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/GormEnhancerAllQualifiersSpec.groovy Adds focused unit tests validating the new allQualifiers() behavior across key combinations.
grails-data-hibernate5/core/src/test/groovy/org/grails/orm/hibernate/connections/DataServiceMultiTenantMultiDataSourceSpec.groovy Adds a Hibernate-backed spec around Data Services + multi-tenancy + secondary datasource behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@jamesfredley jamesfredley force-pushed the fix/multitenant-datasource-qualifier-routing branch from 9a6f246 to fe2682b Compare February 17, 2026 19:34
@jamesfredley jamesfredley marked this pull request as draft February 17, 2026 19:58
…nant + multi-datasource

Add grails-multitenant-multi-datasource functional test app with 7 integration
tests covering DISCRIMINATOR multi-tenancy combined with a non-default datasource.
Tests verify save, get, delete, count, findByName, findAllByName, and schema
creation all route correctly to the secondary datasource with tenant isolation.

Address Copilot review feedback on DataServiceMultiTenantMultiDataSourceSpec:
- Add datasource verification assertion on save test
- Add GormEnhancer qualifier resolution test for unqualified path
- Fix @see reference to existing spec

Assisted-by: OpenCode <opencode@opencode.ai>
Assisted-by: Claude <claude@anthropic.com>
@jamesfredley jamesfredley force-pushed the fix/multitenant-datasource-qualifier-routing branch from 9e030a8 to 58b5fe2 Compare February 17, 2026 20:51
….xml

Register grails-multitenant-multi-datasource test app in the root
settings.gradle so it builds in CI, and add logback.xml matching the
pattern used by other hibernate5 functional test apps.

Assisted-by: OpenCode <opencode@opencode.ai>
Assisted-by: Claude <claude@anthropic.com>
@jamesfredley
Copy link
Contributor Author

Updated with Functional test and PR feedback

@jamesfredley jamesfredley marked this pull request as ready for review February 18, 2026 20:35
@jamesfredley jamesfredley moved this to In Progress in Apache Grails Feb 18, 2026
@jamesfredley jamesfredley added this to the grails:7.0.8 milestone Feb 18, 2026
jamesfredley added a commit to jamesfredley/grails-core that referenced this pull request Feb 19, 2026
…iTenant routing, and CRUD connection fixes

Add documentation reflecting the post-fix state after PRs apache#15393,
apache#15395, and apache#15396 are merged:

- Add @CompileStatic + injected @service property example (PR apache#15396)
- Add Multi-Tenancy with explicit datasource section (PR apache#15393)
- List all CRUD methods that respect connection routing (PR apache#15395)
- Soften IMPORTANT boxes to NOTE with authoritative tone

Assisted-by: Claude Code <Claude@Claude.ai>
Copy link
Contributor

@matrei matrei left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was a pretty significant bug! Nice catch.

@jamesfredley jamesfredley merged commit db9083d into apache:7.0.x Feb 19, 2026
32 checks passed
@jamesfredley jamesfredley deleted the fix/multitenant-datasource-qualifier-routing branch February 19, 2026 15:44
@github-project-automation github-project-automation bot moved this from In Progress to Done in Apache Grails Feb 19, 2026
@jamesfredley
Copy link
Contributor Author

@matrei Thank you for the test cleanup.

jamesfredley added a commit to jamesfredley/grails-core that referenced this pull request Feb 22, 2026
…iTenant routing, and CRUD connection fixes

Add documentation reflecting the post-fix state after PRs apache#15393,
apache#15395, and apache#15396 are merged:

- Add @CompileStatic + injected @service property example (PR apache#15396)
- Add Multi-Tenancy with explicit datasource section (PR apache#15393)
- List all CRUD methods that respect connection routing (PR apache#15395)
- Soften IMPORTANT boxes to NOTE with authoritative tone

Assisted-by: Claude Code <Claude@Claude.ai>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

4 participants