@object-ui/core 16.0.0 → 17.1.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/CHANGELOG.md +1589 -1
- package/dist/actions/ActionRunner.d.ts +88 -16
- package/dist/actions/ActionRunner.js +164 -81
- package/dist/actions/actionKeys.d.ts +118 -0
- package/dist/actions/actionKeys.js +243 -0
- package/dist/actions/index.d.ts +1 -0
- package/dist/actions/index.js +1 -0
- package/dist/adapters/ApiDataSource.d.ts +11 -1
- package/dist/adapters/ApiDataSource.js +21 -1
- package/dist/adapters/ValueDataSource.d.ts +10 -2
- package/dist/adapters/ValueDataSource.js +9 -0
- package/dist/adapters/batchTransaction.d.ts +49 -0
- package/dist/adapters/batchTransaction.js +118 -0
- package/dist/adapters/index.d.ts +1 -0
- package/dist/adapters/index.js +1 -0
- package/dist/evaluator/ExpressionEvaluator.d.ts +10 -0
- package/dist/evaluator/ExpressionEvaluator.js +48 -0
- package/dist/evaluator/SafeExpressionParser.js +5 -2
- package/dist/evaluator/fieldRules.d.ts +0 -2
- package/dist/evaluator/fieldRules.js +3 -2
- package/dist/evaluator/optionLint.js +5 -2
- package/dist/evaluator/optionRules.d.ts +35 -6
- package/dist/evaluator/optionRules.js +18 -23
- package/dist/index.d.ts +5 -0
- package/dist/index.js +8 -0
- package/dist/registry/Registry.d.ts +81 -2
- package/dist/registry/Registry.js +102 -2
- package/dist/registry/public-blocks.js +52 -1
- package/dist/theme/ThemeEngine.js +0 -12
- package/dist/utils/chart-series.d.ts +28 -0
- package/dist/utils/chart-series.js +55 -0
- package/dist/utils/column-identity.d.ts +110 -0
- package/dist/utils/column-identity.js +210 -0
- package/dist/utils/dashboard-filters.d.ts +2 -2
- package/dist/utils/dataset-format.d.ts +18 -1
- package/dist/utils/dataset-format.js +14 -1
- package/dist/utils/expand-fields.js +8 -8
- package/dist/utils/export-filename.js +4 -1
- package/dist/utils/filter-converter.d.ts +47 -0
- package/dist/utils/filter-converter.js +94 -7
- package/dist/utils/filter-tokens.d.ts +101 -0
- package/dist/utils/filter-tokens.js +173 -0
- package/dist/utils/managedBy.d.ts +119 -0
- package/dist/utils/managedBy.js +147 -0
- package/dist/utils/normalize-list-view.d.ts +99 -0
- package/dist/utils/normalize-list-view.js +255 -0
- package/dist/utils/record-title.js +0 -0
- package/dist/utils/sort-values.d.ts +52 -0
- package/dist/utils/sort-values.js +125 -0
- package/dist/validation/index.d.ts +9 -1
- package/dist/validation/index.js +9 -1
- package/dist/validation/validators/index.d.ts +7 -1
- package/dist/validation/validators/index.js +7 -1
- package/dist/validation/validators/object-validation-engine.d.ts +151 -31
- package/dist/validation/validators/object-validation-engine.js +275 -142
- package/package.json +4 -4
|
@@ -5,6 +5,50 @@
|
|
|
5
5
|
* This source code is licensed under the MIT license found in the
|
|
6
6
|
* LICENSE file in the root directory of this source tree.
|
|
7
7
|
*/
|
|
8
|
+
/** The spec's `events` default, applied here because `/meta` ships it absent. */
|
|
9
|
+
const DEFAULT_EVENTS = ['insert', 'update'];
|
|
10
|
+
/** The spec's `priority` default (lower runs first). */
|
|
11
|
+
const DEFAULT_PRIORITY = 100;
|
|
12
|
+
/**
|
|
13
|
+
* Read a predicate out of an `ExpressionInput` — the spec's wire shape for every
|
|
14
|
+
* expression-valued key, which is `string | { dialect, source?, ast? }`.
|
|
15
|
+
*
|
|
16
|
+
* Returns `null` when there is nothing evaluable (an `ast`-only envelope, an
|
|
17
|
+
* empty source, a non-CEL dialect). `null` means BROKEN, and every caller
|
|
18
|
+
* fails open on it, mirroring the server.
|
|
19
|
+
*/
|
|
20
|
+
function expressionSource(input) {
|
|
21
|
+
if (typeof input === 'string')
|
|
22
|
+
return input.trim() === '' ? null : input;
|
|
23
|
+
if (input && typeof input === 'object') {
|
|
24
|
+
const { dialect, source } = input;
|
|
25
|
+
// `cron` / `template` are not predicates, and an absent dialect is not the
|
|
26
|
+
// spec's envelope at all. Either way there is nothing to evaluate here.
|
|
27
|
+
if (dialect !== undefined && dialect !== 'cel')
|
|
28
|
+
return null;
|
|
29
|
+
if (typeof source === 'string' && source.trim() !== '')
|
|
30
|
+
return source;
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
// The three readers below take `unknown` on purpose. They run against metadata
|
|
35
|
+
// as it arrives over `/meta`, where any of these keys may be absent, and an
|
|
36
|
+
// absent key must resolve to the SPEC's default rather than to JavaScript's
|
|
37
|
+
// falsiness. Narrowing here keeps that decision in one place.
|
|
38
|
+
/** `active` defaults to `true`; only an explicit `false` disables a rule. */
|
|
39
|
+
function isActive(rule) {
|
|
40
|
+
return rule?.active !== false;
|
|
41
|
+
}
|
|
42
|
+
/** `events` defaults to `['insert','update']`. */
|
|
43
|
+
function eventsOf(rule) {
|
|
44
|
+
const events = rule?.events;
|
|
45
|
+
return Array.isArray(events) && events.length > 0 ? events : DEFAULT_EVENTS;
|
|
46
|
+
}
|
|
47
|
+
/** `priority` defaults to 100 (lower runs first). */
|
|
48
|
+
function priorityOf(rule) {
|
|
49
|
+
const priority = rule?.priority;
|
|
50
|
+
return typeof priority === 'number' ? priority : DEFAULT_PRIORITY;
|
|
51
|
+
}
|
|
8
52
|
/**
|
|
9
53
|
* Simple expression evaluator using a simple parser (no dynamic code execution)
|
|
10
54
|
*
|
|
@@ -20,9 +64,13 @@
|
|
|
20
64
|
* - Simple escape sequence handling (doesn't handle escaped backslashes)
|
|
21
65
|
* - Field names in bracket notation cannot contain escaped quotes
|
|
22
66
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
67
|
+
* NOT CEL. The spec's `dialect: 'cel'` predicates are evaluated server-side by
|
|
68
|
+
* `@objectstack/formula`; this parser understands the common comparison shape
|
|
69
|
+
* and nothing more. Anything it cannot parse yields a falsy value, which under
|
|
70
|
+
* this engine's polarity (TRUE ⇒ violated) means the rule fails OPEN — the same
|
|
71
|
+
* outcome the server produces for an un-evaluable predicate. To make the
|
|
72
|
+
* pre-check agree with the server on richer predicates, pass a CEL-backed
|
|
73
|
+
* `ValidationExpressionEvaluator` to the constructor.
|
|
26
74
|
*
|
|
27
75
|
* @see https://github.com/objectstack-ai/objectui/blob/main/SECURITY_FIX_SUMMARY.md
|
|
28
76
|
*/
|
|
@@ -169,8 +217,15 @@ class SimpleExpressionEvaluator {
|
|
|
169
217
|
}
|
|
170
218
|
}
|
|
171
219
|
/**
|
|
172
|
-
* Object-Level Validation Engine
|
|
173
|
-
*
|
|
220
|
+
* Object-Level Validation Engine — the client pre-check described at the top of
|
|
221
|
+
* this module.
|
|
222
|
+
*
|
|
223
|
+
* @deprecated (#3110) Validation rules are enforcement, and enforcement is
|
|
224
|
+
* single-implementation on the server (`objectql`'s rule-validator). Render the
|
|
225
|
+
* server's rejection instead; for pre-submit feedback, use a validate-only
|
|
226
|
+
* (dry-run) write. Still functional and still correct as of #3103 — but it
|
|
227
|
+
* cannot stay in step with the server without a conformance-fixture mechanism
|
|
228
|
+
* that does not exist yet. See the module header.
|
|
174
229
|
*/
|
|
175
230
|
export class ObjectValidationEngine {
|
|
176
231
|
constructor(expressionEvaluator, uniquenessChecker) {
|
|
@@ -190,20 +245,21 @@ export class ObjectValidationEngine {
|
|
|
190
245
|
this.uniquenessChecker = uniquenessChecker;
|
|
191
246
|
}
|
|
192
247
|
/**
|
|
193
|
-
* Validate a record against a set of validation rules
|
|
248
|
+
* Validate a record against a set of validation rules.
|
|
249
|
+
*
|
|
250
|
+
* Rules run in `priority` order (lower first, stable within a priority), and
|
|
251
|
+
* only when `active` is not explicitly `false` and `events` covers this write
|
|
252
|
+
* context — reading the spec's defaults, not the falsiness of an absent key.
|
|
194
253
|
*/
|
|
195
254
|
async validateRecord(rules, context, event = 'insert') {
|
|
196
255
|
const results = [];
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
continue;
|
|
205
|
-
}
|
|
206
|
-
const result = await this.validateRule(rule, context);
|
|
256
|
+
const applicable = rules
|
|
257
|
+
.filter((rule) => isActive(rule) && eventsOf(rule).includes(event))
|
|
258
|
+
// `sort` is stable in every engine objectui targets (ES2019+), so equal
|
|
259
|
+
// priorities keep their authored order.
|
|
260
|
+
.sort((a, b) => priorityOf(a) - priorityOf(b));
|
|
261
|
+
for (const rule of applicable) {
|
|
262
|
+
const result = await this.validateRule(rule, context, event);
|
|
207
263
|
if (!result.valid) {
|
|
208
264
|
results.push(result);
|
|
209
265
|
}
|
|
@@ -211,40 +267,76 @@ export class ObjectValidationEngine {
|
|
|
211
267
|
return results;
|
|
212
268
|
}
|
|
213
269
|
/**
|
|
214
|
-
* Validate a single rule
|
|
270
|
+
* Validate a single rule.
|
|
271
|
+
*
|
|
272
|
+
* `rule` is widened past `ObjectValidationRule` because a `conditional`'s
|
|
273
|
+
* `then` / `otherwise` branch may be any rule the SPEC's union admits —
|
|
274
|
+
* including `json_schema`, which this engine does not implement.
|
|
215
275
|
*/
|
|
216
|
-
async validateRule(rule, context) {
|
|
276
|
+
async validateRule(rule, context, event) {
|
|
217
277
|
switch (rule.type) {
|
|
218
278
|
case 'script':
|
|
219
|
-
return this.
|
|
220
|
-
case 'unique':
|
|
221
|
-
return this.validateUniqueness(rule, context);
|
|
222
|
-
case 'state_machine':
|
|
223
|
-
return this.validateStateMachine(rule, context);
|
|
279
|
+
return this.validatePredicate(rule, context, 'Script');
|
|
224
280
|
case 'cross_field':
|
|
225
|
-
return this.
|
|
226
|
-
case '
|
|
227
|
-
return this.
|
|
281
|
+
return this.validatePredicate(rule, context, 'Cross-field');
|
|
282
|
+
case 'state_machine':
|
|
283
|
+
return this.validateStateMachine(rule, context, event);
|
|
228
284
|
case 'conditional':
|
|
229
|
-
return this.validateConditional(rule, context);
|
|
285
|
+
return this.validateConditional(rule, context, event);
|
|
230
286
|
case 'format':
|
|
231
287
|
return this.validateFormat(rule, context);
|
|
288
|
+
// Deprecated objectui-local variants — see @object-ui/types. The spec's
|
|
289
|
+
// union rejects these, so they cannot arrive from `/meta`.
|
|
290
|
+
case 'unique':
|
|
291
|
+
return this.validateUniqueness(rule, context);
|
|
292
|
+
case 'async':
|
|
293
|
+
return this.validateAsync(rule, context);
|
|
232
294
|
case 'range':
|
|
233
295
|
return this.validateRange(rule, context);
|
|
234
296
|
default:
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
297
|
+
// Loud, not silent. `json_schema` is a real spec variant this engine
|
|
298
|
+
// does not implement; reporting it as valid would let the pre-check
|
|
299
|
+
// green-light a record the server is about to reject, with nothing in
|
|
300
|
+
// the console to explain the disagreement.
|
|
301
|
+
console.warn(`[ObjectValidationEngine] rule '${rule.name ?? '(unnamed)'}' has ` +
|
|
302
|
+
`type '${rule.type}', which this client engine does not evaluate — ` +
|
|
303
|
+
`not checked here; the server remains the authority.`);
|
|
304
|
+
return { valid: true };
|
|
239
305
|
}
|
|
240
306
|
}
|
|
241
307
|
/**
|
|
242
|
-
*
|
|
308
|
+
* The evaluation scope for a predicate.
|
|
309
|
+
*
|
|
310
|
+
* The server binds `{ record, previous }`, so a spec-authored predicate reads
|
|
311
|
+
* `record.amount`. objectui's own rules have always been written against the
|
|
312
|
+
* bare field name (`amount`). Both are bound — the roots last so a field
|
|
313
|
+
* literally called `record` cannot shadow them.
|
|
243
314
|
*/
|
|
244
|
-
|
|
315
|
+
scopeFor(context) {
|
|
316
|
+
return {
|
|
317
|
+
...context.record,
|
|
318
|
+
record: context.record,
|
|
319
|
+
previous: context.oldRecord,
|
|
320
|
+
user: context.user,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Evaluate a `script` / `cross_field` predicate.
|
|
325
|
+
*
|
|
326
|
+
* Both variants share one path on the server, and so do they here. The
|
|
327
|
+
* predicate is the FAILURE condition: TRUE means the rule is violated. A
|
|
328
|
+
* predicate that cannot be evaluated is a BROKEN rule and fails open.
|
|
329
|
+
*/
|
|
330
|
+
validatePredicate(rule, context, label) {
|
|
331
|
+
const source = expressionSource(rule.condition);
|
|
332
|
+
if (source === null) {
|
|
333
|
+
console.warn(`[ObjectValidationEngine] ${label.toLowerCase()} rule '${rule.name}' has no evaluable ` +
|
|
334
|
+
`condition — skipped (the server remains the authority).`);
|
|
335
|
+
return { valid: true };
|
|
336
|
+
}
|
|
245
337
|
try {
|
|
246
|
-
const
|
|
247
|
-
if (
|
|
338
|
+
const violated = this.expressionEvaluator.evaluate(source, this.scopeFor(context));
|
|
339
|
+
if (violated === true) {
|
|
248
340
|
return {
|
|
249
341
|
valid: false,
|
|
250
342
|
message: rule.message,
|
|
@@ -255,12 +347,10 @@ export class ObjectValidationEngine {
|
|
|
255
347
|
return { valid: true };
|
|
256
348
|
}
|
|
257
349
|
catch (error) {
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
severity: 'error',
|
|
263
|
-
};
|
|
350
|
+
// Broken, not violated — mirror the server and fail open.
|
|
351
|
+
console.warn(`[ObjectValidationEngine] ${label.toLowerCase()} rule '${rule.name}' failed to ` +
|
|
352
|
+
`evaluate (${error}) — skipped.`);
|
|
353
|
+
return { valid: true };
|
|
264
354
|
}
|
|
265
355
|
}
|
|
266
356
|
/**
|
|
@@ -287,31 +377,65 @@ export class ObjectValidationEngine {
|
|
|
287
377
|
return { valid: true };
|
|
288
378
|
}
|
|
289
379
|
/**
|
|
290
|
-
* Validate state machine transitions
|
|
380
|
+
* Validate state machine transitions (ADR-0020).
|
|
381
|
+
*
|
|
382
|
+
* A flat `field` plus a `{ from: [allowedTo] }` map. Semantics mirror the
|
|
383
|
+
* server-side rule-validator:
|
|
384
|
+
*
|
|
385
|
+
* INSERT
|
|
386
|
+
* - no `initialStates` declared → allow (legacy no-op)
|
|
387
|
+
* - field absent from the write → allow (requiredness is not our job)
|
|
388
|
+
* - value empty → allow (ditto)
|
|
389
|
+
* - value ∉ initialStates → reject (the FSM entry point, #3165)
|
|
390
|
+
*
|
|
391
|
+
* UPDATE
|
|
392
|
+
* - field absent from the write → allow (no transition to police)
|
|
393
|
+
* - no prior record → allow (cannot reason)
|
|
394
|
+
* - field unchanged, or cleared → allow (no transition)
|
|
395
|
+
* - `from` not declared in the map → lenient allow (unconstrained)
|
|
396
|
+
* - `to` ∉ transitions[from] → reject
|
|
291
397
|
*/
|
|
292
|
-
validateStateMachine(rule, context) {
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
398
|
+
validateStateMachine(rule, context, event) {
|
|
399
|
+
const touched = rule.field in context.record;
|
|
400
|
+
if (event === 'insert') {
|
|
401
|
+
const initial = rule.initialStates;
|
|
402
|
+
// No entry-point contract — insert is unconstrained (legacy behavior).
|
|
403
|
+
if (!Array.isArray(initial) || initial.length === 0)
|
|
404
|
+
return { valid: true };
|
|
405
|
+
if (!touched)
|
|
406
|
+
return { valid: true };
|
|
407
|
+
const value = context.record[rule.field];
|
|
408
|
+
// Empty is requiredness's problem, not the FSM's.
|
|
409
|
+
if (value === undefined || value === null || value === '')
|
|
410
|
+
return { valid: true };
|
|
411
|
+
if (initial.includes(String(value)))
|
|
412
|
+
return { valid: true };
|
|
413
|
+
return {
|
|
414
|
+
valid: false,
|
|
415
|
+
message: rule.message || `'${String(value)}' is not a valid initial state for ${rule.field}`,
|
|
416
|
+
rule: rule.name,
|
|
417
|
+
severity: rule.severity,
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
// The state field wasn't part of this write — nothing to police.
|
|
421
|
+
if (!touched)
|
|
422
|
+
return { valid: true };
|
|
299
423
|
const currentState = context.record[rule.field];
|
|
300
424
|
const previousState = context.oldRecord?.[rule.field];
|
|
301
|
-
// No prior state
|
|
425
|
+
// No prior state — cannot reason about a transition.
|
|
302
426
|
if (previousState === undefined || previousState === null) {
|
|
303
427
|
return { valid: true };
|
|
304
428
|
}
|
|
305
|
-
//
|
|
306
|
-
if (previousState === currentState) {
|
|
429
|
+
// Unchanged, or cleared — no transition to enforce.
|
|
430
|
+
if (previousState === currentState || currentState === undefined || currentState === null) {
|
|
307
431
|
return { valid: true };
|
|
308
432
|
}
|
|
309
|
-
const allowed = rule.transitions[previousState];
|
|
433
|
+
const allowed = rule.transitions[String(previousState)];
|
|
310
434
|
// `from` state not declared — leave it unconstrained (lenient).
|
|
311
|
-
if (allowed
|
|
435
|
+
if (!Array.isArray(allowed)) {
|
|
312
436
|
return { valid: true };
|
|
313
437
|
}
|
|
314
|
-
if (allowed.includes(currentState)) {
|
|
438
|
+
if (allowed.includes(String(currentState))) {
|
|
315
439
|
return { valid: true };
|
|
316
440
|
}
|
|
317
441
|
return {
|
|
@@ -321,31 +445,6 @@ export class ObjectValidationEngine {
|
|
|
321
445
|
severity: rule.severity,
|
|
322
446
|
};
|
|
323
447
|
}
|
|
324
|
-
/**
|
|
325
|
-
* Validate cross-field constraints
|
|
326
|
-
*/
|
|
327
|
-
validateCrossField(rule, context) {
|
|
328
|
-
try {
|
|
329
|
-
const result = this.expressionEvaluator.evaluate(rule.condition, context.record);
|
|
330
|
-
if (!result) {
|
|
331
|
-
return {
|
|
332
|
-
valid: false,
|
|
333
|
-
message: rule.message,
|
|
334
|
-
rule: rule.name,
|
|
335
|
-
severity: rule.severity,
|
|
336
|
-
};
|
|
337
|
-
}
|
|
338
|
-
return { valid: true };
|
|
339
|
-
}
|
|
340
|
-
catch (error) {
|
|
341
|
-
return {
|
|
342
|
-
valid: false,
|
|
343
|
-
message: `Cross-field validation error: ${error}`,
|
|
344
|
-
rule: rule.name,
|
|
345
|
-
severity: 'error',
|
|
346
|
-
};
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
448
|
/**
|
|
350
449
|
* Validate async/remote validation
|
|
351
450
|
*/
|
|
@@ -380,71 +479,75 @@ export class ObjectValidationEngine {
|
|
|
380
479
|
}
|
|
381
480
|
}
|
|
382
481
|
/**
|
|
383
|
-
* Validate conditional
|
|
482
|
+
* Validate a conditional rule.
|
|
483
|
+
*
|
|
484
|
+
* Evaluates `when`, then recurses into `then` (TRUE) or `otherwise` (FALSE).
|
|
485
|
+
* The nested rule supplies the violation; an un-evaluable `when` is a broken
|
|
486
|
+
* rule and fails open. A branch whose own `active` is explicitly `false` is
|
|
487
|
+
* skipped, matching the server.
|
|
384
488
|
*/
|
|
385
|
-
async validateConditional(rule, context) {
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
return { valid: true };
|
|
391
|
-
}
|
|
392
|
-
// Condition met, validate nested rules
|
|
393
|
-
for (const nestedRule of rule.rules) {
|
|
394
|
-
const result = await this.validateRule(nestedRule, context);
|
|
395
|
-
if (!result.valid) {
|
|
396
|
-
return result;
|
|
397
|
-
}
|
|
398
|
-
}
|
|
489
|
+
async validateConditional(rule, context, event) {
|
|
490
|
+
const source = expressionSource(rule.when);
|
|
491
|
+
if (source === null) {
|
|
492
|
+
console.warn(`[ObjectValidationEngine] conditional rule '${rule.name}' has no evaluable ` +
|
|
493
|
+
`\`when\` predicate — skipped (the server remains the authority).`);
|
|
399
494
|
return { valid: true };
|
|
400
495
|
}
|
|
496
|
+
let conditionMet;
|
|
497
|
+
try {
|
|
498
|
+
conditionMet = this.expressionEvaluator.evaluate(source, this.scopeFor(context));
|
|
499
|
+
}
|
|
401
500
|
catch (error) {
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
501
|
+
console.warn(`[ObjectValidationEngine] conditional rule '${rule.name}' \`when\` predicate failed ` +
|
|
502
|
+
`to evaluate (${error}) — skipped.`);
|
|
503
|
+
return { valid: true };
|
|
504
|
+
}
|
|
505
|
+
const branch = conditionMet === true ? rule.then : rule.otherwise;
|
|
506
|
+
if (!branch || !isActive(branch)) {
|
|
507
|
+
return { valid: true };
|
|
408
508
|
}
|
|
509
|
+
return this.validateRule(branch, context, event);
|
|
409
510
|
}
|
|
410
511
|
/**
|
|
411
|
-
* Validate format/
|
|
512
|
+
* Validate a field's format against `regex` and/or a named `format`.
|
|
513
|
+
*
|
|
514
|
+
* Mirrors the server: the rule only fires when the write TOUCHES the field and
|
|
515
|
+
* the value is non-empty (requiredness and type-shape belong to the
|
|
516
|
+
* field-level validator, so an absent or blank value is not a format
|
|
517
|
+
* violation). Both checks apply when both are declared. A `regex` that will
|
|
518
|
+
* not compile is a BROKEN rule — logged and skipped, never a violation.
|
|
412
519
|
*/
|
|
413
520
|
validateFormat(rule, context) {
|
|
521
|
+
if (!(rule.field in context.record)) {
|
|
522
|
+
return { valid: true };
|
|
523
|
+
}
|
|
414
524
|
const value = context.record[rule.field];
|
|
415
525
|
if (value === null || value === undefined || value === '') {
|
|
416
526
|
return { valid: true };
|
|
417
527
|
}
|
|
418
|
-
|
|
528
|
+
const str = String(value);
|
|
529
|
+
const violation = {
|
|
530
|
+
valid: false,
|
|
531
|
+
message: rule.message || `Invalid format for ${rule.field}`,
|
|
532
|
+
rule: rule.name,
|
|
533
|
+
severity: rule.severity,
|
|
534
|
+
};
|
|
535
|
+
if (rule.regex) {
|
|
419
536
|
let pattern;
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
pattern = this.getPredefinedPattern(rule.format);
|
|
423
|
-
}
|
|
424
|
-
else if (typeof rule.pattern === 'string') {
|
|
425
|
-
pattern = new RegExp(rule.pattern, rule.flags);
|
|
537
|
+
try {
|
|
538
|
+
pattern = new RegExp(rule.regex);
|
|
426
539
|
}
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
if (!pattern.test(String(value))) {
|
|
431
|
-
return {
|
|
432
|
-
valid: false,
|
|
433
|
-
message: rule.message || `Invalid format for ${rule.field}`,
|
|
434
|
-
rule: rule.name,
|
|
435
|
-
severity: rule.severity,
|
|
436
|
-
};
|
|
540
|
+
catch {
|
|
541
|
+
console.warn(`[ObjectValidationEngine] format rule '${rule.name}' has an invalid regex — skipped.`);
|
|
542
|
+
return { valid: true };
|
|
437
543
|
}
|
|
438
|
-
|
|
544
|
+
if (!pattern.test(str))
|
|
545
|
+
return violation;
|
|
439
546
|
}
|
|
440
|
-
|
|
441
|
-
return
|
|
442
|
-
valid: false,
|
|
443
|
-
message: `Format validation error: ${error}`,
|
|
444
|
-
rule: rule.name,
|
|
445
|
-
severity: 'error',
|
|
446
|
-
};
|
|
547
|
+
if (rule.format && !matchesNamedFormat(rule.format, str)) {
|
|
548
|
+
return violation;
|
|
447
549
|
}
|
|
550
|
+
return { valid: true };
|
|
448
551
|
}
|
|
449
552
|
/**
|
|
450
553
|
* Validate range constraints
|
|
@@ -508,29 +611,59 @@ export class ObjectValidationEngine {
|
|
|
508
611
|
};
|
|
509
612
|
}
|
|
510
613
|
}
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
614
|
+
}
|
|
615
|
+
// Linear-time email check, copied from the server's rule-validator: domain
|
|
616
|
+
// labels exclude '.', so the quantifiers either side of each '.' cannot overlap
|
|
617
|
+
// — avoiding the polynomial backtracking (ReDoS) of the naive
|
|
618
|
+
// `[^\s@]+\.[^\s@]+` shape while still requiring a local part, an '@' and a
|
|
619
|
+
// dotted domain.
|
|
620
|
+
const EMAIL_RE = /^[^\s@]+@[^\s@.]+(?:\.[^\s@.]+)+$/;
|
|
621
|
+
// Lenient phone matcher: optional leading +, then 7–20 digits with spaces,
|
|
622
|
+
// dashes, dots and parens allowed as separators. Intentionally permissive —
|
|
623
|
+
// strict national formats belong in a `regex`.
|
|
624
|
+
const PHONE_RE = /^\+?[\d\s().-]{7,20}$/;
|
|
625
|
+
/**
|
|
626
|
+
* The spec's four named formats, matched the way the server matches them.
|
|
627
|
+
*
|
|
628
|
+
* An unrecognised name returns `true` (no violation): the client must not reject
|
|
629
|
+
* a value over a format only the server knows how to check.
|
|
630
|
+
*/
|
|
631
|
+
function matchesNamedFormat(format, str) {
|
|
632
|
+
switch (format) {
|
|
633
|
+
case 'email':
|
|
634
|
+
return EMAIL_RE.test(str);
|
|
635
|
+
case 'phone':
|
|
636
|
+
return PHONE_RE.test(str);
|
|
637
|
+
case 'url':
|
|
638
|
+
try {
|
|
639
|
+
new URL(str);
|
|
640
|
+
return true;
|
|
641
|
+
}
|
|
642
|
+
catch {
|
|
643
|
+
return false;
|
|
644
|
+
}
|
|
645
|
+
case 'json':
|
|
646
|
+
try {
|
|
647
|
+
JSON.parse(str);
|
|
648
|
+
return true;
|
|
649
|
+
}
|
|
650
|
+
catch {
|
|
651
|
+
return false;
|
|
652
|
+
}
|
|
653
|
+
default:
|
|
654
|
+
return true;
|
|
526
655
|
}
|
|
527
656
|
}
|
|
528
657
|
/**
|
|
529
658
|
* Default instance
|
|
659
|
+
*
|
|
660
|
+
* @deprecated (#3110) See `ObjectValidationEngine`.
|
|
530
661
|
*/
|
|
531
662
|
export const defaultObjectValidationEngine = new ObjectValidationEngine();
|
|
532
663
|
/**
|
|
533
664
|
* Convenience function to validate a record
|
|
665
|
+
*
|
|
666
|
+
* @deprecated (#3110) See `ObjectValidationEngine`.
|
|
534
667
|
*/
|
|
535
668
|
export async function validateRecord(rules, context, event = 'insert') {
|
|
536
669
|
return defaultObjectValidationEngine.validateRecord(rules, context, event);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@object-ui/core",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "17.1.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"license": "MIT",
|
|
@@ -25,11 +25,11 @@
|
|
|
25
25
|
}
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@objectstack/formula": "^
|
|
29
|
-
"@objectstack/spec": "^
|
|
28
|
+
"@objectstack/formula": "^17.0.0-rc.0",
|
|
29
|
+
"@objectstack/spec": "^17.0.0-rc.0",
|
|
30
30
|
"lodash": "^4.18.1",
|
|
31
31
|
"zod": "^4.4.3",
|
|
32
|
-
"@object-ui/types": "
|
|
32
|
+
"@object-ui/types": "17.1.0"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
35
|
"typescript": "^6.0.3",
|