shapecraft 1.0.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.
- package/CLAUDE.md +227 -0
- package/README.md +22 -0
- package/apps/cli/node_modules/.bin/prettier +21 -0
- package/apps/cli/node_modules/.bin/tsc +21 -0
- package/apps/cli/node_modules/.bin/tsserver +21 -0
- package/apps/cli/node_modules/.bin/tsx +21 -0
- package/apps/cli/node_modules/.bin/vitest +21 -0
- package/apps/cli/package.json +47 -0
- package/apps/cli/src/index.ts +98 -0
- package/apps/cli/tsconfig.cjs.json +10 -0
- package/apps/cli/tsconfig.esm.json +10 -0
- package/apps/cli/tsconfig.json +22 -0
- package/package.json +16 -0
- package/packages/core/node_modules/.bin/prettier +21 -0
- package/packages/core/node_modules/.bin/tsc +21 -0
- package/packages/core/node_modules/.bin/tsserver +21 -0
- package/packages/core/node_modules/.bin/tsx +21 -0
- package/packages/core/node_modules/.bin/vitest +21 -0
- package/packages/core/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/packages/core/package.json +44 -0
- package/packages/core/src/common/array.test.ts +19 -0
- package/packages/core/src/common/array.ts +15 -0
- package/packages/core/src/common/index.ts +5 -0
- package/packages/core/src/common/is.ts +23 -0
- package/packages/core/src/common/object.ts +35 -0
- package/packages/core/src/common/phantom.ts +1 -0
- package/packages/core/src/common/result.ts +43 -0
- package/packages/core/src/common/string.ts +28 -0
- package/packages/core/src/common/types.ts +34 -0
- package/packages/core/src/index.ts +1 -0
- package/packages/core/src/shape/annotate.ts +139 -0
- package/packages/core/src/shape/annotation.ts +47 -0
- package/packages/core/src/shape/base.ts +71 -0
- package/packages/core/src/shape/builder.test.ts +728 -0
- package/packages/core/src/shape/builder.ts +475 -0
- package/packages/core/src/shape/error.ts +4 -0
- package/packages/core/src/shape/index.ts +3 -0
- package/packages/core/src/shape/number.ts +118 -0
- package/packages/core/src/shape/shape.test.ts +792 -0
- package/packages/core/src/shape/shape.ts +377 -0
- package/packages/core/src/shape/tags.ts +14 -0
- package/packages/core/src/shape/transforms/index.ts +3 -0
- package/packages/core/src/shape/transforms/json-schema/index.ts +2 -0
- package/packages/core/src/shape/transforms/json-schema/transform.test.ts +850 -0
- package/packages/core/src/shape/transforms/json-schema/transform.ts +882 -0
- package/packages/core/src/shape/transforms/json-schema/types.ts +132 -0
- package/packages/core/src/shape/transforms/sql/dialects/dialect.ts +89 -0
- package/packages/core/src/shape/transforms/sql/dialects/index.ts +14 -0
- package/packages/core/src/shape/transforms/sql/dialects/postgres.ts +392 -0
- package/packages/core/src/shape/transforms/sql/dialects/sqlite.ts +333 -0
- package/packages/core/src/shape/transforms/sql/from-sql.test.ts +704 -0
- package/packages/core/src/shape/transforms/sql/from-sql.ts +210 -0
- package/packages/core/src/shape/transforms/sql/index.ts +3 -0
- package/packages/core/src/shape/transforms/sql/options.ts +6 -0
- package/packages/core/src/shape/transforms/sql/parser/check-decoder.ts +457 -0
- package/packages/core/src/shape/transforms/sql/parser/create-domain.ts +105 -0
- package/packages/core/src/shape/transforms/sql/parser/create-table.ts +809 -0
- package/packages/core/src/shape/transforms/sql/parser/create-type.ts +91 -0
- package/packages/core/src/shape/transforms/sql/parser/cursor.ts +179 -0
- package/packages/core/src/shape/transforms/sql/parser/default-decoder.ts +129 -0
- package/packages/core/src/shape/transforms/sql/parser/lexer.ts +289 -0
- package/packages/core/src/shape/transforms/sql/parser/pg-types.ts +247 -0
- package/packages/core/src/shape/transforms/sql/parser/sqlite-types.ts +103 -0
- package/packages/core/src/shape/transforms/sql/parser/statements.ts +127 -0
- package/packages/core/src/shape/transforms/sql/parser/type-spec.ts +159 -0
- package/packages/core/src/shape/transforms/sql/transform.sqlite.test.ts +448 -0
- package/packages/core/src/shape/transforms/sql/transform.test.ts +880 -0
- package/packages/core/src/shape/transforms/sql/transform.ts +295 -0
- package/packages/core/src/shape/transforms/typescript/index.ts +1 -0
- package/packages/core/src/shape/transforms/typescript/transform.ts +211 -0
- package/packages/core/src/shape/tuple.test.ts +171 -0
- package/packages/core/src/shape/validate.ts +413 -0
- package/packages/core/tsconfig.cjs.json +11 -0
- package/packages/core/tsconfig.esm.json +10 -0
- package/packages/core/tsconfig.json +23 -0
- package/packages/samples/node_modules/.bin/prettier +21 -0
- package/packages/samples/node_modules/.bin/tsc +21 -0
- package/packages/samples/node_modules/.bin/tsserver +21 -0
- package/packages/samples/node_modules/.bin/tsx +21 -0
- package/packages/samples/node_modules/.bin/vitest +21 -0
- package/packages/samples/package.json +47 -0
- package/packages/samples/src/blog.ts +49 -0
- package/packages/samples/src/config.ts +50 -0
- package/packages/samples/src/ecommerce.ts +65 -0
- package/packages/samples/src/embeddings.ts +43 -0
- package/packages/samples/src/events.ts +52 -0
- package/packages/samples/src/geometry.ts +62 -0
- package/packages/samples/src/index.ts +9 -0
- package/packages/samples/src/relational.ts +17 -0
- package/packages/samples/src/tuples.ts +67 -0
- package/packages/samples/src/user.ts +9 -0
- package/packages/samples/tsconfig.cjs.json +11 -0
- package/packages/samples/tsconfig.esm.json +10 -0
- package/packages/samples/tsconfig.json +23 -0
- package/pnpm-workspace.yaml +3 -0
- package/test-data/json-schema/address.json +35 -0
- package/test-data/json-schema/array-of-things.json +36 -0
- package/test-data/json-schema/basic.json +21 -0
- package/test-data/json-schema/blog-post.json +29 -0
- package/test-data/json-schema/calendar.json +48 -0
- package/test-data/json-schema/complex-object-with-nested-properties.json +41 -0
- package/test-data/json-schema/ecommerce-complex.json +344 -0
- package/test-data/json-schema/ecommerce-system.json +27 -0
- package/test-data/json-schema/enumerated-values.json +11 -0
- package/test-data/json-schema/fstab-entry.json +92 -0
- package/test-data/json-schema/geographical-location.json +20 -0
- package/test-data/json-schema/health-record.json +41 -0
- package/test-data/json-schema/job-posting.json +33 -0
- package/test-data/json-schema/movie.json +35 -0
- package/test-data/json-schema/regular-expression-pattern.json +12 -0
- package/test-data/json-schema/user-profile.json +33 -0
- package/test-data/sql/ecommerce.sql +641 -0
|
@@ -0,0 +1,882 @@
|
|
|
1
|
+
import { annotate, shapes } from "../../shape";
|
|
2
|
+
import { NumberTag } from "../../tags";
|
|
3
|
+
import {
|
|
4
|
+
JSONSchema,
|
|
5
|
+
JSONSchemaDefinition,
|
|
6
|
+
JSONSchemaTypeName,
|
|
7
|
+
ShapecraftMeta,
|
|
8
|
+
} from "./types";
|
|
9
|
+
|
|
10
|
+
const INT_RANGES: Record<string, { minimum?: number; maximum?: number }> = {
|
|
11
|
+
int8: { minimum: -128, maximum: 127 },
|
|
12
|
+
uint8: { minimum: 0, maximum: 255 },
|
|
13
|
+
int16: { minimum: -32768, maximum: 32767 },
|
|
14
|
+
uint16: { minimum: 0, maximum: 65535 },
|
|
15
|
+
int32: { minimum: -2_147_483_648, maximum: 2_147_483_647 },
|
|
16
|
+
uint32: { minimum: 0, maximum: 4_294_967_295 },
|
|
17
|
+
int64: { minimum: Number.MIN_SAFE_INTEGER, maximum: Number.MAX_SAFE_INTEGER },
|
|
18
|
+
uint64: { minimum: 0, maximum: Number.MAX_SAFE_INTEGER },
|
|
19
|
+
int: {},
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const FLOAT32_MAX = 3.4028234663852886e38;
|
|
23
|
+
|
|
24
|
+
const FLOAT_RANGES: Record<string, { minimum?: number; maximum?: number }> = {
|
|
25
|
+
float32: { minimum: -FLOAT32_MAX, maximum: FLOAT32_MAX },
|
|
26
|
+
float64: {},
|
|
27
|
+
float: {},
|
|
28
|
+
number: {},
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const numberTagRange = (
|
|
32
|
+
tag: NumberTag,
|
|
33
|
+
): { type: "integer" | "number"; minimum?: number; maximum?: number } => {
|
|
34
|
+
if (tag in INT_RANGES) return { type: "integer", ...(INT_RANGES[tag] ?? {}) };
|
|
35
|
+
return { type: "number", ...(FLOAT_RANGES[tag] ?? {}) };
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const attachMeta = (out: JSONSchema, meta: ShapecraftMeta): JSONSchema =>
|
|
39
|
+
Object.keys(meta).length === 0 ? out : { ...out, "x-shapecraft": meta };
|
|
40
|
+
|
|
41
|
+
const isLiteralScalar = (s: shapes.Shape): boolean =>
|
|
42
|
+
(s.type === "string" || s.type === "number" || s.type === "boolean") &&
|
|
43
|
+
s.literal === true;
|
|
44
|
+
|
|
45
|
+
type ModuleCtx = {
|
|
46
|
+
tablesByName: Record<string, shapes.ShapeMapping>;
|
|
47
|
+
anchorOf: Record<string, string | undefined>;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const refTo = (tableName: string, ctx: ModuleCtx): string => {
|
|
51
|
+
const anchor = ctx.anchorOf[tableName];
|
|
52
|
+
return anchor !== undefined ? `#${anchor}` : `#/$defs/${tableName}`;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const convert = (
|
|
56
|
+
shape: shapes.Shape,
|
|
57
|
+
topLevel: boolean,
|
|
58
|
+
modCtx?: ModuleCtx,
|
|
59
|
+
): JSONSchema => {
|
|
60
|
+
if (
|
|
61
|
+
modCtx !== undefined &&
|
|
62
|
+
shape.anno.foreign !== undefined &&
|
|
63
|
+
shape.anno.foreign.table in modCtx.tablesByName
|
|
64
|
+
) {
|
|
65
|
+
return { $ref: refTo(shape.anno.foreign.table, modCtx) };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const meta: ShapecraftMeta = {};
|
|
69
|
+
if (shape.anno.unique) meta.unique = true;
|
|
70
|
+
if (shape.anno.primary) meta.primary = true;
|
|
71
|
+
if (shape.anno.foreign) meta.foreign = shape.anno.foreign;
|
|
72
|
+
if (topLevel && shape.anno.optional) meta.optional = true;
|
|
73
|
+
|
|
74
|
+
let out: JSONSchema = {};
|
|
75
|
+
switch (shape.type) {
|
|
76
|
+
case "string": {
|
|
77
|
+
if (shape.literal) {
|
|
78
|
+
out = { const: shape.input as string };
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
out = { type: "string" };
|
|
82
|
+
if (shape.anno.min !== undefined) out.minLength = shape.anno.min;
|
|
83
|
+
if (shape.anno.max !== undefined) out.maxLength = shape.anno.max;
|
|
84
|
+
if (shape.anno.pattern !== undefined) {
|
|
85
|
+
out.pattern =
|
|
86
|
+
shape.anno.pattern instanceof RegExp ?
|
|
87
|
+
shape.anno.pattern.source
|
|
88
|
+
: shape.anno.pattern;
|
|
89
|
+
}
|
|
90
|
+
if (shape.anno.format !== undefined) out.format = shape.anno.format;
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
case "number": {
|
|
94
|
+
if (shape.literal) {
|
|
95
|
+
out = { const: shape.input as number };
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
const r = numberTagRange(shape.tag);
|
|
99
|
+
out = { type: r.type };
|
|
100
|
+
if (r.minimum !== undefined) out.minimum = r.minimum;
|
|
101
|
+
if (r.maximum !== undefined) out.maximum = r.maximum;
|
|
102
|
+
if (shape.anno.min !== undefined)
|
|
103
|
+
out.minimum =
|
|
104
|
+
out.minimum === undefined ?
|
|
105
|
+
shape.anno.min
|
|
106
|
+
: Math.max(out.minimum, shape.anno.min);
|
|
107
|
+
if (shape.anno.max !== undefined)
|
|
108
|
+
out.maximum =
|
|
109
|
+
out.maximum === undefined ?
|
|
110
|
+
shape.anno.max
|
|
111
|
+
: Math.min(out.maximum, shape.anno.max);
|
|
112
|
+
meta.numberTag = shape.tag;
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
case "boolean": {
|
|
116
|
+
out =
|
|
117
|
+
shape.literal ? { const: shape.input as boolean } : { type: "boolean" };
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
case "nil":
|
|
121
|
+
out = { type: "null" };
|
|
122
|
+
break;
|
|
123
|
+
case "undefined":
|
|
124
|
+
out = {};
|
|
125
|
+
meta.kind = "undefined";
|
|
126
|
+
break;
|
|
127
|
+
case "unknown":
|
|
128
|
+
out = {};
|
|
129
|
+
break;
|
|
130
|
+
case "date":
|
|
131
|
+
out = { type: "string", format: "date-time" };
|
|
132
|
+
meta.kind = "date";
|
|
133
|
+
break;
|
|
134
|
+
case "binary":
|
|
135
|
+
out = { type: "string", contentEncoding: "base64" };
|
|
136
|
+
meta.kind = "binary";
|
|
137
|
+
break;
|
|
138
|
+
case "array": {
|
|
139
|
+
out = { type: "array", items: convert(shape.input, false, modCtx) };
|
|
140
|
+
if (shape.anno.min !== undefined) out.minItems = shape.anno.min;
|
|
141
|
+
if (shape.anno.max !== undefined) out.maxItems = shape.anno.max;
|
|
142
|
+
if (shape.anno.uniqueItems) out.uniqueItems = true;
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
case "vector": {
|
|
146
|
+
out = {
|
|
147
|
+
type: "array",
|
|
148
|
+
items: convert(shape.input.format, false, modCtx),
|
|
149
|
+
minItems: shape.input.dims,
|
|
150
|
+
maxItems: shape.input.dims,
|
|
151
|
+
};
|
|
152
|
+
meta.kind = "vector";
|
|
153
|
+
meta.vectorDims = shape.input.dims;
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
case "tuple": {
|
|
157
|
+
out = {
|
|
158
|
+
type: "array",
|
|
159
|
+
prefixItems: shape.input.map((s) => convert(s, false, modCtx)),
|
|
160
|
+
minItems: shape.input.length,
|
|
161
|
+
maxItems: shape.input.length,
|
|
162
|
+
};
|
|
163
|
+
meta.kind = "tuple";
|
|
164
|
+
meta.tupleLen = shape.input.length;
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
case "range": {
|
|
168
|
+
const itemSchema = convert(shape.input, false, modCtx);
|
|
169
|
+
out = {
|
|
170
|
+
type: "object",
|
|
171
|
+
properties: {
|
|
172
|
+
lower: { anyOf: [itemSchema, { type: "null" }] },
|
|
173
|
+
upper: { anyOf: [itemSchema, { type: "null" }] },
|
|
174
|
+
lowerInclusive: { type: "boolean" },
|
|
175
|
+
upperInclusive: { type: "boolean" },
|
|
176
|
+
},
|
|
177
|
+
required: ["lower", "upper", "lowerInclusive", "upperInclusive"],
|
|
178
|
+
additionalProperties: false,
|
|
179
|
+
};
|
|
180
|
+
meta.kind = "range";
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
case "mapping": {
|
|
184
|
+
const properties: Record<string, JSONSchema> = {};
|
|
185
|
+
const required: string[] = [];
|
|
186
|
+
for (const [k, child] of Object.entries(shape.input)) {
|
|
187
|
+
properties[k] = convert(child, false, modCtx);
|
|
188
|
+
if (!child.anno.optional) required.push(k);
|
|
189
|
+
}
|
|
190
|
+
out = { type: "object", properties, additionalProperties: false };
|
|
191
|
+
if (required.length > 0) out.required = required;
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
case "record": {
|
|
195
|
+
const [keyShape, valShape] = shape.input;
|
|
196
|
+
out = {
|
|
197
|
+
type: "object",
|
|
198
|
+
additionalProperties: convert(valShape, false, modCtx),
|
|
199
|
+
};
|
|
200
|
+
if (keyShape.type === "string" && keyShape.anno.pattern !== undefined) {
|
|
201
|
+
const src =
|
|
202
|
+
keyShape.anno.pattern instanceof RegExp ?
|
|
203
|
+
keyShape.anno.pattern.source
|
|
204
|
+
: keyShape.anno.pattern;
|
|
205
|
+
out.propertyNames = { type: "string", pattern: src };
|
|
206
|
+
} else if (keyShape.type === "number") {
|
|
207
|
+
out.propertyNames = { type: "string", pattern: "^-?\\d+(\\.\\d+)?$" };
|
|
208
|
+
}
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
case "union": {
|
|
212
|
+
const variants = Array.from(shape.input) as shapes.Shape[];
|
|
213
|
+
if (variants.length > 0 && variants.every(isLiteralScalar)) {
|
|
214
|
+
out = {
|
|
215
|
+
enum: variants.map(
|
|
216
|
+
(v) =>
|
|
217
|
+
(
|
|
218
|
+
v as
|
|
219
|
+
| shapes.ShapeString
|
|
220
|
+
| shapes.ShapeNumber
|
|
221
|
+
| shapes.ShapeBoolean
|
|
222
|
+
).input as string | number | boolean,
|
|
223
|
+
),
|
|
224
|
+
};
|
|
225
|
+
} else {
|
|
226
|
+
out = { anyOf: variants.map((v) => convert(v, false, modCtx)) };
|
|
227
|
+
}
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
case "module": {
|
|
231
|
+
const tablesByName: Record<string, shapes.ShapeMapping> = {};
|
|
232
|
+
const anchorOf: Record<string, string | undefined> = {};
|
|
233
|
+
for (const [k, table] of Object.entries(shape.input)) {
|
|
234
|
+
tablesByName[k] = table;
|
|
235
|
+
const a = table.anno.meta?.anchor;
|
|
236
|
+
anchorOf[k] = typeof a === "string" ? a : undefined;
|
|
237
|
+
}
|
|
238
|
+
const ctx: ModuleCtx = { tablesByName, anchorOf };
|
|
239
|
+
const defs: Record<string, JSONSchema> = {};
|
|
240
|
+
for (const [k, table] of Object.entries(shape.input)) {
|
|
241
|
+
const sub = convert(table, false, ctx);
|
|
242
|
+
const anchor = anchorOf[k];
|
|
243
|
+
defs[k] = anchor !== undefined ? { $anchor: anchor, ...sub } : sub;
|
|
244
|
+
}
|
|
245
|
+
out = { $defs: defs };
|
|
246
|
+
break;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (shape.anno.title !== undefined) out.title = shape.anno.title;
|
|
251
|
+
if (shape.anno.description !== undefined)
|
|
252
|
+
out.description = shape.anno.description;
|
|
253
|
+
|
|
254
|
+
return attachMeta(out, meta);
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
export const toJSONSchema = (shape: shapes.Shape): JSONSchema =>
|
|
258
|
+
convert(shape, true);
|
|
259
|
+
|
|
260
|
+
const numberByTag = (tag: NumberTag): shapes.ShapeNumber => {
|
|
261
|
+
switch (tag) {
|
|
262
|
+
case "int":
|
|
263
|
+
return shapes.int();
|
|
264
|
+
case "int8":
|
|
265
|
+
return shapes.int8();
|
|
266
|
+
case "uint8":
|
|
267
|
+
return shapes.uint8();
|
|
268
|
+
case "int16":
|
|
269
|
+
return shapes.int16();
|
|
270
|
+
case "uint16":
|
|
271
|
+
return shapes.uint16();
|
|
272
|
+
case "int32":
|
|
273
|
+
return shapes.int32();
|
|
274
|
+
case "uint32":
|
|
275
|
+
return shapes.uint32();
|
|
276
|
+
case "int64":
|
|
277
|
+
return shapes.int64();
|
|
278
|
+
case "uint64":
|
|
279
|
+
return shapes.uint64();
|
|
280
|
+
case "float":
|
|
281
|
+
return shapes.float();
|
|
282
|
+
case "float32":
|
|
283
|
+
return shapes.float32();
|
|
284
|
+
case "float64":
|
|
285
|
+
return shapes.float64();
|
|
286
|
+
default:
|
|
287
|
+
return shapes.number();
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
const naturalRangeForTag = (
|
|
292
|
+
tag: NumberTag,
|
|
293
|
+
): { minimum?: number; maximum?: number } => {
|
|
294
|
+
if (tag in INT_RANGES) return INT_RANGES[tag] ?? {};
|
|
295
|
+
return FLOAT_RANGES[tag] ?? {};
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
const asSchema = (def: JSONSchemaDefinition | undefined): JSONSchema => {
|
|
299
|
+
if (def === undefined || def === true) return {};
|
|
300
|
+
if (def === false) return {};
|
|
301
|
+
return def;
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
// JSON Schema doesn't require `type` — when `properties`/`items` are present,
|
|
305
|
+
// the type is implied. Infer it so we don't fall through to `unknown`.
|
|
306
|
+
const effectiveType = (s: JSONSchema): JSONSchemaTypeName | undefined => {
|
|
307
|
+
if (typeof s.type === "string") return s.type;
|
|
308
|
+
if (Array.isArray(s.type) && s.type.length > 0) return s.type[0];
|
|
309
|
+
if (
|
|
310
|
+
s.properties !== undefined ||
|
|
311
|
+
s.additionalProperties !== undefined ||
|
|
312
|
+
s.patternProperties !== undefined ||
|
|
313
|
+
s.required !== undefined ||
|
|
314
|
+
s.propertyNames !== undefined ||
|
|
315
|
+
s.minProperties !== undefined ||
|
|
316
|
+
s.maxProperties !== undefined
|
|
317
|
+
)
|
|
318
|
+
return "object";
|
|
319
|
+
if (
|
|
320
|
+
s.items !== undefined ||
|
|
321
|
+
s.uniqueItems !== undefined ||
|
|
322
|
+
s.minItems !== undefined ||
|
|
323
|
+
s.maxItems !== undefined
|
|
324
|
+
)
|
|
325
|
+
return "array";
|
|
326
|
+
return undefined;
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
const literalFromValue = (v: unknown): shapes.Shape => {
|
|
330
|
+
if (typeof v === "string" || typeof v === "number" || typeof v === "boolean")
|
|
331
|
+
return shapes.literal(v);
|
|
332
|
+
if (v === null) return shapes.nil();
|
|
333
|
+
return shapes.unknown();
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
// oneOf/anyOf variants are often refinements on a sibling `type` (e.g.
|
|
337
|
+
// `{type: "string", oneOf: [{format: "ipv4"}, ...]}`). Inherit the parent's
|
|
338
|
+
// effective type into variants that don't declare their own, so each variant
|
|
339
|
+
// resolves to a real shape rather than `unknown`. Drop any variants that
|
|
340
|
+
// still resolve to `unknown` after that, and fall back to type-based handling
|
|
341
|
+
// if nothing substantive survives.
|
|
342
|
+
const combinatorBase = (
|
|
343
|
+
schema: JSONSchema,
|
|
344
|
+
idx: RefIndex | undefined,
|
|
345
|
+
): shapes.Shape | undefined => {
|
|
346
|
+
const variantsRaw = schema.anyOf ?? schema.oneOf;
|
|
347
|
+
if (variantsRaw === undefined || variantsRaw.length === 0) return undefined;
|
|
348
|
+
const parentType = effectiveType(schema);
|
|
349
|
+
const all = variantsRaw.map((v) => {
|
|
350
|
+
const vs = asSchema(v);
|
|
351
|
+
const merged: JSONSchema =
|
|
352
|
+
(
|
|
353
|
+
vs.type === undefined &&
|
|
354
|
+
parentType !== undefined &&
|
|
355
|
+
vs.$ref === undefined
|
|
356
|
+
) ?
|
|
357
|
+
{ type: parentType, ...vs }
|
|
358
|
+
: vs;
|
|
359
|
+
return convertFrom(merged, false, idx);
|
|
360
|
+
});
|
|
361
|
+
const substantive = all.filter((s) => s.type !== "unknown");
|
|
362
|
+
if (substantive.length === 0) return undefined;
|
|
363
|
+
if (substantive.length === 1) return substantive[0];
|
|
364
|
+
return shapes.union(substantive);
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
const convertFrom = (
|
|
368
|
+
schema: JSONSchema,
|
|
369
|
+
topLevel: boolean,
|
|
370
|
+
idx?: RefIndex,
|
|
371
|
+
): shapes.Shape => {
|
|
372
|
+
if (idx !== undefined && typeof schema.$ref === "string") {
|
|
373
|
+
const key = resolveRef(schema.$ref, idx);
|
|
374
|
+
const target = key !== null ? idx.defs[key] : undefined;
|
|
375
|
+
if (target !== undefined) return convertFrom(target, false, idx);
|
|
376
|
+
return shapes.unknown();
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const meta: ShapecraftMeta = schema["x-shapecraft"] ?? {};
|
|
380
|
+
|
|
381
|
+
let base: shapes.Shape;
|
|
382
|
+
const combinator = combinatorBase(schema, idx);
|
|
383
|
+
|
|
384
|
+
if (schema.const !== undefined) {
|
|
385
|
+
base = literalFromValue(schema.const);
|
|
386
|
+
} else if (
|
|
387
|
+
schema.enum !== undefined &&
|
|
388
|
+
Array.isArray(schema.enum) &&
|
|
389
|
+
schema.enum.length > 0
|
|
390
|
+
) {
|
|
391
|
+
base = shapes.union(schema.enum.map(literalFromValue));
|
|
392
|
+
} else if (combinator !== undefined) {
|
|
393
|
+
base = combinator;
|
|
394
|
+
} else if (meta.kind === "undefined") {
|
|
395
|
+
base = shapes.notDefined();
|
|
396
|
+
} else if (meta.kind === "date") {
|
|
397
|
+
base = shapes.date();
|
|
398
|
+
} else if (meta.kind === "binary") {
|
|
399
|
+
base = shapes.binary();
|
|
400
|
+
} else if (meta.kind === "vector") {
|
|
401
|
+
const itemSchema = asSchema(
|
|
402
|
+
Array.isArray(schema.items) ? schema.items[0] : schema.items,
|
|
403
|
+
);
|
|
404
|
+
const itemTag = itemSchema["x-shapecraft"]?.numberTag ?? "number";
|
|
405
|
+
const format = numberByTag(itemTag);
|
|
406
|
+
const dims = meta.vectorDims ?? schema.minItems ?? 0;
|
|
407
|
+
base = shapes.vector(dims, format);
|
|
408
|
+
} else if (
|
|
409
|
+
meta.kind === "tuple" ||
|
|
410
|
+
Array.isArray(schema.prefixItems) ||
|
|
411
|
+
(Array.isArray(schema.items) && schema.items.length >= 2)
|
|
412
|
+
) {
|
|
413
|
+
const items =
|
|
414
|
+
schema.prefixItems ?? (Array.isArray(schema.items) ? schema.items : []);
|
|
415
|
+
base = shapes.tuple(
|
|
416
|
+
...items.map((s) => convertFrom(asSchema(s), false, idx)),
|
|
417
|
+
);
|
|
418
|
+
} else if (meta.kind === "range") {
|
|
419
|
+
const lowerDef = schema.properties?.["lower"];
|
|
420
|
+
const lowerSchema = asSchema(lowerDef);
|
|
421
|
+
const variants = lowerSchema.anyOf ?? lowerSchema.oneOf ?? undefined;
|
|
422
|
+
const inner =
|
|
423
|
+
variants !== undefined ?
|
|
424
|
+
(variants.map((v) => asSchema(v)).find((v) => v.type !== "null") ?? {})
|
|
425
|
+
: lowerSchema;
|
|
426
|
+
base = shapes.range(convertFrom(inner, false, idx));
|
|
427
|
+
} else {
|
|
428
|
+
switch (effectiveType(schema)) {
|
|
429
|
+
case "null":
|
|
430
|
+
base = shapes.nil();
|
|
431
|
+
break;
|
|
432
|
+
case "boolean":
|
|
433
|
+
base = shapes.boolean();
|
|
434
|
+
break;
|
|
435
|
+
case "string": {
|
|
436
|
+
if (
|
|
437
|
+
schema.format === "date-time" ||
|
|
438
|
+
schema.format === "date" ||
|
|
439
|
+
schema.format === "timestamp" ||
|
|
440
|
+
schema.format === "time"
|
|
441
|
+
) {
|
|
442
|
+
base = shapes.date();
|
|
443
|
+
} else if (schema.contentEncoding === "base64") {
|
|
444
|
+
base = shapes.binary();
|
|
445
|
+
} else {
|
|
446
|
+
let s: shapes.ShapeString = shapes.string();
|
|
447
|
+
if (schema.pattern !== undefined)
|
|
448
|
+
s = annotate.pattern(s, schema.pattern);
|
|
449
|
+
if (schema.minLength !== undefined)
|
|
450
|
+
s = annotate.min(s, schema.minLength);
|
|
451
|
+
if (schema.maxLength !== undefined)
|
|
452
|
+
s = annotate.max(s, schema.maxLength);
|
|
453
|
+
if (schema.format !== undefined)
|
|
454
|
+
s = annotate.format(s, schema.format);
|
|
455
|
+
base = s;
|
|
456
|
+
}
|
|
457
|
+
break;
|
|
458
|
+
}
|
|
459
|
+
case "integer":
|
|
460
|
+
case "number": {
|
|
461
|
+
const tag: NumberTag =
|
|
462
|
+
meta.numberTag ?? (schema.type === "integer" ? "int" : "number");
|
|
463
|
+
let n: shapes.ShapeNumber = numberByTag(tag);
|
|
464
|
+
const natural = naturalRangeForTag(tag);
|
|
465
|
+
if (
|
|
466
|
+
schema.minimum !== undefined &&
|
|
467
|
+
(natural.minimum === undefined || schema.minimum !== natural.minimum)
|
|
468
|
+
)
|
|
469
|
+
n = annotate.min(n, schema.minimum);
|
|
470
|
+
if (
|
|
471
|
+
schema.maximum !== undefined &&
|
|
472
|
+
(natural.maximum === undefined || schema.maximum !== natural.maximum)
|
|
473
|
+
)
|
|
474
|
+
n = annotate.max(n, schema.maximum);
|
|
475
|
+
base = n;
|
|
476
|
+
break;
|
|
477
|
+
}
|
|
478
|
+
case "array": {
|
|
479
|
+
const itemSchema = asSchema(
|
|
480
|
+
Array.isArray(schema.items) ? schema.items[0] : schema.items,
|
|
481
|
+
);
|
|
482
|
+
let a: shapes.ShapeArray = shapes.array(
|
|
483
|
+
convertFrom(itemSchema, false, idx),
|
|
484
|
+
);
|
|
485
|
+
if (schema.minItems !== undefined) a = annotate.min(a, schema.minItems);
|
|
486
|
+
if (schema.maxItems !== undefined) a = annotate.max(a, schema.maxItems);
|
|
487
|
+
if (schema.uniqueItems === true) a = annotate.uniqueItems(a);
|
|
488
|
+
base = a;
|
|
489
|
+
break;
|
|
490
|
+
}
|
|
491
|
+
case "object": {
|
|
492
|
+
if (schema.properties !== undefined) {
|
|
493
|
+
const required = new Set(schema.required ?? []);
|
|
494
|
+
const rec: Record<string, shapes.Shape> = {};
|
|
495
|
+
for (const [k, v] of Object.entries(schema.properties)) {
|
|
496
|
+
let child = convertFrom(asSchema(v), false, idx);
|
|
497
|
+
if (!required.has(k)) child = annotate.optional(child);
|
|
498
|
+
rec[k] = child;
|
|
499
|
+
}
|
|
500
|
+
base = shapes.mapping(rec);
|
|
501
|
+
} else if (
|
|
502
|
+
schema.additionalProperties !== undefined &&
|
|
503
|
+
schema.additionalProperties !== false
|
|
504
|
+
) {
|
|
505
|
+
const valShape = convertFrom(
|
|
506
|
+
asSchema(schema.additionalProperties),
|
|
507
|
+
false,
|
|
508
|
+
idx,
|
|
509
|
+
);
|
|
510
|
+
const pn =
|
|
511
|
+
(
|
|
512
|
+
schema.propertyNames !== undefined &&
|
|
513
|
+
schema.propertyNames !== true &&
|
|
514
|
+
schema.propertyNames !== false
|
|
515
|
+
) ?
|
|
516
|
+
schema.propertyNames
|
|
517
|
+
: undefined;
|
|
518
|
+
let keyShape: shapes.ShapeKeyable;
|
|
519
|
+
if (pn?.pattern === "^-?\\d+(\\.\\d+)?$") {
|
|
520
|
+
keyShape = shapes.number();
|
|
521
|
+
} else if (pn?.pattern !== undefined) {
|
|
522
|
+
keyShape = annotate.pattern(shapes.string(), pn.pattern);
|
|
523
|
+
} else {
|
|
524
|
+
keyShape = shapes.string();
|
|
525
|
+
}
|
|
526
|
+
base = shapes.record(keyShape, valShape);
|
|
527
|
+
} else {
|
|
528
|
+
base = shapes.unknown();
|
|
529
|
+
}
|
|
530
|
+
break;
|
|
531
|
+
}
|
|
532
|
+
default:
|
|
533
|
+
base = shapes.unknown();
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
if (meta.unique === true) base = annotate.unique(base);
|
|
538
|
+
if (meta.primary === true) base = annotate.primary(base);
|
|
539
|
+
if (meta.foreign !== undefined)
|
|
540
|
+
base = annotate.foreign(base, meta.foreign.table, meta.foreign.column);
|
|
541
|
+
if (topLevel && meta.optional === true) base = annotate.optional(base);
|
|
542
|
+
if (schema.title !== undefined) base = annotate.titled(base, schema.title);
|
|
543
|
+
if (schema.description !== undefined)
|
|
544
|
+
base = annotate.described(base, schema.description);
|
|
545
|
+
|
|
546
|
+
return base;
|
|
547
|
+
};
|
|
548
|
+
|
|
549
|
+
type RefIndex = {
|
|
550
|
+
defs: Record<string, JSONSchema>;
|
|
551
|
+
byAnchor: Map<string, string>;
|
|
552
|
+
byPath: Map<string, string>;
|
|
553
|
+
};
|
|
554
|
+
|
|
555
|
+
type PkInfo = {
|
|
556
|
+
column: string;
|
|
557
|
+
shape: shapes.Shape;
|
|
558
|
+
autoInjected: boolean;
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
type PkRegistry = Record<string, PkInfo>;
|
|
562
|
+
|
|
563
|
+
type JunctionSpec = {
|
|
564
|
+
parentTable: string;
|
|
565
|
+
property: string;
|
|
566
|
+
refTable: string;
|
|
567
|
+
};
|
|
568
|
+
|
|
569
|
+
const buildRefIndex = (schema: JSONSchema): RefIndex => {
|
|
570
|
+
const defs: Record<string, JSONSchema> = {};
|
|
571
|
+
const byAnchor = new Map<string, string>();
|
|
572
|
+
const byPath = new Map<string, string>();
|
|
573
|
+
const collect = (
|
|
574
|
+
rawDefs: Record<string, JSONSchemaDefinition> | undefined,
|
|
575
|
+
pathPrefix: string,
|
|
576
|
+
) => {
|
|
577
|
+
if (rawDefs === undefined) return;
|
|
578
|
+
for (const [key, defDef] of Object.entries(rawDefs)) {
|
|
579
|
+
if (typeof defDef !== "object" || defDef === null) continue;
|
|
580
|
+
const def = defDef as JSONSchema;
|
|
581
|
+
if (defs[key] === undefined) defs[key] = def;
|
|
582
|
+
byPath.set(`${pathPrefix}${key}`, key);
|
|
583
|
+
if (def.$anchor !== undefined) byAnchor.set(def.$anchor, key);
|
|
584
|
+
}
|
|
585
|
+
};
|
|
586
|
+
collect(schema.$defs, "#/$defs/");
|
|
587
|
+
collect(schema.definitions, "#/definitions/");
|
|
588
|
+
return { defs, byAnchor, byPath };
|
|
589
|
+
};
|
|
590
|
+
|
|
591
|
+
const resolveRef = (ref: string, idx: RefIndex): string | null => {
|
|
592
|
+
if (ref.startsWith("#/$defs/") || ref.startsWith("#/definitions/"))
|
|
593
|
+
return idx.byPath.get(ref) ?? null;
|
|
594
|
+
if (ref.startsWith("#") && !ref.startsWith("#/"))
|
|
595
|
+
return idx.byAnchor.get(ref.slice(1)) ?? null;
|
|
596
|
+
return null;
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
const detectPkPropertyName = (
|
|
600
|
+
defName: string,
|
|
601
|
+
def: JSONSchema,
|
|
602
|
+
): string | null => {
|
|
603
|
+
const required = new Set(def.required ?? []);
|
|
604
|
+
const props = def.properties ?? {};
|
|
605
|
+
const lower = defName.toLowerCase();
|
|
606
|
+
const candidates = ["id", `${lower}id`, `${lower}_id`];
|
|
607
|
+
for (const cand of candidates) {
|
|
608
|
+
for (const propName of Object.keys(props)) {
|
|
609
|
+
if (propName.toLowerCase() === cand && required.has(propName))
|
|
610
|
+
return propName;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
return null;
|
|
614
|
+
};
|
|
615
|
+
|
|
616
|
+
const bareTypeShape = (s: shapes.Shape): shapes.Shape => {
|
|
617
|
+
switch (s.type) {
|
|
618
|
+
case "number":
|
|
619
|
+
return numberByTag(s.tag);
|
|
620
|
+
case "string":
|
|
621
|
+
return shapes.string();
|
|
622
|
+
case "boolean":
|
|
623
|
+
return shapes.boolean();
|
|
624
|
+
case "date":
|
|
625
|
+
return shapes.date();
|
|
626
|
+
case "binary":
|
|
627
|
+
return shapes.binary();
|
|
628
|
+
default:
|
|
629
|
+
return s;
|
|
630
|
+
}
|
|
631
|
+
};
|
|
632
|
+
|
|
633
|
+
const lastSegment = (id: string): string => {
|
|
634
|
+
const slash = id.lastIndexOf("/");
|
|
635
|
+
const tail = slash >= 0 ? id.slice(slash + 1) : id;
|
|
636
|
+
const dot = tail.indexOf(".");
|
|
637
|
+
return dot >= 0 ? tail.slice(0, dot) : tail;
|
|
638
|
+
};
|
|
639
|
+
|
|
640
|
+
const topLevelTableName = (schema: JSONSchema): string => {
|
|
641
|
+
if (typeof schema.title === "string" && schema.title.length > 0)
|
|
642
|
+
return schema.title;
|
|
643
|
+
if (typeof schema.$id === "string" && schema.$id.length > 0)
|
|
644
|
+
return lastSegment(schema.$id);
|
|
645
|
+
return "root";
|
|
646
|
+
};
|
|
647
|
+
|
|
648
|
+
const buildPkInfo = (
|
|
649
|
+
defKey: string,
|
|
650
|
+
def: JSONSchema,
|
|
651
|
+
idx: RefIndex,
|
|
652
|
+
): PkInfo => {
|
|
653
|
+
const pkName = detectPkPropertyName(defKey, def);
|
|
654
|
+
if (pkName !== null) {
|
|
655
|
+
const propSchema = asSchema(def.properties?.[pkName]);
|
|
656
|
+
const propShape = convertFrom(propSchema, false, idx);
|
|
657
|
+
return {
|
|
658
|
+
column: pkName,
|
|
659
|
+
shape: bareTypeShape(propShape),
|
|
660
|
+
autoInjected: false,
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
const props = def.properties ?? {};
|
|
664
|
+
const hasLiteralId = Object.keys(props).some((n) => n.toLowerCase() === "id");
|
|
665
|
+
if (hasLiteralId) {
|
|
666
|
+
throw new Error(
|
|
667
|
+
`def '${defKey}' has an 'id' field that doesn't qualify as PK (not in required[])`,
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
return {
|
|
671
|
+
column: "id",
|
|
672
|
+
shape: shapes.int64(),
|
|
673
|
+
autoInjected: true,
|
|
674
|
+
};
|
|
675
|
+
};
|
|
676
|
+
|
|
677
|
+
const convertDefToTable = (
|
|
678
|
+
defKey: string,
|
|
679
|
+
def: JSONSchema,
|
|
680
|
+
idx: RefIndex,
|
|
681
|
+
pkRegistry: PkRegistry,
|
|
682
|
+
junctions: JunctionSpec[],
|
|
683
|
+
): shapes.ShapeMapping => {
|
|
684
|
+
const required = new Set(def.required ?? []);
|
|
685
|
+
const props = def.properties ?? {};
|
|
686
|
+
const pkInfo = pkRegistry[defKey];
|
|
687
|
+
|
|
688
|
+
const rec: Record<string, shapes.Shape> = {};
|
|
689
|
+
|
|
690
|
+
if (pkInfo !== undefined && pkInfo.autoInjected) {
|
|
691
|
+
rec[pkInfo.column] = annotate.autoIncremented(
|
|
692
|
+
annotate.primary(shapes.int64()),
|
|
693
|
+
);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
for (const [propName, propSchemaRaw] of Object.entries(props)) {
|
|
697
|
+
const propSchema = asSchema(propSchemaRaw);
|
|
698
|
+
|
|
699
|
+
if (typeof propSchema.$ref === "string") {
|
|
700
|
+
const targetKey = resolveRef(propSchema.$ref, idx);
|
|
701
|
+
const targetDef = targetKey !== null ? idx.defs[targetKey] : undefined;
|
|
702
|
+
if (targetKey === null || targetDef === undefined) {
|
|
703
|
+
let s: shapes.Shape = shapes.unknown();
|
|
704
|
+
if (!required.has(propName)) s = annotate.optional(s);
|
|
705
|
+
rec[propName] = s;
|
|
706
|
+
continue;
|
|
707
|
+
}
|
|
708
|
+
if (targetDef.type === "object") {
|
|
709
|
+
const targetPk = pkRegistry[targetKey];
|
|
710
|
+
if (targetPk === undefined || targetPk.shape.type === "unknown") {
|
|
711
|
+
throw new Error(
|
|
712
|
+
`def '${defKey}': $ref target '${targetKey}' has no usable PK type for FK`,
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
let fk: shapes.Shape = annotate.foreign(
|
|
716
|
+
bareTypeShape(targetPk.shape),
|
|
717
|
+
targetKey,
|
|
718
|
+
targetPk.column,
|
|
719
|
+
);
|
|
720
|
+
if (!required.has(propName)) fk = annotate.optional(fk);
|
|
721
|
+
rec[propName] = fk;
|
|
722
|
+
continue;
|
|
723
|
+
}
|
|
724
|
+
let inlined = convertFrom(targetDef, false, idx);
|
|
725
|
+
if (!required.has(propName)) inlined = annotate.optional(inlined);
|
|
726
|
+
rec[propName] = inlined;
|
|
727
|
+
continue;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
if (propSchema.type === "array") {
|
|
731
|
+
const itemSchema = asSchema(
|
|
732
|
+
Array.isArray(propSchema.items) ?
|
|
733
|
+
propSchema.items[0]
|
|
734
|
+
: propSchema.items,
|
|
735
|
+
);
|
|
736
|
+
if (typeof itemSchema.$ref === "string") {
|
|
737
|
+
const targetKey = resolveRef(itemSchema.$ref, idx);
|
|
738
|
+
const targetDef = targetKey !== null ? idx.defs[targetKey] : undefined;
|
|
739
|
+
if (
|
|
740
|
+
targetKey !== null &&
|
|
741
|
+
targetDef !== undefined &&
|
|
742
|
+
targetDef.type === "object" &&
|
|
743
|
+
pkRegistry[targetKey] !== undefined
|
|
744
|
+
) {
|
|
745
|
+
junctions.push({
|
|
746
|
+
parentTable: defKey,
|
|
747
|
+
property: propName,
|
|
748
|
+
refTable: targetKey,
|
|
749
|
+
});
|
|
750
|
+
continue;
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
if (
|
|
756
|
+
pkInfo !== undefined &&
|
|
757
|
+
!pkInfo.autoInjected &&
|
|
758
|
+
propName === pkInfo.column
|
|
759
|
+
) {
|
|
760
|
+
rec[propName] = annotate.primary(bareTypeShape(pkInfo.shape));
|
|
761
|
+
continue;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
let child = convertFrom(propSchema, false, idx);
|
|
765
|
+
if (!required.has(propName)) child = annotate.optional(child);
|
|
766
|
+
rec[propName] = child;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
let mapping: shapes.ShapeMapping = shapes.mapping(rec);
|
|
770
|
+
mapping = annotate.titled(mapping, defKey) as shapes.ShapeMapping;
|
|
771
|
+
if (typeof def.$anchor === "string") {
|
|
772
|
+
mapping = annotate.meta(mapping, {
|
|
773
|
+
anchor: def.$anchor,
|
|
774
|
+
}) as shapes.ShapeMapping;
|
|
775
|
+
}
|
|
776
|
+
if (typeof def.description === "string") {
|
|
777
|
+
mapping = annotate.described(
|
|
778
|
+
mapping,
|
|
779
|
+
def.description,
|
|
780
|
+
) as shapes.ShapeMapping;
|
|
781
|
+
}
|
|
782
|
+
return mapping;
|
|
783
|
+
};
|
|
784
|
+
|
|
785
|
+
const buildJunctionTable = (
|
|
786
|
+
spec: JunctionSpec,
|
|
787
|
+
pkRegistry: PkRegistry,
|
|
788
|
+
): { name: string; mapping: shapes.ShapeMapping } => {
|
|
789
|
+
const parentPk = pkRegistry[spec.parentTable];
|
|
790
|
+
const refPk = pkRegistry[spec.refTable];
|
|
791
|
+
if (parentPk === undefined || refPk === undefined) {
|
|
792
|
+
throw new Error(
|
|
793
|
+
`junction table ${spec.parentTable}_${spec.property}: missing PK for endpoint`,
|
|
794
|
+
);
|
|
795
|
+
}
|
|
796
|
+
const name = `${spec.parentTable}_${spec.property}`;
|
|
797
|
+
const parentCol = `${spec.parentTable}_id`;
|
|
798
|
+
const refCol = `${spec.property}_id`;
|
|
799
|
+
|
|
800
|
+
const parentFk = annotate.primary(
|
|
801
|
+
annotate.foreign(
|
|
802
|
+
bareTypeShape(parentPk.shape),
|
|
803
|
+
spec.parentTable,
|
|
804
|
+
parentPk.column,
|
|
805
|
+
),
|
|
806
|
+
);
|
|
807
|
+
const refFk = annotate.primary(
|
|
808
|
+
annotate.foreign(bareTypeShape(refPk.shape), spec.refTable, refPk.column),
|
|
809
|
+
);
|
|
810
|
+
|
|
811
|
+
const rec: Record<string, shapes.Shape> =
|
|
812
|
+
parentCol === refCol ?
|
|
813
|
+
// self-ref edge case: column-name collision impossible because we use
|
|
814
|
+
// `${property}_id` for the second column. If somehow they still match,
|
|
815
|
+
// fall back to a numeric suffix.
|
|
816
|
+
{ [parentCol]: parentFk, [`${refCol}_2`]: refFk }
|
|
817
|
+
: { [parentCol]: parentFk, [refCol]: refFk };
|
|
818
|
+
|
|
819
|
+
let mapping: shapes.ShapeMapping = shapes.mapping(rec);
|
|
820
|
+
mapping = annotate.titled(mapping, name) as shapes.ShapeMapping;
|
|
821
|
+
mapping = annotate.meta(mapping, { junction: true }) as shapes.ShapeMapping;
|
|
822
|
+
return { name, mapping };
|
|
823
|
+
};
|
|
824
|
+
|
|
825
|
+
const convertFromModule = (schema: JSONSchema): shapes.ShapeModule => {
|
|
826
|
+
const idx = buildRefIndex(schema);
|
|
827
|
+
|
|
828
|
+
const pkRegistry: PkRegistry = {};
|
|
829
|
+
for (const [defKey, def] of Object.entries(idx.defs)) {
|
|
830
|
+
if (def.type !== "object") continue;
|
|
831
|
+
pkRegistry[defKey] = buildPkInfo(defKey, def, idx);
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
const hasTopLevelObject =
|
|
835
|
+
schema.type === "object" && schema.properties !== undefined;
|
|
836
|
+
let topLevelKey: string | null = null;
|
|
837
|
+
if (hasTopLevelObject) {
|
|
838
|
+
topLevelKey = topLevelTableName(schema);
|
|
839
|
+
if (topLevelKey in pkRegistry) {
|
|
840
|
+
throw new Error(
|
|
841
|
+
`top-level table name '${topLevelKey}' collides with a $defs key`,
|
|
842
|
+
);
|
|
843
|
+
}
|
|
844
|
+
pkRegistry[topLevelKey] = buildPkInfo(topLevelKey, schema, idx);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
const tables: Record<string, shapes.ShapeMapping> = {};
|
|
848
|
+
const junctions: JunctionSpec[] = [];
|
|
849
|
+
|
|
850
|
+
for (const [defKey, def] of Object.entries(idx.defs)) {
|
|
851
|
+
if (def.type !== "object") continue;
|
|
852
|
+
tables[defKey] = convertDefToTable(defKey, def, idx, pkRegistry, junctions);
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
if (topLevelKey !== null) {
|
|
856
|
+
tables[topLevelKey] = convertDefToTable(
|
|
857
|
+
topLevelKey,
|
|
858
|
+
schema,
|
|
859
|
+
idx,
|
|
860
|
+
pkRegistry,
|
|
861
|
+
junctions,
|
|
862
|
+
);
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
for (const spec of junctions) {
|
|
866
|
+
const { name, mapping } = buildJunctionTable(spec, pkRegistry);
|
|
867
|
+
tables[name] = mapping;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
let mod: shapes.ShapeModule = shapes.module(tables);
|
|
871
|
+
if (typeof schema.title === "string")
|
|
872
|
+
mod = annotate.titled(mod, schema.title) as shapes.ShapeModule;
|
|
873
|
+
if (typeof schema.description === "string")
|
|
874
|
+
mod = annotate.described(mod, schema.description) as shapes.ShapeModule;
|
|
875
|
+
return mod;
|
|
876
|
+
};
|
|
877
|
+
|
|
878
|
+
export const fromJSONSchema = (schema: JSONSchema): shapes.Shape => {
|
|
879
|
+
if (schema.$defs !== undefined || schema.definitions !== undefined)
|
|
880
|
+
return convertFromModule(schema);
|
|
881
|
+
return convertFrom(schema, true);
|
|
882
|
+
};
|