Skip to content

feat(database): relation filters some/none/every and relation counts - #116

Merged
Makisuo merged 11 commits into
MapleTechLabs:mainfrom
artile:feat/relation-filters
Nov 2, 2026
Merged

Makisuo merged 11 commits into
MapleTechLabs:mainfrom
artile:feat/relation-filters

Conversation

@artile

@artile artile commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

findMany and the builder cannot filter rows by their related rows or count related rows. Prisma code uses _count and some / every / none a lot, and Drizzle needs raw SQL for most of them.

Closes #80.
Part of #52.

Depends on #110; review only the commits after it.

Changes

  • Db.related(schema.table, $) gives the relations of a table on the $ of a builder query. Each relation has some(where?), none(where?), every(where) and count(where?).
  • some is EXISTS (…), none is NOT EXISTS (…), every is NOT EXISTS (… AND NOT (where)). The subquery reads the target under the alias <outer alias>__<relation>, so a self-relation works. Filters nest: the where of a filter gets its own r.
  • count(where?) is an Expr<number>: coalesce((SELECT count(*) …), 0). It works in a select and in a where.
  • findMany's where (at every level) gets the same object as its second argument: where: ($, r) => [r.posts.some()].
  • _count: { posts: true | { where } } in findMany adds _count: { posts: number } to the rows of that level. It keeps feat(database): relational reads with defineRelations and findMany #110's loading model: one grouped statement per counted relation, SELECT <to>, count(*) … WHERE <to> IN (<keys>) GROUP BY <to>, with the same tenant pin as a loaded level. Only many relations can be counted (type error otherwise).
  • Tenant scope: when both tables declare a tenant column, the subquery also gets <inner tenant> = <outer tenant>. A small change in compile.ts gives such a subquery the binding of the outer tenant column, so a filter under $.orgId.eq("a") stays "single-tenant". Only the relation filters set this internal mark, and they always write the equality. A filter does not scope an unscoped outer query. A filter from an untenanted table into a tenanted one is "cross-tenant" unless its own where pins the tenant.
  • A filter on an unqualified accessor (an update or a delete) dies with a QueryBuilderDefect, so it never correlates a column to itself.
  • Docs: new sections in docs/relations.md, section 6 in design/relations.md, docs/reference.md, one changeset.

ClickHouse (tested live on 26.8.2.7)

  • some, none, every, and nested filters work (correlated subqueries are on by default in 26.8).
  • count() in a where works.
  • count() in a select next to a where works only when the outer columns it correlates on are selected too. Otherwise ClickHouse fails with NOT_IMPLEMENTED ("can't find correlated column … in current header"). The docs say this, and the test selects the key and tenant columns.
  • A correlated count() over no rows returns NULL on ClickHouse, not 0. The coalesce handles it.
  • _count in findMany needs no correlation, so it works on every version.

Left out

  • _count of a one relation: r.author.some() says the same.
  • Relation filters in update / delete: their accessor is not qualified, so there is no alias to correlate on. Refused with a defect.
  • Ordering by a relation count (Prisma orderBy: { posts: { _count: "desc" } }). On a builder query, select count() and order by its alias.
  • SQLite, MySQL, D1: not on main. The SQL is plain EXISTS and count(*), so no dialect work is expected.

Validation

Run on the head commit against a live ClickHouse 26.8.2.7 (the CI matrix version), with EFFECT_ORM_CLICKHOUSE_URL set, so no suite is skipped:

  • bun run build: pass.
  • bun run typecheck: pass.
  • bunx vitest run --testTimeout=60000 --hookTimeout=60000: pass. Test files: 64 passed (64). Tests: 1065 passed (1065).
  • node scripts/check-doc-citations.mjs, check-exports-documented.mjs, check-doc-examples.mjs: pass.
  • bun scripts/check-package.ts (tarball, types, CommonJS consumer): pass.
  • The longer timeouts are only for the local machine, which was under load.
  • Upstream CI for fork PRs waits for a maintainer to approve the workflow run.

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

Correlated EXISTS / NOT EXISTS conditions and a correlated count(*) over a
declared relation, on the builder (Db.related) and in findMany's where.
findMany's _count counts a many relation with one grouped statement per
level. A subquery that correlates on the outer tenant column takes that
column's tenant binding.
@coderabbitai

coderabbitai Bot commented Oct 25, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 46 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 4553ba6e-27e3-4674-9ae8-789a65449e5b
Commits

Reviewing files that changed from the base of the PR and between b57faf1 and 95e5240.

Files selected for processing: 18
  • .changeset/relation-filters.md
  • .changeset/relational-reads.md
  • README.md
  • design/relations.md
  • docs/README.md
  • docs/reference.md
  • docs/relations.md
  • src/ch/compile.ts
  • src/ch/tenant.ts
  • src/database.ts
  • src/database/relation-filters.test-d.ts
  • src/database/relation-filters.test.ts
  • src/database/relations.test-d.ts
  • src/database/relations.test.ts
  • src/database/relations.ts
  • src/sqlite/relations.test.ts
  • tests/relation-filters.clickhouse.test.ts
  • tests/relations.clickhouse.test.ts
  • 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

Makisuo and others added 4 commits November 2, 2026 16:33
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…imit

A level with more keys than D1's 100 bindings failed to compile. Each level
now sends one IN statement per batch that fits maxParameters next to its
other bindings. Adds SQLite and D1 coverage and updates the docs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in origin/main and MapleTechLabs#110's IN-list batching. The count level and the
loaded level now share runBatched, so _count is split to the dialect's
bound-value limit too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Correlated EXISTS, count() and _count run on node:sqlite inside the outer
tenant, and a 250-key _count level on D1 splits into statements of at most
100 bindings. Docs and changeset name SQLite and D1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Makisuo

Makisuo commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

Brought up to date for merge (head 95e5240). This depends on #110. Merge #110 first. This branch contains #110's updated head, so once #110 lands the diff shows only this PR's own work.

  • Merged the updated feat/relations (feat(database): relational reads with defineRelations and findMany #110 + origin/main) into this branch. I resolved the conflicts in src/database/relations.ts and design/relations.md. feat(database): relational reads with defineRelations and findMany #110's batching is now a shared runBatched helper used by both loaded levels and _count levels. The grouped _count statement is therefore split to D1's 100 bindings as well; before, it put every key into one IN.
  • SQLite/D1 coverage (added to src/sqlite/relations.test.ts): some, none, every, _count, and Db.related(...).count() in a builder select all run on node:sqlite with requireTenantScope. A row from another tenant is kept out of every filter and count. A 250-key _count with a correlated some(...) filter on local D1 (Miniflare) sends 4 statements, each binding ≤ 100 values. The correlated EXISTS/count SQL and the compile.ts tenant mark needed no SQLite-specific changes.
  • Docs/changeset now say the feature works on SQLite and D1. The "not on main" notes are gone.

Verified locally: bun run build, bun run typecheck, and bun run test against a live ClickHouse 26.8 (78 files / 1465 tests, plus the doc checks) all pass. tests/relations.clickhouse.test.ts and tests/relation-filters.clickhouse.test.ts ran live: 5/5 passed. FUZZ_SCALE=0.3 vitest run tests/fuzz passed (269/269).

CI run 41473306346: 26.2 passed. On 26.8 the only failure was "compiled row codec retains generated values (postgres)", the known codec-fuzz flake fixed in #131. It is unrelated to this PR.

Left for the maintainer: please review the markCorrelatedTenant change to tenant-scope analysis in src/ch/compile.ts, since it is the security-relevant part. Also, every(where) is NOT EXISTS (… AND NOT (where)), so a related row whose where evaluates to NULL counts as satisfying every. This is SQL three-valued logic, the same as Prisma.

@Makisuo
Makisuo merged commit 1890e6e into MapleTechLabs:main Nov 2, 2026
2 of 3 checks passed
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.

Relation counts and filters: _count, some, every, none

2 participants