You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@grails.apache.org by bo...@apache.org on 2026/10/09 02:39:13 UTC
(grails-core) 03/18: feat: accept a mapped type on the tenant id in native domain binding
This is an automated email from the ASF dual-hosted git repository.
borinquenkid pushed a commit to branch 9.0.x-h7-improvement-phase3
in repository https://gitbox.apache.org/repos/asf/grails-core.git
commit 6634a171647331d3dd5ef01fc5b464c425309ae6
Author: Walter Duque de Estrada <wb...@mac.com>
AuthorDate: Thu Oct 8 14:44:39 2026 -0500
feat: accept a mapped type on the tenant id in native domain binding
The generated mode refused a multi-tenant id with a mapped type, on the belief that the filter parameter would need the same type. The domain binder builds the filter definition from the bound tenant id property, and so does the aligner of the generated mode, so the parameter has the type of the bound column in both. The generator now types the field of the tenant id like any other and only refuses a type it cannot state; a tenant with a text-typed id sees only its own rows in both mo [...]
Co-Authored-By: Claude Sonnet 5.5 <no...@anthropic.com>
---
.../domainbinding/jpa/GrailsDomainGenerator.groovy | 8 +-
.../GeneratedDomainClassesTenantTypeSpec.groovy | 122 +++++++++++++++++++++
.../jpa/GrailsDomainGeneratorTenantSpec.groovy | 10 +-
.../advancedGORMFeatures/nativeDomainBinding.adoc | 5 +-
4 files changed, 132 insertions(+), 13 deletions(-)
diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGenerator.groovy b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGenerator.groovy
index f3a8dc321f..f421805393 100644
--- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGenerator.groovy
+++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGenerator.groovy
@@ -1205,7 +1205,7 @@ class GrailsDomainGenerator {
* written and queried like any other property (plan decision on item 6a).</p>
*
* @return the filter, or {@code null} when the binder adds none to the entity
- * @throws UnsupportedOperationException when the tenant id has a mapped type, which the filter parameter would need too
+ * @throws UnsupportedOperationException when the type of the tenant id is one the generator cannot state
*/
TenantFacets tenantFacets(GrailsHibernatePersistentEntity entity) {
HibernatePersistentProperty tenantId = entity.isMultiTenant() ? entity.hibernateTenantId : null
@@ -1213,10 +1213,8 @@ class GrailsDomainGenerator {
return null
}
TypeDecision type = decideType(tenantId)
- if (!type.supported || type.facets != null) {
- throw new UnsupportedOperationException(
- "The tenant id [${tenantId.name}] of [${entity.name}] has a mapped type, which the filter parameter " +
- 'would need too and the generator cannot state yet')
+ if (!type.supported) {
+ throw new UnsupportedOperationException(typeNotSupported(tenantId, type))
}
return new TenantFacets(GormProperties.TENANT_IDENTITY, entity.getMultiTenantFilterCondition(defaultColumnNames), boxed(tenantId.type))
}
diff --git a/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesTenantTypeSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesTenantTypeSpec.groovy
new file mode 100644
index 0000000000..60d40ab8ea
--- /dev/null
+++ b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/generated/GeneratedDomainClassesTenantTypeSpec.groovy
@@ -0,0 +1,122 @@
+/*
+ * 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
+ *
+ * http://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.gorm.tests.generated
+
+import java.util.concurrent.atomic.AtomicInteger
+
+import grails.gorm.MultiTenant
+import grails.gorm.annotation.Entity
+import org.hibernate.dialect.H2Dialect
+import org.hibernate.mapping.Table
+import spock.lang.AutoCleanup
+import spock.lang.Specification
+import spock.util.environment.RestoreSystemProperties
+
+import org.grails.datastore.mapping.core.DatastoreUtils
+import org.grails.datastore.mapping.multitenancy.MultiTenancySettings
+import org.grails.datastore.mapping.multitenancy.resolvers.SystemPropertyTenantResolver
+import org.grails.orm.hibernate.HibernateDatastore
+
+/**
+ * A multi-tenant id that has a mapped type. The domain binder gives the column the type and builds the tenant filter from the bound
+ * property, so a tenant still sees only its own rows; the generated mode does the same instead of refusing the mapping.
+ */
+@RestoreSystemProperties
+class GeneratedDomainClassesTenantTypeSpec extends Specification {
+
+ static final AtomicInteger BOOTS = new AtomicInteger()
+
+ @AutoCleanup
+ HibernateDatastore datastore
+
+ private HibernateDatastore boot(boolean generated) {
+ datastore?.close()
+ System.setProperty(SystemPropertyTenantResolver.PROPERTY_NAME, 'ford')
+ datastore = new HibernateDatastore(DatastoreUtils.createPropertyResolver([
+ 'grails.gorm.multiTenancy.mode' : MultiTenancySettings.MultiTenancyMode.DISCRIMINATOR,
+ 'grails.gorm.multiTenancy.tenantResolverClass': SystemPropertyTenantResolver.name,
+ 'dataSource.url' : "jdbc:h2:mem:gdt${BOOTS.incrementAndGet()};LOCK_TIMEOUT=10000".toString(),
+ 'dataSource.dbCreate' : 'create-drop',
+ 'dataSource.dialect' : H2Dialect.name,
+ 'hibernate.cache.queries' : 'false',
+ 'hibernate.cache.use_query_cache' : 'false',
+ 'hibernate.generatedDomainClasses' : generated,
+ ]), GdtTypedTenantItem)
+ return datastore
+ }
+
+ private Map<String, String> columns() {
+ Map<String, String> result = new TreeMap<String, String>()
+ for (Table table : datastore.metadata.collectTableMappings()) {
+ if (table.physicalTable) {
+ table.columns.each { org.hibernate.mapping.Column column ->
+ result["${table.name}.${column.name}".toString()] = "${column.getSqlType(datastore.metadata)}${column.nullable ? '' : ' not null'}".toString()
+ }
+ }
+ }
+ return result
+ }
+
+ void "the tenant id column has the mapped type in both modes"() {
+ when:
+ boot(false)
+ Map<String, String> binder = columns()
+ boot(true)
+ Map<String, String> generated = columns()
+
+ then:
+ generated == binder
+ generated['gdt_typed_tenant_item.company_id'].startsWith('clob')
+ }
+
+ void "a tenant sees only its own rows in both modes"() {
+ when:
+ Map<Boolean, List> results = [false, true].collectEntries { boolean generated ->
+ boot(generated)
+ System.setProperty(SystemPropertyTenantResolver.PROPERTY_NAME, 'ford')
+ GdtTypedTenantItem.withTransaction { new GdtTypedTenantItem(name: 'mustang').save(failOnError: true, flush: true) }
+ System.setProperty(SystemPropertyTenantResolver.PROPERTY_NAME, 'tesla')
+ GdtTypedTenantItem.withTransaction {
+ new GdtTypedTenantItem(name: 'model s').save(failOnError: true, flush: true)
+ new GdtTypedTenantItem(name: 'model 3').save(failOnError: true, flush: true)
+ }
+ List tesla = GdtTypedTenantItem.withNewSession { [GdtTypedTenantItem.count(), GdtTypedTenantItem.list()*.name.sort()] }
+ System.setProperty(SystemPropertyTenantResolver.PROPERTY_NAME, 'ford')
+ List ford = GdtTypedTenantItem.withNewSession { [GdtTypedTenantItem.count(), GdtTypedTenantItem.list()*.name, GdtTypedTenantItem.findByName('model s')] }
+ [(generated): [tesla, ford]]
+ }
+
+ then:
+ results[true] == results[false]
+ results[true] == [[2, ['model 3', 'model s']], [1, ['mustang'], null]]
+ }
+}
+
+@Entity
+class GdtTypedTenantItem implements MultiTenant<GdtTypedTenantItem> {
+ String name
+ String companyId
+
+ static mapping = {
+ tenantId name: 'companyId'
+ columns {
+ companyId type: 'text'
+ }
+ }
+}
diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorTenantSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorTenantSpec.groovy
index 392e7f425a..c8479f6efa 100644
--- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorTenantSpec.groovy
+++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/jpa/GrailsDomainGeneratorTenantSpec.groovy
@@ -165,13 +165,15 @@ class GrailsDomainGeneratorTenantSpec extends HibernateGormDatastoreSpec {
getSessionFactory().getFilterDefinition('tenantId').getParameterJdbcMapping('tenantId').javaTypeDescriptor.javaTypeClass == String
}
- void "a tenant id with a mapped type is rejected by name, because the filter parameter would need the same type"() {
+ void "a tenant id with a mapped type is typed like any other column and the entity keeps its filter"() {
when:
- generate(GenTenantTyped)
+ Class<?> generated = generate(GenTenantTyped)
then:
- UnsupportedOperationException e = thrown()
- e.message.contains('The tenant id [companyId] of [' + GenTenantTyped.name + ']')
+ generated.getDeclaredField('companyId').getAnnotation(org.hibernate.annotations.Type).value() == GenUpperType
+ generated.getAnnotation(Filter).name() == 'tenantId'
+ generated.getAnnotation(Filter).condition() == ':tenantId = company_id'
+ newGenerator().tenantFacets(entity(GenTenantTyped)).parameterType() == String
}
private Metadata annotationMetadata(Collection<Class<?>> classes) {
diff --git a/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/nativeDomainBinding.adoc b/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/nativeDomainBinding.adoc
index 3f127e08bd..ef9c0526b5 100644
--- a/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/nativeDomainBinding.adoc
+++ b/grails-data-hibernate7/docs/src/docs/asciidoc/advancedGORMFeatures/nativeDomainBinding.adoc
@@ -70,6 +70,7 @@ For the domain classes in GORM's own test suites, both bindings produce the same
* A natural id declared on a subclass adds a unique key to the table of the hierarchy (the table of the subclass in a joined hierarchy), over the columns of the named properties in the order the mapping names them, and makes those properties updatable only when the natural id is `mutable`. Classic binding also marks the properties as natural identifiers, and with Hibernate 7 that makes every load of the subclass entity fail with a `NullPointerException`; native binding does not mark them [...]
* `insertable: false` and `updatable: false` are honored on a property with a column. On an embedded object they apply to every column of the object. On a `hasMany` and on the inverse side of a one-to-one (the side with no column) both bindings ignore them: the collection is written through its own table, and the foreign key lives on the other side.
* A `discriminator` column may set `precision` and `scale`. They change nothing in the DDL of a string, integer or character column, in both bindings.
+* A multi-tenant id (`tenantId`) may have a mapped `type`: its column has the type, and the tenant filter still compares it with the current tenant.
* Identifier generators, inheritance strategies (single table, joined and table-per-concrete-class), discriminators, natural ids, optimistic locking, lazy and eager fetching, cascades, sorting, batch size and `cache` usage are mapped as with classic binding.
[[nativeDomainBinding-refused]]
@@ -108,10 +109,6 @@ Nothing is ignored silently. A mapping that boots is bound the way it is written
| An association mapped with a `formula`, or with several columns where the target does not have a composite identifier.
| `... the association is mapped with a formula` or `... the mapping states several columns ...`
| Map the association with a single `column`.
-
-| A multi-tenant id (`tenantId`) that has a mapped `type`.
-| `The tenant id [...] of [...] has a mapped type ...`
-| Remove the `type` from the tenant id.
|===
A few mappings fail to boot with classic binding too, so they are not a difference between the two: a `SortedSet` of basic values, a `UserType` class as the `type` of a `hasMany` of domain classes, a `Map` of enums, a hierarchy that mixes inheritance strategies, a `discriminator` type other than string, integer or character, and a joined subclass of a root with a composite identifier.