ormbridge 0.3.0 → 0.4.0

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.
Files changed (46) hide show
  1. package/README.md +186 -37
  2. package/dist/cliRunner.js +1 -1
  3. package/dist/cliRunner.js.map +1 -1
  4. package/dist/emitters/drizzle.d.ts +19 -0
  5. package/dist/emitters/drizzle.js +1337 -0
  6. package/dist/emitters/drizzle.js.map +1 -0
  7. package/dist/emitters/jsonSchema.d.ts +20 -0
  8. package/dist/emitters/jsonSchema.js +503 -0
  9. package/dist/emitters/jsonSchema.js.map +1 -0
  10. package/dist/emitters/sqlDdl.d.ts +29 -0
  11. package/dist/emitters/sqlDdl.js +1741 -0
  12. package/dist/emitters/sqlDdl.js.map +1 -0
  13. package/dist/emitters/typeorm.js +53 -1
  14. package/dist/emitters/typeorm.js.map +1 -1
  15. package/dist/emitters/zod.d.ts +40 -0
  16. package/dist/emitters/zod.js +498 -0
  17. package/dist/emitters/zod.js.map +1 -0
  18. package/dist/formats.d.ts +4 -1
  19. package/dist/formats.js +62 -0
  20. package/dist/formats.js.map +1 -1
  21. package/dist/index.d.ts +14 -0
  22. package/dist/index.js +7 -0
  23. package/dist/index.js.map +1 -1
  24. package/dist/io.js +56 -8
  25. package/dist/io.js.map +1 -1
  26. package/dist/parsers/drizzle.d.ts +12 -0
  27. package/dist/parsers/drizzle.js +2040 -0
  28. package/dist/parsers/drizzle.js.map +1 -0
  29. package/dist/parsers/jsonSchema.d.ts +21 -0
  30. package/dist/parsers/jsonSchema.js +1432 -0
  31. package/dist/parsers/jsonSchema.js.map +1 -0
  32. package/dist/parsers/sqlDdl.d.ts +18 -0
  33. package/dist/parsers/sqlDdl.js +2302 -0
  34. package/dist/parsers/sqlDdl.js.map +1 -0
  35. package/dist/parsers/sqlSyntax.d.ts +79 -0
  36. package/dist/parsers/sqlSyntax.js +698 -0
  37. package/dist/parsers/sqlSyntax.js.map +1 -0
  38. package/dist/parsers/sqlTypes.d.ts +53 -0
  39. package/dist/parsers/sqlTypes.js +742 -0
  40. package/dist/parsers/sqlTypes.js.map +1 -0
  41. package/dist/parsers/typeorm.js +22 -7
  42. package/dist/parsers/typeorm.js.map +1 -1
  43. package/dist/prismaOnlyConstructs.d.ts +2 -2
  44. package/dist/prismaOnlyConstructs.js +5 -5
  45. package/dist/prismaOnlyConstructs.js.map +1 -1
  46. package/package.json +2 -1
@@ -0,0 +1,1432 @@
1
+ /**
2
+ * JSON Schema parser. Reads JSON Schema (draft 2020-12, 2019-09 and 7) and OpenAPI 3.x documents
3
+ * (`components.schemas`) into the shared IR. The input is plain JSON, so no tree-sitter grammar is
4
+ * needed; YAML is not read because no YAML parser is a dependency of this package.
5
+ *
6
+ * Each object schema under `$defs`, `definitions` or `components.schemas` (or a root object schema)
7
+ * is a model. A property whose schema is a `$ref` to another model is a relation. See the README
8
+ * section "JSON Schema (`--from json-schema`)" for the complete mapping.
9
+ */
10
+ import { resolve } from 'node:path';
11
+ import { pathToFileURL } from 'node:url';
12
+ import { splitWords, toPascalCase, toSnakeCase } from '../naming.js';
13
+ import { describeThrown, err, ok } from '../result.js';
14
+ /** Maximum depth of `$ref` chains, `allOf` nesting and array nesting that is followed. */
15
+ const MAX_DEPTH = 32;
16
+ /** Schema collections that hold named schemas, as JSON pointer segments. */
17
+ const DEFINITION_COLLECTIONS = [
18
+ ['$defs'],
19
+ ['definitions'],
20
+ ['components', 'schemas'],
21
+ ];
22
+ function asRecord(value) {
23
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
24
+ ? value
25
+ : undefined;
26
+ }
27
+ function asString(value) {
28
+ return typeof value === 'string' ? value : undefined;
29
+ }
30
+ function asNumber(value) {
31
+ return typeof value === 'number' && Number.isFinite(value)
32
+ ? value
33
+ : undefined;
34
+ }
35
+ function asRecordOrEmpty(value) {
36
+ return asRecord(value) ?? {};
37
+ }
38
+ function omitKey(record, key) {
39
+ const copy = { ...record };
40
+ delete copy[key];
41
+ return copy;
42
+ }
43
+ function escapePointerSegment(segment) {
44
+ return segment.replace(/~/g, '~0').replace(/\//g, '~1');
45
+ }
46
+ function pointerOf(segments) {
47
+ return `#${segments.map((segment) => `/${escapePointerSegment(segment)}`).join('')}`;
48
+ }
49
+ function lastSegment(pointer) {
50
+ const parts = pointer.split('/');
51
+ const last = parts[parts.length - 1] ?? '';
52
+ return last.replace(/~1/g, '/').replace(/~0/g, '~');
53
+ }
54
+ // ---------------------------------------------------------------------------
55
+ // Entry point
56
+ // ---------------------------------------------------------------------------
57
+ /** Reads JSON Schema / OpenAPI documents into the IR. Never throws. */
58
+ export function parseJsonSchema(sources, options) {
59
+ try {
60
+ return parseDocuments(sources, options);
61
+ }
62
+ catch (thrown) {
63
+ return err('PARSE_FAILED', `The JSON Schema input could not be converted: ${describeThrown(thrown)}`);
64
+ }
65
+ }
66
+ function parseDocuments(sources, options) {
67
+ const ctx = {
68
+ options,
69
+ warnings: [],
70
+ registry: new Map(),
71
+ modelsByKey: new Map(),
72
+ enums: new Map(),
73
+ enumNameByKey: new Map(),
74
+ };
75
+ const docs = [];
76
+ for (const source of sources) {
77
+ const doc = readDocument(source, ctx);
78
+ if (!doc.ok) {
79
+ return doc;
80
+ }
81
+ docs.push(doc.value);
82
+ }
83
+ const infos = discoverModels(docs, ctx);
84
+ for (const doc of docs) {
85
+ registerEnumDefinitions(doc, ctx);
86
+ }
87
+ if (infos.length === 0) {
88
+ return err('NO_MODELS_FOUND', `No object schemas with properties were found in: ${sources.map((source) => source.path).join(', ')}. ` +
89
+ 'A model is an object schema with properties under $defs, definitions or components.schemas, or a root object schema.');
90
+ }
91
+ const built = infos.map((info) => buildModel(info, ctx));
92
+ pairRelations(built, ctx);
93
+ const models = built.map((model) => finishModel(model, ctx));
94
+ return ok({
95
+ models,
96
+ enums: [...ctx.enums.values()],
97
+ warnings: ctx.warnings,
98
+ });
99
+ }
100
+ // ---------------------------------------------------------------------------
101
+ // Documents and references
102
+ // ---------------------------------------------------------------------------
103
+ function readDocument(source, ctx) {
104
+ const text = source.text.charCodeAt(0) === 0xfeff ? source.text.slice(1) : source.text;
105
+ let parsed;
106
+ try {
107
+ parsed = JSON.parse(text);
108
+ }
109
+ catch (thrown) {
110
+ const isYaml = /\.ya?ml$/i.test(source.path);
111
+ return err('PARSE_FAILED', `${source.path}: not valid JSON (${describeThrown(thrown)}).` +
112
+ (isYaml
113
+ ? ' YAML is not supported because the package has no YAML parser; convert the document to JSON first.'
114
+ : ''));
115
+ }
116
+ const root = asRecord(parsed);
117
+ if (root === undefined) {
118
+ return err('PARSE_FAILED', `${source.path}: expected a JSON object at the top level (a JSON Schema or OpenAPI document), found ${Array.isArray(parsed) ? 'an array' : typeof parsed}.`);
119
+ }
120
+ const fileUri = pathToFileURL(resolve(source.path)).href;
121
+ let baseUri = fileUri;
122
+ const id = asString(root.$id);
123
+ if (id !== undefined) {
124
+ try {
125
+ baseUri = new URL(id, fileUri).href.split('#')[0] ?? fileUri;
126
+ }
127
+ catch {
128
+ ctx.warnings.push(`${source.path}: the $id "${id}" is not a valid URI reference and was ignored.`);
129
+ }
130
+ }
131
+ const doc = {
132
+ path: source.path,
133
+ fileUri,
134
+ baseUri,
135
+ root,
136
+ isOpenApi: typeof root.openapi === 'string' || typeof root.swagger === 'string',
137
+ };
138
+ for (const uri of new Set([fileUri, baseUri])) {
139
+ const existing = ctx.registry.get(uri);
140
+ if (existing !== undefined && existing !== doc) {
141
+ ctx.warnings.push(`${source.path}: the identifier ${uri} is also used by ${existing.path}; references to it resolve to the first file.`);
142
+ continue;
143
+ }
144
+ ctx.registry.set(uri, doc);
145
+ }
146
+ return ok(doc);
147
+ }
148
+ function navigate(doc, segments) {
149
+ let current = doc.root;
150
+ for (const segment of segments) {
151
+ if (Array.isArray(current)) {
152
+ const index = Number(segment);
153
+ if (!Number.isInteger(index) || index < 0 || index >= current.length) {
154
+ return { found: false };
155
+ }
156
+ current = current[index];
157
+ }
158
+ else {
159
+ const record = asRecord(current);
160
+ if (record === undefined || !Object.hasOwn(record, segment)) {
161
+ return { found: false };
162
+ }
163
+ current = record[segment];
164
+ }
165
+ }
166
+ return { found: true, value: current };
167
+ }
168
+ function resolveRef(ctx, ref, from) {
169
+ let target;
170
+ let hash = '';
171
+ let attempted = '';
172
+ for (const base of new Set([from.baseUri, from.fileUri])) {
173
+ let url;
174
+ try {
175
+ url = new URL(ref, base);
176
+ }
177
+ catch {
178
+ continue;
179
+ }
180
+ const withoutHash = url.href.split('#')[0] ?? '';
181
+ attempted = attempted === '' ? withoutHash : attempted;
182
+ const found = ctx.registry.get(withoutHash);
183
+ if (found !== undefined) {
184
+ target = found;
185
+ hash = url.hash;
186
+ break;
187
+ }
188
+ }
189
+ if (target === undefined) {
190
+ return {
191
+ ok: false,
192
+ reason: attempted === ''
193
+ ? `the reference "${ref}" is not a valid URI reference`
194
+ : `the reference "${ref}" points to ${attempted}, which is not one of the input files (remote references are not fetched)`,
195
+ };
196
+ }
197
+ let fragment = hash.startsWith('#') ? hash.slice(1) : hash;
198
+ try {
199
+ fragment = decodeURIComponent(fragment);
200
+ }
201
+ catch {
202
+ return {
203
+ ok: false,
204
+ reason: `the reference "${ref}" has an invalid escape sequence in its fragment`,
205
+ };
206
+ }
207
+ if (fragment !== '' && !fragment.startsWith('/')) {
208
+ return {
209
+ ok: false,
210
+ reason: `the reference "${ref}" uses a named anchor, which is not supported (use a JSON pointer such as #/$defs/Name)`,
211
+ };
212
+ }
213
+ const segments = fragment === ''
214
+ ? []
215
+ : fragment
216
+ .slice(1)
217
+ .split('/')
218
+ .map((segment) => segment.replace(/~1/g, '/').replace(/~0/g, '~'));
219
+ const navigated = navigate(target, segments);
220
+ if (!navigated.found) {
221
+ return {
222
+ ok: false,
223
+ reason: `the reference "${ref}" does not resolve to anything in ${target.path}`,
224
+ };
225
+ }
226
+ const schema = typeof navigated.value === 'boolean' ? {} : asRecord(navigated.value);
227
+ if (schema === undefined) {
228
+ return {
229
+ ok: false,
230
+ reason: `the reference "${ref}" does not point to a schema`,
231
+ };
232
+ }
233
+ return {
234
+ ok: true,
235
+ located: { doc: target, pointer: pointerOf(segments), schema },
236
+ };
237
+ }
238
+ function locationKey(located) {
239
+ return `${located.doc.fileUri}${located.pointer}`;
240
+ }
241
+ // ---------------------------------------------------------------------------
242
+ // Models
243
+ // ---------------------------------------------------------------------------
244
+ /** Merges `allOf` members and sibling `$ref`s into one object shape. */
245
+ function collectShape(schema, doc, ctx, seen, depth) {
246
+ const shape = {
247
+ properties: new Map(),
248
+ required: new Set(),
249
+ inherited: [],
250
+ hasUnion: false,
251
+ hasPatternProperties: false,
252
+ hasAdditionalSchema: false,
253
+ hasConditional: false,
254
+ };
255
+ if (seen.has(schema) || depth > MAX_DEPTH) {
256
+ return shape;
257
+ }
258
+ seen.add(schema);
259
+ const parents = [];
260
+ const ref = asString(schema.$ref);
261
+ if (ref !== undefined) {
262
+ parents.push({ $ref: ref });
263
+ }
264
+ if (Array.isArray(schema.allOf)) {
265
+ parents.push(...schema.allOf);
266
+ }
267
+ for (const parent of parents) {
268
+ const member = asRecord(parent);
269
+ if (member === undefined) {
270
+ continue;
271
+ }
272
+ let memberSchema = member;
273
+ let memberDoc = doc;
274
+ const memberRef = asString(member.$ref);
275
+ if (memberRef !== undefined) {
276
+ const resolved = resolveRef(ctx, memberRef, doc);
277
+ if (!resolved.ok) {
278
+ ctx.warnings.push(`${doc.path}: allOf member skipped because ${resolved.reason}.`);
279
+ continue;
280
+ }
281
+ memberSchema = resolved.located.schema;
282
+ memberDoc = resolved.located.doc;
283
+ const inheritedFrom = ctx.modelsByKey.get(locationKey(resolved.located));
284
+ shape.inherited.push(inheritedFrom?.name ?? lastSegment(resolved.located.pointer));
285
+ }
286
+ const inner = collectShape(memberSchema, memberDoc, ctx, seen, depth + 1);
287
+ for (const [name, value] of inner.properties) {
288
+ shape.properties.set(name, value);
289
+ }
290
+ for (const name of inner.required) {
291
+ shape.required.add(name);
292
+ }
293
+ shape.inherited.push(...inner.inherited);
294
+ shape.hasUnion ||= inner.hasUnion;
295
+ shape.hasPatternProperties ||= inner.hasPatternProperties;
296
+ shape.hasAdditionalSchema ||= inner.hasAdditionalSchema;
297
+ shape.hasConditional ||= inner.hasConditional;
298
+ }
299
+ const properties = asRecord(schema.properties);
300
+ if (properties !== undefined) {
301
+ for (const [name, value] of Object.entries(properties)) {
302
+ shape.properties.set(name, { schema: value, doc });
303
+ }
304
+ }
305
+ if (Array.isArray(schema.required)) {
306
+ for (const name of schema.required) {
307
+ if (typeof name === 'string') {
308
+ shape.required.add(name);
309
+ }
310
+ }
311
+ }
312
+ shape.hasUnion ||=
313
+ (Array.isArray(schema.oneOf) || Array.isArray(schema.anyOf)) &&
314
+ properties === undefined;
315
+ shape.hasPatternProperties ||=
316
+ asRecord(schema.patternProperties) !== undefined;
317
+ shape.hasAdditionalSchema ||=
318
+ asRecord(schema.additionalProperties) !== undefined;
319
+ shape.hasConditional ||=
320
+ schema.if !== undefined ||
321
+ schema.then !== undefined ||
322
+ schema.else !== undefined;
323
+ return shape;
324
+ }
325
+ function sanitizeModelName(raw) {
326
+ const cleaned = raw
327
+ .replace(/[^A-Za-z0-9_]+/g, '_')
328
+ .replace(/^_+|_+$/g, '');
329
+ const name = cleaned === '' ? 'Model' : cleaned;
330
+ return /^[0-9]/.test(name) ? `_${name}` : name;
331
+ }
332
+ function rootModelName(doc) {
333
+ const title = asString(doc.root.title);
334
+ if (title !== undefined && title.trim() !== '') {
335
+ const pascal = toPascalCase(title);
336
+ return pascal === '' ? sanitizeModelName(title) : pascal;
337
+ }
338
+ const idSource = asString(doc.root.$id) ?? doc.path;
339
+ const segments = idSource.split(/[\\/]/).filter((s) => s !== '');
340
+ const file = (segments[segments.length - 1] ?? 'Root')
341
+ .replace(/#.*$/, '')
342
+ .replace(/(\.schema)?\.json$/i, '');
343
+ const pascal = toPascalCase(file);
344
+ return pascal === '' ? 'Root' : pascal;
345
+ }
346
+ function isEnumLike(schema) {
347
+ return Array.isArray(schema.enum) || allConstMembers(schema) !== undefined;
348
+ }
349
+ /** The `{const, title}` members of a `oneOf`/`anyOf` that only lists constants (an enum spelled with labels). */
350
+ function allConstMembers(schema) {
351
+ for (const keyword of ['oneOf', 'anyOf']) {
352
+ const members = schema[keyword];
353
+ if (Array.isArray(members) &&
354
+ members.length > 0 &&
355
+ members.every((member) => asRecord(member) !== undefined &&
356
+ Object.hasOwn(member, 'const'))) {
357
+ return members;
358
+ }
359
+ }
360
+ return undefined;
361
+ }
362
+ /** The schemas of a document that may be models: the root and every named schema. */
363
+ function candidateSchemas(doc) {
364
+ const candidates = [];
365
+ if (!doc.isOpenApi) {
366
+ candidates.push({ doc, pointer: '#', schema: doc.root });
367
+ }
368
+ for (const collection of DEFINITION_COLLECTIONS) {
369
+ const navigated = navigate(doc, [...collection]);
370
+ const entries = navigated.found
371
+ ? asRecord(navigated.value)
372
+ : undefined;
373
+ if (entries === undefined) {
374
+ continue;
375
+ }
376
+ for (const [key, value] of Object.entries(entries)) {
377
+ const schema = asRecord(value);
378
+ if (schema !== undefined) {
379
+ candidates.push({
380
+ doc,
381
+ pointer: pointerOf([...collection, key]),
382
+ schema,
383
+ });
384
+ }
385
+ }
386
+ }
387
+ return candidates;
388
+ }
389
+ /** A oneOf/anyOf whose alternatives are references (a polymorphic type), apart from a null alternative. */
390
+ function isUnionOfReferences(schema) {
391
+ for (const keyword of ['oneOf', 'anyOf']) {
392
+ const members = schema[keyword];
393
+ if (Array.isArray(members)) {
394
+ const references = members.filter((member) => asString(asRecord(member)?.$ref) !== undefined).length;
395
+ if (references >= 2) {
396
+ return true;
397
+ }
398
+ }
399
+ }
400
+ return false;
401
+ }
402
+ function looksLikeObject(schema) {
403
+ return (schema.type === 'object' ||
404
+ (Array.isArray(schema.type) && schema.type.includes('object')) ||
405
+ schema.properties !== undefined ||
406
+ Array.isArray(schema.allOf));
407
+ }
408
+ function discoverModels(docs, ctx) {
409
+ const pending = [];
410
+ // Pass 1 registers every candidate, so that allOf parents (in any file) are recognised as models.
411
+ for (const doc of docs) {
412
+ for (const located of candidateSchemas(doc)) {
413
+ if (isEnumLike(located.schema)) {
414
+ continue;
415
+ }
416
+ const name = located.pointer === '#'
417
+ ? rootModelName(doc)
418
+ : sanitizeModelName(lastSegment(located.pointer));
419
+ if (!looksLikeObject(located.schema)) {
420
+ if (located.pointer !== '#' && isUnionOfReferences(located.schema)) {
421
+ ctx.warnings.push(`${name}: a oneOf/anyOf schema has no database meaning, so it was not converted to a model.`);
422
+ }
423
+ continue;
424
+ }
425
+ const shape = collectShape(located.schema, doc, ctx, new Set(), 0);
426
+ if (shape.properties.size === 0) {
427
+ if (located.pointer !== '#' &&
428
+ (shape.hasUnion || located.schema.discriminator !== undefined)) {
429
+ ctx.warnings.push(`${name}: a oneOf/anyOf schema has no database meaning, so it was not converted to a model.`);
430
+ }
431
+ continue;
432
+ }
433
+ pending.push({ located, name, shape });
434
+ ctx.modelsByKey.set(locationKey(located), {
435
+ name,
436
+ key: locationKey(located),
437
+ doc,
438
+ schema: located.schema,
439
+ shape,
440
+ });
441
+ }
442
+ }
443
+ // Pass 2 settles the names and re-reads the shapes now that the parents are known.
444
+ const found = [];
445
+ const takenNames = new Set();
446
+ for (const entry of pending) {
447
+ let name = entry.name;
448
+ if (takenNames.has(name)) {
449
+ let counter = 2;
450
+ while (takenNames.has(`${entry.name}${counter}`)) {
451
+ counter += 1;
452
+ }
453
+ name = `${entry.name}${counter}`;
454
+ ctx.warnings.push(`${name}: the model name "${entry.name}" is already used by another schema, so this one (${entry.located.doc.path}${entry.located.pointer}) was renamed.`);
455
+ }
456
+ takenNames.add(name);
457
+ const key = locationKey(entry.located);
458
+ const info = {
459
+ name,
460
+ key,
461
+ doc: entry.located.doc,
462
+ schema: entry.located.schema,
463
+ shape: entry.shape,
464
+ };
465
+ ctx.modelsByKey.set(key, info);
466
+ found.push(info);
467
+ }
468
+ for (const info of found) {
469
+ info.shape = collectShape(info.schema, info.doc, ctx, new Set(), 0);
470
+ }
471
+ return found;
472
+ }
473
+ function registerEnumDefinitions(doc, ctx) {
474
+ for (const collection of DEFINITION_COLLECTIONS) {
475
+ const navigated = navigate(doc, [...collection]);
476
+ const entries = navigated.found
477
+ ? asRecord(navigated.value)
478
+ : undefined;
479
+ if (entries === undefined) {
480
+ continue;
481
+ }
482
+ for (const [key, value] of Object.entries(entries)) {
483
+ const schema = asRecord(value);
484
+ if (schema === undefined || !isEnumLike(schema)) {
485
+ continue;
486
+ }
487
+ const pointer = pointerOf([...collection, key]);
488
+ const described = {
489
+ schema,
490
+ doc,
491
+ defName: key,
492
+ defKey: `${doc.fileUri}${pointer}`,
493
+ nullable: false,
494
+ };
495
+ enumFor(ctx, described, `${sanitizeModelName(key)}`, key, undefined);
496
+ }
497
+ }
498
+ }
499
+ // ---------------------------------------------------------------------------
500
+ // Enums
501
+ // ---------------------------------------------------------------------------
502
+ function enumMemberName(value, index) {
503
+ const words = splitWords(value);
504
+ if (words.length === 0) {
505
+ return value === '' ? 'EMPTY' : `VALUE_${index + 1}`;
506
+ }
507
+ const name = words
508
+ .map((word) => word.toUpperCase())
509
+ .join('_');
510
+ return /^[0-9]/.test(name) ? `_${name}` : name;
511
+ }
512
+ /** Reads the string members of an enum schema, or undefined when it is not a string enum. */
513
+ function readEnumValues(schema) {
514
+ const constMembers = allConstMembers(schema);
515
+ let raw;
516
+ let labels = [];
517
+ if (Array.isArray(schema.enum)) {
518
+ raw = schema.enum;
519
+ }
520
+ else if (constMembers !== undefined) {
521
+ raw = constMembers.map((member) => member.const);
522
+ labels = constMembers.map((member) => asString(member.title));
523
+ }
524
+ else {
525
+ return undefined;
526
+ }
527
+ const hasNull = raw.includes(null);
528
+ const strings = raw.filter((value) => value !== null);
529
+ if (strings.length === 0 ||
530
+ !strings.every((value) => typeof value === 'string')) {
531
+ return undefined;
532
+ }
533
+ const varNames = schema['x-enum-varnames'] ??
534
+ schema['x-enumNames'] ??
535
+ schema['x-enum-names'];
536
+ const seen = new Set();
537
+ const values = [];
538
+ strings.forEach((value, index) => {
539
+ if (values.some((existing) => existing.dbValue === value)) {
540
+ return;
541
+ }
542
+ const given = Array.isArray(varNames)
543
+ ? varNames[index]
544
+ : undefined;
545
+ let name = typeof given === 'string' && given !== ''
546
+ ? given
547
+ : enumMemberName(value, index);
548
+ while (seen.has(name)) {
549
+ name = `${name}_${index + 1}`;
550
+ }
551
+ seen.add(name);
552
+ const label = labels[index];
553
+ values.push({
554
+ name,
555
+ dbValue: value,
556
+ ...(label === undefined ? {} : { label }),
557
+ });
558
+ });
559
+ return { values, hasNull };
560
+ }
561
+ /**
562
+ * Returns the IR enum for an enum schema, creating it on first use. `preferred` is the name to try first
563
+ * (the `$defs` entry or the property); `modelName` disambiguates a clash.
564
+ */
565
+ function enumFor(ctx, described, preferred, location, modelName) {
566
+ const read = readEnumValues(described.schema);
567
+ if (read === undefined) {
568
+ return undefined;
569
+ }
570
+ if (described.defKey !== undefined) {
571
+ const known = ctx.enumNameByKey.get(described.defKey);
572
+ if (known !== undefined) {
573
+ return known;
574
+ }
575
+ }
576
+ const sameValues = (other) => other.values.length === read.values.length &&
577
+ other.values.every((value, index) => value.dbValue === read.values[index]?.dbValue);
578
+ const base = sanitizeModelName(described.defName === undefined
579
+ ? toPascalCase(preferred) || preferred
580
+ : preferred);
581
+ const candidates = [
582
+ base,
583
+ ...(modelName === undefined ? [] : [`${modelName}${toPascalCase(base)}`]),
584
+ ];
585
+ let chosen;
586
+ for (const candidate of candidates) {
587
+ const existing = ctx.enums.get(candidate);
588
+ if (existing === undefined ||
589
+ (described.defKey === undefined && sameValues(existing))) {
590
+ chosen = candidate;
591
+ break;
592
+ }
593
+ }
594
+ if (chosen === undefined) {
595
+ let counter = 2;
596
+ const stem = candidates[candidates.length - 1] ?? base;
597
+ while (ctx.enums.has(`${stem}${counter}`)) {
598
+ counter += 1;
599
+ }
600
+ chosen = `${stem}${counter}`;
601
+ ctx.warnings.push(`${location}: the enum name "${base}" is already used with different values, so this enum was named "${chosen}".`);
602
+ }
603
+ if (!ctx.enums.has(chosen)) {
604
+ ctx.enums.set(chosen, { name: chosen, values: read.values });
605
+ }
606
+ if (described.defKey !== undefined) {
607
+ ctx.enumNameByKey.set(described.defKey, chosen);
608
+ }
609
+ return chosen;
610
+ }
611
+ // ---------------------------------------------------------------------------
612
+ // Describing a property schema
613
+ // ---------------------------------------------------------------------------
614
+ /** Merges `allOf` members of a schema that is not a model into one schema (first `$ref` wins). */
615
+ function flattenAllOf(schema, depth) {
616
+ if (!Array.isArray(schema.allOf) || depth > MAX_DEPTH) {
617
+ return schema;
618
+ }
619
+ const { allOf, ...rest } = schema;
620
+ let merged = {};
621
+ for (const member of allOf) {
622
+ const record = asRecord(member);
623
+ if (record === undefined) {
624
+ continue;
625
+ }
626
+ const flat = flattenAllOf(record, depth + 1);
627
+ const others = omitKey(flat, '$ref');
628
+ merged = {
629
+ ...merged,
630
+ ...others,
631
+ ...(merged.$ref === undefined && flat.$ref !== undefined
632
+ ? { $ref: flat.$ref }
633
+ : {}),
634
+ };
635
+ }
636
+ return { ...merged, ...rest };
637
+ }
638
+ function describeSchema(ctx, schemaValue, startDoc, location) {
639
+ let current = asRecordOrEmpty(schemaValue);
640
+ let doc = startDoc;
641
+ let nullable = false;
642
+ let defName;
643
+ let defKey;
644
+ const visited = new Set();
645
+ for (let step = 0; step <= MAX_DEPTH; step += 1) {
646
+ current = flattenAllOf(current, 0);
647
+ if (current.nullable === true) {
648
+ nullable = true;
649
+ }
650
+ // `anyOf`/`oneOf` of one schema and null is how nullable references are written.
651
+ for (const keyword of ['anyOf', 'oneOf']) {
652
+ const members = current[keyword];
653
+ if (!Array.isArray(members)) {
654
+ continue;
655
+ }
656
+ const nonNull = members.filter((member) => asRecord(member)?.type !== 'null');
657
+ if (nonNull.length === 1 && nonNull.length < members.length) {
658
+ current = {
659
+ ...omitKey(current, keyword),
660
+ ...asRecordOrEmpty(nonNull[0]),
661
+ };
662
+ nullable = true;
663
+ }
664
+ }
665
+ const ref = asString(current.$ref);
666
+ if (ref === undefined) {
667
+ return { schema: current, doc, defName, defKey, nullable };
668
+ }
669
+ const resolved = resolveRef(ctx, ref, doc);
670
+ if (!resolved.ok) {
671
+ return {
672
+ schema: {},
673
+ doc,
674
+ nullable,
675
+ failure: `${location}: ${resolved.reason}; the property was kept as a json column.`,
676
+ };
677
+ }
678
+ const key = locationKey(resolved.located);
679
+ const model = ctx.modelsByKey.get(key);
680
+ if (model !== undefined) {
681
+ return { model, schema: current, doc, nullable };
682
+ }
683
+ if (visited.has(key)) {
684
+ return {
685
+ schema: {},
686
+ doc,
687
+ nullable,
688
+ failure: `${location}: the reference "${ref}" is part of a reference cycle that never reaches an object schema; the property was kept as a json column.`,
689
+ };
690
+ }
691
+ visited.add(key);
692
+ current = { ...resolved.located.schema, ...omitKey(current, '$ref') };
693
+ doc = resolved.located.doc;
694
+ defName = lastSegment(resolved.located.pointer);
695
+ defKey = key;
696
+ }
697
+ return {
698
+ schema: {},
699
+ doc,
700
+ nullable,
701
+ failure: `${location}: references are nested more than ${MAX_DEPTH} levels deep; the property was kept as a json column.`,
702
+ };
703
+ }
704
+ /**
705
+ * Integers and decimals travel as strings in JSON so no precision is lost; the pattern says which.
706
+ * Reads `^-?[0-9]+$` (bigInt), `^-?[0-9]+(\.[0-9]+)?$` (decimal) and the same with a digit limit
707
+ * (`^-?[0-9]{1,6}(\.[0-9]{1,2})?$` is decimal(8, 2)), which is how ormbridge's own JSON Schema
708
+ * emitter writes them.
709
+ */
710
+ function numericPattern(pattern) {
711
+ if (pattern === undefined) {
712
+ return undefined;
713
+ }
714
+ if (pattern === '^-?[0-9]+$') {
715
+ return { type: 'bigInt' };
716
+ }
717
+ if (pattern === '^-?[0-9]+(\\.[0-9]+)?$') {
718
+ return { type: 'decimal' };
719
+ }
720
+ const limited = /^\^-\?(?:\[0-9\]\{1,(\d+)\}|0\?)(?:\(\\\.\[0-9\]\{1,(\d+)\}\)\?)?\$$/.exec(pattern);
721
+ if (limited === null) {
722
+ return undefined;
723
+ }
724
+ const whole = Number(limited[1] ?? '0');
725
+ const places = Number(limited[2] ?? '0');
726
+ return { type: 'decimal', maxDigits: whole + places, decimalPlaces: places };
727
+ }
728
+ /** Maps a scalar `type`/`format` pair to an IR type. */
729
+ function scalarType(type, schema) {
730
+ const format = asString(schema.format) ?? '';
731
+ if (type === 'string' &&
732
+ (schema.contentEncoding === 'base64' ||
733
+ format === 'byte' ||
734
+ format === 'binary')) {
735
+ return { type: 'bytes' };
736
+ }
737
+ if (type === 'string' && schema.contentMediaType === 'application/json') {
738
+ return { type: 'json' };
739
+ }
740
+ const patterned = type === 'string' ? numericPattern(asString(schema.pattern)) : undefined;
741
+ if (patterned !== undefined && format === '') {
742
+ return patterned;
743
+ }
744
+ if (format === 'decimal' || format === 'money') {
745
+ const precision = asNumber(schema['x-precision']);
746
+ const scale = asNumber(schema['x-scale']);
747
+ const multiple = asNumber(schema.multipleOf);
748
+ const derivedScale = multiple !== undefined && multiple > 0 && multiple < 1
749
+ ? Math.max(0, Math.round(-Math.log10(multiple)))
750
+ : undefined;
751
+ return {
752
+ type: 'decimal',
753
+ ...(precision === undefined ? {} : { maxDigits: precision }),
754
+ ...(scale !== undefined
755
+ ? { decimalPlaces: scale }
756
+ : derivedScale === undefined
757
+ ? {}
758
+ : { decimalPlaces: derivedScale }),
759
+ };
760
+ }
761
+ switch (type) {
762
+ case 'string':
763
+ switch (format) {
764
+ case 'date-time':
765
+ return { type: 'dateTime' };
766
+ case 'date':
767
+ return { type: 'date' };
768
+ case 'time':
769
+ return { type: 'time' };
770
+ case 'uuid':
771
+ return { type: 'uuid' };
772
+ case 'duration':
773
+ return { type: 'duration' };
774
+ case 'ipv4':
775
+ case 'ipv6':
776
+ return { type: 'ipAddress' };
777
+ case '':
778
+ // A string with no length limit is unbounded text.
779
+ return { type: schema.maxLength === undefined ? 'text' : 'string' };
780
+ default:
781
+ return { type: 'string' };
782
+ }
783
+ case 'integer':
784
+ return {
785
+ type: format === 'int64' || format === 'uint64' || format === 'unix-time'
786
+ ? 'bigInt'
787
+ : 'int',
788
+ };
789
+ case 'number':
790
+ return {
791
+ type: format === 'int32' ? 'int' : format === 'int64' ? 'bigInt' : 'float',
792
+ };
793
+ case 'boolean':
794
+ return { type: 'boolean' };
795
+ default:
796
+ return { type: 'json' };
797
+ }
798
+ }
799
+ /** Works out the column type of a described schema (never a model). */
800
+ function scalarOf(ctx, described, location, modelName, propertyName, depth) {
801
+ const schema = described.schema;
802
+ if (described.failure !== undefined) {
803
+ ctx.warnings.push(described.failure);
804
+ return { type: 'json' };
805
+ }
806
+ const read = readEnumValues(schema);
807
+ if (read !== undefined) {
808
+ const name = enumFor(ctx, described, described.defName ?? propertyName, location, modelName);
809
+ if (name !== undefined) {
810
+ const maxLength = asNumber(schema.maxLength);
811
+ return {
812
+ type: 'string',
813
+ enumName: name,
814
+ ...(maxLength === undefined ? {} : { maxLength }),
815
+ };
816
+ }
817
+ }
818
+ if (Array.isArray(schema.enum) || allConstMembers(schema) !== undefined) {
819
+ ctx.warnings.push(`${location}: only enums of strings are supported, so the allowed values were dropped and the column keeps its plain type.`);
820
+ }
821
+ const oneOf = schema.oneOf ?? schema.anyOf;
822
+ if (Array.isArray(oneOf) && allConstMembers(schema) === undefined) {
823
+ ctx.warnings.push(`${location}: a oneOf/anyOf union has no database meaning, so the property was kept as a json column.`);
824
+ return { type: 'json' };
825
+ }
826
+ const rawType = schema.type;
827
+ let type;
828
+ if (Array.isArray(rawType)) {
829
+ const kinds = rawType.filter((kind) => typeof kind === 'string' && kind !== 'null');
830
+ if (kinds.length === 1) {
831
+ type = kinds[0];
832
+ }
833
+ else if (kinds.length > 1) {
834
+ ctx.warnings.push(`${location}: the type union ${JSON.stringify(rawType)} has no database meaning, so the property was kept as a json column.`);
835
+ return { type: 'json' };
836
+ }
837
+ }
838
+ else if (typeof rawType === 'string') {
839
+ type = rawType;
840
+ }
841
+ if (type === undefined) {
842
+ if (schema.properties !== undefined) {
843
+ type = 'object';
844
+ }
845
+ else if (schema.items !== undefined || schema.prefixItems !== undefined) {
846
+ type = 'array';
847
+ }
848
+ else if (Object.hasOwn(schema, 'const')) {
849
+ const constant = schema.const;
850
+ type =
851
+ typeof constant === 'string'
852
+ ? 'string'
853
+ : typeof constant === 'number'
854
+ ? Number.isInteger(constant)
855
+ ? 'integer'
856
+ : 'number'
857
+ : typeof constant === 'boolean'
858
+ ? 'boolean'
859
+ : undefined;
860
+ }
861
+ }
862
+ if (type === 'array') {
863
+ return arrayOf(ctx, schema, described, location, modelName, propertyName, depth);
864
+ }
865
+ if (type === 'object') {
866
+ if (asRecord(schema.patternProperties) !== undefined) {
867
+ ctx.warnings.push(`${location}: patternProperties has no database meaning; the object was stored as a json column.`);
868
+ }
869
+ else if (asRecord(schema.additionalProperties) !== undefined) {
870
+ ctx.warnings.push(`${location}: an object used as a map (additionalProperties) has no database meaning; it was stored as a json column.`);
871
+ }
872
+ else if (schema.properties !== undefined) {
873
+ ctx.warnings.push(`${location}: a nested inline object was stored as a json column; its properties are not modelled. Define it under $defs to make it a model.`);
874
+ }
875
+ return { type: 'json' };
876
+ }
877
+ if (type === undefined) {
878
+ return { type: 'json' };
879
+ }
880
+ const mapped = scalarType(type, schema);
881
+ const maxLength = asNumber(schema.maxLength);
882
+ return {
883
+ ...mapped,
884
+ ...(maxLength !== undefined && mapped.type === 'string'
885
+ ? { maxLength }
886
+ : {}),
887
+ };
888
+ }
889
+ function arrayOf(ctx, schema, described, location, modelName, propertyName, depth) {
890
+ if (depth >= MAX_DEPTH || asRecord(schema.items) === undefined) {
891
+ ctx.warnings.push(`${location}: an array without a single item schema was stored as a json column.`);
892
+ return { type: 'json' };
893
+ }
894
+ const element = describeSchema(ctx, schema.items, described.doc, location);
895
+ if (element.model !== undefined) {
896
+ ctx.warnings.push(`${location}: arrays of models are only supported as a relation property; here the array was stored as a json column.`);
897
+ return { type: 'json' };
898
+ }
899
+ const inner = scalarOf(ctx, element, location, modelName, propertyName, depth + 1);
900
+ if (inner.type === 'json' && inner.arrayDepth === undefined) {
901
+ return { type: 'json' };
902
+ }
903
+ return { ...inner, arrayDepth: (inner.arrayDepth ?? 0) + 1 };
904
+ }
905
+ // ---------------------------------------------------------------------------
906
+ // Building models
907
+ // ---------------------------------------------------------------------------
908
+ function toOnDelete(value) {
909
+ if (typeof value !== 'string') {
910
+ return undefined;
911
+ }
912
+ switch (value.toLowerCase().replace(/[\s_-]+/g, '')) {
913
+ case 'cascade':
914
+ return 'cascade';
915
+ case 'setnull':
916
+ return 'setNull';
917
+ case 'restrict':
918
+ return 'restrict';
919
+ case 'noaction':
920
+ return 'noAction';
921
+ case 'setdefault':
922
+ return 'setDefault';
923
+ default:
924
+ return undefined;
925
+ }
926
+ }
927
+ function buildDefault(field, schema, location, ctx) {
928
+ if (!Object.hasOwn(schema, 'default')) {
929
+ return undefined;
930
+ }
931
+ const value = schema.default;
932
+ if (value === null) {
933
+ return undefined;
934
+ }
935
+ if (field.arrayDepth !== undefined) {
936
+ ctx.warnings.push(`${location}: the default of an array column was ignored.`);
937
+ return undefined;
938
+ }
939
+ if (field.enumName !== undefined && typeof value === 'string') {
940
+ const member = ctx.enums
941
+ .get(field.enumName)
942
+ ?.values.find((candidate) => candidate.dbValue === value);
943
+ if (member === undefined) {
944
+ ctx.warnings.push(`${location}: the default "${value}" is not a member of the enum "${field.enumName}" and was ignored.`);
945
+ return undefined;
946
+ }
947
+ return { kind: 'enumValue', value: member.name };
948
+ }
949
+ if (field.type === 'json') {
950
+ return { kind: 'literal', value: JSON.stringify(value) };
951
+ }
952
+ if (field.type === 'dateTime' &&
953
+ typeof value === 'string' &&
954
+ /^(now|now\(\)|current_timestamp(\(\))?)$/i.test(value)) {
955
+ return { kind: 'now' };
956
+ }
957
+ if (typeof value === 'string' ||
958
+ typeof value === 'number' ||
959
+ typeof value === 'boolean') {
960
+ return { kind: 'literal', value };
961
+ }
962
+ ctx.warnings.push(`${location}: the default ${JSON.stringify(value)} cannot be stored as a column default and was ignored.`);
963
+ return undefined;
964
+ }
965
+ function isIdLike(name, modelName) {
966
+ const normalized = name.toLowerCase().replace(/_/g, '');
967
+ return (name === 'id' ||
968
+ normalized === `${modelName.toLowerCase().replace(/_/g, '')}id`);
969
+ }
970
+ function buildModel(info, ctx) {
971
+ const shape = info.shape;
972
+ const built = {
973
+ info,
974
+ fields: [],
975
+ toOne: [],
976
+ toMany: [],
977
+ relations: [],
978
+ indexes: [],
979
+ };
980
+ if (shape.inherited.length > 0) {
981
+ ctx.warnings.push(`${info.name}: allOf composition was flattened; the properties of ${shape.inherited.join(', ')} were copied into the model and the inheritance is not represented.`);
982
+ }
983
+ if (shape.hasUnion) {
984
+ ctx.warnings.push(`${info.name}: oneOf/anyOf has no database meaning and was ignored.`);
985
+ }
986
+ if (shape.hasPatternProperties) {
987
+ ctx.warnings.push(`${info.name}: patternProperties has no database meaning and was ignored.`);
988
+ }
989
+ if (shape.hasAdditionalSchema) {
990
+ ctx.warnings.push(`${info.name}: additionalProperties with a schema (a map) has no database meaning and was ignored.`);
991
+ }
992
+ if (shape.hasConditional) {
993
+ ctx.warnings.push(`${info.name}: if/then/else has no database meaning and was ignored.`);
994
+ }
995
+ const primaryMarked = [];
996
+ const readOnlyNames = new Set();
997
+ for (const [name, member] of shape.properties) {
998
+ const location = `${info.name}.${name}`;
999
+ const propertySchema = asRecordOrEmpty(member.schema);
1000
+ const required = shape.required.has(name);
1001
+ const described = describeSchema(ctx, member.schema, member.doc, location);
1002
+ if (described.model !== undefined) {
1003
+ const nullable = described.nullable || !required;
1004
+ const relatedName = asString(propertySchema['x-related-name']) ??
1005
+ asString(described.schema['x-related-name']);
1006
+ const onDelete = toOnDelete(propertySchema['x-on-delete'] ?? described.schema['x-on-delete']);
1007
+ built.toOne.push({
1008
+ name,
1009
+ target: described.model,
1010
+ nullable,
1011
+ required,
1012
+ paired: false,
1013
+ ...(onDelete === undefined ? {} : { onDelete }),
1014
+ ...(relatedName === undefined ? {} : { relatedName }),
1015
+ });
1016
+ continue;
1017
+ }
1018
+ // An array of models is the to-many side of a relation.
1019
+ if (described.failure === undefined &&
1020
+ described.schema.type === 'array' &&
1021
+ asRecord(described.schema.items) !== undefined) {
1022
+ const element = describeSchema(ctx, described.schema.items, described.doc, location);
1023
+ if (element.model !== undefined) {
1024
+ built.toMany.push({ name, target: element.model, paired: false });
1025
+ continue;
1026
+ }
1027
+ }
1028
+ const scalar = scalarOf(ctx, described, location, info.name, name, 0);
1029
+ const merged = { ...described.schema, ...propertySchema };
1030
+ // Null is allowed by the type; an optional property without a default or a generated value is
1031
+ // nullable too (the column may stay empty).
1032
+ const allowsNull = described.nullable ||
1033
+ (Array.isArray(merged.type) && merged.type.includes('null')) ||
1034
+ (Array.isArray(merged.enum) && merged.enum.includes(null));
1035
+ const field = {
1036
+ name,
1037
+ columnName: name,
1038
+ type: scalar.type,
1039
+ isPrimaryKey: false,
1040
+ isUnique: merged['x-unique'] === true,
1041
+ isNullable: allowsNull,
1042
+ isAutoUpdated: false,
1043
+ ...(scalar.maxLength === undefined
1044
+ ? {}
1045
+ : { maxLength: scalar.maxLength }),
1046
+ ...(scalar.maxDigits === undefined
1047
+ ? {}
1048
+ : { maxDigits: scalar.maxDigits }),
1049
+ ...(scalar.decimalPlaces === undefined
1050
+ ? {}
1051
+ : { decimalPlaces: scalar.decimalPlaces }),
1052
+ ...(scalar.enumName === undefined ? {} : { enumName: scalar.enumName }),
1053
+ ...(scalar.arrayDepth === undefined
1054
+ ? {}
1055
+ : { arrayDepth: scalar.arrayDepth }),
1056
+ };
1057
+ const defaultValue = buildDefault(scalar, merged, location, ctx);
1058
+ if (defaultValue !== undefined) {
1059
+ field.default = defaultValue;
1060
+ }
1061
+ if (merged.readOnly === true && field.default === undefined) {
1062
+ applyReadOnlyHint(field);
1063
+ }
1064
+ if (!required &&
1065
+ merged.readOnly !== true &&
1066
+ field.default === undefined &&
1067
+ !Object.hasOwn(merged, 'default')) {
1068
+ field.isNullable = true;
1069
+ }
1070
+ if (merged['x-primary-key'] === true) {
1071
+ primaryMarked.push(name);
1072
+ }
1073
+ if (merged['x-index'] === true) {
1074
+ built.indexes.push({ fields: [name], isUnique: false });
1075
+ }
1076
+ if (merged.readOnly === true) {
1077
+ readOnlyNames.add(name);
1078
+ }
1079
+ built.fields.push(field);
1080
+ }
1081
+ // Foreign key columns declared next to the relation (authorId, author_id) become the relation's column.
1082
+ for (const relation of built.toOne) {
1083
+ const wanted = relation.name.toLowerCase().replace(/_/g, '');
1084
+ const column = built.fields.find((field) => field.name.toLowerCase().replace(/_/g, '') === `${wanted}id` &&
1085
+ ['int', 'bigInt', 'uuid', 'string', 'text'].includes(field.type) &&
1086
+ field.arrayDepth === undefined &&
1087
+ !primaryMarked.includes(field.name));
1088
+ if (column !== undefined) {
1089
+ relation.fkColumn = column.name;
1090
+ // The column holds the key, so its nullability is the relation's (the property that expands
1091
+ // the reference is usually optional whatever the column says).
1092
+ relation.nullable = column.isNullable;
1093
+ relation.required = !column.isNullable;
1094
+ built.fields.splice(built.fields.indexOf(column), 1);
1095
+ }
1096
+ }
1097
+ assignPrimaryKey(built, primaryMarked, readOnlyNames, info, ctx);
1098
+ const modelIndexes = info.schema['x-indexes'];
1099
+ if (Array.isArray(modelIndexes)) {
1100
+ for (const entry of modelIndexes) {
1101
+ const record = asRecord(entry);
1102
+ const fields = record?.fields;
1103
+ if (record === undefined ||
1104
+ !Array.isArray(fields) ||
1105
+ fields.length === 0 ||
1106
+ !fields.every((item) => typeof item === 'string')) {
1107
+ ctx.warnings.push(`${info.name}: an x-indexes entry without a "fields" array of property names was ignored.`);
1108
+ continue;
1109
+ }
1110
+ const name = asString(record.name);
1111
+ built.indexes.push({
1112
+ fields: fields,
1113
+ isUnique: record.unique === true,
1114
+ ...(name === undefined ? {} : { name }),
1115
+ });
1116
+ }
1117
+ }
1118
+ return built;
1119
+ }
1120
+ /** `readOnly` marks a value the database produces: keys count up, uuids are generated, timestamps are set. */
1121
+ function applyReadOnlyHint(field) {
1122
+ if (field.type === 'uuid') {
1123
+ field.default = { kind: 'uuid' };
1124
+ }
1125
+ else if (field.type === 'dateTime') {
1126
+ if (/^updated/i.test(field.name.replace(/_/g, ''))) {
1127
+ field.isAutoUpdated = true;
1128
+ }
1129
+ else {
1130
+ field.default = { kind: 'now' };
1131
+ }
1132
+ }
1133
+ }
1134
+ function assignPrimaryKey(built, marked, readOnlyNames, info, ctx) {
1135
+ const fieldNamed = (name) => built.fields.find((field) => field.name === name);
1136
+ let keyNames = marked;
1137
+ const modelLevel = info.schema['x-primary-key'];
1138
+ if (keyNames.length === 0 && Array.isArray(modelLevel)) {
1139
+ keyNames = modelLevel.filter((name) => typeof name === 'string' && fieldNamed(name) !== undefined);
1140
+ }
1141
+ if (keyNames.length === 0) {
1142
+ const candidate = fieldNamed('id') ??
1143
+ built.fields.find((field) => isIdLike(field.name, info.name));
1144
+ const usable = [
1145
+ 'int',
1146
+ 'bigInt',
1147
+ 'string',
1148
+ 'text',
1149
+ 'uuid',
1150
+ 'decimal',
1151
+ ];
1152
+ if (candidate !== undefined &&
1153
+ usable.includes(candidate.type) &&
1154
+ candidate.arrayDepth === undefined) {
1155
+ keyNames = [candidate.name];
1156
+ }
1157
+ }
1158
+ if (keyNames.length === 0) {
1159
+ const takenId = built.fields.some((field) => field.name === 'id');
1160
+ const name = takenId ? 'ormbridge_id' : 'id';
1161
+ ctx.warnings.push(`${info.name}: no primary key property was found (an id, ${info.name.charAt(0).toLowerCase()}${info.name.slice(1)}Id or x-primary-key property), so an integer "${name}" column was added.`);
1162
+ built.fields.unshift({
1163
+ name,
1164
+ columnName: name,
1165
+ type: 'int',
1166
+ isPrimaryKey: true,
1167
+ isUnique: false,
1168
+ isNullable: false,
1169
+ isAutoUpdated: false,
1170
+ default: { kind: 'autoIncrement' },
1171
+ });
1172
+ return;
1173
+ }
1174
+ if (keyNames.length > 1) {
1175
+ built.compositePrimaryKey = keyNames;
1176
+ for (const name of keyNames) {
1177
+ const field = fieldNamed(name);
1178
+ if (field !== undefined) {
1179
+ field.isNullable = false;
1180
+ }
1181
+ }
1182
+ return;
1183
+ }
1184
+ const keyField = fieldNamed(keyNames[0] ?? '');
1185
+ if (keyField === undefined) {
1186
+ return;
1187
+ }
1188
+ keyField.isPrimaryKey = true;
1189
+ keyField.isNullable = false;
1190
+ keyField.isUnique = false;
1191
+ if (keyField.default === undefined && readOnlyNames.has(keyField.name)) {
1192
+ if (keyField.type === 'int' || keyField.type === 'bigInt') {
1193
+ keyField.default = { kind: 'autoIncrement' };
1194
+ }
1195
+ else if (keyField.type === 'uuid') {
1196
+ keyField.default = { kind: 'uuid' };
1197
+ }
1198
+ }
1199
+ }
1200
+ // ---------------------------------------------------------------------------
1201
+ // Relations
1202
+ // ---------------------------------------------------------------------------
1203
+ function relationColumn(name, fk) {
1204
+ if (fk !== undefined) {
1205
+ return fk;
1206
+ }
1207
+ return `${toSnakeCase(name)}_id`;
1208
+ }
1209
+ function defaultOnDelete(prop) {
1210
+ return prop.onDelete ?? (prop.nullable ? 'setNull' : 'restrict');
1211
+ }
1212
+ function pairRelations(built, ctx) {
1213
+ const byInfo = new Map(built.map((model) => [model.info, model]));
1214
+ // To-one properties that point at each other are the two sides of a one-to-one relation.
1215
+ for (const model of built) {
1216
+ for (const prop of model.toOne) {
1217
+ if (prop.paired) {
1218
+ continue;
1219
+ }
1220
+ const other = byInfo.get(prop.target);
1221
+ if (other !== undefined && hasArrayBack(model, prop, other)) {
1222
+ continue;
1223
+ }
1224
+ const reverse = other?.toOne.filter((candidate) => !candidate.paired &&
1225
+ candidate !== prop &&
1226
+ candidate.target === model.info &&
1227
+ !hasArrayBack(other, candidate, model) &&
1228
+ (candidate.relatedName === prop.name ||
1229
+ candidate.relatedName === undefined) &&
1230
+ (prop.relatedName === candidate.name ||
1231
+ prop.relatedName === undefined)) ?? [];
1232
+ const sameFirst = reverse.filter((candidate) => prop.relatedName === candidate.name ||
1233
+ candidate.relatedName === prop.name);
1234
+ const chosen = sameFirst.length > 0 ? sameFirst : reverse;
1235
+ const sibling = chosen[0];
1236
+ if (other === undefined || sibling === undefined || chosen.length !== 1) {
1237
+ continue;
1238
+ }
1239
+ // The required side holds the foreign key; otherwise the model defined later does.
1240
+ const siblingOwns = sibling.required !== prop.required
1241
+ ? sibling.required
1242
+ : built.indexOf(other) > built.indexOf(model);
1243
+ if (sibling.required === prop.required) {
1244
+ ctx.warnings.push(`${model.info.name}.${prop.name} and ${other.info.name}.${sibling.name} reference each other and are read as one one-to-one relation owned by ${siblingOwns ? other.info.name : model.info.name}; mark the owning side as required to choose the direction.`);
1245
+ }
1246
+ const owner = siblingOwns ? sibling : prop;
1247
+ const inverse = siblingOwns ? prop : sibling;
1248
+ prop.paired = true;
1249
+ sibling.paired = true;
1250
+ const ownerModel = siblingOwns ? other : model;
1251
+ ownerModel.relations.push({
1252
+ name: owner.name,
1253
+ kind: 'oneToOne',
1254
+ targetModel: owner.target.name,
1255
+ columnName: relationColumn(owner.name, owner.fkColumn),
1256
+ isNullable: owner.nullable,
1257
+ onDelete: defaultOnDelete(owner),
1258
+ relatedName: inverse.name,
1259
+ });
1260
+ }
1261
+ }
1262
+ // An array of models is the reverse of the foreign key on the other model, or a many-to-many when both sides are arrays.
1263
+ for (const model of built) {
1264
+ for (const many of model.toMany) {
1265
+ if (many.paired) {
1266
+ continue;
1267
+ }
1268
+ const other = byInfo.get(many.target);
1269
+ if (other === undefined) {
1270
+ continue;
1271
+ }
1272
+ const foreignKeys = other.toOne.filter((candidate) => !candidate.paired && candidate.target === model.info);
1273
+ const named = foreignKeys.filter((candidate) => candidate.relatedName === many.name);
1274
+ const foreignKey = named[0] ?? (foreignKeys.length === 1 ? foreignKeys[0] : undefined);
1275
+ if (foreignKey !== undefined) {
1276
+ many.paired = true;
1277
+ foreignKey.paired = true;
1278
+ other.relations.push({
1279
+ name: foreignKey.name,
1280
+ kind: 'foreignKey',
1281
+ targetModel: model.info.name,
1282
+ columnName: relationColumn(foreignKey.name, foreignKey.fkColumn),
1283
+ isNullable: foreignKey.nullable,
1284
+ onDelete: defaultOnDelete(foreignKey),
1285
+ relatedName: many.name,
1286
+ });
1287
+ continue;
1288
+ }
1289
+ const unnamed = foreignKeys.filter((candidate) => candidate.relatedName === undefined);
1290
+ const arrays = model.toMany.filter((candidate) => !candidate.paired && candidate.target === many.target);
1291
+ const byPosition = arrays.length === unnamed.length
1292
+ ? unnamed[arrays.indexOf(many)]
1293
+ : undefined;
1294
+ if (byPosition !== undefined) {
1295
+ many.paired = true;
1296
+ byPosition.paired = true;
1297
+ ctx.warnings.push(`${model.info.name}.${many.name}: ${other.info.name} has ${unnamed.length} properties that reference ${model.info.name} (${unnamed.map((candidate) => candidate.name).join(', ')}) and ${model.info.name} has as many arrays of ${other.info.name}, so they were matched in order of appearance and this array is the reverse of ${other.info.name}.${byPosition.name}. Set "x-related-name": "${many.name}" on the right property to say so explicitly.`);
1298
+ other.relations.push({
1299
+ name: byPosition.name,
1300
+ kind: 'foreignKey',
1301
+ targetModel: model.info.name,
1302
+ columnName: relationColumn(byPosition.name, byPosition.fkColumn),
1303
+ isNullable: byPosition.nullable,
1304
+ onDelete: defaultOnDelete(byPosition),
1305
+ relatedName: many.name,
1306
+ });
1307
+ continue;
1308
+ }
1309
+ if (foreignKeys.length > 1) {
1310
+ ctx.warnings.push(`${model.info.name}.${many.name}: ${other.info.name} has several properties that reference ${model.info.name} (${foreignKeys.map((candidate) => candidate.name).join(', ')}), so it is unclear which one this array is the reverse of. Set "x-related-name": "${many.name}" on the right one. The array was ignored.`);
1311
+ many.paired = true;
1312
+ continue;
1313
+ }
1314
+ const mirrors = other.toMany.filter((candidate) => !candidate.paired &&
1315
+ candidate !== many &&
1316
+ candidate.target === model.info);
1317
+ if (mirrors.length === 1 && mirrors[0] !== undefined) {
1318
+ const mirror = mirrors[0];
1319
+ many.paired = true;
1320
+ mirror.paired = true;
1321
+ // The model defined first owns the many-to-many field.
1322
+ const manyOwns = built.indexOf(model) <= built.indexOf(other);
1323
+ const owner = manyOwns ? many : mirror;
1324
+ const inverse = manyOwns ? mirror : many;
1325
+ (manyOwns ? model : other).relations.push({
1326
+ name: owner.name,
1327
+ kind: 'manyToMany',
1328
+ targetModel: owner.target.name,
1329
+ columnName: `${toSnakeCase(owner.name)}_id`,
1330
+ isNullable: false,
1331
+ onDelete: 'cascade',
1332
+ relatedName: inverse.name,
1333
+ });
1334
+ continue;
1335
+ }
1336
+ if (mirrors.length > 1) {
1337
+ ctx.warnings.push(`${model.info.name}.${many.name}: ${other.info.name} has several arrays of ${model.info.name}, so the many-to-many pairing is ambiguous and the array was ignored.`);
1338
+ many.paired = true;
1339
+ continue;
1340
+ }
1341
+ // Only one side is written down: the foreign key lives on the other model.
1342
+ many.paired = true;
1343
+ const reverseName = uniqueName(other, model.info.name.charAt(0).toLowerCase() + model.info.name.slice(1));
1344
+ ctx.warnings.push(`${model.info.name}.${many.name}: ${other.info.name} does not reference ${model.info.name} back, so a nullable foreign key "${reverseName}" was added to ${other.info.name} to hold the relation (many-to-many needs an array on both sides).`);
1345
+ other.relations.push({
1346
+ name: reverseName,
1347
+ kind: 'foreignKey',
1348
+ targetModel: model.info.name,
1349
+ columnName: relationColumn(reverseName, undefined),
1350
+ isNullable: true,
1351
+ onDelete: 'setNull',
1352
+ relatedName: many.name,
1353
+ });
1354
+ }
1355
+ }
1356
+ // Remaining to-one properties are plain many-to-one foreign keys.
1357
+ for (const model of built) {
1358
+ for (const prop of model.toOne) {
1359
+ if (prop.paired) {
1360
+ continue;
1361
+ }
1362
+ prop.paired = true;
1363
+ model.relations.push({
1364
+ name: prop.name,
1365
+ kind: 'foreignKey',
1366
+ targetModel: prop.target.name,
1367
+ columnName: relationColumn(prop.name, prop.fkColumn),
1368
+ isNullable: prop.nullable,
1369
+ onDelete: defaultOnDelete(prop),
1370
+ ...(prop.relatedName === undefined
1371
+ ? {}
1372
+ : { relatedName: prop.relatedName }),
1373
+ });
1374
+ }
1375
+ }
1376
+ }
1377
+ /** True when `target` lists an array of `owner`'s model that is the reverse of the to-one `prop` of `owner`. */
1378
+ function hasArrayBack(owner, prop, target) {
1379
+ return target.toMany.some((many) => many.target === owner.info &&
1380
+ (prop.relatedName === undefined || many.name === prop.relatedName));
1381
+ }
1382
+ function uniqueName(model, wanted) {
1383
+ const taken = (name) => model.fields.some((field) => field.name === name) ||
1384
+ model.relations.some((relation) => relation.name === name) ||
1385
+ model.toOne.some((prop) => prop.name === name) ||
1386
+ model.toMany.some((prop) => prop.name === name);
1387
+ let name = wanted;
1388
+ let counter = 2;
1389
+ while (taken(name)) {
1390
+ name = `${wanted}${counter}`;
1391
+ counter += 1;
1392
+ }
1393
+ return name;
1394
+ }
1395
+ /** Relations in the order of the properties they came from (synthesized ones last). */
1396
+ function sortRelations(built) {
1397
+ const order = [...built.info.shape.properties.keys()];
1398
+ const position = (relation) => {
1399
+ const index = order.indexOf(relation.name);
1400
+ return index === -1 ? order.length : index;
1401
+ };
1402
+ return [...built.relations].sort((first, second) => position(first) - position(second));
1403
+ }
1404
+ function finishModel(built, ctx) {
1405
+ const info = built.info;
1406
+ const tableName = asString(info.schema['x-table-name']) ?? info.name;
1407
+ const names = new Set([
1408
+ ...built.fields.map((field) => field.name),
1409
+ ...built.relations.map((relation) => relation.name),
1410
+ ]);
1411
+ const indexes = [];
1412
+ for (const index of built.indexes) {
1413
+ const unknown = index.fields.filter((name) => !names.has(name));
1414
+ if (unknown.length > 0) {
1415
+ ctx.warnings.push(`${info.name}: an index refers to ${unknown.join(', ')}, which ${unknown.length === 1 ? 'is' : 'are'} not ${unknown.length === 1 ? 'a property' : 'properties'} of the model, so it was ignored.`);
1416
+ continue;
1417
+ }
1418
+ indexes.push(index);
1419
+ }
1420
+ return {
1421
+ name: info.name,
1422
+ tableName,
1423
+ appLabel: ctx.options.appLabel,
1424
+ fields: built.fields,
1425
+ relations: sortRelations(built),
1426
+ indexes,
1427
+ ...(built.compositePrimaryKey === undefined
1428
+ ? {}
1429
+ : { compositePrimaryKey: built.compositePrimaryKey }),
1430
+ };
1431
+ }
1432
+ //# sourceMappingURL=jsonSchema.js.map