@workerstack/database 0.21.0 → 0.22.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/README.md +197 -33
- package/docs/meta-contract.md +55 -1
- package/package.json +1 -6
- package/src/codegen/analyzer-types.ts +6 -1
- package/src/codegen/annotations.ts +66 -9
- package/src/codegen/format-constraints.ts +114 -0
- package/src/codegen/generators/dtos/entity-file.ts +2 -1
- package/src/codegen/generators/dtos/example-meta.ts +22 -2
- package/src/codegen/generators/dtos/field-schema.ts +31 -8
- package/src/codegen/generators/dtos/ip-patterns.ts +25 -11
- package/src/codegen/generators/dtos/number-range.ts +22 -3
- package/src/codegen/generators/dtos/schema-aliases.ts +5 -3
- package/src/codegen/generators/dtos/text-format.ts +63 -0
- package/src/codegen/generators/dtos/text-schema.ts +49 -2
- package/src/codegen/generators/manifest.ts +9 -1
- package/src/codegen/runtime/read-comb.ts +71 -3
- package/src/codegen/runtime/read-tables.ts +31 -19
- package/src/codegen/runtime/snapshot-types.ts +3 -1
- package/src/codegen/runtime/to-meta.ts +6 -3
- package/src/formats/catalog.ts +255 -0
- package/src/formats/iana-time-zone.ts +117 -0
- package/src/formats/iso3166-1.ts +78 -0
- package/src/formats/iso4217.ts +42 -0
- package/src/formats/iso639-1.ts +41 -0
- package/src/formats/uts35.ts +143 -0
- package/src/index.ts +5 -0
- package/src/meta/column.ts +22 -9
- package/src/pg/columns.ts +6 -4
- package/src/sqlite/columns.ts +6 -4
- package/src/sqlite/constraints.ts +120 -6
- package/types/codegen/analyzer-types.d.ts +6 -1
- package/types/codegen/analyzer-types.d.ts.map +1 -1
- package/types/codegen/annotations.d.ts +12 -7
- package/types/codegen/annotations.d.ts.map +1 -1
- package/types/codegen/format-constraints.d.ts +22 -0
- package/types/codegen/format-constraints.d.ts.map +1 -0
- package/types/codegen/generators/dtos/entity-file.d.ts.map +1 -1
- package/types/codegen/generators/dtos/example-meta.d.ts.map +1 -1
- package/types/codegen/generators/dtos/field-schema.d.ts.map +1 -1
- package/types/codegen/generators/dtos/ip-patterns.d.ts +7 -0
- package/types/codegen/generators/dtos/ip-patterns.d.ts.map +1 -1
- package/types/codegen/generators/dtos/number-range.d.ts +8 -0
- package/types/codegen/generators/dtos/number-range.d.ts.map +1 -1
- package/types/codegen/generators/dtos/schema-aliases.d.ts +1 -1
- package/types/codegen/generators/dtos/schema-aliases.d.ts.map +1 -1
- package/types/codegen/generators/dtos/text-format.d.ts +31 -0
- package/types/codegen/generators/dtos/text-format.d.ts.map +1 -0
- package/types/codegen/generators/dtos/text-schema.d.ts +8 -1
- package/types/codegen/generators/dtos/text-schema.d.ts.map +1 -1
- package/types/codegen/generators/manifest.d.ts +5 -0
- package/types/codegen/generators/manifest.d.ts.map +1 -1
- package/types/codegen/runtime/read-comb.d.ts +17 -3
- package/types/codegen/runtime/read-comb.d.ts.map +1 -1
- package/types/codegen/runtime/read-tables.d.ts.map +1 -1
- package/types/codegen/runtime/snapshot-types.d.ts +3 -1
- package/types/codegen/runtime/snapshot-types.d.ts.map +1 -1
- package/types/codegen/runtime/to-meta.d.ts.map +1 -1
- package/types/formats/catalog.d.ts +51 -0
- package/types/formats/catalog.d.ts.map +1 -0
- package/types/formats/iana-time-zone.d.ts +7 -0
- package/types/formats/iana-time-zone.d.ts.map +1 -0
- package/types/formats/iso3166-1.d.ts +12 -0
- package/types/formats/iso3166-1.d.ts.map +1 -0
- package/types/formats/iso4217.d.ts +7 -0
- package/types/formats/iso4217.d.ts.map +1 -0
- package/types/formats/iso639-1.d.ts +6 -0
- package/types/formats/iso639-1.d.ts.map +1 -0
- package/types/formats/uts35.d.ts +13 -0
- package/types/formats/uts35.d.ts.map +1 -0
- package/types/index.d.ts +5 -0
- package/types/index.d.ts.map +1 -1
- package/types/meta/column.d.ts +7 -1
- package/types/meta/column.d.ts.map +1 -1
- package/types/pg/columns.d.ts +3 -3
- package/types/pg/columns.d.ts.map +1 -1
- package/types/sqlite/columns.d.ts +3 -3
- package/types/sqlite/columns.d.ts.map +1 -1
- package/types/sqlite/constraints.d.ts +117 -5
- package/types/sqlite/constraints.d.ts.map +1 -1
- package/src/sqlite/patterns.ts +0 -19
- package/types/sqlite/patterns.d.ts +0 -19
- package/types/sqlite/patterns.d.ts.map +0 -1
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A column's `format` checked against its helper and its other keys: a format sets a text's
|
|
3
|
+
* pattern, case, trim and length and an integer's range, so a transform or a bound it already
|
|
4
|
+
* holds beside it is a second rule a reader of the tables file believes; an enum's values are
|
|
5
|
+
* checked against it here, at codegen, since the DTO is the enum itself.
|
|
6
|
+
*/
|
|
7
|
+
import {
|
|
8
|
+
FORMATS,
|
|
9
|
+
formatTakes,
|
|
10
|
+
INTEGER_FORMATS,
|
|
11
|
+
INTEGER_SPECS,
|
|
12
|
+
type IntegerFormat,
|
|
13
|
+
TEXT_FORMATS,
|
|
14
|
+
type TextFormat,
|
|
15
|
+
} from "../formats/catalog.ts"
|
|
16
|
+
import type { CombColumnKind } from "../meta/column.ts"
|
|
17
|
+
import type { ConstantValue } from "./analyzer-types.ts"
|
|
18
|
+
|
|
19
|
+
/* the transforms a text declares, each of which a format sets itself */
|
|
20
|
+
const TRANSFORMS = ["lowercase", "trim", "uppercase"] as const
|
|
21
|
+
|
|
22
|
+
/** A bound's side, as a format's range names it in an error */
|
|
23
|
+
type Side = "max" | "min"
|
|
24
|
+
|
|
25
|
+
/** Whether `value` is one of `ids` */
|
|
26
|
+
const isOneOf = <F extends string>(ids: readonly F[], value: ConstantValue): value is F =>
|
|
27
|
+
(ids as readonly ConstantValue[]).includes(value)
|
|
28
|
+
|
|
29
|
+
/** A format's id as an error names it, with the article it is read with: "an iso4217", "a bcp47" */
|
|
30
|
+
const anId = (format: TextFormat): string => `${/^(?:[aeio]|rfc|srgb)/.test(format) ? "an" : "a"} ${format}`
|
|
31
|
+
|
|
32
|
+
/** A text format's length on one side, as an error names it: its fixed length, else that side's */
|
|
33
|
+
function lengthOf(format: TextFormat, side: Side): string {
|
|
34
|
+
const { max, min } = FORMATS[format]
|
|
35
|
+
return min === max ? `length ${min}` : `${side} length ${side === "max" ? max : min}`
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Refuses a declared bound the format's range already holds, or one that leaves no value in it;
|
|
40
|
+
* `named` is the range's side as an error reads it
|
|
41
|
+
*/
|
|
42
|
+
function refuseBounds(
|
|
43
|
+
constraints: Readonly<Record<string, ConstantValue>>,
|
|
44
|
+
{ least, most, named, place }: { least: number; most: number | null; named: (side: Side) => string; place: string },
|
|
45
|
+
): void {
|
|
46
|
+
const { max, min } = constraints
|
|
47
|
+
if (typeof max === "number" && most !== null && max > most)
|
|
48
|
+
throw new Error(`${place}: max ${max} is past ${named("max")}: the format holds it`)
|
|
49
|
+
if (typeof max === "number" && max < least)
|
|
50
|
+
throw new Error(`${place}: max ${max} is below ${named("min")}: no value is both`)
|
|
51
|
+
if (typeof min === "number" && most !== null && min > most)
|
|
52
|
+
throw new Error(`${place}: min ${min} is past ${named("max")}: no value is both`)
|
|
53
|
+
if (typeof min === "number" && min < least)
|
|
54
|
+
throw new Error(`${place}: min ${min} is below ${named("min")}: the format holds it`)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Refuses the first of an enum's values its format does not take as written, naming the standard's form where the format's transforms reach it */
|
|
58
|
+
function refuseEnumValues(format: TextFormat, { place, values }: { place: string; values: readonly string[] }): void {
|
|
59
|
+
const { transform } = FORMATS[format]
|
|
60
|
+
const value = values.find((each) => !formatTakes(format, each))
|
|
61
|
+
if (value === undefined) return
|
|
62
|
+
const written = transform.reduce(
|
|
63
|
+
(each, step) => (step === "trim" ? each.trim() : step === "lowercase" ? each.toLowerCase() : each.toUpperCase()),
|
|
64
|
+
value,
|
|
65
|
+
)
|
|
66
|
+
const hint = formatTakes(format, written) ? `; write the standard's form, ${JSON.stringify(written)}` : ""
|
|
67
|
+
throw new Error(`${place}: ${JSON.stringify(value)} is no ${format} value${hint}`)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Refuses what a text format already sets: a declared transform, or a length bound outside its range */
|
|
71
|
+
function refuseTextKeys(format: TextFormat, constraints: Readonly<Record<string, ConstantValue>>, place: string): void {
|
|
72
|
+
const transform = TRANSFORMS.find((each) => constraints[each] === true)
|
|
73
|
+
if (transform !== undefined)
|
|
74
|
+
throw new Error(`${place}: ${anId(format)} text takes no ${transform}: the format sets its case and trim`)
|
|
75
|
+
const { max, min } = FORMATS[format]
|
|
76
|
+
refuseBounds(constraints, { least: min, most: max, named: (side) => `${format}'s ${lengthOf(format, side)}`, place })
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The format a column declares, checked against its helper: an id the helper's kind knows (an
|
|
81
|
+
* integer format on c.integer, a text format on c.text and c.enum), no transform or bound the
|
|
82
|
+
* format already sets or that leaves it no value, and every c.enum value one the format takes as
|
|
83
|
+
* written. Null when none is declared; an error at `place` otherwise. The helper key check refuses a
|
|
84
|
+
* format on any other helper first, and a text's kind check one beside a kind
|
|
85
|
+
*/
|
|
86
|
+
export function formatOf(
|
|
87
|
+
constraints: Readonly<Record<string, ConstantValue>>,
|
|
88
|
+
{ helper, place, values }: { helper: CombColumnKind | null; place: string; values: readonly string[] | null },
|
|
89
|
+
): IntegerFormat | TextFormat | null {
|
|
90
|
+
const format = constraints["format"]
|
|
91
|
+
if (format === undefined) return null
|
|
92
|
+
const integer = helper === "integer"
|
|
93
|
+
if (integer ? !isOneOf(INTEGER_FORMATS, format) : !isOneOf(TEXT_FORMATS, format)) {
|
|
94
|
+
const known = isOneOf(TEXT_FORMATS, format) || isOneOf(INTEGER_FORMATS, format)
|
|
95
|
+
const ids = integer ? `integer formats: ${INTEGER_FORMATS.join(", ")}` : `text formats: ${TEXT_FORMATS.join(", ")}`
|
|
96
|
+
throw new Error(
|
|
97
|
+
`${place}: ${known ? `c.${helper} takes no format` : "comb knows no format"} ${JSON.stringify(format)}; ${ids}`,
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
if (isOneOf(INTEGER_FORMATS, format)) {
|
|
101
|
+
const { max, min } = INTEGER_SPECS[format]
|
|
102
|
+
refuseBounds(constraints, {
|
|
103
|
+
least: min,
|
|
104
|
+
most: max,
|
|
105
|
+
named: (side) => `${format}'s ${side === "max" ? max : min}`,
|
|
106
|
+
place,
|
|
107
|
+
})
|
|
108
|
+
return format
|
|
109
|
+
}
|
|
110
|
+
const text = format as TextFormat
|
|
111
|
+
if (helper === "enum") refuseEnumValues(text, { place, values: values ?? [] })
|
|
112
|
+
else refuseTextKeys(text, constraints, place)
|
|
113
|
+
return text
|
|
114
|
+
}
|
|
@@ -193,7 +193,8 @@ function importLines(
|
|
|
193
193
|
const lines = [`import * as z from "zod"`]
|
|
194
194
|
if (table.combMeta) lines.push(`import { combMeta } from "${options.metaImportFrom ?? "@workerstack/database/meta"}"`)
|
|
195
195
|
/* a local schema kept beside a c.json import may name a comb predicate */
|
|
196
|
-
if (body.some((line) => COMB_NAMESPACE.test(line)))
|
|
196
|
+
if (body.some((line) => COMB_NAMESPACE.test(line)))
|
|
197
|
+
lines.push(combImport(options.combFrom ?? "@workerstack/database"))
|
|
197
198
|
lines.push(...schemaImportLines(options.schemaImports, options.entityDir, options.importStyle))
|
|
198
199
|
const shared = sharedImports(table, spread, options.sharedSchemas)
|
|
199
200
|
if (shared.length > 0) {
|
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
* objects over inline ones.
|
|
4
4
|
*/
|
|
5
5
|
import type { FieldMeta } from "../../analyzer-types.ts"
|
|
6
|
+
import { declaredBounds } from "./number-range.ts"
|
|
7
|
+
import { textFormatOf } from "./text-format.ts"
|
|
8
|
+
import { textRules } from "./text-schema.ts"
|
|
6
9
|
|
|
7
10
|
const EXAMPLE_ULID = "01h5a3g8k9m2n4p6q8r0t2v4x6"
|
|
8
11
|
const ALPHANUMERIC_CYCLE = "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0"
|
|
@@ -66,10 +69,12 @@ const NO_EXAMPLE_KINDS: ReadonlySet<string> = /* @__PURE__ */ new Set([
|
|
|
66
69
|
])
|
|
67
70
|
|
|
68
71
|
/* sensitive or structured values get no example: the schema says enough, the value would leak */
|
|
69
|
-
function hasNoExample({ combKind, constraints, dataType, dimensions, foreignKey }: FieldMeta): boolean {
|
|
72
|
+
function hasNoExample({ combKind, constraints, dataType, dimensions, foreignKey, schemaJson }: FieldMeta): boolean {
|
|
70
73
|
/* a pattern decides what the value looks like, which no generic example can know; an array's
|
|
71
74
|
* example would be its element's, of the wrong shape */
|
|
72
75
|
if (constraints.private || constraints.password || constraints.pattern !== null || foreignKey !== null) return true
|
|
76
|
+
/* a schema's rule may be a refine its published JSON Schema does not state, so comb knows no value it takes */
|
|
77
|
+
if (schemaJson !== null) return true
|
|
73
78
|
if ((dimensions ?? 0) > 0) return true
|
|
74
79
|
if (combKind === null) return dataType === "object json"
|
|
75
80
|
return NO_EXAMPLE_KINDS.has(combKind)
|
|
@@ -83,7 +88,8 @@ const SHARED_NUMBER = { integer: 42, real: 0.5, timestamp: 1_711_468_800_000 }
|
|
|
83
88
|
* or the one bound it misses
|
|
84
89
|
*/
|
|
85
90
|
function midpoint(field: FieldMeta, key: keyof typeof SHARED_NUMBER): ExampleResult {
|
|
86
|
-
|
|
91
|
+
/* an integer format's range is a declared bound, as the DTO checks it */
|
|
92
|
+
const { max, min } = declaredBounds(field.constraints)
|
|
87
93
|
const fits = (value: number) => (min === null || value >= min) && (max === null || value <= max)
|
|
88
94
|
if (fits(SHARED_NUMBER[key])) return shared(key)
|
|
89
95
|
/* an integer's example is an integer within its bounds, a fractional bound rounded inward */
|
|
@@ -120,6 +126,19 @@ function textExample({ constraints: { max, min } }: FieldMeta): ExampleResult {
|
|
|
120
126
|
return inline(JSON.stringify(cycleOf(max !== null && max < word ? max : (min as number))))
|
|
121
127
|
}
|
|
122
128
|
|
|
129
|
+
/**
|
|
130
|
+
* A text format's example, the catalog's value of its standard as written, which its DTO takes;
|
|
131
|
+
* none where the column's own range, its declared bounds and length, leaves it out
|
|
132
|
+
*/
|
|
133
|
+
function formatExample(field: FieldMeta): ExampleResult | null | undefined {
|
|
134
|
+
const format = textFormatOf(field.constraints)
|
|
135
|
+
if (format === null) return undefined
|
|
136
|
+
const { max, min } = textRules(field.constraints, field)
|
|
137
|
+
const { length } = format.spec.example
|
|
138
|
+
if ((max !== null && length > max) || (min !== null && length < min)) return null
|
|
139
|
+
return inline(JSON.stringify(format.spec.example))
|
|
140
|
+
}
|
|
141
|
+
|
|
123
142
|
/** A rule's answer: an example, `null` for none at all, `undefined` when the field is not its kind */
|
|
124
143
|
type Rule = (field: FieldMeta) => ExampleResult | null | undefined
|
|
125
144
|
|
|
@@ -146,6 +165,7 @@ const RULES: Rule[] = [
|
|
|
146
165
|
/* a state column's example is the value a row starts at, which its create alone takes */
|
|
147
166
|
({ enumValues, states }) =>
|
|
148
167
|
enumValues !== null && enumValues.length > 0 ? inline(JSON.stringify(states?.initial ?? enumValues[0])) : undefined,
|
|
168
|
+
formatExample,
|
|
149
169
|
(field) =>
|
|
150
170
|
field.combKind === "integer" || field.combKind === "real" || field.combKind === "timestamp"
|
|
151
171
|
? midpoint(field, field.combKind)
|
|
@@ -13,6 +13,7 @@ import { STORABLE_TEXT_RULE } from "../../../storable-text.ts"
|
|
|
13
13
|
import {
|
|
14
14
|
boundCode,
|
|
15
15
|
clamp,
|
|
16
|
+
declaredBounds,
|
|
16
17
|
integerRange,
|
|
17
18
|
isInteger,
|
|
18
19
|
isNumeric,
|
|
@@ -21,7 +22,7 @@ import {
|
|
|
21
22
|
type Range,
|
|
22
23
|
SAFE,
|
|
23
24
|
} from "./number-range.ts"
|
|
24
|
-
import { stated, textBase, textRules } from "./text-schema.ts"
|
|
25
|
+
import { chainAllOf, stated, textBase, textRules } from "./text-schema.ts"
|
|
25
26
|
|
|
26
27
|
/** A builder's answer: its base, or `undefined` when the column is not its kind */
|
|
27
28
|
type Built = Base | undefined
|
|
@@ -30,12 +31,14 @@ type Built = Base | undefined
|
|
|
30
31
|
* A c.enum, a pgEnum or an enum text column, its values read at runtime and written out; a state
|
|
31
32
|
* column's create takes its machine's `initial` alone, the value a row starts at
|
|
32
33
|
*/
|
|
33
|
-
function enumBase({ element, field, schemaType }: Build): Built {
|
|
34
|
+
function enumBase({ annotations, element, field, schemaType }: Build): Built {
|
|
34
35
|
if (field.enumValues === null) return undefined
|
|
36
|
+
/* the values are the rule, each one codegen checked against the format; the format is what they mean */
|
|
37
|
+
const keys = annotations.format === null ? {} : { "x-comb-format": annotations.format }
|
|
35
38
|
const initial = field.states?.initial ?? null
|
|
36
39
|
if (schemaType === "create" && initial !== null && element !== true)
|
|
37
|
-
return plain(`z.literal(${JSON.stringify(initial)})
|
|
38
|
-
return plain(`z.enum(${JSON.stringify(field.enumValues)})
|
|
40
|
+
return plain(`z.literal(${JSON.stringify(initial)})`, keys)
|
|
41
|
+
return plain(`z.enum(${JSON.stringify(field.enumValues)})`, keys)
|
|
39
42
|
}
|
|
40
43
|
|
|
41
44
|
/* no engine stores U+0000 or a lone UTF-16 surrogate as sent: jsonb refuses both, Postgres text
|
|
@@ -101,8 +104,11 @@ function integerOf(annotations: FieldConstraints, range: Range, schemaType: Sche
|
|
|
101
104
|
`z.number().int().min(${boundCode(range.min)}).max(${boundCode(range.max)})`,
|
|
102
105
|
stated({ maximum: annotations.max, minimum: annotations.min }),
|
|
103
106
|
)
|
|
107
|
+
/* an integer format's range is a declared bound, and its id what the number means */
|
|
108
|
+
const { max, min } = declaredBounds(annotations)
|
|
104
109
|
return plain(
|
|
105
|
-
`z.number().int().min(${boundCode(clamp(
|
|
110
|
+
`z.number().int().min(${boundCode(clamp(min, range.min, Math.max))}).max(${boundCode(clamp(max, range.max, Math.min))})`,
|
|
111
|
+
annotations.format === null ? {} : { "x-comb-format": annotations.format },
|
|
106
112
|
)
|
|
107
113
|
}
|
|
108
114
|
|
|
@@ -217,13 +223,30 @@ export const generateFieldSchema = (
|
|
|
217
223
|
options: FieldSchemaOptions,
|
|
218
224
|
): string | null => fieldSchemaOf(field, schemaType, options)?.code ?? null
|
|
219
225
|
|
|
220
|
-
/** The Zod base of a column of one value: the first builder's that takes it, else text */
|
|
226
|
+
/** The Zod base of a column of one value: the first builder's that takes it, else text, piped into its schema */
|
|
221
227
|
function baseOf(build: Build): Base {
|
|
222
228
|
for (const builder of BUILDERS) {
|
|
223
229
|
const base = builder(build)
|
|
224
|
-
if (base !== undefined) return base
|
|
230
|
+
if (base !== undefined) return piped(build, base, [])
|
|
225
231
|
}
|
|
226
|
-
return textBase(build)
|
|
232
|
+
return piped(build, textBase(build), chainAllOf(build))
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* `base` piped into the schema a text or number column declares, after every rule of comb's, so
|
|
237
|
+
* the schema reads the rewritten value and its issues reach the caller as it yields them; a read
|
|
238
|
+
* never refuses a stored row, so it takes none. zod publishes a pipe's input side, the base, so the
|
|
239
|
+
* schema's own input JSON Schema is published in an `allOf` after the chain's (`own`), which that
|
|
240
|
+
* `allOf` is written over, and its export's name is a key. The `allOf` is on the code, not the
|
|
241
|
+
* keys: a nullable's keys also sit beside its `anyOf`, where the schema's `type` would refuse the
|
|
242
|
+
* null the field takes
|
|
243
|
+
*/
|
|
244
|
+
function piped(build: Build, base: Base, own: readonly unknown[]): Base {
|
|
245
|
+
const { field, schemaType } = build
|
|
246
|
+
const [ref] = field.schemaImports
|
|
247
|
+
if (!isMutative(schemaType) || field.schemaJson === null || ref === undefined) return base
|
|
248
|
+
const code = `${base.code}.pipe(${ref.name})${metaOf({ allOf: [...own, field.schemaJson] })}`
|
|
249
|
+
return { ...base, code, keys: { ...base.keys, "x-comb-schema": ref.imported } }
|
|
227
250
|
}
|
|
228
251
|
|
|
229
252
|
/** What a written value of a column is held to: its bounds and the regexes it must match */
|
|
@@ -10,22 +10,27 @@
|
|
|
10
10
|
*/
|
|
11
11
|
const GROUP = "[0-9A-Fa-f]{1,4}"
|
|
12
12
|
const OCTET = "(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])"
|
|
13
|
+
/** Postgres's IPv4 tail: two groups, or two to four octets */
|
|
13
14
|
const TAIL = `(?:${GROUP}:${GROUP}|${OCTET}(?:\\.${OCTET}){1,3})`
|
|
15
|
+
/** A dotted quad: four decimal octets, none with a leading zero */
|
|
16
|
+
const QUAD = `${OCTET}(?:\\.${OCTET}){3}`
|
|
17
|
+
/** RFC 4291's IPv4 tail: two groups, or a dotted quad */
|
|
18
|
+
const STRICT_TAIL = `(?:${GROUP}:${GROUP}|${QUAD})`
|
|
14
19
|
const PREFIX = "(?:12[0-8]|1[01][0-9]|[1-9]?[0-9])"
|
|
15
20
|
|
|
16
21
|
/** At most `most` + 1 groups split by `:`, or none: what stands before a `::` */
|
|
17
22
|
const before = (most: number): string => `(?:(?:${GROUP}:){0,${most}}${GROUP})?`
|
|
18
23
|
|
|
19
|
-
/** An IPv6 address's source: eight groups, or a `::` with up to seven around it, the last two
|
|
20
|
-
function address(): string {
|
|
24
|
+
/** An IPv6 address's source: eight groups, or a `::` with up to seven around it, the last two `tail` */
|
|
25
|
+
function address(tail: string): string {
|
|
21
26
|
return [
|
|
22
|
-
`(?:${GROUP}:){6}${
|
|
23
|
-
`::(?:${GROUP}:){5}${
|
|
24
|
-
`${before(0)}::(?:${GROUP}:){4}${
|
|
25
|
-
`${before(1)}::(?:${GROUP}:){3}${
|
|
26
|
-
`${before(2)}::(?:${GROUP}:){2}${
|
|
27
|
-
`${before(3)}::${GROUP}:${
|
|
28
|
-
`${before(4)}::${
|
|
27
|
+
`(?:${GROUP}:){6}${tail}`,
|
|
28
|
+
`::(?:${GROUP}:){5}${tail}`,
|
|
29
|
+
`${before(0)}::(?:${GROUP}:){4}${tail}`,
|
|
30
|
+
`${before(1)}::(?:${GROUP}:){3}${tail}`,
|
|
31
|
+
`${before(2)}::(?:${GROUP}:){2}${tail}`,
|
|
32
|
+
`${before(3)}::${GROUP}:${tail}`,
|
|
33
|
+
`${before(4)}::${tail}`,
|
|
29
34
|
`${before(5)}::${GROUP}`,
|
|
30
35
|
`${before(6)}::`,
|
|
31
36
|
].join("|")
|
|
@@ -39,11 +44,20 @@ function address(): string {
|
|
|
39
44
|
const unformatted = (rule: string): string => `${rule}.meta({ format: void 0 })`
|
|
40
45
|
|
|
41
46
|
/** An IPv6 address, as Postgres's inet reads one */
|
|
42
|
-
export const ipv6Rule = (): string => unformatted(`z.stringFormat("ipv6", /^(?:${address()})$/u)`)
|
|
47
|
+
export const ipv6Rule = (): string => unformatted(`z.stringFormat("ipv6", /^(?:${address(TAIL)})$/u)`)
|
|
43
48
|
|
|
44
49
|
/** An IPv6 network, an address and its prefix length, as Postgres's inet and cidr read one */
|
|
45
|
-
export const cidrv6Rule = (): string => unformatted(`z.stringFormat("cidrv6", /^(?:${address()})\\/${PREFIX}$/u)`)
|
|
50
|
+
export const cidrv6Rule = (): string => unformatted(`z.stringFormat("cidrv6", /^(?:${address(TAIL)})\\/${PREFIX}$/u)`)
|
|
46
51
|
|
|
47
52
|
/** A plain inet's rule: a host address, its netmask optional */
|
|
48
53
|
export const inetRule = (): string =>
|
|
49
54
|
`z.union([${unformatted("z.ipv4()")}, ${ipv6Rule()}, ${unformatted("z.cidrv4()")}, ${cidrv6Rule()}])`
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* An IPv6 address as RFC 4291 §2.2 writes one, unanchored: Postgres's rule but an IPv4 tail of four
|
|
58
|
+
* octets, never two or three, so `::ffff:1.2` is refused. The `ipv6` and `ip` text formats' source
|
|
59
|
+
*/
|
|
60
|
+
export const strictIpv6Source = (): string => address(STRICT_TAIL)
|
|
61
|
+
|
|
62
|
+
/** An IPv4 address as a dotted quad, unanchored: the `ipv4` and `ip` text formats' source */
|
|
63
|
+
export const ipv4Source = (): string => QUAD
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/** The range a number column holds and the bounds a written one takes, which the DTO checks and the manifest states. */
|
|
2
2
|
import type { FieldMeta } from "../../analyzer-types.ts"
|
|
3
3
|
import type { FieldConstraints } from "../../annotations.ts"
|
|
4
|
+
import { INTEGER_SPECS, type IntegerFormat } from "../../../formats/catalog.ts"
|
|
4
5
|
import type { Build } from "./build-types.ts"
|
|
5
6
|
|
|
6
7
|
/* Postgres stores c.integer in int4 */
|
|
@@ -49,14 +50,32 @@ export function integerRange(field: FieldMeta, dialect: Build["dialect"]): Range
|
|
|
49
50
|
return field.dataType === "number int16" ? INT2 : SAFE
|
|
50
51
|
}
|
|
51
52
|
|
|
53
|
+
/** Whether `format` is an integer format's id, which an integer column's format always is: codegen refused any other */
|
|
54
|
+
const isIntegerFormat = (format: FieldConstraints["format"]): format is IntegerFormat =>
|
|
55
|
+
format !== null && Object.hasOwn(INTEGER_SPECS, format)
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The bounds a written number is declared to: its own, joined with its integer format's range, the
|
|
59
|
+
* tighter of each pair winning
|
|
60
|
+
*/
|
|
61
|
+
export function declaredBounds({ format, max, min }: FieldConstraints): { max: number | null; min: number | null } {
|
|
62
|
+
if (!isIntegerFormat(format)) return { max, min }
|
|
63
|
+
const spec = INTEGER_SPECS[format]
|
|
64
|
+
return {
|
|
65
|
+
max: max === null ? spec.max : Math.min(max, spec.max),
|
|
66
|
+
min: min === null ? spec.min : Math.max(min, spec.min),
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
52
70
|
/** A declared bound clamped to the column's, the column's where none is declared */
|
|
53
71
|
export const clamp = (declared: number | null, bound: number, pick: (a: number, b: number) => number): number =>
|
|
54
72
|
declared === null ? bound : pick(declared, bound)
|
|
55
73
|
|
|
56
74
|
/** Refuses an integer's bounds, clamped to what the column holds, that leave no integer: every write would be refused */
|
|
57
75
|
export function refuseNoInteger(name: string, annotations: FieldConstraints, range: Range): void {
|
|
58
|
-
const
|
|
59
|
-
const
|
|
76
|
+
const declared = declaredBounds(annotations)
|
|
77
|
+
const min = clamp(declared.min, range.min, Math.max)
|
|
78
|
+
const max = clamp(declared.max, range.max, Math.min)
|
|
60
79
|
if (Math.ceil(min) > Math.floor(max))
|
|
61
80
|
throw new Error(`${name}: its bounds min ${min} and max ${max} leave no integer the column holds; widen them`)
|
|
62
81
|
}
|
|
@@ -67,7 +86,7 @@ export function refuseNoInteger(name: string, annotations: FieldConstraints, ran
|
|
|
67
86
|
* none on a boolean or a timestamp comb fills
|
|
68
87
|
*/
|
|
69
88
|
export function numberRules(field: FieldMeta, dialect: Build["dialect"]): { max: number | null; min: number | null } {
|
|
70
|
-
const { max, min } = field.constraints
|
|
89
|
+
const { max, min } = declaredBounds(field.constraints)
|
|
71
90
|
const kind = field.combKind
|
|
72
91
|
if (kind === "boolean" || kind === "createdAt" || kind === "updatedAt" || kind === "deletedAt")
|
|
73
92
|
return { max: null, min: null }
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The local names an entity file imports its
|
|
2
|
+
* The local names an entity file imports its column schemas under, a c.json column's and a text or
|
|
3
|
+
* number column's alike. A
|
|
3
4
|
* schema is its module's export, so two modules' `profile` are two imports: an export name more than
|
|
4
5
|
* one module gives, or one a generated file declares itself, is imported under an alias that names
|
|
5
6
|
* its module, `profileFromWorkSchemas`, numbered on if that is taken too. Which names keep their own
|
|
@@ -37,7 +38,7 @@ function moduleWord(ref: ImportRef, root: string): string {
|
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
/**
|
|
40
|
-
* The analysis with each
|
|
41
|
+
* The analysis with each column schema import under a local name of its own in every file it is
|
|
41
42
|
* imported into; `root` the project's, which a module's path is taken from
|
|
42
43
|
*/
|
|
43
44
|
function aliasSchemaImports(analysis: AnalysisResult, root: string): AnalysisResult {
|
|
@@ -83,7 +84,8 @@ function aliasSchemaImports(analysis: AnalysisResult, root: string): AnalysisRes
|
|
|
83
84
|
if (ref === undefined) return field
|
|
84
85
|
return {
|
|
85
86
|
...field,
|
|
86
|
-
|
|
87
|
+
/* a text or number column's schema is piped into, never a c.json column's type */
|
|
88
|
+
jsonSchemaName: field.jsonSchemaName === null ? null : (names.get(keyed(ref)) as string),
|
|
87
89
|
schemaImports: field.schemaImports.map(named),
|
|
88
90
|
}
|
|
89
91
|
}),
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A text column's standard format in its DTO: one check that pushes zod's `invalid_format` issue
|
|
3
|
+
* with the format's id and no message, so zod's locale or the consumer's error map words it, and
|
|
4
|
+
* the source and length bounds it holds the value to, published as the format's own. Every format
|
|
5
|
+
* is read from the catalog's spec map alone.
|
|
6
|
+
*/
|
|
7
|
+
import type { FieldConstraints } from "../../annotations.ts"
|
|
8
|
+
import { FORMATS, type FormatSpec, type TextFormat } from "../../../formats/catalog.ts"
|
|
9
|
+
import { regexExpression } from "../../utils/pattern.ts"
|
|
10
|
+
import { metaOf } from "./build-types.ts"
|
|
11
|
+
|
|
12
|
+
/** A declared text format: its id beside its spec */
|
|
13
|
+
export type DeclaredFormat = { id: TextFormat; spec: FormatSpec }
|
|
14
|
+
|
|
15
|
+
/** Whether `format` is a text format's id, which a text column's format always is: codegen refused any other */
|
|
16
|
+
const isTextFormat = (format: FieldConstraints["format"]): format is TextFormat =>
|
|
17
|
+
format !== null && Object.hasOwn(FORMATS, format)
|
|
18
|
+
|
|
19
|
+
/** The format a text declares; null when it declares none */
|
|
20
|
+
export function textFormatOf({ format }: FieldConstraints): DeclaredFormat | null {
|
|
21
|
+
return isTextFormat(format) ? { id: format, spec: FORMATS[format] } : null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A format's source as the regex literal comb builds from it under u, as the manifest states a pattern */
|
|
25
|
+
export const sourceLiteral = (source: string): string => `/${new RegExp(source, "u").source}/u`
|
|
26
|
+
|
|
27
|
+
/** The test a value meets to be of `spec`'s format, as written after its transforms: its length, its source, its registry */
|
|
28
|
+
function formatTest(spec: FormatSpec): string {
|
|
29
|
+
const length =
|
|
30
|
+
spec.min === spec.max
|
|
31
|
+
? [`ctx.value.length === ${spec.min}`]
|
|
32
|
+
: [
|
|
33
|
+
...(spec.min > 0 ? [`ctx.value.length >= ${spec.min}`] : []),
|
|
34
|
+
...(spec.max === null ? [] : [`ctx.value.length <= ${spec.max}`]),
|
|
35
|
+
]
|
|
36
|
+
const member = spec.member === null ? [] : [`comb.${spec.member}(ctx.value)`]
|
|
37
|
+
return [...length, `${regexExpression(sourceLiteral(spec.source))}.test(ctx.value)`, ...member].join(" && ")
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A format's own length bounds as JSON Schema states them; none at a floor of 0 or no cap */
|
|
41
|
+
const lengths = ({ max, min }: FormatSpec): Record<string, number> => ({
|
|
42
|
+
...(min > 0 ? { minLength: min } : {}),
|
|
43
|
+
...(max === null ? {} : { maxLength: max }),
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The format's check and what it publishes: its source as `pattern`, or, beside a declared pattern
|
|
48
|
+
* zod publishes as `pattern` itself (`declaresPattern`), in an `allOf`, and its length bounds
|
|
49
|
+
*/
|
|
50
|
+
export function formatCheck({ id, spec }: DeclaredFormat, declaresPattern: boolean): string {
|
|
51
|
+
const issue = `{ code: "invalid_format", format: ${JSON.stringify(id)}, input: ctx.value, origin: "string" }`
|
|
52
|
+
const source = declaresPattern ? { allOf: [{ pattern: spec.source }] } : { pattern: spec.source }
|
|
53
|
+
return `.check((ctx) => { if (!(${formatTest(spec)})) ctx.issues.push(${issue}) })${metaOf({ ...source, ...lengths(spec) })}`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The bounds of `range` the format's own leave to a check of zod's: those tighter than its */
|
|
57
|
+
export const tighterBounds = (
|
|
58
|
+
{ max, min }: { max: number | null; min: number | null },
|
|
59
|
+
spec: FormatSpec,
|
|
60
|
+
): { max: number | null; min: number | null } => ({
|
|
61
|
+
max: max !== null && (spec.max === null || max < spec.max) ? max : null,
|
|
62
|
+
min: min !== null && min > spec.min ? min : null,
|
|
63
|
+
})
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
/** The Zod expression of a text column: its kind, transforms and bounds, by schema type. */
|
|
2
|
+
import { regexes } from "zod"
|
|
2
3
|
import type { FieldConstraints } from "../../annotations.ts"
|
|
3
4
|
import type { FieldMeta } from "../../analyzer-types.ts"
|
|
4
5
|
import { type Base, type Build, plain } from "./build-types.ts"
|
|
6
|
+
import { formatCheck, sourceLiteral, textFormatOf, tighterBounds } from "./text-format.ts"
|
|
5
7
|
import { STORABLE_TEXT_RULE } from "../../../storable-text.ts"
|
|
6
8
|
import { isMutative } from "./schema-type.ts"
|
|
7
9
|
import { regexExpression } from "../../utils/pattern.ts"
|
|
@@ -44,10 +46,18 @@ const ID_LENGTH = { maxLength: 48, minLength: 1 }
|
|
|
44
46
|
*/
|
|
45
47
|
function isIdText(annotations: FieldConstraints, field: FieldMeta): boolean {
|
|
46
48
|
const untargetedRef = field.combKind === "ref" && field.foreignKey === null
|
|
47
|
-
const declares =
|
|
49
|
+
const declares =
|
|
50
|
+
field.length !== null ||
|
|
51
|
+
annotations.min !== null ||
|
|
52
|
+
annotations.max !== null ||
|
|
53
|
+
annotations.format !== null ||
|
|
54
|
+
field.schemaJson !== null
|
|
48
55
|
return (field.combKind === "id" || field.foreignKey?.toId === true || untargetedRef) && !declares
|
|
49
56
|
}
|
|
50
57
|
|
|
58
|
+
/** The lesser of two upper bounds, either of which may be none */
|
|
59
|
+
const lesser = (a: number | null, b: number | null): number | null => (a === null ? b : b === null ? a : Math.min(a, b))
|
|
60
|
+
|
|
51
61
|
/* the shortest value each format admits, measured from its check in a test: an address
|
|
52
62
|
such as a@b.cc, and https:// with one character after it, or alone as an imageUri's prefix */
|
|
53
63
|
export const TEXT_FLOORS: { email: number; imageUri: number; url: number } = { email: 6, imageUri: 8, url: 9 }
|
|
@@ -65,6 +75,12 @@ const floorOf = ({ email, imageUri, url }: FieldConstraints): number =>
|
|
|
65
75
|
function textRange(annotations: FieldConstraints, field: FieldMeta): TextRange {
|
|
66
76
|
if (isIdText(annotations, field)) return { max: ID_LENGTH.maxLength, min: ID_LENGTH.minLength }
|
|
67
77
|
const max = annotations.max ?? field.length
|
|
78
|
+
/* a format's range is its values': the tighter of it and the declared one, no floor of a required text's 1 */
|
|
79
|
+
const format = textFormatOf(annotations)
|
|
80
|
+
if (format !== null) {
|
|
81
|
+
const min = Math.max(annotations.min ?? 0, format.spec.min)
|
|
82
|
+
return { max: lesser(max, format.spec.max), min: min === 0 ? null : min }
|
|
83
|
+
}
|
|
68
84
|
const floor = floorOf(annotations)
|
|
69
85
|
if (floor > 0) return { max, min: Math.max(annotations.min ?? 0, floor) }
|
|
70
86
|
return { max, min: annotations.min ?? (field.isNotNull ? 1 : null) }
|
|
@@ -97,6 +113,9 @@ const CHAIN: Record<Transform, string> = {
|
|
|
97
113
|
*/
|
|
98
114
|
export function transformsOf(annotations: FieldConstraints, mutative: boolean): Transform[] {
|
|
99
115
|
if (!mutative) return []
|
|
116
|
+
/* a format sets its own trim and case: codegen refused a declared one beside it */
|
|
117
|
+
const format = textFormatOf(annotations)
|
|
118
|
+
if (format !== null) return [...format.spec.transform]
|
|
100
119
|
if (annotations.url) return ["trim", "strip-tab-newline"]
|
|
101
120
|
if (annotations.email) return ["trim", "lowercase"]
|
|
102
121
|
const casing: Transform[] = annotations.lowercase ? ["lowercase"] : annotations.uppercase ? ["uppercase"] : []
|
|
@@ -155,6 +174,8 @@ function textKeys(build: Build): Record<string, unknown> {
|
|
|
155
174
|
const transform = transformKey(annotations, mutative)
|
|
156
175
|
if (isIdText(annotations, field)) return mutative ? { ...transform, ...GUARD_KEY } : stated(ID_LENGTH)
|
|
157
176
|
if (!mutative) return readKeys(build)
|
|
177
|
+
/* a format's source admits no U+0000 or surrogate, so it takes no text guard */
|
|
178
|
+
if (annotations.format !== null) return { ...transform, "x-comb-format": annotations.format }
|
|
158
179
|
if (annotations.email) return transform
|
|
159
180
|
return annotations.url ? { ...transform, ...URL_KEY, ...GUARD_KEY } : { ...transform, ...GUARD_KEY }
|
|
160
181
|
}
|
|
@@ -179,7 +200,11 @@ function textBounds(build: Build, range: TextRange): string {
|
|
|
179
200
|
return held === null ? "" : `.max(${held})`
|
|
180
201
|
}
|
|
181
202
|
if (annotations.email) return emailBounds(annotations, range)
|
|
182
|
-
|
|
203
|
+
const format = textFormatOf(annotations)
|
|
204
|
+
if (format === null) return rangeChecks(range) + writeChecks(annotations)
|
|
205
|
+
/* the format's check first, then a declared bound tighter than its, as zod's own check, and a declared pattern */
|
|
206
|
+
const pattern = annotations.pattern === null ? "" : `.regex(${regexExpression(annotations.pattern)})`
|
|
207
|
+
return formatCheck(format, annotations.pattern !== null) + rangeChecks(tighterBounds(range, format.spec)) + pattern
|
|
183
208
|
}
|
|
184
209
|
|
|
185
210
|
/**
|
|
@@ -229,6 +254,28 @@ export function textRules(annotations: FieldConstraints, field: FieldMeta): Text
|
|
|
229
254
|
const range = textRange(annotations, field)
|
|
230
255
|
const declared = annotations.pattern === null ? [] : [annotations.pattern]
|
|
231
256
|
if (isIdText(annotations, field)) return { ...range, patterns: [ID_PATTERN] }
|
|
257
|
+
const format = textFormatOf(annotations)
|
|
258
|
+
/* the format's source first, as its check runs before the declared pattern's */
|
|
259
|
+
if (format !== null) return { ...range, patterns: [sourceLiteral(format.spec.source), ...declared] }
|
|
232
260
|
const kind = [annotations.url ? HTTPS : null, annotations.name ? NAME : null, annotations.imageUri ? IMAGE : null]
|
|
233
261
|
return { ...range, patterns: [...declared, ...kind.filter((each) => each !== null)] }
|
|
234
262
|
}
|
|
263
|
+
|
|
264
|
+
/** A regex literal's source, as zod publishes it */
|
|
265
|
+
const sourceOf = (literal: string): string => literal.slice(1, literal.lastIndexOf("/"))
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* The `allOf` a written text's own chain publishes: the pattern of each of two or more regexes, as
|
|
269
|
+
* zod writes them in the order they are checked, z.email()'s first, or a format's source beside a
|
|
270
|
+
* declared pattern. A schema's `allOf`, written over the chain's, carries these first
|
|
271
|
+
*/
|
|
272
|
+
export function chainAllOf(build: Build): Array<{ pattern: string }> {
|
|
273
|
+
const { annotations, field } = build
|
|
274
|
+
if (!isMutative(build.schemaType) || isIdText(annotations, field)) return []
|
|
275
|
+
/* a format's source as its check publishes it beside a declared pattern: the catalog's */
|
|
276
|
+
const format = textFormatOf(annotations)
|
|
277
|
+
if (format !== null) return annotations.pattern === null ? [] : [{ pattern: format.spec.source }]
|
|
278
|
+
const { patterns } = textRules(annotations, field)
|
|
279
|
+
const chain = [...(annotations.email ? [regexes.email.source] : []), ...patterns.map(sourceOf)]
|
|
280
|
+
return chain.length > 1 ? chain.map((pattern) => ({ pattern })) : []
|
|
281
|
+
}
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import path from "node:path"
|
|
9
9
|
import { deriveEntityMeta, publishedPartialUniqueIndexes, publishedUniqueIndexes, writesOf } from "../entity-meta.ts"
|
|
10
|
-
import type { FieldMeta, TableMeta } from "../analyzer-types.ts"
|
|
10
|
+
import type { FieldMeta, ImportRef, TableMeta } from "../analyzer-types.ts"
|
|
11
|
+
import type { IntegerFormat, TextFormat } from "../../formats/catalog.ts"
|
|
11
12
|
import { writeGenJsonFile } from "../utils/fs.ts"
|
|
12
13
|
import type { GeneratorContext } from "./context-types.ts"
|
|
13
14
|
import { qualifiedName } from "../utils/table-name.ts"
|
|
@@ -57,6 +58,10 @@ type ManifestColumn = {
|
|
|
57
58
|
min: number | null
|
|
58
59
|
/** Every regex a written text must match, its kind's and the declared one, as JavaScript regex literals; a plain Postgres type's rule is its `kind` and `sqlType`'s */
|
|
59
60
|
patterns: string[]
|
|
61
|
+
/** The standard format a written value is checked against (`iso4217`, `iso8601-weekday`), by its id; null when the column declares none */
|
|
62
|
+
format: TextFormat | IntegerFormat | null
|
|
63
|
+
/** The export name of the zod schema a text or number column declares, which a write runs after the column's own rules; null when none, and on a c.json column, whose schema is its value's shape rather than a rule beside it */
|
|
64
|
+
schema: string | null
|
|
60
65
|
/** A text column's format, null for any other */
|
|
61
66
|
textKind: "email" | "imageUri" | "name" | "password" | "url" | null
|
|
62
67
|
/** The rewrites a written value takes before `min`, `max` and `patterns` bind it, in order: the DTO's own list (meta-contract §2.1) */
|
|
@@ -177,6 +182,7 @@ function toColumn(field: FieldMeta, dialect: GeneratorContext["dialect"]): Manif
|
|
|
177
182
|
const { max, maxBytes, min, patterns } = writeRules(field, dialect)
|
|
178
183
|
return {
|
|
179
184
|
enumValues: field.enumValues,
|
|
185
|
+
format: constraints.format,
|
|
180
186
|
/* what the write bodies do, read off the rule the DTOs and the stamp read, a private column's too */
|
|
181
187
|
generated: !writesOf(field).create,
|
|
182
188
|
immutable: !writesOf(field).update,
|
|
@@ -188,6 +194,8 @@ function toColumn(field: FieldMeta, dialect: GeneratorContext["dialect"]): Manif
|
|
|
188
194
|
notNull: field.isNotNull,
|
|
189
195
|
patterns,
|
|
190
196
|
primaryKey: field.isPrimaryKey,
|
|
197
|
+
/* a text or number column's schema is the field's one import; a c.json column's import is its value's shape, with no schemaJson */
|
|
198
|
+
schema: field.schemaJson === null ? null : (field.schemaImports[0] as ImportRef).imported,
|
|
191
199
|
textKind: textKindOf(constraints),
|
|
192
200
|
transform: transformsOf(constraints, true),
|
|
193
201
|
private: constraints.private,
|