@prisma-next/mongo-query-builder 0.5.0-dev.53 → 0.5.0-dev.54
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -0
- package/dist/index.d.mts +380 -182
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +94 -12
- package/dist/index.mjs.map +1 -1
- package/package.json +8 -8
- package/src/builder.ts +33 -25
- package/src/exports/index.ts +9 -0
- package/src/lookup-builder.ts +272 -0
- package/src/resolve-path.ts +54 -0
- package/src/state-classes.ts +10 -2
- package/src/types.ts +110 -9
package/package.json
CHANGED
|
@@ -1,22 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@prisma-next/mongo-query-builder",
|
|
3
|
-
"version": "0.5.0-dev.
|
|
3
|
+
"version": "0.5.0-dev.54",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"description": "Type-safe MongoDB query builder (reads, writes, find-and-modify, pipeline-terminal writes) with document shape tracking",
|
|
7
7
|
"dependencies": {
|
|
8
|
-
"@prisma-next/
|
|
9
|
-
"@prisma-next/contract": "0.5.0-dev.
|
|
10
|
-
"@prisma-next/mongo-query-ast": "0.5.0-dev.
|
|
11
|
-
"@prisma-next/
|
|
12
|
-
"@prisma-next/
|
|
8
|
+
"@prisma-next/contract": "0.5.0-dev.54",
|
|
9
|
+
"@prisma-next/mongo-contract": "0.5.0-dev.54",
|
|
10
|
+
"@prisma-next/mongo-query-ast": "0.5.0-dev.54",
|
|
11
|
+
"@prisma-next/mongo-value": "0.5.0-dev.54",
|
|
12
|
+
"@prisma-next/utils": "0.5.0-dev.54"
|
|
13
13
|
},
|
|
14
14
|
"devDependencies": {
|
|
15
15
|
"tsdown": "0.18.4",
|
|
16
16
|
"typescript": "5.9.3",
|
|
17
17
|
"vitest": "4.0.17",
|
|
18
|
-
"@prisma-next/
|
|
19
|
-
"@prisma-next/
|
|
18
|
+
"@prisma-next/tsdown": "0.0.0",
|
|
19
|
+
"@prisma-next/tsconfig": "0.0.0"
|
|
20
20
|
},
|
|
21
21
|
"files": [
|
|
22
22
|
"dist",
|
package/src/builder.ts
CHANGED
|
@@ -58,9 +58,15 @@ import {
|
|
|
58
58
|
} from '@prisma-next/mongo-query-ast/execution';
|
|
59
59
|
import { ifDefined } from '@prisma-next/utils/defined';
|
|
60
60
|
import { createFieldAccessor, type Expression, type FieldAccessor } from './field-accessor';
|
|
61
|
+
import {
|
|
62
|
+
createLookupFrom,
|
|
63
|
+
extractLookupResult,
|
|
64
|
+
type LookupFrom,
|
|
65
|
+
type LookupResult,
|
|
66
|
+
} from './lookup-builder';
|
|
61
67
|
import type { FindAndModifyEnabled, LeadingMatch, UpdateEnabled } from './markers';
|
|
62
68
|
import { pipelineSupportsFlatResultShape } from './pipeline-result-shape';
|
|
63
|
-
import type { NestedDocShape } from './resolve-path';
|
|
69
|
+
import type { ModelArrayField, NestedDocShape } from './resolve-path';
|
|
64
70
|
import { contractModelToMongoResultShape } from './result-shape';
|
|
65
71
|
import type {
|
|
66
72
|
DocField,
|
|
@@ -265,40 +271,39 @@ export class PipelineChain<
|
|
|
265
271
|
* the `update` or `findAndModify` wire commands. The original document's
|
|
266
272
|
* nested-path shape `N` is preserved (the lookup adds a sidecar array
|
|
267
273
|
* field; existing keys are untouched).
|
|
274
|
+
*
|
|
275
|
+
* The single callback receives a `from` callable that grounds the
|
|
276
|
+
* foreign-root literal sequentially before the inner `on(...)`
|
|
277
|
+
* callback is type-checked — see `lookup-builder.ts`. The resulting
|
|
278
|
+
* `Shape` gains the `As` key as a `ModelArrayField<ModelName>` so
|
|
279
|
+
* `ResolveRow` produces `Array<ForeignRow>` (with concrete leaf
|
|
280
|
+
* types) instead of the legacy `unknown[]`.
|
|
268
281
|
*/
|
|
269
|
-
lookup<
|
|
270
|
-
from:
|
|
271
|
-
|
|
272
|
-
foreignField: string;
|
|
273
|
-
as: As;
|
|
274
|
-
}): PipelineChain<
|
|
282
|
+
lookup<RootName extends string, ModelName extends string, As extends string>(
|
|
283
|
+
fn: (from: LookupFrom<TContract, Shape, N>) => LookupResult<RootName, ModelName, As>,
|
|
284
|
+
): PipelineChain<
|
|
275
285
|
TContract,
|
|
276
|
-
Shape & Record<As,
|
|
286
|
+
Shape & Record<As, ModelArrayField<ModelName>>,
|
|
277
287
|
'update-cleared',
|
|
278
288
|
'fam-cleared',
|
|
279
289
|
'past-leading',
|
|
280
290
|
N
|
|
281
291
|
> {
|
|
282
|
-
const
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
const validRoots = Object.keys(contract.roots).join(', ');
|
|
286
|
-
throw new Error(`lookup() unknown root: "${options.from}". Valid roots: ${validRoots}`);
|
|
287
|
-
}
|
|
288
|
-
const model = contract.models[modelName];
|
|
289
|
-
const collectionName = model?.storage?.collection ?? options.from;
|
|
292
|
+
const fromCallable = createLookupFrom<TContract, Shape, N>(this.#contract);
|
|
293
|
+
const result = fn(fromCallable);
|
|
294
|
+
const extracted = extractLookupResult(result, this.#contract);
|
|
290
295
|
return this.#withStage<
|
|
291
|
-
Shape & Record<As,
|
|
296
|
+
Shape & Record<As, ModelArrayField<ModelName>>,
|
|
292
297
|
'update-cleared',
|
|
293
298
|
'fam-cleared',
|
|
294
299
|
'past-leading',
|
|
295
300
|
N
|
|
296
301
|
>(
|
|
297
302
|
new MongoLookupStage({
|
|
298
|
-
from:
|
|
299
|
-
localField:
|
|
300
|
-
foreignField:
|
|
301
|
-
as:
|
|
303
|
+
from: extracted.foreignCollection,
|
|
304
|
+
localField: extracted.localField,
|
|
305
|
+
foreignField: extracted.foreignField,
|
|
306
|
+
as: extracted.as,
|
|
302
307
|
}),
|
|
303
308
|
);
|
|
304
309
|
}
|
|
@@ -752,7 +757,7 @@ export class PipelineChain<
|
|
|
752
757
|
updaterFn: (fields: FieldAccessor<Shape, N>) => UpdaterResult,
|
|
753
758
|
opts: { readonly upsert?: boolean; readonly returnDocument?: 'before' | 'after' } = {},
|
|
754
759
|
): MongoQueryPlan<
|
|
755
|
-
ResolveRow<Shape, ExtractMongoCodecTypes<TContract
|
|
760
|
+
ResolveRow<Shape, ExtractMongoCodecTypes<TContract>, TContract> | null,
|
|
756
761
|
FindOneAndUpdateCommand
|
|
757
762
|
> {
|
|
758
763
|
const { filter, sort } = deconstructFindAndModifyChain(this.#state.stages);
|
|
@@ -782,7 +787,7 @@ export class PipelineChain<
|
|
|
782
787
|
findOneAndDelete(
|
|
783
788
|
this: PipelineChain<TContract, Shape, U, 'fam-ok', L, N>,
|
|
784
789
|
): MongoQueryPlan<
|
|
785
|
-
ResolveRow<Shape, ExtractMongoCodecTypes<TContract
|
|
790
|
+
ResolveRow<Shape, ExtractMongoCodecTypes<TContract>, TContract> | null,
|
|
786
791
|
FindOneAndDeleteCommand
|
|
787
792
|
> {
|
|
788
793
|
const { filter, sort } = deconstructFindAndModifyChain(this.#state.stages);
|
|
@@ -800,7 +805,10 @@ export class PipelineChain<
|
|
|
800
805
|
/**
|
|
801
806
|
* Materialise the chain as a `MongoQueryPlan` wrapping an `AggregateCommand`.
|
|
802
807
|
*/
|
|
803
|
-
build(): MongoQueryPlan<
|
|
808
|
+
build(): MongoQueryPlan<
|
|
809
|
+
ResolveRow<Shape, ExtractMongoCodecTypes<TContract>, TContract>,
|
|
810
|
+
AggregateCommand
|
|
811
|
+
> {
|
|
804
812
|
const command = new AggregateCommand(this.#state.collection, this.#state.stages);
|
|
805
813
|
const meta: PlanMeta = {
|
|
806
814
|
target: 'mongo',
|
|
@@ -830,7 +838,7 @@ export class PipelineChain<
|
|
|
830
838
|
* Alias for `build()` — surfaces the read intent at the call site.
|
|
831
839
|
*/
|
|
832
840
|
aggregate(): MongoQueryPlan<
|
|
833
|
-
ResolveRow<Shape, ExtractMongoCodecTypes<TContract
|
|
841
|
+
ResolveRow<Shape, ExtractMongoCodecTypes<TContract>, TContract>,
|
|
834
842
|
AggregateCommand
|
|
835
843
|
> {
|
|
836
844
|
return this.build();
|
package/src/exports/index.ts
CHANGED
|
@@ -14,10 +14,19 @@ export type {
|
|
|
14
14
|
ObjectExpression,
|
|
15
15
|
} from '../field-accessor';
|
|
16
16
|
export { createFieldAccessor } from '../field-accessor';
|
|
17
|
+
export type {
|
|
18
|
+
LookupBuilder,
|
|
19
|
+
LookupBuilderWithKey,
|
|
20
|
+
LookupFrom,
|
|
21
|
+
LookupOnResult,
|
|
22
|
+
LookupResult,
|
|
23
|
+
ModelOf,
|
|
24
|
+
} from '../lookup-builder';
|
|
17
25
|
export type { FindAndModifyEnabled, UpdateEnabled } from '../markers';
|
|
18
26
|
export type { QueryRoot } from '../query';
|
|
19
27
|
export { mongoQuery } from '../query';
|
|
20
28
|
export type {
|
|
29
|
+
ModelArrayField,
|
|
21
30
|
ModelNestedShape,
|
|
22
31
|
NestedDocShape,
|
|
23
32
|
ObjectField,
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import type { MongoContract } from '@prisma-next/mongo-contract';
|
|
2
|
+
import { createFieldAccessor, type FieldAccessor, type LeafExpression } from './field-accessor';
|
|
3
|
+
import type { ModelNestedShape } from './resolve-path';
|
|
4
|
+
import type { DocField, DocShape, ModelToDocShape } from './types';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Resolved foreign-model name for a contract root. Looks `RootName` up
|
|
8
|
+
* through `TContract['roots']` and intersects the result back with
|
|
9
|
+
* `keyof TContract['models']` so it can be used as a `ModelName` index
|
|
10
|
+
* into `models`. Resolves to `never` when the root is not present (this
|
|
11
|
+
* surface should never be reachable through normal use because `from()`
|
|
12
|
+
* constrains its `R` parameter to `keyof TContract['roots']`).
|
|
13
|
+
*/
|
|
14
|
+
export type ModelOf<
|
|
15
|
+
TContract extends MongoContract,
|
|
16
|
+
RootName extends keyof TContract['roots'] & string,
|
|
17
|
+
> = TContract['roots'][RootName] & string & keyof TContract['models'];
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Object returned by the user from the `on(...)` callback. Each side is
|
|
21
|
+
* a `LeafExpression` produced by property access on the corresponding
|
|
22
|
+
* `FieldAccessor` (`local._id`, `foreign.customerId`, etc.). Carrying
|
|
23
|
+
* `LeafExpression` rather than the broader `TypedAggExpr` is what makes
|
|
24
|
+
* non-leaf returns (e.g. `fn.toUpper(local._id)`) a compile-time error
|
|
25
|
+
* without per-field operator gating — `LeafExpression` carries `_path`,
|
|
26
|
+
* `TypedAggExpr` does not (see field-accessor.ts L47–L82).
|
|
27
|
+
*/
|
|
28
|
+
export interface LookupOnResult {
|
|
29
|
+
readonly local: LeafExpression<DocField>;
|
|
30
|
+
readonly foreign: LeafExpression<DocField>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Marker brand on the captured spec returned by the `lookup(...)`
|
|
35
|
+
* callback. The phantom `_brand` literal lets `PipelineChain.lookup`
|
|
36
|
+
* accept the result of `from(...).on(...).as(...)` without exposing the
|
|
37
|
+
* internal field shape to user code, and prevents accidental
|
|
38
|
+
* construction of a malformed spec by hand.
|
|
39
|
+
*/
|
|
40
|
+
export type LookupResultBrand = 'mongo-query-builder/lookup-result@1';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Captured output of the inner `from(name).on(cb).as(name)` chain. The
|
|
44
|
+
* contract is consumed by `PipelineChain.lookup` to construct the
|
|
45
|
+
* `MongoLookupStage` (collection name comes from `models[ModelName]
|
|
46
|
+
* .storage.collection`) and to thread `ModelArrayField<ModelName>` into
|
|
47
|
+
* the resulting `Shape` so the resolver yields `Array<ForeignRow>`.
|
|
48
|
+
*
|
|
49
|
+
* Type parameters carry the foreign-root literal `RootName`, the
|
|
50
|
+
* resolved foreign model name `ModelName`, and the `As` literal so
|
|
51
|
+
* `PipelineChain.lookup`'s return type can encode the result-row
|
|
52
|
+
* promotion precisely.
|
|
53
|
+
*/
|
|
54
|
+
export interface LookupResult<
|
|
55
|
+
RootName extends string,
|
|
56
|
+
ModelName extends string,
|
|
57
|
+
As extends string,
|
|
58
|
+
> {
|
|
59
|
+
readonly _brand: LookupResultBrand;
|
|
60
|
+
readonly _root: RootName;
|
|
61
|
+
readonly _model: ModelName;
|
|
62
|
+
readonly _localField: string;
|
|
63
|
+
readonly _foreignField: string;
|
|
64
|
+
readonly _as: As;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Builder returned by `from(name).on(cb)`. Carries the foreign root /
|
|
69
|
+
* model literals plus the captured local / foreign paths, and exposes
|
|
70
|
+
* `.as(name)` to finalise the spec with the user-chosen field name.
|
|
71
|
+
*/
|
|
72
|
+
export interface LookupBuilderWithKey<RootName extends string, ModelName extends string> {
|
|
73
|
+
as<As extends string>(name: As): LookupResult<RootName, ModelName, As>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Builder returned by `from(name)`. Carries the foreign root / model
|
|
78
|
+
* literals and the local pipeline's `Shape` / nested shape so the
|
|
79
|
+
* `on(...)` callback's `local` and `foreign` accessors are typed
|
|
80
|
+
* narrowly.
|
|
81
|
+
*
|
|
82
|
+
* `on(cb)` runs the user's callback to capture the leaf paths and
|
|
83
|
+
* returns a `LookupBuilderWithKey` that exposes `.as(name)`.
|
|
84
|
+
*/
|
|
85
|
+
export interface LookupBuilder<
|
|
86
|
+
TContract extends MongoContract,
|
|
87
|
+
Shape extends DocShape,
|
|
88
|
+
Nested extends Record<string, DocField>,
|
|
89
|
+
RootName extends string,
|
|
90
|
+
ModelName extends string,
|
|
91
|
+
> {
|
|
92
|
+
on(
|
|
93
|
+
cb: (
|
|
94
|
+
local: FieldAccessor<Shape, Nested>,
|
|
95
|
+
foreign: ModelName extends keyof TContract['models'] & string
|
|
96
|
+
? FieldAccessor<
|
|
97
|
+
ModelToDocShape<TContract, ModelName>,
|
|
98
|
+
ModelNestedShape<TContract, ModelName>
|
|
99
|
+
>
|
|
100
|
+
: never,
|
|
101
|
+
) => LookupOnResult,
|
|
102
|
+
): LookupBuilderWithKey<RootName, ModelName>;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Type of the `from` callable passed to `PipelineChain.lookup`'s outer
|
|
107
|
+
* callback. The generic argument is inferred from a string-literal
|
|
108
|
+
* argument (the same pattern as `mongoQuery<TC>(...).from('orders')`),
|
|
109
|
+
* which grounds `RootName` into the returned `LookupBuilder` *before*
|
|
110
|
+
* the inner `on(...)` callback is type-checked. This sequential
|
|
111
|
+
* inference is what makes `foreign` resolve narrowly to the foreign
|
|
112
|
+
* model's `FieldAccessor` (verified in the R1.5 spike — see spec § Open
|
|
113
|
+
* Questions / Resolved decisions).
|
|
114
|
+
*/
|
|
115
|
+
export type LookupFrom<
|
|
116
|
+
TContract extends MongoContract,
|
|
117
|
+
Shape extends DocShape,
|
|
118
|
+
Nested extends Record<string, DocField>,
|
|
119
|
+
> = <RootName extends keyof TContract['roots'] & string>(
|
|
120
|
+
name: RootName,
|
|
121
|
+
) => LookupBuilder<TContract, Shape, Nested, RootName, ModelOf<TContract, RootName>>;
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Construct the `from` callable for `PipelineChain.lookup`. The contract
|
|
125
|
+
* is captured so `from(name)` can resolve `roots[name]` to the foreign
|
|
126
|
+
* model name at runtime, look up the foreign collection name from
|
|
127
|
+
* `models[modelName].storage.collection`, and assemble a `LookupResult`
|
|
128
|
+
* for the outer `lookup` to consume.
|
|
129
|
+
*
|
|
130
|
+
* The `Shape`/`Nested` generics are erased at runtime — they exist only
|
|
131
|
+
* to type the local accessor inside the user's `on(...)` callback. The
|
|
132
|
+
* contract value at runtime carries the real model lookup table.
|
|
133
|
+
*/
|
|
134
|
+
export function createLookupFrom<
|
|
135
|
+
TContract extends MongoContract,
|
|
136
|
+
Shape extends DocShape,
|
|
137
|
+
Nested extends Record<string, DocField>,
|
|
138
|
+
>(contract: TContract): LookupFrom<TContract, Shape, Nested> {
|
|
139
|
+
const callable = ((rootName) => {
|
|
140
|
+
const modelName = contract.roots[rootName];
|
|
141
|
+
if (!modelName) {
|
|
142
|
+
const validRoots = Object.keys(contract.roots).join(', ');
|
|
143
|
+
throw new Error(`lookup() unknown root: "${rootName}". Valid roots: ${validRoots}`);
|
|
144
|
+
}
|
|
145
|
+
const model = contract.models[modelName];
|
|
146
|
+
const foreignCollection = model?.storage?.collection ?? rootName;
|
|
147
|
+
return createLookupBuilder({
|
|
148
|
+
rootName,
|
|
149
|
+
modelName,
|
|
150
|
+
foreignCollection,
|
|
151
|
+
});
|
|
152
|
+
// The runtime callable accepts a single `string` and returns a
|
|
153
|
+
// generic `LookupBuilder`; the literal `RootName` / `ModelName`
|
|
154
|
+
// generics on `LookupFrom` are erased at runtime and re-asserted
|
|
155
|
+
// here so the surface contract is what the consumer actually sees.
|
|
156
|
+
}) as LookupFrom<TContract, Shape, Nested>;
|
|
157
|
+
return callable;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
interface LookupBuilderRuntimeState {
|
|
161
|
+
readonly rootName: string;
|
|
162
|
+
readonly modelName: string;
|
|
163
|
+
readonly foreignCollection: string;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function createLookupBuilder<
|
|
167
|
+
TContract extends MongoContract,
|
|
168
|
+
Shape extends DocShape,
|
|
169
|
+
Nested extends Record<string, DocField>,
|
|
170
|
+
RootName extends string,
|
|
171
|
+
ModelName extends string,
|
|
172
|
+
>(state: LookupBuilderRuntimeState): LookupBuilder<TContract, Shape, Nested, RootName, ModelName> {
|
|
173
|
+
return {
|
|
174
|
+
on(cb) {
|
|
175
|
+
const localAccessor = createFieldAccessor<Shape, Nested>();
|
|
176
|
+
// Foreign accessor is built unparameterised at runtime — the codec
|
|
177
|
+
// metadata is type-only, and `_path` (the only thing we read off
|
|
178
|
+
// either side) is filled by property access regardless of the
|
|
179
|
+
// generic parameters. The narrow generic on the callback signature
|
|
180
|
+
// is what gives the user the foreign model's keys at compile time.
|
|
181
|
+
const foreignAccessor = createFieldAccessor<DocShape, Record<string, DocField>>();
|
|
182
|
+
const result = cb(localAccessor, foreignAccessor as Parameters<typeof cb>[1]);
|
|
183
|
+
assertLeafExpression(result.local, 'local');
|
|
184
|
+
assertLeafExpression(result.foreign, 'foreign');
|
|
185
|
+
return createLookupBuilderWithKey<RootName, ModelName>({
|
|
186
|
+
...state,
|
|
187
|
+
localField: result.local._path,
|
|
188
|
+
foreignField: result.foreign._path,
|
|
189
|
+
});
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
interface LookupBuilderWithKeyRuntimeState extends LookupBuilderRuntimeState {
|
|
195
|
+
readonly localField: string;
|
|
196
|
+
readonly foreignField: string;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function createLookupBuilderWithKey<RootName extends string, ModelName extends string>(
|
|
200
|
+
state: LookupBuilderWithKeyRuntimeState,
|
|
201
|
+
): LookupBuilderWithKey<RootName, ModelName> {
|
|
202
|
+
return {
|
|
203
|
+
as<As extends string>(name: As): LookupResult<RootName, ModelName, As> {
|
|
204
|
+
return {
|
|
205
|
+
_brand: 'mongo-query-builder/lookup-result@1',
|
|
206
|
+
// The `RootName` / `ModelName` literal generics are erased at
|
|
207
|
+
// runtime; the runtime state holds the same strings as plain
|
|
208
|
+
// `string`. Re-brand so consumers (the lookup-stage builder)
|
|
209
|
+
// can read the literals back without a downstream cast.
|
|
210
|
+
_root: state.rootName as RootName,
|
|
211
|
+
_model: state.modelName as ModelName,
|
|
212
|
+
_localField: state.localField,
|
|
213
|
+
_foreignField: state.foreignField,
|
|
214
|
+
_as: name,
|
|
215
|
+
};
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Defensive runtime guard catching the case where a user returns a
|
|
222
|
+
* non-`LeafExpression` from the `on(...)` callback (e.g. by casting
|
|
223
|
+
* around the type system, or threading a value in from outside the
|
|
224
|
+
* callback). Compile-time gating via `LookupOnResult`'s `LeafExpression`
|
|
225
|
+
* type already rejects `fn.<op>(…)` returns at the type level — this
|
|
226
|
+
* guard is the runtime backstop matching the defensive style of
|
|
227
|
+
* `deconstructFindAndModifyChain` in builder.ts.
|
|
228
|
+
*/
|
|
229
|
+
function assertLeafExpression(
|
|
230
|
+
value: LeafExpression<DocField>,
|
|
231
|
+
side: 'local' | 'foreign',
|
|
232
|
+
): asserts value is LeafExpression<DocField> {
|
|
233
|
+
if (!value || typeof value._path !== 'string' || value._path.length === 0) {
|
|
234
|
+
throw new Error(
|
|
235
|
+
`lookup().on() ${side} side must return a leaf field reference (e.g. \`${side}.<field>\`). ` +
|
|
236
|
+
'Aggregation expressions and computed values are not supported.',
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Extract the runtime metadata from a `LookupResult` for `PipelineChain
|
|
243
|
+
* .lookup` to construct the `MongoLookupStage`. The internal fields are
|
|
244
|
+
* intentionally underscore-prefixed and brand-checked here so user code
|
|
245
|
+
* cannot synthesise a fake spec; this is the single ingress point.
|
|
246
|
+
*/
|
|
247
|
+
export function extractLookupResult(
|
|
248
|
+
result: LookupResult<string, string, string>,
|
|
249
|
+
contract: MongoContract,
|
|
250
|
+
): {
|
|
251
|
+
readonly foreignCollection: string;
|
|
252
|
+
readonly localField: string;
|
|
253
|
+
readonly foreignField: string;
|
|
254
|
+
readonly as: string;
|
|
255
|
+
readonly modelName: string;
|
|
256
|
+
} {
|
|
257
|
+
if (!result || result._brand !== 'mongo-query-builder/lookup-result@1') {
|
|
258
|
+
throw new Error(
|
|
259
|
+
'lookup() callback must return the result of `from(name).on(cb).as(name)`. ' +
|
|
260
|
+
'Returning a hand-rolled options object is not supported.',
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
const model = contract.models[result._model];
|
|
264
|
+
const foreignCollection = model?.storage?.collection ?? result._root;
|
|
265
|
+
return {
|
|
266
|
+
foreignCollection,
|
|
267
|
+
localField: result._localField,
|
|
268
|
+
foreignField: result._foreignField,
|
|
269
|
+
as: result._as,
|
|
270
|
+
modelName: result._model,
|
|
271
|
+
};
|
|
272
|
+
}
|
package/src/resolve-path.ts
CHANGED
|
@@ -23,6 +23,60 @@ export interface ObjectField<N extends NestedDocShape> extends DocField {
|
|
|
23
23
|
readonly fields: N;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Marker `DocField` variant representing "an array of foreign-model rows"
|
|
28
|
+
* — the result of a `$lookup` whose foreign side was selected via the
|
|
29
|
+
* typed `col` accessor. `ModelName` is the literal foreign model name
|
|
30
|
+
* (e.g. `'User'`), preserved in the type so `ResolveRow` can resolve the
|
|
31
|
+
* field to `ResolveRow<ModelToDocShape<TC, ModelName>, …>[]` rather than
|
|
32
|
+
* the opaque `unknown[]` produced by the legacy `mongo/array@1` sentinel.
|
|
33
|
+
*
|
|
34
|
+
* Like `ObjectField`, the codec id is a purely type-level sentinel —
|
|
35
|
+
* there is no runtime codec entry for `'prisma/modelArray@1'`. The
|
|
36
|
+
* surrounding pipeline emits the standard `MongoLookupStage` whose
|
|
37
|
+
* runtime shape is unchanged; the marker exists solely to thread the
|
|
38
|
+
* foreign element type through the result-row resolver.
|
|
39
|
+
*/
|
|
40
|
+
export interface ModelArrayField<ModelName extends string> extends DocField {
|
|
41
|
+
readonly codecId: 'prisma/modelArray@1';
|
|
42
|
+
readonly nullable: false;
|
|
43
|
+
readonly model: ModelName;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Phantom-symbol brand placed on `ModelToDocShape`'s output (and inherited
|
|
48
|
+
* through shape-extending stages like `addFields`, `match`, `lookup`) so
|
|
49
|
+
* `ResolveRow` can route value-object resolution through `InferModelRow`
|
|
50
|
+
* — which walks the contract's `valueObjects` registry and resolves
|
|
51
|
+
* nested types — instead of falling through the codec-lookup branch to
|
|
52
|
+
* `unknown`.
|
|
53
|
+
*
|
|
54
|
+
* The brand is keyed on a `unique symbol` rather than a string so it is
|
|
55
|
+
* invisible to every `keyof Shape & string` walk in the package
|
|
56
|
+
* (`SortSpec`, `ProjectedShape`, `GroupedDocShape`, the field accessor's
|
|
57
|
+
* `Object.keys` iteration, etc.). Field accessors do not surface a
|
|
58
|
+
* `__modelOrigin` autocomplete entry; sorts cannot key on it; projections
|
|
59
|
+
* cannot reference it.
|
|
60
|
+
*
|
|
61
|
+
* Shape-extending stages preserve the brand because intersection types
|
|
62
|
+
* (`Shape & NewFields`) carry through symbol-keyed properties. Shape-
|
|
63
|
+
* replacing stages (`replaceRoot`, `group`, `project`) construct fresh
|
|
64
|
+
* `DocShape`s from scratch, naturally dropping the brand — which is the
|
|
65
|
+
* intended behaviour, since those stages legitimately leave model
|
|
66
|
+
* territory.
|
|
67
|
+
*/
|
|
68
|
+
export declare const ModelOriginBrand: unique symbol;
|
|
69
|
+
export type ModelOriginBrand = typeof ModelOriginBrand;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Brand carrier — a Shape whose row type should be resolved via
|
|
73
|
+
* `InferModelRow<TContract, ModelName>` rather than the per-field codec
|
|
74
|
+
* walk. Use as `ModelToDocShape<TC, ModelName> & ModelOriginBranded<ModelName>`.
|
|
75
|
+
*/
|
|
76
|
+
export type ModelOriginBranded<ModelName extends string> = {
|
|
77
|
+
readonly [ModelOriginBrand]?: ModelName;
|
|
78
|
+
};
|
|
79
|
+
|
|
26
80
|
/**
|
|
27
81
|
* Document shape that carries nested value-object sub-shapes.
|
|
28
82
|
*
|
package/src/state-classes.ts
CHANGED
|
@@ -546,7 +546,11 @@ export class FilteredCollection<
|
|
|
546
546
|
) => UpdaterResult,
|
|
547
547
|
opts: { readonly upsert?: boolean; readonly returnDocument?: 'before' | 'after' } = {},
|
|
548
548
|
): MongoQueryPlan<
|
|
549
|
-
ResolveRow<
|
|
549
|
+
ResolveRow<
|
|
550
|
+
ModelToDocShape<TContract, ModelName>,
|
|
551
|
+
ExtractMongoCodecTypes<TContract>,
|
|
552
|
+
TContract
|
|
553
|
+
> | null,
|
|
550
554
|
FindOneAndUpdateCommand
|
|
551
555
|
> {
|
|
552
556
|
const update = resolveUpdaterCallback<
|
|
@@ -573,7 +577,11 @@ export class FilteredCollection<
|
|
|
573
577
|
* document via the row stream.
|
|
574
578
|
*/
|
|
575
579
|
override findOneAndDelete(): MongoQueryPlan<
|
|
576
|
-
ResolveRow<
|
|
580
|
+
ResolveRow<
|
|
581
|
+
ModelToDocShape<TContract, ModelName>,
|
|
582
|
+
ExtractMongoCodecTypes<TContract>,
|
|
583
|
+
TContract
|
|
584
|
+
> | null,
|
|
577
585
|
FindOneAndDeleteCommand
|
|
578
586
|
> {
|
|
579
587
|
const command = new FindOneAndDeleteCommand(this.#ctx.collection, this.#foldedFilter());
|
package/src/types.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
ExtractMongoTypeMaps,
|
|
3
|
+
InferModelRow,
|
|
4
|
+
MongoContract,
|
|
5
|
+
MongoContractWithTypeMaps,
|
|
6
|
+
MongoTypeMaps,
|
|
7
|
+
} from '@prisma-next/mongo-contract';
|
|
2
8
|
import type { MongoAggAccumulator, MongoAggExpr } from '@prisma-next/mongo-query-ast/execution';
|
|
9
|
+
import type { ModelArrayField, ModelOriginBrand, ModelOriginBranded } from './resolve-path';
|
|
3
10
|
|
|
4
11
|
export interface DocField {
|
|
5
12
|
readonly codecId: string;
|
|
@@ -40,19 +47,103 @@ export type ModelToDocShape<
|
|
|
40
47
|
readonly codecId: ExtractCodecId<TContract['models'][ModelName]['fields'][K]>;
|
|
41
48
|
readonly nullable: TContract['models'][ModelName]['fields'][K]['nullable'];
|
|
42
49
|
};
|
|
43
|
-
}
|
|
50
|
+
} & ModelOriginBranded<ModelName>;
|
|
44
51
|
|
|
45
|
-
|
|
52
|
+
/**
|
|
53
|
+
* Per-field resolver. Walks `Shape`'s string keys, routing
|
|
54
|
+
* `ModelArrayField` (the lookup marker) through `InferModelRow` and
|
|
55
|
+
* everything else through the codec-lookup branch.
|
|
56
|
+
*
|
|
57
|
+
* Internal helper — public callers should use `ResolveRow`, which adds
|
|
58
|
+
* the model-origin brand detection on top.
|
|
59
|
+
*/
|
|
60
|
+
type ResolveFields<
|
|
46
61
|
Shape extends DocShape,
|
|
47
62
|
CodecTypes extends Record<string, { readonly output: unknown }>,
|
|
63
|
+
TContract extends MongoContract,
|
|
48
64
|
> = {
|
|
49
|
-
-readonly [K in keyof Shape & string]: Shape[K]
|
|
50
|
-
?
|
|
51
|
-
?
|
|
52
|
-
|
|
53
|
-
|
|
65
|
+
-readonly [K in keyof Shape & string]: Shape[K] extends ModelArrayField<infer ModelName>
|
|
66
|
+
? IsConcreteContract<TContract> extends true
|
|
67
|
+
? TContract extends MongoContractWithTypeMaps<MongoContract, MongoTypeMaps>
|
|
68
|
+
? ModelName extends string & keyof TContract['models']
|
|
69
|
+
? Array<InferModelRow<TContract, ModelName>>
|
|
70
|
+
: unknown[]
|
|
71
|
+
: unknown[]
|
|
72
|
+
: unknown[]
|
|
73
|
+
: Shape[K]['codecId'] extends keyof CodecTypes
|
|
74
|
+
? Shape[K]['nullable'] extends true
|
|
75
|
+
? CodecTypes[Shape[K]['codecId']]['output'] | null
|
|
76
|
+
: CodecTypes[Shape[K]['codecId']]['output']
|
|
77
|
+
: unknown;
|
|
54
78
|
};
|
|
55
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Resolve a `DocShape` to a concrete row object type.
|
|
82
|
+
*
|
|
83
|
+
* The optional `TContract` parameter exists so the resolver can:
|
|
84
|
+
*
|
|
85
|
+
* 1. Detect the `ModelOriginBrand` on `Shape` — the phantom symbol
|
|
86
|
+
* placed by `ModelToDocShape`. When present (and the contract has
|
|
87
|
+
* type maps), the row is resolved via `InferModelRow<TC, M>` from
|
|
88
|
+
* `@prisma-next/mongo-contract`, which walks scalar / valueObject /
|
|
89
|
+
* union field kinds (handling nested value-objects and `many: true`).
|
|
90
|
+
* This makes entry-point reads (`q.from('users').build()`) and
|
|
91
|
+
* shape-extending stages (`match`, `addFields`) resolve value-object
|
|
92
|
+
* fields to their concrete nested types instead of `unknown`.
|
|
93
|
+
*
|
|
94
|
+
* 2. Detect the per-field `ModelArrayField<ModelName>` marker produced
|
|
95
|
+
* by `lookup()` and resolve it to `Array<InferModelRow<TC, M>>` so
|
|
96
|
+
* lookup rows carry the same fully-typed foreign rows.
|
|
97
|
+
*
|
|
98
|
+
* When the contract is not threaded through (or lacks the type-map
|
|
99
|
+
* phantom), both branches fall back to `unknown` / `unknown[]` —
|
|
100
|
+
* preserving the legacy resolver shape for call sites that do not need
|
|
101
|
+
* model-row resolution.
|
|
102
|
+
*/
|
|
103
|
+
/**
|
|
104
|
+
* Flatten an intersection `A & B` into a single object literal so callers
|
|
105
|
+
* (and `expectTypeOf().toEqualTypeOf<…>()`) see one homogeneous record
|
|
106
|
+
* rather than the intersection form. Vitest's strict equality check
|
|
107
|
+
* treats `A & B` as distinct from the structurally-equivalent flat
|
|
108
|
+
* record, even when assignability is bidirectional, so the
|
|
109
|
+
* `ResolveRow` brand-positive branch normalises its result through this.
|
|
110
|
+
*/
|
|
111
|
+
type Flatten<T> = T extends infer U ? { [K in keyof U]: U[K] } : never;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Decide whether to route a brand-positive `ResolveRow` through
|
|
115
|
+
* `InferModelRow`. The default `MongoContract` (no concrete models)
|
|
116
|
+
* still satisfies `MongoContractWithTypeMaps<MongoContract, MongoTypeMaps>`
|
|
117
|
+
* because the phantom key is optional, but `InferModelRow<MongoContract, …>`
|
|
118
|
+
* collapses to an empty/unknown row. Gate on the presence of the
|
|
119
|
+
* type-maps phantom: a concrete contract attaches concrete `TestTypeMaps`-
|
|
120
|
+
* shaped maps, while the default `MongoContract` has no phantom and
|
|
121
|
+
* `ExtractMongoTypeMaps` resolves to `never`.
|
|
122
|
+
*/
|
|
123
|
+
type IsConcreteContract<TContract> = [ExtractMongoTypeMaps<TContract>] extends [never]
|
|
124
|
+
? false
|
|
125
|
+
: true;
|
|
126
|
+
|
|
127
|
+
export type ResolveRow<
|
|
128
|
+
Shape extends DocShape,
|
|
129
|
+
CodecTypes extends Record<string, { readonly output: unknown }>,
|
|
130
|
+
TContract extends MongoContract = MongoContract,
|
|
131
|
+
> = Shape extends { readonly [ModelOriginBrand]?: infer ModelName extends string }
|
|
132
|
+
? IsConcreteContract<TContract> extends true
|
|
133
|
+
? TContract extends MongoContractWithTypeMaps<MongoContract, MongoTypeMaps>
|
|
134
|
+
? ModelName extends string & keyof TContract['models']
|
|
135
|
+
? Flatten<
|
|
136
|
+
InferModelRow<TContract, ModelName> &
|
|
137
|
+
Omit<
|
|
138
|
+
ResolveFields<Shape, CodecTypes, TContract>,
|
|
139
|
+
keyof InferModelRow<TContract, ModelName>
|
|
140
|
+
>
|
|
141
|
+
>
|
|
142
|
+
: ResolveFields<Shape, CodecTypes, TContract>
|
|
143
|
+
: ResolveFields<Shape, CodecTypes, TContract>
|
|
144
|
+
: ResolveFields<Shape, CodecTypes, TContract>
|
|
145
|
+
: ResolveFields<Shape, CodecTypes, TContract>;
|
|
146
|
+
|
|
56
147
|
export interface TypedAggExpr<F extends DocField> {
|
|
57
148
|
readonly _field: F;
|
|
58
149
|
readonly node: MongoAggExpr;
|
|
@@ -108,6 +199,16 @@ export type GroupedDocShape<Spec extends GroupSpec> = {
|
|
|
108
199
|
*/
|
|
109
200
|
type UnwrapArrayDocField<F extends DocField> = F;
|
|
110
201
|
|
|
202
|
+
/**
|
|
203
|
+
* `$unwind` reshapes the array slot but leaves the rest of the document
|
|
204
|
+
* structurally intact. The mapped iteration is keyed on `keyof S & string`,
|
|
205
|
+
* which discards the symbol-keyed `ModelOriginBrand` carried by
|
|
206
|
+
* model-rooted shapes. Preserve the brand explicitly so post-unwind
|
|
207
|
+
* `ResolveRow` still routes through `InferModelRow` and value-object
|
|
208
|
+
* fields keep their concrete nested types.
|
|
209
|
+
*/
|
|
111
210
|
export type UnwoundShape<S extends DocShape, K extends keyof S & string> = {
|
|
112
211
|
[P in keyof S & string]: P extends K ? UnwrapArrayDocField<S[P]> : S[P];
|
|
113
|
-
}
|
|
212
|
+
} & (S extends ModelOriginBranded<infer ModelName extends string>
|
|
213
|
+
? ModelOriginBranded<ModelName>
|
|
214
|
+
: unknown);
|