@inixiative/json-rules 2.26.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +352 -70
- package/dist/index.cjs +4 -8
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +351 -415
- package/dist/index.d.ts +351 -415
- package/dist/index.js +4 -8
- package/dist/index.js.map +1 -1
- package/package.json +18 -7
package/README.md
CHANGED
|
@@ -204,7 +204,8 @@ Computes `sum` or `avg` of an array and compares the result to a value.
|
|
|
204
204
|
}
|
|
205
205
|
```
|
|
206
206
|
|
|
207
|
-
Empty-array semantics: `sum([]) = 0
|
|
207
|
+
Empty-array semantics: `sum([]) = 0` and `avg([]) = 0`. A NULL or absent array is empty, and
|
|
208
|
+
NULL items are skipped, as SQL's `SUM` / `AVG` skip them.
|
|
208
209
|
|
|
209
210
|
### Date Rule
|
|
210
211
|
|
|
@@ -279,7 +280,7 @@ toSql(rule, { now });
|
|
|
279
280
|
| Option | Default | Governs |
|
|
280
281
|
| --- | --- | --- |
|
|
281
282
|
| `now` | — (required when a relative/period expression is present) | the anchor instant |
|
|
282
|
-
| `timeZone` | `'UTC'` | how `now` and period boundaries localize |
|
|
283
|
+
| `timeZone` | `'UTC'` | how `now` and period boundaries localize — a zone name, or a value source read from context or bindings |
|
|
283
284
|
| `weekStart` | `'monday'` (ISO / isoWeek) | start of `week` for `this`/`last`/`next` |
|
|
284
285
|
|
|
285
286
|
Compilers resolve expressions to concrete `Date` bounds at compile time, so
|
|
@@ -288,8 +289,9 @@ Compilers resolve expressions to concrete `Date` bounds at compile time, so
|
|
|
288
289
|
### Windowing — first/last with `orderBy` / `take` / `skip`
|
|
289
290
|
|
|
290
291
|
Array and aggregate rules accept an ordered-window selector that runs **before** the
|
|
291
|
-
predicate. Pipeline: order → skip → take. Direction comes from `orderBy.dir`, so
|
|
292
|
-
"the last fanMission" is `order by date desc, take 1`.
|
|
292
|
+
predicate. Pipeline: filter → order → skip → take. Direction comes from `orderBy.dir`, so
|
|
293
|
+
"the last fanMission" is `order by date desc, take 1`. NULLs sort last in both directions, so
|
|
294
|
+
`orderBy views desc, take 1` is the largest non-null value.
|
|
293
295
|
|
|
294
296
|
```ts
|
|
295
297
|
// "user whose last fanMission was more than 30 days ago"
|
|
@@ -302,7 +304,8 @@ predicate. Pipeline: order → skip → take. Direction comes from `orderBy.dir`
|
|
|
302
304
|
}
|
|
303
305
|
```
|
|
304
306
|
|
|
305
|
-
`
|
|
307
|
+
`filter` is a condition each element must pass to enter the window (`narrowRule` puts a lens
|
|
308
|
+
grant there under `all`). `orderBy` is a non-empty array of `{ field, dir: 'asc' | 'desc' }` (multi-key);
|
|
306
309
|
`take`/`skip` are non-negative integers. **Empty-window semantics are author-driven**:
|
|
307
310
|
`all` is vacuously true on an empty window, `atLeast: 1` (or `any`) is false. To require
|
|
308
311
|
"the windowed element matches **and** one exists," combine `all` with `notEmpty` / `atLeast: 1`.
|
|
@@ -313,7 +316,7 @@ predicate. Pipeline: order → skip → take. Direction comes from `orderBy.dir`
|
|
|
313
316
|
> It rewrites to `every` / `some` (e.g. the rule above → `{ fanMissions: { every: { completedAt:
|
|
314
317
|
> { lt: <now-30d> } } } }`). Any other windowed rule — `take > 1`, `skip`, multi-key
|
|
315
318
|
> `orderBy`, a different/non-monotonic condition, or a misaligned direction — throws a clear
|
|
316
|
-
> "unsupported" error
|
|
319
|
+
> "unsupported" error, and so does any `filter`. `toSql()` does not compile windowing at all (no relation subqueries in
|
|
317
320
|
> a `WHERE` fragment). Evaluate the unsupported cases in memory with `check()`.
|
|
318
321
|
|
|
319
322
|
## Path Semantics
|
|
@@ -362,13 +365,74 @@ bare `path` is always the root context (`options.context`, defaulting to the roo
|
|
|
362
365
|
```
|
|
363
366
|
|
|
364
367
|
A ref deeper than the nesting (`$$.` at the top level, `$$$.` one array deep) throws in
|
|
365
|
-
`check()`, is a `scope_out_of_bounds` issue from `validateRule`, and
|
|
366
|
-
`
|
|
368
|
+
`check()`, is a `scope_out_of_bounds` issue from `validateRule`, and an error from
|
|
369
|
+
`validateRuleInLens`. A reachable ancestor that lacks the named key fails the comparison
|
|
367
370
|
like any absent field.
|
|
368
371
|
|
|
369
372
|
`toSql()` keeps `path: '$.x'` as a same-row column comparison. Every other scope ref — a
|
|
370
373
|
`$$.` path or any prefixed `field` — is check-only; both compilers throw.
|
|
371
374
|
|
|
375
|
+
### Offsets and Unit Amounts
|
|
376
|
+
|
|
377
|
+
An `offset` moves the comparison value. It is a value source of its own, with the comparison
|
|
378
|
+
value's contract: `{ value }`, `{ path }` (`$.` from the row, bare from context) or `{ bind }`
|
|
379
|
+
(with `bindOptional`). A field rule's offset reads a number, added to the comparison value; a
|
|
380
|
+
date rule's reads a rolling shift (`{ ago }` / `{ ahead }`) anchored on the comparison value
|
|
381
|
+
instead of `now`:
|
|
382
|
+
|
|
383
|
+
```ts
|
|
384
|
+
// net score at or under par: gross <= par + handicap
|
|
385
|
+
{ field: 'grossScore', operator: Operator.lessThanEquals, path: '$.par',
|
|
386
|
+
offset: { path: '$.handicap' } }
|
|
387
|
+
|
|
388
|
+
// within budget plus a tolerance supplied at evaluation
|
|
389
|
+
{ field: 'spend', operator: Operator.lessThanEquals, path: '$.budget',
|
|
390
|
+
offset: { bind: 'tolerance' } }
|
|
391
|
+
|
|
392
|
+
// completed within 30 days before the created date
|
|
393
|
+
{ field: 'completedAt', dateOperator: DateOperator.onOrAfter, path: '$.createdDate',
|
|
394
|
+
offset: { value: { ago: { days: 30 } } } }
|
|
395
|
+
|
|
396
|
+
// on or after the fifth of this month — an edge the expression grammar can't name alone
|
|
397
|
+
{ field: 'paidAt', dateOperator: DateOperator.onOrAfter, value: { start: { this: 'month' } },
|
|
398
|
+
offset: { value: { ahead: { days: 4 } } } }
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
`bindRule` resolves an offset's bind as it does the comparison value's, and
|
|
402
|
+
`listBindings` lists it. A date offset read per row (a column holding
|
|
403
|
+
`{ ago: … }`) is check-only; to size a shift from the row, read the amount instead.
|
|
404
|
+
|
|
405
|
+
Any relative-date unit — in a `value` expression or an offset's rolling shift — is a number or a
|
|
406
|
+
value source: `{ path }` from the row (`$.`) or context, `{ bind }`, or `{ value }`. A relative
|
|
407
|
+
window can take its size from the row it judges:
|
|
408
|
+
|
|
409
|
+
```ts
|
|
410
|
+
// quiet for longer than this incident's rule allows
|
|
411
|
+
{ field: 'lastBreachedAt', dateOperator: DateOperator.before,
|
|
412
|
+
value: { ago: { seconds: { path: '$.platformAlertRule.autoResolveAfterSeconds' } } } }
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
Offsets apply to the comparison operators (`equals` … `greaterThanEquals`, `before` …
|
|
416
|
+
`notAfter`) and to both ends of `between` / `notBetween`. Units apply as Postgres applies an
|
|
417
|
+
interval to a wall-clock time in the evaluation's `timeZone` (UTC by default): months (years,
|
|
418
|
+
quarters, months), then days (weeks, days), then time — so every rail lands on the same instant
|
|
419
|
+
at a month end and across a DST change. Calendar units (years … days) are whole numbers and every
|
|
420
|
+
unit is non-negative: a literal that isn't fails validation, and a value read from data that
|
|
421
|
+
isn't reads as null. A null comparison value, offset or magnitude, or a range missing an end,
|
|
422
|
+
matches nothing (SQL's NULL arithmetic); a negation keeps null fields only. Numeric offsets add
|
|
423
|
+
in double precision on every rail.
|
|
424
|
+
|
|
425
|
+
| | `check()` | `toSql()` | `toPrisma()` |
|
|
426
|
+
| --- | --- | --- | --- |
|
|
427
|
+
| literal, bound or context offset / amount | yes | resolved to a parameter | resolved to a value |
|
|
428
|
+
| `$.` numeric offset or unit amount | yes | `col + n` / `col ± make_interval(…)` | throws |
|
|
429
|
+
| `$.` date offset (a stored `{ ago }`) | yes | throws | throws |
|
|
430
|
+
| `$$.` anything | yes | throws | throws |
|
|
431
|
+
|
|
432
|
+
`validateRuleInLens` gates offset and magnitude refs like `path` (they must resolve through
|
|
433
|
+
the lens and read a number), and an offset must fit the field's kind: a number on a numeric
|
|
434
|
+
field, a rolling shift on a DateTime.
|
|
435
|
+
|
|
372
436
|
## Rule Introspection
|
|
373
437
|
|
|
374
438
|
Reading a stored rule's own content — which values it names, which bindings it needs — is
|
|
@@ -377,9 +441,9 @@ format grows a node type, and it goes blind silently.
|
|
|
377
441
|
|
|
378
442
|
| Function | Purpose |
|
|
379
443
|
| --- | --- |
|
|
380
|
-
| `
|
|
381
|
-
| `
|
|
382
|
-
| `
|
|
444
|
+
| `listBindings(rule, { required: true })` | Names a bindings map must cover — every `{ bind }` token not marked `bindOptional`, sorted. A name optional at one leaf and required at another is required. |
|
|
445
|
+
| `listBindings(rule)` | Every `{ bind }` name in the tree, optional or not, sorted — what a lens declares. |
|
|
446
|
+
| `bindRule(rule, bindings)` | Substitutes covered binds with their values, leaving uncovered tokens in place (partial resolution). |
|
|
383
447
|
|
|
384
448
|
A leaf may mark its bind optional: `{ field, operator, bind: 'region', bindOptional: true }`. An
|
|
385
449
|
unsupplied required bind is a caller bug — `check()` throws, and both compilers refuse a
|
|
@@ -421,6 +485,26 @@ check(rule, {
|
|
|
421
485
|
}); // true
|
|
422
486
|
```
|
|
423
487
|
|
|
488
|
+
### Value Comparison
|
|
489
|
+
|
|
490
|
+
Values compare as JSON does. Lists and objects compare by value, deeply. Types never cross:
|
|
491
|
+
`"3"` never equals `3`, and an ordered comparison or a range holds only between two numbers, two
|
|
492
|
+
strings or two dates. A `Date` field value compares as a DateTime without help. To compare a
|
|
493
|
+
string literal against a number or Boolean field, stamp the rule's `coerceType` (`coerceRule`
|
|
494
|
+
does it from a lens). The compilers refuse a string literal on a number or Boolean column
|
|
495
|
+
without one.
|
|
496
|
+
|
|
497
|
+
An enum compares against its declared values. A case-insensitive comparison, a string operator,
|
|
498
|
+
a pattern or an ordered comparison on an enum compiles to a membership test over the declared
|
|
499
|
+
values `check()` would match (plus the NULL arm for a negation). The field map must list the
|
|
500
|
+
values (prisma-map does).
|
|
501
|
+
|
|
502
|
+
```ts
|
|
503
|
+
// map: { models: { U: { fields: { role: { kind: 'enum', type: 'Role' } } } }, enums: { Role: ['admin', 'member'] } }
|
|
504
|
+
toSql({ field: 'role', operator: Operator.startsWith, value: 'adm' }, { map, model: 'U' });
|
|
505
|
+
// { sql: '"t0"."role"::text = ANY($1)', params: [['admin']], joins: [] }
|
|
506
|
+
```
|
|
507
|
+
|
|
424
508
|
### Custom Errors
|
|
425
509
|
|
|
426
510
|
Every rule can define its own error:
|
|
@@ -450,13 +534,13 @@ const plan = toPrisma({
|
|
|
450
534
|
// plan.steps => [{ operation: 'where', where: { status: { equals: 'active' } } }]
|
|
451
535
|
```
|
|
452
536
|
|
|
453
|
-
Aggregate relation filters (`sum`, `avg`) and count-based filters (`atLeast`, `atMost`, `exactly`) can produce multi-step plans. Use `
|
|
537
|
+
Aggregate relation filters (`sum`, `avg`) and count-based filters (`atLeast`, `atMost`, `exactly`) can produce multi-step plans. Use `executePrismaPlan()` to resolve `groupBy` step references before passing the final `where` into Prisma.
|
|
454
538
|
|
|
455
539
|
```ts
|
|
456
540
|
import {
|
|
457
541
|
ArrayOperator,
|
|
458
542
|
Operator,
|
|
459
|
-
|
|
543
|
+
executePrismaPlan,
|
|
460
544
|
toPrisma,
|
|
461
545
|
} from '@inixiative/json-rules';
|
|
462
546
|
|
|
@@ -474,7 +558,7 @@ const plan = toPrisma(
|
|
|
474
558
|
{ map, model: 'User' },
|
|
475
559
|
);
|
|
476
560
|
|
|
477
|
-
const where = await
|
|
561
|
+
const where = await executePrismaPlan(plan, { post: prisma.post });
|
|
478
562
|
await prisma.user.findMany({ where });
|
|
479
563
|
```
|
|
480
564
|
|
|
@@ -491,10 +575,53 @@ const plan = toPrisma(
|
|
|
491
575
|
{ map, model: 'User' },
|
|
492
576
|
);
|
|
493
577
|
|
|
494
|
-
const where = await
|
|
578
|
+
const where = await executePrismaPlan(plan, { order: prisma.order });
|
|
495
579
|
await prisma.user.findMany({ where }); // users whose orders sum to more than 1000
|
|
496
580
|
```
|
|
497
581
|
|
|
582
|
+
A user with no orders sums to 0, as in `check()`: a comparison that holds at 0 selects the
|
|
583
|
+
parents outside the groups where it fails, so childless parents stay in.
|
|
584
|
+
|
|
585
|
+
### Json null checks
|
|
586
|
+
|
|
587
|
+
A Json column holds a DB NULL or a JSON `null`, and a path inside it can be absent — `check()`
|
|
588
|
+
reads all three as null, and Prisma matches them together only with its `AnyNull` instance,
|
|
589
|
+
which it knows by identity. `toPrisma()` takes it from your installed `@prisma/client` (an
|
|
590
|
+
optional peer dependency), so there is nothing to configure; set `prismaOptions.anyNull` only to
|
|
591
|
+
use a different client's. Without `@prisma/client`, a Json null check throws.
|
|
592
|
+
|
|
593
|
+
Prisma filters follow the column kind the map
|
|
594
|
+
declares: on Json, `contains` / `startsWith` / `endsWith` become `string_contains` / … and `in`
|
|
595
|
+
becomes one `equals` per value; on a scalar list, `contains` becomes `has` and emptiness
|
|
596
|
+
`isEmpty`; `caseInsensitive` adds `mode: 'insensitive'` on text only.
|
|
597
|
+
|
|
598
|
+
## Engine Globals
|
|
599
|
+
|
|
600
|
+
`engineGlobals` holds process-wide defaults. Keys are dotted paths into one state object.
|
|
601
|
+
|
|
602
|
+
| Key | Default | Governs |
|
|
603
|
+
| --- | --- | --- |
|
|
604
|
+
| `string.caseInsensitive` | `false` | Default for a rule's `caseInsensitive`. A rule's own flag wins. Read by `check()`, `toSql()` and `toPrisma()`. |
|
|
605
|
+
| `string.fuzzy` | `false` | Default for a rule's `fuzzy` (`true` or a `FuzzyConfig` `{ maxDistance?, maxRatio? }`): typo-tolerant `contains` / `notContains` on strings. A rule's own flag wins. The compilers have no fuzzy form: they refuse `contains` / `notContains` whenever fuzzy is on, by the rule or by this default. |
|
|
606
|
+
| `prismaOptions.datasource.provider` | `'postgresql'` | The Prisma connector. `toPrisma()` emits `mode: 'insensitive'` only for `postgresql`, `cockroachdb` and `mongodb`; the others are case-insensitive by collation and reject it. `toPrisma(rule, { datasource: { provider } })` overrides it per call. |
|
|
607
|
+
| `prismaOptions.anyNull` | your `@prisma/client`'s | Prisma's `AnyNull` for Json null checks (see above); set it only to use another client's. |
|
|
608
|
+
|
|
609
|
+
```ts
|
|
610
|
+
import { engineGlobals } from '@inixiative/json-rules';
|
|
611
|
+
|
|
612
|
+
engineGlobals.set('string.caseInsensitive', true);
|
|
613
|
+
engineGlobals.get('string.caseInsensitive'); // true
|
|
614
|
+
engineGlobals.set('prismaOptions.datasource.provider', 'mysql');
|
|
615
|
+
engineGlobals.reset(); // back to the defaults
|
|
616
|
+
|
|
617
|
+
// A scoped override: merged over the current state for the duration of a synchronous callback.
|
|
618
|
+
const result = engineGlobals.with({ string: { caseInsensitive: true } }, () => check(rule, data));
|
|
619
|
+
```
|
|
620
|
+
|
|
621
|
+
`with()` restores the previous state when the callback returns or throws. The callback must be
|
|
622
|
+
synchronous: one that returns a Promise throws. `set()` copies plain data and keeps a class
|
|
623
|
+
instance (like `AnyNull`) as given, since Prisma recognizes it by identity.
|
|
624
|
+
|
|
498
625
|
## PostgreSQL SQL Generation
|
|
499
626
|
|
|
500
627
|
`toSql()` converts a rule into a parameterized PostgreSQL `WHERE` clause.
|
|
@@ -527,6 +654,13 @@ const result = toSql(
|
|
|
527
654
|
// result.joins => ['LEFT JOIN "User" AS "t1" ON "t1"."id" = "t0"."authorId"']
|
|
528
655
|
```
|
|
529
656
|
|
|
657
|
+
`map` is a `FieldMap`, or a `FieldMapSet` (a lens works) with `mapName` naming the map to read,
|
|
658
|
+
as `toPrisma()` takes it. A set without `mapName` throws.
|
|
659
|
+
|
|
660
|
+
```ts
|
|
661
|
+
toSql(rule, { map: lens, mapName: 'prisma', model: 'Post' });
|
|
662
|
+
```
|
|
663
|
+
|
|
530
664
|
## Backend Support Matrix
|
|
531
665
|
|
|
532
666
|
Not every backend supports every rule shape.
|
|
@@ -538,14 +672,16 @@ Not every backend supports every rule shape.
|
|
|
538
672
|
| Logical operators | Yes | Yes | Yes |
|
|
539
673
|
| Array `all` / `any` / `none` | Yes | Yes | No |
|
|
540
674
|
| Array `atLeast` / `atMost` / `exactly` | Yes | Yes, with `map` + `model` | No |
|
|
541
|
-
| Array `empty` / `notEmpty` | Yes | Yes | Yes |
|
|
675
|
+
| Array `empty` / `notEmpty` | Yes | Yes | Yes (list and Json columns, not relations) |
|
|
542
676
|
| Aggregate `sum` / `avg` — primitive or object array | Yes | No | Yes |
|
|
543
677
|
| Aggregate `sum` / `avg` — relation list | Yes | Yes, with `map` + `model` | No |
|
|
544
678
|
| Date comparisons | Yes | Most | Yes |
|
|
545
679
|
| Date expressions (`ago`/`ahead`/`this`/`last`/`next`/`start`/`end`) + `within` | Yes | Yes | Yes |
|
|
546
680
|
| `dayIn` / `dayNotIn` | Yes | No | Yes |
|
|
547
|
-
| Windowing (`orderBy` / `take` / `skip`) | Yes | Extremal (`take:1`, aligned) | No |
|
|
681
|
+
| Windowing (`filter` / `orderBy` / `take` / `skip`) | Yes | Extremal (`take:1`, aligned, no `filter`) | No |
|
|
548
682
|
| `path: '$.field'` current-element / same-row refs | Yes | No | Yes |
|
|
683
|
+
| `offset` and unit amounts — value, bind or context | Yes | Yes | Yes |
|
|
684
|
+
| `offset` and unit amounts — `$.` row refs | Yes | No | Yes (not a date offset's) |
|
|
549
685
|
| `$$.` scope refs and `$`-prefixed `field` | Yes | No | No |
|
|
550
686
|
|
|
551
687
|
### NULL Semantics
|
|
@@ -569,7 +705,8 @@ and `{ rel: { col: { equals: null } } }` only matches when the relation exists.
|
|
|
569
705
|
also carries `{ rel: { is: null } }` for each optional to-one hop on the path (licensed by the
|
|
570
706
|
relation entry's `isRequired: false`) — `profile.bio notEquals 'x'` compiles to
|
|
571
707
|
`{ OR: [{ profile: { bio: { not: 'x' } } }, { profile: { bio: { equals: null } } }, { profile: { is: null } }] }`,
|
|
572
|
-
matching check() (a missing hop reads as
|
|
708
|
+
matching check() (a missing hop reads as NULL) and toSql (LEFT JOIN + `IS NULL`). `equals null`
|
|
709
|
+
and `in [null, …]` carry the same hop arms: a user with no profile has a NULL `profile.bio`.
|
|
573
710
|
|
|
574
711
|
`toPrisma()` can only add the null arm when it knows the column is nullable —
|
|
575
712
|
an `equals: null` on a NOT NULL column is a Prisma validation error. Nullability
|
|
@@ -604,8 +741,10 @@ positive operator, ask for them:
|
|
|
604
741
|
- `dayIn` and `dayNotIn` are not supported by Prisma output
|
|
605
742
|
- `path: '$.field'` column-to-column comparisons are not supported by Prisma `WHERE`; no scope ref (`$$.` path, prefixed `field`) compiles
|
|
606
743
|
- count-based and aggregate relation operators require `{ map, model }`
|
|
607
|
-
- aggregate rules with `notBetween` are not supported by Prisma output
|
|
608
744
|
- aggregate rules on JSON/native stored arrays are not supported by Prisma — use `toSql()` or `check()` for those
|
|
745
|
+
- element conditions (`all` / `any` / `none` / counts) over a scalar list or a Json array are not supported by Prisma; test a list's membership with `contains`
|
|
746
|
+
- a field path through a to-many relation (`posts.title`) is an error on both compilers — compare its rows with an array rule on `posts`
|
|
747
|
+
- Prisma loads a NULL scalar-list column as `[]`, so `check()` over Prisma-loaded rows reads it as an empty list while the compilers read NULL; a list Prisma writes is never NULL, so this only matters for rows written outside Prisma
|
|
609
748
|
|
|
610
749
|
### SQL Limitations
|
|
611
750
|
|
|
@@ -634,52 +773,108 @@ type Condition<TRuleValue = RuleValue, TDateValue = DateRuleValue> =
|
|
|
634
773
|
| boolean;
|
|
635
774
|
```
|
|
636
775
|
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
- `toSql`
|
|
643
|
-
- `validateRule`
|
|
644
|
-
- `
|
|
645
|
-
- `
|
|
646
|
-
- `
|
|
647
|
-
- `
|
|
648
|
-
- `
|
|
649
|
-
- `
|
|
650
|
-
- `Rule`
|
|
651
|
-
- `AggregateRule`
|
|
652
|
-
- `AggregateMode`
|
|
653
|
-
- `ArrayRule`
|
|
654
|
-
- `DateRule`
|
|
776
|
+
The public API has one name per operation. [docs/VERBS.md](./docs/VERBS.md) lists every
|
|
777
|
+
exported function by verb.
|
|
778
|
+
|
|
779
|
+
Rules:
|
|
780
|
+
|
|
781
|
+
- `check`, `toPrisma`, `executePrismaPlan`, `toSql`
|
|
782
|
+
- `validateRule`, `assertValidRule`, `bindRule`, `listBindings`
|
|
783
|
+
- `Operator`, `ArrayOperator`, `DateOperator`
|
|
784
|
+
- `Condition`, `StrictCondition`, `Rule`, `AggregateRule`, `AggregateMode`, `ArrayRule`, `DateRule`, `Row`, `CheckData`
|
|
785
|
+
- `GroupByStep`, `WhereStep`, `PrismaStep`, `PrismaWhere`, `StepRef` (a Prisma plan's steps); `ScopeRef`, `ScopedRef`, `ScopeOutOfBounds` (scope refs)
|
|
786
|
+
- `CheckOptions`, `CompileOptions`, `ToPrismaOptions`, `ToSqlOptions`, `ToSqlResult`, `ToPrismaResult`, `ValidateRuleOptions`, `ListBindingsOptions`, `ValidationIssue`, `ValidationResult`
|
|
787
|
+
- every rule-shape type in `src/types.ts` (`StrictRule`, `DateExpr`, `RelativeUnits`, …), listed in `index.ts`
|
|
788
|
+
- `engineGlobals`, `EngineGlobalsState`, `PrismaProvider`, `FuzzyConfig`
|
|
655
789
|
|
|
656
790
|
Lens & bridges:
|
|
657
791
|
|
|
658
|
-
- `Lens`, `LensNarrowing`, `ModelNarrowing`, `ModelDefaultNarrowing`, `NarrowingDefaults`, `EnumNarrowing`
|
|
659
|
-
- `FieldMapSet`, `Bridge`, `BridgeEndpoint`, `BridgeCardinality`
|
|
660
|
-
- `createLens`, `stitchFieldMaps`, `
|
|
661
|
-
- `validateNarrowing`, `
|
|
662
|
-
- `
|
|
663
|
-
- `
|
|
792
|
+
- `Lens`, `LensNarrowing`, `ModelNarrowing`, `ModelDefaultNarrowing`, `NarrowingDefaults`, `EnumNarrowing`, `SourceSpec`, `SourceEntry`
|
|
793
|
+
- `FieldMap`, `FieldMapEntry`, `ModelEntry`, `SourceOption`, `FieldMapSet`, `Bridge`, `BridgeEndpoint`, `BridgeCardinality`, `BridgeDictionary`
|
|
794
|
+
- `createLens`, `storeLens`, `composeLens`, `StoredLens`, `stitchFieldMaps`, `indexBridges`, `validateFieldMaps`, `assertValidFieldMaps`
|
|
795
|
+
- `validateNarrowing`, `assertValidNarrowing`, `validateRuleInLens`, `narrowRule`, `coerceRule`
|
|
796
|
+
- `bindLens`, `listLensBindings`
|
|
797
|
+
- `projectLens`, `walkLensPath`, `describeRule`, `describeRuleSources`
|
|
798
|
+
- `toSourceQueries`, `materializeSources`, `materializeSourceQuery`
|
|
799
|
+
- `PathProjection`, `ProjectedVisit`, `ProjectLensOptions`, `LensPathHop`, `LensPathResolution`, `RuleDescription`, `RuleSourceDescription`, `SourceQuery`, `SourcePrismaQuery`, `SourceSqlQuery`, `SourceSelect`, `SourceValues`, `SourceRowShape`, `MaterializeSourceQueryOptions`
|
|
800
|
+
|
|
801
|
+
A lens has three forms, each with its own job:
|
|
802
|
+
|
|
803
|
+
- **Composed** — a `Lens`, or a `LensNarrowing` whose `parent` holds the layer above it as an
|
|
804
|
+
object, down to the base lens. Every evaluator takes this form.
|
|
805
|
+
- **Stored** — `StoredLens`, one record per layer: its `id`, `parents` (the ids of every layer it
|
|
806
|
+
composes with, the base lens first) and its own part. The base lens is the root-most record,
|
|
807
|
+
stored as itself with no parents. `storeLens(lens, ids)` writes a composed lens out as records;
|
|
808
|
+
`composeLens(id, records)` reads them back — fetch the layer, then the ids it lists — validating
|
|
809
|
+
each layer against the ones above it and failing closed on a missing record, a base out of
|
|
810
|
+
place, or a parent whose own list disagrees.
|
|
811
|
+
- **Projected** — what a lens exposes, which never leads back to the lens:
|
|
812
|
+
- `projectLens(lens)` returns `Record<dottedPath, ProjectedVisit>` for per-path checks where
|
|
813
|
+
sibling paths to the same model diverge.
|
|
814
|
+
- `projectLens(lens, { by: 'model' })` returns the leak-safe total surface *as a Lens* — every
|
|
815
|
+
reachable model with the full narrowing applied, unioned per model, `where` stripped. Use it as
|
|
816
|
+
the server→client builder surface; it never exposes the raw lens.
|
|
817
|
+
|
|
818
|
+
```ts
|
|
819
|
+
const records = storeLens(grantLens, ['user', 'org-acme', 'grant-7']); // persist each record
|
|
820
|
+
// later: fetch 'grant-7', then the ids in its `parents`
|
|
821
|
+
const lens = composeLens('grant-7', { user, 'org-acme': orgAcme, 'grant-7': grant7 });
|
|
822
|
+
```
|
|
823
|
+
|
|
824
|
+
### Operator Catalog
|
|
664
825
|
|
|
665
|
-
|
|
826
|
+
What a rule builder can offer for a field. The catalog's constants are `FieldKind`,
|
|
827
|
+
`RuleTarget`, `ValueShape`, `NUMERIC_KINDS` and `ALL_KINDS`; `getValueShape(operator, family)` takes an
|
|
828
|
+
`OperatorFamily` (`'field' | 'date' | 'array'`).
|
|
666
829
|
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
paths to the same model diverge.
|
|
830
|
+
```ts
|
|
831
|
+
import {
|
|
832
|
+
getAggregateOperators,
|
|
833
|
+
getArrayOperators,
|
|
834
|
+
getOperatorsForKind,
|
|
835
|
+
getValueShape,
|
|
836
|
+
} from '@inixiative/json-rules';
|
|
675
837
|
|
|
676
|
-
|
|
838
|
+
getOperatorsForKind('Int', 'toPrisma');
|
|
839
|
+
// { field: ['equals', 'notEquals', 'lessThan', …], date: [] } — operators that kind takes on that target
|
|
677
840
|
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
841
|
+
getArrayOperators('toSql'); // ['empty', 'notEmpty']
|
|
842
|
+
getAggregateOperators(); // ['equals', 'notEquals', 'lessThan', …, 'between', 'notBetween']
|
|
843
|
+
|
|
844
|
+
getValueShape('between', 'field'); // 'range'
|
|
845
|
+
getValueShape('between', 'date'); // 'dateRange'
|
|
846
|
+
```
|
|
847
|
+
|
|
848
|
+
| Function | Purpose |
|
|
849
|
+
| --- | --- |
|
|
850
|
+
| `getOperatorsForKind(kind, target?)` | `{ field, date }`: the field and date operators a `FieldKind` takes, narrowed to one target when given. |
|
|
851
|
+
| `getArrayOperators(target?)` | The array operators, narrowed to one target when given. |
|
|
852
|
+
| `getAggregateOperators()` | The comparisons an aggregate rule takes. Every target compiles all of them. |
|
|
853
|
+
| `getValueShape(operator, family)` | The operand an operator takes (`'scalar'`, `'range'`, `'dayList'`, …). `family` is `'field'`, `'date'` or `'array'`, since `between` is both a field and a date operator. Throws on an operator the family doesn't have. |
|
|
854
|
+
|
|
855
|
+
To ask whether a whole rule runs on a target (windows, scope refs and operators together), use
|
|
856
|
+
`validateRule(rule, { target })`.
|
|
857
|
+
|
|
858
|
+
### Scope Refs
|
|
859
|
+
|
|
860
|
+
`parseScopeRef` and `readScopeRef` read the `$`-prefixed refs described in
|
|
861
|
+
[Scope References](#scope-references), for code that resolves refs of its own.
|
|
862
|
+
|
|
863
|
+
```ts
|
|
864
|
+
import { parseScopeRef, readScopeRef } from '@inixiative/json-rules';
|
|
865
|
+
|
|
866
|
+
parseScopeRef('$$.maxQty'); // { depth: 2, path: 'maxQty' }
|
|
867
|
+
parseScopeRef('maxQty'); // null — a bare ref
|
|
868
|
+
|
|
869
|
+
// scopes run outermost first; `$.` is the last, `$$.` the one before it
|
|
870
|
+
readScopeRef('$$.maxQty', [rootRow, order, lineItem]); // { scope: order, path: 'maxQty' }
|
|
871
|
+
readScopeRef('maxQty', [rootRow, order]); // { scope: order, path: 'maxQty' }
|
|
872
|
+
readScopeRef('$$$.x', [rootRow, order]);
|
|
873
|
+
// { outOfBounds: "Scope ref '$$$.x' needs depth 3 but only 2 scopes are in reach" }
|
|
874
|
+
```
|
|
875
|
+
|
|
876
|
+
`readScopeRef` returns the scope a ref names and the path left to read in it. It never throws:
|
|
877
|
+
a ref deeper than the stack comes back as `{ outOfBounds }` with the message.
|
|
683
878
|
|
|
684
879
|
## Error Handling
|
|
685
880
|
|
|
@@ -732,7 +927,7 @@ check(
|
|
|
732
927
|
|
|
733
928
|
## Lens & Multi-Source Data
|
|
734
929
|
|
|
735
|
-
> **For the full
|
|
930
|
+
> **For the full lens guide — including the three anchor layers for `where`
|
|
736
931
|
> (root, model-default, relation-descent), the `all` operator filter-first
|
|
737
932
|
> trick, per-model enum narrowing, and a validate-then-apply usage pattern —
|
|
738
933
|
> see [docs/LENS.md](./docs/LENS.md).**
|
|
@@ -747,10 +942,10 @@ trust boundaries (platform → org → space → subtenant → client). Each lay
|
|
|
747
942
|
boundary is **enforced, not documented**:
|
|
748
943
|
|
|
749
944
|
- a rule authored against a lens provably can't reference outside it —
|
|
750
|
-
`
|
|
945
|
+
`validateRuleInLens`, at author time;
|
|
751
946
|
- the row-scope **`where` is the grant, applied server-side at execution** via
|
|
752
|
-
`
|
|
753
|
-
- what reaches an untrusted party reveals nothing hidden — `
|
|
947
|
+
`narrowRule` — the authored rule never sees it and can't escape it;
|
|
948
|
+
- what reaches an untrusted party reveals nothing hidden — `projectLens(…, { by: 'model' })`.
|
|
754
949
|
|
|
755
950
|
A lens defines a **surface area**, reused for distinct, separately-enforced
|
|
756
951
|
constraints that may **diverge**: the *data-flow* surface (what you receive / pass
|
|
@@ -836,17 +1031,104 @@ const narrowing: LensNarrowing = {
|
|
|
836
1031
|
};
|
|
837
1032
|
```
|
|
838
1033
|
|
|
839
|
-
Composition across chained narrowings is pure intersection. `where` clauses are anchored to the model they describe — `root.where` ANDs at the lens anchor, `mapDefaults[X].models[Y].where` injects at every visit of Y in map X, and `root.relations[R]...where` injects when the rule descends through R.
|
|
1034
|
+
Composition across chained narrowings is pure intersection. `where` clauses are anchored to the model they describe — `root.where` ANDs at the lens anchor, `mapDefaults[X].models[Y].where` injects at every visit of Y in map X, and `root.relations[R]...where` injects when the rule descends through R. Under the `all` array operator the grant goes into the rule's window `filter`, so out-of-scope rows are dropped before the user's "every row matches" check; `check()` and `toPrisma` run it. See [docs/LENS.md](./docs/LENS.md) for the full anchor semantics.
|
|
840
1035
|
|
|
841
1036
|
### Lens Utilities
|
|
842
1037
|
|
|
843
1038
|
| Function | Purpose |
|
|
844
1039
|
| --- | --- |
|
|
845
|
-
| `validateNarrowing(narrowing)` |
|
|
846
|
-
| `
|
|
847
|
-
| `
|
|
848
|
-
| `
|
|
849
|
-
| `
|
|
1040
|
+
| `validateNarrowing(narrowing)` | Returns `{ ok, errors: { path, message, code }[] }` for structural or chain problems, one code each: `not_in_lens`, `not_visible` (an item an ancestor hid), `conflicting_selection`, `wrong_kind`, `value_not_allowed`, `invalid_source`, `invalid_binding`, plus the lens gate's codes for a `where`. `assertValidNarrowing` throws instead. Call at narrowing construction. |
|
|
1041
|
+
| `assertValidFieldMaps(set)` | Throws when a field name in any map holds `.` or `:` (a path step and a bridge marker). `validateFieldMaps(set)` returns `{ ok, errors }` with code `invalid_field_name`. To check a single map, wrap it: `assertValidFieldMaps({ maps: { prisma: map } })`. |
|
|
1042
|
+
| `projectLens(lens)` | Returns `Record<dottedPath, ProjectedVisit>` — each declared path keys its own resolved narrowing (path picks/omits/enums chain-intersected ∩ `mapDefaults` for the target model). Sibling paths to the same model stay independent. Use for SDK-contract / OpenAPI emission, search-field enumeration, validation whitelists. See [docs/LENS.md §10](./docs/LENS.md). |
|
|
1043
|
+
| `describeRuleSources(rule, lens)` | The values a rule names at each source the lens declares, keyed like `projectLens` (`path` + `field`, with the source's `mapName` / `model`). Resolved through the lens like `walkLensPath`, so `mapDefaults` sources answer wherever their model appears. `dynamic: true` when the set can't be enumerated: a `path` / `bind` leaf, an offset or a read amount that moves the value, a substring / pattern / range / window operator, or an operator the catalog doesn't know — callers fail closed on it. The reverse question for a reference registry ("which rows does this rule name") — join `model` + `values`. |
|
|
1044
|
+
| `validateRuleInLens(rule, lens)` | Validates a user rule's field paths and enum values against the narrowed lens, path-aware. Returns `{ ok, errors: { path, message, code }[] }` like `validateRule` (codes such as `not_in_lens`, `operator_kind_mismatch`, `invalid_value`, `value_not_allowed`). The security gate. |
|
|
1045
|
+
| `describeRule(rule, lens)` | `{ sources, bridgesCrossed, supportedTargets, errors }`: the maps a rule reads, whether it crosses a bridge, which of `check` / `toPrisma` / `toSql` can run it (by the rule's shape, as `validateRule` reads it — a compiler may still refuse a field's kind, such as a date rule on a String column on Prisma), and the lens gate's `ValidationIssue`s. For routing and UX; `validateRuleInLens` stays the gate. |
|
|
1046
|
+
| `walkLensPath(lens, path)` | Resolves one dotted path through the lens hop by hop: `{ outcome: 'resolved', hops, terminal, jsonSubPath }`, or `hidden` / `missing` / `pastScalar` with the failing `index`. |
|
|
1047
|
+
| `narrowRule(rule, narrowing)` | Composes the user rule with the lens's `where` clauses, injecting each at its anchor in the rule tree. Under an `all`, the grant goes into the rule's window `filter`, which `check()` evaluates and `toPrisma` folds into the rule (`toSql` compiles no relation arrays). Other rules pass to `check` / `toPrisma` / `toSql`. |
|
|
1048
|
+
| `coerceRule(rule, lens)` | Stamps each field rule with its field's `coerceType` from the lens (`Int`, `Float`, `Decimal`, `BigInt`, `DateTime`, `Boolean`, `String`). Leaves date rules, aggregate comparisons, rules that already carry a `coerceType`, and anything below a Json column alone. |
|
|
1049
|
+
|
|
1050
|
+
```ts
|
|
1051
|
+
import { coerceRule } from '@inixiative/json-rules';
|
|
1052
|
+
|
|
1053
|
+
// User.age is { kind: 'scalar', type: 'Int' } in the lens's map
|
|
1054
|
+
coerceRule({ field: 'age', operator: Operator.equals, value: '3' }, lens);
|
|
1055
|
+
// { field: 'age', operator: 'equals', value: '3', coerceType: 'Int' }
|
|
1056
|
+
```
|
|
1057
|
+
|
|
1058
|
+
### Bindings in a Lens
|
|
1059
|
+
|
|
1060
|
+
A narrowing's `where` and `sources` conditions can hold `{ bind }` tokens, filled per request.
|
|
1061
|
+
|
|
1062
|
+
```ts
|
|
1063
|
+
import { bindLens, listLensBindings } from '@inixiative/json-rules';
|
|
1064
|
+
|
|
1065
|
+
const narrowing: LensNarrowing = {
|
|
1066
|
+
parent: lens,
|
|
1067
|
+
root: { where: { field: 'tenantId', operator: Operator.equals, bind: 'tenantId' } },
|
|
1068
|
+
};
|
|
1069
|
+
|
|
1070
|
+
listLensBindings(narrowing); // ['tenantId']
|
|
1071
|
+
const bound = bindLens(narrowing, { tenantId: 't-42' });
|
|
1072
|
+
// bound.root.where => { field: 'tenantId', operator: 'equals', value: 't-42' }
|
|
1073
|
+
```
|
|
1074
|
+
|
|
1075
|
+
| Function | Purpose |
|
|
1076
|
+
| --- | --- |
|
|
1077
|
+
| `listLensBindings(lensOrNarrowing)` | The bind names the whole narrowing chain requires, sorted. `bindOptional` tokens are left out, and a `parent:name` reference counts as `name`. |
|
|
1078
|
+
| `bindLens(lensOrNarrowing, bindings)` | Returns a new narrowing chain with every covered token replaced by its value; uncovered tokens stay, so binding can happen in stages. `parent:name` draws the value of `name`. A bare lens comes back unchanged. The input is not mutated. |
|
|
1079
|
+
|
|
1080
|
+
A layer may not re-declare a bind name an ancestor declares; it reads the inherited one as
|
|
1081
|
+
`parent:name`. `validateNarrowing` reports a collision or a dangling `parent:` reference as
|
|
1082
|
+
`invalid_binding`.
|
|
1083
|
+
|
|
1084
|
+
### Option Sources
|
|
1085
|
+
|
|
1086
|
+
A narrowing node's `sources` declares where a field's selectable values come from: a bare
|
|
1087
|
+
eligibility `Condition`, or a `SourceSpec` `{ where?, label?, groupBy? }`. Two functions turn
|
|
1088
|
+
the declarations into option sets, and `projectLens(lens, { sourceValues })` attaches the result
|
|
1089
|
+
to each field as `options`.
|
|
1090
|
+
|
|
1091
|
+
```ts
|
|
1092
|
+
import {
|
|
1093
|
+
materializeSourceQuery,
|
|
1094
|
+
materializeSources,
|
|
1095
|
+
projectLens,
|
|
1096
|
+
toSourceQueries,
|
|
1097
|
+
} from '@inixiative/json-rules';
|
|
1098
|
+
|
|
1099
|
+
const narrowing: LensNarrowing = {
|
|
1100
|
+
parent: lens,
|
|
1101
|
+
root: {
|
|
1102
|
+
where: { field: 'tenantId', operator: Operator.equals, value: 't-42' },
|
|
1103
|
+
sources: { region: { field: 'active', operator: Operator.equals, value: true } },
|
|
1104
|
+
},
|
|
1105
|
+
};
|
|
1106
|
+
|
|
1107
|
+
// Against a database: one DISTINCT query per sourced field, in Prisma and SQL form.
|
|
1108
|
+
const [query] = toSourceQueries(narrowing);
|
|
1109
|
+
// query.path => 'User', query.field => 'region'
|
|
1110
|
+
// query.composedWhere => the node's `where` AND the source's eligibility
|
|
1111
|
+
// query.prisma => { model: 'User', distinct: ['region'], select: { region: true }, where: { AND: [...] } }
|
|
1112
|
+
// query.sql => { sql: 'SELECT DISTINCT "t0"."region" FROM "User" AS "t0" WHERE (...)', params: ['t-42', true] }
|
|
1113
|
+
const { distinct, select, where } = query.prisma;
|
|
1114
|
+
const rows = await prisma.user.findMany({ distinct, select, where });
|
|
1115
|
+
const values = materializeSourceQuery(query, rows); // { path, mapName, model, field, options: [{ value }] }
|
|
1116
|
+
|
|
1117
|
+
// Or from rows already fetched under the lens (relations inline):
|
|
1118
|
+
const all = materializeSources(narrowing, users, { now });
|
|
1119
|
+
|
|
1120
|
+
const projection = projectLens(narrowing, { sourceValues: [values] });
|
|
1121
|
+
// projection.User.fields.region.options => [{ value: 'eu' }, { value: 'us' }]
|
|
1122
|
+
```
|
|
1123
|
+
|
|
1124
|
+
| Function | Purpose |
|
|
1125
|
+
| --- | --- |
|
|
1126
|
+
| `toSourceQueries(lensOrNarrowing)` | `SourceQuery[]`, one per sourced field: `{ path, mapName, model, field, label?, groupBy?, composedWhere, prisma, sql }`. `prisma.steps` is present when the where needs `executePrismaPlan`. `sql.sql` is `null` with an `error` when SQL can't express the where. A grouped source drops `distinct`. |
|
|
1127
|
+
| `materializeSourceQuery(query, rows, { rowShape? })` | One query's fetched rows as `SourceValues`. `rowShape` is `'prisma'` (default: a dotted `label` and each `groupBy` axis come nested) or `'sql'` (they come flat as `__label` / `__group_i`). Options are deduplicated and sorted. |
|
|
1128
|
+
| `materializeSources(lensOrNarrowing, rows, options?)` | `SourceValues[]` for every sourced field, from rows fetched under the lens. Rows are taken as already lens-scoped, so only each source's own eligibility is applied, through `check()` with `options`. A scalar-list field gives one option per element. |
|
|
1129
|
+
|
|
1130
|
+
Options never offer a value the lens disallows: `projectLens` drops fetched values outside a
|
|
1131
|
+
field's allowed set.
|
|
850
1132
|
|
|
851
1133
|
### Evaluating Across Bridges
|
|
852
1134
|
|
|
@@ -854,8 +1136,8 @@ Composition across chained narrowings is pure intersection. `where` clauses are
|
|
|
854
1136
|
|
|
855
1137
|
**Limitations to know:**
|
|
856
1138
|
|
|
857
|
-
- **1-many bridge arrays are not iterable mid-path.** `field: 'crm:MarketingEvent.campaign'` or `path: 'crm:MarketingEvent.campaign'` returns `undefined` when the bridge value is an array —
|
|
858
|
-
- **Bridge keys are plain object properties.** The engine doesn't consult `lens.bridges` at eval time — callers structure `data` correctly using the schema as a guide. Use `
|
|
1139
|
+
- **1-many bridge arrays are not iterable mid-path.** `field: 'crm:MarketingEvent.campaign'` or `path: 'crm:MarketingEvent.campaign'` returns `undefined` when the bridge value is an array — a path read follows own properties one segment at a time and doesn't fan out across array elements. Use a numeric index (`crm:MarketingEvent.0.campaign` or `crm:MarketingEvent[0].campaign`) or `arrayOperator` on the `field:` side to iterate. A name on `Object.prototype` (`constructor`, `toString`) reads as absent.
|
|
1140
|
+
- **Bridge keys are plain object properties.** The engine doesn't consult `lens.bridges` at eval time — callers structure `data` correctly using the schema as a guide. Use `indexBridges(lens, rawForeign)` to pre-index foreign rows by `on` field, then embed under bridge keys per anchor row.
|
|
859
1141
|
|
|
860
1142
|
|
|
861
1143
|
`check()` itself is bridge-unaware — it walks paths via plain property access. The lens primitive is **schema metadata** (what fields exist, what bridges link them, what `on` fields join each side). The caller is responsible for structuring `data` accordingly:
|