@jarenjs/forms 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +196 -0
- package/dist/types/data.d.ts +67 -0
- package/dist/types/formats.d.ts +22 -0
- package/dist/types/index.d.ts +17 -0
- package/dist/types/model.d.ts +125 -0
- package/dist/types/rules.d.ts +131 -0
- package/dist/types/validate.d.ts +29 -0
- package/package.json +54 -0
- package/src/data.js +223 -0
- package/src/formats.js +120 -0
- package/src/index.js +49 -0
- package/src/model.js +318 -0
- package/src/rules.js +404 -0
- package/src/validate.js +244 -0
package/src/rules.js
ADDED
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `x-form` rules: cross-field form behavior as compiled Jaren JSON Queries.
|
|
5
|
+
*
|
|
6
|
+
* The `x-form` annotation is one namespaced keyword - safe under every
|
|
7
|
+
* metaschema, invisible to validators - carrying query documents for
|
|
8
|
+
* per-keystroke form behavior:
|
|
9
|
+
*
|
|
10
|
+
* visible - EBV query: should the field be shown?
|
|
11
|
+
* enabled - EBV query: should the field accept input?
|
|
12
|
+
* assert - EBV query: cross-field preemptive validation
|
|
13
|
+
* computed - query whose plain-JSON result is the field's derived value
|
|
14
|
+
* message - string shown when `assert` fails
|
|
15
|
+
*
|
|
16
|
+
* Unknown members are ignored (forward compatibility). Every rule kind
|
|
17
|
+
* shares one query context: the input document `$` is the WHOLE form data
|
|
18
|
+
* root (cross-field is the point), and exactly two externals are bound
|
|
19
|
+
* per evaluation - `value`, the field's current value (`null` when the
|
|
20
|
+
* field is absent: `undefined` is not a JSON value and has no defined
|
|
21
|
+
* behavior in the engine), and `pointer`, the field's data pointer
|
|
22
|
+
* string. Any other free name is a compile-time error naming it.
|
|
23
|
+
*
|
|
24
|
+
* Error policy (recorded here as the module contract):
|
|
25
|
+
* - `visible`/`enabled` runtime errors (JQ2xxx, e.g. the JQ2003
|
|
26
|
+
* multi-item EBV) evaluate to `true` - FAIL OPEN: a broken rule must
|
|
27
|
+
* never hide data or lock a control the user needs.
|
|
28
|
+
* - `assert` runtime errors evaluate to failed - FAIL CLOSED: an
|
|
29
|
+
* assertion that cannot be computed has not been satisfied.
|
|
30
|
+
* - `computed` runtime errors leave the value absent - there is no
|
|
31
|
+
* derived value to show.
|
|
32
|
+
* Compile errors always throw, with the field's data pointer prepended.
|
|
33
|
+
*
|
|
34
|
+
* Rules compile ONCE per model (compileFormRules) and evaluate per
|
|
35
|
+
* keystroke as cheap closures (evaluateFormRules) - the same two-stage
|
|
36
|
+
* shape as every other Jaren compiler. Rules on array item templates
|
|
37
|
+
* compile once and dispatch per element (see expandItemRule): this
|
|
38
|
+
* compiled-once/evaluate-per-node mechanism is the seed of the JSLT
|
|
39
|
+
* template layer (packages/json/docs/JSLT-PRELUDE.md section 7).
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import {
|
|
43
|
+
compileJsonQuery,
|
|
44
|
+
JsonQueryRuntimeError,
|
|
45
|
+
} from '@jarenjs/json/query';
|
|
46
|
+
|
|
47
|
+
import {
|
|
48
|
+
compileJSONPointer,
|
|
49
|
+
JSONPOINTER_NOTHING,
|
|
50
|
+
} from '@jarenjs/json/pointer';
|
|
51
|
+
|
|
52
|
+
import { escapePointerKey } from './model.js';
|
|
53
|
+
|
|
54
|
+
/** The externals every rule query may reference, and no others. */
|
|
55
|
+
const ALLOWED_EXTERNALS = ['value', 'pointer'];
|
|
56
|
+
|
|
57
|
+
const DEFAULT_ASSERT_MESSAGE = 'Invalid value';
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @typedef {object} RuleResult
|
|
61
|
+
* @property {boolean} [visible] - EBV of the field's `visible` rule
|
|
62
|
+
* @property {boolean} [enabled] - EBV of the field's `enabled` rule
|
|
63
|
+
* @property {any} [computed] - Plain-JSON result of the `computed` rule
|
|
64
|
+
* @property {Array<import('./validate.js').FieldError>} [errors]
|
|
65
|
+
* `[{ keyword: 'x-form/assert', message }]` when the `assert` rule fails
|
|
66
|
+
* (the validateField error shape, so error rendering works unchanged)
|
|
67
|
+
*/
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Marker for the array-item position in a template pointer's part list
|
|
71
|
+
* (the `-` segment of `/lines/-`): evaluation expands it per element.
|
|
72
|
+
*/
|
|
73
|
+
const ITEM = Symbol('forms.ItemTemplate');
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Compile one rule member, prefixing compile errors with the field's
|
|
77
|
+
* data pointer and enforcing the externals whitelist.
|
|
78
|
+
*/
|
|
79
|
+
function compileRuleQuery(doc, fieldPointer, member, options) {
|
|
80
|
+
let query;
|
|
81
|
+
try {
|
|
82
|
+
query = compileJsonQuery(doc, options);
|
|
83
|
+
}
|
|
84
|
+
catch (e) {
|
|
85
|
+
if (e instanceof Error)
|
|
86
|
+
e.message = `${fieldPointer} x-form/${member}: ${e.message}`;
|
|
87
|
+
throw e;
|
|
88
|
+
}
|
|
89
|
+
const externals = query.externals;
|
|
90
|
+
for (let i = 0; i < externals.length; i++) {
|
|
91
|
+
const name = externals[i];
|
|
92
|
+
if (!ALLOWED_EXTERNALS.includes(name))
|
|
93
|
+
throw new Error(
|
|
94
|
+
`${fieldPointer} x-form/${member}: query cannot bind external '${name}' (only 'value' and 'pointer' are bound)`);
|
|
95
|
+
}
|
|
96
|
+
return query;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* @typedef {object} CompiledFieldRules
|
|
101
|
+
* @property {string} pointer - The field's data pointer (template pointers keep `-`)
|
|
102
|
+
* @property {Array<string|symbol>} parts - Decoded segments; ITEM marks an array-item slot
|
|
103
|
+
* @property {boolean} templated - Whether `parts` contains an ITEM slot
|
|
104
|
+
* @property {((root: any) => any)|null} getValue - Compiled getter (non-template fields)
|
|
105
|
+
* @property {function|null} visible
|
|
106
|
+
* @property {function|null} enabled
|
|
107
|
+
* @property {function|null} assert
|
|
108
|
+
* @property {function|null} computed
|
|
109
|
+
* @property {string|null} message
|
|
110
|
+
*/
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* @typedef {object} CompiledRules
|
|
114
|
+
* @property {Array<CompiledFieldRules>} rules
|
|
115
|
+
*/
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Compile every `x-form` rule of a form model into reusable closures.
|
|
119
|
+
*
|
|
120
|
+
* Walks the field tree once and runs `compileJsonQuery` per rule
|
|
121
|
+
* document. `options.compileTypeTest` passes through to the query
|
|
122
|
+
* compiler, so rules may use `$valid`/`$assert`/`$as` with a
|
|
123
|
+
* caller-supplied type-test compiler (the validator package's `query`
|
|
124
|
+
* module exports `createTypeTestCompiler()`; forms itself never imports
|
|
125
|
+
* the validator).
|
|
126
|
+
* Without the hook, a schema-using rule surfaces the engine's JQ0008.
|
|
127
|
+
*
|
|
128
|
+
* @param {import('./model.js').FormField} model - Root field from buildFormModel
|
|
129
|
+
* @param {object} [options]
|
|
130
|
+
* @param {(schemaJson: any, docPath: string) => ((value: any) => boolean)}
|
|
131
|
+
* [options.compileTypeTest] - hook for schema literals inside rules
|
|
132
|
+
* @returns {CompiledRules}
|
|
133
|
+
* @throws {Error} On a malformed rule document (field pointer prepended)
|
|
134
|
+
* or a rule referencing an external other than `value`/`pointer`
|
|
135
|
+
* @example
|
|
136
|
+
* const compiled = compileFormRules(model);
|
|
137
|
+
* const results = evaluateFormRules(compiled, data);
|
|
138
|
+
* results['/vatId']; // { visible: true, errors: [{ keyword: 'x-form/assert', ... }] }
|
|
139
|
+
*/
|
|
140
|
+
export function compileFormRules(model, options = {}) {
|
|
141
|
+
const queryOptions = options.compileTypeTest !== undefined
|
|
142
|
+
? { compileTypeTest: options.compileTypeTest }
|
|
143
|
+
: {};
|
|
144
|
+
/** @type {Array<CompiledFieldRules>} */
|
|
145
|
+
const rules = [];
|
|
146
|
+
walkField(model, [], rules, queryOptions);
|
|
147
|
+
return { rules };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function walkField(field, parts, rules, queryOptions) {
|
|
151
|
+
if (field == null) return;
|
|
152
|
+
|
|
153
|
+
const raw = field.rules;
|
|
154
|
+
if (raw != null) {
|
|
155
|
+
const pointer = field.pointer;
|
|
156
|
+
const templated = parts.includes(ITEM);
|
|
157
|
+
rules.push({
|
|
158
|
+
pointer,
|
|
159
|
+
parts,
|
|
160
|
+
templated,
|
|
161
|
+
getValue: templated ? null : compileJSONPointer(pointer),
|
|
162
|
+
visible: raw.visible !== undefined
|
|
163
|
+
? compileRuleQuery(raw.visible, pointer, 'visible', queryOptions) : null,
|
|
164
|
+
enabled: raw.enabled !== undefined
|
|
165
|
+
? compileRuleQuery(raw.enabled, pointer, 'enabled', queryOptions) : null,
|
|
166
|
+
assert: raw.assert !== undefined
|
|
167
|
+
? compileRuleQuery(raw.assert, pointer, 'assert', queryOptions) : null,
|
|
168
|
+
computed: raw.computed !== undefined
|
|
169
|
+
? compileRuleQuery(raw.computed, pointer, 'computed', queryOptions) : null,
|
|
170
|
+
message: typeof raw.message === 'string' ? raw.message : null,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (field.children) {
|
|
175
|
+
for (const child of field.children)
|
|
176
|
+
walkField(child, [...parts, child.key], rules, queryOptions);
|
|
177
|
+
}
|
|
178
|
+
if (field.tuple) {
|
|
179
|
+
for (let i = 0; i < field.tuple.length; i++)
|
|
180
|
+
walkField(field.tuple[i], [...parts, String(i)], rules, queryOptions);
|
|
181
|
+
}
|
|
182
|
+
if (field.item)
|
|
183
|
+
walkField(field.item, [...parts, ITEM], rules, queryOptions);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Evaluate one field's compiled rules against the data root.
|
|
188
|
+
* The externals object is reused across rules: the compiled query copies
|
|
189
|
+
* externals into its frame before evaluating (see the query engine), so
|
|
190
|
+
* mutation between calls is safe and allocation-free.
|
|
191
|
+
*/
|
|
192
|
+
function evaluateOne(rule, data, value, pointer, ext, results) {
|
|
193
|
+
ext.value = value === undefined ? null : value;
|
|
194
|
+
ext.pointer = pointer;
|
|
195
|
+
|
|
196
|
+
/** @type {RuleResult} */
|
|
197
|
+
const result = {};
|
|
198
|
+
if (rule.visible !== null)
|
|
199
|
+
result.visible = ebvFailOpen(rule.visible, data, ext);
|
|
200
|
+
if (rule.enabled !== null)
|
|
201
|
+
result.enabled = ebvFailOpen(rule.enabled, data, ext);
|
|
202
|
+
if (rule.computed !== null) {
|
|
203
|
+
try {
|
|
204
|
+
result.computed = rule.computed(data, ext);
|
|
205
|
+
}
|
|
206
|
+
catch (e) {
|
|
207
|
+
if (!(e instanceof JsonQueryRuntimeError)) throw e;
|
|
208
|
+
// no derived value to show
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (rule.assert !== null) {
|
|
212
|
+
let ok;
|
|
213
|
+
try {
|
|
214
|
+
ok = rule.assert.ebv(data, ext);
|
|
215
|
+
}
|
|
216
|
+
catch (e) {
|
|
217
|
+
if (!(e instanceof JsonQueryRuntimeError)) throw e;
|
|
218
|
+
ok = false; // fail closed: an uncomputable assertion is not satisfied
|
|
219
|
+
}
|
|
220
|
+
if (!ok) {
|
|
221
|
+
result.errors = [{
|
|
222
|
+
keyword: 'x-form/assert',
|
|
223
|
+
message: rule.message !== null ? rule.message : DEFAULT_ASSERT_MESSAGE,
|
|
224
|
+
}];
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
results[pointer] = result;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function ebvFailOpen(query, data, ext) {
|
|
231
|
+
try {
|
|
232
|
+
return query.ebv(data, ext);
|
|
233
|
+
}
|
|
234
|
+
catch (e) {
|
|
235
|
+
if (!(e instanceof JsonQueryRuntimeError)) throw e;
|
|
236
|
+
return true; // fail open: never hide data or lock a control on a broken rule
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Expand a template rule against the actual data: walk the decoded parts,
|
|
242
|
+
* and at each ITEM slot fan out over the array's real length, binding
|
|
243
|
+
* `value`/`pointer` per element (`/lines/-/amount` -> `/lines/2/amount`).
|
|
244
|
+
*
|
|
245
|
+
* This is the compiled-once/dispatch-many shape the JSLT template layer
|
|
246
|
+
* needs (JSLT-PRELUDE.md section 3, `$apply`): ONE compiled closure, one
|
|
247
|
+
* dispatcher deciding per node what it applies to. Keep the mechanism in
|
|
248
|
+
* this function.
|
|
249
|
+
*/
|
|
250
|
+
function expandItemRule(rule, data, node, partIndex, pointer, ext, results) {
|
|
251
|
+
const parts = rule.parts;
|
|
252
|
+
for (let i = partIndex; i < parts.length; i++) {
|
|
253
|
+
const part = parts[i];
|
|
254
|
+
if (part === ITEM) {
|
|
255
|
+
if (!Array.isArray(node)) return; // nothing to expand into
|
|
256
|
+
for (let index = 0; index < node.length; index++)
|
|
257
|
+
expandItemRule(rule, data, node[index], i + 1, `${pointer}/${index}`, ext, results);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
pointer = `${pointer}/${escapePointerKey(part)}`;
|
|
261
|
+
node = (node != null && typeof node === 'object')
|
|
262
|
+
? node[/** @type {string} */ (part)]
|
|
263
|
+
: undefined;
|
|
264
|
+
}
|
|
265
|
+
evaluateOne(rule, data, node, pointer, ext, results);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Evaluate compiled form rules against the current data root.
|
|
270
|
+
*
|
|
271
|
+
* Returns a map of data pointer -> RuleResult holding only the rules each
|
|
272
|
+
* field declares. Rules on array item templates are evaluated once per
|
|
273
|
+
* element of the actual array, keyed by the expanded pointer.
|
|
274
|
+
*
|
|
275
|
+
* @param {CompiledRules} compiled - From compileFormRules
|
|
276
|
+
* @param {any} data - The form data root (the query input `$`)
|
|
277
|
+
* @returns {Record<string, RuleResult>}
|
|
278
|
+
* @example
|
|
279
|
+
* const results = evaluateFormRules(compiled, { company: 'ACME', vatId: '' });
|
|
280
|
+
* results['/vatId'].errors; // [{ keyword: 'x-form/assert', message: '...' }]
|
|
281
|
+
*/
|
|
282
|
+
export function evaluateFormRules(compiled, data) {
|
|
283
|
+
/** @type {Record<string, RuleResult>} */
|
|
284
|
+
const results = {};
|
|
285
|
+
const ext = { value: null, pointer: '' };
|
|
286
|
+
const rules = compiled.rules;
|
|
287
|
+
for (let i = 0; i < rules.length; i++) {
|
|
288
|
+
const rule = rules[i];
|
|
289
|
+
if (rule.templated) {
|
|
290
|
+
expandItemRule(rule, data, data, 0, '', ext, results);
|
|
291
|
+
}
|
|
292
|
+
else {
|
|
293
|
+
const value = rule.getValue(data);
|
|
294
|
+
evaluateOne(rule, data, value === JSONPOINTER_NOTHING ? undefined : value, rule.pointer, ext, results);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return results;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
//#region $query synergy
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Build the RFC 9535 name selector `['...']` for one pointer segment.
|
|
304
|
+
* Single-quoted string literal: `\` and `'` escape with a backslash,
|
|
305
|
+
* control characters as `\uXXXX`.
|
|
306
|
+
*/
|
|
307
|
+
function pathNameSelector(key) {
|
|
308
|
+
let out = "['";
|
|
309
|
+
for (const ch of key) {
|
|
310
|
+
if (ch === '\\' || ch === "'")
|
|
311
|
+
out += `\\${ch}`;
|
|
312
|
+
else if (/** @type {number} */ (ch.codePointAt(0)) < 0x20)
|
|
313
|
+
out += `\\u${ch.codePointAt(0).toString(16).padStart(4, '0')}`;
|
|
314
|
+
else
|
|
315
|
+
out += ch;
|
|
316
|
+
}
|
|
317
|
+
return out + "']";
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Copy every `x-form.assert` of a schema into a `$query` assertion, so a
|
|
322
|
+
* rule authored once for per-keystroke feedback is also enforced by the
|
|
323
|
+
* authoritative submit validation (the validator's `$query` keyword).
|
|
324
|
+
* Pure schema-to-schema transform - no validator import; the output only
|
|
325
|
+
* spells the keyword.
|
|
326
|
+
*
|
|
327
|
+
* The `$query` lands on the ROOT schema (where the query input `$` is the
|
|
328
|
+
* instance root, matching the rule context), with each assert wrapped to
|
|
329
|
+
* rebuild its bindings: `value` binds to the field's location, `pointer`
|
|
330
|
+
* to its pointer string. An assert on an array item template quantifies
|
|
331
|
+
* with `$every` over the actual elements (`pointer` then stays the
|
|
332
|
+
* template pointer - element indexes are a render-time notion). Multiple
|
|
333
|
+
* asserts conjoin under `$and`; an existing root `$query` is preserved by
|
|
334
|
+
* wrapping the new one in an `allOf` branch.
|
|
335
|
+
*
|
|
336
|
+
* The transform follows the same structural spine as buildFormModel
|
|
337
|
+
* (`properties`, `items`, `prefixItems`, `allOf`) but does not resolve
|
|
338
|
+
* `$ref`s - a `$def`'s data location depends on its use site.
|
|
339
|
+
*
|
|
340
|
+
* @param {object|boolean} schema - The root JSON schema
|
|
341
|
+
* @returns {object|boolean} A new root schema (input is not mutated;
|
|
342
|
+
* untouched subtrees are shared) with the collected `$query`, or the
|
|
343
|
+
* input itself when there is nothing to copy
|
|
344
|
+
* @example
|
|
345
|
+
* const submitSchema = formRulesToQueryAssertions(schema);
|
|
346
|
+
* const validate = new JarenValidator().compile(submitSchema); // caller-side
|
|
347
|
+
*/
|
|
348
|
+
export function formRulesToQueryAssertions(schema) {
|
|
349
|
+
if (schema == null || typeof schema !== 'object' || Array.isArray(schema))
|
|
350
|
+
return schema;
|
|
351
|
+
|
|
352
|
+
/** @type {any[]} */
|
|
353
|
+
const assertions = [];
|
|
354
|
+
collectAsserts(schema, '', '$', 0, assertions);
|
|
355
|
+
if (assertions.length === 0)
|
|
356
|
+
return schema;
|
|
357
|
+
|
|
358
|
+
const queryDoc = assertions.length === 1 ? assertions[0] : { $and: assertions };
|
|
359
|
+
if (schema.$query !== undefined) {
|
|
360
|
+
const allOf = Array.isArray(schema.allOf) ? schema.allOf : [];
|
|
361
|
+
return { ...schema, allOf: [...allOf, { $query: queryDoc }] };
|
|
362
|
+
}
|
|
363
|
+
return { ...schema, $query: queryDoc };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Depth-first collection of `x-form.assert` documents with the pointer
|
|
368
|
+
* and root-relative JSONPath of their data location. `itemDepth` counts
|
|
369
|
+
* enclosing `[*]` expansions: inside one, `value` must quantify per
|
|
370
|
+
* element instead of binding the selected sequence.
|
|
371
|
+
*/
|
|
372
|
+
function collectAsserts(schema, pointer, path, itemDepth, out) {
|
|
373
|
+
if (schema == null || typeof schema !== 'object' || Array.isArray(schema))
|
|
374
|
+
return;
|
|
375
|
+
|
|
376
|
+
const rules = schema['x-form'];
|
|
377
|
+
if (rules != null && typeof rules === 'object' && !Array.isArray(rules)
|
|
378
|
+
&& rules.assert !== undefined) {
|
|
379
|
+
const bindPointer = { $const: pointer };
|
|
380
|
+
out.push(itemDepth === 0
|
|
381
|
+
? { $let: { value: path, pointer: bindPointer }, $return: rules.assert }
|
|
382
|
+
: { $every: { value: path },
|
|
383
|
+
$satisfies: { $let: { pointer: bindPointer }, $return: rules.assert } });
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (schema.properties != null && typeof schema.properties === 'object') {
|
|
387
|
+
for (const [key, sub] of Object.entries(schema.properties)) {
|
|
388
|
+
collectAsserts(sub, `${pointer}/${escapePointerKey(key)}`,
|
|
389
|
+
path + pathNameSelector(key), itemDepth, out);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
if (Array.isArray(schema.prefixItems)) {
|
|
393
|
+
for (let i = 0; i < schema.prefixItems.length; i++)
|
|
394
|
+
collectAsserts(schema.prefixItems[i], `${pointer}/${i}`, `${path}[${i}]`, itemDepth, out);
|
|
395
|
+
}
|
|
396
|
+
if (schema.items != null && typeof schema.items === 'object' && !Array.isArray(schema.items))
|
|
397
|
+
collectAsserts(schema.items, `${pointer}/-`, `${path}[*]`, itemDepth + 1, out);
|
|
398
|
+
if (Array.isArray(schema.allOf)) {
|
|
399
|
+
for (const branch of schema.allOf)
|
|
400
|
+
collectAsserts(branch, pointer, path, itemDepth, out);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
//#endregion
|
package/src/validate.js
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Preemptive per-field validation.
|
|
5
|
+
*
|
|
6
|
+
* These checks run synchronously on every keystroke, powered directly by
|
|
7
|
+
* @jarenjs/core primitives (grapheme-aware string length, unicode regexes,
|
|
8
|
+
* format testers, deep equality). They give the user immediate feedback
|
|
9
|
+
* per field BEFORE the complete compiled schema validation runs - which
|
|
10
|
+
* remains authoritative for cross-field rules (required combinations,
|
|
11
|
+
* dependencies, unevaluatedProperties, ...). Cross-field feedback per
|
|
12
|
+
* keystroke is rules.js territory (the `x-form` annotation).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
getStringLength,
|
|
17
|
+
createRegExp,
|
|
18
|
+
} from '@jarenjs/core/string';
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
equalsDeep,
|
|
22
|
+
isUniqueDeepArray,
|
|
23
|
+
} from '@jarenjs/core/object';
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
getFormatInfo,
|
|
27
|
+
} from './formats.js';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @typedef {object} FieldError
|
|
31
|
+
* @property {string} keyword - The JSON Schema keyword that failed
|
|
32
|
+
* @property {string} message - Human readable message
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const regexCache = new Map();
|
|
36
|
+
function getPattern(source) {
|
|
37
|
+
let regex = regexCache.get(source);
|
|
38
|
+
if (regex === undefined) {
|
|
39
|
+
try {
|
|
40
|
+
regex = createRegExp(source);
|
|
41
|
+
}
|
|
42
|
+
catch (e) {
|
|
43
|
+
regex = null;
|
|
44
|
+
}
|
|
45
|
+
if (regexCache.size > 500) regexCache.clear();
|
|
46
|
+
regexCache.set(source, regex);
|
|
47
|
+
}
|
|
48
|
+
return regex;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function formatValue(value) {
|
|
52
|
+
return typeof value === 'string' ? `"${value}"` : JSON.stringify(value);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Validate a single field value against its own constraints.
|
|
57
|
+
*
|
|
58
|
+
* @param {import('./model.js').FormField} field - Field from buildFormModel
|
|
59
|
+
* @param {any} value - The TYPED value (see parseFieldInput); undefined = absent
|
|
60
|
+
* @returns {FieldError[]} Empty when the value passes every per-field check
|
|
61
|
+
* @example
|
|
62
|
+
* const errors = validateField(emailField, 'not-an-email');
|
|
63
|
+
* // [{ keyword: 'format', message: 'Must be a valid email' }]
|
|
64
|
+
*/
|
|
65
|
+
export function validateField(field, value) {
|
|
66
|
+
/** @type {FieldError[]} */
|
|
67
|
+
const errors = [];
|
|
68
|
+
if (field == null) return errors;
|
|
69
|
+
|
|
70
|
+
// Absent value: only `required` applies
|
|
71
|
+
if (value === undefined || value === null) {
|
|
72
|
+
if (field.required && field.kind !== 'boolean') {
|
|
73
|
+
errors.push({ keyword: 'required', message: 'This field is required' });
|
|
74
|
+
}
|
|
75
|
+
return errors;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const c = field.constraints;
|
|
79
|
+
|
|
80
|
+
switch (field.kind) {
|
|
81
|
+
case 'const': {
|
|
82
|
+
if (!equalsDeep(value, field.constValue)) {
|
|
83
|
+
errors.push({ keyword: 'const', message: `Must be ${formatValue(field.constValue)}` });
|
|
84
|
+
}
|
|
85
|
+
return errors;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
case 'enum': {
|
|
89
|
+
if (!field.enumValues?.some((option) => equalsDeep(value, option))) {
|
|
90
|
+
errors.push({
|
|
91
|
+
keyword: 'enum',
|
|
92
|
+
message: `Must be one of: ${field.enumValues?.map(formatValue).join(', ')}`,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
return errors;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
case 'string': {
|
|
99
|
+
if (typeof value !== 'string') {
|
|
100
|
+
errors.push({ keyword: 'type', message: 'Must be a string' });
|
|
101
|
+
return errors;
|
|
102
|
+
}
|
|
103
|
+
let len = -1;
|
|
104
|
+
if (c.minLength !== undefined || c.maxLength !== undefined) {
|
|
105
|
+
len = getStringLength(value, true); // grapheme-aware, like the validator
|
|
106
|
+
}
|
|
107
|
+
if (c.minLength !== undefined && len < c.minLength) {
|
|
108
|
+
errors.push({
|
|
109
|
+
keyword: 'minLength',
|
|
110
|
+
message: `Must be at least ${c.minLength} character${c.minLength === 1 ? '' : 's'} (currently ${len})`,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
if (c.maxLength !== undefined && len > c.maxLength) {
|
|
114
|
+
errors.push({
|
|
115
|
+
keyword: 'maxLength',
|
|
116
|
+
message: `Must be at most ${c.maxLength} character${c.maxLength === 1 ? '' : 's'} (currently ${len})`,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
if (c.pattern !== undefined) {
|
|
120
|
+
const regex = getPattern(c.pattern);
|
|
121
|
+
if (regex != null && !regex.test(value)) {
|
|
122
|
+
errors.push({ keyword: 'pattern', message: `Must match pattern ${c.pattern}` });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (c.format !== undefined && value !== '') {
|
|
126
|
+
const info = getFormatInfo(c.format);
|
|
127
|
+
if (info != null && !info.test(value)) {
|
|
128
|
+
errors.push({ keyword: 'format', message: `Must be a valid ${c.format}` });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return errors;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
case 'number':
|
|
135
|
+
case 'integer': {
|
|
136
|
+
const num = typeof value === 'number' ? value : Number(value);
|
|
137
|
+
if (typeof value === 'boolean' || Number.isNaN(num)) {
|
|
138
|
+
errors.push({ keyword: 'type', message: 'Must be a number' });
|
|
139
|
+
return errors;
|
|
140
|
+
}
|
|
141
|
+
if (field.kind === 'integer' && !Number.isInteger(num)) {
|
|
142
|
+
errors.push({ keyword: 'type', message: 'Must be an integer' });
|
|
143
|
+
}
|
|
144
|
+
if (c.minimum !== undefined && num < c.minimum) {
|
|
145
|
+
errors.push({ keyword: 'minimum', message: `Must be at least ${c.minimum}` });
|
|
146
|
+
}
|
|
147
|
+
if (c.maximum !== undefined && num > c.maximum) {
|
|
148
|
+
errors.push({ keyword: 'maximum', message: `Must be at most ${c.maximum}` });
|
|
149
|
+
}
|
|
150
|
+
if (c.exclusiveMinimum !== undefined && num <= c.exclusiveMinimum) {
|
|
151
|
+
errors.push({ keyword: 'exclusiveMinimum', message: `Must be greater than ${c.exclusiveMinimum}` });
|
|
152
|
+
}
|
|
153
|
+
if (c.exclusiveMaximum !== undefined && num >= c.exclusiveMaximum) {
|
|
154
|
+
errors.push({ keyword: 'exclusiveMaximum', message: `Must be less than ${c.exclusiveMaximum}` });
|
|
155
|
+
}
|
|
156
|
+
if (c.multipleOf !== undefined) {
|
|
157
|
+
const quotient = num / c.multipleOf;
|
|
158
|
+
if (Math.abs(quotient - Math.round(quotient)) >= 1e-6) {
|
|
159
|
+
errors.push({ keyword: 'multipleOf', message: `Must be a multiple of ${c.multipleOf}` });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return errors;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
case 'boolean': {
|
|
166
|
+
if (typeof value !== 'boolean') {
|
|
167
|
+
errors.push({ keyword: 'type', message: 'Must be a boolean' });
|
|
168
|
+
}
|
|
169
|
+
return errors;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
case 'array': {
|
|
173
|
+
if (!Array.isArray(value)) {
|
|
174
|
+
errors.push({ keyword: 'type', message: 'Must be an array' });
|
|
175
|
+
return errors;
|
|
176
|
+
}
|
|
177
|
+
if (c.minItems !== undefined && value.length < c.minItems) {
|
|
178
|
+
errors.push({
|
|
179
|
+
keyword: 'minItems',
|
|
180
|
+
message: `Must have at least ${c.minItems} item${c.minItems === 1 ? '' : 's'}`,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
if (c.maxItems !== undefined && value.length > c.maxItems) {
|
|
184
|
+
errors.push({
|
|
185
|
+
keyword: 'maxItems',
|
|
186
|
+
message: `Must have at most ${c.maxItems} item${c.maxItems === 1 ? '' : 's'}`,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
if (c.uniqueItems === true && !isUniqueDeepArray(value)) {
|
|
190
|
+
errors.push({ keyword: 'uniqueItems', message: 'Items must be unique' });
|
|
191
|
+
}
|
|
192
|
+
return errors;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
case 'object': {
|
|
196
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
197
|
+
errors.push({ keyword: 'type', message: 'Must be an object' });
|
|
198
|
+
return errors;
|
|
199
|
+
}
|
|
200
|
+
const size = Object.keys(value).length;
|
|
201
|
+
if (c.minProperties !== undefined && size < c.minProperties) {
|
|
202
|
+
errors.push({ keyword: 'minProperties', message: `Must have at least ${c.minProperties} properties` });
|
|
203
|
+
}
|
|
204
|
+
if (c.maxProperties !== undefined && size > c.maxProperties) {
|
|
205
|
+
errors.push({ keyword: 'maxProperties', message: `Must have at most ${c.maxProperties} properties` });
|
|
206
|
+
}
|
|
207
|
+
return errors;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
default:
|
|
211
|
+
return errors;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Validate every leaf field of a model against the current data.
|
|
217
|
+
* Returns a map of data-pointer -> FieldError[] for fields that fail.
|
|
218
|
+
* @param {import('./model.js').FormField} model - Root field from buildFormModel
|
|
219
|
+
* @param {any} data - Current form data
|
|
220
|
+
* @returns {Record<string, FieldError[]>}
|
|
221
|
+
*/
|
|
222
|
+
export function validateAllFields(model, data) {
|
|
223
|
+
/** @type {Record<string, FieldError[]>} */
|
|
224
|
+
const result = {};
|
|
225
|
+
walkFields(model, data, '', result);
|
|
226
|
+
return result;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function walkFields(field, value, pointer, result) {
|
|
230
|
+
const errors = validateField(field, value);
|
|
231
|
+
if (errors.length > 0) result[pointer] = errors;
|
|
232
|
+
|
|
233
|
+
if (field.kind === 'object' && field.children && value != null && typeof value === 'object') {
|
|
234
|
+
for (const child of field.children) {
|
|
235
|
+
walkFields(child, value[child.key], `${pointer}/${child.key}`, result);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
else if (field.kind === 'array' && Array.isArray(value)) {
|
|
239
|
+
for (let i = 0; i < value.length; i++) {
|
|
240
|
+
const itemField = field.tuple?.[i] ?? field.item;
|
|
241
|
+
if (itemField) walkFields(itemField, value[i], `${pointer}/${i}`, result);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|