@prisma-next/target-mongo 0.3.0 → 0.4.0-dev.2

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.
@@ -0,0 +1,112 @@
1
+ import type { MigrationPlanOperation } from '@prisma-next/framework-components/control';
2
+ import type {
3
+ CollModCommand,
4
+ CreateCollectionCommand,
5
+ CreateIndexCommand,
6
+ DropCollectionCommand,
7
+ DropIndexCommand,
8
+ MongoDdlCommandVisitor,
9
+ MongoIndexKey,
10
+ } from '@prisma-next/mongo-query-ast/control';
11
+
12
+ function formatKeySpec(keys: ReadonlyArray<MongoIndexKey>): string {
13
+ const entries = keys.map((k) => `${JSON.stringify(k.field)}: ${JSON.stringify(k.direction)}`);
14
+ return `{ ${entries.join(', ')} }`;
15
+ }
16
+
17
+ function formatOptions(cmd: CreateIndexCommand): string | undefined {
18
+ const parts: string[] = [];
19
+ if (cmd.unique) parts.push('unique: true');
20
+ if (cmd.sparse) parts.push('sparse: true');
21
+ if (cmd.expireAfterSeconds !== undefined)
22
+ parts.push(`expireAfterSeconds: ${cmd.expireAfterSeconds}`);
23
+ if (cmd.name) parts.push(`name: ${JSON.stringify(cmd.name)}`);
24
+ if (cmd.collation) parts.push(`collation: ${JSON.stringify(cmd.collation)}`);
25
+ if (cmd.weights) parts.push(`weights: ${JSON.stringify(cmd.weights)}`);
26
+ if (cmd.default_language) parts.push(`default_language: ${JSON.stringify(cmd.default_language)}`);
27
+ if (cmd.language_override)
28
+ parts.push(`language_override: ${JSON.stringify(cmd.language_override)}`);
29
+ if (cmd.wildcardProjection)
30
+ parts.push(`wildcardProjection: ${JSON.stringify(cmd.wildcardProjection)}`);
31
+ if (cmd.partialFilterExpression)
32
+ parts.push(`partialFilterExpression: ${JSON.stringify(cmd.partialFilterExpression)}`);
33
+ if (parts.length === 0) return undefined;
34
+ return `{ ${parts.join(', ')} }`;
35
+ }
36
+
37
+ function formatCreateCollectionOptions(cmd: CreateCollectionCommand): string | undefined {
38
+ const parts: string[] = [];
39
+ if (cmd.capped) parts.push('capped: true');
40
+ if (cmd.size !== undefined) parts.push(`size: ${cmd.size}`);
41
+ if (cmd.max !== undefined) parts.push(`max: ${cmd.max}`);
42
+ if (cmd.timeseries) parts.push(`timeseries: ${JSON.stringify(cmd.timeseries)}`);
43
+ if (cmd.collation) parts.push(`collation: ${JSON.stringify(cmd.collation)}`);
44
+ if (cmd.clusteredIndex) parts.push(`clusteredIndex: ${JSON.stringify(cmd.clusteredIndex)}`);
45
+ if (cmd.validator) parts.push(`validator: ${JSON.stringify(cmd.validator)}`);
46
+ if (cmd.validationLevel) parts.push(`validationLevel: ${JSON.stringify(cmd.validationLevel)}`);
47
+ if (cmd.validationAction) parts.push(`validationAction: ${JSON.stringify(cmd.validationAction)}`);
48
+ if (cmd.changeStreamPreAndPostImages)
49
+ parts.push(`changeStreamPreAndPostImages: ${JSON.stringify(cmd.changeStreamPreAndPostImages)}`);
50
+ if (parts.length === 0) return undefined;
51
+ return `{ ${parts.join(', ')} }`;
52
+ }
53
+
54
+ class MongoDdlCommandFormatter implements MongoDdlCommandVisitor<string> {
55
+ createIndex(cmd: CreateIndexCommand): string {
56
+ const keySpec = formatKeySpec(cmd.keys);
57
+ const opts = formatOptions(cmd);
58
+ return opts
59
+ ? `db.${cmd.collection}.createIndex(${keySpec}, ${opts})`
60
+ : `db.${cmd.collection}.createIndex(${keySpec})`;
61
+ }
62
+
63
+ dropIndex(cmd: DropIndexCommand): string {
64
+ return `db.${cmd.collection}.dropIndex(${JSON.stringify(cmd.name)})`;
65
+ }
66
+
67
+ createCollection(cmd: CreateCollectionCommand): string {
68
+ const opts = formatCreateCollectionOptions(cmd);
69
+ return opts
70
+ ? `db.createCollection(${JSON.stringify(cmd.collection)}, ${opts})`
71
+ : `db.createCollection(${JSON.stringify(cmd.collection)})`;
72
+ }
73
+
74
+ dropCollection(cmd: DropCollectionCommand): string {
75
+ return `db.${cmd.collection}.drop()`;
76
+ }
77
+
78
+ collMod(cmd: CollModCommand): string {
79
+ const parts: string[] = [`collMod: ${JSON.stringify(cmd.collection)}`];
80
+ if (cmd.validator) parts.push(`validator: ${JSON.stringify(cmd.validator)}`);
81
+ if (cmd.validationLevel) parts.push(`validationLevel: ${JSON.stringify(cmd.validationLevel)}`);
82
+ if (cmd.validationAction)
83
+ parts.push(`validationAction: ${JSON.stringify(cmd.validationAction)}`);
84
+ if (cmd.changeStreamPreAndPostImages)
85
+ parts.push(
86
+ `changeStreamPreAndPostImages: ${JSON.stringify(cmd.changeStreamPreAndPostImages)}`,
87
+ );
88
+ return `db.runCommand({ ${parts.join(', ')} })`;
89
+ }
90
+ }
91
+
92
+ const formatter = new MongoDdlCommandFormatter();
93
+
94
+ interface MongoExecuteStep {
95
+ readonly command: { readonly accept: <R>(visitor: MongoDdlCommandVisitor<R>) => R };
96
+ }
97
+
98
+ export function formatMongoOperations(operations: readonly MigrationPlanOperation[]): string[] {
99
+ const statements: string[] = [];
100
+ for (const operation of operations) {
101
+ const candidate = operation as unknown as Record<string, unknown>;
102
+ if (!('execute' in candidate) || !Array.isArray(candidate['execute'])) {
103
+ continue;
104
+ }
105
+ for (const step of candidate['execute'] as MongoExecuteStep[]) {
106
+ if (step.command && typeof step.command.accept === 'function') {
107
+ statements.push(step.command.accept(formatter));
108
+ }
109
+ }
110
+ }
111
+ return statements;
112
+ }
@@ -0,0 +1,84 @@
1
+ import type {
2
+ MongoAndExpr,
3
+ MongoExistsExpr,
4
+ MongoExprFilter,
5
+ MongoFieldFilter,
6
+ MongoFilterExpr,
7
+ MongoFilterVisitor,
8
+ MongoNotExpr,
9
+ MongoOrExpr,
10
+ } from '@prisma-next/mongo-query-ast/control';
11
+ import { deepEqual } from '@prisma-next/mongo-schema-ir';
12
+ import type { MongoValue } from '@prisma-next/mongo-value';
13
+
14
+ function getNestedField(doc: Record<string, unknown>, path: string): unknown {
15
+ const parts = path.split('.');
16
+ let current: unknown = doc;
17
+ for (const part of parts) {
18
+ if (current === null || current === undefined || typeof current !== 'object') {
19
+ return undefined;
20
+ }
21
+ const record = current as Record<string, unknown>;
22
+ if (!Object.hasOwn(record, part)) {
23
+ return undefined;
24
+ }
25
+ current = record[part];
26
+ }
27
+ return current;
28
+ }
29
+
30
+ function evaluateFieldOp(op: string, actual: unknown, expected: MongoValue): boolean {
31
+ switch (op) {
32
+ case '$eq':
33
+ return deepEqual(actual, expected);
34
+ case '$ne':
35
+ return !deepEqual(actual, expected);
36
+ case '$gt':
37
+ return typeof actual === typeof expected && (actual as number) > (expected as number);
38
+ case '$gte':
39
+ return typeof actual === typeof expected && (actual as number) >= (expected as number);
40
+ case '$lt':
41
+ return typeof actual === typeof expected && (actual as number) < (expected as number);
42
+ case '$lte':
43
+ return typeof actual === typeof expected && (actual as number) <= (expected as number);
44
+ case '$in':
45
+ return Array.isArray(expected) && expected.some((v) => deepEqual(actual, v));
46
+ default:
47
+ throw new Error(`Unsupported filter operator in migration check: ${op}`);
48
+ }
49
+ }
50
+
51
+ export class FilterEvaluator implements MongoFilterVisitor<boolean> {
52
+ private doc: Record<string, unknown> = {};
53
+
54
+ evaluate(filter: MongoFilterExpr, doc: Record<string, unknown>): boolean {
55
+ this.doc = doc;
56
+ return filter.accept(this);
57
+ }
58
+
59
+ field(expr: MongoFieldFilter): boolean {
60
+ const value = getNestedField(this.doc, expr.field);
61
+ return evaluateFieldOp(expr.op, value, expr.value);
62
+ }
63
+
64
+ and(expr: MongoAndExpr): boolean {
65
+ return expr.exprs.every((child) => child.accept(this));
66
+ }
67
+
68
+ or(expr: MongoOrExpr): boolean {
69
+ return expr.exprs.some((child) => child.accept(this));
70
+ }
71
+
72
+ not(expr: MongoNotExpr): boolean {
73
+ return !expr.expr.accept(this);
74
+ }
75
+
76
+ exists(expr: MongoExistsExpr): boolean {
77
+ const has = getNestedField(this.doc, expr.field) !== undefined;
78
+ return expr.exists ? has : !has;
79
+ }
80
+
81
+ expr(_expr: MongoExprFilter): boolean {
82
+ throw new Error('Aggregation expression filters are not supported in migration checks');
83
+ }
84
+ }
@@ -0,0 +1,244 @@
1
+ import type { MongoIndexKey } from '@prisma-next/mongo-query-ast/control';
2
+ import {
3
+ buildIndexOpId,
4
+ CollModCommand,
5
+ type CollModOptions,
6
+ CreateCollectionCommand,
7
+ type CreateCollectionOptions,
8
+ CreateIndexCommand,
9
+ type CreateIndexOptions,
10
+ DropCollectionCommand,
11
+ DropIndexCommand,
12
+ defaultMongoIndexName,
13
+ keysToKeySpec,
14
+ ListCollectionsCommand,
15
+ ListIndexesCommand,
16
+ MongoAndExpr,
17
+ MongoFieldFilter,
18
+ type MongoMigrationPlanOperation,
19
+ } from '@prisma-next/mongo-query-ast/control';
20
+ import type { CollModMeta } from './op-factory-call';
21
+
22
+ function formatKeys(keys: ReadonlyArray<MongoIndexKey>): string {
23
+ return keys.map((k) => `${k.field}:${k.direction}`).join(', ');
24
+ }
25
+
26
+ function isTextIndex(keys: ReadonlyArray<MongoIndexKey>): boolean {
27
+ return keys.some((k) => k.direction === 'text');
28
+ }
29
+
30
+ function keyFilter(keys: ReadonlyArray<MongoIndexKey>) {
31
+ return isTextIndex(keys)
32
+ ? MongoFieldFilter.eq('key._fts', 'text')
33
+ : MongoFieldFilter.eq('key', keysToKeySpec(keys));
34
+ }
35
+
36
+ export function createIndex(
37
+ collection: string,
38
+ keys: ReadonlyArray<MongoIndexKey>,
39
+ options?: CreateIndexOptions,
40
+ ): MongoMigrationPlanOperation {
41
+ const name = defaultMongoIndexName(keys);
42
+ const filter = keyFilter(keys);
43
+ const fullFilter = options?.unique
44
+ ? MongoAndExpr.of([filter, MongoFieldFilter.eq('unique', true)])
45
+ : filter;
46
+
47
+ return {
48
+ id: buildIndexOpId('create', collection, keys),
49
+ label: `Create index on ${collection} (${formatKeys(keys)})`,
50
+ operationClass: 'additive',
51
+ precheck: [
52
+ {
53
+ description: `index does not already exist on ${collection}`,
54
+ source: new ListIndexesCommand(collection),
55
+ filter,
56
+ expect: 'notExists',
57
+ },
58
+ ],
59
+ execute: [
60
+ {
61
+ description: `create index on ${collection}`,
62
+ command: new CreateIndexCommand(collection, keys, {
63
+ ...options,
64
+ unique: options?.unique ?? undefined,
65
+ name,
66
+ }),
67
+ },
68
+ ],
69
+ postcheck: [
70
+ {
71
+ description: `index exists on ${collection}`,
72
+ source: new ListIndexesCommand(collection),
73
+ filter: fullFilter,
74
+ expect: 'exists',
75
+ },
76
+ ],
77
+ };
78
+ }
79
+
80
+ export function dropIndex(
81
+ collection: string,
82
+ keys: ReadonlyArray<MongoIndexKey>,
83
+ ): MongoMigrationPlanOperation {
84
+ const indexName = defaultMongoIndexName(keys);
85
+ const filter = keyFilter(keys);
86
+
87
+ return {
88
+ id: buildIndexOpId('drop', collection, keys),
89
+ label: `Drop index on ${collection} (${formatKeys(keys)})`,
90
+ operationClass: 'destructive',
91
+ precheck: [
92
+ {
93
+ description: `index exists on ${collection}`,
94
+ source: new ListIndexesCommand(collection),
95
+ filter,
96
+ expect: 'exists',
97
+ },
98
+ ],
99
+ execute: [
100
+ {
101
+ description: `drop index on ${collection}`,
102
+ command: new DropIndexCommand(collection, indexName),
103
+ },
104
+ ],
105
+ postcheck: [
106
+ {
107
+ description: `index no longer exists on ${collection}`,
108
+ source: new ListIndexesCommand(collection),
109
+ filter,
110
+ expect: 'notExists',
111
+ },
112
+ ],
113
+ };
114
+ }
115
+
116
+ export function createCollection(
117
+ collection: string,
118
+ options?: CreateCollectionOptions,
119
+ ): MongoMigrationPlanOperation {
120
+ return {
121
+ id: `collection.${collection}.create`,
122
+ label: `Create collection ${collection}`,
123
+ operationClass: 'additive',
124
+ precheck: [
125
+ {
126
+ description: `collection ${collection} does not exist`,
127
+ source: new ListCollectionsCommand(),
128
+ filter: MongoFieldFilter.eq('name', collection),
129
+ expect: 'notExists',
130
+ },
131
+ ],
132
+ execute: [
133
+ {
134
+ description: `create collection ${collection}`,
135
+ command: new CreateCollectionCommand(collection, options),
136
+ },
137
+ ],
138
+ postcheck: [],
139
+ };
140
+ }
141
+
142
+ export function dropCollection(collection: string): MongoMigrationPlanOperation {
143
+ return {
144
+ id: `collection.${collection}.drop`,
145
+ label: `Drop collection ${collection}`,
146
+ operationClass: 'destructive',
147
+ precheck: [],
148
+ execute: [
149
+ {
150
+ description: `drop collection ${collection}`,
151
+ command: new DropCollectionCommand(collection),
152
+ },
153
+ ],
154
+ postcheck: [],
155
+ };
156
+ }
157
+
158
+ export function setValidation(
159
+ collection: string,
160
+ schema: Record<string, unknown>,
161
+ options?: { validationLevel?: 'strict' | 'moderate'; validationAction?: 'error' | 'warn' },
162
+ ): MongoMigrationPlanOperation {
163
+ return {
164
+ id: `collection.${collection}.setValidation`,
165
+ label: `Set validation on ${collection}`,
166
+ operationClass: 'destructive',
167
+ precheck: [],
168
+ execute: [
169
+ {
170
+ description: `set validation on ${collection}`,
171
+ command: new CollModCommand(collection, {
172
+ validator: { $jsonSchema: schema },
173
+ validationLevel: options?.validationLevel,
174
+ validationAction: options?.validationAction,
175
+ }),
176
+ },
177
+ ],
178
+ postcheck: [],
179
+ };
180
+ }
181
+
182
+ export function collMod(
183
+ collection: string,
184
+ options: CollModOptions,
185
+ meta?: CollModMeta,
186
+ ): MongoMigrationPlanOperation {
187
+ const hasValidator = options.validator != null && Object.keys(options.validator).length > 0;
188
+
189
+ return {
190
+ id: meta?.id ?? `collection.${collection}.collMod`,
191
+ label: meta?.label ?? `Modify collection ${collection}`,
192
+ operationClass: meta?.operationClass ?? 'destructive',
193
+ precheck:
194
+ options.validator != null
195
+ ? [
196
+ {
197
+ description: `collection ${collection} exists`,
198
+ source: new ListCollectionsCommand(),
199
+ filter: MongoFieldFilter.eq('name', collection),
200
+ expect: 'exists' as const,
201
+ },
202
+ ]
203
+ : [],
204
+ execute: [
205
+ {
206
+ description: `modify ${collection}`,
207
+ command: new CollModCommand(collection, options),
208
+ },
209
+ ],
210
+ postcheck: hasValidator
211
+ ? [
212
+ {
213
+ description: `validator applied on ${collection}`,
214
+ source: new ListCollectionsCommand(),
215
+ filter: MongoAndExpr.of([
216
+ MongoFieldFilter.eq('name', collection),
217
+ ...(options.validationLevel
218
+ ? [MongoFieldFilter.eq('options.validationLevel', options.validationLevel)]
219
+ : []),
220
+ ...(options.validationAction
221
+ ? [MongoFieldFilter.eq('options.validationAction', options.validationAction)]
222
+ : []),
223
+ ]),
224
+ expect: 'exists' as const,
225
+ },
226
+ ]
227
+ : [],
228
+ };
229
+ }
230
+
231
+ export function validatedCollection(
232
+ name: string,
233
+ schema: Record<string, unknown>,
234
+ indexes: ReadonlyArray<{ keys: MongoIndexKey[]; unique?: boolean }>,
235
+ ): MongoMigrationPlanOperation[] {
236
+ return [
237
+ createCollection(name, {
238
+ validator: { $jsonSchema: schema },
239
+ validationLevel: 'strict',
240
+ validationAction: 'error',
241
+ }),
242
+ ...indexes.map((idx) => createIndex(name, idx.keys, { unique: idx.unique })),
243
+ ];
244
+ }