feat(spring-data): add Spring Data JPA query plan adapter#220
Draft
alexolivier wants to merge 6 commits into
Draft
feat(spring-data): add Spring Data JPA query plan adapter#220alexolivier wants to merge 6 commits into
alexolivier wants to merge 6 commits into
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
New
cerbos-spring-dataadapter (0.1.0-alpha.1) that converts a CerbosPlanResourcesresponse into aorg.springframework.data.jpa.domain.Specification<T>for use withJpaSpecificationExecutor.@OneToMany,@ManyToMany,@ManyToOne,@ElementCollection, and arbitrarily deep nested relations via correlatedEXISTSsubqueries with explicit parent-query tracking through a sealedScopeinterface.AlwaysAllowedreturns aSpecificationwhose predicate isnull(Spring Data's canonical "no restriction" signal — omits theWHEREclause entirely).AlwaysDeniedreturnscb.disjunction().Conditionalwraps the translated Specification; the lambda is re-invoked per query (including Spring Data's separate COUNT pass), so callers must not cache the producedPredicate.spring-data-jpaandjakarta.persistence-apiare declaredcompileOnly, 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 ofhibernate-coreas<optional>true</optional>).IllegalArgumentExceptionwith a message naming the operator. Consumers can register per-operator overrides viaMap<String, OperatorFunction>.spring-data.yaml) runs on JDK 17 and 21.AttributeMapping.field/AttributeMapping.relationbefore 1.0.Operator coverage
and/or/notcb.and/cb.or/cb.noteq/necb.equal/cb.notEqual(autoisNull/isNotNullfornullRHS)lt/gt/le/gecb.lessThan/greaterThan/lessThanOrEqualTo/greaterThanOrEqualToinpath.in(values)or correlatedEXISTSfor collectionscontains/startsWith/endsWithcb.like(...)with_/%/\escaping (three-arg form)isSet(field, true/false)cb.isNotNull/cb.isNullhasIntersection(coll, [values])EXISTSwithINhasIntersection(coll.map(x, x.f), [values])EXISTSwith projectedINsize(coll) > 0(and>= 1)EXISTSsize(coll) == 0(and<= 0,< 1)NOT EXISTSexists/exists_one/all/except/filterEXISTS/(SELECT COUNT) = 1/NOT EXISTS(... AND NOT)/EXISTS(... AND NOT)/ treated asexistscb.equal(path, true)eq(field, add(const, const))cb.equal(field, folded)eq(value, add(const, field))1=0/1=1not(and(...)),not(contains(...)), etc.)cb.notcomposes over every leaf handlerNot yet supported
Throw
IllegalArgumentExceptionwith the operator name and (where applicable) a pointer at the supported alternative. Override viaOperatorFunctionto unblock per-dialect.sub/mult/div/mod) andaddoutsideeq/neR.attr.aNumber + 1 > 2R.attr.aString.matches("^foo.*")R.attr.tags[0] == "x"int(...)/double(...)/string(...))int(R.attr.aString) > 0CASTin Criteria.(R.attr.aBool ? R.attr.aNumber : 0) > 0if(cond, then, else); noCASE WHENvalue-expression builder.size(string)size(R.attr.aString) > 0size(collection)is supported.R.attr.aString == R.attr.ideq(map(...), [...])R.attr.tags.map(t, t.id) == ["tag1", "tag2"]hasIntersection(map(...), [...]).size(filter(...)) <op> Nsize(R.attr.tags.filter(...)) > 0exists(coll, lambda).size(coll) <op> NforN > 0size(R.attr.tags) > 5hierarchy.overlaps(...)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.ymlfor Prisma-style sidecar runs. Audit + decision logs stream to stdout so everyPlanResourcescall is verifiable.SpringDataQueryPlanAdapterTest— unit tests that build protobuf operands directly and run them against an empty H2 schema (catches mapping/type errors via Hibernate SQL generation).SpringDataIntegrationTest— integration tests against the policy actions in/policies/resource.yaml, including the cross-adapter scenarios from test: add DeMorgan/negation query plan scenarios across adapters #222 (DeMorgan), feat: CEL arithmetic, regex, casts, ternary, size across adapters #223 (CEL primitives), test: add minor operator/comparison shapes across adapters #234 (minor operator shapes), and test: collection macro composition (all-nested, map-compared, filter-count-gt) #235 (collection macro composition).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:latestvia Testcontainers)./scripts/run-e2e.sh— external-PDP mode (docker compose); confirmPlanResources calls served≥ 150 in the audit summaryFollow-ups
Joininto a nested subquery is spec-legal but lacks an authoritative Hibernate test fixture — confirm no cross-join withspring.jpa.show-sql=true).noBackslashEscapesEnabled=trueor anOperatorFunctionoverride forcontains/startsWith/endsWith).