Repository navigation
fix(policy): check row-dependent create policies for the whole batch in one query - #2900
evgenovalov wants to merge 1 commit into
Conversation
…in one query When the create policy references the inserted row, the pre-create check ran one SELECT EXISTS(...) per row (zenstackhq#2842 only skipped the constant case), so createMany of N rows cost N sequential round trips. Check all rows in one query by counting the rows that satisfy the filter and comparing the count with the number of rows. A row whose filter evaluates to NULL still rejects the batch. DEFAULT cells and Postgres list values are now bound the way the INSERT binds them, so the batched check stays within the INSERT's bind parameter budget. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RwcqsRcacQHX18hXKbJN4n
|
No actionable comments were generated in the recent review.
|
| Layer / File(s) | Summary |
|---|---|
Batch ordinary create-policy enforcement packages/plugins/policy/src/policy-handler.ts |
The handler checks ordinary create rows together and allows the insert only when every row matches the filter. Omitted values use NULL, and primitive arrays are passed as ValueNodes. Many-to-many checks remain per row. |
Regression coverage for batch checks tests/regression/test/issue-2841.test.ts |
The tests verify one policy check for a five-row batch, rejection when one row fails the filter, and rejection of a batch with a null owner ID. |
Estimated code review effort: 3 (Moderate) | ~20 minutes
Change: Bug fix
Possibly related PRs
- zenstackhq/zenstack#2897: Both changes use the create-input
VALUEStable, including its handling of array values in create-policy checks.
Suggested reviewers: ymc9, bgowers
Merge Risk: 
Minimal · up to f4ea6
-
The batch policy change is mergeable after normal checks; no material issue was established.
Pre-merge checks: 
5
-
Full details: Docstring Coverage
-
Explanation
No eligible functions found in the changed files. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff with cyclomatic complexity of at least 2. Documentation-only edits and deletions within surviving functions are included; unchanged diff context and fully deleted functions are excluded. Analyzed 0 functions across 2 files.
-
Finishing Touches
-
Generate unit tests (beta)
-
- Create a new PR
-
- Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.
Problem
When a create policy references the row being inserted (e.g.
@@allow('create', ownerId == auth().id)),createManywith N rows runs N sequentialSELECT EXISTS(...)checks before the INSERT. #2842 (for #2841) only skips the check when the filter folds totrue. In our app, acreateManyof 30 000 rows took about 16 minutes at ~30 ms per round trip.Cause
PolicyHandler.enforcePreCreatePolicy(policy-handler.ts:980) awaitsenforcePreCreatePolicyForOneonce per value row. Each call builds a one-rowVALUEStable and runsSELECT EXISTS(... WHERE <filter>)(:1062-1111).Fix
Build one
VALUEStable with all the rows and compare the number of rows that satisfy the filter with the row count:The dialects'
buildValuesTableSelectalready supports many rows; the post-update check already uses it that way.Counting keeps the per-row semantics exactly:
EXISTS ... WHERE NULLdid. ANOT EXISTS ... WHERE NOT <filter>check would let that row through.COUNTtype never reaches JS. The result is read like every other$conditionin the handler.The error is unchanged (
NO_ACCESS). A singlecreatetakes the same path with one row. Inserts into implicit many-to-many join tables are still checked per row.Because the check now carries the whole batch, it has to fit the same bind-parameter limit as the INSERT (the ORM does not chunk
createMany). Two cell encodings bound more parameters than the INSERT does; they no longer do:DEFAULTcells (rows with missing columns) were bound as anullparameter. They are now an inlineNULL, like the fields that are not provided.ARRAY[$1, $2, ...], one parameter per element. They are now bound as one parameter, as the INSERT does and as rows withDEFAULTcells already did. The values table casts the column to its list type.Measured: the check now binds exactly the INSERT's parameters plus the filter's own. For 5 000 rows that is 20 002 vs 20 000 on Postgres, and 15 001 vs 15 000 on SQLite and MySQL. Before this change, a
String[]value with 3 elements cost 3 parameters per row in the check and 1 in the INSERT.Tests
tests/regression/test/issue-2841.test.ts:ownerId) rejects the batch. This test fails against aNOT EXISTS ... WHERE NOT <filter>variant.tests/e2e/orm/policypasses on SQLite, PostgreSQL and MySQL 8.4.tests/regressionpasses on SQLite and PostgreSQL; on MySQL 8.4 onlyissue-2841.test.tswas run.🤖 Generated with Claude Code
https://claude.ai/code/session_01RwcqsRcacQHX18hXKbJN4n
Summary by CodeRabbit
null.