Skip to content

Commit 20832ee

Browse files
committed
refactor(mongo-query-builder): rename f.raw -> f.rawPath to avoid shadowing 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).
1 parent 9328934 commit 20832ee

7 files changed

Lines changed: 69 additions & 46 deletions

File tree

docs/architecture docs/adrs/ADR 180 - Dot-path field accessor.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# ADR 180 — Dot-path field accessor
22

3-
> **Implementation update (Mongo query builder unification).** The consolidated `FieldAccessor` shipped in `@prisma-next/mongo-query-builder` replaced the earlier `FieldProxy` and `FilterProxy` types — filter and update operators now hang off a single accessor, used by both read callbacks (`match`, `addFields`, `project`, `group`) and write callbacks (`updateMany`, `findOneAndUpdate`, etc.). Type-safe dot-path validation for the callable form `f("address.city")` was implemented in [TML-2281](https://linear.app/prisma-company/issue/TML-2281): a second generic `N extends NestedDocShape` threads the contract's model + value-object structure through the pipeline, paths are constrained by `ValidPaths<N>`, the resolved leaf's codec drives the returned `Expression`, and non-leaf paths surface a reduced `ObjectExpression` operator surface. Additive pipeline stages preserve `N`; replacement stages (`project`, `group`, `replaceRoot`, …) reset it, disabling the callable form downstream. For paths that are intentionally outside the typed model (canonically, migration authoring where a backfill writes a field that is not yet in the contract), `f.raw("path")` is the sanctioned escape hatch — it returns a `LeafExpression<DocField>` with the verbatim path and the full leaf operator surface.
3+
> **Implementation update (Mongo query builder unification).** The consolidated `FieldAccessor` shipped in `@prisma-next/mongo-query-builder` replaced the earlier `FieldProxy` and `FilterProxy` types — filter and update operators now hang off a single accessor, used by both read callbacks (`match`, `addFields`, `project`, `group`) and write callbacks (`updateMany`, `findOneAndUpdate`, etc.). Type-safe dot-path validation for the callable form `f("address.city")` was implemented in [TML-2281](https://linear.app/prisma-company/issue/TML-2281): a second generic `N extends NestedDocShape` threads the contract's model + value-object structure through the pipeline, paths are constrained by `ValidPaths<N>`, the resolved leaf's codec drives the returned `Expression`, and non-leaf paths surface a reduced `ObjectExpression` operator surface. Additive pipeline stages preserve `N`; replacement stages (`project`, `group`, `replaceRoot`, …) reset it, disabling the callable form downstream. For paths that are intentionally outside the typed model (canonically, migration authoring where a backfill writes a field that is not yet in the contract), `f.rawPath("path")` is the sanctioned escape hatch — it returns a `LeafExpression<DocField>` with the verbatim path and the full leaf operator surface. The method is named `rawPath` rather than `raw` so the escape hatch does not shadow a legitimate top-level `raw` field on a user model (the callable fallback `f("raw")` is disabled downstream of replacement stages, which would otherwise leave such a field inaccessible).
44

55
## At a glance
66

examples/retail-store/migrations/20260416_backfill-product-status/migration.ts

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,25 +19,26 @@ class BackfillProductStatus extends Migration {
1919
return [
2020
dataTransform('backfill-product-status', {
2121
check: {
22-
// `status` is not part of the typed Product shape (it's the field we
23-
// are backfilling), so use the `f.raw(...)` escape hatch — the
24-
// strict callable `f("status")` would reject an unknown path per
25-
// TML-2281. `f.raw` yields the full leaf operator surface with no
26-
// contract validation; this is the sanctioned pattern for
22+
// `status` is not part of the typed Product shape (it's the field
23+
// we are backfilling), so use the `f.rawPath(...)` escape hatch —
24+
// the strict callable `f("status")` would reject an unknown path
25+
// per TML-2281. `f.rawPath` yields the full leaf operator surface
26+
// with no contract validation; this is the sanctioned pattern for
2727
// migration authoring where the target field is not yet part of
28-
// the contract. See ADR 180 and @prisma-next/mongo-query-builder's
29-
// README.
28+
// the contract. The method is named `rawPath` (not `raw`) so it
29+
// does not shadow a legitimate top-level `raw` field on a user
30+
// model. See ADR 180 and @prisma-next/mongo-query-builder's README.
3031
source: () =>
3132
query
3233
.from('products')
33-
.match((f) => f.raw('status').exists(false))
34+
.match((f) => f.rawPath('status').exists(false))
3435
.limit(1),
3536
},
3637
run: () =>
3738
query
3839
.from('products')
39-
.match((f) => f.raw('status').exists(false))
40-
.updateMany((f) => [f.raw('status').set('active')]),
40+
.match((f) => f.rawPath('status').exists(false))
41+
.updateMany((f) => [f.rawPath('status').set('active')]),
4142
}),
4243
];
4344
}

packages/2-mongo-family/5-query-builders/query-builder/src/field-accessor.ts

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -164,11 +164,13 @@ function buildStageEmitters(): StageEmitters {
164164
* resolve to an `ObjectExpression` whose reduced surface covers the
165165
* whole-value operations (`set`, `unset`, `exists`, `eq(null)`,
166166
* `ne(null)`).
167-
* - `f.raw('path')` is a deliberate escape hatch that skips path
167+
* - `f.rawPath('path')` is a deliberate escape hatch that skips path
168168
* validation and returns a `LeafExpression<F>` for the given string.
169169
* Intended for migration authoring where the target field is not yet
170170
* part of the typed contract (e.g. a backfill writing a newly-added
171-
* column before the contract hash rolls forward).
171+
* column before the contract hash rolls forward). The method name is
172+
* deliberately `rawPath` rather than `raw` so it does not shadow a
173+
* legitimate top-level `raw` field on a user model.
172174
* - `f.stage` exposes pipeline-style update emitters (`$set`, `$unset`,
173175
* `$replaceRoot`, `$replaceWith`).
174176
*
@@ -177,8 +179,8 @@ function buildStageEmitters(): StageEmitters {
177179
* `ValidPaths<N>` is `never` and the callable form is effectively
178180
* disabled at the type level. This keeps the builder sound downstream of
179181
* stages that invalidate the original document's nested-path tree.
180-
* `f.raw(...)` remains available in that state for callers that need an
181-
* explicit unvalidated path.
182+
* `f.rawPath(...)` remains available in that state for callers that need
183+
* an explicit unvalidated path.
182184
*/
183185
export type FieldAccessor<S extends DocShape, N extends NestedDocShape = Record<string, never>> = {
184186
readonly [K in keyof S & string]: Expression<S[K]>;
@@ -190,12 +192,15 @@ export type FieldAccessor<S extends DocShape, N extends NestedDocShape = Record<
190192
* model surface — data-migration authoring is the canonical case
191193
* (e.g. backfilling a field that is not yet in the contract). Default
192194
* `F` is the opaque `DocField`; callers can narrow via the explicit
193-
* generic: `f.raw<StringField>("status").set("active")`.
195+
* generic: `f.rawPath<StringField>("status").set("active")`.
194196
*
195-
* Does not participate in `ValidPaths<N>` / `ResolvePath<N, P>` — the
196-
* path is passed through verbatim and no IDE autocomplete is offered.
197+
* The method is named `rawPath` (not `raw`) so a user model with a
198+
* top-level `raw` field still resolves `f.raw` to the field-expression
199+
* property, not to this escape hatch. Does not participate in
200+
* `ValidPaths<N>` / `ResolvePath<N, P>` — the path is passed through
201+
* verbatim and no IDE autocomplete is offered.
197202
*/
198-
raw<F extends DocField = DocField>(path: string): LeafExpression<F>;
203+
rawPath<F extends DocField = DocField>(path: string): LeafExpression<F>;
199204
};
200205

201206
function buildExpression<F extends DocField>(path: string): Expression<F> {
@@ -266,7 +271,7 @@ export function createFieldAccessor<
266271
if (prop === 'stage') {
267272
return stageInstance;
268273
}
269-
if (prop === 'raw') {
274+
if (prop === 'rawPath') {
270275
return (path: string) => buildExpression<DocField>(path);
271276
}
272277
return buildExpression(prop);

packages/2-mongo-family/5-query-builders/query-builder/test/builder.test.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -83,12 +83,13 @@ describe('PipelineChain', () => {
8383
expect(callableStage.filter).toEqual(expectedFilter);
8484
});
8585

86-
// Runtime non-regression for the `f.raw(path)` escape hatch (TML-2281):
87-
// the resulting expression must emit the same filter/update nodes as the
88-
// strict callable form, just without compile-time path validation.
89-
it('match via f.raw(path) emits identical nodes as the strict callable', () => {
86+
// Runtime non-regression for the `f.rawPath(path)` escape hatch
87+
// (TML-2281): the resulting expression must emit the same filter/update
88+
// nodes as the strict callable form, just without compile-time path
89+
// validation.
90+
it('match via f.rawPath(path) emits identical nodes as the strict callable', () => {
9091
const rawPlan = createCustomersBuilder()
91-
.match((f) => f.raw('status').exists(false))
92+
.match((f) => f.rawPath('status').exists(false))
9293
.build();
9394
const rawStage = rawPlan.command.pipeline[0] as MongoMatchStage;
9495

packages/2-mongo-family/5-query-builders/query-builder/test/field-accessor.test-d.ts

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -154,16 +154,16 @@ describe('ObjectExpression operator surface', () => {
154154
});
155155
});
156156

157-
describe('FieldAccessor.raw escape hatch', () => {
157+
describe('FieldAccessor.rawPath escape hatch', () => {
158158
it('accepts an arbitrary string path without contract validation', () => {
159159
const f = createFieldAccessor<CustomerShape, CustomerNested>();
160-
const expr = f.raw('status');
160+
const expr = f.rawPath('status');
161161
expectTypeOf(expr).toExtend<LeafExpression<DocField>>();
162162
});
163163

164164
it('returns the full leaf operator surface (set, exists, inc, …)', () => {
165165
const f = createFieldAccessor<CustomerShape, CustomerNested>();
166-
const expr = f.raw('status');
166+
const expr = f.rawPath('status');
167167
expectTypeOf(expr.set).toBeFunction();
168168
expectTypeOf(expr.unset).toBeFunction();
169169
expectTypeOf(expr.exists).toBeFunction();
@@ -173,27 +173,41 @@ describe('FieldAccessor.raw escape hatch', () => {
173173

174174
it('accepts paths that are not in ValidPaths<N>', () => {
175175
const f = createFieldAccessor<CustomerShape, CustomerNested>();
176-
// 'status' is not a valid path on Customer; `f.raw` must still accept
177-
// it — that is the whole point of the escape hatch (migration authoring
178-
// where the target field is not yet in the contract).
179-
f.raw('status');
180-
f.raw('deeply.nested.not.in.contract');
176+
// 'status' is not a valid path on Customer; `f.rawPath` must still
177+
// accept it — that is the whole point of the escape hatch (migration
178+
// authoring where the target field is not yet in the contract).
179+
f.rawPath('status');
180+
f.rawPath('deeply.nested.not.in.contract');
181181
});
182182

183183
it('remains available when N is empty (callable disabled)', () => {
184184
const f = createFieldAccessor<CustomerShape>();
185185
// Callable (strict) is disabled because ValidPaths<{}> = never.
186-
// `f.raw` has no such dependency on N and remains usable.
187-
const expr = f.raw('status');
186+
// `f.rawPath` has no such dependency on N and remains usable.
187+
const expr = f.rawPath('status');
188188
expectTypeOf(expr).toExtend<LeafExpression<DocField>>();
189189
});
190190

191191
it('narrows the return via the explicit generic', () => {
192192
const f = createFieldAccessor<CustomerShape, CustomerNested>();
193193
type StringField = { readonly codecId: 'mongo/string@1'; readonly nullable: false };
194-
const expr = f.raw<StringField>('status');
194+
const expr = f.rawPath<StringField>('status');
195195
expectTypeOf(expr).toEqualTypeOf<LeafExpression<StringField>>();
196196
});
197+
198+
it('does not shadow a legitimate top-level `raw` field', () => {
199+
// Regression test: the escape hatch is named `rawPath`, not `raw`, so a
200+
// user model with a `raw` field still resolves `f.raw` to the field
201+
// expression (via the mapped-type property form) rather than to the
202+
// escape-hatch function.
203+
type ModelWithRawField = {
204+
readonly id: { readonly codecId: 'mongo/string@1'; readonly nullable: false };
205+
readonly raw: { readonly codecId: 'mongo/string@1'; readonly nullable: false };
206+
};
207+
const f = createFieldAccessor<ModelWithRawField>();
208+
expectTypeOf(f.raw).toExtend<LeafExpression<DocField>>();
209+
expectTypeOf(f.raw).not.toBeFunction();
210+
});
197211
});
198212

199213
describe('Pipeline integration — N threading', () => {

projects/mongo-pipeline-builder/plans/callable-field-accessor-path-validation-plan.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,4 +119,4 @@ Carried forward from the spec's Open Questions. These are execution-time decisio
119119
3. **Nullable intermediate behaviour.** Default: fold nullability downward (nullable intermediate → leaf's `nullable = true`). Implementation note for task 1.4.
120120
4. **Compile-time performance.** No depth cap on `PathCompletions` for v1. Monitor CI time during PR; if the query-builder package's typecheck time regresses meaningfully, add a cap and file a follow-up.
121121
5. **ORM callable form.** In scope only to the extent of keeping the ORM compiling. If the ORM already derives a nested shape equivalent to `N`, wire it through; otherwise leave its callable form defaulted (unusable) and file a follow-up.
122-
6. **Unvalidated string paths for migration authoring.** **Resolved in review round 1 (F12/F13).** Strict validation breaks backfill migrations that write to fields not yet present in the pre-migration contract (the canonical case: the `retail-store` `backfill-product-status` migration writes to `status` before the post-migration contract hash rolls forward). Resolution: add an explicit `f.raw("path")` escape hatch on `FieldAccessor` that returns `LeafExpression<DocField>` with the verbatim path, callable-form validation bypassed. Migration authoring uses `f.raw("status")` in place of `f("status")`. Additionally, example consumer tsconfigs must include `migrations/**/*.ts` so the typecheck exercises migration code (the `retail-store` tsconfig was updated accordingly). Follow-up: audit other example/package tsconfigs for the same exclusion pattern and file a separate ticket if others are found.
122+
6. **Unvalidated string paths for migration authoring.** **Resolved in review round 1 (F12/F13), refined in round 2 (CodeRabbit `f.raw` shadow finding).** Strict validation breaks backfill migrations that write to fields not yet present in the pre-migration contract (the canonical case: the `retail-store` `backfill-product-status` migration writes to `status` before the post-migration contract hash rolls forward). Resolution: add an explicit `f.rawPath("path")` escape hatch on `FieldAccessor` that returns `LeafExpression<DocField>` with the verbatim path, callable-form validation bypassed. Migration authoring uses `f.rawPath("status")` in place of `f("status")`. The method is named `rawPath` (not `raw`) so a user model with a legitimate top-level `raw` field still resolves `f.raw` to its field expression — `raw` on the property namespace would have silently shadowed the field, and the callable fallback `f("raw")` is disabled downstream of replacement stages. Additionally, example consumer tsconfigs must include `migrations/**/*.ts` so the typecheck exercises migration code (the `retail-store` tsconfig was updated accordingly). Follow-up: audit other example/package tsconfigs for the same exclusion pattern and file a separate ticket if others are found.

0 commit comments

Comments
 (0)