Skip to content

feat(mongo-query-builder): type-safe callable-form dot paths#361

Merged
wmadden merged 7 commits intomainfrom
tml-2281-type-safe-dot-path-validation-for-query-builder-callable
Apr 21, 2026
Merged

feat(mongo-query-builder): type-safe callable-form dot paths#361
wmadden merged 7 commits intomainfrom
tml-2281-type-safe-dot-path-validation-for-query-builder-callable

Conversation

@wmadden
Copy link
Copy Markdown
Contributor

@wmadden wmadden commented Apr 21, 2026

closes TML-2281

Intent

Close the framework gap where the query-builder's callable field accessor — f("address.city") — accepted any string and returned an untyped Expression<DocField>. This branch makes the callable form as type-safe as the property form: paths are constrained to the contract's valid dot-paths, invalid paths are compile-time errors with a useful diagnostic, and the returned expression carries the resolved leaf's codec so existing operator-surface semantics (and the future trait-gating hook per ADR 202) work correctly.

A sanctioned escape hatch — f.raw("path") — is provided for the one legitimate consumer that genuinely needs unvalidated paths: data-migration backfills writing to fields that are not yet part of the typed contract. The escape hatch is explicit, independent of the threaded N shape, and emits byte-identical runtime nodes to the strict callable.

The change is intentionally type-level only — no new AST nodes, no wire changes, no runtime behavior drift.

Change map

The story

  1. Describe the contract's nested structure at the type level. A new NestedDocShape = Record<string, DocField> and a new ObjectField<N> extends DocField give the type system a tree that mirrors the contract's model + value-object graph. ModelNestedShape<TContract, ModelName> walks a model's fields table (scalar → leaf with concrete codecId/nullable; valueObjectObjectField whose fields payload is the recursively-resolved sub-shape; many: true → leaf at the array boundary; union → opaque DocField leaf for now). The mapped iteration is inlined at the entry points so the mapped type stays homomorphic over the source fields record and literal keys survive TypeScript's intersection-collapsing machinery — without this, the translation silently degrades to { readonly [x: string]: any } and every downstream type assertion becomes vacuous.
  2. Walk that tree with a dot-path. ResolvePath<N, Path> pattern-matches Path against ${Head}.${Rest}, recurses into the ObjectField<Sub> at Head, and terminates on a leaf DocField or never. ValidPaths<N> is the distributed template-literal union of every valid (leaf or intermediate) path. string extends keyof N ? never : … guards the open-ended Record<string, never> case so the callable form is automatically disabled when N carries no structural information.
  3. Constrain the callable. FieldAccessor<S, N> becomes a second-generic type. The callable overload is <P extends ValidPaths<N>>(path: P) => Expression<ResolvePath<N, P>> — paths the contract doesn't know about are compile-time errors, and the returned expression carries the resolved leaf's codec. Property access (f.status) is unchanged.
  4. Expose a reduced operator surface for non-leaf paths. Expression<F> becomes a conditional: F extends ObjectField<infer N> resolves to a new ObjectExpression<N> interface exposing only the ops that make sense on a whole value object (set, unset, exists, eq(null), ne(null)). Everything else resolves to LeafExpression<F> (today's full operator surface). Runtime is unchanged — the proxy builds one uniform implementation that satisfies both type shapes.
  5. Thread N through the pipeline. PipelineChain gains a fifth type parameter. Stages that preserve document structure (match, sort, limit, skip, sample, addFields, lookup, unwind, redact, densify, fill, search, vectorSearch, unionWith) re-pass N explicitly; stages that rewrite the document (group, project, replaceRoot, count, sortByCount, bucket*, facet, geoNear, graphLookup, setWindowFields, searchMeta, pipe) omit the type argument and pick up the Record<string, never> default, resetting N and disabling the callable form downstream.
  6. Seed N at the entry points. CollectionHandle and FilteredCollection extend PipelineChain<…, ModelNestedShape<TContract, ModelName>> and pass that derived shape into every createFieldAccessor call site (match, updateMany, updateOne, updateAll, upsertOne, findOneAndUpdate). The derived shape is computed once per model (not per stage), so the typecheck cost per stage is a cheap parameter carry.
  7. Provide a sanctioned escape hatch for migration authoring. FieldAccessor<S, N> gains raw<F extends DocField = DocField>(path: string): LeafExpression<F> — explicitly unvalidated (any string), full leaf operator surface, independent of N (so still callable after replacement stages or under the default-disabled state), same runtime helper as the strict callable (so nodes are byte-identical). The retail-store backfill migration consumes f.raw('status') against a field that is not in the typed Product shape; examples/retail-store/tsconfig.json now includes migrations/**/*.ts so regressions in this layer surface at typecheck time. A pre-existing latent bug in MongoMigration's operation-union parameter (pinned to DDL-only ops) also surfaced once the migrations directory entered typecheck scope, and was fixed in the same wave.
  8. Lock it down with tests and anti-regression guards. New type-level tests cover happy-path resolution, negative paths, the reduced ObjectExpression surface, the additive-vs-replacement pipeline policy, and the five f.raw scenarios (arbitrary path, full surface, paths outside ValidPaths<N>, availability under empty N, explicit generic narrowing). Two runtime tests assert byte-identical filter-node emission for the strict callable and for f.raw, guarding against accidental runtime divergence when Expression<F> was split.

Behavior changes & evidence

Behavior change A — Callable-form dot-paths are now contract-validated

Before → After:

// BEFORE — f: FieldAccessor<Shape>, callable is (path: string) => Expression<DocField>
f("address.typo").eq("x");            // compiled, ran, silently matched nothing
f("nonexistent").set("whatever");     // compiled, ran, no-op at runtime
// AFTER — f: FieldAccessor<Shape, N>, callable is <P extends ValidPaths<N>>(path: P) => Expression<ResolvePath<N, P>>
f("address.city").eq("London");       // ok — resolved to LeafExpression<{ codecId: 'mongo/string@1', nullable: false }>
f("address.typo").eq("x");            // ts error: Argument of type '"address.typo"' is not assignable to parameter of type
                                      //   '"_id" | "name" | "address" | "workAddress" | "stats" | "address.street" | "address.city" | …'
f("address").set({...});              // ok — ObjectExpression; set/unset/exists/eq(null)/ne(null) exposed, others hidden
f("address").inc(1);                  // ts error: Property 'inc' does not exist on ObjectExpression<Address>

Why: Typos and stale paths after contract refactors used to surface only at runtime as zero-match updates or "field not found" oddities. Constraining paths at the call site turns them into IDE-surfaced errors and gives the returned expression a concrete codec (future trait-gating hook per ADR 202).

Implementation:

Tests:

Behavior change B — Non-leaf paths (f("address")) expose a reduced operator surface

New capability:

// f("address") now returns ObjectExpression<AddressNestedShape>
f("address").set({ street: "1 Main", city: "NYC", zip: null, geo: { lat: 0, lng: 0 } });
f("address").exists();
f("address").eq(null);       // matches Mongo $eq: null semantics (null OR missing)
f("address").ne(null);
f("address").unset();

// leaf-only operators are hidden on the type level
f("address").inc(1);         // ts error
f("address").gt({});         // ts error
f("address").push(...);      // ts error

Why: Whole-value-object operations (set / unset / presence checks) are the set of operators that actually make sense on a structured sub-document. Arithmetic, comparison, and array operators belong on the constituent leaves (f("address.city"), f("stats.visits")). The conditional Expression<F> gives us type-level narrowing without changing the runtime shape.

Implementation:

Tests:

Behavior change C — Replacement pipeline stages disable the callable form downstream

Before → After:

// BEFORE — Shape narrows correctly but N doesn't exist; callable is always available and untyped.
p.from("customers").group(...).match((f) => f("address.city").eq("x"));   // compiled; ran; produced bogus filter.
// AFTER — group() resets N to Record<string, never>; ValidPaths<{}> = never; callable is unusable.
p.from("customers").group(...).match((f) => f("address.city").eq("x")); // ts error: Argument not assignable to parameter of type 'never'
p.from("customers").project("name").match((f) => f("address.city").eq("x")); // ts error — same
// Additive stages keep N intact:
p.from("customers").sort({ name: 1 }).match((f) => f("address.city").eq("London"));   // ok
p.from("customers").addFields((_f) => ({})).match((f) => f("address.city").eq("London")); // ok

Why: After a $group / $project / $replaceRoot / etc., the source document's nested-path tree is no longer meaningful — the pipeline has produced a new shape whose paths live in Shape, not in the original contract model. Resetting N keeps the callable form sound with no additional work from the stage author: the default generic argument on #withStage makes "reset" the behavior a replacement stage gets for free.

Implementation:

Tests:

Behavior change D — f.raw(path) escape hatch for unvalidated paths

Before → After:

// BEFORE strict validation (untyped-callable era): any string worked, no validation.
f("status").set("active");                   // fine — untyped, but it ran.

// With strict validation alone (no escape hatch): migrations break.
f("status").set("active");                   // ts error — "status" not in ValidPaths<ProductNested>.
//  \— But this is a legitimate use case: migration backfill for a field not yet in the contract.

// With f.raw: strict validation for normal paths, explicit opt-out for unvalidated ones.
f("status").set("active");                   // still a ts error — typo protection preserved.
f.raw("status").set("active");               // ok — explicit opt-out; returns LeafExpression<DocField>.
f.raw<StringField>("status").set("active");  // ok — narrowed return via explicit generic.

Why: Data-migration backfills are a first-class use case that by definition write to fields the post-migration contract knows about but the pre-migration contract doesn't. The strict callable's "not assignable to parameter of type never" error was correct for the common case but wrong for this one. f.raw is the sanctioned opt-out: explicit, named, searchable at call sites, and lints obvious in review ("why is this using raw?" is easier to audit than "why is this cast with as never?"). Independence from N means it remains usable after replacement stages reset N.

Implementation:

Tests:

Behavior change E — Runtime emission is provably unchanged

Why flagged: Expression<F> was split into a conditional type with an as unknown as Expression<F> assertion in buildExpression. It is easy for such a split to accidentally drop a method on the runtime object; the accessor would still typecheck but throw at runtime.

Evidence:

Compatibility / migration / risk

  • External consumers. The new second generic on FieldAccessor and fifth generic on PipelineChain both have defaults (Record<string, never>). Existing code that instantiates these types without explicit arguments continues to compile; the callable form in those codepaths is (path: never) => …, which was already the design intent for "disabled" call sites. The ORM package has its own field-accessor.ts and does not consume @prisma-next/mongo-query-builder's FieldAccessor, so there is no cross-package ripple there.
  • In-repo consumer — data migrations. The retail-store backfill migration intentionally writes to a field (status) that is not yet in the typed Product shape. It now uses f.raw('status') and examples/retail-store/tsconfig.json includes migrations/**/*.ts in the include list so the migration directory participates in pnpm --filter retail-store typecheck. That tsconfig extension also surfaced a pre-existing latent bug in MongoMigration's operation-union parameter (pinned to DDL-only ops, rejecting dataTransform) — fixed in the same wave.
  • Property-name collision. FieldAccessor<S, N> reserves raw and stage as property names on the proxy, but the type does not subtract those keys from the mapped [K in keyof S] half. A contract field named raw or stage typechecks as if both the Expression and the reserved surface were available, while the runtime proxy returns only the reserved surface (no Expression methods). stage predates TML-2281; raw is new. Mitigation options range from a README note to subtracting the reserved keys from the mapped type — see Follow-ups.
  • Runtime. No runtime change beyond the additive f.raw dispatch in the accessor proxy. MongoAggFieldRef.of(path) and MongoFieldFilter.*(path, …) nodes are unchanged.
  • Compile-time cost. ValidPaths<N> is a single recursive distributive conditional per stage; PathCompletions = ValidPaths means only one recursive union is built. No depth cap ships; monitor-and-add-cap-if-CI-regresses is the stated plan, and the two-level Customer fixture shows no pathology.
  • Reserved codec id. ObjectField.codecId = 'prisma/object@1' is a type-level sentinel, never consumed at runtime. If a future code path adds codecRegistry.get(field.codecId) over nested paths, we'd need to either register an object codec or suppress the lookup for ObjectFields.

Follow-ups / open questions

  • PathCompletions progressive form. The spec's AC calls for "address." prefix completions; the current implementation aliases PathCompletions = ValidPaths. Either implement the ArkType-style progressive completion (and intersect it at the callable's parameter position) or narrow the spec and README and downgrade the AC.
  • Nullable intermediate fold-down. Spec's Open Question Decouple extensions #3 default is to fold a nullable intermediate's nullable = true onto its leaves; not implemented. Decide whether to implement or to explicitly update the spec to "leaf nullability only".
  • Union-kind field distribution. Spec names this as a functional requirement; implementation treats union-kind fields as opaque leaves. Same call as above.
  • Self-referential value-object fixture. The no-explosion AC (NavItem.children: NavItem) is only implicitly covered by the suite typecheck; add the fixture for an explicit test.
  • f.raw / f.stage property-name collision. Document the reserved names in the accessor docstring and README, or subtract them from the mapped type so collisions become a compile-time error pointing at the offending field.
  • Pipeline-integration test for f.raw after replacement stages. The "independent of N" guarantee is tested only on a standalone accessor, not in the real .group(...).match(f => f.raw(...)) path. One short test would close the gap.

Non-goals / intentionally out of scope

  • Array element / positional paths (tags.0, items.$[elem]).
  • addFields / project-computed fields re-entering N (computed fields becoming callable-addressable in downstream stages).
  • Trait-gated operator surface on LeafExpression<F> per ADR 202.
  • Depth cap on PathCompletions / ValidPaths.
  • Runtime path validation (purely compile-time).
  • SQL JSONB path support.

Summary by CodeRabbit

  • New Features

    • Type-safe dot-path field accessor for nested document properties with compile-time validation and IDE completions; callable access preserved through additive stages and disabled after replacement stages.
    • New escape hatch renamed to rawPath(...) to bypass validation for out-of-contract fields.
    • Non-leaf object paths expose a reduced operator set (set, unset, exists, null comparisons).
  • Documentation

    • Updated design doc and query-builder README to describe typed traversal, stage semantics, and rawPath usage.
  • Tests

    • Added compile-time and runtime tests covering path resolution, operator surfaces, and stage behavior.
  • Chores

    • Migration example updated to use rawPath; project config now includes migrations.

wmadden added 5 commits April 21, 2026 16:19
…idation

Captures the design agreed for type-safe dot-path validation on
`FieldAccessor`s callable form: nested shape derivation from the
contract, constrained callable signature with ArkType-style
completions, reduced op surface on non-leaf paths, and additive-vs-
replacement stage threading of the nested shape through the pipeline
builder. Single-milestone execution plan with test-first sequencing
and acceptance-criterion coverage mapping.
… FieldAccessor (TML-2281)

Close the known gap in the Mongo query-builder's callable field accessor:
`f("address.city")` is now compile-time validated against the contract's
model + value-object structure, returns an `Expression` carrying the
resolved leaf's codec, and surfaces IDE autocomplete over the valid
path union.

Core type machinery lives in `src/resolve-path.ts`:
- `NestedDocShape` / `ObjectField<N>` mark value-object paths and carry
  sub-shapes alongside the flat `DocShape`.
- `ModelNestedShape` derives the initial nested tree from the contract.
- `ResolvePath<N, Path>` resolves a dot-path to a `DocField`.
- `ValidPaths<N>` produces the union of valid paths, short-circuiting to
  `never` for open-ended index-signature shapes so the callable form is
  automatically disabled downstream of replacement stages.

`Expression<F>` splits into `LeafExpression<F>` (full operator surface)
and `ObjectExpression<N>` (reduced `set`/`unset`/`exists`/`eq(null)`/
`ne(null)` surface for whole value objects) via a conditional type. The
runtime proxy is unchanged.

`FieldAccessor<S, N>` gains a second generic; `PipelineChain` threads
`N` as its fifth generic. Additive stages (match/addFields/sort/limit/
skip/sample/lookup/unwind/unionWith/densify/fill/redact/search/
vectorSearch) preserve `N`; replacement stages (project/group/
replaceRoot/count/sortByCount/bucket*/geoNear/facet/graphLookup/
setWindowFields/searchMeta/pipe) reset it to `Record<string, never>`.
`CollectionHandle` / `FilteredCollection` seed `N` from
`ModelNestedShape<TContract, ModelName>`.

Test fixtures extended with `Customer` + `Address`/`GeoPoint`/`Stats`
value objects (2-level nesting with a nullable intermediate). Adds
`resolve-path.test-d.ts` (18 tests) and `field-accessor.test-d.ts`
(22 tests) covering happy-path resolution, negative cases, non-leaf
reduced operator surface, and pipeline threading. Adds a runtime
non-regression assertion in `builder.test.ts` that the callable
dot-path still emits the same `MongoFieldFilter` node.

Docs: package README loses the "does not currently validate" caveat,
ADR 180's implementation note is updated to "Implemented in TML-2281",
and the parent project spec status table references this work.
…keyof stay concrete

The previous `FieldsToNested<TContract, Fields>` helper took the
`fields` record as a generic parameter and mapped over it internally.
When TypeScript instantiated that helper with
`TContract["models"]["X"]["fields"]` — an intersection of
`Record<string, ContractField>` (from the `MongoContract` base) and the
literal per-field record — the `keyof` inside the helper collapsed to
`string`, and the result hover degraded to `{ readonly [x: string]: any }`.

The value-side assertions in `resolve-path.test-d.ts` still returned
the right leaf types via indexed access under `tsc`, but the IDE hover
and intellisense surface were effectively broken (and any future test
that relied on `keyof CustomerShape` would tautologically pass against
`any`).

Fix by inlining the mapped iteration at the `ModelNestedShape` entry
point and at the VO recursion point (`VONestedShape`), matching the
homomorphic pattern already used by `ModelToDocShape`. `TranslateField`
is kept as a per-field helper with a single non-record generic
parameter, so it does not re-introduce the intersection collapse.

Adds a guard assertion to `resolve-path.test-d.ts` that `keyof
CustomerShape` is exactly the literal field-name union and that
`string extends keyof CustomerShape` is `false`, so any regression into
the open-index-signature shape fails loudly at the top of the suite
instead of silently making every downstream assertion vacuous.
…aths (TML-2281 review F12)

Strict dot-path validation (shipped in `db88f733c`) breaks a legitimate
use case: data-backfill migrations that write to fields not yet present
in the pre-migration contract. The retail-store `backfill-product-status`
migration intentionally used the callable form `f("status")` because
`status` is the field being backfilled — strict validation rejects that
path as "not assignable to parameter of type `never`" even though the
runtime is fine. Until now there was no sanctioned way out.

Add `f.raw<F extends DocField = DocField>(path: string): LeafExpression<F>`
on `FieldAccessor`:

- Accepts any string; no `ValidPaths<N>` constraint, no IDE autocomplete.
- Returns the full leaf operator surface (`set`, `exists`, `inc`, `push`,
  `unset`, …). Default `F = DocField`; callers can narrow the return
  via the explicit generic: `f.raw<StringField>("status").set("active")`.
- Independent of `N`, so it remains usable in the "callable disabled"
  state downstream of replacement stages.
- Runtime uses the same `buildExpression(path)` helper as the strict
  callable, so emitted filter/update nodes are byte-identical.

Runtime non-regression: `MongoExistsExpr.notExists("status")` produced
via `f.raw("status").exists(false)` is `.toEqual`-equivalent to the
low-level constructor, asserted in `builder.test.ts`.

Documents the escape hatch in the package README, ADR 180 implementation
note, and the spec/plan (resolving a new Open Item carried forward from
the F12 review finding).
…(TML-2281 review F12/F13)

Two linked review findings on TML-2281:

F12 — The `backfill-product-status` migration used the callable form
`f("status")` to write a field not yet present in the Product contract.
Strict validation shipped with TML-2281 rejected the path. Switch to the
new `f.raw("status")` escape hatch (introduced in the preceding commit)
so the migration compiles and the emitted runtime nodes are unchanged.

F13 — `examples/retail-store/tsconfig.json` excluded `migrations/**`
from `include`, so `pnpm --filter retail-store typecheck` silently
passed even while `migration.ts` was broken. Add `migrations/**/*.ts`
to the `include` list so the migration directory participates in the
typecheck going forward; this is the CI signal that surfaces regressions
like F12 at authoring time rather than at run time.

Combined with the `family-mongo` operation-union fix, `pnpm --filter
retail-store typecheck` now passes with migrations included.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 21, 2026

📝 Walkthrough

Walkthrough

Adds compile-time, type-safe dot-path field access by threading a nested document-shape generic through pipeline chains, splitting expression types into leaf vs object surfaces, introducing path-resolution utilities and a rawPath escape hatch, and updating pipeline/states/tests/docs to propagate and validate dot-path usage.

Changes

Cohort / File(s) Summary
Type-system foundation
packages/2-mongo-family/5-query-builders/query-builder/src/resolve-path.ts
New type-level path resolver and validators: ObjectField, NestedDocShape, ModelNestedShape, ResolvePath, ValidPaths, and PathCompletions to model nested value-object shapes and produce compile-time valid dot-path unions.
Field accessor & expression types
packages/2-mongo-family/5-query-builders/query-builder/src/field-accessor.ts
Split Expression into LeafExpression and ObjectExpression; made FieldAccessor<S, N> callable only for ValidPaths<N>; added rawPath(path) escape hatch returning LeafExpression; tightened operator typings and updated createFieldAccessor signature.
Pipeline generic threading
packages/2-mongo-family/5-query-builders/query-builder/src/builder.ts
Added N extends NestedDocShape generic to PipelineChain, propagated N through stage methods, and made #withStage accept/propagate NewN so additive stages preserve N and replacement stages reset it. Updated many stage signatures and updater callback types.
State classes / collection handles
packages/2-mongo-family/5-query-builders/query-builder/src/state-classes.ts
Threaded ModelNestedShape into CollectionHandle and FilteredCollection generics and updater/filter callback types; updated resolveUpdaterCallback and createFieldAccessor invocations to pass nested-shape type.
Public exports
packages/2-mongo-family/5-query-builders/query-builder/src/exports/index.ts
Re-exported newly introduced types (LeafExpression, ObjectExpression, ModelNestedShape, NestedDocShape, ObjectField, PathCompletions, ResolvePath, ValidPaths) to the module surface.
Type-level tests
packages/2-mongo-family/5-query-builders/query-builder/test/field-accessor.test-d.ts, .../resolve-path.test-d.ts, .../state-machine.test-d.ts
Added extensive .d.ts tests validating ResolvePath/ValidPaths, Expression conditional resolution (leaf vs object), callable dot-path acceptance/rejection, stage-preserve/reset semantics, and rawPath escape-hatch behavior; adjusted extractor types for new PipelineChain arity.
Runtime tests & fixtures
packages/2-mongo-family/5-query-builders/query-builder/test/builder.test.ts, .../test/fixtures/test-contract.ts
Added runtime assertions for f('address.city') and f.rawPath('status') produced filters; expanded test contract with Customer model and value-objects (Address, GeoPoint, Stats) and customers collection.
Docs / ADR / README
docs/architecture docs/adrs/ADR 180 - Dot-path field accessor.md, packages/.../README.md
ADR and README updated to document the new typed callable dot-path behavior, operator-surface distinction, stage-based enable/disable semantics, and the rawPath escape hatch (named to avoid colliding with a raw model field).
Examples & config
examples/retail-store/tsconfig.json, examples/retail-store/migrations/.../migration.ts
Included migrations/**/*.ts in project tsconfig; migration updated to use f.rawPath('status') for untyped field access and explanatory comments.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰 I nibbled paths through docs and code,
Dot-dots now safe where once they strode.
Leaves and objects know what to do,
When stages change, callable hides too.
For wild roads, use my rawPath cue — hop on through!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.00% 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
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately captures the primary change: introducing type-safety for the callable-form dot-path field accessor in the mongo query builder.
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 tml-2281-type-safe-dot-path-validation-for-query-builder-callable

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 Apr 21, 2026

Open in StackBlitz

@prisma-next/mongo-runtime

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-runtime@361

@prisma-next/family-mongo

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/family-mongo@361

@prisma-next/sql-runtime

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-runtime@361

@prisma-next/family-sql

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/family-sql@361

@prisma-next/middleware-telemetry

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/middleware-telemetry@361

@prisma-next/mongo

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo@361

@prisma-next/extension-paradedb

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/extension-paradedb@361

@prisma-next/extension-pgvector

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/extension-pgvector@361

@prisma-next/postgres

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/postgres@361

@prisma-next/sql-orm-client

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-orm-client@361

@prisma-next/sqlite

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sqlite@361

@prisma-next/target-mongo

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/target-mongo@361

@prisma-next/adapter-mongo

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/adapter-mongo@361

@prisma-next/driver-mongo

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/driver-mongo@361

@prisma-next/contract

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/contract@361

@prisma-next/utils

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/utils@361

@prisma-next/config

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/config@361

@prisma-next/errors

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/errors@361

@prisma-next/framework-components

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/framework-components@361

@prisma-next/operations

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/operations@361

@prisma-next/contract-authoring

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/contract-authoring@361

@prisma-next/ids

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/ids@361

@prisma-next/psl-parser

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/psl-parser@361

@prisma-next/psl-printer

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/psl-printer@361

@prisma-next/cli

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/cli@361

@prisma-next/emitter

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/emitter@361

@prisma-next/migration-tools

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/migration-tools@361

prisma-next

npm i https://pkg.pr.new/prisma/prisma-next@361

@prisma-next/vite-plugin-contract-emit

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/vite-plugin-contract-emit@361

@prisma-next/runtime-executor

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/runtime-executor@361

@prisma-next/mongo-codec

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-codec@361

@prisma-next/mongo-contract

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-contract@361

@prisma-next/mongo-value

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-value@361

@prisma-next/mongo-contract-psl

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-contract-psl@361

@prisma-next/mongo-contract-ts

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-contract-ts@361

@prisma-next/mongo-emitter

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-emitter@361

@prisma-next/mongo-schema-ir

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-schema-ir@361

@prisma-next/mongo-query-ast

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-query-ast@361

@prisma-next/mongo-orm

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-orm@361

@prisma-next/mongo-query-builder

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-query-builder@361

@prisma-next/mongo-lowering

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-lowering@361

@prisma-next/mongo-wire

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-wire@361

@prisma-next/sql-contract

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-contract@361

@prisma-next/sql-errors

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-errors@361

@prisma-next/sql-operations

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-operations@361

@prisma-next/sql-schema-ir

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-schema-ir@361

@prisma-next/sql-contract-psl

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-contract-psl@361

@prisma-next/sql-contract-ts

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-contract-ts@361

@prisma-next/sql-contract-emitter

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-contract-emitter@361

@prisma-next/sql-lane-query-builder

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-lane-query-builder@361

@prisma-next/sql-relational-core

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-relational-core@361

@prisma-next/sql-builder

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-builder@361

@prisma-next/target-postgres

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/target-postgres@361

@prisma-next/target-sqlite

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/target-sqlite@361

@prisma-next/adapter-postgres

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/adapter-postgres@361

@prisma-next/adapter-sqlite

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/adapter-sqlite@361

@prisma-next/driver-postgres

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/driver-postgres@361

@prisma-next/driver-sqlite

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/driver-sqlite@361

commit: 7cc4f8a

Copy link
Copy Markdown

@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

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/2-mongo-family/5-query-builders/query-builder/README.md`:
- Line 97: The ADR link in README.md currently uses a relative path that climbs
one directory too far; update the link target for "ADR 180 — Dot-path field
accessor" so it points into the repository's docs tree (e.g., adjust the
../../../../../docs/... path to the correct relative path that reaches
docs/architecture docs/adrs/ADR 180 — Dot-path field accessor.md from this
README), ensuring the rendered link resolves to the repo's docs/ directory.

In
`@packages/2-mongo-family/5-query-builders/query-builder/src/field-accessor.ts`:
- Around line 183-199: The escape-hatch method named raw on the FieldAccessor
type is shadowing legitimate top-level fields called "raw"; rename the
escape-hatch to a non-property name (e.g., rawPath or rawField) and update the
FieldAccessor signature (the method raw<F extends DocField = DocField>(path:
string): LeafExpression<F>) to the new name, preserving the same generic and
return type, then update all call sites and any other duplicate declarations of
the escape hatch (the other occurrence similar to the one at the end of the
file) so callers use the new identifier and no top-level field named "raw" is
overridden; keep ValidPaths, ResolvePath, LeafExpression and DocField semantics
unchanged.
🪄 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: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: b723d828-878a-47d8-8579-edeade6b0c4d

📥 Commits

Reviewing files that changed from the base of the PR and between 2292ce1 and c26895b.

⛔ Files ignored due to path filters (3)
  • projects/mongo-pipeline-builder/plans/callable-field-accessor-path-validation-plan.md is excluded by !projects/**
  • projects/mongo-pipeline-builder/spec.md is excluded by !projects/**
  • projects/mongo-pipeline-builder/specs/callable-field-accessor-path-validation.spec.md is excluded by !projects/**
📒 Files selected for processing (14)
  • docs/architecture docs/adrs/ADR 180 - Dot-path field accessor.md
  • examples/retail-store/migrations/20260416_backfill-product-status/migration.ts
  • examples/retail-store/tsconfig.json
  • packages/2-mongo-family/5-query-builders/query-builder/README.md
  • packages/2-mongo-family/5-query-builders/query-builder/src/builder.ts
  • packages/2-mongo-family/5-query-builders/query-builder/src/exports/index.ts
  • packages/2-mongo-family/5-query-builders/query-builder/src/field-accessor.ts
  • packages/2-mongo-family/5-query-builders/query-builder/src/resolve-path.ts
  • packages/2-mongo-family/5-query-builders/query-builder/src/state-classes.ts
  • packages/2-mongo-family/5-query-builders/query-builder/test/builder.test.ts
  • packages/2-mongo-family/5-query-builders/query-builder/test/field-accessor.test-d.ts
  • packages/2-mongo-family/5-query-builders/query-builder/test/fixtures/test-contract.ts
  • packages/2-mongo-family/5-query-builders/query-builder/test/resolve-path.test-d.ts
  • packages/2-mongo-family/5-query-builders/query-builder/test/state-machine.test-d.ts

Comment thread packages/2-mongo-family/5-query-builders/query-builder/README.md Outdated
wmadden added 2 commits April 21, 2026 17:12
The README lives 4 directories deep under the repo root, so its link to
ADR 180 needs four `..`, not five. The extra `..` was causing the link
to point outside the repository tree and miss `docs/` entirely.

Addresses PR #361 review comment from CodeRabbit
(discussion_r3118221548).
…dowing real fields

The escape hatch was originally named `raw`, which silently shadowed any
legitimate top-level `raw` field on a user model: the intersection of
the mapped-type property form and the extra method both resolve at
`f.raw`, and the runtime Proxy intercepted `raw` before falling through
to `buildExpression`, so property access for a real `raw` field became
unreachable. The callable fallback `f("raw")` does not cover this case
either — the callable is disabled downstream of replacement stages, so
such a field would be permanently inaccessible once a `project` / `group`
/ `replaceRoot` was introduced.

Rename the escape hatch to `rawPath` so it lives off the property
namespace. The name also reads as exactly what it is: a raw, unvalidated
path string. All call sites in the query-builder tests, the retail-store
`backfill-product-status` migration, README, ADR 180, spec, and plan
are updated. A regression type test in field-accessor.test-d.ts pins the
non-shadowing guarantee for a model with a top-level `raw` field.

Addresses PR #361 review comment from CodeRabbit
(discussion_r3118221588, TML-2281 review round 2).
Copy link
Copy Markdown

@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.

🧹 Nitpick comments (1)
packages/2-mongo-family/5-query-builders/query-builder/src/field-accessor.ts (1)

206-245: Consider documenting the cast safety invariant more explicitly.

The as unknown as Expression<F> cast at line 244 violates the coding guideline against blind casts in production code. However, the pattern is technically safe here because:

  1. ObjectExpression is a strict subset of LeafExpression (reduced operator surface)
  2. The runtime object implements the full LeafExpression surface
  3. Compile-time gating via Expression<F> prevents misuse

The comment at lines 207-210 explains what happens but not why the cast is safe. Consider adding an explicit safety note, e.g.:

// SAFETY: ObjectExpression<N> ⊂ LeafExpression<F> structurally — all
// ObjectExpression methods (set/unset/exists/eq/ne) exist on this object.
// The conditional Expression<F> type restricts access at compile time.

This would help future maintainers understand the invariant that keeps this cast sound. As per coding guidelines: "No blind casts in production code: as unknown as X is forbidden outside tests."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/2-mongo-family/5-query-builders/query-builder/src/field-accessor.ts`
around lines 206 - 245, Add an explicit SAFETY comment above the final cast in
buildExpression explaining why the as unknown as Expression<F> is sound: state
that ObjectExpression is a strict subset of LeafExpression, that the returned
object implements the full LeafExpression runtime surface (list the implemented
methods like eq, ne, set, unset, exists, push, etc.), and that the generic
Expression<F> type provides compile-time gating to restrict usage; reference the
types Expression<F>, ObjectExpression, LeafExpression and the buildExpression
function so future maintainers can see the invariant that justifies the cast.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In
`@packages/2-mongo-family/5-query-builders/query-builder/src/field-accessor.ts`:
- Around line 206-245: Add an explicit SAFETY comment above the final cast in
buildExpression explaining why the as unknown as Expression<F> is sound: state
that ObjectExpression is a strict subset of LeafExpression, that the returned
object implements the full LeafExpression runtime surface (list the implemented
methods like eq, ne, set, unset, exists, push, etc.), and that the generic
Expression<F> type provides compile-time gating to restrict usage; reference the
types Expression<F>, ObjectExpression, LeafExpression and the buildExpression
function so future maintainers can see the invariant that justifies the cast.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: a0883eaf-15f9-46f2-b6f7-b63ae75bb5ce

📥 Commits

Reviewing files that changed from the base of the PR and between c26895b and 7cc4f8a.

⛔ Files ignored due to path filters (2)
  • projects/mongo-pipeline-builder/plans/callable-field-accessor-path-validation-plan.md is excluded by !projects/**
  • projects/mongo-pipeline-builder/specs/callable-field-accessor-path-validation.spec.md is excluded by !projects/**
📒 Files selected for processing (6)
  • docs/architecture docs/adrs/ADR 180 - Dot-path field accessor.md
  • examples/retail-store/migrations/20260416_backfill-product-status/migration.ts
  • packages/2-mongo-family/5-query-builders/query-builder/README.md
  • packages/2-mongo-family/5-query-builders/query-builder/src/field-accessor.ts
  • packages/2-mongo-family/5-query-builders/query-builder/test/builder.test.ts
  • packages/2-mongo-family/5-query-builders/query-builder/test/field-accessor.test-d.ts
✅ Files skipped from review due to trivial changes (1)
  • examples/retail-store/migrations/20260416_backfill-product-status/migration.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • docs/architecture docs/adrs/ADR 180 - Dot-path field accessor.md
  • packages/2-mongo-family/5-query-builders/query-builder/README.md

@wmadden wmadden merged commit 20832ee into main Apr 21, 2026
16 checks passed
wmadden added a commit that referenced this pull request Apr 21, 2026
The README lives 4 directories deep under the repo root, so its link to
ADR 180 needs four `..`, not five. The extra `..` was causing the link
to point outside the repository tree and miss `docs/` entirely.

Addresses PR #361 review comment from CodeRabbit
(discussion_r3118221548).
@wmadden wmadden deleted the tml-2281-type-safe-dot-path-validation-for-query-builder-callable branch April 21, 2026 15:29
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