MartinJesusDev opened a new issue, #16674:
URL: https://github.com/apache/grails-core/issues/16674
### Expected Behavior
The GORM criteria API (`where { }`, `createCriteria`, `DetachedCriteria`,
the bulk `updateAll`/`deleteAll`)
behaves as the reference manual documents it, consistently across the
datastores that support each feature,
and never drops a restriction silently:
```groovy
Person.where { age > avg(age) }.list() // documented
aggregate subquery
Person.where { upper(lastName) == 'SIMPSON' }.list() // documented
function call
Team.where { def t = Team; def c = club // documented
alias variables
exists(Club.where { id == t.club.id }.property('name')) }.list()
Person.where { }.readOnly(true).list() // documented
read only mode
Person.where { }.max(2).deleteAll() // must not
silently delete every row
```
### Actual Behaviour
An audit of the Grails 8 criteria/query surface against the manual, the
Hibernate 5 implementation and the
code found **28 findings**: 23 in the Hibernate 7 implementation (F3 is open
for a maintainer decision and F7
needs no change), 1 in the Rx module (F5, out of this branch), 2 in the
non-SQL implementations (F24, F25) and
2 found by a code review of the branch (F26, F27). Most are capabilities the
Hibernate 7 rewrite dropped, or
conditions it ignored **silently** (the query matches every row instead of
failing). The complete inventory,
with the fix commit and the test that covers each finding:
**How to read the table.** Each row is one finding: the area, what a user
saw before the branch (the finding), the commit that fixes it in the branch
above (linked to the fork) and the spec that covers it. *decision* means the
branch does not change it because it needs a maintainer decision; *no change
needed* means the behaviour is already usable as documented; *out of branch*
means the module is disabled in 8.0.x; *separate issue* means it is tracked in
another issue of this audit. The detailed notes at the end explain the two
non-obvious ones (F20 and F7).
`F18`/`F19` were false alarms during the audit (test authoring); the
numbering keeps the gaps on purpose.
| # | Area | Finding | Fix commit (branch `audit/gorm-criteria-8.0.x-clean`)
| Test |
|---|------|---------|--------------------------------------------------------|------|
| F1 | reads | a deferred (lazy) subquery was not materialised, so
`exists`/`inList` with a lazily built `where` matched nothing |
[`0fc027f04a`](https://github.com/MartinJesusDev/grails-core/commit/0fc027f04a)
| `DetachCriteriaSubquerySpec` |
| F2 | reads | translating a subquery mutated the criteria projection list,
so a second execution differed |
[`a412c55e7a`](https://github.com/MartinJesusDev/grails-core/commit/a412c55e7a),
[`fdd04528be`](https://github.com/MartinJesusDev/grails-core/commit/fdd04528be)
| `DetachCriteriaSubquerySpec`, `CriteriaSubqueryMatrixSpec` |
| F3 | reads | `singleResult` with more than one row behaves inconsistently
across the API (some paths return the first row, others fail) and the
documentation does not state the contract, so it needs a maintainer decision.
The branch pins the current shape with a test. | — (decision) |
`DetachedCriteriaProjectionSpec` |
| F4 | API | `GormEntity.where`/`whereLazy`/`whereAny`/`findAll` lacked
`@DelegatesTo` (unusable from `@CompileStatic`) |
[`09af071871`](https://github.com/MartinJesusDev/grails-core/commit/09af071871)
| `GormEntityWhereCompileStaticSpec` |
| F5 | API | the Rx `DetachedCriteria` resolves closure calls with
`OWNER_FIRST` instead of `DELEGATE_FIRST`, so a where closure can pick up the
wrong receiver. The Rx module is disabled in 8.0.x, so the fix is out of this
branch. | out of branch | — |
| F6 | tests | the Hibernate 7 criteria translation had no guard for the
criterion registry and little compile-static coverage, so a missing handler
only showed up at runtime. Tests added: translator completeness, compile-static
API, builder path, inheritance and a PostgreSQL oracle. |
[`4ac0a93c8b`](https://github.com/MartinJesusDev/grails-core/commit/4ac0a93c8b),
[`149af266b1`](https://github.com/MartinJesusDev/grails-core/commit/149af266b1)
| `CriteriaTranslatorCompletenessSpec` |
| F7 | API | `createCriteria()` omits builder-only methods: the
Hibernate/JPA ones (`createAlias`, `fetchMode`, `lock`, `sql*`) cannot be
shared, most are internal, and the generic projection/pagination ones
(`projections`, `maxResults`, …) work in `@CompileStatic` inside `list {
}`/`withCriteria { }`. No change needed. | — (no change needed) |
`StaticCompiledCriteriaSpec` |
| F8 | writes | several subqueries in one bulk write shared parameter
positions |
[`5675818fbb`](https://github.com/MartinJesusDev/grails-core/commit/5675818fbb)
| `CriteriaWriteOperationsSpec` |
| F9 | writes | `exists`/`notExists` were not supported in the bulk write
path |
[`50d85ac379`](https://github.com/MartinJesusDev/grails-core/commit/50d85ac379)
| `CriteriaWriteOperationsSpec` |
| F10 | writes | bulk writes did not filter through to-one association paths
|
[`0c5ded46d1`](https://github.com/MartinJesusDev/grails-core/commit/0c5ded46d1)
| `CriteriaWriteOperationsSpec` |
| F11 | writes | association aliases and the `{alias}` placeholder in bulk
writes |
[`58e1ed76e9`](https://github.com/MartinJesusDev/grails-core/commit/58e1ed76e9)
| `CriteriaWriteOperationsSpec` |
| F12 | writes | a deferred subquery in the HQL write path |
[`822653628b`](https://github.com/MartinJesusDev/grails-core/commit/822653628b)
| `CriteriaWriteOperationsSpec` |
| F13 | reads | where-query functions (`upper`, `lower`, `trim`, `length`,
`year`, …) threw `UnsupportedOperationException` (Hibernate 5 supported them;
the manual documents them) |
[`337484dc14`](https://github.com/MartinJesusDev/grails-core/commit/337484dc14)
| `WhereQueryDocumentedFormsSpec`, `DataServiceSpec` |
| F14 | reads | comparing a property with a subquery for `gt`/`ge`/`lt`/`le`
bound the criteria as a parameter (the manual documents `gt "age", new
DetachedCriteria(Person).build { projections { avg "age" } }`) |
[`337484dc14`](https://github.com/MartinJesusDev/grails-core/commit/337484dc14)
| `WhereQueryDocumentedFormsSpec`, `CriteriaWriteOperationsSpec` |
| F15a | reads | an unresolved property path in a property comparison was
dropped silently (every row matched) |
[`337484dc14`](https://github.com/MartinJesusDev/grails-core/commit/337484dc14)
| `WhereQueryDocumentedFormsSpec` |
| F15b | reads | a criteria root alias was not resolvable from a subquery
(#14623) |
[`337484dc14`](https://github.com/MartinJesusDev/grails-core/commit/337484dc14)
| `SubqueryAliasSpec` |
| F16 | reads | `in`/`inList` with a null member failed the whole query |
[`ee593c46c8`](https://github.com/MartinJesusDev/grails-core/commit/ee593c46c8)
| `WhereQueryDocumentedFormsSpec`, `CriteriaWriteOperationsSpec` |
| F17 | mapping | the binder ignored the Hibernate annotations `@Formula`,
`@SQLRestriction` and `@FilterDef`/`@Filter`: the property loaded as `null`,
the restriction was dropped (every query returned the rows it was meant to
hide) and the session could not enable the filter. The binder maps them now; a
restriction declared on a subclass fails with a clear message (declare it on
the root entity). |
[`0b595f31a4`](https://github.com/MartinJesusDev/grails-core/commit/0b595f31a4),
[`7c2fd42f99`](https://github.com/MartinJesusDev/grails-core/commit/7c2fd42f99),
[`b811cce4e5`](https://github.com/MartinJesusDev/grails-core/commit/b811cce4e5)
| `CriteriaMappingFeaturesSpec` |
| F17d | mapping | a `@SQLRestriction` on a subclass was dropped silently;
it fails with a clear message now (declare it on the root entity) |
[`a77279235c`](https://github.com/MartinJesusDev/grails-core/commit/a77279235c),
[`2dbd58e7fc`](https://github.com/MartinJesusDev/grails-core/commit/2dbd58e7fc)
| `SubclassSqlRestrictionSpec`, `SubClassBinderSpec` |
| F20 | writes | a batch operation silently ignored the pagination of the
criteria: `Person.where { }.max(2).deleteAll()` deleted **every** matching row
(a bulk statement has no limit clause and the builder never read `defaultMax`).
A plain `deleteAll()` still deletes everything; only the paginated call is
rejected, with a message naming the values (see the detailed notes). |
[`e070f5e678`](https://github.com/MartinJesusDev/grails-core/commit/e070f5e678)
| `CriteriaWriteOperationsSpec` |
| F21 | services | `@Where({ amount > avg(amount) })` generated invalid SQL
(the aggregate projection landed on the outer query) |
[`e654e88e71`](https://github.com/MartinJesusDev/grails-core/commit/e654e88e71)
| `DataServiceSpec` |
| F22 | reads | a comparison through a nested alias path (`t.club.id`) was
evaluated at runtime |
[`d891e5c317`](https://github.com/MartinJesusDev/grails-core/commit/d891e5c317)
| `SubqueryAliasSpec` |
| F23 | reads | `readOnly(true)`/`cache(true)` on a detached criteria were
no-ops |
[`f2941fe1a0`](https://github.com/MartinJesusDev/grails-core/commit/f2941fe1a0)
| `WhereQueryDocumentedFormsSpec`, `HibernateCriteriaBuilderSpec` |
| F23b | reads | the Hibernate settings of the query arguments map
(`readOnly`, `cache`, `fetchSize`, `timeout`, `flushMode`) were dropped for
criteria |
[`c5b634eb12`](https://github.com/MartinJesusDev/grails-core/commit/c5b634eb12),
[`8ead79eaca`](https://github.com/MartinJesusDev/grails-core/commit/8ead79eaca)
| `WhereQueryDocumentedFormsSpec` |
| F24 | non-SQL | on MongoDB and Neo4j the four aggregate subquery forms of
the manual fail with `MissingMethodException: avg(<DetachedCriteria>)`: the
transformer emits a call their criteria API cannot satisfy (a different call
than on Hibernate, where it works). Both can express the forms with their own
machinery (Mongo: `$expr`/aggregation pipeline; Neo4j: a Cypher subquery), so
it is an implementation gap; until then it should be a clear 'not supported'
message and documented. A separate issue of this audit tracks it; the branch
pins the current state with pending specs. | — (separate issue) | pending specs
|
| F25 | tests | the TCK sources are compiled without the where-query AST
transformation, so a TCK test written with operators or property references
runs untransformed and passes without testing anything (for example
`WqFoo.where { bar == null }` compares a `PropertyReference` and always reports
0). The where-query coverage of the kit is partly fake; a separate issue of
this audit tracks the cause (the TCK rework lost the wiring). | — (separate
issue) | — |
| F26 | reads | a deferred criteria (`whereLazy`/`buildLazy` and the
subqueries the transformer builds) applied its closure **in place** on first
use: two threads sharing one criteria could both see it and apply it twice,
duplicating criteria or projections. The read and bulk paths materialise a copy
now; only the builder path applies in place, which is single threaded by
nature. |
[`cb4fc2f439`](https://github.com/MartinJesusDev/grails-core/commit/cb4fc2f439)
| `DetachCriteriaSubquerySpec` |
| F27 | reads | `AbstractDetachedCriteria.clone()` copied every field but
the root alias, so a derived criteria (`sort`, `max`, `build`, `whereLazy`) and
the copy the deferred materialisation returns lost it: a subquery referring to
the alias failed with `Cannot resolve property`. The clone keeps the alias now,
and the read path takes the alias, the max and the offset from the materialised
criteria. |
[`e3deb72e10`](https://github.com/MartinJesusDev/grails-core/commit/e3deb72e10)
| `SubqueryAliasSpec` |
### Steps To Reproduce
**Where the change can be reviewed** (fork of `apache/grails-core`):
- Branch:
<https://github.com/MartinJesusDev/grails-core/tree/audit/gorm-criteria-8.0.x-clean>
- Compare with `8.0.x`:
<https://github.com/MartinJesusDev/grails-core/compare/8.0.x...audit/gorm-criteria-8.0.x-clean>
- Full commit list (one commit per finding, the finding in every message):
`git log --oneline 8.0.x..audit/gorm-criteria-8.0.x-clean`
- Production files touched (21):
`git diff --name-only 8.0.x...audit/gorm-criteria-8.0.x-clean | grep
src/main`
**Run the reproduction** (H2, no containers):
```bash
# the documented forms end to end
./gradlew :grails-data-hibernate7-core:test --tests
"*WhereQueryDocumentedFormsSpec" --tests "*SubqueryAliasSpec"
# the bulk write path (subqueries, aliases, association paths, the
pagination rejection)
./gradlew :grails-data-hibernate7-core:test --tests
"*CriteriaWriteOperationsSpec"
# the binder annotations (formula, restriction, filters)
./gradlew :grails-data-hibernate7-core:test --tests
"*CriteriaMappingFeaturesSpec" --tests "*SubclassSqlRestrictionSpec"
# the data service @Where path
./gradlew :grails-data-hibernate7-core:test --tests "*DataServiceSpec"
```
Before the branch: `upper(name) == 'A'` throws
`UnsupportedOperationException`; `gt('age', <subquery>)` throws
`QueryArgumentException`; `max(2).deleteAll()` deletes every row;
`readOnly(true)` does nothing;
`@SQLRestriction`/`@Formula`/`@FilterDef` are ignored; the alias form loses
its restriction silently. With the
branch, each of those has a test that proves the fixed behaviour.
**CI runs of the branch** — all three are green: the gate at 19:26 UTC
(style, analysis, rat, validate and
core), all-suites at 18:47 UTC (functional, hibernate5, hibernate7, mongodb,
redis, neo4j, springsecurity) and
docs at 18:00 UTC. They run on public runners:
- gate:
<https://github.com/MartinJesusDev/grails-core-ci/actions/runs/39297989319>
- all-suites (functional, hibernate5, hibernate7, mongodb, redis, neo4j,
springsecurity):
<https://github.com/MartinJesusDev/grails-core-ci/actions/runs/39297994535>
- docs:
<https://github.com/MartinJesusDev/grails-core-ci/actions/runs/39297999214>
### Environment Information
- Grails 8.0.x, `grails-data-hibernate7` (Hibernate ORM 7.4.x) with H2 in
memory; PostgreSQL 16 through
Testcontainers for the SQL oracle.
- `grails-data-hibernate5-core` (Hibernate 5.6) and the MongoDB/Neo4j/Redis
suites run in the matrix.
- JDK 21 (Liberica), Gradle 9.8.x.
### Example Application
Not a separate application: the branch above is the reproduction. Every
finding has a focused test in the
tree, and the table points at the spec that covers it. A reviewer can check
out the fork branch and run the
commands of *Steps To Reproduce*; the manual sections that document the
forms are the
`whereQueries`/`detachedCriteria`/`criteria` pages of the Hibernate manual,
and the migration note is in
`upgrading80x.adoc`, section 26.11 (*Criteria and Query Behaviour*), which
the branch adds.
### Version
8.0.x
### API and behaviour impact
The branch adds public API (binary compatible, with no removals or renames)
and documents the behaviour changes in the upgrade guide:
- New `default` methods: `Query.readOnly(boolean)` (no-op for datastores
without support) and
`QueryableCriteria.materializeLazyCriteria()` (returns `this` by default,
overridden by
`grails.gorm.DetachedCriteria`).
- New accessors:
`AbstractDetachedCriteria.getDefaultMax()`/`getDefaultOffset()` (needed by the
batch
pagination guard) and `HibernateQuery.getQueryFlushMode()` (the test of
the arguments map).
- `HibernateQuery` implements `QueryArgumentsAware`, so a criteria receives
the same settings map as the HQL
methods.
Behaviour changes that can make an application fail at startup, both
documented with the action to take in
`upgrading80x.adoc` section 26.11:
- a batch operation with `max`/`offset` now throws (it used to touch every
matching row);
- a `@SQLRestriction` declared on a subclass now fails at mapping (declare
it on the root entity).
### Open items (not changed by the branch)
- **F3** — `singleResult` with more than one row: fail loudly or return the
first row? A maintainer decision;
the branch pins the current shape with a test.
- **F5** — the Rx `DetachedCriteria` resolves with `OWNER_FIRST`; the module
is disabled in 8.0.x.
- **F7** — `createCriteria()` typing: no change needed (the generic methods
work inside the closure form and
the Hibernate/JPA-only ones cannot be shared); explained in the detailed
notes.
- **F24 / F25** — the aggregate forms on MongoDB/Neo4j and the TCK transform
are filed as separate issues of
this audit (the TCK issue explains why its where-query coverage is partly
vacuous today).
### Proposed plan
**Four pull requests**, one per subsystem, split from the branch so each
finding can be reviewed against the
failing test it fixes:
| PR | Theme (one subsystem) | Findings | Size |
|----|-----------------------|----------|------|
| 1 | the Hibernate 7 read translation | F1, F2, F4, F6, F13, F14, F15a,
F15b, F16, F22, F23, F23b, F26, F27 | ~25 files |
| 2 | the bulk write path | F8–F12, F20 | ~6 files |
| 3 | the binder annotations | F17, F17d | ~6 files |
| 4 | the data service `@Where` translation | F21 | 2 files |
Open them smallest first (4 → 3 → 2 → 1) so the maintainers can steer the
style before the core change. The
`What's New` entry for the features is in the branch (commit `0fbf51a26d`);
the two breaking behaviours are in
the Upgrade Notes (26.11). F24 and F25 are separate issues of this audit;
F3, F5 and F7 are not changed by the
branch.
### AI usage
The audit, the implementation and the tests were done with an AI coding
agent (OpenCode, model DeepSeek
V4.1 Flash), reviewed iteratively against the repository rules and the CI
gates. The pull requests will state
this explicitly, as CONTRIBUTING requires.
### Detailed notes
**F20 — the batch pagination.** `max(2)` on a criteria is read pagination
(`defaultMax`), and `deleteAll()`
builds an HQL bulk statement (`delete from Person where ...`), which has no
`LIMIT`. Before the branch the
builder never read `defaultMax`, so the call deleted every matching row and
returned the full count with no
warning (reproduced: 5 rows deleted with `max(2)`). The manual already
documents that batch operations reject
join queries and `sqlRestriction` with an exception; pagination is the same
kind of limitation, and the only
destructive one. The branch rejects it
(`InvalidDataAccessResourceUsageException`, naming `maxResults`/`offset`)
and documents it in `detachedCriteria.adoc`. A plain
`deleteAll()`/`updateAll()` without pagination is
unaffected. "Delete at most N" is not implemented because HQL has no
portable `LIMIT` on `delete`, and which N
rows would need an explicit order: today a caller selects the ids with an
order and deletes by
`inList('id', ids)`.
**F7 — `createCriteria()` typing (no change needed).** The 46 methods
`HibernateCriteriaBuilder` declares
beyond `BuildableCriteria`/`Criteria` are internal plumbing, six
Hibernate/JPA-only methods that cannot be shared
with Mongo/Neo4j, and the generic projection and pagination methods, which
work in `@CompileStatic` inside
`list { }`/`withCriteria { }` — the TCK's `StaticCompiledCriteriaQueries`
uses `maxResults`, `projections` and
`count` that way. Only the fluent style outside a closure needs a cast.
Putting the Hibernate-only methods on the
shared interface would force every datastore to implement them, so no code
change.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]