Skip to content

Fix constraints overwriting mapping index configuration (#15680)#15700

Open
codeconsole wants to merge 4 commits into
apache:7.2.xfrom
codeconsole:fix/constraint-overwrites-mapping-index
Open

Fix constraints overwriting mapping index configuration (#15680)#15700
codeconsole wants to merge 4 commits into
apache:7.2.xfrom
codeconsole:fix/constraint-overwrites-mapping-index

Conversation

@codeconsole
Copy link
Copy Markdown
Contributor

Summary

Fixes #15680.

When a domain property has both index: true, indexAttributes: [unique: true] in static mapping and unique: true in static constraints, the constraint evaluation overwrites the mapping-configured property, so the database-level unique index is silently never created.

Root Cause

AbstractGormMappingFactory.createMappedForm() evaluates both closures through the same DefaultMappingConfigurationBuilder:

  1. The mapping closure (name index: true, indexAttributes: [unique: true]) goes through builder.invokeMethod, which stores a MongoAttribute with index=true, indexAttributes={unique:true} in builder.properties['name'].
  2. The constraint closure (name unique: true) goes through Entity.methodMissing, which creates a SEPARATE instance in target.propertyConfigs['name'] containing only unique=true.
  3. builder.getProperties() then does properties.putAll(target.propertyConfigs), overwriting the mapping-set property with the constraint-only version. index reverts to false and indexAttributes becomes null.

MongoDatastore.initializeIndices() reads property.getMapping().getMappedForm().isIndex(), which now returns false, so the MongoDB index is silently never created.

Fix

Change getProperties() to use putIfAbsent semantics — properties already configured by the mapping closure are preserved when the constraint closure provides a conflicting entry.

Test

DefaultMappingConfigurationBuilderSpec covers the scenario directly: a property simulating GORM's Entity.methodMissing path against a builder that already has a mapping-configured property. The test FAILS before the fix and PASSES after.

Verified

Tested against a real Pixoto3 Grails 7.2 app with embedded MongoDB:

  • Before: Category.name declared index: true, indexAttributes: [unique: true] in mapping + unique: true in constraints → only _id_ index in MongoDB
  • After: same declarations → name_1 unique index correctly created in MongoDB

Backward Compatibility

Behavior change: if a user previously relied on constraints overwriting mapping (unlikely — that's the bug), they would observe their mapping-configured property surviving instead. The opposite direction (constraint values being applied on top of an existing mapping property) was already happening through builder.invokeMethod reusing the existing instance, so the typical "add a constraint to a mapped property" flow is unaffected.

@bito-code-review
Copy link
Copy Markdown

The PR comments file appears to be empty (only the header row is present). I cannot analyze the suggestion or provide context-specific advice without actual review comments. Please ensure the file contains the necessary data for evaluation.

@codeconsole codeconsole force-pushed the fix/constraint-overwrites-mapping-index branch from dbba9f5 to 88020b4 Compare May 29, 2026 08:33
@codeconsole
Copy link
Copy Markdown
Contributor Author

Updated based on review

Pushed an amended commit with the following tweaks (verified by an independent multi-agent review of the original PR):

Test coverage improved

The original test (now renamed "constraints closure does not overwrite mapping properties (standard DSL flow)") passed even with the fix reverted — both closures route through builder.invokeMethod and reuse the same instance, so target.propertyConfigs stays empty for that flow and the changed merge path is never exercised.

The new spec adds "mapping property in builder.properties is preserved when target.propertyConfigs has a different instance for the same key" which:

  • Populates builder.properties['name'] via the mapping closure (the normal invokeMethod path)
  • Independently populates entity.propertyConfigs['name'] with a separate instance (simulating the constraint-evaluator / Entity.property(name, Map) direct-API path that actually triggers the bug in a full Grails app)
  • Asserts the builder's mapping-configured instance is the one returned by getProperties()

Verified: this new test FAILS against the pre-fix properties.putAll(target.propertyConfigs) code and PASSES with the putIfAbsent fix. The TCK-based MongoDB integration spec was attempted but does not exercise the bug — the TCK uses DefaultGrailsApplication directly and skips the constraint-evaluator wiring that populates propertyConfigs in a real Grails-app boot, so it cannot reproduce the failure.

Also added "target.propertyConfigs entries fill in for keys the mapping closure never touched" to lock in that constraint-only configuration for properties the mapping never mentions still propagates correctly.

Code comment added

Added javadoc to getProperties() documenting:

  • The precedence rule (mapping-side instance wins when both maps have the same key)
  • Why the asymmetry exists (dual-store legacy)
  • A link back to this issue for the structural cleanup that would let the method go away

Known follow-up

The dual-store design (builder.properties vs Entity.propertyConfigs) is the underlying root cause — this PR papers over a symptom. A future major-release cleanup should collapse the two into a single canonical store. Tracking would happen in a separate issue.

When a domain property has both `index: true, indexAttributes: [unique: true]`
in `static mapping` and `unique: true` in `static constraints`, the constraint
evaluation overwrites the mapping-configured property. The root cause is in
`DefaultMappingConfigurationBuilder.getProperties()` which uses `putAll` to
merge `Entity.propertyConfigs` (populated by constraint evaluation) over
`builder.properties` (populated by mapping evaluation), destroying the
mapping-set `index` and `indexAttributes` values.

The fix changes `getProperties()` to use `putIfAbsent` semantics — properties
already configured by the mapping closure are preserved when the constraint
closure provides a conflicting entry.

Test: DefaultMappingConfigurationBuilderSpec verifies that mapping-set index
properties survive constraint evaluation.
@codeconsole codeconsole force-pushed the fix/constraint-overwrites-mapping-index branch from 88020b4 to 365956b Compare May 29, 2026 08:39
@jdaugherty
Copy link
Copy Markdown
Contributor

The code style & licensing issues need addressed

DefaultMappingConfigurationBuilderSpec was committed without the ASF
license header that every other source file in grails-datastore-core
carries, and imported org.grails.datastore.mapping.config.Property
without using it. Add the standard header and remove the unused import.
The multi-line log.debug message used double-quoted strings with no GString
interpolation, which the project's UnnecessaryGString CodeNarc rule flags
(codenarcMain). Convert the three non-interpolated continuation lines to
single quotes; the first line keeps double quotes because it embeds '{}'.
@testlens-app
Copy link
Copy Markdown

testlens-app Bot commented May 31, 2026

✅ All tests passed ✅

🏷️ Commit: 9c6e41a
▶️ Tests: 37231 executed
⚪️ Checks: 37/37 completed


Learn more about TestLens at testlens.app.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

4 participants