@prisma-next/emitter 0.5.0-dev.7 → 0.5.0-dev.8
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/package.json +6 -7
- package/test/canonicalization.test.ts +0 -387
- package/test/domain-type-generation.test.ts +0 -997
- package/test/emitter.integration.test.ts +0 -219
- package/test/emitter.roundtrip.test.ts +0 -296
- package/test/emitter.test.ts +0 -367
- package/test/hashing.test.ts +0 -34
- package/test/mock-spi.ts +0 -18
- package/test/type-expression-safety.test.ts +0 -34
- package/test/utils.ts +0 -31
|
@@ -1,997 +0,0 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
ContractField,
|
|
3
|
-
ContractModel,
|
|
4
|
-
ContractValueObject,
|
|
5
|
-
} from '@prisma-next/contract/types';
|
|
6
|
-
import type { Codec, CodecLookup } from '@prisma-next/framework-components/codec';
|
|
7
|
-
import type { TypesImportSpec } from '@prisma-next/framework-components/emission';
|
|
8
|
-
import { describe, expect, it, vi } from 'vitest';
|
|
9
|
-
import {
|
|
10
|
-
deduplicateImports,
|
|
11
|
-
generateBothFieldTypesMaps,
|
|
12
|
-
generateCodecTypeIntersection,
|
|
13
|
-
generateContractFieldDescriptor,
|
|
14
|
-
generateFieldInputTypesMap,
|
|
15
|
-
generateFieldOutputTypesMap,
|
|
16
|
-
generateFieldResolvedType,
|
|
17
|
-
generateHashTypeAliases,
|
|
18
|
-
generateImportLines,
|
|
19
|
-
generateModelFieldsType,
|
|
20
|
-
generateModelRelationsType,
|
|
21
|
-
generateModelsType,
|
|
22
|
-
generateRootsType,
|
|
23
|
-
generateValueObjectsDescriptorType,
|
|
24
|
-
generateValueObjectType,
|
|
25
|
-
generateValueObjectTypeAliases,
|
|
26
|
-
resolveFieldType,
|
|
27
|
-
serializeExecutionType,
|
|
28
|
-
serializeObjectKey,
|
|
29
|
-
serializeValue,
|
|
30
|
-
} from '../src/domain-type-generation';
|
|
31
|
-
|
|
32
|
-
describe('serializeValue', () => {
|
|
33
|
-
it('serializes null', () => {
|
|
34
|
-
expect(serializeValue(null)).toBe('null');
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it('serializes undefined', () => {
|
|
38
|
-
expect(serializeValue(undefined)).toBe('undefined');
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
it('serializes strings with single quotes', () => {
|
|
42
|
-
expect(serializeValue('hello')).toBe("'hello'");
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
it('escapes backslashes and single quotes in strings', () => {
|
|
46
|
-
expect(serializeValue("it's")).toBe("'it\\'s'");
|
|
47
|
-
expect(serializeValue('back\\slash')).toBe("'back\\\\slash'");
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it('serializes numbers', () => {
|
|
51
|
-
expect(serializeValue(42)).toBe('42');
|
|
52
|
-
expect(serializeValue(3.14)).toBe('3.14');
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it('serializes booleans', () => {
|
|
56
|
-
expect(serializeValue(true)).toBe('true');
|
|
57
|
-
expect(serializeValue(false)).toBe('false');
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
it('serializes bigints', () => {
|
|
61
|
-
expect(serializeValue(BigInt(123))).toBe('123n');
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
it('serializes arrays as readonly tuples', () => {
|
|
65
|
-
expect(serializeValue(['a', 'b'])).toBe("readonly ['a', 'b']");
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
it('serializes objects with readonly properties', () => {
|
|
69
|
-
expect(serializeValue({ key: 'val' })).toBe("{ readonly key: 'val' }");
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
it('serializes nested objects', () => {
|
|
73
|
-
const result = serializeValue({ a: { b: 1 } });
|
|
74
|
-
expect(result).toBe('{ readonly a: { readonly b: 1 } }');
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
it('returns unknown for unsupported types', () => {
|
|
78
|
-
expect(serializeValue(Symbol('test'))).toBe('unknown');
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
describe('injection safety', () => {
|
|
82
|
-
// Lock the escape behavior so attacker-controlled (or merely weird) strings
|
|
83
|
-
// in a schema.prisma cannot break out of the emitted single-quoted literal
|
|
84
|
-
// and inject arbitrary TypeScript into contract.d.ts.
|
|
85
|
-
|
|
86
|
-
it('escapes a string attempting to terminate the literal', () => {
|
|
87
|
-
const injected = "x'; export let foo = 'bar";
|
|
88
|
-
const serialized = serializeValue(injected);
|
|
89
|
-
expect(serialized).toBe("'x\\'; export let foo = \\'bar'");
|
|
90
|
-
// The serialized form is a single valid string literal: exactly two
|
|
91
|
-
// outer single quotes, and every inner single quote is backslash-escaped.
|
|
92
|
-
expect(serialized.match(/(?<!\\)'/g)?.length).toBe(2);
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
it('escapes backslash-terminated strings (no lookahead break-out)', () => {
|
|
96
|
-
expect(serializeValue('ends with \\')).toBe("'ends with \\\\'");
|
|
97
|
-
expect(serializeValue('double\\\\back')).toBe("'double\\\\\\\\back'");
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
it('passes through control characters and line separators as raw bytes', () => {
|
|
101
|
-
// U+2028/U+2029 are JavaScript line terminators in legacy parsers.
|
|
102
|
-
// The current emitter does not escape them but they cannot break the
|
|
103
|
-
// single-quoted literal since they are not \' or \\. Pin the behavior.
|
|
104
|
-
expect(serializeValue('a\u2028b')).toBe("'a\u2028b'");
|
|
105
|
-
expect(serializeValue('a\u2029b')).toBe("'a\u2029b'");
|
|
106
|
-
expect(serializeValue('a\nb')).toBe("'a\nb'");
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
it('quotes object keys that look like identifier bypass attempts', () => {
|
|
110
|
-
expect(serializeObjectKey("k'; injected: 'v")).toBe("'k\\'; injected: \\'v'");
|
|
111
|
-
expect(serializeObjectKey('')).toBe("''");
|
|
112
|
-
});
|
|
113
|
-
});
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
describe('serializeObjectKey', () => {
|
|
117
|
-
it('passes through valid identifiers', () => {
|
|
118
|
-
expect(serializeObjectKey('foo')).toBe('foo');
|
|
119
|
-
expect(serializeObjectKey('_bar')).toBe('_bar');
|
|
120
|
-
expect(serializeObjectKey('$baz')).toBe('$baz');
|
|
121
|
-
expect(serializeObjectKey('camelCase')).toBe('camelCase');
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
it('quotes keys with special characters', () => {
|
|
125
|
-
expect(serializeObjectKey('has space')).toBe("'has space'");
|
|
126
|
-
expect(serializeObjectKey('has-dash')).toBe("'has-dash'");
|
|
127
|
-
expect(serializeObjectKey('ns/name@1')).toBe("'ns/name@1'");
|
|
128
|
-
});
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
describe('generateModelFieldsType', () => {
|
|
132
|
-
it('returns Record<string, never> for empty fields', () => {
|
|
133
|
-
expect(generateModelFieldsType({})).toBe('Record<string, never>');
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
it('generates field with type descriptor and nullable', () => {
|
|
137
|
-
const result = generateModelFieldsType({
|
|
138
|
-
name: { type: { kind: 'scalar', codecId: 'sql/text@1' }, nullable: false },
|
|
139
|
-
});
|
|
140
|
-
expect(result).toBe(
|
|
141
|
-
"{ readonly name: { readonly nullable: false; readonly type: { readonly kind: 'scalar'; readonly codecId: 'sql/text@1' } } }",
|
|
142
|
-
);
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
it('generates multiple fields', () => {
|
|
146
|
-
const result = generateModelFieldsType({
|
|
147
|
-
id: { type: { kind: 'scalar', codecId: 'sql/int4@1' }, nullable: false },
|
|
148
|
-
email: { type: { kind: 'scalar', codecId: 'sql/text@1' }, nullable: true },
|
|
149
|
-
});
|
|
150
|
-
expect(result).toContain(
|
|
151
|
-
"readonly id: { readonly nullable: false; readonly type: { readonly kind: 'scalar'; readonly codecId: 'sql/int4@1' } }",
|
|
152
|
-
);
|
|
153
|
-
expect(result).toContain(
|
|
154
|
-
"readonly email: { readonly nullable: true; readonly type: { readonly kind: 'scalar'; readonly codecId: 'sql/text@1' } }",
|
|
155
|
-
);
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
it('quotes keys with special characters', () => {
|
|
159
|
-
const result = generateModelFieldsType({
|
|
160
|
-
'field-name': { type: { kind: 'scalar', codecId: 'sql/text@1' }, nullable: false },
|
|
161
|
-
});
|
|
162
|
-
expect(result).toContain("readonly 'field-name':");
|
|
163
|
-
});
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
describe('generateModelsType', () => {
|
|
167
|
-
const noopStorage = () => 'Record<string, never>';
|
|
168
|
-
|
|
169
|
-
function makeModel(overrides: Partial<ContractModel> = {}): ContractModel {
|
|
170
|
-
return {
|
|
171
|
-
fields: {},
|
|
172
|
-
relations: {},
|
|
173
|
-
storage: { storageHash: 'test' },
|
|
174
|
-
...overrides,
|
|
175
|
-
};
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
it('returns Record<string, never> for empty models', () => {
|
|
179
|
-
expect(generateModelsType({}, noopStorage)).toBe('Record<string, never>');
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
it('generates model with fields, relations, and storage', () => {
|
|
183
|
-
const models: Record<string, ContractModel> = {
|
|
184
|
-
User: makeModel({
|
|
185
|
-
fields: { name: { type: { kind: 'scalar', codecId: 'sql/text@1' }, nullable: false } },
|
|
186
|
-
relations: { posts: { to: 'Post', cardinality: '1:N' } },
|
|
187
|
-
}),
|
|
188
|
-
};
|
|
189
|
-
const result = generateModelsType(models, () => "{ readonly table: 'users' }");
|
|
190
|
-
expect(result).toContain('readonly User:');
|
|
191
|
-
expect(result).toContain("readonly codecId: 'sql/text@1'");
|
|
192
|
-
expect(result).toContain("readonly to: 'Post'");
|
|
193
|
-
expect(result).toContain("readonly table: 'users'");
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
it('sorts models by name', () => {
|
|
197
|
-
const models: Record<string, ContractModel> = {
|
|
198
|
-
Zebra: makeModel(),
|
|
199
|
-
Alpha: makeModel(),
|
|
200
|
-
};
|
|
201
|
-
const result = generateModelsType(models, noopStorage);
|
|
202
|
-
const alphaIdx = result.indexOf('Alpha');
|
|
203
|
-
const zebraIdx = result.indexOf('Zebra');
|
|
204
|
-
expect(alphaIdx).toBeLessThan(zebraIdx);
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
it('passes modelName and model to the storage callback', () => {
|
|
208
|
-
const model = makeModel();
|
|
209
|
-
const models: Record<string, ContractModel> = { User: model };
|
|
210
|
-
const storageFn = vi.fn(() => 'Record<string, never>');
|
|
211
|
-
generateModelsType(models, storageFn);
|
|
212
|
-
expect(storageFn).toHaveBeenCalledWith('User', model);
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
it('includes owner when present', () => {
|
|
216
|
-
const models: Record<string, ContractModel> = {
|
|
217
|
-
Comment: makeModel({ owner: 'Post' }),
|
|
218
|
-
};
|
|
219
|
-
const result = generateModelsType(models, noopStorage);
|
|
220
|
-
expect(result).toContain("readonly owner: 'Post'");
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
it('includes discriminator when present', () => {
|
|
224
|
-
const models: Record<string, ContractModel> = {
|
|
225
|
-
Animal: makeModel({ discriminator: { field: 'type' } }),
|
|
226
|
-
};
|
|
227
|
-
const result = generateModelsType(models, noopStorage);
|
|
228
|
-
expect(result).toContain("readonly discriminator: { readonly field: 'type' }");
|
|
229
|
-
});
|
|
230
|
-
|
|
231
|
-
it('includes variants when present', () => {
|
|
232
|
-
const models: Record<string, ContractModel> = {
|
|
233
|
-
Animal: makeModel({ variants: { Dog: { value: 'dog' }, Cat: { value: 'cat' } } }),
|
|
234
|
-
};
|
|
235
|
-
const result = generateModelsType(models, noopStorage);
|
|
236
|
-
expect(result).toContain('readonly variants:');
|
|
237
|
-
expect(result).toContain('readonly Dog:');
|
|
238
|
-
expect(result).toContain('readonly Cat:');
|
|
239
|
-
});
|
|
240
|
-
|
|
241
|
-
it('includes base when present', () => {
|
|
242
|
-
const models: Record<string, ContractModel> = {
|
|
243
|
-
Dog: makeModel({ base: 'Animal' }),
|
|
244
|
-
};
|
|
245
|
-
const result = generateModelsType(models, noopStorage);
|
|
246
|
-
expect(result).toContain("readonly base: 'Animal'");
|
|
247
|
-
});
|
|
248
|
-
});
|
|
249
|
-
|
|
250
|
-
describe('generateRootsType', () => {
|
|
251
|
-
it('returns Record<string, string> for undefined roots', () => {
|
|
252
|
-
expect(generateRootsType(undefined)).toBe('Record<string, string>');
|
|
253
|
-
});
|
|
254
|
-
|
|
255
|
-
it('returns Record<string, string> for empty roots', () => {
|
|
256
|
-
expect(generateRootsType({})).toBe('Record<string, string>');
|
|
257
|
-
});
|
|
258
|
-
|
|
259
|
-
it('generates literal object type for roots', () => {
|
|
260
|
-
const result = generateRootsType({ users: 'User', posts: 'Post' });
|
|
261
|
-
expect(result).toContain("readonly users: 'User'");
|
|
262
|
-
expect(result).toContain("readonly posts: 'Post'");
|
|
263
|
-
});
|
|
264
|
-
});
|
|
265
|
-
|
|
266
|
-
describe('generateModelRelationsType', () => {
|
|
267
|
-
it('returns empty object for empty relations', () => {
|
|
268
|
-
expect(generateModelRelationsType({})).toBe('Record<string, never>');
|
|
269
|
-
});
|
|
270
|
-
|
|
271
|
-
it('generates relation with to and cardinality', () => {
|
|
272
|
-
const result = generateModelRelationsType({
|
|
273
|
-
posts: { to: 'Post', cardinality: '1:N' },
|
|
274
|
-
});
|
|
275
|
-
expect(result).toContain("readonly to: 'Post'");
|
|
276
|
-
expect(result).toContain("readonly cardinality: '1:N'");
|
|
277
|
-
});
|
|
278
|
-
|
|
279
|
-
it('generates relation with on (localFields/targetFields)', () => {
|
|
280
|
-
const result = generateModelRelationsType({
|
|
281
|
-
author: {
|
|
282
|
-
to: 'User',
|
|
283
|
-
cardinality: 'N:1',
|
|
284
|
-
on: { localFields: ['authorId'], targetFields: ['_id'] },
|
|
285
|
-
},
|
|
286
|
-
});
|
|
287
|
-
expect(result).toContain("readonly to: 'User'");
|
|
288
|
-
expect(result).toContain("readonly cardinality: 'N:1'");
|
|
289
|
-
expect(result).toContain("readonly localFields: readonly ['authorId']");
|
|
290
|
-
expect(result).toContain("readonly targetFields: readonly ['_id']");
|
|
291
|
-
});
|
|
292
|
-
|
|
293
|
-
it('skips non-object relations', () => {
|
|
294
|
-
const result = generateModelRelationsType({
|
|
295
|
-
bad: 'not an object' as unknown as Record<string, unknown>,
|
|
296
|
-
});
|
|
297
|
-
expect(result).toBe('Record<string, never>');
|
|
298
|
-
});
|
|
299
|
-
|
|
300
|
-
it('generates multiple relations', () => {
|
|
301
|
-
const result = generateModelRelationsType({
|
|
302
|
-
author: { to: 'User', cardinality: 'N:1' },
|
|
303
|
-
comments: { to: 'Comment', cardinality: '1:N' },
|
|
304
|
-
});
|
|
305
|
-
expect(result).toContain('readonly author:');
|
|
306
|
-
expect(result).toContain('readonly comments:');
|
|
307
|
-
});
|
|
308
|
-
|
|
309
|
-
it('omits to when missing from relation', () => {
|
|
310
|
-
const result = generateModelRelationsType({
|
|
311
|
-
rel: { cardinality: '1:N' },
|
|
312
|
-
});
|
|
313
|
-
expect(result).toContain("readonly cardinality: '1:N'");
|
|
314
|
-
expect(result).not.toContain('readonly to:');
|
|
315
|
-
});
|
|
316
|
-
|
|
317
|
-
it('omits cardinality when missing from relation', () => {
|
|
318
|
-
const result = generateModelRelationsType({
|
|
319
|
-
rel: { to: 'Post' },
|
|
320
|
-
});
|
|
321
|
-
expect(result).toContain("readonly to: 'Post'");
|
|
322
|
-
expect(result).not.toContain('readonly cardinality:');
|
|
323
|
-
});
|
|
324
|
-
|
|
325
|
-
it('skips relation object with no recognized properties', () => {
|
|
326
|
-
const result = generateModelRelationsType({
|
|
327
|
-
empty: { unknown: true },
|
|
328
|
-
});
|
|
329
|
-
expect(result).toBe('Record<string, never>');
|
|
330
|
-
});
|
|
331
|
-
|
|
332
|
-
it('throws when relation has on but missing localFields/targetFields', () => {
|
|
333
|
-
expect(() =>
|
|
334
|
-
generateModelRelationsType({
|
|
335
|
-
author: {
|
|
336
|
-
to: 'User',
|
|
337
|
-
cardinality: 'N:1',
|
|
338
|
-
on: { parentCols: ['userId'], childCols: ['id'] },
|
|
339
|
-
},
|
|
340
|
-
}),
|
|
341
|
-
).toThrow('missing localFields or targetFields');
|
|
342
|
-
});
|
|
343
|
-
});
|
|
344
|
-
|
|
345
|
-
describe('deduplicateImports', () => {
|
|
346
|
-
it('returns empty array for empty input', () => {
|
|
347
|
-
expect(deduplicateImports([])).toEqual([]);
|
|
348
|
-
});
|
|
349
|
-
|
|
350
|
-
it('keeps unique imports', () => {
|
|
351
|
-
const imports: TypesImportSpec[] = [
|
|
352
|
-
{ package: 'pkg-a', named: 'CodecTypes', alias: 'A' },
|
|
353
|
-
{ package: 'pkg-b', named: 'CodecTypes', alias: 'B' },
|
|
354
|
-
];
|
|
355
|
-
expect(deduplicateImports(imports)).toHaveLength(2);
|
|
356
|
-
});
|
|
357
|
-
|
|
358
|
-
it('deduplicates by package+named (first wins)', () => {
|
|
359
|
-
const imports: TypesImportSpec[] = [
|
|
360
|
-
{ package: 'pkg-a', named: 'CodecTypes', alias: 'First' },
|
|
361
|
-
{ package: 'pkg-a', named: 'CodecTypes', alias: 'Second' },
|
|
362
|
-
];
|
|
363
|
-
const result = deduplicateImports(imports);
|
|
364
|
-
expect(result).toHaveLength(1);
|
|
365
|
-
expect(result[0]!.alias).toBe('First');
|
|
366
|
-
});
|
|
367
|
-
|
|
368
|
-
it('preserves insertion order', () => {
|
|
369
|
-
const imports: TypesImportSpec[] = [
|
|
370
|
-
{ package: 'pkg-b', named: 'X', alias: 'X' },
|
|
371
|
-
{ package: 'pkg-a', named: 'Y', alias: 'Y' },
|
|
372
|
-
];
|
|
373
|
-
const result = deduplicateImports(imports);
|
|
374
|
-
expect(result[0]!.package).toBe('pkg-b');
|
|
375
|
-
expect(result[1]!.package).toBe('pkg-a');
|
|
376
|
-
});
|
|
377
|
-
});
|
|
378
|
-
|
|
379
|
-
describe('generateImportLines', () => {
|
|
380
|
-
it('generates import with alias', () => {
|
|
381
|
-
const imports: TypesImportSpec[] = [
|
|
382
|
-
{ package: '@prisma-next/adapter', named: 'CodecTypes', alias: 'PgCodecTypes' },
|
|
383
|
-
];
|
|
384
|
-
const lines = generateImportLines(imports);
|
|
385
|
-
expect(lines).toEqual([
|
|
386
|
-
"import type { CodecTypes as PgCodecTypes } from '@prisma-next/adapter';",
|
|
387
|
-
]);
|
|
388
|
-
});
|
|
389
|
-
|
|
390
|
-
it('simplifies import when named === alias', () => {
|
|
391
|
-
const imports: TypesImportSpec[] = [
|
|
392
|
-
{ package: '@prisma-next/adapter', named: 'Vector', alias: 'Vector' },
|
|
393
|
-
];
|
|
394
|
-
const lines = generateImportLines(imports);
|
|
395
|
-
expect(lines).toEqual(["import type { Vector } from '@prisma-next/adapter';"]);
|
|
396
|
-
});
|
|
397
|
-
});
|
|
398
|
-
|
|
399
|
-
describe('generateCodecTypeIntersection', () => {
|
|
400
|
-
it('returns Record<string, never> when no matching imports', () => {
|
|
401
|
-
expect(generateCodecTypeIntersection([], 'CodecTypes')).toBe('Record<string, never>');
|
|
402
|
-
});
|
|
403
|
-
|
|
404
|
-
it('returns single alias when one match', () => {
|
|
405
|
-
const imports: TypesImportSpec[] = [
|
|
406
|
-
{ package: 'pkg', named: 'CodecTypes', alias: 'PgCodecTypes' },
|
|
407
|
-
];
|
|
408
|
-
expect(generateCodecTypeIntersection(imports, 'CodecTypes')).toBe('PgCodecTypes');
|
|
409
|
-
});
|
|
410
|
-
|
|
411
|
-
it('returns intersection when multiple matches', () => {
|
|
412
|
-
const imports: TypesImportSpec[] = [
|
|
413
|
-
{ package: 'pkg-a', named: 'CodecTypes', alias: 'A' },
|
|
414
|
-
{ package: 'pkg-b', named: 'CodecTypes', alias: 'B' },
|
|
415
|
-
];
|
|
416
|
-
expect(generateCodecTypeIntersection(imports, 'CodecTypes')).toBe('A & B');
|
|
417
|
-
});
|
|
418
|
-
|
|
419
|
-
it('filters by named parameter', () => {
|
|
420
|
-
const imports: TypesImportSpec[] = [
|
|
421
|
-
{ package: 'pkg', named: 'CodecTypes', alias: 'CT' },
|
|
422
|
-
{ package: 'pkg', named: 'OperationTypes', alias: 'OT' },
|
|
423
|
-
];
|
|
424
|
-
expect(generateCodecTypeIntersection(imports, 'OperationTypes')).toBe('OT');
|
|
425
|
-
});
|
|
426
|
-
});
|
|
427
|
-
|
|
428
|
-
describe('generateHashTypeAliases', () => {
|
|
429
|
-
it('generates storage and profile hash aliases', () => {
|
|
430
|
-
const result = generateHashTypeAliases({
|
|
431
|
-
storageHash: 'sha256:abc123',
|
|
432
|
-
profileHash: 'sha256:def456',
|
|
433
|
-
});
|
|
434
|
-
expect(result).toContain("StorageHashBase<'sha256:abc123'>");
|
|
435
|
-
expect(result).toContain("ProfileHashBase<'sha256:def456'>");
|
|
436
|
-
});
|
|
437
|
-
|
|
438
|
-
it('generates concrete execution hash when provided', () => {
|
|
439
|
-
const result = generateHashTypeAliases({
|
|
440
|
-
storageHash: 'sha256:abc',
|
|
441
|
-
executionHash: 'sha256:exec',
|
|
442
|
-
profileHash: 'sha256:prof',
|
|
443
|
-
});
|
|
444
|
-
expect(result).toContain("ExecutionHashBase<'sha256:exec'>");
|
|
445
|
-
});
|
|
446
|
-
|
|
447
|
-
it('generates generic execution hash when not provided', () => {
|
|
448
|
-
const result = generateHashTypeAliases({
|
|
449
|
-
storageHash: 'sha256:abc',
|
|
450
|
-
profileHash: 'sha256:prof',
|
|
451
|
-
});
|
|
452
|
-
expect(result).toContain('ExecutionHashBase<string>');
|
|
453
|
-
});
|
|
454
|
-
});
|
|
455
|
-
|
|
456
|
-
describe('serializeExecutionType', () => {
|
|
457
|
-
it('uses ExecutionHash alias instead of literal hash value', () => {
|
|
458
|
-
const result = serializeExecutionType({
|
|
459
|
-
executionHash: 'sha256:abc123',
|
|
460
|
-
mutations: { defaults: [] },
|
|
461
|
-
});
|
|
462
|
-
expect(result).toContain('readonly executionHash: ExecutionHash');
|
|
463
|
-
expect(result).not.toContain('sha256:abc123');
|
|
464
|
-
});
|
|
465
|
-
|
|
466
|
-
it('serializes non-hash fields normally', () => {
|
|
467
|
-
const result = serializeExecutionType({
|
|
468
|
-
executionHash: 'sha256:abc123',
|
|
469
|
-
mutations: { defaults: [{ kind: 'autoIncrement' }] },
|
|
470
|
-
});
|
|
471
|
-
expect(result).toContain('readonly mutations:');
|
|
472
|
-
expect(result).toContain("readonly kind: 'autoIncrement'");
|
|
473
|
-
});
|
|
474
|
-
});
|
|
475
|
-
|
|
476
|
-
describe('generateFieldResolvedType', () => {
|
|
477
|
-
it('generates CodecTypes lookup for scalar fields', () => {
|
|
478
|
-
const field: ContractField = {
|
|
479
|
-
nullable: false,
|
|
480
|
-
type: { kind: 'scalar', codecId: 'mongo/string@1' },
|
|
481
|
-
};
|
|
482
|
-
expect(generateFieldResolvedType(field)).toBe("CodecTypes['mongo/string@1']['output']");
|
|
483
|
-
});
|
|
484
|
-
|
|
485
|
-
it('generates suffixed type reference for value object fields', () => {
|
|
486
|
-
const field: ContractField = {
|
|
487
|
-
nullable: false,
|
|
488
|
-
type: { kind: 'valueObject', name: 'Address' },
|
|
489
|
-
};
|
|
490
|
-
expect(generateFieldResolvedType(field)).toBe('AddressOutput');
|
|
491
|
-
});
|
|
492
|
-
|
|
493
|
-
it('wraps in ReadonlyArray for many: true', () => {
|
|
494
|
-
const field: ContractField = {
|
|
495
|
-
nullable: false,
|
|
496
|
-
type: { kind: 'valueObject', name: 'Address' },
|
|
497
|
-
many: true,
|
|
498
|
-
};
|
|
499
|
-
expect(generateFieldResolvedType(field)).toBe('ReadonlyArray<AddressOutput>');
|
|
500
|
-
});
|
|
501
|
-
|
|
502
|
-
it('wraps in Readonly<Record> for dict: true', () => {
|
|
503
|
-
const field: ContractField = {
|
|
504
|
-
nullable: false,
|
|
505
|
-
type: { kind: 'scalar', codecId: 'mongo/string@1' },
|
|
506
|
-
dict: true,
|
|
507
|
-
};
|
|
508
|
-
expect(generateFieldResolvedType(field)).toBe(
|
|
509
|
-
"Readonly<Record<string, CodecTypes['mongo/string@1']['output']>>",
|
|
510
|
-
);
|
|
511
|
-
});
|
|
512
|
-
|
|
513
|
-
it('appends | null for nullable: true', () => {
|
|
514
|
-
const field: ContractField = {
|
|
515
|
-
nullable: true,
|
|
516
|
-
type: { kind: 'valueObject', name: 'Address' },
|
|
517
|
-
};
|
|
518
|
-
expect(generateFieldResolvedType(field)).toBe('AddressOutput | null');
|
|
519
|
-
});
|
|
520
|
-
|
|
521
|
-
it('combines many and nullable', () => {
|
|
522
|
-
const field: ContractField = {
|
|
523
|
-
nullable: true,
|
|
524
|
-
type: { kind: 'valueObject', name: 'Address' },
|
|
525
|
-
many: true,
|
|
526
|
-
};
|
|
527
|
-
expect(generateFieldResolvedType(field)).toBe('ReadonlyArray<AddressOutput> | null');
|
|
528
|
-
});
|
|
529
|
-
|
|
530
|
-
it('handles union types with output side', () => {
|
|
531
|
-
const field: ContractField = {
|
|
532
|
-
nullable: false,
|
|
533
|
-
type: {
|
|
534
|
-
kind: 'union',
|
|
535
|
-
members: [
|
|
536
|
-
{ kind: 'scalar', codecId: 'mongo/string@1' },
|
|
537
|
-
{ kind: 'valueObject', name: 'Address' },
|
|
538
|
-
],
|
|
539
|
-
},
|
|
540
|
-
};
|
|
541
|
-
expect(generateFieldResolvedType(field)).toBe(
|
|
542
|
-
"CodecTypes['mongo/string@1']['output'] | AddressOutput",
|
|
543
|
-
);
|
|
544
|
-
});
|
|
545
|
-
|
|
546
|
-
it('generates input side for scalar fields', () => {
|
|
547
|
-
const field: ContractField = {
|
|
548
|
-
nullable: false,
|
|
549
|
-
type: { kind: 'scalar', codecId: 'mongo/string@1' },
|
|
550
|
-
};
|
|
551
|
-
expect(generateFieldResolvedType(field, undefined, 'input')).toBe(
|
|
552
|
-
"CodecTypes['mongo/string@1']['input']",
|
|
553
|
-
);
|
|
554
|
-
});
|
|
555
|
-
|
|
556
|
-
it('generates input-suffixed type for value object fields on input side', () => {
|
|
557
|
-
const field: ContractField = {
|
|
558
|
-
nullable: false,
|
|
559
|
-
type: { kind: 'valueObject', name: 'Price' },
|
|
560
|
-
};
|
|
561
|
-
expect(generateFieldResolvedType(field, undefined, 'input')).toBe('PriceInput');
|
|
562
|
-
});
|
|
563
|
-
|
|
564
|
-
it('generates input side for union types', () => {
|
|
565
|
-
const field: ContractField = {
|
|
566
|
-
nullable: false,
|
|
567
|
-
type: {
|
|
568
|
-
kind: 'union',
|
|
569
|
-
members: [
|
|
570
|
-
{ kind: 'scalar', codecId: 'mongo/string@1' },
|
|
571
|
-
{ kind: 'valueObject', name: 'Address' },
|
|
572
|
-
],
|
|
573
|
-
},
|
|
574
|
-
};
|
|
575
|
-
expect(generateFieldResolvedType(field, undefined, 'input')).toBe(
|
|
576
|
-
"CodecTypes['mongo/string@1']['input'] | AddressInput",
|
|
577
|
-
);
|
|
578
|
-
});
|
|
579
|
-
});
|
|
580
|
-
|
|
581
|
-
describe('generateValueObjectType', () => {
|
|
582
|
-
const addressVo: ContractValueObject = {
|
|
583
|
-
fields: {
|
|
584
|
-
street: { nullable: false, type: { kind: 'scalar', codecId: 'mongo/string@1' } },
|
|
585
|
-
city: { nullable: false, type: { kind: 'scalar', codecId: 'mongo/string@1' } },
|
|
586
|
-
zip: { nullable: false, type: { kind: 'scalar', codecId: 'mongo/string@1' } },
|
|
587
|
-
},
|
|
588
|
-
};
|
|
589
|
-
const valueObjects: Record<string, ContractValueObject> = { Address: addressVo };
|
|
590
|
-
|
|
591
|
-
it('generates object type with all fields', () => {
|
|
592
|
-
const result = generateValueObjectType('Address', addressVo, valueObjects);
|
|
593
|
-
expect(result).toContain("readonly street: CodecTypes['mongo/string@1']['output']");
|
|
594
|
-
expect(result).toContain("readonly city: CodecTypes['mongo/string@1']['output']");
|
|
595
|
-
expect(result).toContain("readonly zip: CodecTypes['mongo/string@1']['output']");
|
|
596
|
-
});
|
|
597
|
-
|
|
598
|
-
it('handles value object field referencing another value object (output)', () => {
|
|
599
|
-
const companyVo: ContractValueObject = {
|
|
600
|
-
fields: {
|
|
601
|
-
name: { nullable: false, type: { kind: 'scalar', codecId: 'mongo/string@1' } },
|
|
602
|
-
address: { nullable: false, type: { kind: 'valueObject', name: 'Address' } },
|
|
603
|
-
},
|
|
604
|
-
};
|
|
605
|
-
const vos = { ...valueObjects, Company: companyVo };
|
|
606
|
-
const result = generateValueObjectType('Company', companyVo, vos);
|
|
607
|
-
expect(result).toContain('readonly address: AddressOutput');
|
|
608
|
-
});
|
|
609
|
-
|
|
610
|
-
it('handles value object field referencing another value object (input)', () => {
|
|
611
|
-
const companyVo: ContractValueObject = {
|
|
612
|
-
fields: {
|
|
613
|
-
name: { nullable: false, type: { kind: 'scalar', codecId: 'mongo/string@1' } },
|
|
614
|
-
address: { nullable: false, type: { kind: 'valueObject', name: 'Address' } },
|
|
615
|
-
},
|
|
616
|
-
};
|
|
617
|
-
const vos = { ...valueObjects, Company: companyVo };
|
|
618
|
-
const result = generateValueObjectType('Company', companyVo, vos, 'input');
|
|
619
|
-
expect(result).toContain('readonly address: AddressInput');
|
|
620
|
-
});
|
|
621
|
-
|
|
622
|
-
it('handles self-referencing value object (no infinite recursion)', () => {
|
|
623
|
-
const navItemVo: ContractValueObject = {
|
|
624
|
-
fields: {
|
|
625
|
-
label: { nullable: false, type: { kind: 'scalar', codecId: 'mongo/string@1' } },
|
|
626
|
-
children: {
|
|
627
|
-
nullable: false,
|
|
628
|
-
type: { kind: 'valueObject', name: 'NavItem' },
|
|
629
|
-
many: true,
|
|
630
|
-
},
|
|
631
|
-
},
|
|
632
|
-
};
|
|
633
|
-
const vos = { NavItem: navItemVo };
|
|
634
|
-
const result = generateValueObjectType('NavItem', navItemVo, vos);
|
|
635
|
-
expect(result).toContain('readonly children: ReadonlyArray<NavItemOutput>');
|
|
636
|
-
});
|
|
637
|
-
|
|
638
|
-
it('returns Record<string, never> for empty value object', () => {
|
|
639
|
-
const emptyVo: ContractValueObject = { fields: {} };
|
|
640
|
-
expect(generateValueObjectType('Empty', emptyVo, {})).toBe('Record<string, never>');
|
|
641
|
-
});
|
|
642
|
-
});
|
|
643
|
-
|
|
644
|
-
describe('generateContractFieldDescriptor', () => {
|
|
645
|
-
it('generates scalar field descriptor', () => {
|
|
646
|
-
const field: ContractField = {
|
|
647
|
-
nullable: false,
|
|
648
|
-
type: { kind: 'scalar', codecId: 'pg/text@1' },
|
|
649
|
-
};
|
|
650
|
-
const result = generateContractFieldDescriptor('name', field);
|
|
651
|
-
expect(result).toBe(
|
|
652
|
-
"readonly name: { readonly nullable: false; readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' } }",
|
|
653
|
-
);
|
|
654
|
-
});
|
|
655
|
-
|
|
656
|
-
it('generates value object field descriptor', () => {
|
|
657
|
-
const field: ContractField = {
|
|
658
|
-
nullable: true,
|
|
659
|
-
type: { kind: 'valueObject', name: 'Address' },
|
|
660
|
-
};
|
|
661
|
-
const result = generateContractFieldDescriptor('homeAddress', field);
|
|
662
|
-
expect(result).toBe(
|
|
663
|
-
"readonly homeAddress: { readonly nullable: true; readonly type: { readonly kind: 'valueObject'; readonly name: 'Address' } }",
|
|
664
|
-
);
|
|
665
|
-
});
|
|
666
|
-
|
|
667
|
-
it('includes many modifier', () => {
|
|
668
|
-
const field: ContractField = {
|
|
669
|
-
nullable: false,
|
|
670
|
-
type: { kind: 'valueObject', name: 'Address' },
|
|
671
|
-
many: true,
|
|
672
|
-
};
|
|
673
|
-
const result = generateContractFieldDescriptor('addresses', field);
|
|
674
|
-
expect(result).toContain('; readonly many: true');
|
|
675
|
-
});
|
|
676
|
-
|
|
677
|
-
it('includes dict modifier', () => {
|
|
678
|
-
const field: ContractField = {
|
|
679
|
-
nullable: false,
|
|
680
|
-
type: { kind: 'scalar', codecId: 'mongo/string@1' },
|
|
681
|
-
dict: true,
|
|
682
|
-
};
|
|
683
|
-
const result = generateContractFieldDescriptor('labels', field);
|
|
684
|
-
expect(result).toContain('; readonly dict: true');
|
|
685
|
-
});
|
|
686
|
-
|
|
687
|
-
it('includes typeParams for scalar fields', () => {
|
|
688
|
-
const field: ContractField = {
|
|
689
|
-
nullable: false,
|
|
690
|
-
type: { kind: 'scalar', codecId: 'pg/vector@1', typeParams: { length: 1536 } },
|
|
691
|
-
};
|
|
692
|
-
const result = generateContractFieldDescriptor('embedding', field);
|
|
693
|
-
expect(result).toContain('readonly typeParams: { readonly length: 1536 }');
|
|
694
|
-
});
|
|
695
|
-
});
|
|
696
|
-
|
|
697
|
-
describe('generateValueObjectsDescriptorType', () => {
|
|
698
|
-
it('returns Record<string, never> for undefined', () => {
|
|
699
|
-
expect(generateValueObjectsDescriptorType(undefined)).toBe('Record<string, never>');
|
|
700
|
-
});
|
|
701
|
-
|
|
702
|
-
it('returns Record<string, never> for empty', () => {
|
|
703
|
-
expect(generateValueObjectsDescriptorType({})).toBe('Record<string, never>');
|
|
704
|
-
});
|
|
705
|
-
|
|
706
|
-
it('generates descriptor with fields for each value object', () => {
|
|
707
|
-
const valueObjects: Record<string, ContractValueObject> = {
|
|
708
|
-
Address: {
|
|
709
|
-
fields: {
|
|
710
|
-
street: { nullable: false, type: { kind: 'scalar', codecId: 'mongo/string@1' } },
|
|
711
|
-
},
|
|
712
|
-
},
|
|
713
|
-
};
|
|
714
|
-
const result = generateValueObjectsDescriptorType(valueObjects);
|
|
715
|
-
expect(result).toContain('readonly Address: { readonly fields:');
|
|
716
|
-
expect(result).toContain("readonly kind: 'scalar'");
|
|
717
|
-
expect(result).toContain("readonly codecId: 'mongo/string@1'");
|
|
718
|
-
});
|
|
719
|
-
});
|
|
720
|
-
|
|
721
|
-
describe('generateValueObjectTypeAliases', () => {
|
|
722
|
-
it('returns empty string for undefined', () => {
|
|
723
|
-
expect(generateValueObjectTypeAliases(undefined)).toBe('');
|
|
724
|
-
});
|
|
725
|
-
|
|
726
|
-
it('returns empty string for empty', () => {
|
|
727
|
-
expect(generateValueObjectTypeAliases({})).toBe('');
|
|
728
|
-
});
|
|
729
|
-
|
|
730
|
-
it('generates output and input type alias pairs for each value object', () => {
|
|
731
|
-
const valueObjects: Record<string, ContractValueObject> = {
|
|
732
|
-
Address: {
|
|
733
|
-
fields: {
|
|
734
|
-
street: { nullable: false, type: { kind: 'scalar', codecId: 'mongo/string@1' } },
|
|
735
|
-
},
|
|
736
|
-
},
|
|
737
|
-
};
|
|
738
|
-
const result = generateValueObjectTypeAliases(valueObjects);
|
|
739
|
-
expect(result).toContain('export type AddressOutput =');
|
|
740
|
-
expect(result).toContain('export type AddressInput =');
|
|
741
|
-
expect(result).toContain("readonly street: CodecTypes['mongo/string@1']['output']");
|
|
742
|
-
expect(result).toContain("readonly street: CodecTypes['mongo/string@1']['input']");
|
|
743
|
-
expect(result).not.toMatch(/export type Address =/);
|
|
744
|
-
});
|
|
745
|
-
|
|
746
|
-
it('generates multiple type alias pairs', () => {
|
|
747
|
-
const valueObjects: Record<string, ContractValueObject> = {
|
|
748
|
-
Address: {
|
|
749
|
-
fields: {
|
|
750
|
-
street: { nullable: false, type: { kind: 'scalar', codecId: 'mongo/string@1' } },
|
|
751
|
-
},
|
|
752
|
-
},
|
|
753
|
-
GeoPoint: {
|
|
754
|
-
fields: {
|
|
755
|
-
lat: { nullable: false, type: { kind: 'scalar', codecId: 'mongo/double@1' } },
|
|
756
|
-
lng: { nullable: false, type: { kind: 'scalar', codecId: 'mongo/double@1' } },
|
|
757
|
-
},
|
|
758
|
-
},
|
|
759
|
-
};
|
|
760
|
-
const result = generateValueObjectTypeAliases(valueObjects);
|
|
761
|
-
expect(result).toContain('export type AddressOutput =');
|
|
762
|
-
expect(result).toContain('export type AddressInput =');
|
|
763
|
-
expect(result).toContain('export type GeoPointOutput =');
|
|
764
|
-
expect(result).toContain('export type GeoPointInput =');
|
|
765
|
-
});
|
|
766
|
-
});
|
|
767
|
-
|
|
768
|
-
function stubCodec(overrides: Partial<Codec> & { id: string }): Codec {
|
|
769
|
-
return {
|
|
770
|
-
targetTypes: [],
|
|
771
|
-
decode: (w: unknown) => w,
|
|
772
|
-
encodeJson: (v: unknown) => v,
|
|
773
|
-
decodeJson: (j: unknown) => j,
|
|
774
|
-
...overrides,
|
|
775
|
-
} as unknown as Codec;
|
|
776
|
-
}
|
|
777
|
-
|
|
778
|
-
function stubCodecLookup(codecs: Record<string, Codec>): CodecLookup {
|
|
779
|
-
return { get: (id) => codecs[id] };
|
|
780
|
-
}
|
|
781
|
-
|
|
782
|
-
describe('generateFieldResolvedType', () => {
|
|
783
|
-
it('uses codec renderOutputType when typeParams are present', () => {
|
|
784
|
-
const lookup = stubCodecLookup({
|
|
785
|
-
'pg/char@1': stubCodec({
|
|
786
|
-
id: 'pg/char@1',
|
|
787
|
-
renderOutputType: (p) => `Char<${p['length']}>`,
|
|
788
|
-
}),
|
|
789
|
-
});
|
|
790
|
-
const field: ContractField = {
|
|
791
|
-
nullable: false,
|
|
792
|
-
type: { kind: 'scalar', codecId: 'pg/char@1', typeParams: { length: 36 } },
|
|
793
|
-
};
|
|
794
|
-
expect(generateFieldResolvedType(field, lookup)).toBe('Char<36>');
|
|
795
|
-
});
|
|
796
|
-
|
|
797
|
-
it('falls back to CodecTypes lookup when no codecLookup provided', () => {
|
|
798
|
-
const field: ContractField = {
|
|
799
|
-
nullable: false,
|
|
800
|
-
type: { kind: 'scalar', codecId: 'pg/int4@1' },
|
|
801
|
-
};
|
|
802
|
-
expect(generateFieldResolvedType(field)).toBe("CodecTypes['pg/int4@1']['output']");
|
|
803
|
-
});
|
|
804
|
-
|
|
805
|
-
it('falls back to CodecTypes when renderOutputType returns unsafe expression', () => {
|
|
806
|
-
const lookup = stubCodecLookup({
|
|
807
|
-
'test@1': stubCodec({
|
|
808
|
-
id: 'test@1',
|
|
809
|
-
renderOutputType: () => 'import("fs")',
|
|
810
|
-
}),
|
|
811
|
-
});
|
|
812
|
-
const field: ContractField = {
|
|
813
|
-
nullable: false,
|
|
814
|
-
type: { kind: 'scalar', codecId: 'test@1', typeParams: { x: 1 } },
|
|
815
|
-
};
|
|
816
|
-
expect(generateFieldResolvedType(field, lookup)).toBe("CodecTypes['test@1']['output']");
|
|
817
|
-
});
|
|
818
|
-
|
|
819
|
-
it('falls back to CodecTypes when codec has no renderOutputType', () => {
|
|
820
|
-
const lookup = stubCodecLookup({
|
|
821
|
-
'pg/int4@1': stubCodec({ id: 'pg/int4@1' }),
|
|
822
|
-
});
|
|
823
|
-
const field: ContractField = {
|
|
824
|
-
nullable: false,
|
|
825
|
-
type: { kind: 'scalar', codecId: 'pg/int4@1', typeParams: { x: 1 } },
|
|
826
|
-
};
|
|
827
|
-
expect(generateFieldResolvedType(field, lookup)).toBe("CodecTypes['pg/int4@1']['output']");
|
|
828
|
-
});
|
|
829
|
-
|
|
830
|
-
it('falls back to CodecTypes when typeParams is empty', () => {
|
|
831
|
-
const lookup = stubCodecLookup({
|
|
832
|
-
'pg/char@1': stubCodec({
|
|
833
|
-
id: 'pg/char@1',
|
|
834
|
-
renderOutputType: () => 'Char<36>',
|
|
835
|
-
}),
|
|
836
|
-
});
|
|
837
|
-
const field: ContractField = {
|
|
838
|
-
nullable: false,
|
|
839
|
-
type: { kind: 'scalar', codecId: 'pg/char@1', typeParams: {} },
|
|
840
|
-
};
|
|
841
|
-
expect(generateFieldResolvedType(field, lookup)).toBe("CodecTypes['pg/char@1']['output']");
|
|
842
|
-
});
|
|
843
|
-
});
|
|
844
|
-
|
|
845
|
-
describe('generateFieldOutputTypesMap', () => {
|
|
846
|
-
it('generates map entries with codec-dispatched rendering', () => {
|
|
847
|
-
const lookup = stubCodecLookup({
|
|
848
|
-
'pg/char@1': stubCodec({
|
|
849
|
-
id: 'pg/char@1',
|
|
850
|
-
renderOutputType: (p) => `Char<${p['length']}>`,
|
|
851
|
-
}),
|
|
852
|
-
});
|
|
853
|
-
const models: Record<string, ContractModel> = {
|
|
854
|
-
User: {
|
|
855
|
-
fields: {
|
|
856
|
-
id: {
|
|
857
|
-
nullable: false,
|
|
858
|
-
type: { kind: 'scalar', codecId: 'pg/char@1', typeParams: { length: 36 } },
|
|
859
|
-
},
|
|
860
|
-
name: {
|
|
861
|
-
nullable: false,
|
|
862
|
-
type: { kind: 'scalar', codecId: 'pg/text@1' },
|
|
863
|
-
},
|
|
864
|
-
},
|
|
865
|
-
relations: {},
|
|
866
|
-
storage: { fields: {}, table: 'user' },
|
|
867
|
-
},
|
|
868
|
-
};
|
|
869
|
-
const result = generateFieldOutputTypesMap(models, lookup);
|
|
870
|
-
expect(result).toContain('Char<36>');
|
|
871
|
-
expect(result).toContain("CodecTypes['pg/text@1']['output']");
|
|
872
|
-
});
|
|
873
|
-
|
|
874
|
-
it('returns Record<string, never> for empty models', () => {
|
|
875
|
-
expect(generateFieldOutputTypesMap(undefined)).toBe('Record<string, never>');
|
|
876
|
-
expect(generateFieldOutputTypesMap({})).toBe('Record<string, never>');
|
|
877
|
-
});
|
|
878
|
-
|
|
879
|
-
it('references {Name}Output for value object fields', () => {
|
|
880
|
-
const models: Record<string, ContractModel> = {
|
|
881
|
-
Product: {
|
|
882
|
-
fields: {
|
|
883
|
-
price: {
|
|
884
|
-
nullable: false,
|
|
885
|
-
type: { kind: 'valueObject', name: 'Price' },
|
|
886
|
-
},
|
|
887
|
-
},
|
|
888
|
-
relations: {},
|
|
889
|
-
storage: {},
|
|
890
|
-
},
|
|
891
|
-
};
|
|
892
|
-
const result = generateFieldOutputTypesMap(models);
|
|
893
|
-
expect(result).toContain('readonly price: PriceOutput');
|
|
894
|
-
});
|
|
895
|
-
});
|
|
896
|
-
|
|
897
|
-
describe('generateFieldInputTypesMap', () => {
|
|
898
|
-
it('generates input-side codec lookups', () => {
|
|
899
|
-
const models: Record<string, ContractModel> = {
|
|
900
|
-
User: {
|
|
901
|
-
fields: {
|
|
902
|
-
name: {
|
|
903
|
-
nullable: false,
|
|
904
|
-
type: { kind: 'scalar', codecId: 'mongo/string@1' },
|
|
905
|
-
},
|
|
906
|
-
},
|
|
907
|
-
relations: {},
|
|
908
|
-
storage: {},
|
|
909
|
-
},
|
|
910
|
-
};
|
|
911
|
-
const result = generateFieldInputTypesMap(models);
|
|
912
|
-
expect(result).toContain("CodecTypes['mongo/string@1']['input']");
|
|
913
|
-
});
|
|
914
|
-
|
|
915
|
-
it('references {Name}Input for value object fields', () => {
|
|
916
|
-
const models: Record<string, ContractModel> = {
|
|
917
|
-
Product: {
|
|
918
|
-
fields: {
|
|
919
|
-
price: {
|
|
920
|
-
nullable: false,
|
|
921
|
-
type: { kind: 'valueObject', name: 'Price' },
|
|
922
|
-
},
|
|
923
|
-
},
|
|
924
|
-
relations: {},
|
|
925
|
-
storage: {},
|
|
926
|
-
},
|
|
927
|
-
};
|
|
928
|
-
const result = generateFieldInputTypesMap(models);
|
|
929
|
-
expect(result).toContain('readonly price: PriceInput');
|
|
930
|
-
});
|
|
931
|
-
|
|
932
|
-
it('returns Record<string, never> for empty models', () => {
|
|
933
|
-
expect(generateFieldInputTypesMap(undefined)).toBe('Record<string, never>');
|
|
934
|
-
expect(generateFieldInputTypesMap({})).toBe('Record<string, never>');
|
|
935
|
-
});
|
|
936
|
-
});
|
|
937
|
-
|
|
938
|
-
describe('generateBothFieldTypesMaps', () => {
|
|
939
|
-
it('generates both output and input maps in a single pass', () => {
|
|
940
|
-
const models: Record<string, ContractModel> = {
|
|
941
|
-
User: {
|
|
942
|
-
fields: {
|
|
943
|
-
_id: { nullable: false, type: { kind: 'scalar', codecId: 'mongo/objectId@1' } },
|
|
944
|
-
},
|
|
945
|
-
relations: {},
|
|
946
|
-
storage: {},
|
|
947
|
-
},
|
|
948
|
-
};
|
|
949
|
-
const result = generateBothFieldTypesMaps(models);
|
|
950
|
-
expect(result.output).toContain("CodecTypes['mongo/objectId@1']['output']");
|
|
951
|
-
expect(result.input).toContain("CodecTypes['mongo/objectId@1']['input']");
|
|
952
|
-
});
|
|
953
|
-
|
|
954
|
-
it('returns Record<string, never> for empty models on both sides', () => {
|
|
955
|
-
const result = generateBothFieldTypesMaps(undefined);
|
|
956
|
-
expect(result.output).toBe('Record<string, never>');
|
|
957
|
-
expect(result.input).toBe('Record<string, never>');
|
|
958
|
-
});
|
|
959
|
-
});
|
|
960
|
-
|
|
961
|
-
describe('resolveFieldType', () => {
|
|
962
|
-
it('returns both input and output for scalar fields', () => {
|
|
963
|
-
const field: ContractField = {
|
|
964
|
-
nullable: false,
|
|
965
|
-
type: { kind: 'scalar', codecId: 'mongo/string@1' },
|
|
966
|
-
};
|
|
967
|
-
const result = resolveFieldType(field);
|
|
968
|
-
expect(result.output).toBe("CodecTypes['mongo/string@1']['output']");
|
|
969
|
-
expect(result.input).toBe("CodecTypes['mongo/string@1']['input']");
|
|
970
|
-
});
|
|
971
|
-
|
|
972
|
-
it('returns suffixed types for value object fields', () => {
|
|
973
|
-
const field: ContractField = {
|
|
974
|
-
nullable: false,
|
|
975
|
-
type: { kind: 'valueObject', name: 'Price' },
|
|
976
|
-
};
|
|
977
|
-
const result = resolveFieldType(field);
|
|
978
|
-
expect(result.output).toBe('PriceOutput');
|
|
979
|
-
expect(result.input).toBe('PriceInput');
|
|
980
|
-
});
|
|
981
|
-
|
|
982
|
-
it('uses renderOutputType only for output side of parameterized codecs', () => {
|
|
983
|
-
const lookup = stubCodecLookup({
|
|
984
|
-
'pg/char@1': stubCodec({
|
|
985
|
-
id: 'pg/char@1',
|
|
986
|
-
renderOutputType: (p) => `Char<${p['length']}>`,
|
|
987
|
-
}),
|
|
988
|
-
});
|
|
989
|
-
const field: ContractField = {
|
|
990
|
-
nullable: false,
|
|
991
|
-
type: { kind: 'scalar', codecId: 'pg/char@1', typeParams: { length: 36 } },
|
|
992
|
-
};
|
|
993
|
-
const result = resolveFieldType(field, lookup);
|
|
994
|
-
expect(result.output).toBe('Char<36>');
|
|
995
|
-
expect(result.input).toBe("CodecTypes['pg/char@1']['input']");
|
|
996
|
-
});
|
|
997
|
-
});
|