Skip to content

feat(spring-data): add Spring Data JPA query plan adapter#220

Draft
alexolivier wants to merge 6 commits into
mainfrom
spring-data
Draft

feat(spring-data): add Spring Data JPA query plan adapter#220
alexolivier wants to merge 6 commits into
mainfrom
spring-data

Conversation

@alexolivier

@alexolivier alexolivier commented May 18, 2026

Copy link
Copy Markdown
Contributor

Summary

New cerbos-spring-data adapter (0.1.0-alpha.1) that converts a Cerbos PlanResources response into a org.springframework.data.jpa.domain.Specification<T> for use with JpaSpecificationExecutor.

  • Supports @OneToMany, @ManyToMany, @ManyToOne, @ElementCollection, and arbitrarily deep nested relations via correlated EXISTS subqueries with explicit parent-query tracking through a sealed Scope interface.
  • AlwaysAllowed returns a Specification whose predicate is null (Spring Data's canonical "no restriction" signal — omits the WHERE clause entirely). AlwaysDenied returns cb.disjunction(). Conditional wraps the translated Specification; the lambda is re-invoked per query (including Spring Data's separate COUNT pass), so callers must not cache the produced Predicate.
  • spring-data-jpa and jakarta.persistence-api are declared compileOnly, so the published POM does not pin Jakarta Persistence on consumers — the consuming app's Spring Boot BOM controls versions (mirrors Spring Data JPA's treatment of hibernate-core as <optional>true</optional>).
  • Unsupported operators throw IllegalArgumentException with a message naming the operator. Consumers can register per-operator overrides via Map<String, OperatorFunction>.
  • CI workflow (spring-data.yaml) runs on JDK 17 and 21.
  • Marked alpha to invite feedback on AttributeMapping.field / AttributeMapping.relation before 1.0.

Operator coverage

Cerbos operator JPA Criteria translation
and / or / not cb.and / cb.or / cb.not
eq / ne cb.equal / cb.notEqual (auto isNull/isNotNull for null RHS)
lt / gt / le / ge cb.lessThan / greaterThan / lessThanOrEqualTo / greaterThanOrEqualTo
in path.in(values) or correlated EXISTS for collections
contains / startsWith / endsWith cb.like(...) with _/%/\ escaping (three-arg form)
isSet(field, true/false) cb.isNotNull / cb.isNull
hasIntersection(coll, [values]) Correlated EXISTS with IN
hasIntersection(coll.map(x, x.f), [values]) Correlated EXISTS with projected IN
size(coll) > 0 (and >= 1) Correlated EXISTS
size(coll) == 0 (and <= 0, < 1) NOT EXISTS
exists / exists_one / all / except / filter Correlated EXISTS / (SELECT COUNT) = 1 / NOT EXISTS(... AND NOT) / EXISTS(... AND NOT) / treated as exists
Bare boolean variable cb.equal(path, true)
eq(field, add(const, const)) Constant-fold, then cb.equal(field, folded)
eq(value, add(const, field)) Solve for field (string prefix/suffix strip; numeric subtract); unsolvable cases collapse to 1=0 / 1=1
DeMorgan/negation wrappers (not(and(...)), not(contains(...)), etc.) cb.not composes over every leaf handler

Not yet supported

Throw IllegalArgumentException with the operator name and (where applicable) a pointer at the supported alternative. Override via OperatorFunction to unblock per-dialect.

Construct Example CEL Why
Arithmetic (sub/mult/div/mod) and add outside eq/ne R.attr.aNumber + 1 > 2 Criteria API has no value-expression engine for column arithmetic.
Regex R.attr.aString.matches("^foo.*") No portable regex predicate in JPA; override per-dialect.
List indexing R.attr.tags[0] == "x" JPA collections are unordered.
Type casts (int(...) / double(...) / string(...)) int(R.attr.aString) > 0 No portable CAST in Criteria.
Ternary (R.attr.aBool ? R.attr.aNumber : 0) > 0 CEL emits this as if(cond, then, else); no CASE WHEN value-expression builder.
size(string) size(R.attr.aString) > 0 Only size(collection) is supported.
Field-to-field comparison R.attr.aString == R.attr.id Leaf operators require exactly one variable + one value operand.
eq(map(...), [...]) R.attr.tags.map(t, t.id) == ["tag1", "tag2"] Use hasIntersection(map(...), [...]).
size(filter(...)) <op> N size(R.attr.tags.filter(...)) > 0 Use exists(coll, lambda).
size(coll) <op> N for N > 0 size(R.attr.tags) > 5 Only emptiness checks.
Hierarchy operators hierarchy.overlaps(...) Not yet ported from the Prisma adapter.

Test coverage

161 tests across two suites against a real Cerbos PDP — Testcontainers by default, or an externally-managed PDP via CERBOS_HOST/CERBOS_PORT + docker-compose.yml for Prisma-style sidecar runs. Audit + decision logs stream to stdout so every PlanResources call is verifiable.

Throw-tests assert the exception message contains the operator name, so a future change can't silently regress to a less-helpful message or a different exception type.

Test plan

  • gradle test --rerun-tasks — 161/161 pass (JDK 17, ghcr.io/cerbos/cerbos:latest via Testcontainers)
  • ./scripts/run-e2e.sh — external-PDP mode (docker compose); confirm PlanResources calls served ≥ 150 in the audit summary
  • CI matrix passes on JDK 17 + 21
  • Sanity check: README quick-start snippet compiles against the published jar shape

Follow-ups

  • Three-level-nesting integration test (correlating the same Join into a nested subquery is spec-legal but lacks an authoritative Hibernate test fixture — confirm no cross-join with spring.jpa.show-sql=true).
  • MySQL/MariaDB LIKE collation note (Hibernate 6.4+ auto-escapes backslashes; consumers may need noBackslashEscapesEnabled=true or an OperatorFunction override for contains/startsWith/endsWith).
  • Hierarchy operators port from the Prisma adapter (~250 LoC).

Translates Cerbos PlanResources responses into Spring Data JPA
Specifications, mirroring the operator coverage of the Prisma adapter
(eq/ne/lt/gt/le/ge, in, contains/startsWith/endsWith, isSet,
hasIntersection, size, exists/exists_one/all/except/filter lambdas,
and add with constant folding/solving). Supports @onetomany,
@manytomany, @manytoone, @ElementCollection and deeply nested
relations via correlated subqueries.

Includes a full e2e test suite that runs against a real Cerbos PDP
container (Testcontainers by default, or an externally-managed
container via CERBOS_HOST/CERBOS_PORT + docker-compose), with audit
logs streamed to stdout so PlanResources calls are verifiable.

Published as 0.1.0-alpha.1 to invite feedback on the field/relation
mapping shapes before 1.0.

Signed-off-by: Alex Olivier <alex@alexolivier.me>
Add 54 tests (27 unit + 27 integration) pinning Spring Data adapter behaviour
against the cross-adapter scenarios merged today:

- DeMorgan/negation (PR #222): not-and, not-or, not-gt, not-lt, not-contains,
  not-starts-with — all natively supported via cb.not over existing handlers.
- CEL primitives (PR #223): empty-collection works via the existing size-as-
  emptiness path; arithmetic, regex, casts, ternary, list indexing and
  size(string) throw — no shape for them in the Criteria-based predicate
  builder. TODO(#223) for revisit.
- Minor operators (PR #234): is-not-set, equal-bool-false, in-number,
  or-leaf-exists supported; equal-field-to-field throws (adapter requires
  exactly one value operand).
- Collection macro composition (PR #235): all-nested supported via cb.not(
  EXISTS(NOT(...))); map(...) == [...] and size(filter(...)) > 0 throw — the
  former because the leaf handler rejects expression operands, the latter
  because trySizeComparison requires size's operand to be a Variable.

Total suite: 159/159 pass (gradle test, JDK 17, Testcontainers Cerbos PDP).

Signed-off-by: Alex Olivier <alex@alexolivier.me>
- **B1**: Move `spring-data-jpa` and `jakarta.persistence-api` to `compileOnly`
  so the consumer's Spring Boot BOM controls versions. The published POM no
  longer pins Jakarta Persistence 3.2.0 on every consumer (Boot 3.3 still
  ships 3.1). Matches Spring Data JPA's own treatment of `hibernate-core`
  as `<optional>true</optional>`. Tests pull both back in as
  `testImplementation`.

- **B2**: `Result.AlwaysAllowed.toSpecification()` now returns a Specification
  whose `toPredicate` returns `null` — the canonical no-restriction signal
  per `Specification.unrestricted()` / `SimpleJpaRepository`. Previously
  emitted `cb.conjunction()` (`WHERE 1=1`), which breaks composition via
  `.and(otherSpec)` and blocks some query-planner optimizations. New unit
  tests pin the null/non-null contract for AlwaysAllowed/AlwaysDenied.

- **H1**: Detect `eq(var, var)` explicitly and throw
  "Field-to-field comparison is not supported for operator 'eq': X vs Y"
  instead of the misleading "Missing value operand for eq".

- **H3**: When a `map(...)` expression appears as a leaf comparison operand
  (e.g. `eq(map(...), [...])`), throw with a hint pointing users at
  `hasIntersection(map(...), [...])` — matches Prisma's recently-fixed
  message in #235.

- **H4**: Standardise unsupported-operator wording to "Unsupported operator:"
  (was "Unknown operator:") to align with Prisma. ES-Java and SQLAlchemy
  should follow in separate PRs.

- **H2**: Throw-tests now assert message substrings (operator name +
  hint where relevant) via new `assertActionThrows` / `assertConditionThrows`
  helpers. Stops silent regressions to less-helpful messages or different
  exception types.

Also updates the `ternary` test op name from "conditional" to "if" — the
CEL planner emits `if(cond, then, else)` in the AST.

Tests: 161/161 pass (gradle test --rerun-tasks, JDK 17, Testcontainers PDP).

Signed-off-by: Alex Olivier <alex@alexolivier.me>
- **N1** (README): "Not yet supported" table enumerating the gaps surfaced by
  this PR's test suite — arithmetic, regex, list indexing, casts, ternary
  (`if`), `size(string)`, field-to-field eq, `eq(map(...), [...])`,
  `size(filter(...))`, non-emptiness `size(coll) > N`, hierarchy ops. Names
  the override path (`OperatorFunction`) for each so consumers know how to
  unblock themselves per dialect.

- **H5** (Result.Conditional Javadoc): warn that the wrapped Specification is
  re-invoked per query (Spring Data's separate COUNT pass under
  `findAll(spec, Pageable)` uses a different Root); callers must never cache
  the produced Predicate. Without this Hibernate 6 throws
  `SqlTreeCreationException: Could not locate TableGroup`.

- **N3** (chain sentinel): swap `__chain__` → `$$chain$$` for the internal
  lambda variable name in `chainedExistsSubquery`. `$` is not a valid CEL
  identifier character, so the sentinel can never collide with a
  user-supplied lambda name even under a future refactor that calls
  `resolvePath` on the intermediate scope.

- **N4** (`protoValueToJava`): handle `KIND_NOT_SET` explicitly with an
  actionable message ("Protobuf Value has no kind set — the planner emitted
  a malformed operand") instead of falling through to the generic default.

- **N5** (`foldAdd`): pre-check both operands for `null` so the error path
  emits "add requires non-null operands" instead of NPE'ing on
  `.getClass()`.

Tests: 161/161 still pass.

Signed-off-by: Alex Olivier <alex@alexolivier.me>
Signed-off-by: Alex Olivier <alex@alexolivier.me>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant