@zmdb/schema 1.0.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +674 -0
- package/README.md +30 -0
- package/dist/custom-types/index.d.ts +41 -0
- package/dist/custom-types/index.d.ts.map +1 -0
- package/dist/custom-types/index.js +32 -0
- package/dist/custom-types/index.js.map +1 -0
- package/dist/derive/index.d.ts +122 -0
- package/dist/derive/index.d.ts.map +1 -0
- package/dist/derive/index.js +13 -0
- package/dist/derive/index.js.map +1 -0
- package/dist/derive/query.d.ts +62 -0
- package/dist/derive/query.d.ts.map +1 -0
- package/dist/derive/query.js +18 -0
- package/dist/derive/query.js.map +1 -0
- package/dist/dto/index.d.ts +224 -0
- package/dist/dto/index.d.ts.map +1 -0
- package/dist/dto/index.js +118 -0
- package/dist/dto/index.js.map +1 -0
- package/dist/entity-modeling/index.d.ts +12 -0
- package/dist/entity-modeling/index.d.ts.map +1 -0
- package/dist/entity-modeling/index.js +28 -0
- package/dist/entity-modeling/index.js.map +1 -0
- package/dist/index.d.ts +151 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +84 -0
- package/dist/index.js.map +1 -0
- package/dist/ir/index.d.ts +374 -0
- package/dist/ir/index.d.ts.map +1 -0
- package/dist/ir/index.js +735 -0
- package/dist/ir/index.js.map +1 -0
- package/dist/ir/validation-shape.d.ts +46 -0
- package/dist/ir/validation-shape.d.ts.map +1 -0
- package/dist/ir/validation-shape.js +130 -0
- package/dist/ir/validation-shape.js.map +1 -0
- package/dist/ir/vocabulary.d.ts +54 -0
- package/dist/ir/vocabulary.d.ts.map +1 -0
- package/dist/ir/vocabulary.js +51 -0
- package/dist/ir/vocabulary.js.map +1 -0
- package/dist/naming/index.d.ts +26 -0
- package/dist/naming/index.d.ts.map +1 -0
- package/dist/naming/index.js +147 -0
- package/dist/naming/index.js.map +1 -0
- package/dist/openapi/index.d.ts +57 -0
- package/dist/openapi/index.d.ts.map +1 -0
- package/dist/openapi/index.js +98 -0
- package/dist/openapi/index.js.map +1 -0
- package/dist/relations/index.d.ts +23 -0
- package/dist/relations/index.d.ts.map +1 -0
- package/dist/relations/index.js +98 -0
- package/dist/relations/index.js.map +1 -0
- package/dist/tags/index.d.ts +261 -0
- package/dist/tags/index.d.ts.map +1 -0
- package/dist/tags/index.js +64 -0
- package/dist/tags/index.js.map +1 -0
- package/package.json +82 -0
- package/src/custom-types/index.ts +59 -0
- package/src/derive/index.ts +224 -0
- package/src/derive/query.ts +128 -0
- package/src/dto/index.ts +395 -0
- package/src/entity-modeling/index.ts +33 -0
- package/src/index.ts +263 -0
- package/src/ir/index.ts +1085 -0
- package/src/ir/validation-shape.ts +145 -0
- package/src/ir/vocabulary.ts +56 -0
- package/src/naming/index.ts +159 -0
- package/src/openapi/index.ts +133 -0
- package/src/relations/index.ts +134 -0
- package/src/tags/index.ts +284 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// The facts about an IR node that the emitted code and the runtime walker must agree on.
|
|
2
|
+
//
|
|
3
|
+
// REQ-AV-4 asks for identical accept/reject sets *and* identical issue paths from the
|
|
4
|
+
// emitted validator and the fallback walker. That is a property of two independent
|
|
5
|
+
// walks over the same IR, so anything both of them decide lives here rather than being
|
|
6
|
+
// written twice: what an `expected` string reads, and whether a union has a
|
|
7
|
+
// discriminant worth switching on.
|
|
8
|
+
//
|
|
9
|
+
// Nothing in this file builds JavaScript, so the runtime path can import it without
|
|
10
|
+
// dragging the emitter into a browser bundle.
|
|
11
|
+
|
|
12
|
+
import { type Constraints, type ObjectIR, type TypeIR } from './index.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* What a value at this position was supposed to be, as the `expected` field of a
|
|
16
|
+
* `ValidationIssue`. Structural only — a bound that a value of the right *shape*
|
|
17
|
+
* violated gets its own issue via `expectedForConstraint`.
|
|
18
|
+
*/
|
|
19
|
+
export function expectedOf(node: TypeIR): string {
|
|
20
|
+
switch (node.kind) {
|
|
21
|
+
case 'scalar':
|
|
22
|
+
return node.scalar === 'date' ? 'Date' : node.scalar;
|
|
23
|
+
case 'literal':
|
|
24
|
+
return JSON.stringify(node.value);
|
|
25
|
+
case 'null':
|
|
26
|
+
return 'null';
|
|
27
|
+
case 'undefined':
|
|
28
|
+
return 'undefined';
|
|
29
|
+
case 'unknown':
|
|
30
|
+
return 'anything';
|
|
31
|
+
case 'union':
|
|
32
|
+
return node.members.map(expectedOf).join(' | ');
|
|
33
|
+
case 'array':
|
|
34
|
+
return 'array';
|
|
35
|
+
case 'tuple':
|
|
36
|
+
return `tuple of length ${node.elements.length}`;
|
|
37
|
+
case 'object':
|
|
38
|
+
return node.name ?? 'object';
|
|
39
|
+
case 'ref':
|
|
40
|
+
return node.name;
|
|
41
|
+
case 'unsupported':
|
|
42
|
+
// Reachable only through a bug: the emitter refuses an `unsupported` node
|
|
43
|
+
// before any issue text is needed (plan D4). Naming it beats `undefined`.
|
|
44
|
+
return `an unsupported type (${node.reason})`;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type ConstraintKeyword = keyof Constraints;
|
|
49
|
+
|
|
50
|
+
/** `minLength`, `3` → `'minLength 3'`. One spelling, used by both walks. */
|
|
51
|
+
export function expectedForConstraint(keyword: ConstraintKeyword, value: number | string): string {
|
|
52
|
+
return `${keyword} ${value}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The `message` of an issue is always derived from its `expected`. */
|
|
56
|
+
export function messageFor(expected: string): string {
|
|
57
|
+
return `expected ${expected}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface DiscriminantArm {
|
|
61
|
+
readonly value: string | number | boolean;
|
|
62
|
+
readonly node: ObjectIR;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface Discriminant {
|
|
66
|
+
readonly key: string;
|
|
67
|
+
readonly arms: readonly DiscriminantArm[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The property to switch on for a union of objects, when there is one.
|
|
72
|
+
*
|
|
73
|
+
* A union is discriminated when every member is an object and some property is
|
|
74
|
+
* present, required and a distinct literal on all of them. The reflection deliberately
|
|
75
|
+
* records no strategy (`reflect/SPEC.md` §10) — it says "this property is a literal",
|
|
76
|
+
* and choosing what to do about that is this function's job.
|
|
77
|
+
*
|
|
78
|
+
* Worth doing for more than speed. Without a discriminant, a failing union can only
|
|
79
|
+
* say "none of these arms matched" at the union's own path; with one, the failure is
|
|
80
|
+
* reported *inside* the arm the value was clearly trying to be, so `input.radius` gets
|
|
81
|
+
* named instead of the whole shape.
|
|
82
|
+
*
|
|
83
|
+
* The first qualifying key in the first member's declaration order wins, so the answer
|
|
84
|
+
* does not depend on property iteration order elsewhere.
|
|
85
|
+
*/
|
|
86
|
+
export function discriminantOf(members: readonly TypeIR[]): Discriminant | undefined {
|
|
87
|
+
if (members.length < 2) return undefined;
|
|
88
|
+
const objects: ObjectIR[] = [];
|
|
89
|
+
for (const member of members) {
|
|
90
|
+
if (member.kind !== 'object') return undefined;
|
|
91
|
+
objects.push(member);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const first = objects[0];
|
|
95
|
+
if (!first) return undefined;
|
|
96
|
+
|
|
97
|
+
for (const candidate of first.properties) {
|
|
98
|
+
if (candidate.optional || candidate.type.kind !== 'literal') continue;
|
|
99
|
+
const arms: DiscriminantArm[] = [];
|
|
100
|
+
const seen = new Set<string>();
|
|
101
|
+
let usable = true;
|
|
102
|
+
for (const object of objects) {
|
|
103
|
+
const property = object.properties.find(p => p.name === candidate.name);
|
|
104
|
+
if (!property || property.optional || property.type.kind !== 'literal') {
|
|
105
|
+
usable = false;
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
// `JSON.stringify` rather than the raw value so `1` and `'1'` are two arms
|
|
109
|
+
// rather than one collision — the emitted switch compares with `===`.
|
|
110
|
+
const key = JSON.stringify(property.type.value);
|
|
111
|
+
if (seen.has(key)) {
|
|
112
|
+
usable = false;
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
seen.add(key);
|
|
116
|
+
arms.push({ value: property.type.value, node: object });
|
|
117
|
+
}
|
|
118
|
+
if (usable) return { key: candidate.name, arms };
|
|
119
|
+
}
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** `'circle' | 'square'`, for the message when no arm's discriminant matched. */
|
|
124
|
+
export function expectedForDiscriminant(discriminant: Discriminant): string {
|
|
125
|
+
return discriminant.arms.map(arm => JSON.stringify(arm.value)).join(' | ');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Whether excess properties are even defined for this node. A value can satisfy
|
|
130
|
+
* several arms of an undiscriminated union, so "which arm's property list is the
|
|
131
|
+
* declared one" has no answer there and neither walk checks it — see `emit/SPEC.md`.
|
|
132
|
+
*/
|
|
133
|
+
export function hasExcessCheck(node: TypeIR): boolean {
|
|
134
|
+
switch (node.kind) {
|
|
135
|
+
case 'object':
|
|
136
|
+
case 'ref':
|
|
137
|
+
case 'array':
|
|
138
|
+
case 'tuple':
|
|
139
|
+
return true;
|
|
140
|
+
case 'union':
|
|
141
|
+
return discriminantOf(node.members) !== undefined;
|
|
142
|
+
default:
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ValidationRule.kind` is an open `string`, so this is the set any back-end
|
|
3
|
+
* interprets rather than the set a consumer may write. Anything else is a named
|
|
4
|
+
* custom rule and lands in `ColumnIR.rules`.
|
|
5
|
+
*/
|
|
6
|
+
export const KNOWN_CONSTRAINT_KINDS = ['minimum', 'maximum', 'minLength', 'maxLength', 'pattern'] as const;
|
|
7
|
+
|
|
8
|
+
export type ConstraintKind = (typeof KNOWN_CONSTRAINT_KINDS)[number];
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The tag vocabulary as data: **IR field → the tag name the reflection recognises**.
|
|
12
|
+
* `../tags` is types-only and must stay that way, so the reflection cannot import the
|
|
13
|
+
* tags themselves. It matches the escaped unique-symbol name the checker reports
|
|
14
|
+
* (`__@zmdbSerial@1`), except for `Ext`'s frozen structural `__zmdbExt` marker.
|
|
15
|
+
*
|
|
16
|
+
* Keyed by the IR field rather than by the tag's public name because that is the
|
|
17
|
+
* mapping every consumer actually wants, and because keeping it in one table is what
|
|
18
|
+
* lets `vocabulary.type-test.ts` prove the two vocabularies line up. A tag added to
|
|
19
|
+
* `../tags` without an entry here is invisible to the reflection, which is precisely
|
|
20
|
+
* the silent-gap failure the whole IR exists to prevent.
|
|
21
|
+
*/
|
|
22
|
+
export const TAG_NAMES = {
|
|
23
|
+
table: 'zmdbTable',
|
|
24
|
+
ftsTable: 'zmdbFts',
|
|
25
|
+
shardKey: 'zmdbShardKey',
|
|
26
|
+
sortKey: 'zmdbSortKey',
|
|
27
|
+
rowstore: 'zmdbRowstore',
|
|
28
|
+
softDelete: 'zmdbSoftDelete',
|
|
29
|
+
sql: 'zmdbSqlType',
|
|
30
|
+
extension: 'zmdbExt',
|
|
31
|
+
primaryKey: 'zmdbPrimaryKey',
|
|
32
|
+
serial: 'zmdbSerial',
|
|
33
|
+
unique: 'zmdbUnique',
|
|
34
|
+
hasDefault: 'zmdbDefault',
|
|
35
|
+
sensitive: 'zmdbSensitive',
|
|
36
|
+
references: 'zmdbReferences',
|
|
37
|
+
onDelete: 'zmdbOnDelete',
|
|
38
|
+
onUpdate: 'zmdbOnUpdate',
|
|
39
|
+
foreignKeys: 'zmdbForeignKey',
|
|
40
|
+
length: 'zmdbLength',
|
|
41
|
+
precision: 'zmdbNumeric',
|
|
42
|
+
codec: 'zmdbCodec',
|
|
43
|
+
wire: 'zmdbWire',
|
|
44
|
+
relation: 'zmdbRelation',
|
|
45
|
+
minimum: 'zmdbMin',
|
|
46
|
+
maximum: 'zmdbMax',
|
|
47
|
+
minLength: 'zmdbMinLength',
|
|
48
|
+
maxLength: 'zmdbMaxLength',
|
|
49
|
+
pattern: 'zmdbPattern',
|
|
50
|
+
rules: 'zmdbRule',
|
|
51
|
+
protoField: 'zmdbProtoField',
|
|
52
|
+
protoScalar: 'zmdbProtoScalar',
|
|
53
|
+
} as const;
|
|
54
|
+
|
|
55
|
+
/** An IR field a tag can set. */
|
|
56
|
+
export type TagField = keyof typeof TAG_NAMES;
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A build-time mapping from declared TypeScript names to physical SQL names.
|
|
3
|
+
*
|
|
4
|
+
* The reflector calls these functions once and stores their answers in `SchemaIR`.
|
|
5
|
+
* Query compilation and row handling never receive a strategy.
|
|
6
|
+
*/
|
|
7
|
+
export interface NamingStrategy {
|
|
8
|
+
readonly column?: (property: string, context: { readonly table: string }) => string;
|
|
9
|
+
readonly table?: (declared: string) => string;
|
|
10
|
+
readonly index?: (table: string, columns: readonly string[], unique: boolean) => string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type NamingStrategyName = 'snake_case' | 'snake_case_plural';
|
|
14
|
+
export type NamingStrategyConfig = NamingStrategy | NamingStrategyName | undefined;
|
|
15
|
+
|
|
16
|
+
const IRREGULAR_PLURALS = new Map([
|
|
17
|
+
['child', 'children'],
|
|
18
|
+
['index', 'indices'],
|
|
19
|
+
['man', 'men'],
|
|
20
|
+
['matrix', 'matrices'],
|
|
21
|
+
['person', 'people'],
|
|
22
|
+
['woman', 'women'],
|
|
23
|
+
]);
|
|
24
|
+
const IRREGULAR_PLURAL_FORMS = new Set(IRREGULAR_PLURALS.values());
|
|
25
|
+
const UNINFLECTED = new Set([
|
|
26
|
+
'data',
|
|
27
|
+
'equipment',
|
|
28
|
+
'fish',
|
|
29
|
+
'information',
|
|
30
|
+
'metadata',
|
|
31
|
+
'news',
|
|
32
|
+
'series',
|
|
33
|
+
'sheep',
|
|
34
|
+
'species',
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
function toSnakeCase(value: string): string {
|
|
38
|
+
return value
|
|
39
|
+
.replaceAll(/([A-Z]+)([A-Z][a-z])/g, '$1_$2')
|
|
40
|
+
.replaceAll(/([a-z\d])([A-Z])/g, '$1_$2')
|
|
41
|
+
.replaceAll(/[-\s]+/g, '_')
|
|
42
|
+
.toLowerCase();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function pluralizeWord(word: string): string {
|
|
46
|
+
const lower = word.toLowerCase();
|
|
47
|
+
if (word.length === 0 || UNINFLECTED.has(lower) || IRREGULAR_PLURAL_FORMS.has(lower)) return word;
|
|
48
|
+
|
|
49
|
+
const irregular = IRREGULAR_PLURALS.get(lower);
|
|
50
|
+
if (irregular !== undefined) return irregular;
|
|
51
|
+
|
|
52
|
+
// Keep an already-plural declaration stable. The guarded endings are singular
|
|
53
|
+
// words that happen to end in `s` and still need the explicit `-es` rule below.
|
|
54
|
+
if (
|
|
55
|
+
lower.endsWith('s') &&
|
|
56
|
+
!lower.endsWith('ss') &&
|
|
57
|
+
!lower.endsWith('us') &&
|
|
58
|
+
!lower.endsWith('is') &&
|
|
59
|
+
!lower.endsWith('as') &&
|
|
60
|
+
!lower.endsWith('os') &&
|
|
61
|
+
lower !== 'status' &&
|
|
62
|
+
lower !== 'alias'
|
|
63
|
+
) {
|
|
64
|
+
return word;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (/[^aeiou]y$/i.test(word)) return `${word.slice(0, -1)}ies`;
|
|
68
|
+
if (/(?:s|x|z|ch|sh)$/i.test(word)) return `${word}es`;
|
|
69
|
+
return `${word}s`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function pluralizeTable(value: string): string {
|
|
73
|
+
const words = value.split('_');
|
|
74
|
+
const last = words.at(-1);
|
|
75
|
+
if (last === undefined) return value;
|
|
76
|
+
words[words.length - 1] = pluralizeWord(last);
|
|
77
|
+
return words.join('_');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function indexName(table: string, columns: readonly string[], unique: boolean): string {
|
|
81
|
+
return `${toSnakeCase(table)}_${columns.map(toSnakeCase).join('_')}_${unique ? 'uniq' : 'idx'}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export const snakeCase: NamingStrategy = Object.freeze({
|
|
85
|
+
column: toSnakeCase,
|
|
86
|
+
table: toSnakeCase,
|
|
87
|
+
index: indexName,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
export const snakeCasePlural: NamingStrategy = Object.freeze({
|
|
91
|
+
column: toSnakeCase,
|
|
92
|
+
table: (declared: string) => pluralizeTable(toSnakeCase(declared)),
|
|
93
|
+
index: (table: string, columns: readonly string[], unique: boolean) =>
|
|
94
|
+
indexName(pluralizeTable(toSnakeCase(table)), columns, unique),
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const IDENTITY: NamingStrategy = Object.freeze({});
|
|
98
|
+
|
|
99
|
+
/** Resolve a config spelling once, before reflection begins. */
|
|
100
|
+
export function resolveNaming(config: NamingStrategyConfig): NamingStrategy {
|
|
101
|
+
if (config === undefined) return IDENTITY;
|
|
102
|
+
if (typeof config !== 'string') return config;
|
|
103
|
+
if (config === 'snake_case') return snakeCase;
|
|
104
|
+
if (config === 'snake_case_plural') return snakeCasePlural;
|
|
105
|
+
throw new TypeError(`Unknown naming strategy ${JSON.stringify(config)}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function singularizeWord(word: string): string {
|
|
109
|
+
if (!word) return word;
|
|
110
|
+
const lower = word.toLowerCase();
|
|
111
|
+
|
|
112
|
+
if (
|
|
113
|
+
lower.endsWith('ss') ||
|
|
114
|
+
lower.endsWith('us') ||
|
|
115
|
+
lower.endsWith('is') ||
|
|
116
|
+
lower.endsWith('as') ||
|
|
117
|
+
lower.endsWith('os') ||
|
|
118
|
+
lower === 'series' ||
|
|
119
|
+
lower === 'species' ||
|
|
120
|
+
lower === 'news' ||
|
|
121
|
+
lower === 'lens'
|
|
122
|
+
) {
|
|
123
|
+
return word;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (lower === 'people') return 'person';
|
|
127
|
+
if (lower === 'children') return 'child';
|
|
128
|
+
if (lower === 'men') return 'man';
|
|
129
|
+
if (lower === 'women') return 'woman';
|
|
130
|
+
if (lower === 'matrices') return 'matrix';
|
|
131
|
+
if (lower === 'indices') return 'index';
|
|
132
|
+
|
|
133
|
+
if (/([^aeiou])ies$/i.test(word)) return `${word.slice(0, -3)}y`;
|
|
134
|
+
if (/lves$/i.test(word)) return `${word.slice(0, -4)}lf`;
|
|
135
|
+
if (/(kn|w)ives$/i.test(word)) return `${word.slice(0, -4)}ife`;
|
|
136
|
+
if (/eaves$/i.test(word)) return `${word.slice(0, -5)}eaf`;
|
|
137
|
+
|
|
138
|
+
if (/sses$/i.test(word) || /statuses$/i.test(word) || /aliases$/i.test(word)) {
|
|
139
|
+
return word.slice(0, -2);
|
|
140
|
+
}
|
|
141
|
+
if (/ises$/i.test(word)) return `${word.slice(0, -4)}is`;
|
|
142
|
+
if (/(xes|ches|shes|zzes)$/i.test(word)) return word.slice(0, -2);
|
|
143
|
+
if (/([aeiou])zes$/i.test(word)) return word.slice(0, -1);
|
|
144
|
+
if (word.endsWith('s') && !word.endsWith('ss')) return word.slice(0, -1);
|
|
145
|
+
return word;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Singularize each physical-name word through the repository's explicit rule set and
|
|
150
|
+
* PascalCase the result. The physical table remains on `Table<'...'>`; this name is only
|
|
151
|
+
* the generated TypeScript identifier.
|
|
152
|
+
*/
|
|
153
|
+
export function singularPascalCase(value: string): string {
|
|
154
|
+
return value
|
|
155
|
+
.split(/[-_]+/)
|
|
156
|
+
.map(word => singularizeWord(word))
|
|
157
|
+
.map(word => (word ? `${word.charAt(0).toUpperCase()}${word.slice(1)}` : ''))
|
|
158
|
+
.join('');
|
|
159
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { type CoreSchema } from '../index.js';
|
|
2
|
+
import { jsonSchemaFromIR, type JsonSchemaObject, type Variant } from '../ir/index.js';
|
|
3
|
+
// JSON Schema / OpenAPI generation — implementation.
|
|
4
|
+
// #64 toJsonSchema scalar/enum/nullable (+ tag mapping and variant/aggregation
|
|
5
|
+
// logic that the shared golden suite exercises). Build-time, no reflection.
|
|
6
|
+
//
|
|
7
|
+
// The scalar/variant walk used to live here as `scalarSchema` — one of the four
|
|
8
|
+
// independent walkers over column metadata catalogued in `PLAN-type-first.md` §1.
|
|
9
|
+
// It now delegates to `../ir`, so naming a variant and naming a derived type cannot
|
|
10
|
+
// produce different documents: both are read off the same `SchemaIR`, and the emitter is
|
|
11
|
+
// a pure function of it (REQ-TF-7). What is left in this file is the OpenAPI framing —
|
|
12
|
+
// components, list/search envelopes, naming — which is genuinely its own concern.
|
|
13
|
+
import { singularPascalCase } from '../naming/index.js';
|
|
14
|
+
|
|
15
|
+
export type { JsonSchemaObject, Variant };
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The document for a type, computed at build time (REQ-TF-7).
|
|
19
|
+
*
|
|
20
|
+
* `toJsonSchema<ReadDTO<User>>()` is replaced by the document itself — an object
|
|
21
|
+
* literal, frozen, with no schema value and no reflection left in the bundle. The
|
|
22
|
+
* variant is the type argument rather than a string, so `toJsonSchema<CreateDTO<User>>()`
|
|
23
|
+
* is the create body and `toJsonSchema<Pick<Entity<User>, 'id' | 'email'>>()` is a
|
|
24
|
+
* projection nothing in the string-variant vocabulary could express.
|
|
25
|
+
*
|
|
26
|
+
* Untransformed it throws, and that is the design (plan D4). There is no honest
|
|
27
|
+
* fallback: the document is a function of a type, types do not exist at runtime, and the
|
|
28
|
+
* alternatives are to return something wrong or to ask the caller to hand over the very
|
|
29
|
+
* thing the call exists to compute. A build that skipped the transform should fail
|
|
30
|
+
* loudly at the first call, not serve a plausible document.
|
|
31
|
+
*/
|
|
32
|
+
// oxlint-disable-next-line no-unused-vars -- `T` is the whole input; it has nowhere else to appear
|
|
33
|
+
export function toJsonSchema<T>(): JsonSchemaObject;
|
|
34
|
+
/** The document for a schema value and a named variant. */
|
|
35
|
+
export function toJsonSchema(schema: CoreSchema<string>, variant?: Variant): JsonSchemaObject;
|
|
36
|
+
export function toJsonSchema(schema?: CoreSchema<string>, variant: Variant = 'entity'): JsonSchemaObject {
|
|
37
|
+
if (!schema) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
'toJsonSchema<T>() was not replaced at build time. It is compiled away by @zmdb/compiler ' +
|
|
40
|
+
'(the unplugin, Metro adapter, or project compiler), which did not run over this file — a type argument cannot ' +
|
|
41
|
+
'be read at runtime, so there is nothing to fall back to.',
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
return jsonSchemaFromIR(schema.ir, variant);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The `components.schemas` key for a table, and the target of every `$ref` that points at
|
|
49
|
+
* it: singularized, then PascalCase. `user_addresses` → `UserAddress`.
|
|
50
|
+
*
|
|
51
|
+
* Exported because it is the only way to write a `$ref` by hand that resolves against a
|
|
52
|
+
* document this module produced, and because it is the whole subject of
|
|
53
|
+
* `singularization.spec.ts` — which used to reach it by declaring twenty schemas whose only
|
|
54
|
+
* distinguishing feature was the table name.
|
|
55
|
+
*/
|
|
56
|
+
export function componentName(table: string): string {
|
|
57
|
+
return singularPascalCase(table);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The entity schema with a `$ref` per relation: a to-one refs the target component, a to-many
|
|
62
|
+
* is an array of them.
|
|
63
|
+
*
|
|
64
|
+
* Relations reach the `entity` (response) variant only. A create or update body is columns,
|
|
65
|
+
* and a `$ref` to a whole entity in one would say the client may post a nested graph, which
|
|
66
|
+
* no write path here accepts.
|
|
67
|
+
*
|
|
68
|
+
* There used to be a second parameter — a `Record<string, RelationLike>` naming each relation
|
|
69
|
+
* and its target table — because a schema value carried no relations for this to read. It
|
|
70
|
+
* carries them now, on `schema.ir`, so a document generated from a set of schemas can no
|
|
71
|
+
* longer disagree with the tables about which relations exist. The kinds are the IR's
|
|
72
|
+
* (`oneToMany`, not `one-to-many`).
|
|
73
|
+
*/
|
|
74
|
+
export function toJsonSchemaWithRelations(schema: CoreSchema<string>, variant: Variant = 'entity'): JsonSchemaObject {
|
|
75
|
+
const base = toJsonSchema(schema, variant);
|
|
76
|
+
if (variant !== 'entity') return base; // input bodies exclude relations
|
|
77
|
+
const properties: Record<string, unknown> = { ...base.properties };
|
|
78
|
+
for (const rel of schema.ir.relations) {
|
|
79
|
+
const ref = { $ref: `#/components/schemas/${componentName(rel.target)}` };
|
|
80
|
+
const toMany = rel.relation === 'oneToMany' || rel.relation === 'manyToMany';
|
|
81
|
+
properties[rel.name] = toMany ? { type: 'array', items: ref } : ref;
|
|
82
|
+
}
|
|
83
|
+
return { type: 'object', properties, required: base.required };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function toOpenApiComponents(schemas: readonly CoreSchema<string>[]): {
|
|
87
|
+
schemas: Record<string, JsonSchemaObject>;
|
|
88
|
+
} {
|
|
89
|
+
const out: Record<string, JsonSchemaObject> = {};
|
|
90
|
+
for (const s of [...schemas].toSorted((a, b) => a.ir.table.localeCompare(b.ir.table))) {
|
|
91
|
+
out[componentName(s.ir.table)] = toJsonSchema(s, 'entity');
|
|
92
|
+
}
|
|
93
|
+
return { schemas: out };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// #175 — read-variant envelopes. list wraps the entity in a paged envelope;
|
|
97
|
+
// search adds an optional _score to each item. Build-time, deterministic.
|
|
98
|
+
export interface EnvelopeSchema {
|
|
99
|
+
readonly type: 'object';
|
|
100
|
+
readonly properties: Readonly<Record<string, unknown>>;
|
|
101
|
+
readonly required: readonly string[];
|
|
102
|
+
}
|
|
103
|
+
export function toListSchema(schema: CoreSchema<string>): EnvelopeSchema {
|
|
104
|
+
const entity = toJsonSchema(schema, 'entity');
|
|
105
|
+
return {
|
|
106
|
+
type: 'object',
|
|
107
|
+
properties: {
|
|
108
|
+
items: { type: 'array', items: entity },
|
|
109
|
+
total: { type: 'integer' },
|
|
110
|
+
hasMore: { type: 'boolean' },
|
|
111
|
+
cursor: { type: 'string' },
|
|
112
|
+
},
|
|
113
|
+
required: ['hasMore', 'items'],
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
export function toSearchSchema(schema: CoreSchema<string>): EnvelopeSchema {
|
|
117
|
+
const entity = toJsonSchema(schema, 'entity');
|
|
118
|
+
const hit = {
|
|
119
|
+
type: 'object',
|
|
120
|
+
properties: { ...entity.properties, _score: { type: 'number' } },
|
|
121
|
+
required: entity.required,
|
|
122
|
+
};
|
|
123
|
+
return {
|
|
124
|
+
type: 'object',
|
|
125
|
+
properties: {
|
|
126
|
+
items: { type: 'array', items: hit },
|
|
127
|
+
total: { type: 'integer' },
|
|
128
|
+
hasMore: { type: 'boolean' },
|
|
129
|
+
cursor: { type: 'string' },
|
|
130
|
+
},
|
|
131
|
+
required: ['hasMore', 'items'],
|
|
132
|
+
};
|
|
133
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { type SchemaIR } from '../ir/index.js';
|
|
2
|
+
|
|
3
|
+
export interface ResolvedRelation {
|
|
4
|
+
readonly name: string;
|
|
5
|
+
/** Where the related rows live. */
|
|
6
|
+
readonly targetTable: string;
|
|
7
|
+
/** Ordered columns on the declaring table whose values the join matches. */
|
|
8
|
+
readonly parentKey: readonly string[];
|
|
9
|
+
/** Ordered columns on the target table, positionally paired with `parentKey`. */
|
|
10
|
+
readonly targetKey: readonly string[];
|
|
11
|
+
/** `true` for `oneToMany`: the relation attaches an array, empty where nothing matched. */
|
|
12
|
+
readonly toMany: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Resolve one relation of a table by name.
|
|
17
|
+
*
|
|
18
|
+
* Throws for a name the type does not declare — naming the ones it does, because a
|
|
19
|
+
* misspelled `populate` is the common case — and for `manyToMany`, whose `via` is a join
|
|
20
|
+
* table rather than a column: two hops cannot be expressed as one `IN`, and guessing the
|
|
21
|
+
* join table's two foreign keys from the table names either side is how a wrong query gets
|
|
22
|
+
* built quietly.
|
|
23
|
+
*/
|
|
24
|
+
export function resolveRelation(ir: SchemaIR, name: string): ResolvedRelation {
|
|
25
|
+
const declared = ir.relations;
|
|
26
|
+
const rel = declared.find(candidate => candidate.name === name);
|
|
27
|
+
if (!rel) {
|
|
28
|
+
const known = declared.map(candidate => candidate.name);
|
|
29
|
+
throw new Error(
|
|
30
|
+
`unknown relation "${name}" on ${ir.table}: ` +
|
|
31
|
+
(known.length > 0 ? `the type declares ${known.join(', ')}` : 'the type declares none'),
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
if (rel.relation === 'manyToMany') {
|
|
35
|
+
throw new Error(
|
|
36
|
+
`relation "${name}" on ${ir.table} is many-to-many through "${rel.via}", which populate ` +
|
|
37
|
+
'does not resolve — join the two tables explicitly',
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
if (rel.relation === 'oneToMany') {
|
|
41
|
+
// The inverse side: the foreign key is a column of the *target*, holding this row's key.
|
|
42
|
+
return inverseRelation(ir, name, rel, true);
|
|
43
|
+
}
|
|
44
|
+
const via = relationColumns(ir, name, rel.via);
|
|
45
|
+
if (rel.relation === 'oneToOne' && !via.every(column => ir.columns.some(candidate => candidate.name === column))) {
|
|
46
|
+
// A one-to-one pair is symmetric, so `OneToOne<'profiles', 'userId'>` does not say which
|
|
47
|
+
// of the two tables holds the key — and the answer is "the one with the column". Declared
|
|
48
|
+
// on `users`, which has no `userId`, it is the inverse side, joined from the primary key
|
|
49
|
+
// exactly as a to-many is; it just cannot match twice.
|
|
50
|
+
return inverseRelation(ir, name, rel, false);
|
|
51
|
+
}
|
|
52
|
+
// The owning side: this row holds the foreign key, and the column it points at is written
|
|
53
|
+
// down on that column, as `References<'users.id'>`.
|
|
54
|
+
return {
|
|
55
|
+
name,
|
|
56
|
+
targetTable: rel.target,
|
|
57
|
+
parentKey: via,
|
|
58
|
+
targetKey: via.map(column => referencedColumn(ir, name, rel.target, column, via.length > 1)),
|
|
59
|
+
toMany: false,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function relationColumns(ir: SchemaIR, relation: string, via: string): readonly string[] {
|
|
64
|
+
const columns = via.split(',').map(column => column.trim());
|
|
65
|
+
if (columns.some(column => column.length === 0)) {
|
|
66
|
+
throw new Error(`${ir.table}.${relation}: relation via "${via}" contains an empty column name`);
|
|
67
|
+
}
|
|
68
|
+
return columns;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The column a foreign key points at, per its `References<'table.column'>`; `id` without one. */
|
|
72
|
+
function referencedColumn(
|
|
73
|
+
ir: SchemaIR,
|
|
74
|
+
relation: string,
|
|
75
|
+
target: string,
|
|
76
|
+
fk: string,
|
|
77
|
+
requireReference: boolean,
|
|
78
|
+
): string {
|
|
79
|
+
const reference = ir.columns.find(col => col.name === fk)?.references;
|
|
80
|
+
const separator = reference?.lastIndexOf('.') ?? -1;
|
|
81
|
+
if (reference !== undefined && separator > 0 && separator < reference.length - 1) {
|
|
82
|
+
return reference.slice(separator + 1);
|
|
83
|
+
}
|
|
84
|
+
if (requireReference) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
`${ir.table}.${relation}: composite relation via column "${fk}" must carry ` +
|
|
87
|
+
`References<'${target}.column'>; every via column must name its target`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
return 'id';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function primaryKeyOf(ir: SchemaIR): readonly string[] {
|
|
94
|
+
if (ir.primaryKey.length === 0) {
|
|
95
|
+
throw new Error(`schema ${ir.table} has no primary key, so its relations have nothing to join from`);
|
|
96
|
+
}
|
|
97
|
+
return ir.primaryKey;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function relationTag(relation: 'manyToOne' | 'oneToMany' | 'oneToOne'): string {
|
|
101
|
+
switch (relation) {
|
|
102
|
+
case 'manyToOne':
|
|
103
|
+
return 'ManyToOne';
|
|
104
|
+
case 'oneToMany':
|
|
105
|
+
return 'OneToMany';
|
|
106
|
+
case 'oneToOne':
|
|
107
|
+
return 'OneToOne';
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function inverseRelation(
|
|
112
|
+
ir: SchemaIR,
|
|
113
|
+
name: string,
|
|
114
|
+
rel: SchemaIR['relations'][number],
|
|
115
|
+
toMany: boolean,
|
|
116
|
+
): ResolvedRelation {
|
|
117
|
+
if (rel.relation !== 'oneToMany' && rel.relation !== 'oneToOne') {
|
|
118
|
+
throw new Error(`${ir.table}.${name}: ${rel.relation} is not an inverse relation`);
|
|
119
|
+
}
|
|
120
|
+
const parentKey = primaryKeyOf(ir);
|
|
121
|
+
const targetKey = relationColumns(ir, name, rel.via);
|
|
122
|
+
if (parentKey.length !== targetKey.length) {
|
|
123
|
+
const tag = relationTag(rel.relation);
|
|
124
|
+
const missing = Math.max(0, parentKey.length - targetKey.length);
|
|
125
|
+
const suggestedVia = [...parentKey.slice(0, missing), ...targetKey].join(',');
|
|
126
|
+
const targetLabel = targetKey.length === 1 ? 'column' : 'columns';
|
|
127
|
+
throw new Error(
|
|
128
|
+
`${ir.table}.${name}: ${tag}<'${rel.target}', '${rel.via}'> supplies ${String(targetKey.length)} target ` +
|
|
129
|
+
`${targetLabel} for a ${String(parentKey.length)}-column parent key (${parentKey.join(', ')}); ` +
|
|
130
|
+
`name every column, in key order — ${tag}<'${rel.target}', '${suggestedVia}'>`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
return { name, targetTable: rel.target, parentKey, targetKey, toMany };
|
|
134
|
+
}
|