Skip to content

fix(policy): check row-dependent create policies for the whole batch in one query - #2900

Open
evgenovalov wants to merge 1 commit into
zenstackhq:devfrom
evgenovalov:fix/policy-batch-pre-create-check
Open

evgenovalov wants to merge 1 commit into
zenstackhq:devfrom
evgenovalov:fix/policy-batch-pre-create-check

Conversation

@evgenovalov

@evgenovalov evgenovalov commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

Problem

When a create policy references the row being inserted (e.g. @@allow('create', ownerId == auth().id)), createMany with N rows runs N sequential SELECT EXISTS(...) checks before the INSERT. #2842 (for #2841) only skips the check when the filter folds to true. In our app, a createMany of 30 000 rows took about 16 minutes at ~30 ms per round trip.

Cause

PolicyHandler.enforcePreCreatePolicy (policy-handler.ts:980) awaits enforcePreCreatePolicyForOne once per value row. Each call builds a one-row VALUES table and runs SELECT EXISTS(... WHERE <filter>) (:1062-1111).

Fix

Build one VALUES table with all the rows and compare the number of rows that satisfy the filter with the row count:

SELECT COUNT(1) = <n> AS $condition FROM (VALUES (...), (...)) AS <model> WHERE <filter>

The dialects' buildValuesTableSelect already supports many rows; the post-update check already uses it that way.

Counting keeps the per-row semantics exactly:

  • A row whose filter evaluates to NULL is not counted, so it rejects the batch, as EXISTS ... WHERE NULL did. A NOT EXISTS ... WHERE NOT <filter> check would let that row through.
  • Duplicate rows are counted one by one.
  • The comparison runs in SQL, so the driver-specific COUNT type never reaches JS. The result is read like every other $condition in the handler.

The error is unchanged (NO_ACCESS). A single create takes 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:

  • DEFAULT cells (rows with missing columns) were bound as a null parameter. They are now an inline NULL, like the fields that are not provided.
  • On Postgres, list values in fully provided rows were expanded to ARRAY[$1, $2, ...], one parameter per element. They are now bound as one parameter, as the INSERT does and as rows with DEFAULT cells 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:

  • The row-dependent case now asserts one policy check for a 5-row batch (it was one per row). One offending row in the middle of a batch still rejects the whole batch, and nothing is written.
  • New: a row whose rule evaluates to NULL (nullable ownerId) rejects the batch. This test fails against a NOT EXISTS ... WHERE NOT <filter> variant.

tests/e2e/orm/policy passes on SQLite, PostgreSQL and MySQL 8.4. tests/regression passes on SQLite and PostgreSQL; on MySQL 8.4 only issue-2841.test.ts was run.

🤖 Generated with Claude Code

https://claude.ai/code/session_01RwcqsRcacQHX18hXKbJN4n

Summary by CodeRabbit

  • Bug Fixes
    • Create operations containing multiple rows now apply policy checks consistently across the entire batch. The batch is rejected if any row fails the create policy, including when an owner ID is null.
    • Updated regression coverage verifies that row-dependent policies handle batches correctly.

…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
@coderabbitai

coderabbitai Bot commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review.

Recent review info
Run configuration
Commits

Reviewing files that changed from the base of the PR and between d44e489 and f4ea61e.

Files selected for processing: 2
  • packages/plugins/policy/src/policy-handler.ts
  • tests/regression/test/issue-2841.test.ts

Included review availability: This review used your included allowance. Your plan provides up to 8 included reviews per hour; 6 remain after this review.

Walkthrough

Ordinary create-row policy checks now evaluate a batch in one check. The handler still checks many-to-many join-table rows individually. Regression tests cover row-dependent filters and batches containing a null owner ID.

Changes: Batched create-policy checks

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 VALUES table, 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: Passed 5
Check name Status Explanation
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check Passed The title clearly and concisely describes the main change: evaluating row-dependent create policies for the full batch in one query.
Docstring Coverage Passed 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. Documentat…
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
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.

Share

This branch has not been deployed

No deployments
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