From d5b1b05d4122dd9e97fb4f5c92e0090a155d6bf9 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 2 Jul 2026 21:16:15 +0200 Subject: [PATCH 1/3] TML-2964: spec + plan for the codec-agnostic Mongo aggregation builder Signed-off-by: willbot Signed-off-by: Will Madden --- projects/mongo-agg-codec-source/plan.md | 40 +++++++++++++++ projects/mongo-agg-codec-source/spec.md | 67 +++++++++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 projects/mongo-agg-codec-source/plan.md create mode 100644 projects/mongo-agg-codec-source/spec.md diff --git a/projects/mongo-agg-codec-source/plan.md b/projects/mongo-agg-codec-source/plan.md new file mode 100644 index 0000000000..046c6c495d --- /dev/null +++ b/projects/mongo-agg-codec-source/plan.md @@ -0,0 +1,40 @@ +# Plan — codec-agnostic Mongo aggregation builder + +Spec: [`./spec.md`](./spec.md). Linear: **TML-2964**. Branch: `tml-2964-agg-codec-source` (off main incl. #897). + +## Grounding (from codebase investigation) + +- The field proxy already exists and is generic (`createFieldAccessor`, minted per-call in 6 stage methods); `f.field` already satisfies `TypedAggExpr` as a `fn.*` argument. Nothing to build there. +- 6 stages are already single-arg callbacks (`project` spec-overload, `addFields`, `group`, `replaceRoot`, `sortByCount`, `redact`); `match` is callback too. Widening signatures is mechanical. +- `fn`/`acc` call sites: 1 production (`mongo-control-adapter.ts`, incl. standalone `fn.setUnion` and `fn.eq` inside `match`), 3 examples, 2 integration-test files, ~9 package test files. +- The builder-side codec need is exactly one thing: the **operation→output-codec table** (value + type). The builder stamps ids; only the runtime resolves ids to codecs, and it already has the registry. So the port the query-builder defines is just the table — no registry threading. +- `mongoQuery` has one production call site (`buildMongoStaticContext`), where the context and adapter are already in scope. + +## Slices (stack: 1 → 2 → 3) + +Stacked, not parallel: all three rework the same type vocabulary (`types.ts`), the helper factories, and the result-shape reifier — file contention would eat any parallel win — and slice 3 upgrades placeholders slice 2 introduces (`push`/`addToSet`). + +### Slice 1 — the spine: context-bound `fn` with adapter-declared output codecs + +**Outcome:** the adapter declares the operation→output-codec table (value + type, the Mongo `queryOperationTypes` analog); it flows adapter → context → `mongoQuery` (required param, no default) → `PipelineChain`. `fn` is minted from the root (exposed there) and handed to the stage callbacks (`project`/`addFields`/`replaceRoot`/`sortByCount`/`redact`/`match` gain the `fn` param). Role-fixed scalar helpers (string/bool/date/numeric/objectId outputs) and `count`/`sortByCount` stamp table-sourced codecs at the value **and** type level (generics carry the table type; input params constrained by decoded output type, so `dateToString({date: f.createdAt})` compiles uncast). The result-shape reifier consumes the same table, so role-fixed computed scalars decode at runtime. The free-floating `fn` export is deleted; array/document helpers temporarily stamp no codec (observable behavior unchanged — their fake codecs already resolved to `unknown`). `acc` untouched. +**Hands to:** the threaded table + context-bound minting pattern + decoded-output input typing, which slices 2–3 reuse. +**End-to-end test:** `fn.dateToString({date: f.createdAt})`/`fn.dateDiff` compile uncast; a `$project` with `fn.toDate`/`fn.dateToString` decodes at runtime (integration); grep-guard: no `mongo/*@1` in `expression-helpers.ts`/`types.ts`/`builder.ts`. + +### Slice 2 — propagation + accumulators + +**Outcome:** `acc` becomes context-bound the same way; free-floating `acc` export deleted; `group` callback gains `acc` (`group((f, fn, acc) => …)`). Propagation rules land: `$min`/`$max`/`$first`/`$last`/`$cond`-branches carry the operand's codec; multi-operand arithmetic propagates when all codec-bearing operands agree (literals ignored), else falls back to the declared output; `acc.sum` propagates, `acc.avg`/`stdDev*` use declared outputs; `$group {_id:null}` becomes a structural null marker (no `mongo/null@1`). `push`/`addToSet` stamp no codec (placeholder, upgraded in slice 3). Reifier applies the same propagation during replay, so propagated accumulator outputs decode. +**Hands to:** propagation machinery (builder + reifier) that slice 3's element propagation reuses; the one canonical accumulator table TML-2954 slices 2/4 consume. +**End-to-end test:** `$group` with `acc.max()` decodes a `Date`; `acc.sum()` propagates and decodes; arithmetic agreement/disagreement type tests; grep-guard extends to `accumulator-helpers.ts`. + +### Slice 3 — structural outputs + +**Outcome:** array/document results become real structural shapes: `$split`→array of declared-string elements, `$range`→array of declared-numeric elements, `$concatArrays`/`$setUnion`/`$slice`/`$reverseArray`/element-access propagate the input element, `$map`/`$zip`/`$objectToArray`→unknown element; `$arrayToObject`/`$getField`/`$regexFind`/etc.→document shapes (`Record`); `push`/`addToSet` upgraded to array-of-element. Type level and reifier agree; decodable elements decode. +**Hands to:** project close-out; final DoD grep across the package incl. emitted `.d.ts`. +**End-to-end test:** `$split` of a contract string field decodes its elements; `$arrayToObject` types as `Record`; `acc.push()` yields `Date[]` decoded. + +## Delivery mechanics + +- Implementers: **sonnet**, tests-first, per `references/dispatch-work.md`; reviewer: **opus** before each PR. +- One PR per slice; slice 1 on `tml-2964-agg-codec-source`, subsequent slices branch after merge. +- Full gate before each PR: build, typecheck, lint (incl. `lint:deps`, `lint:casts`), `fixtures:check`, all three test suites, examples. +- TML-2954 slices 2–4 (`$group`/`$unwind`/`$replaceRoot` reify) resume after slice 2 lands, consuming its accumulator resolution. diff --git a/projects/mongo-agg-codec-source/spec.md b/projects/mongo-agg-codec-source/spec.md new file mode 100644 index 0000000000..7dcc730930 --- /dev/null +++ b/projects/mongo-agg-codec-source/spec.md @@ -0,0 +1,67 @@ +# Spec — codec-agnostic Mongo aggregation builder + +Linear: **TML-2964** (subsumes cancelled TML-2963). Plan: [`./plan.md`](./plan.md). + +## Problem + +The Mongo aggregation helpers `fn`/`acc` are free-floating module exports that stamp hardcoded codec-id literals (`mongo/double@1`, `mongo/string@1`, …) into every computed expression — both as runtime `_field` values (`expression-helpers.ts`, `accumulator-helpers.ts`, `builder.ts` `count`/`sortByCount`) and as type-level literals (`types.ts`). The family layer must not know specific codec ids; that is adapter knowledge. Because the helpers are detached from any execution context, they had no codec source to consult and inlined literals instead. Flagged on #897. + +## Outcome + +`fn`/`acc` become context-bound: minted from the query-builder root, which is constructed with the execution context's codec knowledge. They are delivered to callers through stage callbacks (mirroring the SQL builder) and remain available standalone from the root. Computed and contract fields share one representation — real codec ids — so they interoperate with no casts. Array/document/null results are structural, not codecs. The family names Mongo operators and nothing else; every codec id, at the value level and the type level, originates from the adapter or the contract. + +## Codec resolution + +An operator's output codec is determined by exactly one of: + +**Adapter-declared operation outputs.** The Mongo adapter declares an operation→output-codec table for operators whose output type is fixed and input-independent: `$concat`/`$toLower`/`$toUpper`/`$toString`/`$dateToString`/`$type`/… → its string codec; `$eq`/`$gt`/`$regexMatch`/`$isArray`/… → its bool codec; `$toDate`/`$dateAdd`/`$dateSubtract`/`$dateTrunc`/`$dateFromString` → its date codec; `$year`/`$size`/`$strLenCP`/`$cmp`/`$dateDiff`/`$count`/… → its numeric codec of choice; `$toObjectId` → its objectId codec. The table is exported as a value **and** as a type (the Mongo analog of SQL's `queryOperationTypes`), so one declaration drives both runtime stamping and compile-time output types. The adapter owns every choice in it — including which numeric codec `$count` returns. + +**Propagation.** Operators whose output is the operand's own type (`$min`/`$max`/`$first`/`$last`, `$cond` branches, element access into a known array) carry the operand's codec forward. This is operator semantics, which the family legitimately owns; no codec is named. Multi-operand arithmetic (`$add`/`$subtract`/`$multiply`/`$divide`): when every codec-bearing operand carries the same codec id, that codec propagates (codec-less literals are ignored); otherwise the result falls back to the adapter's declared output for that operation. Date arithmetic goes through `fn.dateAdd`/`fn.dateSubtract` (declared date outputs); plain `$add`/`$subtract` over a date types as the declared numeric output — documented, and no worse than the previous hardcode. + +**Structure.** Arrays and documents are shapes, not codecs — the adapter registers no array/document/null codec. Arrays are an array shape whose element is a descriptor: propagated from the input array (`$concatArrays`/`$setUnion`/`$slice`/`$reverseArray`/`$arrayElemAt`/`$first`/`$last` element access), a declared leaf (`$split` → string elements, `$range` → numeric elements), or `unknown` (`$map` body, `$zip`, `$objectToArray`). Documents are a document shape — `Record` for dynamic keys (`$arrayToObject`, `$getField`, `$regexFind`), merged shapes where statically known. `$group { _id: null }` is the literal `null`, a structural marker. This reuses the runtime `MongoResultShape` vocabulary (leaf/array/document/unknown) and the builder's existing `ObjectField`/`ModelArrayField` markers; only leaves carry codecs. + +## Input typing without labels + +Helper parameters that require a particular value type (`dateToString`'s `date`, `trim`'s `input`, `regexMatch`'s `regex`, …) are constrained by **decoded output type**, not codec identity: the parameter accepts any expression whose codec decodes to `Date` (resp. `string`, `number`, `boolean`), computed as a type-level filter over the contract's codec-type map — the same filtering pattern as SQL's `CodecIdsWithTrait`, keyed on decoded output type instead of traits. A contract `createdAt` column and a computed `fn.toDate(...)` both satisfy the date constraint: one representation, no casts, no category labels anywhere in the family. + +## Delivery + +- **Stage callbacks:** `project((f, fn) => spec)`, `addFields((f, fn) => spec)`, `group((f, fn, acc) => spec)`, `replaceRoot((f, fn) => expr)`, `sortByCount((f, fn) => expr)`, `redact((f, fn) => expr)`, and `match((f, fn) => filter)` — the control adapter already needs `fn.eq` inside `match`. `f` is the existing `createFieldAccessor` proxy over the stage's current shape; no new proxy. Existing single-param callbacks keep compiling (a callback taking fewer parameters is assignable). +- **Standalone:** the context-bound `fn`/`acc` are also exposed on the query root (and via the static context), because real consumers build expressions outside stage callbacks — `mongo-control-adapter.ts` builds `fn.setUnion(...)` standalone. +- The free-floating, context-free `fn`/`acc` module exports are **deleted**. A detached helper cannot source a codec, so no context-free form exists. All call sites migrate (~9 files: the control adapter, three examples, integration tests, package tests). + +## Threading and layering + +`mongoQuery` requires a codec source (the operation-output table plus codec lookup); there is **no default** — a builder without codec knowledge cannot mint the helpers, and a family-level fallback table is exactly what this project removes. `buildMongoStaticContext` already has the context in scope and passes it through (`mongo-static.ts` → `query.ts` → `state-classes.ts` → `PipelineChain`). Direct `mongoQuery({contractJson})` callers migrate to `mongoStatic()`/the facade (the supported surface since #888) or supply a source explicitly; tests construct a test-local source. + +The query-builder package defines the minimal interface it consumes (a codec-source port); the runtime context and adapter satisfy it structurally. The family imports nothing from the runtime or target layers — `lint:deps` stays green. + +At the type level, the builder's generics carry the operation-table type and the contract's codec-type map, so computed expressions' output codec ids and TS types resolve entirely from type parameters. No `mongo/*@1` literal remains in the family's source **or** its emitted `.d.ts`. + +## Runtime decode of computed scalars + +A computed scalar carries a real codec id, so the per-stage result shape (TML-2954's reifier) records it as a leaf and the runtime decodes it — `fn.toDate(...)` returns a real `Date`, `fn.dateToString(...)` a real string: the same "every field through its codec" invariant as any other read. The propagation/agreement rules keep decode sound — a leaf is only stamped with a codec the value actually has (int32-vs-double mixes are benign; both decode to JS number). Slice tests cover date arithmetic explicitly. + +## Non-goals + +- Decoding heterogeneous/computed structural results (`$map` body, `$zip`, dynamic-key documents) — they stay `unknown` (pass-through). Correct final answer, not a gap. +- Contract-field codec handling — unchanged; contract fields already carry their contract codec. +- The `$group`/`$unwind`/`$replaceRoot` runtime result-shape reify — owned by TML-2954 slices 2–4, which consume this project's resolution (one table, no drift). +- Explicit-codec literals (`fn.literal(v, codec)`) — consistent with TML-2959's direction but out of scope; literals stay codec-less here and are ignored by propagation. + +## Definition of done + +- Grep-guard: no `mongo/*@1` literal in query-builder source or emitted `.d.ts`. +- `fn`/`acc` are context-bound (stage callbacks + root); the free-floating exports are deleted; all call sites migrated (control adapter, three examples, integration and package tests). +- `fn.dateDiff({ startDate: f.createdAt, … })` and `fn.dateToString({ date: f.createdAt, … })` type-check with no cast — contract/computed unification proven. +- Computed scalars decode at runtime — integration tests: `$toDate`, `$dateToString`, and a propagated `$max` return decoded values; date-arithmetic covered. +- Structural outputs resolve structurally: array element propagation, `$split`→string elements, `Record` documents, `null` group id. +- Full gate: build, typecheck, lint (incl. `lint:deps`, `lint:casts`), fixtures:check, all three test suites; examples green. + +## Alternatives considered + +- **Scalar-kind vocabulary** (TML-2963): a parallel type classification (`string`/`number`/…) beside codecs. Rejected — codecs are the single type vocabulary; it also split computed and contract fields into non-interoperable representations, breaking `dateToString({date: f.createdAt})`. +- **Adapter role→codec table** (`numeric`/`textual`/`boolean`/`date`/`objectId` → codec): lighter than per-operation declarations, but reintroduces scalar-category labels into the family and cannot express per-operation facts (which numeric codec `$count` returns; date-arithmetic signatures). Rejected in favor of per-operation output declarations. +- **Trait-based resolution:** traits are a compile-time input-typing device; no runtime trait query exists, `date`/`objectId` have no trait, and `numeric` is ambiguous (double and int32 both carry it). Rejected. +- **Defaulted codec source on `mongoQuery`:** implies a family-level fallback codec table — the thing being removed. Rejected; the source is required. +- **Inferring literal codecs from JS value shape:** rejected repo-wide (TML-2959 deletes the SQL inferer); literals stay codec-less. From 75e39a6c7311b841e3d5ec85ae5f13883049f553 Mon Sep 17 00:00:00 2001 From: willbot Date: Fri, 3 Jul 2026 07:40:25 +0200 Subject: [PATCH 2/3] TML-2964: Mongo fn helpers source output codecs from the adapter, not literals The adapter declares an operation-to-output-codec table (value + type, the Mongo analog of SQL queryOperationTypes), threaded through the execution context into mongoQuery({ contractJson, operationCodecs }) and the pipeline chain. fn is minted from that table and delivered context-bound: exposed on the query root and passed as the second parameter to the stage callbacks (project/addFields/replaceRoot/sortByCount/redact/match). The free-floating fn export is deleted. Computed outputs are table-sourced at the value and type level; input params are constrained by decoded output type, so a contract date field passes to fn.dateToString/dateDiff uncast. The result-shape reifier resolves in-table operators to leaf codecs, so computed scalars decode at runtime. Array and document helpers stamp a structural unresolved marker (resolves to unknown, as before); acc is unchanged for now. Signed-off-by: willbot Signed-off-by: Will Madden --- .../query-builder/src/accumulator-helpers.ts | 16 +- .../query-builder/src/builder.ts | 282 ++++-- .../query-builder/src/exports/index.ts | 17 +- .../query-builder/src/expression-helpers.ts | 937 +++++++++--------- .../src/pipeline-result-shape.ts | 25 +- .../query-builder/src/query.ts | 32 +- .../query-builder/src/resolve-path.ts | 6 +- .../query-builder/src/state-classes.ts | 83 +- .../query-builder/src/types.ts | 66 +- .../test/accumulator-helpers.test-d.ts | 15 +- .../test/accumulator-helpers.test.ts | 4 +- .../test/builder-new-stages.test.ts | 7 +- .../query-builder/test/builder.test-d.ts | 198 +++- .../query-builder/test/builder.test.ts | 20 +- .../test/contract-free-collection.test.ts | 5 +- .../test/expression-helpers.test-d.ts | 389 ++++---- .../test/expression-helpers.test.ts | 70 +- .../test/field-accessor.test-d.ts | 13 +- .../test/find-and-modify.test.ts | 5 +- .../test/fixtures/test-contract.ts | 76 ++ .../test/pipeline-result-shape.test.ts | 54 +- .../test/pipeline-updates.test.ts | 5 +- .../query-builder/test/raw-command.test.ts | 11 +- .../query-builder/test/result-shape.test.ts | 48 +- .../test/state-machine-surface.test-d.ts | 6 +- .../test/state-machine.test-d.ts | 67 +- .../query-builder/test/types.test-d.ts | 6 +- .../query-builder/test/writes.test.ts | 6 +- .../7-runtime/src/mongo-execution-stack.ts | 12 +- .../test/decode-via-query-builder.test.ts | 11 +- .../7-runtime/test/decode.integration.test.ts | 6 +- .../7-runtime/test/mongo-middleware.test.ts | 3 +- .../test/mongo-runtime-abort.test.ts | 3 +- .../7-runtime/test/runtime-types.test-d.ts | 13 +- .../mongo/src/static/mongo-static.ts | 11 +- .../src/core/mongo-control-adapter.ts | 5 +- .../src/core/operation-output-codecs.ts | 75 ++ .../2-mongo-adapter/src/exports/runtime.ts | 9 + 38 files changed, 1623 insertions(+), 994 deletions(-) create mode 100644 packages/3-mongo-target/2-mongo-adapter/src/core/operation-output-codecs.ts diff --git a/packages/2-mongo-family/5-query-builders/query-builder/src/accumulator-helpers.ts b/packages/2-mongo-family/5-query-builders/query-builder/src/accumulator-helpers.ts index 5e37f8f89f..4c528ccf09 100644 --- a/packages/2-mongo-family/5-query-builders/query-builder/src/accumulator-helpers.ts +++ b/packages/2-mongo-family/5-query-builders/query-builder/src/accumulator-helpers.ts @@ -1,13 +1,13 @@ import type { MongoAggExpr } from '@prisma-next/mongo-query-ast/execution'; import { MongoAggAccumulator, MongoAggLiteral } from '@prisma-next/mongo-query-ast/execution'; -import type { - ArrayField, - DocField, - NullableNumericField, - NumericField, - TypedAccumulatorExpr, - TypedAggExpr, -} from './types'; +import type { DocField, TypedAccumulatorExpr, TypedAggExpr } from './types'; + +// TML-2964: `acc` still carries hardcoded codec-id literals; it becomes +// context-bound like `fn`. Private aliases so the rest of the package +// carries no codec-id literals. +type NumericField = { readonly codecId: 'mongo/double@1'; readonly nullable: false }; +type NullableNumericField = { readonly codecId: 'mongo/double@1'; readonly nullable: true }; +type ArrayField = { readonly codecId: 'mongo/array@1'; readonly nullable: false }; function namedAccumulatorArgs( args: Readonly | undefined>>, diff --git a/packages/2-mongo-family/5-query-builders/query-builder/src/builder.ts b/packages/2-mongo-family/5-query-builders/query-builder/src/builder.ts index 5fec8e60c8..1183762bbe 100644 --- a/packages/2-mongo-family/5-query-builders/query-builder/src/builder.ts +++ b/packages/2-mongo-family/5-query-builders/query-builder/src/builder.ts @@ -58,6 +58,7 @@ import { } from '@prisma-next/mongo-query-ast/execution'; import { castAs } from '@prisma-next/utils/casts'; import { ifDefined } from '@prisma-next/utils/defined'; +import { createFn, type MongoFn } from './expression-helpers'; import { createFieldAccessor, type Expression, type FieldAccessor } from './field-accessor'; import { createLookupFrom, @@ -70,11 +71,13 @@ import { computePipelineResultShape } from './pipeline-result-shape'; import type { ModelArrayField, NestedDocShape } from './resolve-path'; import { contractModelToMongoResultShape } from './result-shape'; import type { + ComputedField, DocField, DocShape, ExtractDocShape, GroupedDocShape, GroupSpec, + MongoOperationCodecTable, ProjectedShape, ResolveRow, SortSpec, @@ -90,6 +93,16 @@ interface PipelineChainState { readonly modelName?: string; } +/** + * The context-bound `fn` helper surface handed to stage callbacks and + * exposed on the chain: outputs typed from the adapter table, inputs + * constrained by the contract's decoded codec outputs. + */ +export type StageFn< + TContract extends MongoContractWithTypeMaps, + TOps extends MongoOperationCodecTable, +> = MongoFn>; + /** * The pipeline state in the query-builder state machine. * @@ -121,6 +134,7 @@ export class PipelineChain< F extends FindAndModifyEnabled = 'fam-ok', L extends LeadingMatch = 'leading', N extends NestedDocShape = Record, + TOps extends MongoOperationCodecTable = MongoOperationCodecTable, > { declare readonly __updateCompat: U; declare readonly __findAndModifyCompat: F; @@ -128,10 +142,23 @@ export class PipelineChain< readonly #contract: TContract; readonly #state: PipelineChainState; + readonly #operationCodecs: TOps; + #fn: StageFn | undefined; - constructor(contract: TContract, state: PipelineChainState) { + constructor(contract: TContract, state: PipelineChainState, operationCodecs: TOps) { this.#contract = contract; this.#state = state; + this.#operationCodecs = operationCodecs; + } + + /** + * The context-bound expression helpers, minted from the operation table + * this chain was constructed with. Also passed to stage callbacks as the + * second callback parameter. + */ + get fn(): StageFn { + this.#fn ??= createFn>(this.#operationCodecs); + return this.#fn; } /** @@ -149,11 +176,15 @@ export class PipelineChain< NewF extends FindAndModifyEnabled, NewL extends LeadingMatch = 'past-leading', NewN extends NestedDocShape = Record, - >(stage: MongoPipelineStage): PipelineChain { - return new PipelineChain(this.#contract, { - ...this.#state, - stages: [...this.#state.stages, stage], - }); + >(stage: MongoPipelineStage): PipelineChain { + return new PipelineChain( + this.#contract, + { + ...this.#state, + stages: [...this.#state.stages, stage], + }, + this.#operationCodecs, + ); } #writeMeta(): PlanMeta { @@ -176,15 +207,19 @@ export class PipelineChain< */ match( filter: MongoFilterExpr, - ): PipelineChain; + ): PipelineChain; match( - fn: (fields: FieldAccessor) => MongoFilterExpr, - ): PipelineChain; + fn: (fields: FieldAccessor, helpers: StageFn) => MongoFilterExpr, + ): PipelineChain; match( - filterOrFn: MongoFilterExpr | ((fields: FieldAccessor) => MongoFilterExpr), - ): PipelineChain { + filterOrFn: + | MongoFilterExpr + | ((fields: FieldAccessor, helpers: StageFn) => MongoFilterExpr), + ): PipelineChain { const filter = - typeof filterOrFn === 'function' ? filterOrFn(createFieldAccessor()) : filterOrFn; + typeof filterOrFn === 'function' + ? filterOrFn(createFieldAccessor(), this.fn) + : filterOrFn; return this.#withStage( new MongoMatchStage(filter), ); @@ -196,7 +231,7 @@ export class PipelineChain< */ sort( spec: SortSpec, - ): PipelineChain { + ): PipelineChain { return this.#withStage( new MongoSortStage(spec as Record), ); @@ -209,7 +244,7 @@ export class PipelineChain< */ limit( n: number, - ): PipelineChain { + ): PipelineChain { return this.#withStage( new MongoLimitStage(n), ); @@ -223,7 +258,7 @@ export class PipelineChain< */ skip( n: number, - ): PipelineChain { + ): PipelineChain { return this.#withStage( new MongoSkipStage(n), ); @@ -231,7 +266,7 @@ export class PipelineChain< sample( n: number, - ): PipelineChain { + ): PipelineChain { return this.#withStage( new MongoSampleStage(n), ); @@ -247,17 +282,18 @@ export class PipelineChain< * (`f.newField`) but do not themselves carry nested structure. */ addFields>>( - fn: (fields: FieldAccessor) => NewFields, + fn: (fields: FieldAccessor, helpers: StageFn) => NewFields, ): PipelineChain< TContract, Shape & ExtractDocShape, U, 'fam-cleared', 'past-leading', - N + N, + TOps > { const accessor = createFieldAccessor(); - const newFields = fn(accessor); + const newFields = fn(accessor, this.fn); const exprRecord: Record = {}; for (const [key, typed] of Object.entries(newFields)) { exprRecord[key] = typed.node; @@ -288,7 +324,8 @@ export class PipelineChain< 'update-cleared', 'fam-cleared', 'past-leading', - N + N, + TOps > { const fromCallable = createLookupFrom(this.#contract); const result = fn(fromCallable); @@ -328,20 +365,39 @@ export class PipelineChain< Pick, U, 'fam-cleared', - 'past-leading' + 'past-leading', + Record, + TOps >; project>>( - fn: (fields: FieldAccessor) => Spec, - ): PipelineChain, U, 'fam-cleared', 'past-leading'>; + fn: (fields: FieldAccessor, helpers: StageFn) => Spec, + ): PipelineChain< + TContract, + ProjectedShape, + U, + 'fam-cleared', + 'past-leading', + Record, + TOps + >; project( ...args: unknown[] - ): PipelineChain { + ): PipelineChain< + TContract, + DocShape, + U, + 'fam-cleared', + 'past-leading', + Record, + TOps + > { if (args.length === 1 && typeof args[0] === 'function') { const fn = args[0] as ( fields: FieldAccessor, + helpers: StageFn, ) => Record>; const accessor = createFieldAccessor(); - const spec = fn(accessor); + const spec = fn(accessor, this.fn); const projection: Record = {}; for (const [key, val] of Object.entries(spec)) { projection[key] = val === 1 ? 1 : (val as TypedAggExpr).node; @@ -372,7 +428,8 @@ export class PipelineChain< 'update-cleared', 'fam-cleared', 'past-leading', - N + N, + TOps > { return this.#withStage< UnwoundShape, @@ -397,7 +454,9 @@ export class PipelineChain< GroupedDocShape, 'update-cleared', 'fam-cleared', - 'past-leading' + 'past-leading', + Record, + TOps > { const accessor = createFieldAccessor(); const spec = fn(accessor); @@ -427,10 +486,21 @@ export class PipelineChain< * the original document structure. */ replaceRoot( - fn: (fields: FieldAccessor) => Expression | TypedAggExpr, - ): PipelineChain { + fn: ( + fields: FieldAccessor, + helpers: StageFn, + ) => Expression | TypedAggExpr, + ): PipelineChain< + TContract, + NewShape, + U, + 'fam-cleared', + 'past-leading', + Record, + TOps + > { const accessor = createFieldAccessor(); - const expr = fn(accessor); + const expr = fn(accessor, this.fn); return this.#withStage(new MongoReplaceRootStage(expr.node)); } @@ -438,28 +508,35 @@ export class PipelineChain< field: Field, ): PipelineChain< TContract, - Record, + Record>, 'update-cleared', 'fam-cleared', - 'past-leading' + 'past-leading', + Record, + TOps > { return this.#withStage(new MongoCountStage(field)); } sortByCount( - fn: (fields: FieldAccessor) => Expression | TypedAggExpr, + fn: ( + fields: FieldAccessor, + helpers: StageFn, + ) => Expression | TypedAggExpr, ): PipelineChain< TContract, { _id: F2; - count: { readonly codecId: 'mongo/double@1'; readonly nullable: false }; + count: ComputedField; }, 'update-cleared', 'fam-cleared', - 'past-leading' + 'past-leading', + Record, + TOps > { const accessor = createFieldAccessor(); - const expr = fn(accessor); + const expr = fn(accessor, this.fn); return this.#withStage(new MongoSortByCountStage(expr.node)); } @@ -470,10 +547,13 @@ export class PipelineChain< * Shape- and nested-path-preserving (the document tree is unchanged). */ redact( - fn: (fields: FieldAccessor) => Expression | TypedAggExpr, - ): PipelineChain { + fn: ( + fields: FieldAccessor, + helpers: StageFn, + ) => Expression | TypedAggExpr, + ): PipelineChain { const accessor = createFieldAccessor(); - const expr = fn(accessor); + const expr = fn(accessor, this.fn); return this.#withStage( new MongoRedactStage(expr.node), ); @@ -529,7 +609,7 @@ export class PipelineChain< unionWith( collection: string, pipeline?: ReadonlyArray, - ): PipelineChain { + ): PipelineChain { return this.#withStage( new MongoUnionWithStage(collection, pipeline), ); @@ -542,7 +622,15 @@ export class PipelineChain< boundaries: ReadonlyArray; default_?: unknown; output?: Record; - }): PipelineChain { + }): PipelineChain< + TContract, + DocShape, + 'update-cleared', + 'fam-cleared', + 'past-leading', + Record, + TOps + > { return this.#withStage( new MongoBucketStage(options), ); @@ -553,7 +641,15 @@ export class PipelineChain< buckets: number; output?: Record; granularity?: string; - }): PipelineChain { + }): PipelineChain< + TContract, + DocShape, + 'update-cleared', + 'fam-cleared', + 'past-leading', + Record, + TOps + > { return this.#withStage( new MongoBucketAutoStage(options), ); @@ -571,7 +667,15 @@ export class PipelineChain< key?: string; distanceMultiplier?: number; includeLocs?: string; - }): PipelineChain { + }): PipelineChain< + TContract, + DocShape, + 'update-cleared', + 'fam-cleared', + 'past-leading', + Record, + TOps + > { return this.#withStage( new MongoGeoNearStage(options), ); @@ -581,7 +685,15 @@ export class PipelineChain< facet( facets: Record>, - ): PipelineChain { + ): PipelineChain< + TContract, + DocShape, + 'update-cleared', + 'fam-cleared', + 'past-leading', + Record, + TOps + > { return this.#withStage(new MongoFacetStage(facets)); } @@ -596,7 +708,15 @@ export class PipelineChain< maxDepth?: number; depthField?: string; restrictSearchWithMatch?: MongoFilterExpr; - }): PipelineChain { + }): PipelineChain< + TContract, + DocShape, + 'update-cleared', + 'fam-cleared', + 'past-leading', + Record, + TOps + > { return this.#withStage( new MongoGraphLookupStage(options), ); @@ -608,7 +728,15 @@ export class PipelineChain< partitionBy?: MongoAggExpr; sortBy?: Record; output: Record; - }): PipelineChain { + }): PipelineChain< + TContract, + DocShape, + 'update-cleared', + 'fam-cleared', + 'past-leading', + Record, + TOps + > { return this.#withStage( new MongoSetWindowFieldsStage(options), ); @@ -618,7 +746,7 @@ export class PipelineChain< field: string; partitionByFields?: ReadonlyArray; range: MongoDensifyRange; - }): PipelineChain { + }): PipelineChain { return this.#withStage( new MongoDensifyStage(options), ); @@ -629,7 +757,7 @@ export class PipelineChain< partitionByFields?: ReadonlyArray; sortBy?: Record; output: Record; - }): PipelineChain { + }): PipelineChain { return this.#withStage( new MongoFillStage(options), ); @@ -640,7 +768,7 @@ export class PipelineChain< search( config: Record, index?: string, - ): PipelineChain { + ): PipelineChain { return this.#withStage( new MongoSearchStage(config, index), ); @@ -649,7 +777,15 @@ export class PipelineChain< searchMeta( config: Record, index?: string, - ): PipelineChain { + ): PipelineChain< + TContract, + DocShape, + 'update-cleared', + 'fam-cleared', + 'past-leading', + Record, + TOps + > { return this.#withStage( new MongoSearchMetaStage(config, index), ); @@ -662,7 +798,7 @@ export class PipelineChain< numCandidates: number; limit: number; filter?: Record; - }): PipelineChain { + }): PipelineChain { return this.#withStage( new MongoVectorSearchStage(options), ); @@ -672,13 +808,37 @@ export class PipelineChain< pipe( stage: MongoPipelineStage, - ): PipelineChain; + ): PipelineChain< + TContract, + Shape, + 'update-cleared', + 'fam-cleared', + 'past-leading', + Record, + TOps + >; pipe( stage: MongoPipelineStage, - ): PipelineChain; + ): PipelineChain< + TContract, + NewShape, + 'update-cleared', + 'fam-cleared', + 'past-leading', + Record, + TOps + >; pipe( stage: MongoPipelineStage, - ): PipelineChain { + ): PipelineChain< + TContract, + NewShape, + 'update-cleared', + 'fam-cleared', + 'past-leading', + Record, + TOps + > { return this.#withStage(stage); } @@ -703,7 +863,7 @@ export class PipelineChain< * pattern for typed DSL builders.md` for the marker-transition table. */ updateMany( - this: PipelineChain, + this: PipelineChain, updaterFn?: (fields: FieldAccessor) => UpdaterResult, ): MongoQueryPlan { if (updaterFn !== undefined) { @@ -724,7 +884,7 @@ export class PipelineChain< * casts in internal tests. */ updateOne( - this: PipelineChain, + this: PipelineChain, updaterFn?: (fields: FieldAccessor) => UpdaterResult, ): MongoQueryPlan { if (updaterFn !== undefined) { @@ -754,7 +914,7 @@ export class PipelineChain< * prevent this). */ findOneAndUpdate( - this: PipelineChain, + this: PipelineChain, updaterFn: (fields: FieldAccessor) => UpdaterResult, opts: { readonly upsert?: boolean; readonly returnDocument?: 'before' | 'after' } = {}, ): MongoQueryPlan< @@ -786,7 +946,7 @@ export class PipelineChain< * Same marker gating and deconstruction as `findOneAndUpdate`. */ findOneAndDelete( - this: PipelineChain, + this: PipelineChain, ): MongoQueryPlan< ResolveRow, TContract> | null, FindOneAndDeleteCommand @@ -824,7 +984,11 @@ export class PipelineChain< domainModelsAtDefaultNamespace(contractNarrow.domain)[modelName], ); resultShape = model - ? computePipelineResultShape(this.#state.stages, contractModelToMongoResultShape(model)) + ? computePipelineResultShape( + this.#state.stages, + contractModelToMongoResultShape(model), + this.#operationCodecs, + ) : { kind: 'unknown' as const }; } return { diff --git a/packages/2-mongo-family/5-query-builders/query-builder/src/exports/index.ts b/packages/2-mongo-family/5-query-builders/query-builder/src/exports/index.ts index 9e70c56094..bde94fabeb 100644 --- a/packages/2-mongo-family/5-query-builders/query-builder/src/exports/index.ts +++ b/packages/2-mongo-family/5-query-builders/query-builder/src/exports/index.ts @@ -5,8 +5,10 @@ export type { UpdateResult, } from '@prisma-next/mongo-query-ast/execution'; export { acc } from '../accumulator-helpers'; +export type { StageFn } from '../builder'; export { PipelineChain } from '../builder'; -export { fn } from '../expression-helpers'; +export type { MongoFn } from '../expression-helpers'; +export { createFn } from '../expression-helpers'; export type { Expression, FieldAccessor, @@ -40,25 +42,22 @@ export { } from '../result-shape'; export { CollectionHandle, FilteredCollection } from '../state-classes'; export type { - ArrayField, - BooleanField, - DateField, + CodecIdsWithOutput, + CodecTypesBase, + ComputedField, DocField, DocShape, ExtractDocShape, GroupedDocShape, GroupSpec, - LiteralValue, ModelToDocShape, - NullableDocField, - NullableNumericField, - NumericField, + MongoOperationCodecTable, ProjectedShape, ResolveRow, SortSpec, - StringField, TypedAccumulatorExpr, TypedAggExpr, + UnresolvedField, UnwoundShape, } from '../types'; export type { TypedUpdateOp, UpdaterResult } from '../update-ops'; diff --git a/packages/2-mongo-family/5-query-builders/query-builder/src/expression-helpers.ts b/packages/2-mongo-family/5-query-builders/query-builder/src/expression-helpers.ts index 25c060fb83..ec620a6238 100644 --- a/packages/2-mongo-family/5-query-builders/query-builder/src/expression-helpers.ts +++ b/packages/2-mongo-family/5-query-builders/query-builder/src/expression-helpers.ts @@ -4,516 +4,483 @@ import { MongoAggLiteral, MongoAggOperator, } from '@prisma-next/mongo-query-ast/execution'; +import { blindCast } from '@prisma-next/utils/casts'; import type { - ArrayField, - BooleanField, - DateField, + CodecIdsWithOutput, + CodecTypesBase, + ComputedField, DocField, - LiteralValue, - NullableDocField, - NumericField, - StringField, + MongoOperationCodecTable, TypedAggExpr, + UnresolvedField, } from './types'; -// --------------------------------------------------------------------------- -// Internal factory helpers -// --------------------------------------------------------------------------- - -function numericExpr(op: string, args: TypedAggExpr[]): TypedAggExpr { - return { - _field: { codecId: 'mongo/double@1', nullable: false } as NumericField, - node: MongoAggOperator.of( - op, - args.map((a) => a.node), - ), - }; -} - -function numericUnaryExpr(op: string, arg: TypedAggExpr): TypedAggExpr { - return { - _field: { codecId: 'mongo/double@1', nullable: false } as NumericField, - node: MongoAggOperator.of(op, arg.node), - }; -} - -function stringExpr(op: string, args: TypedAggExpr[]): TypedAggExpr { - return { - _field: { codecId: 'mongo/string@1', nullable: false } as StringField, - node: MongoAggOperator.of( - op, - args.map((a) => a.node), - ), - }; -} - -function stringUnaryExpr(op: string, arg: TypedAggExpr): TypedAggExpr { - return { - _field: { codecId: 'mongo/string@1', nullable: false } as StringField, - node: MongoAggOperator.of(op, arg.node), - }; -} - -function booleanExpr(op: string, args: TypedAggExpr[]): TypedAggExpr { - return { - _field: { codecId: 'mongo/bool@1', nullable: false } as BooleanField, - node: MongoAggOperator.of( - op, - args.map((a) => a.node), - ), - }; -} - -function booleanUnaryExpr(op: string, arg: TypedAggExpr): TypedAggExpr { - return { - _field: { codecId: 'mongo/bool@1', nullable: false } as BooleanField, - node: MongoAggOperator.of(op, arg.node), - }; -} - -function dateUnaryExpr(op: string, arg: TypedAggExpr): TypedAggExpr { - return { - _field: { codecId: 'mongo/date@1', nullable: false } as DateField, - node: MongoAggOperator.of(op, arg.node), - }; -} - -function arrayExpr(op: string, args: TypedAggExpr[]): TypedAggExpr { - return { - _field: { codecId: 'mongo/array@1', nullable: false } as ArrayField, - node: MongoAggOperator.of( - op, - args.map((a) => a.node), - ), - }; -} - -function arrayUnaryExpr(op: string, arg: TypedAggExpr): TypedAggExpr { - return { - _field: { codecId: 'mongo/array@1', nullable: false } as ArrayField, - node: MongoAggOperator.of(op, arg.node), - }; -} - -function docUnaryExpr(op: string, arg: TypedAggExpr): TypedAggExpr { - return { - _field: { codecId: arg._field.codecId, nullable: false }, - node: MongoAggOperator.of(op, arg.node), - }; -} - -function namedArgsExpr( - op: string, - args: Readonly | undefined>>, - _field: F, -): TypedAggExpr { - const nodeArgs: Record = {}; - for (const [key, val] of Object.entries(args)) { - if (val !== undefined) { - nodeArgs[key] = val.node; - } - } - return { _field, node: MongoAggOperator.of(op, nodeArgs) }; -} - -const NUMERIC: NumericField = { codecId: 'mongo/double@1', nullable: false } as NumericField; -const STRING: StringField = { codecId: 'mongo/string@1', nullable: false } as StringField; -const BOOLEAN: BooleanField = { codecId: 'mongo/bool@1', nullable: false } as BooleanField; -const DATE: DateField = { codecId: 'mongo/date@1', nullable: false } as DateField; -const ARRAY: ArrayField = { codecId: 'mongo/array@1', nullable: false } as ArrayField; -const DOC: DocField = { codecId: 'mongo/document@1', nullable: false }; - -function literal(value: string): TypedAggExpr; -function literal(value: number): TypedAggExpr; -function literal(value: boolean): TypedAggExpr; -function literal(value: Date): TypedAggExpr; -function literal(value: LiteralValue): TypedAggExpr; -function literal(value: unknown): TypedAggExpr { - return { _field: undefined as never, node: MongoAggLiteral.of(value) }; -} - -// --------------------------------------------------------------------------- -// Public helpers -// --------------------------------------------------------------------------- - -export const fn = { - // -- Arithmetic (existing) ------------------------------------------------ - - add(...args: TypedAggExpr[]): TypedAggExpr { - return numericExpr('$add', args); - }, - - subtract(a: TypedAggExpr, b: TypedAggExpr): TypedAggExpr { - return numericExpr('$subtract', [a, b]); - }, - - multiply(...args: TypedAggExpr[]): TypedAggExpr { - return numericExpr('$multiply', args); - }, - - divide(a: TypedAggExpr, b: TypedAggExpr): TypedAggExpr { - return numericExpr('$divide', [a, b]); - }, - - // -- String (existing) ---------------------------------------------------- - - concat(...args: TypedAggExpr[]): TypedAggExpr { - return stringExpr('$concat', args); - }, - - toLower(a: TypedAggExpr): TypedAggExpr { - return stringUnaryExpr('$toLower', a); - }, - - toUpper(a: TypedAggExpr): TypedAggExpr { - return stringUnaryExpr('$toUpper', a); - }, - - // -- Size (existing) ------------------------------------------------------ - - size(a: TypedAggExpr): TypedAggExpr { - return numericUnaryExpr('$size', a); - }, +type AnyExpr = TypedAggExpr; + +// Operators grouped by declared output role. The family names operators +// only; which codec each op returns is the adapter table's decision. +type StringOutputOps = + | '$concat' + | '$toLower' + | '$toUpper' + | '$toString' + | '$substr' + | '$substrBytes' + | '$trim' + | '$ltrim' + | '$rtrim' + | '$replaceOne' + | '$replaceAll' + | '$dateToString' + | '$type'; + +type NumericOutputOps = + | '$add' + | '$subtract' + | '$multiply' + | '$divide' + | '$size' + | '$year' + | '$month' + | '$dayOfMonth' + | '$hour' + | '$minute' + | '$second' + | '$millisecond' + | '$dateDiff' + | '$strLenCP' + | '$strLenBytes' + | '$cmp' + | '$indexOfArray' + | '$toInt' + | '$toLong' + | '$toDouble' + | '$toDecimal' + | '$count'; + +type BooleanOutputOps = + | '$eq' + | '$ne' + | '$gt' + | '$gte' + | '$lt' + | '$lte' + | '$in' + | '$regexMatch' + | '$isArray' + | '$toBool' + | '$setEquals' + | '$setIsSubset' + | '$anyElementTrue' + | '$allElementsTrue'; + +type DateOutputOps = '$toDate' | '$dateAdd' | '$dateSubtract' | '$dateTrunc' | '$dateFromString'; + +/** + * Codec ids acceptable where a value decoding to `TOut` is required: any + * contract codec whose decoded output extends `TOut`, plus the declared + * outputs of the operators in `Ops` (so computed values qualify even when + * the contract itself declares no such codec). + */ +type AcceptedCodecIds< + CT extends CodecTypesBase, + TOps extends MongoOperationCodecTable, + TOut, + Ops extends string, +> = CodecIdsWithOutput | TOps[Ops & keyof TOps]; + +type InputField< + CT extends CodecTypesBase, + TOps extends MongoOperationCodecTable, + TOut, + Ops extends string, +> = { + readonly codecId: AcceptedCodecIds; + readonly nullable: boolean; +}; - // -- Control flow (existing) ---------------------------------------------- +type LiteralField< + CT extends CodecTypesBase, + TOps extends MongoOperationCodecTable, + TOut, + Ops extends string, +> = { + readonly codecId: AcceptedCodecIds; + readonly nullable: false; +}; +type StringExpr = TypedAggExpr< + InputField +>; +type NumericExpr = TypedAggExpr< + InputField +>; +type BooleanExpr = TypedAggExpr< + InputField +>; +type DateExpr = TypedAggExpr< + InputField +>; +type StringOrNumericExpr< + CT extends CodecTypesBase, + TOps extends MongoOperationCodecTable, +> = TypedAggExpr>; + +type Computed = TypedAggExpr< + ComputedField +>; + +type Unresolved = TypedAggExpr; + +/** + * Context-bound aggregation expression helpers. Minted from the query root + * via {@link createFn}; every role-fixed output codec — value and type — + * comes from the adapter-declared operation table, never from the family. + */ +export interface MongoFn { + // -- Arithmetic ----------------------------------------------------------- + add(...args: AnyExpr[]): Computed; + subtract(a: AnyExpr, b: AnyExpr): Computed; + multiply(...args: AnyExpr[]): Computed; + divide(a: AnyExpr, b: AnyExpr): Computed; + + // -- String --------------------------------------------------------------- + concat(...args: AnyExpr[]): Computed; + toLower(a: AnyExpr): Computed; + toUpper(a: AnyExpr): Computed; + + // -- Size ----------------------------------------------------------------- + size(a: AnyExpr): Computed; + + // -- Control flow --------------------------------------------------------- cond( condition: MongoAggExpr, thenExpr: TypedAggExpr, - elseExpr: TypedAggExpr, - ): TypedAggExpr { - return { - _field: thenExpr._field, - node: new MongoAggCond(condition, thenExpr.node, elseExpr.node), - }; - }, - - literal, - - // -- Date helpers --------------------------------------------------------- - - year(a: TypedAggExpr): TypedAggExpr { - return numericUnaryExpr('$year', a); - }, - month(a: TypedAggExpr): TypedAggExpr { - return numericUnaryExpr('$month', a); - }, - dayOfMonth(a: TypedAggExpr): TypedAggExpr { - return numericUnaryExpr('$dayOfMonth', a); - }, - hour(a: TypedAggExpr): TypedAggExpr { - return numericUnaryExpr('$hour', a); - }, - minute(a: TypedAggExpr): TypedAggExpr { - return numericUnaryExpr('$minute', a); - }, - second(a: TypedAggExpr): TypedAggExpr { - return numericUnaryExpr('$second', a); - }, - millisecond(a: TypedAggExpr): TypedAggExpr { - return numericUnaryExpr('$millisecond', a); - }, + elseExpr: AnyExpr, + ): TypedAggExpr; + + literal(value: string): TypedAggExpr>; + literal(value: number): TypedAggExpr>; + literal(value: boolean): TypedAggExpr>; + literal(value: Date): TypedAggExpr>; + literal(value: unknown): TypedAggExpr; + + // -- Date helpers ---------------------------------------------------------- + year(a: AnyExpr): Computed; + month(a: AnyExpr): Computed; + dayOfMonth(a: AnyExpr): Computed; + hour(a: AnyExpr): Computed; + minute(a: AnyExpr): Computed; + second(a: AnyExpr): Computed; + millisecond(a: AnyExpr): Computed; dateToString(args: { - date: TypedAggExpr; - format?: TypedAggExpr; - timezone?: TypedAggExpr; - onNull?: TypedAggExpr; - }): TypedAggExpr { - return namedArgsExpr('$dateToString', args, STRING); - }, + date: DateExpr; + format?: StringExpr; + timezone?: StringExpr; + onNull?: AnyExpr; + }): Computed; dateFromString(args: { - dateString: TypedAggExpr; - format?: TypedAggExpr; - timezone?: TypedAggExpr; - onError?: TypedAggExpr; - onNull?: TypedAggExpr; - }): TypedAggExpr { - return namedArgsExpr('$dateFromString', args, DATE); - }, + dateString: StringExpr; + format?: StringExpr; + timezone?: StringExpr; + onError?: AnyExpr; + onNull?: AnyExpr; + }): Computed; dateDiff(args: { - startDate: TypedAggExpr; - endDate: TypedAggExpr; - unit: TypedAggExpr; - timezone?: TypedAggExpr; - startOfWeek?: TypedAggExpr; - }): TypedAggExpr { - return namedArgsExpr('$dateDiff', args, NUMERIC); - }, + startDate: DateExpr; + endDate: DateExpr; + unit: StringExpr; + timezone?: StringExpr; + startOfWeek?: StringExpr; + }): Computed; dateAdd(args: { - startDate: TypedAggExpr; - unit: TypedAggExpr; - amount: TypedAggExpr; - timezone?: TypedAggExpr; - }): TypedAggExpr { - return namedArgsExpr('$dateAdd', args, DATE); - }, + startDate: DateExpr; + unit: StringExpr; + amount: NumericExpr; + timezone?: StringExpr; + }): Computed; dateSubtract(args: { - startDate: TypedAggExpr; - unit: TypedAggExpr; - amount: TypedAggExpr; - timezone?: TypedAggExpr; - }): TypedAggExpr { - return namedArgsExpr('$dateSubtract', args, DATE); - }, + startDate: DateExpr; + unit: StringExpr; + amount: NumericExpr; + timezone?: StringExpr; + }): Computed; dateTrunc(args: { - date: TypedAggExpr; - unit: TypedAggExpr; - binSize?: TypedAggExpr; - timezone?: TypedAggExpr; - startOfWeek?: TypedAggExpr; - }): TypedAggExpr { - return namedArgsExpr('$dateTrunc', args, DATE); - }, - - // -- String helpers ------------------------------------------------------- - - substr( - str: TypedAggExpr, - start: TypedAggExpr, - length: TypedAggExpr, - ): TypedAggExpr { - return stringExpr('$substr', [str, start, length]); - }, - substrBytes( - str: TypedAggExpr, - start: TypedAggExpr, - count: TypedAggExpr, - ): TypedAggExpr { - return stringExpr('$substrBytes', [str, start, count]); - }, + date: DateExpr; + unit: StringExpr; + binSize?: NumericExpr; + timezone?: StringExpr; + startOfWeek?: StringExpr; + }): Computed; + + // -- String helpers --------------------------------------------------------- + substr(str: AnyExpr, start: AnyExpr, length: AnyExpr): Computed; + substrBytes(str: AnyExpr, start: AnyExpr, count: AnyExpr): Computed; trim(args: { - input: TypedAggExpr; - chars?: TypedAggExpr; - }): TypedAggExpr { - return namedArgsExpr('$trim', args, STRING); - }, + input: StringExpr; + chars?: StringExpr; + }): Computed; ltrim(args: { - input: TypedAggExpr; - chars?: TypedAggExpr; - }): TypedAggExpr { - return namedArgsExpr('$ltrim', args, STRING); - }, + input: StringExpr; + chars?: StringExpr; + }): Computed; rtrim(args: { - input: TypedAggExpr; - chars?: TypedAggExpr; - }): TypedAggExpr { - return namedArgsExpr('$rtrim', args, STRING); - }, - split(str: TypedAggExpr, delimiter: TypedAggExpr): TypedAggExpr { - return arrayExpr('$split', [str, delimiter]); - }, - strLenCP(a: TypedAggExpr): TypedAggExpr { - return numericUnaryExpr('$strLenCP', a); - }, - strLenBytes(a: TypedAggExpr): TypedAggExpr { - return numericUnaryExpr('$strLenBytes', a); - }, + input: StringExpr; + chars?: StringExpr; + }): Computed; + split(str: AnyExpr, delimiter: AnyExpr): Unresolved; + strLenCP(a: AnyExpr): Computed; + strLenBytes(a: AnyExpr): Computed; regexMatch(args: { - input: TypedAggExpr; - regex: TypedAggExpr; - options?: TypedAggExpr; - }): TypedAggExpr { - return namedArgsExpr('$regexMatch', args, BOOLEAN); - }, + input: StringExpr; + regex: StringExpr; + options?: StringExpr; + }): Computed; regexFind(args: { - input: TypedAggExpr; - regex: TypedAggExpr; - options?: TypedAggExpr; - }): TypedAggExpr { - return namedArgsExpr('$regexFind', args, DOC); - }, + input: StringExpr; + regex: StringExpr; + options?: StringExpr; + }): Unresolved; regexFindAll(args: { - input: TypedAggExpr; - regex: TypedAggExpr; - options?: TypedAggExpr; - }): TypedAggExpr { - return namedArgsExpr('$regexFindAll', args, ARRAY); - }, + input: StringExpr; + regex: StringExpr; + options?: StringExpr; + }): Unresolved; replaceOne(args: { - input: TypedAggExpr; - find: TypedAggExpr; - replacement: TypedAggExpr; - }): TypedAggExpr { - return namedArgsExpr('$replaceOne', args, STRING); - }, + input: StringExpr; + find: StringExpr; + replacement: StringExpr; + }): Computed; replaceAll(args: { - input: TypedAggExpr; - find: TypedAggExpr; - replacement: TypedAggExpr; - }): TypedAggExpr { - return namedArgsExpr('$replaceAll', args, STRING); - }, - - // -- Comparison helpers --------------------------------------------------- - - cmp(a: TypedAggExpr, b: TypedAggExpr): TypedAggExpr { - return numericExpr('$cmp', [a, b]); - }, - eq(a: TypedAggExpr, b: TypedAggExpr): TypedAggExpr { - return booleanExpr('$eq', [a, b]); - }, - ne(a: TypedAggExpr, b: TypedAggExpr): TypedAggExpr { - return booleanExpr('$ne', [a, b]); - }, - gt(a: TypedAggExpr, b: TypedAggExpr): TypedAggExpr { - return booleanExpr('$gt', [a, b]); - }, - gte(a: TypedAggExpr, b: TypedAggExpr): TypedAggExpr { - return booleanExpr('$gte', [a, b]); - }, - lt(a: TypedAggExpr, b: TypedAggExpr): TypedAggExpr { - return booleanExpr('$lt', [a, b]); - }, - lte(a: TypedAggExpr, b: TypedAggExpr): TypedAggExpr { - return booleanExpr('$lte', [a, b]); - }, - - // -- Array helpers -------------------------------------------------------- - - arrayElemAt( - arr: TypedAggExpr, - idx: TypedAggExpr, - ): TypedAggExpr { - return { - _field: { codecId: DOC.codecId, nullable: true }, - node: MongoAggOperator.of('$arrayElemAt', [arr.node, idx.node]), - }; - }, - concatArrays(...args: TypedAggExpr[]): TypedAggExpr { - return arrayExpr('$concatArrays', args); - }, - firstElem(a: TypedAggExpr): TypedAggExpr { + input: StringExpr; + find: StringExpr; + replacement: StringExpr; + }): Computed; + + // -- Comparison helpers ------------------------------------------------------ + cmp(a: AnyExpr, b: AnyExpr): Computed; + eq(a: AnyExpr, b: AnyExpr): Computed; + ne(a: AnyExpr, b: AnyExpr): Computed; + gt(a: AnyExpr, b: AnyExpr): Computed; + gte(a: AnyExpr, b: AnyExpr): Computed; + lt(a: AnyExpr, b: AnyExpr): Computed; + lte(a: AnyExpr, b: AnyExpr): Computed; + + // -- Array helpers ----------------------------------------------------------- + arrayElemAt(arr: AnyExpr, idx: AnyExpr): Unresolved; + concatArrays(...args: AnyExpr[]): Unresolved; + firstElem(a: AnyExpr): Unresolved; + lastElem(a: AnyExpr): Unresolved; + isIn(elem: AnyExpr, arr: AnyExpr): Computed; + indexOfArray(arr: AnyExpr, value: AnyExpr, ...rest: AnyExpr[]): Computed; + isArray(a: AnyExpr): Computed; + reverseArray(a: AnyExpr): Unresolved; + slice(arr: AnyExpr, ...rest: AnyExpr[]): Unresolved; + zip(args: { + inputs: Unresolved[]; + useLongestLength?: BooleanExpr; + defaults?: Unresolved; + }): Unresolved; + range(start: AnyExpr, end: AnyExpr, step: AnyExpr): Unresolved; + + // -- Set helpers ------------------------------------------------------------- + setUnion(...args: AnyExpr[]): Unresolved; + setIntersection(...args: AnyExpr[]): Unresolved; + setDifference(a: AnyExpr, b: AnyExpr): Unresolved; + setEquals(...args: AnyExpr[]): Computed; + setIsSubset(a: AnyExpr, b: AnyExpr): Computed; + anyElementTrue(a: AnyExpr): Computed; + allElementsTrue(a: AnyExpr): Computed; + + // -- Type helpers ------------------------------------------------------------ + typeOf(a: AnyExpr): Computed; + convert(args: { + input: AnyExpr; + to: StringOrNumericExpr; + onError?: AnyExpr; + onNull?: AnyExpr; + }): Unresolved; + toInt(a: AnyExpr): Computed; + toLong(a: AnyExpr): Computed; + toDouble(a: AnyExpr): Computed; + toDecimal(a: AnyExpr): Computed; + toString_(a: AnyExpr): Computed; + toObjectId(a: AnyExpr): Computed; + toBool(a: AnyExpr): Computed; + toDate(a: AnyExpr): Computed; + + // -- Object helpers ---------------------------------------------------------- + objectToArray(a: AnyExpr): Unresolved; + arrayToObject(a: AnyExpr): Unresolved; + getField(args: { field: StringExpr; input?: AnyExpr }): Unresolved; + setField(args: { field: StringExpr; input: AnyExpr; value: AnyExpr }): Unresolved; +} + +const UNRESOLVED: UnresolvedField = { codecId: '', nullable: false, unresolved: true }; +const UNRESOLVED_NULLABLE: UnresolvedField = { codecId: '', nullable: true, unresolved: true }; + +function namedArgs( + args: Readonly>, +): Record { + const nodeArgs: Record = {}; + for (const [key, val] of Object.entries(args)) { + if (val !== undefined) { + nodeArgs[key] = val.node; + } + } + return nodeArgs; +} + +function nodesOf(args: ReadonlyArray): MongoAggExpr[] { + return args.map((a) => a.node); +} + +/** + * Mint the context-bound `fn` helpers from an adapter-declared + * operation→output-codec table. There is deliberately no context-free + * counterpart: a detached helper has no codec source to consult. + */ +export function createFn< + TOps extends MongoOperationCodecTable, + CT extends CodecTypesBase = CodecTypesBase, +>(table: TOps): MongoFn { + const lookup: Readonly> = table; + + function computed( + op: Op, + args: MongoAggExpr | ReadonlyArray | Readonly>, + ): Computed { return { - _field: { codecId: DOC.codecId, nullable: true }, - node: MongoAggOperator.of('$first', a.node), + _field: blindCast< + ComputedField, + 'codecId is read from the adapter table entry for this operator' + >({ codecId: lookup[op] ?? '', nullable: false }), + node: MongoAggOperator.of(op, args), }; - }, - lastElem(a: TypedAggExpr): TypedAggExpr { + } + + function unresolvedExpr( + op: string, + args: MongoAggExpr | ReadonlyArray | Readonly>, + field: UnresolvedField = UNRESOLVED, + ): Unresolved { + return { _field: field, node: MongoAggOperator.of(op, args) }; + } + + function literal(value: string): TypedAggExpr>; + function literal(value: number): TypedAggExpr>; + function literal(value: boolean): TypedAggExpr>; + function literal(value: Date): TypedAggExpr>; + function literal(value: unknown): TypedAggExpr; + function literal(value: unknown): AnyExpr { return { - _field: { codecId: DOC.codecId, nullable: true }, - node: MongoAggOperator.of('$last', a.node), - }; - }, - isIn(elem: TypedAggExpr, arr: TypedAggExpr): TypedAggExpr { - return booleanExpr('$in', [elem, arr]); - }, - indexOfArray( - arr: TypedAggExpr, - value: TypedAggExpr, - ...rest: TypedAggExpr[] - ): TypedAggExpr { - return numericExpr('$indexOfArray', [arr, value, ...rest]); - }, - isArray(a: TypedAggExpr): TypedAggExpr { - return booleanUnaryExpr('$isArray', a); - }, - reverseArray(a: TypedAggExpr): TypedAggExpr { - return arrayUnaryExpr('$reverseArray', a); - }, - slice(arr: TypedAggExpr, ...rest: TypedAggExpr[]): TypedAggExpr { - return arrayExpr('$slice', [arr, ...rest]); - }, - zip(args: { - inputs: TypedAggExpr[]; - useLongestLength?: TypedAggExpr; - defaults?: TypedAggExpr; - }): TypedAggExpr { - const nodeArgs: Record> = { - inputs: args.inputs.map((a) => a.node), + _field: blindCast(undefined), + node: MongoAggLiteral.of(value), }; - if (args.useLongestLength) nodeArgs['useLongestLength'] = args.useLongestLength.node; - if (args.defaults) nodeArgs['defaults'] = args.defaults.node; - return { _field: ARRAY, node: MongoAggOperator.of('$zip', nodeArgs) }; - }, - range( - start: TypedAggExpr, - end: TypedAggExpr, - step: TypedAggExpr, - ): TypedAggExpr { - return arrayExpr('$range', [start, end, step]); - }, - - // -- Set helpers ---------------------------------------------------------- - - setUnion(...args: TypedAggExpr[]): TypedAggExpr { - return arrayExpr('$setUnion', args); - }, - setIntersection(...args: TypedAggExpr[]): TypedAggExpr { - return arrayExpr('$setIntersection', args); - }, - setDifference(a: TypedAggExpr, b: TypedAggExpr): TypedAggExpr { - return arrayExpr('$setDifference', [a, b]); - }, - setEquals(...args: TypedAggExpr[]): TypedAggExpr { - return booleanExpr('$setEquals', args); - }, - setIsSubset(a: TypedAggExpr, b: TypedAggExpr): TypedAggExpr { - return booleanExpr('$setIsSubset', [a, b]); - }, - anyElementTrue(a: TypedAggExpr): TypedAggExpr { - return booleanUnaryExpr('$anyElementTrue', a); - }, - allElementsTrue(a: TypedAggExpr): TypedAggExpr { - return booleanUnaryExpr('$allElementsTrue', a); - }, - - // -- Type helpers --------------------------------------------------------- - - typeOf(a: TypedAggExpr): TypedAggExpr { - return stringUnaryExpr('$type', a); - }, - convert(args: { - input: TypedAggExpr; - to: TypedAggExpr; - onError?: TypedAggExpr; - onNull?: TypedAggExpr; - }): TypedAggExpr { - return namedArgsExpr('$convert', args, DOC); - }, - toInt(a: TypedAggExpr): TypedAggExpr { - return numericUnaryExpr('$toInt', a); - }, - toLong(a: TypedAggExpr): TypedAggExpr { - return numericUnaryExpr('$toLong', a); - }, - toDouble(a: TypedAggExpr): TypedAggExpr { - return numericUnaryExpr('$toDouble', a); - }, - toDecimal(a: TypedAggExpr): TypedAggExpr { - return numericUnaryExpr('$toDecimal', a); - }, - toString_(a: TypedAggExpr): TypedAggExpr { - return stringUnaryExpr('$toString', a); - }, - toObjectId(a: TypedAggExpr): TypedAggExpr { - return docUnaryExpr('$toObjectId', a); - }, - toBool(a: TypedAggExpr): TypedAggExpr { - return booleanUnaryExpr('$toBool', a); - }, - toDate(a: TypedAggExpr): TypedAggExpr { - return dateUnaryExpr('$toDate', a); - }, - - // -- Object helpers ------------------------------------------------------- - - objectToArray(a: TypedAggExpr): TypedAggExpr { - return arrayUnaryExpr('$objectToArray', a); - }, - arrayToObject(a: TypedAggExpr): TypedAggExpr { - return { _field: DOC, node: MongoAggOperator.of('$arrayToObject', a.node) }; - }, - getField(args: { - field: TypedAggExpr; - input?: TypedAggExpr; - }): TypedAggExpr { - return namedArgsExpr('$getField', args, DOC); - }, - setField(args: { - field: TypedAggExpr; - input: TypedAggExpr; - value: TypedAggExpr; - }): TypedAggExpr { - return namedArgsExpr('$setField', args, DOC); - }, -}; + } + + return { + add: (...args) => computed('$add', nodesOf(args)), + subtract: (a, b) => computed('$subtract', nodesOf([a, b])), + multiply: (...args) => computed('$multiply', nodesOf(args)), + divide: (a, b) => computed('$divide', nodesOf([a, b])), + + concat: (...args) => computed('$concat', nodesOf(args)), + toLower: (a) => computed('$toLower', a.node), + toUpper: (a) => computed('$toUpper', a.node), + + size: (a) => computed('$size', a.node), + + cond: ( + condition: MongoAggExpr, + thenExpr: TypedAggExpr, + elseExpr: AnyExpr, + ): TypedAggExpr => ({ + _field: thenExpr._field, + node: new MongoAggCond(condition, thenExpr.node, elseExpr.node), + }), + + literal, + + year: (a) => computed('$year', a.node), + month: (a) => computed('$month', a.node), + dayOfMonth: (a) => computed('$dayOfMonth', a.node), + hour: (a) => computed('$hour', a.node), + minute: (a) => computed('$minute', a.node), + second: (a) => computed('$second', a.node), + millisecond: (a) => computed('$millisecond', a.node), + dateToString: (args) => computed('$dateToString', namedArgs(args)), + dateFromString: (args) => computed('$dateFromString', namedArgs(args)), + dateDiff: (args) => computed('$dateDiff', namedArgs(args)), + dateAdd: (args) => computed('$dateAdd', namedArgs(args)), + dateSubtract: (args) => computed('$dateSubtract', namedArgs(args)), + dateTrunc: (args) => computed('$dateTrunc', namedArgs(args)), + + substr: (str, start, length) => computed('$substr', nodesOf([str, start, length])), + substrBytes: (str, start, count) => computed('$substrBytes', nodesOf([str, start, count])), + trim: (args) => computed('$trim', namedArgs(args)), + ltrim: (args) => computed('$ltrim', namedArgs(args)), + rtrim: (args) => computed('$rtrim', namedArgs(args)), + split: (str, delimiter) => unresolvedExpr('$split', nodesOf([str, delimiter])), + strLenCP: (a) => computed('$strLenCP', a.node), + strLenBytes: (a) => computed('$strLenBytes', a.node), + regexMatch: (args) => computed('$regexMatch', namedArgs(args)), + regexFind: (args) => unresolvedExpr('$regexFind', namedArgs(args)), + regexFindAll: (args) => unresolvedExpr('$regexFindAll', namedArgs(args)), + replaceOne: (args) => computed('$replaceOne', namedArgs(args)), + replaceAll: (args) => computed('$replaceAll', namedArgs(args)), + + cmp: (a, b) => computed('$cmp', nodesOf([a, b])), + eq: (a, b) => computed('$eq', nodesOf([a, b])), + ne: (a, b) => computed('$ne', nodesOf([a, b])), + gt: (a, b) => computed('$gt', nodesOf([a, b])), + gte: (a, b) => computed('$gte', nodesOf([a, b])), + lt: (a, b) => computed('$lt', nodesOf([a, b])), + lte: (a, b) => computed('$lte', nodesOf([a, b])), + + arrayElemAt: (arr, idx) => + unresolvedExpr('$arrayElemAt', nodesOf([arr, idx]), UNRESOLVED_NULLABLE), + concatArrays: (...args) => unresolvedExpr('$concatArrays', nodesOf(args)), + firstElem: (a) => unresolvedExpr('$first', a.node, UNRESOLVED_NULLABLE), + lastElem: (a) => unresolvedExpr('$last', a.node, UNRESOLVED_NULLABLE), + isIn: (elem, arr) => computed('$in', nodesOf([elem, arr])), + indexOfArray: (arr, value, ...rest) => + computed('$indexOfArray', nodesOf([arr, value, ...rest])), + isArray: (a) => computed('$isArray', a.node), + reverseArray: (a) => unresolvedExpr('$reverseArray', a.node), + slice: (arr, ...rest) => unresolvedExpr('$slice', nodesOf([arr, ...rest])), + zip: (args) => { + const nodeArgs: Record> = { + inputs: args.inputs.map((a) => a.node), + }; + if (args.useLongestLength) nodeArgs['useLongestLength'] = args.useLongestLength.node; + if (args.defaults) nodeArgs['defaults'] = args.defaults.node; + return { _field: UNRESOLVED, node: MongoAggOperator.of('$zip', nodeArgs) }; + }, + range: (start, end, step) => unresolvedExpr('$range', nodesOf([start, end, step])), + + setUnion: (...args) => unresolvedExpr('$setUnion', nodesOf(args)), + setIntersection: (...args) => unresolvedExpr('$setIntersection', nodesOf(args)), + setDifference: (a, b) => unresolvedExpr('$setDifference', nodesOf([a, b])), + setEquals: (...args) => computed('$setEquals', nodesOf(args)), + setIsSubset: (a, b) => computed('$setIsSubset', nodesOf([a, b])), + anyElementTrue: (a) => computed('$anyElementTrue', a.node), + allElementsTrue: (a) => computed('$allElementsTrue', a.node), + + typeOf: (a) => computed('$type', a.node), + convert: (args) => unresolvedExpr('$convert', namedArgs(args)), + toInt: (a) => computed('$toInt', a.node), + toLong: (a) => computed('$toLong', a.node), + toDouble: (a) => computed('$toDouble', a.node), + toDecimal: (a) => computed('$toDecimal', a.node), + toString_: (a) => computed('$toString', a.node), + toObjectId: (a) => computed('$toObjectId', a.node), + toBool: (a) => computed('$toBool', a.node), + toDate: (a) => computed('$toDate', a.node), + + objectToArray: (a) => unresolvedExpr('$objectToArray', a.node), + arrayToObject: (a) => unresolvedExpr('$arrayToObject', a.node), + getField: (args) => unresolvedExpr('$getField', namedArgs(args)), + setField: (args) => unresolvedExpr('$setField', namedArgs(args)), + }; +} diff --git a/packages/2-mongo-family/5-query-builders/query-builder/src/pipeline-result-shape.ts b/packages/2-mongo-family/5-query-builders/query-builder/src/pipeline-result-shape.ts index 68a68711a3..f80fddef79 100644 --- a/packages/2-mongo-family/5-query-builders/query-builder/src/pipeline-result-shape.ts +++ b/packages/2-mongo-family/5-query-builders/query-builder/src/pipeline-result-shape.ts @@ -8,8 +8,10 @@ import { freezeMongoResultShape, MongoAddFieldsStage, MongoAggFieldRef, + MongoAggOperator, MongoProjectStage, } from '@prisma-next/mongo-query-ast/execution'; +import type { MongoOperationCodecTable } from './types'; const identityStageKinds = new Set(['match', 'sort', 'limit', 'skip', 'sample', 'vectorSearch']); @@ -22,16 +24,27 @@ function fieldShapeAtPath(shape: MongoResultShape, path: string): MongoFieldShap return shape.fields[path] ?? unknownShape; } -function shapeForExpr(currentShape: MongoResultShape, expr: MongoAggExpr): MongoFieldShape { +function shapeForExpr( + currentShape: MongoResultShape, + expr: MongoAggExpr, + operationCodecs: MongoOperationCodecTable, +): MongoFieldShape { if (expr instanceof MongoAggFieldRef) { return fieldShapeAtPath(currentShape, expr.path); } + if (expr instanceof MongoAggOperator) { + const codecId = operationCodecs[expr.op]; + if (codecId !== undefined) { + return { kind: 'leaf' as const, codecId, nullable: false }; + } + } return unknownShape; } function resultShapeAfterProject( currentShape: MongoResultShape, stage: MongoProjectStage, + operationCodecs: MongoOperationCodecTable, ): MongoResultShape { if (currentShape.kind !== 'document') { return { kind: 'unknown' as const }; @@ -45,7 +58,7 @@ function resultShapeAfterProject( fields[key] = currentShape.fields[key] ?? unknownShape; continue; } - fields[key] = shapeForExpr(currentShape, value); + fields[key] = shapeForExpr(currentShape, value, operationCodecs); } if (!Object.hasOwn(stage.projection, '_id') && currentShape.fields['_id']) { fields['_id'] = currentShape.fields['_id']; @@ -56,13 +69,14 @@ function resultShapeAfterProject( function resultShapeAfterAddFields( currentShape: MongoResultShape, stage: MongoAddFieldsStage, + operationCodecs: MongoOperationCodecTable, ): MongoResultShape { if (currentShape.kind !== 'document') { return { kind: 'unknown' as const }; } const fields: Record = { ...currentShape.fields }; for (const [key, expr] of Object.entries(stage.fields)) { - fields[key] = shapeForExpr(currentShape, expr); + fields[key] = shapeForExpr(currentShape, expr, operationCodecs); } return freezeMongoResultShape({ kind: 'document' as const, fields }); } @@ -70,6 +84,7 @@ function resultShapeAfterAddFields( export function computePipelineResultShape( stages: ReadonlyArray, startShape: MongoResultShape, + operationCodecs: MongoOperationCodecTable, ): MongoResultShape { let shape = startShape; for (const stage of stages) { @@ -80,11 +95,11 @@ export function computePipelineResultShape( continue; } if (stage instanceof MongoProjectStage) { - shape = resultShapeAfterProject(shape, stage); + shape = resultShapeAfterProject(shape, stage, operationCodecs); continue; } if (stage instanceof MongoAddFieldsStage) { - shape = resultShapeAfterAddFields(shape, stage); + shape = resultShapeAfterAddFields(shape, stage, operationCodecs); continue; } return { kind: 'unknown' as const }; diff --git a/packages/2-mongo-family/5-query-builders/query-builder/src/query.ts b/packages/2-mongo-family/5-query-builders/query-builder/src/query.ts index a268095e85..0c5c7a6b64 100644 --- a/packages/2-mongo-family/5-query-builders/query-builder/src/query.ts +++ b/packages/2-mongo-family/5-query-builders/query-builder/src/query.ts @@ -1,19 +1,25 @@ import type { PlanMeta } from '@prisma-next/contract/types'; import type { AnyMongoTypeMaps, + ExtractMongoCodecTypes, MongoContract, MongoContractWithTypeMaps, RootModelName, } from '@prisma-next/mongo-contract'; import type { AnyMongoCommand, MongoQueryPlan } from '@prisma-next/mongo-query-ast/execution'; -import { blindCast } from '@prisma-next/utils/casts'; +import { createFn, type MongoFn } from './expression-helpers'; import { asMongoContract, type CollectionHandle, createCollectionHandle } from './state-classes'; +import type { MongoOperationCodecTable } from './types'; /** * Public entry point of the query builder. `mongoQuery(...).from(rootName)` * yields the root state of the three-state machine * (`CollectionHandle` → `FilteredCollection` → `PipelineChain`). * + * The root also exposes the context-bound `fn` expression helpers, minted + * from the adapter-declared `operationCodecs` table, for standalone + * expression construction outside stage callbacks. + * * `rawCommand(cmd)` is the escape hatch for cases the typed surface does * not cover (yet) — it accepts any `AnyMongoCommand` (typed CRUD or a * `RawMongoCommand` of `Document`s) and packages it into a `MongoQueryPlan` @@ -22,23 +28,32 @@ import { asMongoContract, type CollectionHandle, createCollectionHandle } from ' */ export interface QueryRoot< TContract extends MongoContractWithTypeMaps, + TOps extends MongoOperationCodecTable = MongoOperationCodecTable, > { from( rootName: K, - ): CollectionHandle>; + ): CollectionHandle, TOps>; rawCommand(command: C): MongoQueryPlan; + readonly fn: MongoFn>; } +/** + * Construct a query root from a validated contract and the adapter's + * operation→output-codec table. `operationCodecs` is required — a builder + * without codec knowledge cannot mint the `fn` helpers, and the family + * declares no fallback table of its own. The supported production surface + * threads it from the execution context (`mongoStatic()`); tests construct + * a local table. + */ export function mongoQuery< TContract extends MongoContractWithTypeMaps, ->(options: { contractJson: unknown }): QueryRoot { - const contract = blindCast< - TContract, - 'mongoQuery accepts validated contract JSON with domain.namespaces' - >(options.contractJson); + TOps extends MongoOperationCodecTable, +>(options: { contractJson: TContract; operationCodecs: TOps }): QueryRoot { + const contract = options.contractJson; + const operationCodecs = options.operationCodecs; return { from(rootName: K) { - return createCollectionHandle(contract, rootName); + return createCollectionHandle(contract, rootName, operationCodecs); }, rawCommand(command: C): MongoQueryPlan { const c = asMongoContract(contract); @@ -55,5 +70,6 @@ export function mongoQuery< }; return { collection: command.collection, command, meta }; }, + fn: createFn>(operationCodecs), }; } diff --git a/packages/2-mongo-family/5-query-builders/query-builder/src/resolve-path.ts b/packages/2-mongo-family/5-query-builders/query-builder/src/resolve-path.ts index c5c3285c0c..59b3e7f480 100644 --- a/packages/2-mongo-family/5-query-builders/query-builder/src/resolve-path.ts +++ b/packages/2-mongo-family/5-query-builders/query-builder/src/resolve-path.ts @@ -1,6 +1,6 @@ import type { ContractValueObjectDefinitions } from '@prisma-next/contract/types'; import type { MongoContract, MongoModelsMap } from '@prisma-next/mongo-contract'; -import type { DocField } from './types'; +import type { DocField, UnresolvedField } from './types'; /** * Marker `DocField` variant representing a non-leaf (value-object) path in @@ -30,7 +30,7 @@ export interface ObjectField extends DocField { * typed `col` accessor. `ModelName` is the literal foreign model name * (e.g. `'User'`), preserved in the type so `ResolveRow` can resolve the * field to `ResolveRow, …>[]` rather than - * the opaque `unknown[]` produced by the legacy `mongo/array@1` sentinel. + * the opaque `unknown[]` produced by the legacy array sentinel. * * Like `ObjectField`, the codec id is a purely type-level sentinel — * there is no runtime codec entry for `'prisma/modelArray@1'`. The @@ -102,7 +102,7 @@ type FieldToLeaf = F extends { } ? { readonly codecId: C; readonly nullable: N } : F extends { readonly many: true; readonly nullable: infer N extends boolean } - ? { readonly codecId: 'mongo/array@1'; readonly nullable: N } + ? UnresolvedField & { readonly nullable: N } : DocField; /** diff --git a/packages/2-mongo-family/5-query-builders/query-builder/src/state-classes.ts b/packages/2-mongo-family/5-query-builders/query-builder/src/state-classes.ts index 1e17540d4c..0f54eda99d 100644 --- a/packages/2-mongo-family/5-query-builders/query-builder/src/state-classes.ts +++ b/packages/2-mongo-family/5-query-builders/query-builder/src/state-classes.ts @@ -31,10 +31,10 @@ import { UpdateOneCommand, } from '@prisma-next/mongo-query-ast/execution'; import type { MongoValue } from '@prisma-next/mongo-value'; -import { PipelineChain } from './builder'; +import { PipelineChain, type StageFn } from './builder'; import { createFieldAccessor, type FieldAccessor } from './field-accessor'; import type { ModelNestedShape, NestedDocShape } from './resolve-path'; -import type { ModelToDocShape, ResolveRow } from './types'; +import type { ModelToDocShape, MongoOperationCodecTable, ResolveRow } from './types'; import { resolveUpdaterResult, type UpdaterResult } from './update-ops'; /** @@ -96,24 +96,30 @@ function writeMeta(storageHash: string): PlanMeta { export class CollectionHandle< TContract extends MongoContractWithTypeMaps, ModelName extends keyof MongoModelsMap & string, + TOps extends MongoOperationCodecTable = MongoOperationCodecTable, > extends PipelineChain< TContract, ModelToDocShape, 'update-cleared', 'fam-cleared', 'leading', - ModelNestedShape + ModelNestedShape, + TOps > { - readonly #ctx: BindingContext; + readonly #ctx: BindingContext; readonly #modelName: ModelName; - constructor(ctx: BindingContext, modelName: ModelName) { - super(ctx.contract, { - collection: ctx.collection, - stages: [], - storageHash: ctx.storageHash, - modelName: modelName as string, - }); + constructor(ctx: BindingContext, modelName: ModelName) { + super( + ctx.contract, + { + collection: ctx.collection, + stages: [], + storageHash: ctx.storageHash, + modelName: modelName as string, + }, + ctx.operationCodecs, + ); this.#ctx = ctx; this.#modelName = modelName; } @@ -135,15 +141,16 @@ export class CollectionHandle< * Mongo — but `FilteredCollection` makes the accumulated filter * addressable for the write/find-and-modify terminals landing in M2/M3. */ - override match(filter: MongoFilterExpr): FilteredCollection; + override match(filter: MongoFilterExpr): FilteredCollection; override match( fn: ( fields: FieldAccessor< ModelToDocShape, ModelNestedShape >, + helpers: StageFn, ) => MongoFilterExpr, - ): FilteredCollection; + ): FilteredCollection; override match( filterOrFn: | MongoFilterExpr @@ -152,8 +159,9 @@ export class CollectionHandle< ModelToDocShape, ModelNestedShape >, + helpers: StageFn, ) => MongoFilterExpr), - ): FilteredCollection { + ): FilteredCollection { const resolved = typeof filterOrFn === 'function' ? filterOrFn( @@ -161,9 +169,12 @@ export class CollectionHandle< ModelToDocShape, ModelNestedShape >(), + this.fn, ) : filterOrFn; - return new FilteredCollection(this.#ctx, this.#modelName, [resolved]); + return new FilteredCollection(this.#ctx, this.#modelName, [ + resolved, + ]); } // --- Inserts --- @@ -320,20 +331,22 @@ export class CollectionHandle< export class FilteredCollection< TContract extends MongoContractWithTypeMaps, ModelName extends keyof MongoModelsMap & string, + TOps extends MongoOperationCodecTable = MongoOperationCodecTable, > extends PipelineChain< TContract, ModelToDocShape, 'update-cleared', 'fam-cleared', 'leading', - ModelNestedShape + ModelNestedShape, + TOps > { - readonly #ctx: BindingContext; + readonly #ctx: BindingContext; readonly #modelName: ModelName; readonly #filters: ReadonlyArray; constructor( - ctx: BindingContext, + ctx: BindingContext, modelName: ModelName, filters: ReadonlyArray, ) { @@ -345,12 +358,16 @@ export class FilteredCollection< throw new Error('FilteredCollection: unreachable empty-filters branch'); } const leading = filters.length === 1 ? first : foldAnd(filters); - super(ctx.contract, { - collection: ctx.collection, - stages: [new MongoMatchStage(leading)], - storageHash: ctx.storageHash, - modelName: modelName as string, - }); + super( + ctx.contract, + { + collection: ctx.collection, + stages: [new MongoMatchStage(leading)], + storageHash: ctx.storageHash, + modelName: modelName as string, + }, + ctx.operationCodecs, + ); this.#ctx = ctx; this.#modelName = modelName; this.#filters = filters; @@ -376,15 +393,16 @@ export class FilteredCollection< * second `$match` stage), so the write/find-and-modify terminals see a * single authoritative filter expression. */ - override match(filter: MongoFilterExpr): FilteredCollection; + override match(filter: MongoFilterExpr): FilteredCollection; override match( fn: ( fields: FieldAccessor< ModelToDocShape, ModelNestedShape >, + helpers: StageFn, ) => MongoFilterExpr, - ): FilteredCollection; + ): FilteredCollection; override match( filterOrFn: | MongoFilterExpr @@ -393,8 +411,9 @@ export class FilteredCollection< ModelToDocShape, ModelNestedShape >, + helpers: StageFn, ) => MongoFilterExpr), - ): FilteredCollection { + ): FilteredCollection { const resolved = typeof filterOrFn === 'function' ? filterOrFn( @@ -402,9 +421,10 @@ export class FilteredCollection< ModelToDocShape, ModelNestedShape >(), + this.fn, ) : filterOrFn; - return new FilteredCollection(this.#ctx, this.#modelName, [ + return new FilteredCollection(this.#ctx, this.#modelName, [ ...this.#filters, resolved, ]); @@ -619,10 +639,12 @@ export function asMongoContract( */ export interface BindingContext< TContract extends MongoContractWithTypeMaps, + TOps extends MongoOperationCodecTable = MongoOperationCodecTable, > { readonly contract: TContract; readonly collection: string; readonly storageHash: string; + readonly operationCodecs: TOps; } /** @@ -632,10 +654,12 @@ export interface BindingContext< export function createCollectionHandle< TContract extends MongoContractWithTypeMaps, RootName extends keyof TContract['roots'] & string, + TOps extends MongoOperationCodecTable = MongoOperationCodecTable, >( contract: TContract, rootName: RootName, -): CollectionHandle> { + operationCodecs: TOps, +): CollectionHandle, TOps> { const c = asMongoContract(contract); const modelName = c.roots[rootName]?.model; if (!modelName) { @@ -659,6 +683,7 @@ export function createCollectionHandle< contract, collection: collectionName, storageHash: String(c.storage.storageHash), + operationCodecs, }, modelName as RootModelName, ); diff --git a/packages/2-mongo-family/5-query-builders/query-builder/src/types.ts b/packages/2-mongo-family/5-query-builders/query-builder/src/types.ts index d29d9deeed..38ef85bf41 100644 --- a/packages/2-mongo-family/5-query-builders/query-builder/src/types.ts +++ b/packages/2-mongo-family/5-query-builders/query-builder/src/types.ts @@ -14,23 +14,41 @@ export interface DocField { readonly nullable: boolean; } -export type NumericField = { readonly codecId: 'mongo/double@1'; readonly nullable: false }; -export type NullableNumericField = { readonly codecId: 'mongo/double@1'; readonly nullable: true }; -export type StringField = { readonly codecId: 'mongo/string@1'; readonly nullable: false }; -export type ArrayField = { readonly codecId: 'mongo/array@1'; readonly nullable: false }; -export type BooleanField = { readonly codecId: 'mongo/bool@1'; readonly nullable: false }; -export type DateField = { readonly codecId: 'mongo/date@1'; readonly nullable: false }; -export type NullableDocField = { readonly codecId: string; readonly nullable: true }; - -export type LiteralValue = F extends StringField - ? string - : F extends NumericField - ? number - : F extends BooleanField - ? boolean - : F extends DateField - ? Date - : unknown; +/** + * Structural placeholder for computed outputs the builder cannot yet + * resolve to a codec (array/document results become structural shapes under TML-2964). + * `ResolveFields` maps it to `unknown`. The `codecId` is a vestigial empty + * string at runtime — never a real codec id. + */ +export interface UnresolvedField extends DocField { + readonly unresolved: true; +} + +/** + * The operation→output-codec table the builder consumes. Declared by the + * adapter (which owns codec-id knowledge); the family only names operators. + */ +export type MongoOperationCodecTable = Readonly>; + +export type CodecTypesBase = Record; + +/** + * Codec ids from the contract's codec-type map whose decoded output extends + * `TOutput` — the Mongo analog of SQL's `CodecIdsWithTrait`, keyed on + * decoded output type instead of traits. + */ +export type CodecIdsWithOutput = { + [K in keyof CT & string]: CT[K]['output'] extends TOutput ? K : never; +}[keyof CT & string]; + +/** + * Field stamped on a computed expression whose output codec is declared by + * the adapter table for `Op`. + */ +export type ComputedField = { + readonly codecId: TOps[Op & keyof TOps]; + readonly nullable: false; +}; export type DocShape = Record; @@ -71,11 +89,13 @@ type ResolveFields< : unknown[] : unknown[] : unknown[] - : Shape[K]['codecId'] extends keyof CodecTypes - ? Shape[K]['nullable'] extends true - ? CodecTypes[Shape[K]['codecId']]['output'] | null - : CodecTypes[Shape[K]['codecId']]['output'] - : unknown; + : Shape[K] extends { readonly unresolved: true } + ? unknown + : Shape[K]['codecId'] extends keyof CodecTypes + ? Shape[K]['nullable'] extends true + ? CodecTypes[Shape[K]['codecId']]['output'] | null + : CodecTypes[Shape[K]['codecId']]['output'] + : unknown; }; /** @@ -189,7 +209,7 @@ export type GroupedDocShape = { : Spec[K] extends TypedAccumulatorExpr ? F : Spec[K] extends null - ? { readonly codecId: 'mongo/null@1'; readonly nullable: true } + ? UnresolvedField : DocField; }; diff --git a/packages/2-mongo-family/5-query-builders/query-builder/test/accumulator-helpers.test-d.ts b/packages/2-mongo-family/5-query-builders/query-builder/test/accumulator-helpers.test-d.ts index 4f791e2009..9832aa544d 100644 --- a/packages/2-mongo-family/5-query-builders/query-builder/test/accumulator-helpers.test-d.ts +++ b/packages/2-mongo-family/5-query-builders/query-builder/test/accumulator-helpers.test-d.ts @@ -1,14 +1,11 @@ import { expectTypeOf } from 'vitest'; import { acc } from '../src/accumulator-helpers'; -import type { - ArrayField, - DocField, - NullableNumericField, - NumericField, - StringField, - TypedAccumulatorExpr, - TypedAggExpr, -} from '../src/types'; +import type { DocField, TypedAccumulatorExpr, TypedAggExpr } from '../src/types'; + +type NumericField = { readonly codecId: 'mongo/double@1'; readonly nullable: false }; +type NullableNumericField = { readonly codecId: 'mongo/double@1'; readonly nullable: true }; +type ArrayField = { readonly codecId: 'mongo/array@1'; readonly nullable: false }; +type StringField = { readonly codecId: 'mongo/string@1'; readonly nullable: false }; const d = {} as TypedAggExpr; const n = {} as TypedAggExpr; diff --git a/packages/2-mongo-family/5-query-builders/query-builder/test/accumulator-helpers.test.ts b/packages/2-mongo-family/5-query-builders/query-builder/test/accumulator-helpers.test.ts index df476663f1..95435015ba 100644 --- a/packages/2-mongo-family/5-query-builders/query-builder/test/accumulator-helpers.test.ts +++ b/packages/2-mongo-family/5-query-builders/query-builder/test/accumulator-helpers.test.ts @@ -5,7 +5,9 @@ import { } from '@prisma-next/mongo-query-ast/execution'; import { describe, expect, it } from 'vitest'; import { acc } from '../src/accumulator-helpers'; -import type { DocField, NumericField, TypedAggExpr } from '../src/types'; +import type { DocField, TypedAggExpr } from '../src/types'; + +type NumericField = { readonly codecId: 'mongo/double@1'; readonly nullable: false }; const d: TypedAggExpr = { _field: { codecId: 'mongo/string@1', nullable: false }, diff --git a/packages/2-mongo-family/5-query-builders/query-builder/test/builder-new-stages.test.ts b/packages/2-mongo-family/5-query-builders/query-builder/test/builder-new-stages.test.ts index a28bdbc1a6..5931248f92 100644 --- a/packages/2-mongo-family/5-query-builders/query-builder/test/builder-new-stages.test.ts +++ b/packages/2-mongo-family/5-query-builders/query-builder/test/builder-new-stages.test.ts @@ -24,11 +24,12 @@ import { } from '@prisma-next/mongo-query-ast/execution'; import { describe, expect, it } from 'vitest'; import { mongoQuery } from '../src/query'; -import type { TContract } from './fixtures/test-contract'; -import { testContractJson } from './fixtures/test-contract'; +import { testContract, testOperationCodecs } from './fixtures/test-contract'; function createOrdersBuilder() { - return mongoQuery({ contractJson: testContractJson }).from('orders'); + return mongoQuery({ contractJson: testContract, operationCodecs: testOperationCodecs }).from( + 'orders', + ); } describe('new stage builder methods', () => { diff --git a/packages/2-mongo-family/5-query-builders/query-builder/test/builder.test-d.ts b/packages/2-mongo-family/5-query-builders/query-builder/test/builder.test-d.ts index 138e7a01d5..d106b80ea6 100644 --- a/packages/2-mongo-family/5-query-builders/query-builder/test/builder.test-d.ts +++ b/packages/2-mongo-family/5-query-builders/query-builder/test/builder.test-d.ts @@ -2,11 +2,14 @@ import type { MongoFilterExpr, MongoQueryPlan } from '@prisma-next/mongo-query-a import { MongoAggFieldRef, MongoLimitStage } from '@prisma-next/mongo-query-ast/execution'; import { expectTypeOf } from 'vitest'; import { acc } from '../src/accumulator-helpers'; -import { fn } from '../src/expression-helpers'; +import { createFn } from '../src/expression-helpers'; import { mongoQuery } from '../src/query'; -import type { TContract } from './fixtures/test-contract'; +import type { TContract, TestCodecTypes, TestOperationCodecs } from './fixtures/test-contract'; +import { testOperationCodecs } from './fixtures/test-contract'; -const contractJson = {} as unknown; +const fn = createFn(testOperationCodecs); + +const contractJson = {} as TContract; type PlanRow

= P extends MongoQueryPlan ? R : never; @@ -17,11 +20,12 @@ type OrderRow = { readonly customerId: string; readonly notes: string | null; readonly tags: string[]; + readonly createdAt: Date; }; describe('builder shape tests', () => { it('sort() only accepts keys from current shape', () => { - const p = mongoQuery({ contractJson }); + const p = mongoQuery({ contractJson, operationCodecs: testOperationCodecs }); const builder = p.from('orders'); builder.sort({ amount: -1 }); builder.sort({ status: 1 }); @@ -30,7 +34,7 @@ describe('builder shape tests', () => { }); it('group() replaces shape — previous fields inaccessible', () => { - const p = mongoQuery({ contractJson }); + const p = mongoQuery({ contractJson, operationCodecs: testOperationCodecs }); const grouped = p.from('orders').group((f) => ({ _id: f.customerId, total: acc.sum(f.amount), @@ -47,7 +51,7 @@ describe('builder shape tests', () => { }); it('addFields() extends shape', () => { - const p = mongoQuery({ contractJson }); + const p = mongoQuery({ contractJson, operationCodecs: testOperationCodecs }); const extended = p.from('orders').addFields((f) => ({ fullName: fn.concat(f.status, fn.literal(' ')), })); @@ -57,7 +61,7 @@ describe('builder shape tests', () => { }); it('project() inclusion narrows shape', () => { - const p = mongoQuery({ contractJson }); + const p = mongoQuery({ contractJson, operationCodecs: testOperationCodecs }); const projected = p.from('orders').project('status', 'amount'); projected.sort({ status: 1 }); @@ -68,7 +72,7 @@ describe('builder shape tests', () => { }); it('count() replaces shape with single field', () => { - const p = mongoQuery({ contractJson }); + const p = mongoQuery({ contractJson, operationCodecs: testOperationCodecs }); const counted = p.from('orders').count('total'); counted.sort({ total: 1 }); @@ -77,7 +81,7 @@ describe('builder shape tests', () => { }); it('lookup() adds array field and preserves existing fields', () => { - const p = mongoQuery({ contractJson }); + const p = mongoQuery({ contractJson, operationCodecs: testOperationCodecs }); const withLookup = p.from('orders').lookup((from) => from('users') .on((local, foreign) => ({ @@ -96,7 +100,7 @@ describe('builder shape tests', () => { }); it('lookup() rejects bad local field at type level (AC-1 TC-1)', () => { - const p = mongoQuery({ contractJson }); + const p = mongoQuery({ contractJson, operationCodecs: testOperationCodecs }); p.from('orders').lookup((from) => from('users') .on((local, foreign) => ({ @@ -109,7 +113,7 @@ describe('builder shape tests', () => { }); it('lookup() rejects bad foreign field at type level (AC-1 TC-2)', () => { - const p = mongoQuery({ contractJson }); + const p = mongoQuery({ contractJson, operationCodecs: testOperationCodecs }); p.from('orders').lookup((from) => from('users') .on((local, foreign) => ({ @@ -122,7 +126,7 @@ describe('builder shape tests', () => { }); it('lookup() rejects non-leaf returns from on() (AC-2 TC-3)', () => { - const p = mongoQuery({ contractJson }); + const p = mongoQuery({ contractJson, operationCodecs: testOperationCodecs }); p.from('orders').lookup((from) => from('users') .on((local, foreign) => ({ @@ -140,7 +144,7 @@ describe('builder shape tests', () => { // behaviour). See spec § Open Questions / Resolved decisions. it('replaceRoot() replaces entire shape', () => { - const p = mongoQuery({ contractJson }); + const p = mongoQuery({ contractJson, operationCodecs: testOperationCodecs }); type NewShape = { readonly x: { readonly codecId: 'mongo/string@1'; readonly nullable: false }; }; @@ -155,12 +159,14 @@ describe('builder shape tests', () => { describe('resolved row types', () => { it('from() → build() resolves to concrete field types', () => { - const plan = mongoQuery({ contractJson }).from('orders').build(); + const plan = mongoQuery({ contractJson, operationCodecs: testOperationCodecs }) + .from('orders') + .build(); expectTypeOf>().toEqualTypeOf(); }); it('match() preserves row type', () => { - const plan = mongoQuery({ contractJson }) + const plan = mongoQuery({ contractJson, operationCodecs: testOperationCodecs }) .from('orders') .match((f) => f.status.eq('active') as MongoFilterExpr) .build(); @@ -168,7 +174,7 @@ describe('resolved row types', () => { }); it('sort() preserves row type', () => { - const plan = mongoQuery({ contractJson }) + const plan = mongoQuery({ contractJson, operationCodecs: testOperationCodecs }) .from('orders') .sort({ amount: -1 }) .build(); @@ -176,7 +182,7 @@ describe('resolved row types', () => { }); it('limit() / skip() / sample() preserve row type', () => { - const plan = mongoQuery({ contractJson }) + const plan = mongoQuery({ contractJson, operationCodecs: testOperationCodecs }) .from('orders') .limit(10) .skip(5) @@ -186,7 +192,7 @@ describe('resolved row types', () => { }); it('addFields() extends row with new fields at correct types', () => { - const plan = mongoQuery({ contractJson }) + const plan = mongoQuery({ contractJson, operationCodecs: testOperationCodecs }) .from('orders') .addFields((f) => ({ fullName: fn.concat(f.status, fn.literal(' ')), @@ -200,13 +206,14 @@ describe('resolved row types', () => { readonly customerId: string; readonly notes: string | null; readonly tags: string[]; + readonly createdAt: Date; fullName: string; doubled: number; }>(); }); it('project() inclusion narrows to selected fields at correct types', () => { - const plan = mongoQuery({ contractJson }) + const plan = mongoQuery({ contractJson, operationCodecs: testOperationCodecs }) .from('orders') .project('status', 'amount') .build(); @@ -218,7 +225,7 @@ describe('resolved row types', () => { }); it('project() computed includes expression fields at correct types', () => { - const plan = mongoQuery({ contractJson }) + const plan = mongoQuery({ contractJson, operationCodecs: testOperationCodecs }) .from('orders') .project((f) => ({ status: 1 as const, @@ -235,7 +242,7 @@ describe('resolved row types', () => { }); it('group() resolves _id at grouped-by field type, accumulators at correct types', () => { - const plan = mongoQuery({ contractJson }) + const plan = mongoQuery({ contractJson, operationCodecs: testOperationCodecs }) .from('orders') .group((f) => ({ _id: f.customerId, @@ -253,17 +260,23 @@ describe('resolved row types', () => { }); it('unwind() preserves row type', () => { - const plan = mongoQuery({ contractJson }).from('orders').unwind('tags').build(); + const plan = mongoQuery({ contractJson, operationCodecs: testOperationCodecs }) + .from('orders') + .unwind('tags') + .build(); expectTypeOf>().toEqualTypeOf(); }); it('count() row type is { [field]: number }', () => { - const plan = mongoQuery({ contractJson }).from('orders').count('total').build(); + const plan = mongoQuery({ contractJson, operationCodecs: testOperationCodecs }) + .from('orders') + .count('total') + .build(); expectTypeOf>().toEqualTypeOf<{ total: number }>(); }); it('sortByCount() row type is { _id: ; count: number }', () => { - const plan = mongoQuery({ contractJson }) + const plan = mongoQuery({ contractJson, operationCodecs: testOperationCodecs }) .from('orders') .sortByCount((f) => f.status) .build(); @@ -274,7 +287,7 @@ describe('resolved row types', () => { }); it('lookup() resolves the as-named field to Array with concrete leaf types (AC-3 TC-4)', () => { - const plan = mongoQuery({ contractJson }) + const plan = mongoQuery({ contractJson, operationCodecs: testOperationCodecs }) .from('orders') .lookup((from) => from('users') @@ -292,6 +305,7 @@ describe('resolved row types', () => { readonly customerId: string; readonly notes: string | null; readonly tags: string[]; + readonly createdAt: Date; customer: Array<{ readonly _id: string; readonly firstName: string; @@ -302,7 +316,7 @@ describe('resolved row types', () => { }); it('lookup() resolves nested value-object fields on the foreign model recursively', () => { - const plan = mongoQuery({ contractJson }) + const plan = mongoQuery({ contractJson, operationCodecs: testOperationCodecs }) .from('orders') .lookup((from) => from('customers') @@ -320,6 +334,7 @@ describe('resolved row types', () => { readonly customerId: string; readonly notes: string | null; readonly tags: string[]; + readonly createdAt: Date; customer: Array<{ readonly _id: string; readonly name: string; @@ -341,7 +356,9 @@ describe('resolved row types', () => { }); it('entry-point query resolves value-object fields recursively (model-origin brand)', () => { - const plan = mongoQuery({ contractJson }).from('customers').build(); + const plan = mongoQuery({ contractJson, operationCodecs: testOperationCodecs }) + .from('customers') + .build(); expectTypeOf>().toEqualTypeOf<{ readonly _id: string; readonly name: string; @@ -362,7 +379,7 @@ describe('resolved row types', () => { }); it('addFields() preserves model-origin resolution and adds the new fields on top', () => { - const plan = mongoQuery({ contractJson }) + const plan = mongoQuery({ contractJson, operationCodecs: testOperationCodecs }) .from('customers') .addFields((f) => ({ upperName: fn.concat(f.name, fn.literal('!')), @@ -389,7 +406,7 @@ describe('resolved row types', () => { }); it('group() drops model-origin resolution (legitimate shape replacement)', () => { - const plan = mongoQuery({ contractJson }) + const plan = mongoQuery({ contractJson, operationCodecs: testOperationCodecs }) .from('customers') .group((f) => ({ _id: f.name, @@ -409,7 +426,7 @@ describe('resolved row types', () => { readonly x: { readonly codecId: 'mongo/string@1'; readonly nullable: false }; readonly y: { readonly codecId: 'mongo/double@1'; readonly nullable: true }; }; - const plan = mongoQuery({ contractJson }) + const plan = mongoQuery({ contractJson, operationCodecs: testOperationCodecs }) .from('orders') .replaceRoot((f) => f.status) .build(); @@ -420,7 +437,7 @@ describe('resolved row types', () => { }); it('pipe() preserves row type by default', () => { - const plan = mongoQuery({ contractJson }) + const plan = mongoQuery({ contractJson, operationCodecs: testOperationCodecs }) .from('orders') .pipe(new MongoLimitStage(5)) .build(); @@ -431,7 +448,7 @@ describe('resolved row types', () => { type NewShape = { readonly a: { readonly codecId: 'mongo/string@1'; readonly nullable: false }; }; - const plan = mongoQuery({ contractJson }) + const plan = mongoQuery({ contractJson, operationCodecs: testOperationCodecs }) .from('orders') .pipe(new MongoLimitStage(5)) .build(); @@ -439,14 +456,16 @@ describe('resolved row types', () => { }); it('nullable fields resolve to T | null', () => { - const plan = mongoQuery({ contractJson }).from('orders').build(); + const plan = mongoQuery({ contractJson, operationCodecs: testOperationCodecs }) + .from('orders') + .build(); type Row = PlanRow; expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); }); it('chained pipeline produces correct cumulative row types', () => { - const plan = mongoQuery({ contractJson }) + const plan = mongoQuery({ contractJson, operationCodecs: testOperationCodecs }) .from('orders') .match((f) => f.status.eq('active') as MongoFilterExpr) .group((f) => ({ @@ -465,7 +484,9 @@ describe('resolved row types', () => { }); it('execute() infers Row from build() plan type', () => { - const plan = mongoQuery({ contractJson }).from('orders').build(); + const plan = mongoQuery({ contractJson, operationCodecs: testOperationCodecs }) + .from('orders') + .build(); const execute = {} as (p: MongoQueryPlan) => Promise; const result = execute(plan); expectTypeOf[0]>().toEqualTypeOf(); @@ -474,7 +495,7 @@ describe('resolved row types', () => { describe('resolved row types — new stages', () => { it('redact() preserves row type', () => { - const plan = mongoQuery({ contractJson }) + const plan = mongoQuery({ contractJson, operationCodecs: testOperationCodecs }) .from('orders') .redact((f) => f.status) .build(); @@ -482,17 +503,21 @@ describe('resolved row types — new stages', () => { }); it('out() returns a write-terminal plan with an unknown row type', () => { - const plan = mongoQuery({ contractJson }).from('orders').out('archive'); + const plan = mongoQuery({ contractJson, operationCodecs: testOperationCodecs }) + .from('orders') + .out('archive'); expectTypeOf>().toEqualTypeOf(); }); it('merge() returns a write-terminal plan with an unknown row type', () => { - const plan = mongoQuery({ contractJson }).from('orders').merge({ into: 'summary' }); + const plan = mongoQuery({ contractJson, operationCodecs: testOperationCodecs }) + .from('orders') + .merge({ into: 'summary' }); expectTypeOf>().toEqualTypeOf(); }); it('unionWith() preserves row type', () => { - const plan = mongoQuery({ contractJson }) + const plan = mongoQuery({ contractJson, operationCodecs: testOperationCodecs }) .from('orders') .unionWith('archived_orders') .build(); @@ -500,7 +525,7 @@ describe('resolved row types — new stages', () => { }); it('densify() preserves row type', () => { - const plan = mongoQuery({ contractJson }) + const plan = mongoQuery({ contractJson, operationCodecs: testOperationCodecs }) .from('orders') .densify({ field: 'amount', range: { step: 10, bounds: 'full' } }) .build(); @@ -508,7 +533,7 @@ describe('resolved row types — new stages', () => { }); it('fill() preserves row type', () => { - const plan = mongoQuery({ contractJson }) + const plan = mongoQuery({ contractJson, operationCodecs: testOperationCodecs }) .from('orders') .fill({ sortBy: { amount: 1 }, output: { notes: { method: 'linear' } } }) .build(); @@ -516,7 +541,7 @@ describe('resolved row types — new stages', () => { }); it('search() preserves row type', () => { - const plan = mongoQuery({ contractJson }) + const plan = mongoQuery({ contractJson, operationCodecs: testOperationCodecs }) .from('orders') .search({ text: { query: 'test', path: 'status' } }) .build(); @@ -524,7 +549,7 @@ describe('resolved row types — new stages', () => { }); it('vectorSearch() preserves row type', () => { - const plan = mongoQuery({ contractJson }) + const plan = mongoQuery({ contractJson, operationCodecs: testOperationCodecs }) .from('orders') .vectorSearch({ index: 'idx', @@ -538,7 +563,7 @@ describe('resolved row types — new stages', () => { }); it('bucket() resets to untyped row', () => { - const plan = mongoQuery({ contractJson }) + const plan = mongoQuery({ contractJson, operationCodecs: testOperationCodecs }) .from('orders') .bucket({ groupBy: MongoAggFieldRef.of('amount'), boundaries: [0, 100, 500] }) .build(); @@ -546,7 +571,7 @@ describe('resolved row types — new stages', () => { }); it('bucketAuto() resets to untyped row', () => { - const plan = mongoQuery({ contractJson }) + const plan = mongoQuery({ contractJson, operationCodecs: testOperationCodecs }) .from('orders') .bucketAuto({ groupBy: MongoAggFieldRef.of('amount'), buckets: 5 }) .build(); @@ -554,7 +579,7 @@ describe('resolved row types — new stages', () => { }); it('facet() resets to untyped row', () => { - const plan = mongoQuery({ contractJson }) + const plan = mongoQuery({ contractJson, operationCodecs: testOperationCodecs }) .from('orders') .facet({ counts: [], top: [] }) .build(); @@ -562,7 +587,7 @@ describe('resolved row types — new stages', () => { }); it('geoNear() resets to untyped row', () => { - const plan = mongoQuery({ contractJson }) + const plan = mongoQuery({ contractJson, operationCodecs: testOperationCodecs }) .from('orders') .geoNear({ near: [0, 0], distanceField: 'dist' }) .build(); @@ -570,7 +595,7 @@ describe('resolved row types — new stages', () => { }); it('graphLookup() resets to untyped row', () => { - const plan = mongoQuery({ contractJson }) + const plan = mongoQuery({ contractJson, operationCodecs: testOperationCodecs }) .from('orders') .graphLookup({ from: 'categories', @@ -584,7 +609,7 @@ describe('resolved row types — new stages', () => { }); it('setWindowFields() resets to untyped row', () => { - const plan = mongoQuery({ contractJson }) + const plan = mongoQuery({ contractJson, operationCodecs: testOperationCodecs }) .from('orders') .setWindowFields({ sortBy: { amount: 1 }, output: {} }) .build(); @@ -592,10 +617,83 @@ describe('resolved row types — new stages', () => { }); it('searchMeta() resets to untyped row', () => { - const plan = mongoQuery({ contractJson }) + const plan = mongoQuery({ contractJson, operationCodecs: testOperationCodecs }) .from('orders') .searchMeta({ facet: { operator: {} } }) .build(); expectTypeOf>().toEqualTypeOf>(); }); }); + +describe('context-bound fn (TML-2964)', () => { + it('stage callbacks receive the minted fn as second parameter', () => { + const plan = mongoQuery({ contractJson, operationCodecs: testOperationCodecs }) + .from('orders') + .addFields((f, helpers) => ({ + shout: helpers.toUpper(f.status), + })) + .build(); + expectTypeOf['shout']>().toEqualTypeOf(); + }); + + it('dateToString accepts a contract date field uncast', () => { + mongoQuery({ contractJson, operationCodecs: testOperationCodecs }) + .from('orders') + .addFields((f, helpers) => ({ + rendered: helpers.dateToString({ date: f.createdAt }), + })); + }); + + it('dateDiff accepts contract date fields and a computed date uncast', () => { + mongoQuery({ contractJson, operationCodecs: testOperationCodecs }) + .from('orders') + .addFields((f, helpers) => ({ + age: helpers.dateDiff({ + startDate: f.createdAt, + endDate: helpers.toDate(f.status), + unit: helpers.literal('day'), + }), + })); + }); + + it('rejects a non-date contract field where a date is required', () => { + mongoQuery({ contractJson, operationCodecs: testOperationCodecs }) + .from('orders') + .addFields((f, helpers) => ({ + // @ts-expect-error — status decodes to string, not Date + bad: helpers.dateToString({ date: f.status }), + })); + }); + + it('computed outputs resolve through the contract codec map in the row type', () => { + const plan = mongoQuery({ contractJson, operationCodecs: testOperationCodecs }) + .from('orders') + .project((f, helpers) => ({ + asDate: helpers.toDate(f.status), + isActive: helpers.eq(f.status, helpers.literal('active')), + yearOf: helpers.year(f.createdAt), + })) + .build(); + expectTypeOf['asDate']>().toEqualTypeOf(); + expectTypeOf['isActive']>().toEqualTypeOf(); + expectTypeOf['yearOf']>().toEqualTypeOf(); + }); + + it('count() row type resolves via the table entry for $count', () => { + const plan = mongoQuery({ contractJson, operationCodecs: testOperationCodecs }) + .from('orders') + .count('total') + .build(); + expectTypeOf>().toEqualTypeOf<{ total: number }>(); + }); + + it('the query root exposes the minted fn for standalone construction', () => { + const root = mongoQuery({ contractJson, operationCodecs: testOperationCodecs }); + expectTypeOf( + root.fn.concat(root.fn.literal('a'))._field.codecId, + ).toEqualTypeOf<'mongo/string@1'>(); + expectTypeOf( + root.fn.toDate(root.fn.literal('x'))._field.codecId, + ).toEqualTypeOf<'mongo/date@1'>(); + }); +}); diff --git a/packages/2-mongo-family/5-query-builders/query-builder/test/builder.test.ts b/packages/2-mongo-family/5-query-builders/query-builder/test/builder.test.ts index 726bc7de2a..2093bbe31c 100644 --- a/packages/2-mongo-family/5-query-builders/query-builder/test/builder.test.ts +++ b/packages/2-mongo-family/5-query-builders/query-builder/test/builder.test.ts @@ -21,18 +21,20 @@ import { } from '@prisma-next/mongo-query-ast/execution'; import { describe, expect, it } from 'vitest'; import { acc } from '../src/accumulator-helpers'; -import { fn } from '../src/expression-helpers'; import type { LookupOnResult } from '../src/lookup-builder'; import { mongoQuery } from '../src/query'; -import type { TContract } from './fixtures/test-contract'; -import { testContractJson } from './fixtures/test-contract'; +import { testContract, testOperationCodecs } from './fixtures/test-contract'; function createOrdersBuilder() { - return mongoQuery({ contractJson: testContractJson }).from('orders'); + return mongoQuery({ contractJson: testContract, operationCodecs: testOperationCodecs }).from( + 'orders', + ); } function createCustomersBuilder() { - return mongoQuery({ contractJson: testContractJson }).from('customers'); + return mongoQuery({ contractJson: testContract, operationCodecs: testOperationCodecs }).from( + 'customers', + ); } describe('PipelineChain', () => { @@ -136,7 +138,7 @@ describe('PipelineChain', () => { describe('addFields()', () => { it('produces MongoAddFieldsStage with correct expressions', () => { const plan = createOrdersBuilder() - .addFields((f) => ({ + .addFields((f, fn) => ({ fullName: fn.concat(f.status, fn.literal(' ')), })) .build(); @@ -161,7 +163,7 @@ describe('PipelineChain', () => { it('computed form produces MongoProjectStage with expressions', () => { const plan = createOrdersBuilder() - .project((f) => ({ + .project((f, fn) => ({ status: 1 as const, upper: fn.toUpper(f.status), })) @@ -388,14 +390,14 @@ describe('PipelineChain', () => { describe('mongoQuery()', () => { it('from() creates builder for known root', () => { - const p = mongoQuery({ contractJson: testContractJson }); + const p = mongoQuery({ contractJson: testContract, operationCodecs: testOperationCodecs }); const builder = p.from('orders'); const plan = builder.build(); expect(plan.collection).toBe('orders'); }); it('from() throws for unknown root', () => { - const p = mongoQuery({ contractJson: testContractJson }); + const p = mongoQuery({ contractJson: testContract, operationCodecs: testOperationCodecs }); expect(() => p.from('nonexistent' as 'orders')).toThrow('Unknown root'); }); }); diff --git a/packages/2-mongo-family/5-query-builders/query-builder/test/contract-free-collection.test.ts b/packages/2-mongo-family/5-query-builders/query-builder/test/contract-free-collection.test.ts index 9bcfbc6a2b..ca473c642d 100644 --- a/packages/2-mongo-family/5-query-builders/query-builder/test/contract-free-collection.test.ts +++ b/packages/2-mongo-family/5-query-builders/query-builder/test/contract-free-collection.test.ts @@ -11,7 +11,10 @@ import { } from '@prisma-next/mongo-query-ast/execution'; import { describe, expect, it } from 'vitest'; import { collection } from '../src/contract-free/collection'; -import { expr, fn } from '../src/exports/index'; +import { createFn, expr } from '../src/exports/index'; +import { testOperationCodecs } from './fixtures/test-contract'; + +const fn = createFn(testOperationCodecs); type TestShape = { readonly _id: { readonly codecId: 'mongo/string@1'; readonly nullable: false }; diff --git a/packages/2-mongo-family/5-query-builders/query-builder/test/expression-helpers.test-d.ts b/packages/2-mongo-family/5-query-builders/query-builder/test/expression-helpers.test-d.ts index e83a07ba1e..ad82beffbf 100644 --- a/packages/2-mongo-family/5-query-builders/query-builder/test/expression-helpers.test-d.ts +++ b/packages/2-mongo-family/5-query-builders/query-builder/test/expression-helpers.test-d.ts @@ -1,324 +1,305 @@ import { expectTypeOf } from 'vitest'; -import { fn } from '../src/expression-helpers'; -import type { - ArrayField, - BooleanField, - DateField, - DocField, - NullableDocField, - NumericField, - StringField, - TypedAggExpr, -} from '../src/types'; +import { createFn } from '../src/expression-helpers'; +import type { DocField, TypedAggExpr, UnresolvedField } from '../src/types'; +import type { TestCodecTypes, TestOperationCodecs } from './fixtures/test-contract'; +import { testOperationCodecs } from './fixtures/test-contract'; + +const fn = createFn(testOperationCodecs); + +type StringLeaf = { readonly codecId: 'mongo/string@1'; readonly nullable: false }; +type NumericLeaf = { readonly codecId: 'mongo/double@1'; readonly nullable: false }; +type BooleanLeaf = { readonly codecId: 'mongo/bool@1'; readonly nullable: false }; +type DateLeaf = { readonly codecId: 'mongo/date@1'; readonly nullable: false }; +type ObjectIdLeaf = { readonly codecId: 'mongo/objectId@1'; readonly nullable: false }; const d = {} as TypedAggExpr; -const s = {} as TypedAggExpr; -const n = {} as TypedAggExpr; -const dt = {} as TypedAggExpr; -const b = {} as TypedAggExpr; -const arr = {} as TypedAggExpr; +const s = {} as TypedAggExpr; +const n = {} as TypedAggExpr; +const dt = {} as TypedAggExpr; +const b = {} as TypedAggExpr; +const arr = {} as TypedAggExpr; -describe('date helpers', () => { - it('year returns NumericField', () => { - expectTypeOf(fn.year(d)).toEqualTypeOf>(); +describe('table-sourced output types', () => { + it('concat output codec comes from the table', () => { + expectTypeOf(fn.concat(d)).toEqualTypeOf>(); }); - it('month returns NumericField', () => { - expectTypeOf(fn.month(d)).toEqualTypeOf>(); + it('toDate output codec comes from the table', () => { + expectTypeOf(fn.toDate(d)).toEqualTypeOf>(); }); - it('dayOfMonth returns NumericField', () => { - expectTypeOf(fn.dayOfMonth(d)).toEqualTypeOf>(); + it('eq output codec comes from the table', () => { + expectTypeOf(fn.eq(d, d)).toEqualTypeOf>(); }); - it('hour returns NumericField', () => { - expectTypeOf(fn.hour(d)).toEqualTypeOf>(); + it('year output codec comes from the table', () => { + expectTypeOf(fn.year(dt)).toEqualTypeOf>(); }); - it('minute returns NumericField', () => { - expectTypeOf(fn.minute(d)).toEqualTypeOf>(); + it('toObjectId output codec comes from the table', () => { + expectTypeOf(fn.toObjectId(s)).toEqualTypeOf>(); }); - it('second returns NumericField', () => { - expectTypeOf(fn.second(d)).toEqualTypeOf>(); - }); - it('millisecond returns NumericField', () => { - expectTypeOf(fn.millisecond(d)).toEqualTypeOf>(); + + it('a different table yields different output codec types — no hardcode', () => { + const altOps = { $concat: 'alt/text@9', $toDate: 'alt/when@2' } as const; + const altFn = createFn(altOps); + expectTypeOf(altFn.concat(d)).toEqualTypeOf< + TypedAggExpr<{ readonly codecId: 'alt/text@9'; readonly nullable: false }> + >(); + expectTypeOf(altFn.toDate(d)).toEqualTypeOf< + TypedAggExpr<{ readonly codecId: 'alt/when@2'; readonly nullable: false }> + >(); }); - it('dateToString returns StringField', () => { +}); + +describe('date helpers', () => { + it('date parts return the numeric output', () => { + expectTypeOf(fn.month(dt)).toEqualTypeOf>(); + expectTypeOf(fn.dayOfMonth(dt)).toEqualTypeOf>(); + expectTypeOf(fn.hour(dt)).toEqualTypeOf>(); + expectTypeOf(fn.minute(dt)).toEqualTypeOf>(); + expectTypeOf(fn.second(dt)).toEqualTypeOf>(); + expectTypeOf(fn.millisecond(dt)).toEqualTypeOf>(); + }); + it('dateToString accepts a contract-shaped date leaf uncast', () => { expectTypeOf(fn.dateToString({ date: dt, format: s })).toEqualTypeOf< - TypedAggExpr + TypedAggExpr >(); + expectTypeOf(fn.dateToString({ date: dt })).toEqualTypeOf>(); }); - it('dateToString accepts optional keys', () => { - expectTypeOf(fn.dateToString({ date: dt })).toEqualTypeOf>(); + it('dateToString accepts a computed date uncast', () => { + expectTypeOf(fn.dateToString({ date: fn.toDate(s) })).toEqualTypeOf>(); }); - it('dateFromString returns DateField', () => { - expectTypeOf(fn.dateFromString({ dateString: s })).toEqualTypeOf>(); + it('dateFromString returns the date output', () => { + expectTypeOf(fn.dateFromString({ dateString: s })).toEqualTypeOf>(); }); - it('dateDiff returns NumericField', () => { - expectTypeOf(fn.dateDiff({ startDate: dt, endDate: dt, unit: s })).toEqualTypeOf< - TypedAggExpr + it('dateDiff accepts contract and computed dates and returns the numeric output', () => { + expectTypeOf(fn.dateDiff({ startDate: dt, endDate: fn.toDate(s), unit: s })).toEqualTypeOf< + TypedAggExpr >(); }); - it('dateAdd returns DateField', () => { + it('dateAdd returns the date output', () => { expectTypeOf(fn.dateAdd({ startDate: dt, unit: s, amount: n })).toEqualTypeOf< - TypedAggExpr + TypedAggExpr >(); }); - it('dateSubtract returns DateField', () => { + it('dateSubtract returns the date output', () => { expectTypeOf(fn.dateSubtract({ startDate: dt, unit: s, amount: n })).toEqualTypeOf< - TypedAggExpr + TypedAggExpr >(); }); - it('dateTrunc returns DateField', () => { - expectTypeOf(fn.dateTrunc({ date: dt, unit: s })).toEqualTypeOf>(); + it('dateTrunc returns the date output', () => { + expectTypeOf(fn.dateTrunc({ date: dt, unit: s })).toEqualTypeOf>(); }); it('rejects wrong type for dateToString date key', () => { - // @ts-expect-error — date requires DateField, not StringField + // @ts-expect-error — date requires an expression whose codec decodes to Date fn.dateToString({ date: s }); }); it('rejects wrong type for dateAdd amount key', () => { - // @ts-expect-error — amount requires NumericField, not StringField + // @ts-expect-error — amount requires an expression whose codec decodes to number fn.dateAdd({ startDate: dt, unit: s, amount: s }); }); }); describe('string helpers', () => { - it('substr returns StringField', () => { - expectTypeOf(fn.substr(d, d, d)).toEqualTypeOf>(); - }); - it('substrBytes returns StringField', () => { - expectTypeOf(fn.substrBytes(d, d, d)).toEqualTypeOf>(); - }); - it('trim returns StringField', () => { - expectTypeOf(fn.trim({ input: s })).toEqualTypeOf>(); + it('substr returns the string output', () => { + expectTypeOf(fn.substr(d, d, d)).toEqualTypeOf>(); }); - it('ltrim returns StringField', () => { - expectTypeOf(fn.ltrim({ input: s })).toEqualTypeOf>(); + it('substrBytes returns the string output', () => { + expectTypeOf(fn.substrBytes(d, d, d)).toEqualTypeOf>(); }); - it('rtrim returns StringField', () => { - expectTypeOf(fn.rtrim({ input: s })).toEqualTypeOf>(); + it('trim family returns the string output', () => { + expectTypeOf(fn.trim({ input: s })).toEqualTypeOf>(); + expectTypeOf(fn.ltrim({ input: s })).toEqualTypeOf>(); + expectTypeOf(fn.rtrim({ input: s })).toEqualTypeOf>(); }); - it('split returns ArrayField', () => { - expectTypeOf(fn.split(d, d)).toEqualTypeOf>(); + it('trim accepts a computed string uncast', () => { + expectTypeOf(fn.trim({ input: fn.concat(s, s) })).toEqualTypeOf>(); }); - it('strLenCP returns NumericField', () => { - expectTypeOf(fn.strLenCP(d)).toEqualTypeOf>(); + it('split returns an unresolved structural field', () => { + expectTypeOf(fn.split(s, s)).toEqualTypeOf>(); }); - it('strLenBytes returns NumericField', () => { - expectTypeOf(fn.strLenBytes(d)).toEqualTypeOf>(); + it('strLen helpers return the numeric output', () => { + expectTypeOf(fn.strLenCP(s)).toEqualTypeOf>(); + expectTypeOf(fn.strLenBytes(s)).toEqualTypeOf>(); }); - it('regexMatch returns BooleanField', () => { - expectTypeOf(fn.regexMatch({ input: s, regex: s })).toEqualTypeOf>(); + it('regexMatch returns the boolean output', () => { + expectTypeOf(fn.regexMatch({ input: s, regex: s })).toEqualTypeOf>(); }); - it('regexFind returns DocField', () => { - expectTypeOf(fn.regexFind({ input: s, regex: s })).toEqualTypeOf>(); + it('regexFind returns an unresolved structural field', () => { + expectTypeOf(fn.regexFind({ input: s, regex: s })).toEqualTypeOf< + TypedAggExpr + >(); }); - it('regexFindAll returns ArrayField', () => { - expectTypeOf(fn.regexFindAll({ input: s, regex: s })).toEqualTypeOf>(); + it('regexFindAll returns an unresolved structural field', () => { + expectTypeOf(fn.regexFindAll({ input: s, regex: s })).toEqualTypeOf< + TypedAggExpr + >(); }); - it('replaceOne returns StringField', () => { + it('replaceOne/replaceAll return the string output', () => { expectTypeOf(fn.replaceOne({ input: s, find: s, replacement: s })).toEqualTypeOf< - TypedAggExpr + TypedAggExpr >(); - }); - it('replaceAll returns StringField', () => { expectTypeOf(fn.replaceAll({ input: s, find: s, replacement: s })).toEqualTypeOf< - TypedAggExpr + TypedAggExpr >(); }); it('rejects wrong type for trim input key', () => { - // @ts-expect-error — input requires StringField, not NumericField + // @ts-expect-error — input requires an expression whose codec decodes to string fn.trim({ input: n }); }); it('rejects wrong type for regexMatch input key', () => { - // @ts-expect-error — input requires StringField, not DateField + // @ts-expect-error — input requires an expression whose codec decodes to string fn.regexMatch({ input: dt, regex: s }); }); }); describe('comparison helpers', () => { - it('cmp returns NumericField', () => { - expectTypeOf(fn.cmp(d, d)).toEqualTypeOf>(); - }); - it('eq returns BooleanField', () => { - expectTypeOf(fn.eq(d, d)).toEqualTypeOf>(); - }); - it('ne returns BooleanField', () => { - expectTypeOf(fn.ne(d, d)).toEqualTypeOf>(); - }); - it('gt returns BooleanField', () => { - expectTypeOf(fn.gt(d, d)).toEqualTypeOf>(); + it('cmp returns the numeric output', () => { + expectTypeOf(fn.cmp(d, d)).toEqualTypeOf>(); }); - it('gte returns BooleanField', () => { - expectTypeOf(fn.gte(d, d)).toEqualTypeOf>(); + it('binary comparisons return the boolean output', () => { + expectTypeOf(fn.ne(d, d)).toEqualTypeOf>(); + expectTypeOf(fn.gt(d, d)).toEqualTypeOf>(); + expectTypeOf(fn.gte(d, d)).toEqualTypeOf>(); + expectTypeOf(fn.lt(d, d)).toEqualTypeOf>(); + expectTypeOf(fn.lte(d, d)).toEqualTypeOf>(); }); - it('lt returns BooleanField', () => { - expectTypeOf(fn.lt(d, d)).toEqualTypeOf>(); +}); + +describe('arithmetic helpers', () => { + it('add/subtract/multiply/divide return the numeric output', () => { + expectTypeOf(fn.add(n, n)).toEqualTypeOf>(); + expectTypeOf(fn.subtract(n, n)).toEqualTypeOf>(); + expectTypeOf(fn.multiply(n, n)).toEqualTypeOf>(); + expectTypeOf(fn.divide(n, n)).toEqualTypeOf>(); }); - it('lte returns BooleanField', () => { - expectTypeOf(fn.lte(d, d)).toEqualTypeOf>(); + it('size returns the numeric output', () => { + expectTypeOf(fn.size(arr)).toEqualTypeOf>(); }); }); describe('array helpers', () => { - it('arrayElemAt returns NullableDocField', () => { - expectTypeOf(fn.arrayElemAt(d, d)).toEqualTypeOf>(); - }); - it('concatArrays returns ArrayField', () => { - expectTypeOf(fn.concatArrays(d, d)).toEqualTypeOf>(); - }); - it('firstElem returns NullableDocField', () => { - expectTypeOf(fn.firstElem(d)).toEqualTypeOf>(); - }); - it('lastElem returns NullableDocField', () => { - expectTypeOf(fn.lastElem(d)).toEqualTypeOf>(); + it('arrayElemAt returns an unresolved structural field', () => { + expectTypeOf(fn.arrayElemAt(d, d)).toEqualTypeOf>(); }); - it('isIn returns BooleanField', () => { - expectTypeOf(fn.isIn(d, d)).toEqualTypeOf>(); + it('concatArrays returns an unresolved structural field', () => { + expectTypeOf(fn.concatArrays(d, d)).toEqualTypeOf>(); }); - it('indexOfArray returns NumericField', () => { - expectTypeOf(fn.indexOfArray(d, d)).toEqualTypeOf>(); + it('firstElem/lastElem return unresolved structural fields', () => { + expectTypeOf(fn.firstElem(d)).toEqualTypeOf>(); + expectTypeOf(fn.lastElem(d)).toEqualTypeOf>(); }); - it('isArray returns BooleanField', () => { - expectTypeOf(fn.isArray(d)).toEqualTypeOf>(); + it('isIn returns the boolean output', () => { + expectTypeOf(fn.isIn(d, d)).toEqualTypeOf>(); }); - it('reverseArray returns ArrayField', () => { - expectTypeOf(fn.reverseArray(d)).toEqualTypeOf>(); + it('indexOfArray returns the numeric output', () => { + expectTypeOf(fn.indexOfArray(d, d)).toEqualTypeOf>(); }); - it('slice returns ArrayField', () => { - expectTypeOf(fn.slice(d, d)).toEqualTypeOf>(); + it('isArray returns the boolean output', () => { + expectTypeOf(fn.isArray(d)).toEqualTypeOf>(); }); - it('zip returns ArrayField', () => { - expectTypeOf(fn.zip({ inputs: [arr, arr] })).toEqualTypeOf>(); + it('reverseArray/slice/range return unresolved structural fields', () => { + expectTypeOf(fn.reverseArray(d)).toEqualTypeOf>(); + expectTypeOf(fn.slice(d, d)).toEqualTypeOf>(); + expectTypeOf(fn.range(n, n, n)).toEqualTypeOf>(); }); - it('range returns ArrayField', () => { - expectTypeOf(fn.range(d, d, d)).toEqualTypeOf>(); + it('zip returns an unresolved structural field', () => { + expectTypeOf(fn.zip({ inputs: [arr, arr] })).toEqualTypeOf>(); }); it('rejects wrong type for zip inputs key', () => { - // @ts-expect-error — inputs requires ArrayField[], not StringField + // @ts-expect-error — inputs requires unresolved-array expressions, not a bare string leaf fn.zip({ inputs: s }); }); }); describe('set helpers', () => { - it('setUnion returns ArrayField', () => { - expectTypeOf(fn.setUnion(d, d)).toEqualTypeOf>(); + it('set-producing helpers return unresolved structural fields', () => { + expectTypeOf(fn.setUnion(d, d)).toEqualTypeOf>(); + expectTypeOf(fn.setIntersection(d, d)).toEqualTypeOf>(); + expectTypeOf(fn.setDifference(d, d)).toEqualTypeOf>(); }); - it('setIntersection returns ArrayField', () => { - expectTypeOf(fn.setIntersection(d, d)).toEqualTypeOf>(); - }); - it('setDifference returns ArrayField', () => { - expectTypeOf(fn.setDifference(d, d)).toEqualTypeOf>(); - }); - it('setEquals returns BooleanField', () => { - expectTypeOf(fn.setEquals(d, d)).toEqualTypeOf>(); - }); - it('setIsSubset returns BooleanField', () => { - expectTypeOf(fn.setIsSubset(d, d)).toEqualTypeOf>(); - }); - it('anyElementTrue returns BooleanField', () => { - expectTypeOf(fn.anyElementTrue(d)).toEqualTypeOf>(); - }); - it('allElementsTrue returns BooleanField', () => { - expectTypeOf(fn.allElementsTrue(d)).toEqualTypeOf>(); + it('set predicates return the boolean output', () => { + expectTypeOf(fn.setEquals(d, d)).toEqualTypeOf>(); + expectTypeOf(fn.setIsSubset(d, d)).toEqualTypeOf>(); + expectTypeOf(fn.anyElementTrue(d)).toEqualTypeOf>(); + expectTypeOf(fn.allElementsTrue(d)).toEqualTypeOf>(); }); }); describe('type helpers', () => { - it('typeOf returns StringField', () => { - expectTypeOf(fn.typeOf(d)).toEqualTypeOf>(); - }); - it('convert returns DocField', () => { - expectTypeOf(fn.convert({ input: d, to: s })).toEqualTypeOf>(); + it('typeOf returns the string output', () => { + expectTypeOf(fn.typeOf(d)).toEqualTypeOf>(); }); - it('convert accepts NumericField for to', () => { - expectTypeOf(fn.convert({ input: d, to: n })).toEqualTypeOf>(); + it('convert returns an unresolved structural field', () => { + expectTypeOf(fn.convert({ input: d, to: s })).toEqualTypeOf>(); + expectTypeOf(fn.convert({ input: d, to: n })).toEqualTypeOf>(); }); - it('toInt returns NumericField', () => { - expectTypeOf(fn.toInt(d)).toEqualTypeOf>(); + it('numeric conversions return the numeric output', () => { + expectTypeOf(fn.toInt(d)).toEqualTypeOf>(); + expectTypeOf(fn.toLong(d)).toEqualTypeOf>(); + expectTypeOf(fn.toDouble(d)).toEqualTypeOf>(); + expectTypeOf(fn.toDecimal(d)).toEqualTypeOf>(); }); - it('toLong returns NumericField', () => { - expectTypeOf(fn.toLong(d)).toEqualTypeOf>(); + it('toString_ returns the string output', () => { + expectTypeOf(fn.toString_(d)).toEqualTypeOf>(); }); - it('toDouble returns NumericField', () => { - expectTypeOf(fn.toDouble(d)).toEqualTypeOf>(); - }); - it('toDecimal returns NumericField', () => { - expectTypeOf(fn.toDecimal(d)).toEqualTypeOf>(); - }); - it('toString_ returns StringField', () => { - expectTypeOf(fn.toString_(d)).toEqualTypeOf>(); - }); - it('toObjectId returns DocField', () => { - expectTypeOf(fn.toObjectId(d)).toEqualTypeOf>(); - }); - it('toBool returns BooleanField', () => { - expectTypeOf(fn.toBool(d)).toEqualTypeOf>(); - }); - it('toDate returns DateField', () => { - expectTypeOf(fn.toDate(d)).toEqualTypeOf>(); + it('toBool returns the boolean output', () => { + expectTypeOf(fn.toBool(d)).toEqualTypeOf>(); }); it('rejects wrong type for convert to key', () => { - // @ts-expect-error — to requires StringField | NumericField, not DateField + // @ts-expect-error — to requires an expression decoding to string or number fn.convert({ input: d, to: dt }); }); }); describe('object helpers', () => { - it('objectToArray returns ArrayField', () => { - expectTypeOf(fn.objectToArray(d)).toEqualTypeOf>(); - }); - it('arrayToObject returns DocField', () => { - expectTypeOf(fn.arrayToObject(d)).toEqualTypeOf>(); + it('objectToArray/arrayToObject return unresolved structural fields', () => { + expectTypeOf(fn.objectToArray(d)).toEqualTypeOf>(); + expectTypeOf(fn.arrayToObject(d)).toEqualTypeOf>(); }); - it('getField returns DocField', () => { - expectTypeOf(fn.getField({ field: s, input: d })).toEqualTypeOf>(); - }); - it('getField accepts optional input', () => { - expectTypeOf(fn.getField({ field: s })).toEqualTypeOf>(); - }); - it('setField returns DocField', () => { + it('getField/setField return unresolved structural fields', () => { + expectTypeOf(fn.getField({ field: s, input: d })).toEqualTypeOf< + TypedAggExpr + >(); + expectTypeOf(fn.getField({ field: s })).toEqualTypeOf>(); expectTypeOf(fn.setField({ field: s, input: d, value: d })).toEqualTypeOf< - TypedAggExpr + TypedAggExpr >(); }); it('rejects wrong type for getField field key', () => { - // @ts-expect-error — field requires StringField, not NumericField + // @ts-expect-error — field requires an expression whose codec decodes to string fn.getField({ field: n }); }); it('rejects wrong type for setField field key', () => { - // @ts-expect-error — field requires StringField, not BooleanField + // @ts-expect-error — field requires an expression whose codec decodes to string fn.setField({ field: b, input: d, value: d }); }); }); -describe('literal type inference', () => { - it('infers StringField from string value', () => { - expectTypeOf(fn.literal('hello')).toEqualTypeOf>(); - }); - it('infers NumericField from number value', () => { - expectTypeOf(fn.literal(42)).toEqualTypeOf>(); - }); - it('infers BooleanField from boolean value', () => { - expectTypeOf(fn.literal(true)).toEqualTypeOf>(); - }); - it('infers DateField from Date value', () => { - expectTypeOf(fn.literal(new Date())).toEqualTypeOf>(); +describe('control flow', () => { + it('cond propagates the then-branch field', () => { + expectTypeOf(fn.cond(fn.eq(d, d).node, s, d)).toEqualTypeOf>(); + expectTypeOf(fn.cond(fn.eq(d, d).node, dt, d)).toEqualTypeOf>(); }); +}); - it('contextual inference constrains value — string literal in StringField position', () => { +describe('literal inference', () => { + it('string literal usable in string-input positions', () => { expectTypeOf(fn.dateToString({ date: dt, format: fn.literal('%Y-%m-%d') })).toEqualTypeOf< - TypedAggExpr + TypedAggExpr >(); }); - - it('rejects wrong value type in contextual position', () => { - // @ts-expect-error — format expects StringField, but 42 infers NumericField + it('number literal usable in numeric-input positions', () => { + expectTypeOf(fn.dateAdd({ startDate: dt, unit: s, amount: fn.literal(3) })).toEqualTypeOf< + TypedAggExpr + >(); + }); + it('rejects wrong literal type in contextual position', () => { + // @ts-expect-error — format expects a string-decoding expression, 42 infers numeric fn.dateToString({ date: dt, format: fn.literal(42) }); }); - it('allows explicit generic for custom field types', () => { type CustomField = { readonly codecId: 'custom/bigint@1'; readonly nullable: false }; const custom = fn.literal(42n); diff --git a/packages/2-mongo-family/5-query-builders/query-builder/test/expression-helpers.test.ts b/packages/2-mongo-family/5-query-builders/query-builder/test/expression-helpers.test.ts index e37a6b46b4..ed1c2939ac 100644 --- a/packages/2-mongo-family/5-query-builders/query-builder/test/expression-helpers.test.ts +++ b/packages/2-mongo-family/5-query-builders/query-builder/test/expression-helpers.test.ts @@ -4,40 +4,72 @@ import { MongoAggOperator, } from '@prisma-next/mongo-query-ast/execution'; import { describe, expect, it } from 'vitest'; -import { fn } from '../src/expression-helpers'; -import type { - ArrayField, - DateField, - DocField, - NumericField, - StringField, - TypedAggExpr, -} from '../src/types'; +import { createFn, type MongoFn } from '../src/expression-helpers'; +import type { DocField, TypedAggExpr } from '../src/types'; +import type { TestCodecTypes, TestOperationCodecs } from './fixtures/test-contract'; +import { testOperationCodecs } from './fixtures/test-contract'; + +const fn: MongoFn = createFn< + TestOperationCodecs, + TestCodecTypes +>(testOperationCodecs); const d: TypedAggExpr = { _field: { codecId: 'mongo/string@1', nullable: false }, node: MongoAggLiteral.of('x'), }; -const s: TypedAggExpr = { - _field: { codecId: 'mongo/string@1', nullable: false } as StringField, +const s: TypedAggExpr<{ readonly codecId: 'mongo/string@1'; readonly nullable: false }> = { + _field: { codecId: 'mongo/string@1', nullable: false }, node: MongoAggLiteral.of('x'), }; -const n: TypedAggExpr = { - _field: { codecId: 'mongo/double@1', nullable: false } as NumericField, +const n: TypedAggExpr<{ readonly codecId: 'mongo/double@1'; readonly nullable: false }> = { + _field: { codecId: 'mongo/double@1', nullable: false }, node: MongoAggLiteral.of(1), }; -const dt: TypedAggExpr = { - _field: { codecId: 'mongo/date@1', nullable: false } as DateField, +const dt: TypedAggExpr<{ readonly codecId: 'mongo/date@1'; readonly nullable: false }> = { + _field: { codecId: 'mongo/date@1', nullable: false }, node: MongoAggLiteral.of('2024-01-01'), }; -const arr: TypedAggExpr = { - _field: { codecId: 'mongo/array@1', nullable: false } as ArrayField, - node: MongoAggLiteral.of([]), -}; +const arr = fn.setUnion(d, d); + +describe('table-sourced codec stamps', () => { + it('role-fixed helpers stamp the codec id from the table, not a hardcode', () => { + expect(fn.concat(s, s)._field).toEqual({ + codecId: testOperationCodecs.$concat, + nullable: false, + }); + expect(fn.toDate(s)._field).toEqual({ codecId: testOperationCodecs.$toDate, nullable: false }); + expect(fn.eq(d, d)._field).toEqual({ codecId: testOperationCodecs.$eq, nullable: false }); + expect(fn.year(dt)._field).toEqual({ codecId: testOperationCodecs.$year, nullable: false }); + expect(fn.toObjectId(s)._field).toEqual({ + codecId: testOperationCodecs.$toObjectId, + nullable: false, + }); + }); + + it('two different tables produce different stamps for the same helper', () => { + const altOps = { $concat: 'alt/text@9' } as const; + const altFn = createFn(altOps); + expect(altFn.concat(d)._field.codecId).toBe('alt/text@9'); + expect(fn.concat(d)._field.codecId).toBe('mongo/string@1'); + }); + + it('array/document helpers stamp the unresolved structural marker', () => { + expect(fn.split(s, s)._field).toEqual({ codecId: '', nullable: false, unresolved: true }); + expect(fn.arrayToObject(d)._field).toEqual({ codecId: '', nullable: false, unresolved: true }); + expect(fn.arrayElemAt(arr, n)._field).toEqual({ + codecId: '', + nullable: true, + unresolved: true, + }); + expect(fn.firstElem(arr)._field).toEqual({ codecId: '', nullable: true, unresolved: true }); + expect(fn.lastElem(arr)._field).toEqual({ codecId: '', nullable: true, unresolved: true }); + }); +}); describe('expression helpers — unary', () => { it.each([ diff --git a/packages/2-mongo-family/5-query-builders/query-builder/test/field-accessor.test-d.ts b/packages/2-mongo-family/5-query-builders/query-builder/test/field-accessor.test-d.ts index c53bd50389..c1ed7332b7 100644 --- a/packages/2-mongo-family/5-query-builders/query-builder/test/field-accessor.test-d.ts +++ b/packages/2-mongo-family/5-query-builders/query-builder/test/field-accessor.test-d.ts @@ -10,8 +10,9 @@ import { mongoQuery } from '../src/query'; import type { ModelNestedShape, NestedDocShape, ObjectField } from '../src/resolve-path'; import type { DocField, ModelToDocShape } from '../src/types'; import type { TContract, TestContract } from './fixtures/test-contract'; +import { testOperationCodecs } from './fixtures/test-contract'; -const contractJson = {} as unknown; +const contractJson = {} as TContract; type CustomerShape = ModelToDocShape; type CustomerNested = ModelNestedShape; @@ -212,20 +213,20 @@ describe('FieldAccessor.rawPath escape hatch', () => { describe('Pipeline integration — N threading', () => { it('CollectionHandle.match callback allows callable dot-path access', () => { - const p = mongoQuery({ contractJson }); + const p = mongoQuery({ contractJson, operationCodecs: testOperationCodecs }); p.from('customers').match((f) => f('address.city').eq('London')); p.from('customers').match((f) => f('address.geo.lat').gt(0)); }); it('FilteredCollection.updateMany callback allows callable dot-path access', () => { - const p = mongoQuery({ contractJson }); + const p = mongoQuery({ contractJson, operationCodecs: testOperationCodecs }); p.from('customers') .match((f) => f('address.city').eq('London')) .updateMany((f) => [f('address.zip').set('SW1'), f('stats.visits').inc(1)]); }); it('rejects bogus paths in match callback', () => { - const p = mongoQuery({ contractJson }); + const p = mongoQuery({ contractJson, operationCodecs: testOperationCodecs }); p.from('customers').match((f) => // @ts-expect-error -- 'address.bogus' is not a valid path f('address.bogus').eq('x'), @@ -233,7 +234,7 @@ describe('Pipeline integration — N threading', () => { }); it('additive stages (sort/addFields/redact) preserve callable dot-paths', () => { - const p = mongoQuery({ contractJson }); + const p = mongoQuery({ contractJson, operationCodecs: testOperationCodecs }); p.from('customers') .sort({ name: 1 }) .match((f) => f('address.city').eq('London')); @@ -244,7 +245,7 @@ describe('Pipeline integration — N threading', () => { }); it('replacement stages (group/project/replaceRoot) disable callable dot-paths', () => { - const p = mongoQuery({ contractJson }); + const p = mongoQuery({ contractJson, operationCodecs: testOperationCodecs }); const grouped = p.from('customers').group((f) => ({ _id: f.name, count: f._id })); // After group, N has been reset. The flat Shape no longer contains // value objects, and the callable form rejects any string. diff --git a/packages/2-mongo-family/5-query-builders/query-builder/test/find-and-modify.test.ts b/packages/2-mongo-family/5-query-builders/query-builder/test/find-and-modify.test.ts index 54fdc909cf..8dd663b5be 100644 --- a/packages/2-mongo-family/5-query-builders/query-builder/test/find-and-modify.test.ts +++ b/packages/2-mongo-family/5-query-builders/query-builder/test/find-and-modify.test.ts @@ -8,9 +8,10 @@ import type { PipelineChain } from '../src/builder'; import { mongoQuery } from '../src/query'; import type { ModelToDocShape } from '../src/types'; import type { TContract } from './fixtures/test-contract'; -import { testContractJson } from './fixtures/test-contract'; +import { testContract, testOperationCodecs } from './fixtures/test-contract'; -const orders = () => mongoQuery({ contractJson: testContractJson }).from('orders'); +const orders = () => + mongoQuery({ contractJson: testContract, operationCodecs: testOperationCodecs }).from('orders'); /** * After A25 (`CollectionHandle`/`FilteredCollection` start with diff --git a/packages/2-mongo-family/5-query-builders/query-builder/test/fixtures/test-contract.ts b/packages/2-mongo-family/5-query-builders/query-builder/test/fixtures/test-contract.ts index c1f9ad9159..5f34cd42f5 100644 --- a/packages/2-mongo-family/5-query-builders/query-builder/test/fixtures/test-contract.ts +++ b/packages/2-mongo-family/5-query-builders/query-builder/test/fixtures/test-contract.ts @@ -9,6 +9,7 @@ import type { MongoContractWithTypeMaps, MongoTypeMaps, } from '@prisma-next/mongo-contract'; +import { blindCast } from '@prisma-next/utils/casts'; type TestModels = { readonly Order: { @@ -38,6 +39,10 @@ type TestModels = { readonly nullable: false; readonly many: true; }; + readonly createdAt: { + readonly type: { readonly kind: 'scalar'; readonly codecId: 'mongo/date@1' }; + readonly nullable: false; + }; }; readonly relations: Record; readonly storage: { readonly collection: 'orders' }; @@ -203,6 +208,7 @@ type TestFieldOutputTypes = { readonly customerId: string; readonly notes: string | null; readonly tags: string[]; + readonly createdAt: Date; }; readonly User: { readonly _id: string; @@ -262,6 +268,7 @@ export const testContractJson = { nullable: false, many: true, }, + createdAt: { type: { kind: 'scalar', codecId: 'mongo/date@1' }, nullable: false }, }, relations: {}, storage: { collection: 'orders' }, @@ -332,3 +339,72 @@ export const testContractJson = { profileHash: 'test-profile', meta: {}, }; + +export const testContract = blindCast< + TContract, + 'query-builder fixture JSON carries domain.namespaces envelope' +>(testContractJson); + +/** + * Test-local operation→output-codec table mirroring the Mongo adapter's + * declaration. Tests may name `mongo/*` codec ids; the family source may not. + */ +export const testOperationCodecs = { + $concat: 'mongo/string@1', + $toLower: 'mongo/string@1', + $toUpper: 'mongo/string@1', + $toString: 'mongo/string@1', + $substr: 'mongo/string@1', + $substrBytes: 'mongo/string@1', + $trim: 'mongo/string@1', + $ltrim: 'mongo/string@1', + $rtrim: 'mongo/string@1', + $replaceOne: 'mongo/string@1', + $replaceAll: 'mongo/string@1', + $dateToString: 'mongo/string@1', + $type: 'mongo/string@1', + $eq: 'mongo/bool@1', + $ne: 'mongo/bool@1', + $gt: 'mongo/bool@1', + $gte: 'mongo/bool@1', + $lt: 'mongo/bool@1', + $lte: 'mongo/bool@1', + $in: 'mongo/bool@1', + $regexMatch: 'mongo/bool@1', + $isArray: 'mongo/bool@1', + $toBool: 'mongo/bool@1', + $setEquals: 'mongo/bool@1', + $setIsSubset: 'mongo/bool@1', + $anyElementTrue: 'mongo/bool@1', + $allElementsTrue: 'mongo/bool@1', + $toDate: 'mongo/date@1', + $dateAdd: 'mongo/date@1', + $dateSubtract: 'mongo/date@1', + $dateTrunc: 'mongo/date@1', + $dateFromString: 'mongo/date@1', + $add: 'mongo/double@1', + $subtract: 'mongo/double@1', + $multiply: 'mongo/double@1', + $divide: 'mongo/double@1', + $size: 'mongo/double@1', + $year: 'mongo/double@1', + $month: 'mongo/double@1', + $dayOfMonth: 'mongo/double@1', + $hour: 'mongo/double@1', + $minute: 'mongo/double@1', + $second: 'mongo/double@1', + $millisecond: 'mongo/double@1', + $dateDiff: 'mongo/double@1', + $strLenCP: 'mongo/double@1', + $strLenBytes: 'mongo/double@1', + $cmp: 'mongo/double@1', + $indexOfArray: 'mongo/double@1', + $toInt: 'mongo/double@1', + $toLong: 'mongo/double@1', + $toDouble: 'mongo/double@1', + $toDecimal: 'mongo/double@1', + $count: 'mongo/double@1', + $toObjectId: 'mongo/objectId@1', +} as const; + +export type TestOperationCodecs = typeof testOperationCodecs; diff --git a/packages/2-mongo-family/5-query-builders/query-builder/test/pipeline-result-shape.test.ts b/packages/2-mongo-family/5-query-builders/query-builder/test/pipeline-result-shape.test.ts index 873c7b7031..0f5836ee11 100644 --- a/packages/2-mongo-family/5-query-builders/query-builder/test/pipeline-result-shape.test.ts +++ b/packages/2-mongo-family/5-query-builders/query-builder/test/pipeline-result-shape.test.ts @@ -1,6 +1,7 @@ import { MongoAddFieldsStage, MongoAggFieldRef, + MongoAggLiteral, MongoAggOperator, MongoGroupStage, MongoProjectStage, @@ -12,7 +13,7 @@ import { describe, expect, it } from 'vitest'; import { computePipelineResultShape } from '../src/pipeline-result-shape'; import { contractModelToMongoResultShape } from '../src/result-shape'; import type { TContract } from './fixtures/test-contract'; -import { testContractJson } from './fixtures/test-contract'; +import { testContractJson, testOperationCodecs } from './fixtures/test-contract'; const contract = blindCast< TContract, @@ -21,6 +22,7 @@ const contract = blindCast< const orderModel = contract.domain.namespaces.__unbound__!.models['Order']; const orderShape = contractModelToMongoResultShape(orderModel); +const ops = testOperationCodecs; describe('computePipelineResultShape', () => { it('vectorSearch stage carries the input shape through unchanged', () => { @@ -32,7 +34,7 @@ describe('computePipelineResultShape', () => { limit: 1, }); - const shape = computePipelineResultShape([stage], orderShape); + const shape = computePipelineResultShape([stage], orderShape, ops); expect(shape).toEqual(orderShape); if (shape.kind !== 'document') throw new Error('expected document'); @@ -46,7 +48,7 @@ describe('computePipelineResultShape', () => { it('project stage implicitly keeps _id and keeps a listed scalar field', () => { const stage = new MongoProjectStage({ status: 1 }); - const shape = computePipelineResultShape([stage], orderShape); + const shape = computePipelineResultShape([stage], orderShape, ops); if (shape.kind !== 'document') throw new Error('expected document'); expect(shape.fields['_id']).toEqual({ @@ -68,7 +70,7 @@ describe('computePipelineResultShape', () => { renamedId: MongoAggFieldRef.of('_id'), }); - const shape = computePipelineResultShape([stage], orderShape); + const shape = computePipelineResultShape([stage], orderShape, ops); if (shape.kind !== 'document') throw new Error('expected document'); expect(shape.fields['label']).toEqual({ @@ -84,16 +86,26 @@ describe('computePipelineResultShape', () => { expect(Object.keys(shape.fields).sort()).toEqual(['_id', 'label', 'renamedId']); }); - it('project stage with a computed field yields unknown at that key', () => { + it('project stage resolves a table-covered computed field to a leaf with the table codec', () => { const stage = new MongoProjectStage({ status: 1, shout: MongoAggOperator.toUpper(MongoAggFieldRef.of('status')), + asDate: MongoAggOperator.of('$toDate', MongoAggFieldRef.of('status')), }); - const shape = computePipelineResultShape([stage], orderShape); + const shape = computePipelineResultShape([stage], orderShape, ops); if (shape.kind !== 'document') throw new Error('expected document'); - expect(shape.fields['shout']).toEqual({ kind: 'unknown' }); + expect(shape.fields['shout']).toEqual({ + kind: 'leaf', + codecId: ops.$toUpper, + nullable: false, + }); + expect(shape.fields['asDate']).toEqual({ + kind: 'leaf', + codecId: ops.$toDate, + nullable: false, + }); expect(shape.fields['status']).toEqual({ kind: 'leaf', codecId: 'mongo/string@1', @@ -101,13 +113,27 @@ describe('computePipelineResultShape', () => { }); }); - it('addFields stage copies a fieldRef source shape and marks computed fields unknown', () => { + it('project stage with an operator outside the table yields unknown at that key', () => { + const stage = new MongoProjectStage({ + parts: MongoAggOperator.of('$split', [ + MongoAggFieldRef.of('status'), + MongoAggLiteral.of('-'), + ]), + }); + + const shape = computePipelineResultShape([stage], orderShape, ops); + + if (shape.kind !== 'document') throw new Error('expected document'); + expect(shape.fields['parts']).toEqual({ kind: 'unknown' }); + }); + + it('addFields stage copies a fieldRef source shape and resolves table-covered computed fields', () => { const stage = new MongoAddFieldsStage({ statusCopy: MongoAggFieldRef.of('status'), shout: MongoAggOperator.toUpper(MongoAggFieldRef.of('status')), }); - const shape = computePipelineResultShape([stage], orderShape); + const shape = computePipelineResultShape([stage], orderShape, ops); if (shape.kind !== 'document') throw new Error('expected document'); expect(shape.fields['statusCopy']).toEqual({ @@ -115,7 +141,11 @@ describe('computePipelineResultShape', () => { codecId: 'mongo/string@1', nullable: false, }); - expect(shape.fields['shout']).toEqual({ kind: 'unknown' }); + expect(shape.fields['shout']).toEqual({ + kind: 'leaf', + codecId: ops.$toUpper, + nullable: false, + }); // original fields remain expect(shape.fields['_id']).toEqual({ kind: 'leaf', @@ -132,7 +162,7 @@ describe('computePipelineResultShape', () => { it('unhandled stage kind collapses the whole shape to unknown', () => { const stage = new MongoGroupStage(MongoAggFieldRef.of('status'), {}); - const shape = computePipelineResultShape([stage], orderShape); + const shape = computePipelineResultShape([stage], orderShape, ops); expect(shape).toEqual({ kind: 'unknown' }); }); @@ -140,7 +170,7 @@ describe('computePipelineResultShape', () => { it('unwind stage (also unhandled in this slice) collapses the whole shape to unknown', () => { const stage = new MongoUnwindStage('$tags', false); - const shape = computePipelineResultShape([stage], orderShape); + const shape = computePipelineResultShape([stage], orderShape, ops); expect(shape).toEqual({ kind: 'unknown' }); }); diff --git a/packages/2-mongo-family/5-query-builders/query-builder/test/pipeline-updates.test.ts b/packages/2-mongo-family/5-query-builders/query-builder/test/pipeline-updates.test.ts index e36ff7df85..3e0fd1524c 100644 --- a/packages/2-mongo-family/5-query-builders/query-builder/test/pipeline-updates.test.ts +++ b/packages/2-mongo-family/5-query-builders/query-builder/test/pipeline-updates.test.ts @@ -11,9 +11,10 @@ import { mongoQuery } from '../src/query'; import type { ModelToDocShape } from '../src/types'; import type { UpdaterResult } from '../src/update-ops'; import type { TContract } from './fixtures/test-contract'; -import { testContractJson } from './fixtures/test-contract'; +import { testContract, testOperationCodecs } from './fixtures/test-contract'; -const orders = () => mongoQuery({ contractJson: testContractJson }).from('orders'); +const orders = () => + mongoQuery({ contractJson: testContract, operationCodecs: testOperationCodecs }).from('orders'); /** * After A25 (`CollectionHandle`/`FilteredCollection` start with diff --git a/packages/2-mongo-family/5-query-builders/query-builder/test/raw-command.test.ts b/packages/2-mongo-family/5-query-builders/query-builder/test/raw-command.test.ts index 4011e635f3..9d081cb7b6 100644 --- a/packages/2-mongo-family/5-query-builders/query-builder/test/raw-command.test.ts +++ b/packages/2-mongo-family/5-query-builders/query-builder/test/raw-command.test.ts @@ -2,9 +2,9 @@ import { InsertOneCommand, RawAggregateCommand } from '@prisma-next/mongo-query- import { describe, expect, it } from 'vitest'; import { mongoQuery } from '../src/query'; import type { TContract } from './fixtures/test-contract'; -import { testContractJson } from './fixtures/test-contract'; +import { testContract, testOperationCodecs } from './fixtures/test-contract'; -const root = () => mongoQuery({ contractJson: testContractJson }); +const root = () => mongoQuery({ contractJson: testContract, operationCodecs: testOperationCodecs }); describe('M5 raw escape hatch', () => { it('packages a typed CRUD command into a plan with lane: mongo-query', () => { @@ -13,7 +13,7 @@ describe('M5 raw escape hatch', () => { expect(plan.command).toBe(cmd); expect(plan.collection).toBe('orders'); expect(plan.meta.lane).toBe('mongo-query'); - expect(plan.meta.storageHash).toBe(testContractJson.storage.storageHash); + expect(plan.meta.storageHash).toBe(testContract.storage.storageHash); }); it('packages a RawMongoCommand (raw aggregate) without translating it', () => { @@ -26,7 +26,10 @@ describe('M5 raw escape hatch', () => { it('throws when the contract is missing a storageHash (signals an unvalidated contract)', () => { const cmd = new InsertOneCommand('orders', { status: 'new' }); expect(() => - mongoQuery({ contractJson: { ...testContractJson, storage: {} } }).rawCommand(cmd), + mongoQuery({ + contractJson: { ...testContract, storage: {} } as TContract, + operationCodecs: testOperationCodecs, + }).rawCommand(cmd), ).toThrow(/storageHash/); }); }); diff --git a/packages/2-mongo-family/5-query-builders/query-builder/test/result-shape.test.ts b/packages/2-mongo-family/5-query-builders/query-builder/test/result-shape.test.ts index e693282da7..03150fdda8 100644 --- a/packages/2-mongo-family/5-query-builders/query-builder/test/result-shape.test.ts +++ b/packages/2-mongo-family/5-query-builders/query-builder/test/result-shape.test.ts @@ -7,7 +7,7 @@ import { contractModelToMongoResultShape, } from '../src/result-shape'; import type { TContract } from './fixtures/test-contract'; -import { testContractJson } from './fixtures/test-contract'; +import { testContract, testContractJson, testOperationCodecs } from './fixtures/test-contract'; describe('contractModelToMongoResultShape', () => { // Hand-authored fixture JSON; cast at the test-fixture seam (allowed by @@ -65,12 +65,14 @@ describe('contractFieldToMongoFieldShape', () => { describe('PipelineChain build resultShape', () => { it('identity pipeline attaches document shape from bound model', () => { - const plan = mongoQuery({ contractJson: testContractJson }).from('orders').build(); + const plan = mongoQuery({ contractJson: testContract, operationCodecs: testOperationCodecs }) + .from('orders') + .build(); expect(plan.resultShape?.kind).toBe('document'); }); it('project stage reifies a document shape retaining _id and the projected field', () => { - const plan = mongoQuery({ contractJson: testContractJson }) + const plan = mongoQuery({ contractJson: testContract, operationCodecs: testOperationCodecs }) .from('orders') .match(MongoFieldFilter.eq('status', 'x')) .project('status') @@ -84,4 +86,44 @@ describe('PipelineChain build resultShape', () => { }, }); }); + + it('computed table-covered scalar reifies to a leaf with the table codec', () => { + const plan = mongoQuery({ contractJson: testContract, operationCodecs: testOperationCodecs }) + .from('orders') + .project((f, fn) => ({ + asDate: fn.toDate(f.status), + rendered: fn.dateToString({ date: f.createdAt }), + })) + .build(); + expect(plan.resultShape).toEqual({ + kind: 'document', + fields: { + _id: { kind: 'leaf', codecId: 'mongo/objectId@1', nullable: false }, + asDate: { kind: 'leaf', codecId: testOperationCodecs.$toDate, nullable: false }, + rendered: { kind: 'leaf', codecId: testOperationCodecs.$dateToString, nullable: false }, + }, + }); + }); + + it('computed operator outside the table reifies to unknown', () => { + const plan = mongoQuery({ contractJson: testContract, operationCodecs: testOperationCodecs }) + .from('orders') + .project((f, fn) => ({ + parts: fn.split(f.status, fn.literal('-')), + })) + .build(); + expect(plan.resultShape).toEqual({ + kind: 'document', + fields: { + _id: { kind: 'leaf', codecId: 'mongo/objectId@1', nullable: false }, + parts: { kind: 'unknown' }, + }, + }); + }); + + it('standalone fn from the query root builds the same expressions as callback fn', () => { + const root = mongoQuery({ contractJson: testContract, operationCodecs: testOperationCodecs }); + const viaRoot = root.fn.toDate(root.fn.literal('2024-01-01')); + expect(viaRoot._field).toEqual({ codecId: testOperationCodecs.$toDate, nullable: false }); + }); }); diff --git a/packages/2-mongo-family/5-query-builders/query-builder/test/state-machine-surface.test-d.ts b/packages/2-mongo-family/5-query-builders/query-builder/test/state-machine-surface.test-d.ts index 3f9c32200d..0033127d85 100644 --- a/packages/2-mongo-family/5-query-builders/query-builder/test/state-machine-surface.test-d.ts +++ b/packages/2-mongo-family/5-query-builders/query-builder/test/state-machine-surface.test-d.ts @@ -2,9 +2,11 @@ import { MongoFieldFilter } from '@prisma-next/mongo-query-ast/execution'; import { describe, it } from 'vitest'; import { mongoQuery } from '../src/query'; import type { TContract } from './fixtures/test-contract'; +import { testOperationCodecs } from './fixtures/test-contract'; -const contractJson = {} as unknown; -const handle = () => mongoQuery({ contractJson }).from('orders'); +const contractJson = {} as TContract; +const handle = () => + mongoQuery({ contractJson, operationCodecs: testOperationCodecs }).from('orders'); const filtered = () => handle().match(MongoFieldFilter.eq('status', 'new')); /** diff --git a/packages/2-mongo-family/5-query-builders/query-builder/test/state-machine.test-d.ts b/packages/2-mongo-family/5-query-builders/query-builder/test/state-machine.test-d.ts index 00440a1b00..4c371136a9 100644 --- a/packages/2-mongo-family/5-query-builders/query-builder/test/state-machine.test-d.ts +++ b/packages/2-mongo-family/5-query-builders/query-builder/test/state-machine.test-d.ts @@ -4,9 +4,10 @@ import type { PipelineChain } from '../src/builder'; import type { FindAndModifyEnabled, UpdateEnabled } from '../src/markers'; import { mongoQuery } from '../src/query'; import type { CollectionHandle, FilteredCollection } from '../src/state-classes'; -import type { TContract } from './fixtures/test-contract'; +import type { TContract, TestOperationCodecs } from './fixtures/test-contract'; +import { testOperationCodecs } from './fixtures/test-contract'; -const contractJson = {} as unknown; +const contractJson = {} as TContract; /** * Extract the `UpdateEnabled` marker from any `PipelineChain` (or subclass). @@ -14,42 +15,64 @@ const contractJson = {} as unknown; * without having to reconstruct the full `Shape` parameter at the call site. */ type GetU = - T extends PipelineChain + T extends PipelineChain< + infer _TContract, + infer _Shape, + infer U, + infer _F, + infer _L, + infer _N, + infer _TOps + > ? U : never; type GetF = - T extends PipelineChain + T extends PipelineChain< + infer _TContract, + infer _Shape, + infer _U, + infer F, + infer _L, + infer _N, + infer _TOps + > ? F : never; describe('state machine', () => { it('from(name) returns CollectionHandle (root state) inheriting PipelineChain', () => { - const handle = mongoQuery({ contractJson }).from('orders'); - expectTypeOf(handle).toExtend>(); + const handle = mongoQuery({ contractJson, operationCodecs: testOperationCodecs }).from( + 'orders', + ); + expectTypeOf(handle).toExtend>(); }); it('CollectionHandle.match(...) transitions to FilteredCollection', () => { - const filtered = mongoQuery({ contractJson }) + const filtered = mongoQuery({ contractJson, operationCodecs: testOperationCodecs }) .from('orders') .match(MongoFieldFilter.eq('status', 'active')); - expectTypeOf(filtered).toExtend>(); + expectTypeOf(filtered).toExtend>(); }); it('FilteredCollection.match(...) stays in FilteredCollection (AND-folds)', () => { - const filtered = mongoQuery({ contractJson }) + const filtered = mongoQuery({ contractJson, operationCodecs: testOperationCodecs }) .from('orders') .match(MongoFieldFilter.eq('status', 'active')) .match(MongoFieldFilter.gt('amount', 100)); - expectTypeOf(filtered).toExtend>(); + expectTypeOf(filtered).toExtend>(); }); it('pipeline-stage methods drop out of the state-class subclasses', () => { - const sorted = mongoQuery({ contractJson }).from('orders').sort({ amount: -1 }); + const sorted = mongoQuery({ contractJson, operationCodecs: testOperationCodecs }) + .from('orders') + .sort({ amount: -1 }); // No longer a CollectionHandle/FilteredCollection — write/find-and-modify // surfaces have been left behind. expectTypeOf(sorted).not.toExtend>(); - expectTypeOf(sorted).not.toExtend>(); + expectTypeOf(sorted).not.toExtend< + FilteredCollection + >(); }); it('from(name) starts with both markers cleared', () => { @@ -58,7 +81,9 @@ describe('state machine', () => { // find-and-modify terminals inherited from `PipelineChain` are gated off // by default, so a leading `.match(...)` is required before reaching them // via the `FilteredCollection` overrides (see ADR 201). - const handle = mongoQuery({ contractJson }).from('orders'); + const handle = mongoQuery({ contractJson, operationCodecs: testOperationCodecs }).from( + 'orders', + ); expectTypeOf>().toEqualTypeOf<'update-cleared' & UpdateEnabled>(); expectTypeOf>().toEqualTypeOf<'fam-cleared' & FindAndModifyEnabled>(); }); @@ -70,7 +95,7 @@ describe('state machine', () => { // marker-gated PipelineChain versions), so the initial marker state // stays cleared to prevent accidental access to the PipelineChain // inheritance path. - const filtered = mongoQuery({ contractJson }) + const filtered = mongoQuery({ contractJson, operationCodecs: testOperationCodecs }) .from('orders') .match(MongoFieldFilter.eq('status', 'active')); expectTypeOf>().toEqualTypeOf<'update-cleared' & UpdateEnabled>(); @@ -78,23 +103,27 @@ describe('state machine', () => { }); it('marker table: limit() leaves both markers cleared', () => { - const limited = mongoQuery({ contractJson }).from('orders').limit(1); + const limited = mongoQuery({ contractJson, operationCodecs: testOperationCodecs }) + .from('orders') + .limit(1); expectTypeOf>().toEqualTypeOf<'update-cleared' & UpdateEnabled>(); expectTypeOf>().toEqualTypeOf<'fam-cleared' & FindAndModifyEnabled>(); }); it('marker table: sort / addFields / group all leave both markers cleared from .from()', () => { - const sorted = mongoQuery({ contractJson }).from('orders').sort({ amount: -1 }); + const sorted = mongoQuery({ contractJson, operationCodecs: testOperationCodecs }) + .from('orders') + .sort({ amount: -1 }); expectTypeOf>().toEqualTypeOf<'update-cleared' & UpdateEnabled>(); expectTypeOf>().toEqualTypeOf<'fam-cleared' & FindAndModifyEnabled>(); - const added = mongoQuery({ contractJson }) + const added = mongoQuery({ contractJson, operationCodecs: testOperationCodecs }) .from('orders') .addFields((f) => ({ doubled: f.amount })); expectTypeOf>().toEqualTypeOf<'update-cleared' & UpdateEnabled>(); expectTypeOf>().toEqualTypeOf<'fam-cleared' & FindAndModifyEnabled>(); - const grouped = mongoQuery({ contractJson }) + const grouped = mongoQuery({ contractJson, operationCodecs: testOperationCodecs }) .from('orders') .group((_f) => ({ _id: null })); expectTypeOf>().toEqualTypeOf<'update-cleared' & UpdateEnabled>(); @@ -106,7 +135,7 @@ describe('state machine', () => { // parameters here; the runtime / shape behaviour is asserted in // builder.test-d.ts. it('marker table: lookup() clears both markers', () => { - const looked = mongoQuery({ contractJson }) + const looked = mongoQuery({ contractJson, operationCodecs: testOperationCodecs }) .from('orders') .lookup((from) => from('users') diff --git a/packages/2-mongo-family/5-query-builders/query-builder/test/types.test-d.ts b/packages/2-mongo-family/5-query-builders/query-builder/test/types.test-d.ts index eea3338e38..8b98da987f 100644 --- a/packages/2-mongo-family/5-query-builders/query-builder/test/types.test-d.ts +++ b/packages/2-mongo-family/5-query-builders/query-builder/test/types.test-d.ts @@ -2,8 +2,6 @@ import type { MongoContract } from '@prisma-next/mongo-contract'; import { expectTypeOf } from 'vitest'; import type { ModelArrayField } from '../src/resolve-path'; import type { - BooleanField, - DateField, DocField, DocShape, ExtractDocShape, @@ -11,6 +9,10 @@ import type { ResolveRow, TypedAggExpr, } from '../src/types'; + +type BooleanField = { readonly codecId: 'mongo/bool@1'; readonly nullable: false }; +type DateField = { readonly codecId: 'mongo/date@1'; readonly nullable: false }; + import type { TestCodecTypes, TestContract } from './fixtures/test-contract'; describe('type machinery', () => { diff --git a/packages/2-mongo-family/5-query-builders/query-builder/test/writes.test.ts b/packages/2-mongo-family/5-query-builders/query-builder/test/writes.test.ts index de013dc798..9c4f958995 100644 --- a/packages/2-mongo-family/5-query-builders/query-builder/test/writes.test.ts +++ b/packages/2-mongo-family/5-query-builders/query-builder/test/writes.test.ts @@ -10,10 +10,10 @@ import { } from '@prisma-next/mongo-query-ast/execution'; import { describe, expect, it } from 'vitest'; import { mongoQuery } from '../src/query'; -import type { TContract } from './fixtures/test-contract'; -import { testContractJson } from './fixtures/test-contract'; +import { testContract, testOperationCodecs } from './fixtures/test-contract'; -const orders = () => mongoQuery({ contractJson: testContractJson }).from('orders'); +const orders = () => + mongoQuery({ contractJson: testContract, operationCodecs: testOperationCodecs }).from('orders'); describe('M2 write terminals', () => { describe('inserts (CollectionHandle)', () => { diff --git a/packages/2-mongo-family/7-runtime/src/mongo-execution-stack.ts b/packages/2-mongo-family/7-runtime/src/mongo-execution-stack.ts index f19a146fc3..ae853bcfae 100644 --- a/packages/2-mongo-family/7-runtime/src/mongo-execution-stack.ts +++ b/packages/2-mongo-family/7-runtime/src/mongo-execution-stack.ts @@ -44,7 +44,15 @@ export interface MongoRuntimeAdapterDescriptor< TTargetId > = MongoRuntimeAdapterInstance, > extends RuntimeAdapterDescriptor<'mongo', TTargetId, TAdapterInstance>, - MongoStaticContributions {} + MongoStaticContributions { + /** + * Operation→output-codec table for aggregation operators with fixed, + * input-independent output types. Adapter-owned knowledge, threaded to + * the query builder via the execution context so computed expressions + * are stamped with real codec ids. + */ + readonly operationOutputCodecs: Readonly>; +} export interface MongoRuntimeExtensionInstance extends RuntimeExtensionInstance<'mongo', TTargetId> {} @@ -114,6 +122,7 @@ export interface MongoCodecLookup { export interface MongoExecutionContext { readonly contract: TContract; readonly codecs: MongoCodecLookup; + readonly operationOutputCodecs: Readonly>; readonly stack: MongoExecutionStack; } @@ -152,6 +161,7 @@ export function createMongoExecutionContext< return Object.freeze({ contract: options.contract, codecs: registry, + operationOutputCodecs: options.stack.adapter.operationOutputCodecs, stack: options.stack, }); } diff --git a/packages/2-mongo-family/7-runtime/test/decode-via-query-builder.test.ts b/packages/2-mongo-family/7-runtime/test/decode-via-query-builder.test.ts index a23373c240..5c26f6c1ef 100644 --- a/packages/2-mongo-family/7-runtime/test/decode-via-query-builder.test.ts +++ b/packages/2-mongo-family/7-runtime/test/decode-via-query-builder.test.ts @@ -11,6 +11,7 @@ * `expectTypeOf`) and runtime values. */ +import { mongoOperationOutputCodecs } from '@prisma-next/adapter-mongo/runtime'; import { MongoContractSerializer } from '@prisma-next/family-mongo/ir'; import { acc, mongoQuery } from '@prisma-next/mongo-query-builder'; import { MongoParamRef } from '@prisma-next/mongo-value'; @@ -22,7 +23,10 @@ import { } from './fixtures/decode-fixture-contract'; import { withMongod } from './setup'; -const q = mongoQuery({ contractJson: decodeFixtureContractJson }); +const q = mongoQuery({ + contractJson: decodeFixtureContractJson as TDecodeFixtureContract, + operationCodecs: mongoOperationOutputCodecs, +}); describe('Mongo runtime decode integration via query-builder', () => { it('typed read: contract → query-builder → runtime decode end-to-end', async () => { @@ -39,7 +43,10 @@ describe('Mongo runtime decode integration via query-builder', () => { embeddings: vec, }); - const plan = mongoQuery({ contractJson: contract }) + const plan = mongoQuery({ + contractJson: contract, + operationCodecs: mongoOperationOutputCodecs, + }) .from('users') .match((f) => f['_id']!.eq(MongoParamRef.of(insert.insertedId, { codecId: 'mongo/objectId@1' })), diff --git a/packages/2-mongo-family/7-runtime/test/decode.integration.test.ts b/packages/2-mongo-family/7-runtime/test/decode.integration.test.ts index 264e25fe00..b93b59d03b 100644 --- a/packages/2-mongo-family/7-runtime/test/decode.integration.test.ts +++ b/packages/2-mongo-family/7-runtime/test/decode.integration.test.ts @@ -1,3 +1,4 @@ +import { mongoOperationOutputCodecs } from '@prisma-next/adapter-mongo/runtime'; import { MongoContractSerializer } from '@prisma-next/family-mongo/ir'; import { isRuntimeError } from '@prisma-next/framework-components/runtime'; import { mongoCodec } from '@prisma-next/mongo-codec'; @@ -33,7 +34,10 @@ describe('Mongo runtime decode integration', () => { }); // User-facing path: build the plan through the query-builder so the Row type is contract-derived (no explicit annotation on execute). - const plan = mongoQuery({ contractJson: contract }) + const plan = mongoQuery({ + contractJson: contract, + operationCodecs: mongoOperationOutputCodecs, + }) .from('users') .match((f) => f['_id']!.eq(MongoParamRef.of(insert.insertedId, { codecId: 'mongo/objectId@1' })), diff --git a/packages/2-mongo-family/7-runtime/test/mongo-middleware.test.ts b/packages/2-mongo-family/7-runtime/test/mongo-middleware.test.ts index 67eca55e56..2d9cf4ea03 100644 --- a/packages/2-mongo-family/7-runtime/test/mongo-middleware.test.ts +++ b/packages/2-mongo-family/7-runtime/test/mongo-middleware.test.ts @@ -47,6 +47,7 @@ function makeContext(adapter: MongoAdapter): MongoExecutionContext { targetId: 'mongo', version: '0.0.1', codecs: () => newMongoCodecRegistry(), + operationOutputCodecs: {}, create: () => adapterInstance, }; const stack: MongoExecutionStack<'mongo'> = { @@ -55,7 +56,7 @@ function makeContext(adapter: MongoAdapter): MongoExecutionContext { driver: undefined, extensionPacks: [], }; - return Object.freeze({ contract: {}, codecs, stack }); + return Object.freeze({ contract: {}, codecs, operationOutputCodecs: {}, stack }); } const baseMeta: PlanMeta = { diff --git a/packages/2-mongo-family/7-runtime/test/mongo-runtime-abort.test.ts b/packages/2-mongo-family/7-runtime/test/mongo-runtime-abort.test.ts index 4c0772c379..45ea574eda 100644 --- a/packages/2-mongo-family/7-runtime/test/mongo-runtime-abort.test.ts +++ b/packages/2-mongo-family/7-runtime/test/mongo-runtime-abort.test.ts @@ -82,6 +82,7 @@ function makeContext(adapter: MongoAdapter): MongoExecutionContext { targetId: 'mongo', version: '0.0.1', codecs: () => newMongoCodecRegistry(), + operationOutputCodecs: {}, create: () => adapterInstance, }; const stack: MongoExecutionStack<'mongo'> = { @@ -90,7 +91,7 @@ function makeContext(adapter: MongoAdapter): MongoExecutionContext { driver: undefined, extensionPacks: [], }; - return Object.freeze({ contract: {}, codecs, stack }); + return Object.freeze({ contract: {}, codecs, operationOutputCodecs: {}, stack }); } function rowsDriver(rows: Record[] = []): MongoDriver { diff --git a/packages/2-mongo-family/7-runtime/test/runtime-types.test-d.ts b/packages/2-mongo-family/7-runtime/test/runtime-types.test-d.ts index cbb1d0000c..147c2c54c8 100644 --- a/packages/2-mongo-family/7-runtime/test/runtime-types.test-d.ts +++ b/packages/2-mongo-family/7-runtime/test/runtime-types.test-d.ts @@ -1,3 +1,4 @@ +import { mongoOperationOutputCodecs } from '@prisma-next/adapter-mongo/runtime'; import type { CrossReference, StorageHashBase } from '@prisma-next/contract/types'; import type { AsyncIterableResult } from '@prisma-next/framework-components/runtime'; import type { @@ -81,8 +82,10 @@ type OrderRow = { readonly _id: string; readonly status: string; readonly amount describe('runtime type safety', () => { it('execute() returns AsyncIterableResult where Row matches build() row type', () => { - const contractJson = {} as unknown; - const plan = mongoQuery({ contractJson }).from('orders').build(); + const contractJson = {} as TContract; + const plan = mongoQuery({ contractJson, operationCodecs: mongoOperationOutputCodecs }) + .from('orders') + .build(); type Row = PlanRow; expectTypeOf().toEqualTypeOf(); @@ -92,8 +95,10 @@ describe('runtime type safety', () => { }); it('execute() result awaits to Row[]', () => { - const contractJson = {} as unknown; - const plan = mongoQuery({ contractJson }).from('orders').build(); + const contractJson = {} as TContract; + const plan = mongoQuery({ contractJson, operationCodecs: mongoOperationOutputCodecs }) + .from('orders') + .build(); type Row = PlanRow; const runtime = {} as MongoRuntime; diff --git a/packages/3-extensions/mongo/src/static/mongo-static.ts b/packages/3-extensions/mongo/src/static/mongo-static.ts index a9723ac1a0..b63711b9a7 100644 --- a/packages/3-extensions/mongo/src/static/mongo-static.ts +++ b/packages/3-extensions/mongo/src/static/mongo-static.ts @@ -1,4 +1,6 @@ -import mongoRuntimeAdapter from '@prisma-next/adapter-mongo/runtime'; +import mongoRuntimeAdapter, { + type MongoOperationOutputCodecs, +} from '@prisma-next/adapter-mongo/runtime'; import { buildNamespacedEnums, type NamespacedEnums } from '@prisma-next/contract/enum-accessor'; import { MongoContractSerializer } from '@prisma-next/family-mongo/ir'; import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir'; @@ -36,7 +38,7 @@ export interface MongoStaticContext< readonly context: MongoExecutionContext; readonly contract: TContract; readonly enums: UnboundEnums; - readonly query: ReturnType>; + readonly query: ReturnType>; readonly raw: MongoRawClient; } @@ -49,7 +51,10 @@ export function buildMongoStaticContext< }); const context = createMongoExecutionContext({ contract, stack }); const enums = extractUnboundEnums(contract); - const query = mongoQuery({ contractJson: contract }); + const query = mongoQuery({ + contractJson: contract, + operationCodecs: mongoRuntimeAdapter.operationOutputCodecs, + }); const raw = mongoRaw({ contract }); return { context, contract, enums, query, raw }; } diff --git a/packages/3-mongo-target/2-mongo-adapter/src/core/mongo-control-adapter.ts b/packages/3-mongo-target/2-mongo-adapter/src/core/mongo-control-adapter.ts index ecb654e280..b934ab7ca5 100644 --- a/packages/3-mongo-target/2-mongo-adapter/src/core/mongo-control-adapter.ts +++ b/packages/3-mongo-target/2-mongo-adapter/src/core/mongo-control-adapter.ts @@ -12,7 +12,7 @@ import { MongoAggOperator, type MongoQueryPlan, } from '@prisma-next/mongo-query-ast/execution'; -import { expr, fn } from '@prisma-next/mongo-query-builder'; +import { createFn, expr } from '@prisma-next/mongo-query-builder'; import { collection } from '@prisma-next/mongo-query-builder/contract-free'; import type { MongoSchemaIR } from '@prisma-next/mongo-schema-ir'; import type { MongoValue } from '@prisma-next/mongo-value'; @@ -27,8 +27,11 @@ import { } from './marker-ledger'; import { MARKER_LEDGER_COLLECTION, type MarkerLedgerDocShape } from './marker-ledger-collection'; import { isMongoControlDriver } from './mongo-control-driver'; +import { mongoOperationOutputCodecs } from './operation-output-codecs'; import { extractDb } from './runner-deps'; +const fn = createFn(mongoOperationOutputCodecs); + /** * Mongo control adapter for control-plane operations like introspection * and marker-ledger CAS. Implements the family-level `MongoControlAdapter` diff --git a/packages/3-mongo-target/2-mongo-adapter/src/core/operation-output-codecs.ts b/packages/3-mongo-target/2-mongo-adapter/src/core/operation-output-codecs.ts new file mode 100644 index 0000000000..5a6dc12775 --- /dev/null +++ b/packages/3-mongo-target/2-mongo-adapter/src/core/operation-output-codecs.ts @@ -0,0 +1,75 @@ +import { + MONGO_BOOLEAN_CODEC_ID, + MONGO_DATE_CODEC_ID, + MONGO_DOUBLE_CODEC_ID, + MONGO_OBJECTID_CODEC_ID, + MONGO_STRING_CODEC_ID, +} from './codec-ids'; + +/** + * Adapter-declared operation→output-codec table: for every aggregation + * operator whose output type is fixed and input-independent, the codec this + * adapter decodes that output with. Consumed by the query builder (value + * and type level) and by the pipeline result-shape reifier, so computed + * scalars decode like any other read. The Mongo analog of the SQL + * adapters' `queryOperationTypes`. + */ +export const mongoOperationOutputCodecs = { + $concat: MONGO_STRING_CODEC_ID, + $toLower: MONGO_STRING_CODEC_ID, + $toUpper: MONGO_STRING_CODEC_ID, + $toString: MONGO_STRING_CODEC_ID, + $substr: MONGO_STRING_CODEC_ID, + $substrBytes: MONGO_STRING_CODEC_ID, + $trim: MONGO_STRING_CODEC_ID, + $ltrim: MONGO_STRING_CODEC_ID, + $rtrim: MONGO_STRING_CODEC_ID, + $replaceOne: MONGO_STRING_CODEC_ID, + $replaceAll: MONGO_STRING_CODEC_ID, + $dateToString: MONGO_STRING_CODEC_ID, + $type: MONGO_STRING_CODEC_ID, + $eq: MONGO_BOOLEAN_CODEC_ID, + $ne: MONGO_BOOLEAN_CODEC_ID, + $gt: MONGO_BOOLEAN_CODEC_ID, + $gte: MONGO_BOOLEAN_CODEC_ID, + $lt: MONGO_BOOLEAN_CODEC_ID, + $lte: MONGO_BOOLEAN_CODEC_ID, + $in: MONGO_BOOLEAN_CODEC_ID, + $regexMatch: MONGO_BOOLEAN_CODEC_ID, + $isArray: MONGO_BOOLEAN_CODEC_ID, + $toBool: MONGO_BOOLEAN_CODEC_ID, + $setEquals: MONGO_BOOLEAN_CODEC_ID, + $setIsSubset: MONGO_BOOLEAN_CODEC_ID, + $anyElementTrue: MONGO_BOOLEAN_CODEC_ID, + $allElementsTrue: MONGO_BOOLEAN_CODEC_ID, + $toDate: MONGO_DATE_CODEC_ID, + $dateAdd: MONGO_DATE_CODEC_ID, + $dateSubtract: MONGO_DATE_CODEC_ID, + $dateTrunc: MONGO_DATE_CODEC_ID, + $dateFromString: MONGO_DATE_CODEC_ID, + $add: MONGO_DOUBLE_CODEC_ID, + $subtract: MONGO_DOUBLE_CODEC_ID, + $multiply: MONGO_DOUBLE_CODEC_ID, + $divide: MONGO_DOUBLE_CODEC_ID, + $size: MONGO_DOUBLE_CODEC_ID, + $year: MONGO_DOUBLE_CODEC_ID, + $month: MONGO_DOUBLE_CODEC_ID, + $dayOfMonth: MONGO_DOUBLE_CODEC_ID, + $hour: MONGO_DOUBLE_CODEC_ID, + $minute: MONGO_DOUBLE_CODEC_ID, + $second: MONGO_DOUBLE_CODEC_ID, + $millisecond: MONGO_DOUBLE_CODEC_ID, + $dateDiff: MONGO_DOUBLE_CODEC_ID, + $strLenCP: MONGO_DOUBLE_CODEC_ID, + $strLenBytes: MONGO_DOUBLE_CODEC_ID, + $cmp: MONGO_DOUBLE_CODEC_ID, + $indexOfArray: MONGO_DOUBLE_CODEC_ID, + $toInt: MONGO_DOUBLE_CODEC_ID, + $toLong: MONGO_DOUBLE_CODEC_ID, + $toDouble: MONGO_DOUBLE_CODEC_ID, + $toDecimal: MONGO_DOUBLE_CODEC_ID, + $count: MONGO_DOUBLE_CODEC_ID, + $toObjectId: MONGO_OBJECTID_CODEC_ID, +} as const; + +export type MongoOperationOutputCodecs = typeof mongoOperationOutputCodecs; diff --git a/packages/3-mongo-target/2-mongo-adapter/src/exports/runtime.ts b/packages/3-mongo-target/2-mongo-adapter/src/exports/runtime.ts index 35ab7f4b8e..2be875cc64 100644 --- a/packages/3-mongo-target/2-mongo-adapter/src/exports/runtime.ts +++ b/packages/3-mongo-target/2-mongo-adapter/src/exports/runtime.ts @@ -6,8 +6,15 @@ import type { import type { MongoCodecRegistry } from '@prisma-next/mongo-codec'; import type { MongoAdapter } from '@prisma-next/mongo-lowering'; import { buildStandardCodecRegistry } from '../core/codecs'; +import { + type MongoOperationOutputCodecs, + mongoOperationOutputCodecs, +} from '../core/operation-output-codecs'; import { createMongoAdapter } from '../mongo-adapter'; +export type { MongoOperationOutputCodecs } from '../core/operation-output-codecs'; +export { mongoOperationOutputCodecs } from '../core/operation-output-codecs'; + /** * adapter-mongo deliberately does NOT import the `MongoRuntimeAdapterDescriptor` type alias from `@prisma-next/mongo-runtime`. The adapter package is downstream of the Mongo runtime package only conceptually; introducing a hard import would create a workspace dependency cycle (`mongo-runtime` consumes the runtime descriptor's `create(stack)` factory; `adapter-mongo` would then need `mongo-runtime` to type the * descriptor). The descriptor is shaped to satisfy the framework's `RuntimeAdapterDescriptor` plus the structural `MongoStaticContributions` (`codecs()`) that `@prisma-next/mongo-runtime` narrows to at composition time. This mirrors the `target-postgres` ↔ `sql-runtime` decoupling pattern. @@ -23,6 +30,7 @@ const mongoRuntimeAdapterDescriptor: RuntimeAdapterDescriptor< MongoRuntimeAdapterInstance > & { readonly codecs: () => MongoCodecRegistry; + readonly operationOutputCodecs: MongoOperationOutputCodecs; } = { kind: 'adapter', id: 'mongo', @@ -30,6 +38,7 @@ const mongoRuntimeAdapterDescriptor: RuntimeAdapterDescriptor< targetId: 'mongo', version: '0.0.1', codecs: buildStandardCodecRegistry, + operationOutputCodecs: mongoOperationOutputCodecs, create(_stack: ExecutionStack<'mongo', 'mongo'>): MongoRuntimeAdapterInstance { const adapter = createMongoAdapter(); return { From 625e3c3f47747c672f97778d6410ae13a5471175 Mon Sep 17 00:00:00 2001 From: willbot Date: Fri, 3 Jul 2026 07:40:29 +0200 Subject: [PATCH 3/3] TML-2964: migrate fn consumers to the context-bound form + upgrade notes Examples and integration tests move off the deleted free fn import to the stage-callback parameter or the query root; direct mongoQuery calls supply operationCodecs. New integration tests prove computed scalars decode (fn.toDate returns a real Date) and that a contract date field passes to fn.dateToString uncast (the old Parameters<> cast is deleted). Upgrade instructions document the breaking change for app and extension authors. Signed-off-by: willbot Signed-off-by: Will Madden --- examples/mongo-demo/src/server.ts | 4 +- .../mongo-demo/test/cache-middleware.test.ts | 9 +- .../test/query-builder-writes.test.ts | 9 +- .../upgrades/0.14-to-0.15/instructions.md | 79 ++++++++++++++++ .../upgrades/0.14-to-0.15/instructions.md | 91 ++++++++++++++++++- .../cross-family-middleware.test.ts | 2 + .../test/mongo-runtime/query-builder.test.ts | 26 ++++-- .../test/mongo/query-builder.test.ts | 56 +++++++++--- 8 files changed, 248 insertions(+), 28 deletions(-) diff --git a/examples/mongo-demo/src/server.ts b/examples/mongo-demo/src/server.ts index fb3f1d540d..cf0dda99af 100644 --- a/examples/mongo-demo/src/server.ts +++ b/examples/mongo-demo/src/server.ts @@ -11,7 +11,7 @@ import { MongoLimitStage, MongoSortStage, } from '@prisma-next/mongo-query-ast/execution'; -import { acc, fn } from '@prisma-next/mongo-query-builder'; +import { acc } from '@prisma-next/mongo-query-builder'; import type { Db } from './db'; import { createClient } from './db'; import { seed } from './seed'; @@ -80,7 +80,7 @@ export async function getRecentPostSummaries(query: Db['query'], runtime: Db['ru .from('posts') .sort({ createdAt: -1 }) .limit(3) - .addFields((f) => ({ + .addFields((f, fn) => ({ titleUpper: fn.toUpper(f.title), })) .project('title', 'titleUpper', 'authorId', 'createdAt') diff --git a/examples/mongo-demo/test/cache-middleware.test.ts b/examples/mongo-demo/test/cache-middleware.test.ts index c3630061bc..8795177812 100644 --- a/examples/mongo-demo/test/cache-middleware.test.ts +++ b/examples/mongo-demo/test/cache-middleware.test.ts @@ -1,4 +1,6 @@ -import mongoRuntimeAdapter from '@prisma-next/adapter-mongo/runtime'; +import mongoRuntimeAdapter, { + type MongoOperationOutputCodecs, +} from '@prisma-next/adapter-mongo/runtime'; import { createMongoDriver } from '@prisma-next/driver-mongo'; import { MongoContractSerializer } from '@prisma-next/family-mongo/ir'; import type { CachePayload } from '@prisma-next/middleware-cache'; @@ -77,7 +79,10 @@ describe('mongo-demo cache middleware integration', { const cache = createCacheMiddleware({ maxEntries: 100 }); const runtime = createMongoRuntime({ context, driver, middleware: [cache] }); const orm = mongoOrm({ contract, executor: runtime }); - const query = mongoQuery({ contractJson }); + const query = mongoQuery({ + contractJson: contract, + operationCodecs: mongoRuntimeAdapter.operationOutputCodecs, + }); return { runtime, orm, query, driver, driverExecuteSpy }; } diff --git a/examples/mongo-demo/test/query-builder-writes.test.ts b/examples/mongo-demo/test/query-builder-writes.test.ts index e5417c60df..bbfa5a99f7 100644 --- a/examples/mongo-demo/test/query-builder-writes.test.ts +++ b/examples/mongo-demo/test/query-builder-writes.test.ts @@ -1,4 +1,6 @@ -import mongoRuntimeAdapter from '@prisma-next/adapter-mongo/runtime'; +import mongoRuntimeAdapter, { + type MongoOperationOutputCodecs, +} from '@prisma-next/adapter-mongo/runtime'; import { createMongoDriver } from '@prisma-next/driver-mongo'; import { MongoContractSerializer } from '@prisma-next/family-mongo/ir'; import { mongoQuery } from '@prisma-next/mongo-query-builder'; @@ -18,7 +20,10 @@ import contractJson from '../src/contract.json' with { type: 'json' }; const contract = new MongoContractSerializer().deserializeContract(contractJson); -const q = mongoQuery({ contractJson: contract }); +const q = mongoQuery({ + contractJson: contract, + operationCodecs: mongoRuntimeAdapter.operationOutputCodecs, +}); describe('query-builder write terminals (integration)', { timeout: timeouts.spinUpMongoMemoryServer, diff --git a/skills/extension-author/prisma-next-extension-upgrade/upgrades/0.14-to-0.15/instructions.md b/skills/extension-author/prisma-next-extension-upgrade/upgrades/0.14-to-0.15/instructions.md index d42e04a798..f666a8552a 100644 --- a/skills/extension-author/prisma-next-extension-upgrade/upgrades/0.14-to-0.15/instructions.md +++ b/skills/extension-author/prisma-next-extension-upgrade/upgrades/0.14-to-0.15/instructions.md @@ -72,6 +72,26 @@ changes: - "deriveJsonSchema" - "derivePolymorphicJsonSchema" anyMatch: true + - id: mongo-runtime-adapter-descriptor-operation-output-codecs + summary: | + `MongoRuntimeAdapterDescriptor` (from `@prisma-next/mongo-runtime`) gains a required + `operationOutputCodecs` field: a table mapping each aggregation operator with a fixed, + input-independent output type (e.g. `$toUpper`, `$eq`, `$toDate`, `$count`) to the codec id + your adapter decodes that operator's output with. This is the Mongo analog of the SQL + adapters' `queryOperationTypes`, and it is what lets the query builder's `fn` helpers stamp + real codec ids on computed expressions instead of a family-owned placeholder. If your + extension declares its own `MongoRuntimeAdapterDescriptor` (a custom Mongo adapter/target, + or a test double that satisfies the interface), add `operationOutputCodecs` — you can reuse + `mongoOperationOutputCodecs` from `@prisma-next/adapter-mongo/runtime` if your codec ids match + the standard Mongo codecs, or declare your own table shaped `Record` keyed by + Mongo operator name. Extensions that only consume `mongo(...)` / `mongoStatic(...)` (rather + than declaring their own descriptor) need no change. + detection: + glob: "**/*.{ts,mts,cts}" + contains: + - "MongoRuntimeAdapterDescriptor" + - "kind: 'adapter'" + anyMatch: true --- + +# 0.14 → 0.15 — Extension-author upgrade instructions + +## `mongo-runtime-adapter-descriptor-operation-output-codecs` + +The Mongo query builder's `fn` expression helpers now stamp computed expressions with real +codec ids sourced from the adapter, instead of a family-owned placeholder table. The source of +those ids is a new required field on `MongoRuntimeAdapterDescriptor`: + +```ts +export interface MongoRuntimeAdapterDescriptor<...> extends RuntimeAdapterDescriptor<...>, MongoStaticContributions { + readonly operationOutputCodecs: Readonly>; +} +``` + +`operationOutputCodecs` maps each aggregation operator whose output type is fixed and +input-independent (`$toUpper`, `$eq`, `$toDate`, `$count`, …) to the codec id your adapter +decodes that operator's output with — the Mongo analog of the SQL adapters' `queryOperationTypes`. + +### Who needs to change code + +Extensions that declare their own `MongoRuntimeAdapterDescriptor` — a custom Mongo +adapter/target, or a test double built to satisfy the interface (e.g. `{ kind: 'adapter', id: +'mongo', ... }` passed to `createMongoExecutionStack`). Add the field: + +```ts +// Before +const adapterDescriptor: MongoRuntimeAdapterDescriptor<'mongo'> = { + kind: 'adapter', + id: 'mongo', + familyId: 'mongo', + targetId: 'mongo', + version: '0.0.1', + codecs: () => myCodecRegistry(), + create: (stack) => myAdapterInstance(stack), +}; + +// After +import { mongoOperationOutputCodecs } from '@prisma-next/adapter-mongo/runtime'; + +const adapterDescriptor: MongoRuntimeAdapterDescriptor<'mongo'> = { + kind: 'adapter', + id: 'mongo', + familyId: 'mongo', + targetId: 'mongo', + version: '0.0.1', + codecs: () => myCodecRegistry(), + operationOutputCodecs: mongoOperationOutputCodecs, + create: (stack) => myAdapterInstance(stack), +}; +``` + +Reuse `mongoOperationOutputCodecs` if your codecs match the standard Mongo adapter's ids; +otherwise declare your own `Record` table keyed by Mongo operator name. + +### Who does not need to change anything + +Extensions that only consume the built-in `mongo(...)` facade or `mongoStatic(...)` — those +already thread the standard adapter's `operationOutputCodecs` internally. diff --git a/skills/upgrade/prisma-next-upgrade/upgrades/0.14-to-0.15/instructions.md b/skills/upgrade/prisma-next-upgrade/upgrades/0.14-to-0.15/instructions.md index 5b70668846..8dcd58e19f 100644 --- a/skills/upgrade/prisma-next-upgrade/upgrades/0.14-to-0.15/instructions.md +++ b/skills/upgrade/prisma-next-upgrade/upgrades/0.14-to-0.15/instructions.md @@ -1,7 +1,27 @@ --- from: "0.14" to: "0.15" -changes: [] +changes: + - id: mongo-fn-context-bound + summary: | + The Mongo aggregation expression helper `fn` is no longer a free-floating + import from `@prisma-next/mongo-query-builder` — it is minted from the + query builder's execution context, the same way SQL's builder is. Inside a + stage callback (`project`, `addFields`, `replaceRoot`, `sortByCount`, + `redact`, `match`), take `fn` as the callback's second parameter instead of + importing it: `.addFields((f, fn) => ({ ... }))`. Outside a stage callback, + use the query root's `fn` property (`db.query.fn.eq(...)`, or the value + returned by `mongoQuery(...)`). If you call `mongoQuery(...)` directly + (rather than through the `mongo(...)` facade or `mongoStatic(...)`), it now + requires an `operationCodecs` option: pass `mongoOperationOutputCodecs` from + `@prisma-next/adapter-mongo/runtime`. The `acc` accumulator helper is + unaffected by this change. + detection: + glob: "**/*.{ts,tsx}" + contains: + - "from '@prisma-next/mongo-query-builder'" + - "mongoQuery(" + anyMatch: true ---