@supatype/cli 0.1.11 → 0.1.13
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/.turbo/turbo-build.log +1 -1
- package/.turbo/turbo-test.log +136 -130
- package/.turbo/turbo-typecheck.log +1 -1
- package/dist/cli-version-embedded.js +1 -1
- package/dist/commands/db.d.ts.map +1 -1
- package/dist/commands/db.js +23 -1
- package/dist/commands/db.js.map +1 -1
- package/dist/commands/doctor.d.ts +0 -7
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +26 -0
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/push.d.ts.map +1 -1
- package/dist/commands/push.js +15 -7
- package/dist/commands/push.js.map +1 -1
- package/dist/compose-local-server-image.d.ts +11 -0
- package/dist/compose-local-server-image.d.ts.map +1 -1
- package/dist/compose-local-server-image.js +18 -0
- package/dist/compose-local-server-image.js.map +1 -1
- package/dist/db-preflight.d.ts +23 -0
- package/dist/db-preflight.d.ts.map +1 -1
- package/dist/db-preflight.js +66 -1
- package/dist/db-preflight.js.map +1 -1
- package/dist/dev-compose.d.ts +1 -0
- package/dist/dev-compose.d.ts.map +1 -1
- package/dist/dev-compose.js +79 -10
- package/dist/dev-compose.js.map +1 -1
- package/dist/field-bounds.d.ts +68 -0
- package/dist/field-bounds.d.ts.map +1 -0
- package/dist/field-bounds.js +277 -0
- package/dist/field-bounds.js.map +1 -0
- package/dist/hooks-generator.d.ts +1 -1
- package/dist/hooks-generator.d.ts.map +1 -1
- package/dist/hooks-generator.js +78 -4
- package/dist/hooks-generator.js.map +1 -1
- package/dist/model-hooks.d.ts +44 -2
- package/dist/model-hooks.d.ts.map +1 -1
- package/dist/model-hooks.js +116 -12
- package/dist/model-hooks.js.map +1 -1
- package/dist/schema-ast-v2.d.ts +38 -4
- package/dist/schema-ast-v2.d.ts.map +1 -1
- package/dist/schema-ast-v2.js +87 -4
- package/dist/schema-ast-v2.js.map +1 -1
- package/dist/type-extractor.d.ts.map +1 -1
- package/dist/type-extractor.js +309 -27
- package/dist/type-extractor.js.map +1 -1
- package/package.json +4 -3
- package/src/cli-version-embedded.ts +1 -1
- package/src/commands/db.ts +27 -1
- package/src/commands/doctor.ts +30 -0
- package/src/commands/push.ts +26 -6
- package/src/compose-local-server-image.ts +17 -0
- package/src/db-preflight.ts +89 -1
- package/src/dev-compose.ts +96 -9
- package/src/field-bounds.ts +359 -0
- package/src/hooks-generator.ts +81 -4
- package/src/model-hooks.ts +158 -12
- package/src/schema-ast-v2.ts +114 -10
- package/src/type-extractor.ts +374 -39
- package/tests/db-preflight.test.ts +80 -0
- package/tests/field-bounds-matrix.test.ts +163 -0
- package/tests/field-bounds.test.ts +139 -0
- package/tests/field-validators.test.ts +139 -0
- package/tests/hooks-generator.test.ts +86 -0
- package/tests/local-server-image-env.test.ts +93 -0
- package/tests/model-constraints.test.ts +293 -0
- package/tests/model-hooks.test.ts +56 -0
- package/tests/type-extractor.test.ts +49 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
|
|
2
|
+
import { tmpdir } from "node:os"
|
|
3
|
+
import { join } from "node:path"
|
|
4
|
+
import { afterEach, describe, expect, it } from "vitest"
|
|
5
|
+
import { extractSchemaAstFromTypes } from "../src/type-extractor.js"
|
|
6
|
+
|
|
7
|
+
// Model-level constraints reuse the access-rule vocabulary deliberately: a constraint is an access
|
|
8
|
+
// rule with a narrower operand set. That reuse is the point, and it is also the risk, because the
|
|
9
|
+
// wider set is one import away. Everything a CHECK cannot evaluate has to be refused at push time,
|
|
10
|
+
// where the message can name the node, rather than at CREATE TABLE where Postgres names nothing.
|
|
11
|
+
|
|
12
|
+
const dirs: string[] = []
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true })
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
function extract(body: string, prelude = ""): ReturnType<typeof extractSchemaAstFromTypes> {
|
|
19
|
+
const dir = mkdtempSync(join(tmpdir(), "supatype-constraints-"))
|
|
20
|
+
dirs.push(dir)
|
|
21
|
+
const schemaPath = join(dir, "schema.ts")
|
|
22
|
+
writeFileSync(
|
|
23
|
+
schemaPath,
|
|
24
|
+
`
|
|
25
|
+
import type {
|
|
26
|
+
All, Any, AuthUid, Block, Blocks, Bytea, DateTime, Eq, Gte, IsNull, ItemCount, JSON, Length,
|
|
27
|
+
Literal, Lte, Matches, Model, NotNull, Now, Optional, Public, RichText, Role, UUID,
|
|
28
|
+
} from "@supatype/types"
|
|
29
|
+
|
|
30
|
+
${prelude}
|
|
31
|
+
|
|
32
|
+
${body}
|
|
33
|
+
`,
|
|
34
|
+
"utf8",
|
|
35
|
+
)
|
|
36
|
+
return extractSchemaAstFromTypes(schemaPath, dir)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const constraintsOf = (ast: ReturnType<typeof extractSchemaAstFromTypes>): unknown[] =>
|
|
40
|
+
(ast?.models[0]?.annotations.db.constraints ?? []) as unknown[]
|
|
41
|
+
|
|
42
|
+
describe("model constraints", () => {
|
|
43
|
+
it("extracts a cross-column comparison, which no field modifier can express", () => {
|
|
44
|
+
const ast = extract(`
|
|
45
|
+
export type Event = Model<{
|
|
46
|
+
id: UUID
|
|
47
|
+
starts_at: DateTime
|
|
48
|
+
ends_at: DateTime
|
|
49
|
+
}, {
|
|
50
|
+
access: { read: Public }
|
|
51
|
+
constraints: [Lte<"starts_at", "ends_at">]
|
|
52
|
+
}>`)
|
|
53
|
+
|
|
54
|
+
expect(constraintsOf(ast)).toEqual([
|
|
55
|
+
{
|
|
56
|
+
type: "compare",
|
|
57
|
+
op: "lte",
|
|
58
|
+
left: { kind: "column", name: "starts_at" },
|
|
59
|
+
right: { kind: "column", name: "ends_at" },
|
|
60
|
+
},
|
|
61
|
+
])
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it("extracts the item-count and length operands", () => {
|
|
65
|
+
const ast = extract(`
|
|
66
|
+
export type Product = Model<{
|
|
67
|
+
id: UUID
|
|
68
|
+
setup_items: JSON<{ label: string }[]>
|
|
69
|
+
sku: string
|
|
70
|
+
}, {
|
|
71
|
+
access: { read: Public }
|
|
72
|
+
constraints: [Gte<ItemCount<"setup_items">, Literal<1>>, Lte<Length<"sku">, Literal<32>>]
|
|
73
|
+
}>`)
|
|
74
|
+
|
|
75
|
+
expect(constraintsOf(ast)).toMatchObject([
|
|
76
|
+
{ type: "compare", op: "gte", left: { kind: "itemCount", column: "setup_items" } },
|
|
77
|
+
{ type: "compare", op: "lte", left: { kind: "length", column: "sku" } },
|
|
78
|
+
])
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it("extracts a pattern as a pattern, never as an expression", () => {
|
|
82
|
+
const ast = extract(`
|
|
83
|
+
export type Part = Model<{
|
|
84
|
+
id: UUID
|
|
85
|
+
sku: string
|
|
86
|
+
}, {
|
|
87
|
+
access: { read: Public }
|
|
88
|
+
constraints: [Matches<"sku", "^[A-Z]{3}$">]
|
|
89
|
+
}>`)
|
|
90
|
+
|
|
91
|
+
expect(constraintsOf(ast)).toEqual([
|
|
92
|
+
{ type: "matches", column: "sku", pattern: "^[A-Z]{3}$" },
|
|
93
|
+
])
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it("extracts combinators, so a constraint can be more than one comparison", () => {
|
|
97
|
+
const ast = extract(`
|
|
98
|
+
export type Page = Model<{
|
|
99
|
+
id: UUID
|
|
100
|
+
status: "draft" | "published"
|
|
101
|
+
published_at: Optional<DateTime>
|
|
102
|
+
}, {
|
|
103
|
+
access: { read: Public }
|
|
104
|
+
constraints: [Any<[Eq<"status", Literal<"draft">>, NotNull<"published_at">]>]
|
|
105
|
+
}>`)
|
|
106
|
+
|
|
107
|
+
const [rule] = constraintsOf(ast) as Array<{ type: string; rules: unknown[] }>
|
|
108
|
+
expect(rule?.type).toBe("any")
|
|
109
|
+
expect(rule?.rules).toHaveLength(2)
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
describe("model constraints: what a CHECK cannot evaluate is refused", () => {
|
|
114
|
+
const cases: Array<[string, string, RegExp]> = [
|
|
115
|
+
["the caller", "Eq<\"owner_id\", AuthUid>", /cannot see who is writing/],
|
|
116
|
+
["the caller's role", "Role<\"admin\">", /cannot see the caller's role/],
|
|
117
|
+
["the clock", "Lte<\"starts_at\", Now>", /cannot read the clock/],
|
|
118
|
+
]
|
|
119
|
+
|
|
120
|
+
for (const [what, rule, expected] of cases) {
|
|
121
|
+
it(`refuses ${what}, naming why`, () => {
|
|
122
|
+
expect(() =>
|
|
123
|
+
extract(`
|
|
124
|
+
export type Thing = Model<{
|
|
125
|
+
id: UUID
|
|
126
|
+
owner_id: UUID
|
|
127
|
+
status: string
|
|
128
|
+
starts_at: DateTime
|
|
129
|
+
}, {
|
|
130
|
+
access: { read: Public }
|
|
131
|
+
constraints: [${rule}]
|
|
132
|
+
}>`),
|
|
133
|
+
).toThrow(expected)
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
it("refuses one nested inside a combinator, not only at the top level", () => {
|
|
138
|
+
expect(() =>
|
|
139
|
+
extract(`
|
|
140
|
+
export type Thing = Model<{
|
|
141
|
+
id: UUID
|
|
142
|
+
owner_id: UUID
|
|
143
|
+
status: string
|
|
144
|
+
}, {
|
|
145
|
+
access: { read: Public }
|
|
146
|
+
constraints: [All<[Eq<"status", Literal<"live">>, Eq<"owner_id", AuthUid>]>]
|
|
147
|
+
}>`),
|
|
148
|
+
).toThrow(/cannot see who is writing/)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it("names the model and which constraint, so a large schema stays navigable", () => {
|
|
152
|
+
expect(() =>
|
|
153
|
+
extract(`
|
|
154
|
+
export type Thing = Model<{
|
|
155
|
+
id: UUID
|
|
156
|
+
a: DateTime
|
|
157
|
+
b: DateTime
|
|
158
|
+
owner_id: UUID
|
|
159
|
+
}, {
|
|
160
|
+
access: { read: Public }
|
|
161
|
+
constraints: [Lte<"a", "b">, Eq<"owner_id", AuthUid>]
|
|
162
|
+
}>`),
|
|
163
|
+
).toThrow(/Model "Thing": constraint 2/)
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it("refuses a constraints value that is not a tuple", () => {
|
|
167
|
+
expect(() =>
|
|
168
|
+
extract(`
|
|
169
|
+
export type Thing = Model<{ id: UUID }, {
|
|
170
|
+
access: { read: Public }
|
|
171
|
+
constraints: string
|
|
172
|
+
}>`),
|
|
173
|
+
).toThrow(/`constraints` must be a tuple/)
|
|
174
|
+
})
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
describe("model constraints: absence", () => {
|
|
178
|
+
it("emits no key when a model declares none", () => {
|
|
179
|
+
const ast = extract(`
|
|
180
|
+
export type Plain = Model<{ id: UUID }, { access: { read: Public } }>`)
|
|
181
|
+
expect(ast?.models[0]?.annotations.db.constraints).toBeUndefined()
|
|
182
|
+
})
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
describe("the constraint operands stay out of access rules", () => {
|
|
186
|
+
// They share a parser, so nothing syntactic stops one appearing in an `access` block. The engine's
|
|
187
|
+
// RLS renderer has no case for them, so it would emit a policy that does not say what was written.
|
|
188
|
+
it("refuses Length in an access rule, naming where it belongs", () => {
|
|
189
|
+
expect(() =>
|
|
190
|
+
extract(`
|
|
191
|
+
export type Doc = Model<{ id: UUID; title: string }, {
|
|
192
|
+
access: { read: Gte<Length<"title">, Literal<5>> }
|
|
193
|
+
}>`),
|
|
194
|
+
).toThrow(/`Length<>` is not supported in an `access` rule.*belongs in `constraints`/s)
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it("refuses Matches in an access rule too", () => {
|
|
198
|
+
expect(() =>
|
|
199
|
+
extract(`
|
|
200
|
+
export type Doc = Model<{ id: UUID; sku: string }, {
|
|
201
|
+
access: { read: Matches<"sku", "^[A-Z]{3}$"> }
|
|
202
|
+
}>`),
|
|
203
|
+
).toThrow(/not supported in an `access` rule/)
|
|
204
|
+
})
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
describe("constraint measures resolve to the same form the modifiers use", () => {
|
|
208
|
+
it("resolves Length per storage, not per hope", () => {
|
|
209
|
+
const ast = extract(`
|
|
210
|
+
export type Doc = Model<{
|
|
211
|
+
id: UUID
|
|
212
|
+
title: string
|
|
213
|
+
body: RichText
|
|
214
|
+
blob: Bytea
|
|
215
|
+
}, {
|
|
216
|
+
access: { read: Public }
|
|
217
|
+
constraints: [
|
|
218
|
+
Lte<Length<"title">, Literal<10>>,
|
|
219
|
+
Lte<Length<"body">, Literal<10>>,
|
|
220
|
+
Lte<Length<"blob">, Literal<10>>,
|
|
221
|
+
]
|
|
222
|
+
}>`)
|
|
223
|
+
|
|
224
|
+
expect(constraintsOf(ast)).toMatchObject([
|
|
225
|
+
{ left: { kind: "length", column: "title", form: "chars" } },
|
|
226
|
+
{ left: { kind: "length", column: "body", form: "richText" } },
|
|
227
|
+
{ left: { kind: "length", column: "blob", form: "octets" } },
|
|
228
|
+
])
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it("tells a real array from a JSON array, which JSONB alone cannot", () => {
|
|
232
|
+
const ast = extract(`
|
|
233
|
+
export type Doc = Model<{
|
|
234
|
+
id: UUID
|
|
235
|
+
tags: string[]
|
|
236
|
+
refs: JSON<{ id: string }[]>
|
|
237
|
+
sections: Blocks<Note>
|
|
238
|
+
}, {
|
|
239
|
+
access: { read: Public }
|
|
240
|
+
constraints: [
|
|
241
|
+
Lte<ItemCount<"tags">, Literal<3>>,
|
|
242
|
+
Gte<ItemCount<"refs">, Literal<1>>,
|
|
243
|
+
Gte<ItemCount<"sections">, Literal<1>>,
|
|
244
|
+
]
|
|
245
|
+
}>`, "type Note = Block<\"note\", { text: string }>")
|
|
246
|
+
|
|
247
|
+
expect(constraintsOf(ast)).toMatchObject([
|
|
248
|
+
{ left: { kind: "itemCount", column: "tags", form: "array" } },
|
|
249
|
+
{ left: { kind: "itemCount", column: "refs", form: "jsonbArray" } },
|
|
250
|
+
{ left: { kind: "itemCount", column: "sections", form: "jsonbArray" } },
|
|
251
|
+
])
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
it("refuses a measure the column cannot take, naming the alternative", () => {
|
|
255
|
+
expect(() =>
|
|
256
|
+
extract(`
|
|
257
|
+
export type Doc = Model<{ id: UUID; tags: string[] }, {
|
|
258
|
+
access: { read: Public }
|
|
259
|
+
constraints: [Lte<Length<"tags">, Literal<3>>]
|
|
260
|
+
}>`),
|
|
261
|
+
).toThrow(/an array has items, not characters; use MaxItems\/MinItems/)
|
|
262
|
+
|
|
263
|
+
expect(() =>
|
|
264
|
+
extract(`
|
|
265
|
+
export type Doc = Model<{ id: UUID; title: string }, {
|
|
266
|
+
access: { read: Public }
|
|
267
|
+
constraints: [Lte<ItemCount<"title">, Literal<3>>]
|
|
268
|
+
}>`),
|
|
269
|
+
).toThrow(/text has characters, not items/)
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
it("refuses a measure over a column that is not a field", () => {
|
|
273
|
+
// Composite columns like `created_at` exist in the table and not in `fields`, so the measure
|
|
274
|
+
// cannot be resolved. Saying so beats emitting SQL against a kind nobody classified.
|
|
275
|
+
expect(() =>
|
|
276
|
+
extract(`
|
|
277
|
+
export type Doc = Model<{ id: UUID }, {
|
|
278
|
+
access: { read: Public }
|
|
279
|
+
constraints: [Lte<Length<"created_at">, Literal<3>>]
|
|
280
|
+
}>`),
|
|
281
|
+
).toThrow(/is not a field on this model/)
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
it("resolves a measure nested inside a combinator", () => {
|
|
285
|
+
const ast = extract(`
|
|
286
|
+
export type Doc = Model<{ id: UUID; title: string }, {
|
|
287
|
+
access: { read: Public }
|
|
288
|
+
constraints: [Any<[Lte<Length<"title">, Literal<5>>, IsNull<"title">]>]
|
|
289
|
+
}>`)
|
|
290
|
+
const [rule] = constraintsOf(ast) as Array<{ rules: Array<{ left?: { form?: string } }> }>
|
|
291
|
+
expect(rule?.rules[0]?.left?.form).toBe("chars")
|
|
292
|
+
})
|
|
293
|
+
})
|
|
@@ -218,6 +218,62 @@ export type Post = Model<{ id: UUID }, { tableName: "posts" }>
|
|
|
218
218
|
) as Record<string, unknown>
|
|
219
219
|
expect(manifest["hooks"]).toBeUndefined()
|
|
220
220
|
})
|
|
221
|
+
|
|
222
|
+
it("writes the validator map, which is what makes a validator run at all", () => {
|
|
223
|
+
// The map was built and unit-tested for a release in which nothing wrote it to the manifest.
|
|
224
|
+
// The server therefore had no validator to call, and a write that should have been refused was
|
|
225
|
+
// accepted with a 201 and no error anywhere: the one failure this feature cannot have.
|
|
226
|
+
const { dir, ast } = project(`
|
|
227
|
+
import type { JSON, Model, UUID } from "@supatype/types"
|
|
228
|
+
|
|
229
|
+
export type Post = Model<{
|
|
230
|
+
id: UUID
|
|
231
|
+
items: JSON<{ minutes: number }[]>
|
|
232
|
+
}, {
|
|
233
|
+
tableName: "posts"
|
|
234
|
+
validate: { items: "check-items" }
|
|
235
|
+
}>
|
|
236
|
+
`)
|
|
237
|
+
mkdirSync(join(dir, ".supatype"), { recursive: true })
|
|
238
|
+
writeFileSync(
|
|
239
|
+
join(dir, ".supatype", "manifest.json"),
|
|
240
|
+
JSON.stringify({ functions_enabled: true }),
|
|
241
|
+
"utf8",
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
expect(syncManifestHooks(dir, ast)).toBe(true)
|
|
245
|
+
const manifest = JSON.parse(
|
|
246
|
+
readFileSync(join(dir, ".supatype", "manifest.json"), "utf8"),
|
|
247
|
+
) as Record<string, unknown>
|
|
248
|
+
const validators = manifest["validators"] as Record<string, Record<string, unknown>>
|
|
249
|
+
expect(validators?.["posts"]?.["items"]).toMatchObject({
|
|
250
|
+
function: "check-items",
|
|
251
|
+
onUnavailable: "reject",
|
|
252
|
+
})
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it("removes the validator map when the last validator goes away", () => {
|
|
256
|
+
const { dir, ast } = project(`
|
|
257
|
+
import type { Model, UUID } from "@supatype/types"
|
|
258
|
+
|
|
259
|
+
export type Post = Model<{ id: UUID }, { tableName: "posts" }>
|
|
260
|
+
`)
|
|
261
|
+
mkdirSync(join(dir, ".supatype"), { recursive: true })
|
|
262
|
+
writeFileSync(
|
|
263
|
+
join(dir, ".supatype", "manifest.json"),
|
|
264
|
+
JSON.stringify({
|
|
265
|
+
functions_enabled: true,
|
|
266
|
+
validators: { posts: { items: { function: "gone" } } },
|
|
267
|
+
}),
|
|
268
|
+
"utf8",
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
expect(syncManifestHooks(dir, ast)).toBe(true)
|
|
272
|
+
const manifest = JSON.parse(
|
|
273
|
+
readFileSync(join(dir, ".supatype", "manifest.json"), "utf8"),
|
|
274
|
+
) as Record<string, unknown>
|
|
275
|
+
expect(manifest["validators"]).toBeUndefined()
|
|
276
|
+
})
|
|
221
277
|
})
|
|
222
278
|
|
|
223
279
|
describe("model hooks: generated module on disk", () => {
|
|
@@ -1322,4 +1322,53 @@ export type Media = Bucket<"media", {
|
|
|
1322
1322
|
/Bucket "media": could not resolve its `access` rules/,
|
|
1323
1323
|
)
|
|
1324
1324
|
})
|
|
1325
|
+
|
|
1326
|
+
it("emits structured validation bounds alongside the compiled check constraint", () => {
|
|
1327
|
+
const dir = mkdtempSync(join(tmpdir(), "supatype-validation-"))
|
|
1328
|
+
dirs.push(dir)
|
|
1329
|
+
const schemaPath = join(dir, "schema.ts")
|
|
1330
|
+
writeFileSync(
|
|
1331
|
+
schemaPath,
|
|
1332
|
+
`
|
|
1333
|
+
import type {
|
|
1334
|
+
Model, Public, UUID, Int, Optional, MaxLength, MinLength, Between,
|
|
1335
|
+
} from "@supatype/types"
|
|
1336
|
+
|
|
1337
|
+
export type Review = Model<{
|
|
1338
|
+
id: UUID
|
|
1339
|
+
headline: MaxLength<string, 120>
|
|
1340
|
+
body: MinLength<MaxLength<string, 4000>, 20>
|
|
1341
|
+
rating: Between<Int, 1, 5>
|
|
1342
|
+
note: Optional<string>
|
|
1343
|
+
}, {
|
|
1344
|
+
access: { read: Public }
|
|
1345
|
+
}>
|
|
1346
|
+
`,
|
|
1347
|
+
"utf8",
|
|
1348
|
+
)
|
|
1349
|
+
|
|
1350
|
+
const ast = extractSchemaAstFromTypes(schemaPath, dir)
|
|
1351
|
+
const review = ast?.models.find((m) => m.name === "Review")
|
|
1352
|
+
expect(review).toBeDefined()
|
|
1353
|
+
|
|
1354
|
+
expect(review?.fields["headline"]).toMatchObject({
|
|
1355
|
+
check: 'char_length("{name}") <= 120',
|
|
1356
|
+
validation: { maxLength: 120 },
|
|
1357
|
+
})
|
|
1358
|
+
|
|
1359
|
+
// Stacked modifiers merge into one check and one validation object.
|
|
1360
|
+
expect(review?.fields["body"]).toMatchObject({
|
|
1361
|
+
validation: { maxLength: 4000, minLength: 20 },
|
|
1362
|
+
})
|
|
1363
|
+
expect(review?.fields["body"]?.["check"]).toContain('char_length("{name}") <= 4000')
|
|
1364
|
+
expect(review?.fields["body"]?.["check"]).toContain('char_length("{name}") >= 20')
|
|
1365
|
+
|
|
1366
|
+
expect(review?.fields["rating"]).toMatchObject({
|
|
1367
|
+
kind: "integer",
|
|
1368
|
+
validation: { min: 1, max: 5 },
|
|
1369
|
+
})
|
|
1370
|
+
|
|
1371
|
+
// A field with no constraint modifier carries no validation key at all.
|
|
1372
|
+
expect(review?.fields["note"]?.["validation"]).toBeUndefined()
|
|
1373
|
+
})
|
|
1325
1374
|
})
|