Skip to content

Commit db9083d

Browse files
authored
Merge pull request #15393 from jamesfredley/fix/multitenant-datasource-qualifier-routing
fix: GormEnhancer.allQualifiers() overrides explicit datasource declarations for MultiTenant entities
2 parents 3d1d619 + 465218f commit db9083d

File tree

11 files changed

+994
-1
lines changed

11 files changed

+994
-1
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* https://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
package org.grails.orm.hibernate.connections
20+
21+
import org.hibernate.Session
22+
import org.hibernate.dialect.H2Dialect
23+
import spock.lang.AutoCleanup
24+
import spock.lang.Shared
25+
import spock.lang.Specification
26+
import spock.util.environment.RestoreSystemProperties
27+
28+
import grails.gorm.MultiTenant
29+
import grails.gorm.annotation.Entity
30+
import grails.gorm.services.Service
31+
import grails.gorm.transactions.Transactional
32+
import org.grails.datastore.gorm.GormEnhancer
33+
import org.grails.datastore.gorm.GormEntity
34+
import org.grails.datastore.gorm.GormStaticApi
35+
import org.grails.datastore.mapping.core.DatastoreUtils
36+
import org.grails.datastore.mapping.multitenancy.MultiTenancySettings
37+
import org.grails.datastore.mapping.multitenancy.resolvers.SystemPropertyTenantResolver
38+
import org.grails.orm.hibernate.HibernateDatastore
39+
40+
/**
41+
* Tests GORM Data Service auto-implemented CRUD methods when both DISCRIMINATOR
42+
* multi-tenancy and a non-default datasource are configured on the same domain.
43+
*
44+
* This combination triggers the allQualifiers() bug: when MultiTenant is present,
45+
* allQualifiers() returns tenant IDs instead of datasource names, causing schema
46+
* creation and query routing to go to the wrong database.
47+
*
48+
* Covers:
49+
* - Schema creation on the correct (analytics) datasource for MultiTenant domains
50+
* - save(), get(), delete(), count() with tenant isolation on secondary datasource
51+
* - findBy* dynamic finders with tenant isolation on secondary datasource
52+
* - GormEnhancer escape-hatch for aggregate HQL on secondary datasource
53+
* - Tenant isolation: same-named data under different tenants stays separate
54+
*
55+
* @see PartitionedMultiTenancySpec for basic DISCRIMINATOR multi-tenancy
56+
* @see MultipleDataSourceConnectionsSpec for Data Services on secondary datasource without multi-tenancy
57+
*/
58+
@RestoreSystemProperties
59+
class DataServiceMultiTenantMultiDataSourceSpec extends Specification {
60+
61+
@Shared @AutoCleanup HibernateDatastore datastore
62+
63+
void setupSpec() {
64+
Map config = [
65+
"grails.gorm.multiTenancy.mode": MultiTenancySettings.MultiTenancyMode.DISCRIMINATOR,
66+
"grails.gorm.multiTenancy.tenantResolverClass": SystemPropertyTenantResolver,
67+
'dataSource.url': "jdbc:h2:mem:grailsDB;LOCK_TIMEOUT=10000",
68+
'dataSource.dbCreate': 'create-drop',
69+
'dataSource.dialect': H2Dialect.name,
70+
'dataSource.formatSql': 'true',
71+
'hibernate.flush.mode': 'COMMIT',
72+
'hibernate.cache.queries': 'true',
73+
'hibernate.hbm2ddl.auto': 'create-drop',
74+
'dataSources.analytics': [url: "jdbc:h2:mem:analyticsDB;LOCK_TIMEOUT=10000"],
75+
]
76+
77+
datastore = new HibernateDatastore(
78+
DatastoreUtils.createPropertyResolver(config), Metric)
79+
}
80+
81+
MetricService metricService
82+
83+
void setup() {
84+
tenant = 'tenant1'
85+
metricService = datastore
86+
.getDatastoreForConnection('analytics')
87+
.getService(MetricService)
88+
metricService.deleteAll()
89+
// Also clean tenant2 data
90+
tenant = 'tenant2'
91+
metricService.deleteAll()
92+
// Reset to tenant1 for tests
93+
tenant = 'tenant1'
94+
}
95+
96+
void "schema is created on analytics datasource"() {
97+
expect: 'The analytics datasource connects to the analyticsDB H2 database'
98+
Metric.analytics.withNewSession { Session s ->
99+
assert s.connection().metaData.getURL() == 'jdbc:h2:mem:analyticsDB'
100+
return true
101+
}
102+
103+
and: 'The default datasource connects to a different database'
104+
datastore.withNewSession { Session s ->
105+
assert s.connection().metaData.getURL() == 'jdbc:h2:mem:grailsDB'
106+
return true
107+
}
108+
}
109+
110+
void "save routes to analytics datasource with tenant isolation"() {
111+
when: 'A metric is saved under tenant1'
112+
def saved = metricService.save(new Metric(name: 'page_views', amount: 100))
113+
114+
then: 'The metric is persisted with an ID'
115+
saved != null
116+
saved.id != null
117+
saved.name == 'page_views'
118+
saved.amount == 100
119+
120+
and: 'The metric is retrievable via the analytics datasource qualifier'
121+
Metric.analytics.withNewSession {
122+
Metric.analytics.get(saved.id) != null
123+
}
124+
}
125+
126+
void "get retrieves from analytics datasource"() {
127+
given: 'A metric saved to the analytics datasource'
128+
def saved = metricService.save(new Metric(name: 'sessions', amount: 42))
129+
130+
when: 'The metric is retrieved by ID'
131+
def found = metricService.get(saved.id)
132+
133+
then: 'The correct metric is returned'
134+
found != null
135+
found.id == saved.id
136+
found.name == 'sessions'
137+
found.amount == 42
138+
}
139+
140+
void "count returns count scoped to current tenant"() {
141+
given: 'Metrics saved under tenant1'
142+
metricService.save(new Metric(name: 'alpha', amount: 1))
143+
metricService.save(new Metric(name: 'beta', amount: 2))
144+
145+
and: 'Metrics saved under tenant2'
146+
tenant = 'tenant2'
147+
metricService.save(new Metric(name: 'gamma', amount: 3))
148+
149+
when: 'Counting under tenant1'
150+
tenant = 'tenant1'
151+
def count1 = metricService.count()
152+
153+
and: 'Counting under tenant2'
154+
tenant = 'tenant2'
155+
def count2 = metricService.count()
156+
157+
then: 'Each tenant sees only its own data'
158+
count1 == 2
159+
count2 == 1
160+
}
161+
162+
void "delete removes from analytics datasource"() {
163+
given: 'A metric saved under tenant1'
164+
def saved = metricService.save(new Metric(name: 'disposable', amount: 0))
165+
def id = saved.id
166+
167+
when: 'The metric is deleted'
168+
metricService.delete(id)
169+
170+
then: 'The metric is no longer retrievable'
171+
metricService.get(id) == null
172+
metricService.count() == 0
173+
}
174+
175+
void "findByName routes to analytics datasource with tenant isolation"() {
176+
given: 'Same-named metrics under different tenants'
177+
metricService.save(new Metric(name: 'shared_name', amount: 100))
178+
tenant = 'tenant2'
179+
metricService.save(new Metric(name: 'shared_name', amount: 200))
180+
181+
when: 'Finding by name under tenant1'
182+
tenant = 'tenant1'
183+
def found1 = metricService.findByName('shared_name')
184+
185+
and: 'Finding by name under tenant2'
186+
tenant = 'tenant2'
187+
def found2 = metricService.findByName('shared_name')
188+
189+
then: 'Each tenant gets its own metric'
190+
found1 != null
191+
found1.amount == 100
192+
193+
found2 != null
194+
found2.amount == 200
195+
}
196+
197+
void "GormEnhancer resolves analytics qualifier for MultiTenant entity with explicit datasource"() {
198+
when: 'Looking up the static API without specifying a connection'
199+
def api = GormEnhancer.findStaticApi(Metric)
200+
201+
then: 'The API is registered and functional (schema exists on correct datasource)'
202+
api != null
203+
204+
when: 'Using the explicit analytics qualifier'
205+
def analyticsApi = GormEnhancer.findStaticApi(Metric, 'analytics')
206+
207+
then: 'The analytics API is also registered'
208+
analyticsApi != null
209+
}
210+
211+
void "GormEnhancer aggregate HQL routes to analytics datasource"() {
212+
given: 'Multiple metrics saved under tenant1'
213+
metricService.save(new Metric(name: 'alpha', amount: 10))
214+
metricService.save(new Metric(name: 'beta', amount: 20))
215+
metricService.save(new Metric(name: 'gamma', amount: 30))
216+
217+
when: 'Using GormEnhancer for an aggregate query'
218+
def results = metricService.getTotalAmountAbove(15)
219+
220+
then: 'The HQL executes against the analytics datasource'
221+
results.size() == 1
222+
results[0] == 50 // 20 + 30
223+
}
224+
225+
private static void setTenant(String tenantId) {
226+
System.setProperty(SystemPropertyTenantResolver.PROPERTY_NAME, tenantId)
227+
}
228+
}
229+
230+
/**
231+
* Metric domain mapped to the 'analytics' datasource with DISCRIMINATOR multi-tenancy.
232+
* This combination triggers the allQualifiers() bug when both MultiTenant and
233+
* a non-default datasource are configured.
234+
*/
235+
@Entity
236+
class Metric implements GormEntity<Metric>, MultiTenant<Metric> {
237+
Long id
238+
Long version
239+
String tenantId
240+
String name
241+
Integer amount
242+
243+
static mapping = {
244+
datasource 'analytics'
245+
}
246+
247+
static constraints = {
248+
name blank: false
249+
amount min: 0
250+
}
251+
}
252+
253+
/**
254+
* Data Service interface for Metric - all methods auto-implemented by GORM.
255+
*/
256+
interface MetricDataService {
257+
Metric get(Serializable id)
258+
Metric save(Metric metric)
259+
void delete(Serializable id)
260+
Long count()
261+
Metric findByName(String name)
262+
List<Metric> findAllByAmountGreaterThan(Integer amount)
263+
}
264+
265+
/**
266+
* Abstract class that binds MetricDataService to the 'analytics' datasource.
267+
* The @Transactional(connection = "analytics") ensures all auto-implemented methods
268+
* and custom methods route to the secondary datasource.
269+
*/
270+
@Service(Metric)
271+
@Transactional(connection = 'analytics')
272+
abstract class MetricService implements MetricDataService {
273+
274+
/**
275+
* Statically compiled access to the analytics datasource via GormEnhancer.
276+
*/
277+
private GormStaticApi<Metric> getAnalyticsApi() {
278+
GormEnhancer.findStaticApi(Metric, 'analytics')
279+
}
280+
281+
/**
282+
* Delete all metrics for the current tenant from the analytics datasource.
283+
*/
284+
void deleteAll() {
285+
analyticsApi.executeUpdate('delete from Metric')
286+
}
287+
288+
/**
289+
* Aggregate query - calculates total amount of metrics above a threshold.
290+
*/
291+
List getTotalAmountAbove(Integer minAmount) {
292+
analyticsApi.executeQuery(
293+
'select sum(m.amount) from Metric m where m.amount > :minAmount',
294+
[minAmount: minAmount]
295+
)
296+
}
297+
}

grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormEnhancer.groovy

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,23 @@ class GormEnhancer implements Closeable {
180180
List<String> allQualifiers(Datastore datastore, PersistentEntity entity) {
181181
List<String> qualifiers = new ArrayList<>()
182182
qualifiers.addAll(ConnectionSourcesSupport.getConnectionSourceNames(entity))
183-
if ((MultiTenant.isAssignableFrom(entity.javaClass) || qualifiers.contains(ConnectionSource.ALL)) && (datastore instanceof ConnectionSourcesProvider)) {
183+
184+
// For MultiTenant entities OR entities declared with ConnectionSource.ALL,
185+
// expand qualifiers to include all available connection sources — BUT only
186+
// if the entity does not have an explicit non-DEFAULT datasource declaration.
187+
//
188+
// When a MultiTenant entity declares `datasource 'secondary'`, that explicit
189+
// mapping must be preserved. Expanding to all connections causes silent
190+
// data routing to the wrong database (the DEFAULT datasource) for
191+
// DISCRIMINATOR multi-tenancy mode.
192+
boolean isMultiTenant = MultiTenant.isAssignableFrom(entity.javaClass)
193+
boolean hasExplicitAll = qualifiers.contains(ConnectionSource.ALL)
194+
boolean hasExplicitNonDefaultDatasource = isMultiTenant &&
195+
!hasExplicitAll &&
196+
qualifiers.size() > 0 &&
197+
!qualifiers.equals(ConnectionSourcesSupport.DEFAULT_CONNECTION_SOURCE_NAMES)
198+
199+
if ((isMultiTenant || hasExplicitAll) && !hasExplicitNonDefaultDatasource && (datastore instanceof ConnectionSourcesProvider)) {
184200
qualifiers.clear()
185201
qualifiers.add(ConnectionSource.DEFAULT)
186202

0 commit comments

Comments
 (0)