Skip to content

feat(db): add caseWhen query operator#1536

Open
samwillis wants to merge 14 commits into
mainfrom
case-when
Open

feat(db): add caseWhen query operator#1536
samwillis wants to merge 14 commits into
mainfrom
case-when

Conversation

@samwillis
Copy link
Copy Markdown
Collaborator

@samwillis samwillis commented May 18, 2026

Summary

  • Adds caseWhen(...) as a unified conditional query helper inspired by SQL CASE WHEN, with scalar expression support in select, where, orderBy, groupBy, having, and equality join operands.
  • Adds projection-valued caseWhen support for select() branch values, including nested projection objects, ref spreads, toArray(...), and Collection includes.
  • Implements guarded include routing so includes inside inactive caseWhen branches are not materialized for that parent row.
  • Documents caseWhen in the live query guide and adds a changeset for the new operator.

Why This Is Useful

caseWhen lets queries express conditional logic without dropping down to functional callbacks, so the query compiler can keep optimizing and incrementally maintaining the result.

A common scalar use case is categorizing rows while preserving literal result types:

const userCategories = createLiveQueryCollection((q) =>
  q
    .from({ user: usersCollection })
    .select(({ user }) => ({
      id: user.id,
      category: caseWhen(
        gt(user.age, 65),
        'senior',
        gt(user.age, 18),
        'adult',
        'minor',
      ),
    }))
)

Because scalar caseWhen is a normal query expression, it can also drive filtering, grouping, sorting, and join operands:

const activeFirst = createLiveQueryCollection((q) =>
  q
    .from({ user: usersCollection })
    .where(({ user }) => caseWhen(eq(user.deleted, true), false, true))
    .orderBy(({ user }) => caseWhen(eq(user.active, true), 0, 1))
    .select(({ user }) => ({
      id: user.id,
      name: user.name,
    }))
)

Projection-valued branches make optional result shapes easier to model. For example, a joined row can project an optional nested object only when the joined source exists:

const usersWithOptionalPost = createLiveQueryCollection((q) =>
  q
    .from({ user: usersCollection })
    .leftJoin({ post: postsCollection }, ({ user, post }) =>
      eq(user.id, post.userId),
    )
    .select(({ user, post }) => ({
      id: user.id,
      post: caseWhen(post, {
        title: post.title,
      }),
    }))
)

It also enables guarded includes, where child collections are materialized only for rows whose branch is active:

const adultProfiles = createLiveQueryCollection((q) =>
  q
    .from({ user: usersCollection })
    .select(({ user }) => ({
      id: user.id,
      adultProfile: caseWhen(gt(user.age, 18), {
        ...user,
        posts: q
          .from({ post: postsCollection })
          .where(({ post }) => eq(post.userId, user.id))
          .select(({ post }) => ({
            title: post.title,
          })),
      }),
    }))
)

Design

caseWhen is one public API with two internal paths selected by branch value shape, not call location:

  • When every branch/default value is expression-like, the builder lowers to a scalar Func('caseWhen', ...). The evaluator walks condition/value pairs left to right, returns the first matching branch, evaluates only the active value branch, and returns null when there is no default.
  • When any branch/default value is select-only, the builder returns a CaseWhenWrapper that is lowered by select() into ConditionalSelect IR. The select compiler evaluates branch conditions lazily and returns undefined when there is no matching projection branch and no default.
  • Includes under ConditionalSelect branches carry guard metadata through include extraction/routing. Parent keys are emitted only when the guard chain matches, which keeps inactive branch includes from loading or attaching.

Full design notes: https://gist.github.com/samwillis/60d8d0d937cf23267d0f8383bea3af92

Test plan

  • pnpm exec vitest --run tests/query/case-when.test.ts tests/query/case-when.test-d.ts from packages/db
  • pnpm test from packages/db
  • pnpm test from the repo root was also run; packages/db passed, but the workspace run failed in unrelated @tanstack/expo-db-sqlite-persistence type-checking for untracked tests/helpers/expo-runtime-node-preload.cjs.

Made with Cursor

Summary by CodeRabbit

  • New Features

    • Added caseWhen() conditional expressions for scalar and object projections, usable in selects, filters, grouping, and joins.
    • Query engine now models conditional selects so branches and defaults are compiled and evaluated predictably.
  • Includes / Materialization

    • Guarded includes: nested includes are materialized only when branch guards match; nested result paths supported.
  • Tests

    • Added comprehensive type- and runtime tests covering branching, nested includes, grouping, and joins.

Review Change Stack

Made with Cursor

samwillis and others added 11 commits May 17, 2026 12:13
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 18, 2026

📝 Walkthrough

Walkthrough

This PR introduces the caseWhen conditional expression operator to the query builder. It adds a public API with type-safe overloads, an intermediate representation (ConditionalSelect), compilation support for expressions and select projections, include materialization for nested paths, and comprehensive type/runtime tests.

Changes

caseWhen Conditional Expression Feature

Layer / File(s) Summary
caseWhen API with type inference
packages/db/src/query/builder/functions.ts, packages/db/src/query/builder/types.ts, packages/db/src/query/index.ts
Public caseWhen function with 10+ TypeScript overloads, CaseWhenWrapper runtime wrapper, and type utilities (CaseWhenValue, ResultTypeFromCaseWhen) that infer result types based on branch structure and default presence.
ConditionalSelect IR
packages/db/src/query/ir.ts
New IR types ConditionalSelectBranch and SelectValueExpression, class ConditionalSelect with branches and optional default, and isExpressionLike updated to recognize conditional selects as expressions.
Select/projection compilation
packages/db/src/query/builder/index.ts, packages/db/src/query/compiler/select.ts
Builder-level detection of CaseWhenWrapper via buildNestedSelect delegates to buildConditionalSelect, which partitions arguments into condition/value pairs and constructs ConditionalSelect IR. Compiler adds compileConditionalSelect helper that evaluates branches per row.
Expression evaluation
packages/db/src/query/compiler/evaluators.ts, packages/db/src/query/builder/ref-proxy.ts
Runtime caseWhen operator evaluates condition/value pairs, returns first matching branch or default. Custom isCaseWhenConditionTrue helper implements CASE-WHEN-like truthiness (zero/NaN/null/false are false, others per context). Validation prevents caseWhen projections inside expression contexts.
Aggregate detection
packages/db/src/query/compiler/group-by.ts
containsAggregate extended to recursively traverse ConditionalSelect branches and detect aggregates in both conditions and values; extraction/transformation and grouped-compile helpers added.
Conditional includes
packages/db/src/query/compiler/index.ts
Include extraction collects IncludesSubquery from inside ConditionalSelect branches, recording guard conditions. Compilation produces per-include SKIP_INCLUDE markers and guarded routing functions. Include replacement traverses path arrays and recurses into conditional branches.
Live query materialization
packages/db/src/query/live/collection-config-builder.ts
IncludesOutputState carries resultPath array instead of single field. New setNestedValue helper creates intermediate objects and sets values at nested paths. Parent includes and updates use path-based assignment for correct nested structure.
Query traversal
packages/db/src/query/live/utils.ts
extractCollectionsFromQuery and extractCollectionAliases recognize and traverse ConditionalSelect nodes, extracting collections and aliases from branch values and defaults.
Type tests
packages/db/tests/query/case-when.test-d.ts
Validates caseWhen type inference for scalar/variadic branches, conditional projections, nested includes, join contexts, and optional/union behavior with and without defaults.
Runtime tests
packages/db/tests/query/case-when.test.ts
Covers scalar selection, short-circuiting, expression contexts (where/orderBy/groupBy/having), conditional object projections, join scenarios, error cases, and nested include materialization across multiple branch types.
changeset
.changeset/tender-mugs-hear.md
Patch changeset documenting the new caseWhen operator and release notes.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

A rabbit hops through conditional branches,
Plucking caseWhen from SQL ranches,
Each path materialized just right,
With nested values shining bright,
Query projections take their flight! 🐰✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 28.57% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and concisely summarizes the main change: adding a new caseWhen query operator to the database package.
Description check ✅ Passed The PR description provides comprehensive coverage of changes, use cases, design decisions, and testing approach. Both required checklist items are addressed and marked.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch case-when

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

Comment @coderabbitai help to get the list of available commands and usage tips.

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 18, 2026

More templates

@tanstack/angular-db

npm i https://pkg.pr.new/@tanstack/angular-db@1536

@tanstack/browser-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/browser-db-sqlite-persistence@1536

@tanstack/capacitor-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/capacitor-db-sqlite-persistence@1536

@tanstack/cloudflare-durable-objects-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/cloudflare-durable-objects-db-sqlite-persistence@1536

@tanstack/db

npm i https://pkg.pr.new/@tanstack/db@1536

@tanstack/db-ivm

npm i https://pkg.pr.new/@tanstack/db-ivm@1536

@tanstack/db-sqlite-persistence-core

npm i https://pkg.pr.new/@tanstack/db-sqlite-persistence-core@1536

@tanstack/electric-db-collection

npm i https://pkg.pr.new/@tanstack/electric-db-collection@1536

@tanstack/electron-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/electron-db-sqlite-persistence@1536

@tanstack/expo-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/expo-db-sqlite-persistence@1536

@tanstack/node-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/node-db-sqlite-persistence@1536

@tanstack/offline-transactions

npm i https://pkg.pr.new/@tanstack/offline-transactions@1536

@tanstack/powersync-db-collection

npm i https://pkg.pr.new/@tanstack/powersync-db-collection@1536

@tanstack/query-db-collection

npm i https://pkg.pr.new/@tanstack/query-db-collection@1536

@tanstack/react-db

npm i https://pkg.pr.new/@tanstack/react-db@1536

@tanstack/react-native-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/react-native-db-sqlite-persistence@1536

@tanstack/rxdb-db-collection

npm i https://pkg.pr.new/@tanstack/rxdb-db-collection@1536

@tanstack/solid-db

npm i https://pkg.pr.new/@tanstack/solid-db@1536

@tanstack/svelte-db

npm i https://pkg.pr.new/@tanstack/svelte-db@1536

@tanstack/tauri-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/tauri-db-sqlite-persistence@1536

@tanstack/trailbase-db-collection

npm i https://pkg.pr.new/@tanstack/trailbase-db-collection@1536

@tanstack/vue-db

npm i https://pkg.pr.new/@tanstack/vue-db@1536

commit: 901eb6e

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 18, 2026

Size Change: +2.86 kB (+2.5%)

Total Size: 117 kB

📦 View Changed
Filename Size Change
packages/db/dist/esm/index.js 3.01 kB +15 B (+0.5%)
packages/db/dist/esm/query/builder/functions.js 1.33 kB +410 B (+44.61%) 🚨
packages/db/dist/esm/query/builder/index.js 5.54 kB +285 B (+5.43%) 🔍
packages/db/dist/esm/query/builder/ref-proxy.js 1.22 kB +16 B (+1.33%)
packages/db/dist/esm/query/compiler/evaluators.js 1.81 kB +191 B (+11.8%) ⚠️
packages/db/dist/esm/query/compiler/group-by.js 3.35 kB +656 B (+24.37%) 🚨
packages/db/dist/esm/query/compiler/index.js 4.8 kB +670 B (+16.21%) ⚠️
packages/db/dist/esm/query/compiler/select.js 1.42 kB +306 B (+27.54%) 🚨
packages/db/dist/esm/query/ir.js 899 B +70 B (+8.44%) 🔍
packages/db/dist/esm/query/live/collection-config-builder.js 8.01 kB +131 B (+1.66%)
packages/db/dist/esm/query/live/utils.js 1.75 kB +106 B (+6.47%) 🔍
ℹ️ View Unchanged
Filename Size
packages/db/dist/esm/collection/change-events.js 1.39 kB
packages/db/dist/esm/collection/changes.js 1.38 kB
packages/db/dist/esm/collection/cleanup-queue.js 810 B
packages/db/dist/esm/collection/events.js 434 B
packages/db/dist/esm/collection/index.js 3.61 kB
packages/db/dist/esm/collection/indexes.js 1.99 kB
packages/db/dist/esm/collection/lifecycle.js 1.69 kB
packages/db/dist/esm/collection/mutations.js 2.47 kB
packages/db/dist/esm/collection/state.js 5.26 kB
packages/db/dist/esm/collection/subscription.js 3.74 kB
packages/db/dist/esm/collection/sync.js 2.88 kB
packages/db/dist/esm/collection/transaction-metadata.js 144 B
packages/db/dist/esm/deferred.js 207 B
packages/db/dist/esm/errors.js 4.92 kB
packages/db/dist/esm/event-emitter.js 748 B
packages/db/dist/esm/indexes/auto-index.js 830 B
packages/db/dist/esm/indexes/base-index.js 729 B
packages/db/dist/esm/indexes/basic-index.js 2.05 kB
packages/db/dist/esm/indexes/btree-index.js 2.17 kB
packages/db/dist/esm/indexes/index-registry.js 820 B
packages/db/dist/esm/indexes/reverse-index.js 538 B
packages/db/dist/esm/local-only.js 890 B
packages/db/dist/esm/local-storage.js 2.1 kB
packages/db/dist/esm/optimistic-action.js 359 B
packages/db/dist/esm/paced-mutations.js 496 B
packages/db/dist/esm/proxy.js 3.75 kB
packages/db/dist/esm/query/compiler/expressions.js 430 B
packages/db/dist/esm/query/compiler/joins.js 2.34 kB
packages/db/dist/esm/query/compiler/order-by.js 1.72 kB
packages/db/dist/esm/query/effect.js 4.78 kB
packages/db/dist/esm/query/expression-helpers.js 1.43 kB
packages/db/dist/esm/query/live-query-collection.js 360 B
packages/db/dist/esm/query/live/collection-registry.js 264 B
packages/db/dist/esm/query/live/collection-subscriber.js 1.95 kB
packages/db/dist/esm/query/live/internal.js 145 B
packages/db/dist/esm/query/optimizer.js 2.62 kB
packages/db/dist/esm/query/predicate-utils.js 2.97 kB
packages/db/dist/esm/query/query-once.js 359 B
packages/db/dist/esm/query/subset-dedupe.js 960 B
packages/db/dist/esm/scheduler.js 1.3 kB
packages/db/dist/esm/SortedMap.js 1.3 kB
packages/db/dist/esm/strategies/debounceStrategy.js 247 B
packages/db/dist/esm/strategies/queueStrategy.js 428 B
packages/db/dist/esm/strategies/throttleStrategy.js 246 B
packages/db/dist/esm/transactions.js 2.9 kB
packages/db/dist/esm/utils.js 927 B
packages/db/dist/esm/utils/array-utils.js 273 B
packages/db/dist/esm/utils/browser-polyfills.js 304 B
packages/db/dist/esm/utils/btree.js 5.61 kB
packages/db/dist/esm/utils/comparison.js 1.05 kB
packages/db/dist/esm/utils/cursor.js 457 B
packages/db/dist/esm/utils/index-optimization.js 1.54 kB
packages/db/dist/esm/utils/type-guards.js 157 B
packages/db/dist/esm/virtual-props.js 360 B

compressed-size-action::db-package-size

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 18, 2026

Size Change: 0 B

Total Size: 4.24 kB

ℹ️ View Unchanged
Filename Size
packages/react-db/dist/esm/index.js 249 B
packages/react-db/dist/esm/useLiveInfiniteQuery.js 1.32 kB
packages/react-db/dist/esm/useLiveQuery.js 1.34 kB
packages/react-db/dist/esm/useLiveQueryEffect.js 355 B
packages/react-db/dist/esm/useLiveSuspenseQuery.js 567 B
packages/react-db/dist/esm/usePacedMutations.js 401 B

compressed-size-action::react-db-package-size

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (5)
packages/db/tests/query/case-when.test-d.ts (1)

31-49: ⚡ Quick win

Add explicit return types to test helper factories.

Line 31 and Line 41 rely on inferred return types; please annotate both function return types explicitly to keep type tests stable against inference drift.

Proposed diff
-function createUsers() {
+function createUsers(): ReturnType<typeof createCollection<User>> {
   return createCollection(
     mockSyncCollectionOptions<User>({
       id: `case-when-type-users`,
       getKey: (user) => user.id,
       initialData: [],
     }),
   )
 }
 
-function createPosts() {
+function createPosts(): ReturnType<typeof createCollection<Post>> {
   return createCollection(
     mockSyncCollectionOptions<Post>({
       id: `case-when-type-posts`,
       getKey: (post) => post.id,
       initialData: [],
     }),
   )
 }

As per coding guidelines, "**/*.{ts,tsx}: Provide proper type annotations for function return values instead of relying on implicit types" and "Provide precise return types for functions; avoid unknownorany return types unless absolutely necessary".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/db/tests/query/case-when.test-d.ts` around lines 31 - 49, The helper
factory functions createUsers and createPosts rely on inferred return types; add
explicit return type annotations to both (for example, the concrete collection
type returned by createCollection for User and Post or ReturnType<typeof
createCollection> specialized for each) so the test helpers have stable, precise
return types; update the function signatures for createUsers and createPosts to
include those explicit return types.
packages/db/tests/query/case-when.test.ts (2)

384-583: ⚡ Quick win

Add an explicit empty-include corner-case assertion.

The conditional include materialization tests are strong, but they don’t explicitly assert behavior when the included child query resolves to an empty array ([]) in an active branch.

As per coding guidelines, "**/*.test.{ts,tsx,js,jsx}: Test corner cases including: empty collections, single elements, undefined vs null, resolved promises, race conditions, limit/offset edge cases".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/db/tests/query/case-when.test.ts` around lines 384 - 583, Add an
assertion that explicitly covers the empty-include corner case by ensuring a
branch that should include child rows returns an empty array when there are no
matching child rows: modify one of the conditional projection tests (e.g., the
tests using createUsersCollection/createPostsCollection and the LiveQuery named
query) to either include a user with no posts or adapt an existing user to have
no posts, call query.preload(), then assert that the included field (postTitles
or profile.posts) for that user is exactly [] using query.toArray (optionally
wrapped by stripVirtualPropsAndSymbols or childRows for Collection includes) so
the test verifies an empty-array include is handled correctly.

59-77: ⚡ Quick win

Replace any in test helpers with safer types.

Both helpers use any for parameters and return types, which removes compile-time type checking even in tests.

Suggested typed rewrite
-function stripVirtualPropsAndSymbols(value: any): any {
+function stripVirtualPropsAndSymbols(value: unknown): unknown {
   if (Array.isArray(value)) {
     return value.map((entry) => stripVirtualPropsAndSymbols(entry))
   }

   if (value && typeof value === `object`) {
-    const out: Record<string, any> = {}
-    for (const [key, entry] of Object.entries(stripVirtualProps(value))) {
+    const out: Record<string, unknown> = {}
+    for (const [key, entry] of Object.entries(stripVirtualProps(value as object))) {
       out[key] = stripVirtualPropsAndSymbols(entry)
     }
     return out
   }

   return value
 }

-function childRows(collection: any): Array<any> {
-  return [...collection.toArray].map((row) => stripVirtualPropsAndSymbols(row))
+function childRows(
+  collection: { toArray: Iterable<unknown> },
+): Array<unknown> {
+  return [...collection.toArray].map((row) => stripVirtualPropsAndSymbols(row))
 }

Per coding guidelines: "**/*.{ts,tsx}: Avoid using any types; use unknown instead when type is truly unknown and provide type guards to narrow safely".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/db/tests/query/case-when.test.ts` around lines 59 - 77, Replace the
unsafe any usages in the test helpers by typing
stripVirtualPropsAndSymbols(value: unknown): unknown (and arrays as unknown[])
and childRows(collection: { toArray: Iterable<unknown> }): unknown[]; keep the
existing runtime checks (Array.isArray, typeof value === 'object' && value !==
null) as the type guards, change the intermediate out to Record<string,
unknown>, and treat entries/returned values from stripVirtualProps as unknown
before recursing so you avoid any while preserving the current logic in
stripVirtualPropsAndSymbols, childRows, and the use of collection.toArray.
packages/db/src/query/compiler/select.ts (1)

204-245: ⚡ Quick win

Extract isCaseWhenConditionTrue() into a shared helper.

This truthiness rule now exists here and again in compiler/evaluators.ts. If one side changes, scalar caseWhen() and projection caseWhen() will pick different branches for the same row.

As per coding guidelines, "Extract common logic into reusable utility functions when duplicated across multiple places".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/db/src/query/compiler/select.ts` around lines 204 - 245, The
truthiness logic in isCaseWhenConditionTrue is duplicated; extract it into a
single shared helper (e.g., export function isCaseWhenConditionTrue from a
new/shared utility module) and update compileConditionalSelect to import and use
that helper instead of its local implementation, then update the other
occurrence in compiler/evaluators.ts (and any caseWhen implementations) to
import the same helper so scalar and projection caseWhen use identical logic;
ensure the helper is exported with the same name and update imports where
isCaseWhenConditionTrue was previously defined or referenced.
packages/db/src/query/compiler/group-by.ts (1)

656-666: ⚡ Quick win

Use proper types from the IR module.

The inline type assertion doesn't reference the actual ConditionalSelect type from the IR module. This creates a maintenance risk if the structure changes.

♻️ Refactor to use imported types

Import ConditionalSelect from the IR module (if not already imported), then refine the type check:

  if (expr.type === `conditionalSelect` && `branches` in expr) {
+   const conditionalExpr = expr as ConditionalSelect
    return (
-     expr.branches as Array<{
-       condition: BasicExpression
-       value: BasicExpression | Aggregate | Select | { type: string }
-     }>
+     conditionalExpr.branches
    ).some(
      (branch) =>
        containsAggregate(branch.condition) || containsAggregate(branch.value),
    )
  }

Note: Ensure ConditionalSelect is imported at the top of the file alongside other IR types.

As per coding guidelines, avoid using any types and provide proper type annotations instead of relying on type assertions.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/db/src/query/compiler/group-by.ts` around lines 656 - 666, Replace
the inline ad-hoc type assertion for conditional selects with the actual
ConditionalSelect IR type: import ConditionalSelect from the IR module (or add
it to the existing IR import), then narrow expr using that type (e.g., treat
expr as ConditionalSelect after the type check `expr.type ===
'conditionalSelect'`) and access expr.branches typed as
ConditionalSelect['branches'] instead of an anonymous cast; update the import
section at the top of group-by.ts accordingly and adjust the containsAggregate
checks to operate on the properly typed branch elements.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/db/src/query/builder/index.ts`:
- Around line 901-905: buildNestedSelect currently calls
buildConditionalSelect(obj, parentAliases) but buildConditionalSelect lowers
branches with buildNestedSelect(args[i * 2 + 1], parentAliases) which loses the
current select key/alias and prevents includes (toArray/IncludesSubquery) from
being produced; update the API so the destination/alias is threaded through:
add/pass a destination identifier (e.g. destKey or current select key) from
buildNestedSelect into buildConditionalSelect and ensure buildConditionalSelect
calls buildNestedSelect(branchValue, parentAliasesWithDest) (or
parentAliases.concat(dest)) when lowering each branch; adjust buildNestedSelect
signature and all call sites (including the initial call sites around
CaseWhenWrapper and the other occurrences noted) so branch values retain the
select alias and are treated as include nodes.

In `@packages/db/src/query/compiler/group-by.ts`:
- Around line 656-666: The conditionalSelect branch checker currently tests only
each branch's condition and value for aggregates but misses the optional
expr.defaultValue; update the logic in group-by.ts (inside the conditionalSelect
handling) to also call containsAggregate(expr.defaultValue) and include that
result in the overall some/any check so that aggregates present only in the
defaultValue are detected; reference the conditionalSelect `expr`, the helper
`containsAggregate`, and the branch structure when making this change.

---

Nitpick comments:
In `@packages/db/src/query/compiler/group-by.ts`:
- Around line 656-666: Replace the inline ad-hoc type assertion for conditional
selects with the actual ConditionalSelect IR type: import ConditionalSelect from
the IR module (or add it to the existing IR import), then narrow expr using that
type (e.g., treat expr as ConditionalSelect after the type check `expr.type ===
'conditionalSelect'`) and access expr.branches typed as
ConditionalSelect['branches'] instead of an anonymous cast; update the import
section at the top of group-by.ts accordingly and adjust the containsAggregate
checks to operate on the properly typed branch elements.

In `@packages/db/src/query/compiler/select.ts`:
- Around line 204-245: The truthiness logic in isCaseWhenConditionTrue is
duplicated; extract it into a single shared helper (e.g., export function
isCaseWhenConditionTrue from a new/shared utility module) and update
compileConditionalSelect to import and use that helper instead of its local
implementation, then update the other occurrence in compiler/evaluators.ts (and
any caseWhen implementations) to import the same helper so scalar and projection
caseWhen use identical logic; ensure the helper is exported with the same name
and update imports where isCaseWhenConditionTrue was previously defined or
referenced.

In `@packages/db/tests/query/case-when.test-d.ts`:
- Around line 31-49: The helper factory functions createUsers and createPosts
rely on inferred return types; add explicit return type annotations to both (for
example, the concrete collection type returned by createCollection for User and
Post or ReturnType<typeof createCollection> specialized for each) so the test
helpers have stable, precise return types; update the function signatures for
createUsers and createPosts to include those explicit return types.

In `@packages/db/tests/query/case-when.test.ts`:
- Around line 384-583: Add an assertion that explicitly covers the empty-include
corner case by ensuring a branch that should include child rows returns an empty
array when there are no matching child rows: modify one of the conditional
projection tests (e.g., the tests using
createUsersCollection/createPostsCollection and the LiveQuery named query) to
either include a user with no posts or adapt an existing user to have no posts,
call query.preload(), then assert that the included field (postTitles or
profile.posts) for that user is exactly [] using query.toArray (optionally
wrapped by stripVirtualPropsAndSymbols or childRows for Collection includes) so
the test verifies an empty-array include is handled correctly.
- Around line 59-77: Replace the unsafe any usages in the test helpers by typing
stripVirtualPropsAndSymbols(value: unknown): unknown (and arrays as unknown[])
and childRows(collection: { toArray: Iterable<unknown> }): unknown[]; keep the
existing runtime checks (Array.isArray, typeof value === 'object' && value !==
null) as the type guards, change the intermediate out to Record<string,
unknown>, and treat entries/returned values from stripVirtualProps as unknown
before recursing so you avoid any while preserving the current logic in
stripVirtualPropsAndSymbols, childRows, and the use of collection.toArray.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 069e5d1e-ffc5-4ecc-b6ca-f024c435c362

📥 Commits

Reviewing files that changed from the base of the PR and between 6d9e4c6 and 17564a4.

📒 Files selected for processing (14)
  • packages/db/src/query/builder/functions.ts
  • packages/db/src/query/builder/index.ts
  • packages/db/src/query/builder/ref-proxy.ts
  • packages/db/src/query/builder/types.ts
  • packages/db/src/query/compiler/evaluators.ts
  • packages/db/src/query/compiler/group-by.ts
  • packages/db/src/query/compiler/index.ts
  • packages/db/src/query/compiler/select.ts
  • packages/db/src/query/index.ts
  • packages/db/src/query/ir.ts
  • packages/db/src/query/live/collection-config-builder.ts
  • packages/db/src/query/live/utils.ts
  • packages/db/tests/query/case-when.test-d.ts
  • packages/db/tests/query/case-when.test.ts

Comment thread packages/db/src/query/builder/index.ts Outdated
Comment thread packages/db/src/query/compiler/group-by.ts Outdated
samwillis and others added 2 commits May 18, 2026 15:02
Thread conditional include destinations through caseWhen branches and tighten aggregate handling for grouped conditional projections.

Co-authored-by: Cursor <cursoragent@cursor.com>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
packages/db/src/query/compiler/select.ts (1)

231-249: ⚡ Quick win

Extract shared CASE truthiness evaluation.

isCaseWhenConditionTrue() is now duplicated here and in packages/db/src/query/compiler/group-by.ts. Keeping both copies in sync is part of caseWhen() correctness now, so this should live in one shared helper.

As per coding guidelines, Extract common logic into reusable utility functions when duplicated across multiple places.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/db/src/query/compiler/select.ts` around lines 231 - 249, Duplicate
CASE truthiness logic exists in isCaseWhenConditionTrue (in select.ts) and in
group-by.ts; extract this logic into a single shared utility (e.g., create a new
helper function name like evaluateCaseTruthiness or keep
isCaseWhenConditionTrue) in a common utilities module and replace both local
definitions with imports from that module, update references in caseWhen and any
callers (select.ts's isCaseWhenConditionTrue and the duplicate in group-by.ts)
to use the shared function, and remove the duplicated implementation so there is
a single source of truth.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/db/src/query/compiler/group-by.ts`:
- Around line 766-852: The branch conditions/values compiled in
compileGroupedConditionalSelect (and any grouped select/value compilation paths
used by compileGroupedSelectObject) still reference original namespaced refs
(e.g., users.status) but the grouped evaluator runs against a row containing
only group-backed namespaces like $selected/group-key, so refs must be lowered
before compiling; update compileGroupedConditionalSelect to transform
branch.condition and branch.value refs into group-scoped refs (or wrap them)
prior to calling compileExpression/compileGroupedSelectValue (i.e., add a helper
that rewrites BasicExpression PropRef nodes that point to grouped tables into
PropRefs under $selected or the group-key namespace and apply it to each
branch.condition and branch.value), and apply the same lowering when creating
entries in compileGroupedSelectObject for non-aggregate ref expressions so the
compiled functions will read from the reconstructed grouped row rather than
undefined original namespaces.
- Around line 746-759: The branch that handles isNestedSelectObject currently
blindly runs extractAndReplaceAggregates over every property, which converts
IncludesSubquery IR into a plain object and breaks compileGroupedSelectValue's
includesSubquery handling; fix this by detecting IncludesSubquery nodes while
iterating the object (check value.type === 'includesSubquery' or the project's
IncludesSubquery predicate) and preserve them as-is into transformed[key] (skip
calling extractAndReplaceAggregates), otherwise continue extracting aggregates
as before (keep using extractAndReplaceAggregates and merging into
allExtracted), so compileGroupedSelectValue can still match value.type ===
'includesSubquery'.

---

Nitpick comments:
In `@packages/db/src/query/compiler/select.ts`:
- Around line 231-249: Duplicate CASE truthiness logic exists in
isCaseWhenConditionTrue (in select.ts) and in group-by.ts; extract this logic
into a single shared utility (e.g., create a new helper function name like
evaluateCaseTruthiness or keep isCaseWhenConditionTrue) in a common utilities
module and replace both local definitions with imports from that module, update
references in caseWhen and any callers (select.ts's isCaseWhenConditionTrue and
the duplicate in group-by.ts) to use the shared function, and remove the
duplicated implementation so there is a single source of truth.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 40e73b2a-e80c-481d-930b-d0072bc10f93

📥 Commits

Reviewing files that changed from the base of the PR and between 17564a4 and 901eb6e.

📒 Files selected for processing (6)
  • .changeset/tender-mugs-hear.md
  • packages/db/src/query/builder/functions.ts
  • packages/db/src/query/builder/index.ts
  • packages/db/src/query/compiler/group-by.ts
  • packages/db/src/query/compiler/select.ts
  • packages/db/tests/query/case-when.test.ts
✅ Files skipped from review due to trivial changes (1)
  • .changeset/tender-mugs-hear.md
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/db/tests/query/case-when.test.ts
  • packages/db/src/query/builder/functions.ts

Comment on lines +746 to +759
if (isNestedSelectObject(expr)) {
const allExtracted: Record<string, Aggregate> = {}
const transformed: Select = {}

for (const [key, value] of Object.entries(expr)) {
const result = extractAndReplaceAggregates(
value as SelectValueExpression,
counter,
)
Object.assign(allExtracted, result.extracted)
transformed[key] = result.transformed
}

return { transformed, extracted: allExtracted }
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Preserve IncludesSubquery nodes during aggregate extraction.

isNestedSelectObject() also matches IncludesSubquery, so this branch rewrites include IR into a plain object whenever the surrounding projection contains an aggregate. That prevents compileGroupedSelectValue() from hitting its value.type === 'includesSubquery' placeholder path and breaks grouped caseWhen() branches that mix aggregates with toArray(...) or collection includes.

Possible minimal fix
 function extractAndReplaceAggregates(
   expr: SelectValueExpression,
   counter: { value: number },
 ): {
   transformed: SelectValueExpression
   extracted: Record<string, Aggregate>
 } {
   if (expr.type === `agg`) {
     const alias = `__agg_${counter.value++}`
     return {
       transformed: new PropRef([`$selected`, alias]),
       extracted: { [alias]: expr },
     }
   }

   if (expr.type === `func`) {
     ...
   }
+
+  if ((expr as { type?: string }).type === `includesSubquery`) {
+    return { transformed: expr, extracted: {} }
+  }

   if (isConditionalSelect(expr)) {
     ...
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/db/src/query/compiler/group-by.ts` around lines 746 - 759, The
branch that handles isNestedSelectObject currently blindly runs
extractAndReplaceAggregates over every property, which converts IncludesSubquery
IR into a plain object and breaks compileGroupedSelectValue's includesSubquery
handling; fix this by detecting IncludesSubquery nodes while iterating the
object (check value.type === 'includesSubquery' or the project's
IncludesSubquery predicate) and preserve them as-is into transformed[key] (skip
calling extractAndReplaceAggregates), otherwise continue extracting aggregates
as before (keep using extractAndReplaceAggregates and merging into
allExtracted), so compileGroupedSelectValue can still match value.type ===
'includesSubquery'.

Comment on lines +766 to +852
function compileGroupedSelectValue(
value: SelectValueExpression,
): (row: NamespacedRow) => any {
if (isConditionalSelect(value)) {
return compileGroupedConditionalSelect(value)
}

if (value.type === `includesSubquery`) {
return () => null
}

if (isNestedSelectObject(value)) {
return compileGroupedSelectObject(value)
}

if (!isExpressionLike(value)) {
return () => value
}

return compileExpression(value as BasicExpression)
}

function compileGroupedSelectObject(
obj: Select,
): (row: NamespacedRow) => Record<string, any> {
const entries = Object.entries(obj).map(([key, value]) => {
if (key.startsWith(`__SPREAD_SENTINEL__`)) {
const rest = key.slice(`__SPREAD_SENTINEL__`.length)
const splitIndex = rest.lastIndexOf(`__`)
const pathStr = splitIndex >= 0 ? rest.slice(0, splitIndex) : rest
const isRefExpr =
typeof value === `object` && `type` in value && value.type === `ref`
const expression = isRefExpr
? (value as BasicExpression)
: (new PropRef(pathStr.split(`.`)) as BasicExpression)

return {
key,
spread: true,
value: compileExpression(expression),
}
}

return {
key,
spread: false,
value: compileGroupedSelectValue(value as SelectValueExpression),
}
})

return (row) => {
const result: Record<string, any> = {}
for (const entry of entries) {
const value = entry.value(row)
if (entry.spread) {
if (value && typeof value === `object`) {
Object.assign(result, value)
}
} else {
result[entry.key] = value
}
}
return result
}
}

function compileGroupedConditionalSelect(
conditional: ConditionalSelect,
): (row: NamespacedRow) => any {
const branches = conditional.branches.map((branch) => ({
condition: compileExpression(branch.condition),
value: compileGroupedSelectValue(branch.value),
}))
const defaultValue =
conditional.defaultValue === undefined
? undefined
: compileGroupedSelectValue(conditional.defaultValue)

return (row) => {
for (const branch of branches) {
if (isCaseWhenConditionTrue(branch.condition(row))) {
return branch.value(row)
}
}

return defaultValue ? defaultValue(row) : undefined
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Grouped conditional wrappers lose access to grouped refs.

These evaluators compile branch conditions/values with refs like users.status intact, but wrapped aggregate evaluation still runs them against { $selected } only. A grouped projection such as caseWhen(eq(users.status, 'active'), count(users.id), 0) will therefore see users.status as undefined and take the wrong branch/value. Lower the non-aggregate group-backed refs to $selected/group-key refs before compiling, or evaluate the wrapper against a row that reconstructs the grouped namespaces.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/db/src/query/compiler/group-by.ts` around lines 766 - 852, The
branch conditions/values compiled in compileGroupedConditionalSelect (and any
grouped select/value compilation paths used by compileGroupedSelectObject) still
reference original namespaced refs (e.g., users.status) but the grouped
evaluator runs against a row containing only group-backed namespaces like
$selected/group-key, so refs must be lowered before compiling; update
compileGroupedConditionalSelect to transform branch.condition and branch.value
refs into group-scoped refs (or wrap them) prior to calling
compileExpression/compileGroupedSelectValue (i.e., add a helper that rewrites
BasicExpression PropRef nodes that point to grouped tables into PropRefs under
$selected or the group-key namespace and apply it to each branch.condition and
branch.value), and apply the same lowering when creating entries in
compileGroupedSelectObject for non-aggregate ref expressions so the compiled
functions will read from the reconstructed grouped row rather than undefined
original namespaces.

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