@prisma-next/mongo-query-builder 0.5.0-dev.9 → 0.6.0-dev.1
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 +398 -192
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +189 -54
- package/dist/index.mjs.map +1 -1
- package/package.json +11 -9
- package/src/builder.ts +56 -31
- package/src/exports/index.ts +13 -0
- package/src/lookup-builder.ts +272 -0
- package/src/pipeline-result-shape.ts +15 -0
- package/src/query.ts +0 -1
- package/src/resolve-path.ts +54 -0
- package/src/result-shape.ts +63 -0
- package/src/state-classes.ts +12 -3
- package/src/types.ts +110 -9
package/package.json
CHANGED
|
@@ -1,21 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@prisma-next/mongo-query-builder",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0-dev.1",
|
|
4
|
+
"license": "Apache-2.0",
|
|
4
5
|
"type": "module",
|
|
5
6
|
"sideEffects": false,
|
|
6
7
|
"description": "Type-safe MongoDB query builder (reads, writes, find-and-modify, pipeline-terminal writes) with document shape tracking",
|
|
7
8
|
"dependencies": {
|
|
8
|
-
"@prisma-next/contract": "0.
|
|
9
|
-
"@prisma-next/mongo-
|
|
10
|
-
"@prisma-next/mongo-
|
|
11
|
-
"@prisma-next/
|
|
9
|
+
"@prisma-next/contract": "0.6.0-dev.1",
|
|
10
|
+
"@prisma-next/mongo-value": "0.6.0-dev.1",
|
|
11
|
+
"@prisma-next/mongo-contract": "0.6.0-dev.1",
|
|
12
|
+
"@prisma-next/utils": "0.6.0-dev.1",
|
|
13
|
+
"@prisma-next/mongo-query-ast": "0.6.0-dev.1"
|
|
12
14
|
},
|
|
13
15
|
"devDependencies": {
|
|
14
|
-
"tsdown": "0.
|
|
16
|
+
"tsdown": "0.22.0",
|
|
15
17
|
"typescript": "5.9.3",
|
|
16
|
-
"vitest": "4.
|
|
17
|
-
"@prisma-next/
|
|
18
|
-
"@prisma-next/
|
|
18
|
+
"vitest": "4.1.5",
|
|
19
|
+
"@prisma-next/tsconfig": "0.0.0",
|
|
20
|
+
"@prisma-next/tsdown": "0.0.0"
|
|
19
21
|
},
|
|
20
22
|
"files": [
|
|
21
23
|
"dist",
|
package/src/builder.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type {
|
|
|
3
3
|
ExtractMongoCodecTypes,
|
|
4
4
|
MongoContract,
|
|
5
5
|
MongoContractWithTypeMaps,
|
|
6
|
+
MongoModelDefinition,
|
|
6
7
|
MongoTypeMaps,
|
|
7
8
|
} from '@prisma-next/mongo-contract';
|
|
8
9
|
import type {
|
|
@@ -14,6 +15,7 @@ import type {
|
|
|
14
15
|
MongoPipelineStage,
|
|
15
16
|
MongoProjectionValue,
|
|
16
17
|
MongoQueryPlan,
|
|
18
|
+
MongoResultShape,
|
|
17
19
|
MongoUpdatePipelineStage,
|
|
18
20
|
MongoWindowField,
|
|
19
21
|
UpdateResult,
|
|
@@ -54,9 +56,18 @@ import {
|
|
|
54
56
|
UpdateManyCommand,
|
|
55
57
|
UpdateOneCommand,
|
|
56
58
|
} from '@prisma-next/mongo-query-ast/execution';
|
|
59
|
+
import { ifDefined } from '@prisma-next/utils/defined';
|
|
57
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';
|
|
58
67
|
import type { FindAndModifyEnabled, LeadingMatch, UpdateEnabled } from './markers';
|
|
59
|
-
import
|
|
68
|
+
import { pipelineSupportsFlatResultShape } from './pipeline-result-shape';
|
|
69
|
+
import type { ModelArrayField, NestedDocShape } from './resolve-path';
|
|
70
|
+
import { contractModelToMongoResultShape } from './result-shape';
|
|
60
71
|
import type {
|
|
61
72
|
DocField,
|
|
62
73
|
DocShape,
|
|
@@ -75,6 +86,7 @@ interface PipelineChainState {
|
|
|
75
86
|
readonly collection: string;
|
|
76
87
|
readonly stages: ReadonlyArray<MongoPipelineStage>;
|
|
77
88
|
readonly storageHash: string;
|
|
89
|
+
readonly modelName?: string;
|
|
78
90
|
}
|
|
79
91
|
|
|
80
92
|
/**
|
|
@@ -148,7 +160,6 @@ export class PipelineChain<
|
|
|
148
160
|
target: 'mongo',
|
|
149
161
|
storageHash: this.#state.storageHash,
|
|
150
162
|
lane: 'mongo-query',
|
|
151
|
-
paramDescriptors: [],
|
|
152
163
|
};
|
|
153
164
|
}
|
|
154
165
|
|
|
@@ -260,40 +271,39 @@ export class PipelineChain<
|
|
|
260
271
|
* the `update` or `findAndModify` wire commands. The original document's
|
|
261
272
|
* nested-path shape `N` is preserved (the lookup adds a sidecar array
|
|
262
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[]`.
|
|
263
281
|
*/
|
|
264
|
-
lookup<
|
|
265
|
-
from:
|
|
266
|
-
|
|
267
|
-
foreignField: string;
|
|
268
|
-
as: As;
|
|
269
|
-
}): 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<
|
|
270
285
|
TContract,
|
|
271
|
-
Shape & Record<As,
|
|
286
|
+
Shape & Record<As, ModelArrayField<ModelName>>,
|
|
272
287
|
'update-cleared',
|
|
273
288
|
'fam-cleared',
|
|
274
289
|
'past-leading',
|
|
275
290
|
N
|
|
276
291
|
> {
|
|
277
|
-
const
|
|
278
|
-
const
|
|
279
|
-
|
|
280
|
-
const validRoots = Object.keys(contract.roots).join(', ');
|
|
281
|
-
throw new Error(`lookup() unknown root: "${options.from}". Valid roots: ${validRoots}`);
|
|
282
|
-
}
|
|
283
|
-
const model = contract.models[modelName];
|
|
284
|
-
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);
|
|
285
295
|
return this.#withStage<
|
|
286
|
-
Shape & Record<As,
|
|
296
|
+
Shape & Record<As, ModelArrayField<ModelName>>,
|
|
287
297
|
'update-cleared',
|
|
288
298
|
'fam-cleared',
|
|
289
299
|
'past-leading',
|
|
290
300
|
N
|
|
291
301
|
>(
|
|
292
302
|
new MongoLookupStage({
|
|
293
|
-
from:
|
|
294
|
-
localField:
|
|
295
|
-
foreignField:
|
|
296
|
-
as:
|
|
303
|
+
from: extracted.foreignCollection,
|
|
304
|
+
localField: extracted.localField,
|
|
305
|
+
foreignField: extracted.foreignField,
|
|
306
|
+
as: extracted.as,
|
|
297
307
|
}),
|
|
298
308
|
);
|
|
299
309
|
}
|
|
@@ -509,7 +519,6 @@ export class PipelineChain<
|
|
|
509
519
|
target: 'mongo',
|
|
510
520
|
storageHash: this.#state.storageHash,
|
|
511
521
|
lane: 'mongo-query',
|
|
512
|
-
paramDescriptors: [],
|
|
513
522
|
};
|
|
514
523
|
return { collection: this.#state.collection, command, meta };
|
|
515
524
|
}
|
|
@@ -748,7 +757,7 @@ export class PipelineChain<
|
|
|
748
757
|
updaterFn: (fields: FieldAccessor<Shape, N>) => UpdaterResult,
|
|
749
758
|
opts: { readonly upsert?: boolean; readonly returnDocument?: 'before' | 'after' } = {},
|
|
750
759
|
): MongoQueryPlan<
|
|
751
|
-
ResolveRow<Shape, ExtractMongoCodecTypes<TContract
|
|
760
|
+
ResolveRow<Shape, ExtractMongoCodecTypes<TContract>, TContract> | null,
|
|
752
761
|
FindOneAndUpdateCommand
|
|
753
762
|
> {
|
|
754
763
|
const { filter, sort } = deconstructFindAndModifyChain(this.#state.stages);
|
|
@@ -767,7 +776,6 @@ export class PipelineChain<
|
|
|
767
776
|
target: 'mongo',
|
|
768
777
|
storageHash: this.#state.storageHash,
|
|
769
778
|
lane: 'mongo-query',
|
|
770
|
-
paramDescriptors: [],
|
|
771
779
|
};
|
|
772
780
|
return { collection: this.#state.collection, command, meta };
|
|
773
781
|
}
|
|
@@ -779,7 +787,7 @@ export class PipelineChain<
|
|
|
779
787
|
findOneAndDelete(
|
|
780
788
|
this: PipelineChain<TContract, Shape, U, 'fam-ok', L, N>,
|
|
781
789
|
): MongoQueryPlan<
|
|
782
|
-
ResolveRow<Shape, ExtractMongoCodecTypes<TContract
|
|
790
|
+
ResolveRow<Shape, ExtractMongoCodecTypes<TContract>, TContract> | null,
|
|
783
791
|
FindOneAndDeleteCommand
|
|
784
792
|
> {
|
|
785
793
|
const { filter, sort } = deconstructFindAndModifyChain(this.#state.stages);
|
|
@@ -788,7 +796,6 @@ export class PipelineChain<
|
|
|
788
796
|
target: 'mongo',
|
|
789
797
|
storageHash: this.#state.storageHash,
|
|
790
798
|
lane: 'mongo-query',
|
|
791
|
-
paramDescriptors: [],
|
|
792
799
|
};
|
|
793
800
|
return { collection: this.#state.collection, command, meta };
|
|
794
801
|
}
|
|
@@ -798,22 +805,40 @@ export class PipelineChain<
|
|
|
798
805
|
/**
|
|
799
806
|
* Materialise the chain as a `MongoQueryPlan` wrapping an `AggregateCommand`.
|
|
800
807
|
*/
|
|
801
|
-
build(): MongoQueryPlan<
|
|
808
|
+
build(): MongoQueryPlan<
|
|
809
|
+
ResolveRow<Shape, ExtractMongoCodecTypes<TContract>, TContract>,
|
|
810
|
+
AggregateCommand
|
|
811
|
+
> {
|
|
802
812
|
const command = new AggregateCommand(this.#state.collection, this.#state.stages);
|
|
803
813
|
const meta: PlanMeta = {
|
|
804
814
|
target: 'mongo',
|
|
805
815
|
storageHash: this.#state.storageHash,
|
|
806
816
|
lane: 'mongo-query',
|
|
807
|
-
paramDescriptors: [],
|
|
808
817
|
};
|
|
809
|
-
|
|
818
|
+
const modelName = this.#state.modelName;
|
|
819
|
+
const contractNarrow = this.#contract as MongoContract;
|
|
820
|
+
let resultShape: MongoResultShape | undefined;
|
|
821
|
+
if (modelName !== undefined) {
|
|
822
|
+
if (pipelineSupportsFlatResultShape(this.#state.stages)) {
|
|
823
|
+
const model = contractNarrow.models[modelName] as MongoModelDefinition | undefined;
|
|
824
|
+
resultShape = model ? contractModelToMongoResultShape(model) : { kind: 'unknown' as const };
|
|
825
|
+
} else {
|
|
826
|
+
resultShape = { kind: 'unknown' as const };
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
return {
|
|
830
|
+
collection: this.#state.collection,
|
|
831
|
+
command,
|
|
832
|
+
meta,
|
|
833
|
+
...ifDefined('resultShape', resultShape),
|
|
834
|
+
};
|
|
810
835
|
}
|
|
811
836
|
|
|
812
837
|
/**
|
|
813
838
|
* Alias for `build()` — surfaces the read intent at the call site.
|
|
814
839
|
*/
|
|
815
840
|
aggregate(): MongoQueryPlan<
|
|
816
|
-
ResolveRow<Shape, ExtractMongoCodecTypes<TContract
|
|
841
|
+
ResolveRow<Shape, ExtractMongoCodecTypes<TContract>, TContract>,
|
|
817
842
|
AggregateCommand
|
|
818
843
|
> {
|
|
819
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,
|
|
@@ -25,6 +34,10 @@ export type {
|
|
|
25
34
|
ResolvePath,
|
|
26
35
|
ValidPaths,
|
|
27
36
|
} from '../resolve-path';
|
|
37
|
+
export {
|
|
38
|
+
contractFieldToMongoFieldShape,
|
|
39
|
+
contractModelToMongoResultShape,
|
|
40
|
+
} from '../result-shape';
|
|
28
41
|
export { CollectionHandle, FilteredCollection } from '../state-classes';
|
|
29
42
|
export type {
|
|
30
43
|
ArrayField,
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { MongoPipelineStage } from '@prisma-next/mongo-query-ast/execution';
|
|
2
|
+
|
|
3
|
+
const identityStageKinds = new Set(['match', 'sort', 'limit', 'skip', 'sample']);
|
|
4
|
+
|
|
5
|
+
export function pipelineSupportsFlatResultShape(
|
|
6
|
+
stages: ReadonlyArray<MongoPipelineStage>,
|
|
7
|
+
): boolean {
|
|
8
|
+
for (const stage of stages) {
|
|
9
|
+
const k = stage.kind;
|
|
10
|
+
if (!identityStageKinds.has(k)) {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
return true;
|
|
15
|
+
}
|
package/src/query.ts
CHANGED
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
|
*
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { ContractField } from '@prisma-next/contract/types';
|
|
2
|
+
import type { MongoModelDefinition } from '@prisma-next/mongo-contract';
|
|
3
|
+
import type { MongoFieldShape, MongoResultShape } from '@prisma-next/mongo-query-ast/execution';
|
|
4
|
+
import {
|
|
5
|
+
freezeMongoFieldShape,
|
|
6
|
+
freezeMongoResultShape,
|
|
7
|
+
} from '@prisma-next/mongo-query-ast/execution';
|
|
8
|
+
|
|
9
|
+
export function contractFieldToMongoFieldShape(field: ContractField): MongoFieldShape {
|
|
10
|
+
const { type, nullable, many } = field;
|
|
11
|
+
if (type.kind === 'valueObject' || type.kind === 'union') {
|
|
12
|
+
return Object.freeze({ kind: 'unknown' as const });
|
|
13
|
+
}
|
|
14
|
+
if (type.kind !== 'scalar') {
|
|
15
|
+
return Object.freeze({ kind: 'unknown' as const });
|
|
16
|
+
}
|
|
17
|
+
if (field.dict === true) {
|
|
18
|
+
return Object.freeze({ kind: 'unknown' as const });
|
|
19
|
+
}
|
|
20
|
+
if (many === true) {
|
|
21
|
+
return freezeMongoFieldShape({
|
|
22
|
+
kind: 'array',
|
|
23
|
+
nullable,
|
|
24
|
+
element: { kind: 'leaf', codecId: type.codecId, nullable: false },
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
return freezeMongoFieldShape({
|
|
28
|
+
kind: 'leaf',
|
|
29
|
+
codecId: type.codecId,
|
|
30
|
+
nullable,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function contractModelToMongoResultShape(
|
|
35
|
+
model: MongoModelDefinition,
|
|
36
|
+
options?: {
|
|
37
|
+
readonly selection?: readonly string[];
|
|
38
|
+
readonly includeRelationNames?: readonly string[];
|
|
39
|
+
},
|
|
40
|
+
): MongoResultShape {
|
|
41
|
+
const fields: Record<string, MongoFieldShape> = {};
|
|
42
|
+
for (const rel of options?.includeRelationNames ?? []) {
|
|
43
|
+
fields[rel] = Object.freeze({ kind: 'unknown' as const });
|
|
44
|
+
}
|
|
45
|
+
const modelFields = model.fields;
|
|
46
|
+
// An explicit empty selection is honored as-is (returns a document shape
|
|
47
|
+
// with no fields). Only the absence of a selection falls back to the model's
|
|
48
|
+
// full field set.
|
|
49
|
+
const keys = options?.selection !== undefined ? options.selection : Object.keys(modelFields);
|
|
50
|
+
|
|
51
|
+
for (const key of keys) {
|
|
52
|
+
if (Object.hasOwn(fields, key)) {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const cf = modelFields[key];
|
|
56
|
+
if (!cf) {
|
|
57
|
+
fields[key] = Object.freeze({ kind: 'unknown' as const });
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
fields[key] = contractFieldToMongoFieldShape(cf);
|
|
61
|
+
}
|
|
62
|
+
return freezeMongoResultShape({ kind: 'document', fields });
|
|
63
|
+
}
|