Skip to content

Commit 9a6f246

Browse files
committed
test: add DataServiceMultiTenantMultiDataSourceSpec for DISCRIMINATOR + 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: Claude Code <Claude@Claude.ai> Assisted-by: Claude Opus 4 <claude@anthropic.com>
1 parent 4e04e96 commit 9a6f246

File tree

1 file changed

+274
-0
lines changed

1 file changed

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

0 commit comments

Comments
 (0)