@inixiative/json-rules 2.27.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 +291 -72
- package/dist/index.cjs +4 -8
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +321 -454
- package/dist/index.d.ts +321 -454
- 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
|
|
|
@@ -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,8 +365,8 @@ 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
|
|
@@ -395,8 +398,8 @@ instead of `now`:
|
|
|
395
398
|
offset: { value: { ahead: { days: 4 } } } }
|
|
396
399
|
```
|
|
397
400
|
|
|
398
|
-
`
|
|
399
|
-
`
|
|
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
|
|
400
403
|
`{ ago: … }`) is check-only; to size a shift from the row, read the amount instead.
|
|
401
404
|
|
|
402
405
|
Any relative-date unit — in a `value` expression or an offset's rolling shift — is a number or a
|
|
@@ -426,7 +429,7 @@ in double precision on every rail.
|
|
|
426
429
|
| `$.` date offset (a stored `{ ago }`) | yes | throws | throws |
|
|
427
430
|
| `$$.` anything | yes | throws | throws |
|
|
428
431
|
|
|
429
|
-
`
|
|
432
|
+
`validateRuleInLens` gates offset and magnitude refs like `path` (they must resolve through
|
|
430
433
|
the lens and read a number), and an offset must fit the field's kind: a number on a numeric
|
|
431
434
|
field, a rolling shift on a DateTime.
|
|
432
435
|
|
|
@@ -438,9 +441,9 @@ format grows a node type, and it goes blind silently.
|
|
|
438
441
|
|
|
439
442
|
| Function | Purpose |
|
|
440
443
|
| --- | --- |
|
|
441
|
-
| `
|
|
442
|
-
| `
|
|
443
|
-
| `
|
|
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). |
|
|
444
447
|
|
|
445
448
|
A leaf may mark its bind optional: `{ field, operator, bind: 'region', bindOptional: true }`. An
|
|
446
449
|
unsupplied required bind is a caller bug — `check()` throws, and both compilers refuse a
|
|
@@ -482,6 +485,26 @@ check(rule, {
|
|
|
482
485
|
}); // true
|
|
483
486
|
```
|
|
484
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
|
+
|
|
485
508
|
### Custom Errors
|
|
486
509
|
|
|
487
510
|
Every rule can define its own error:
|
|
@@ -511,13 +534,13 @@ const plan = toPrisma({
|
|
|
511
534
|
// plan.steps => [{ operation: 'where', where: { status: { equals: 'active' } } }]
|
|
512
535
|
```
|
|
513
536
|
|
|
514
|
-
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.
|
|
515
538
|
|
|
516
539
|
```ts
|
|
517
540
|
import {
|
|
518
541
|
ArrayOperator,
|
|
519
542
|
Operator,
|
|
520
|
-
|
|
543
|
+
executePrismaPlan,
|
|
521
544
|
toPrisma,
|
|
522
545
|
} from '@inixiative/json-rules';
|
|
523
546
|
|
|
@@ -535,7 +558,7 @@ const plan = toPrisma(
|
|
|
535
558
|
{ map, model: 'User' },
|
|
536
559
|
);
|
|
537
560
|
|
|
538
|
-
const where = await
|
|
561
|
+
const where = await executePrismaPlan(plan, { post: prisma.post });
|
|
539
562
|
await prisma.user.findMany({ where });
|
|
540
563
|
```
|
|
541
564
|
|
|
@@ -552,10 +575,53 @@ const plan = toPrisma(
|
|
|
552
575
|
{ map, model: 'User' },
|
|
553
576
|
);
|
|
554
577
|
|
|
555
|
-
const where = await
|
|
578
|
+
const where = await executePrismaPlan(plan, { order: prisma.order });
|
|
556
579
|
await prisma.user.findMany({ where }); // users whose orders sum to more than 1000
|
|
557
580
|
```
|
|
558
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
|
+
|
|
559
625
|
## PostgreSQL SQL Generation
|
|
560
626
|
|
|
561
627
|
`toSql()` converts a rule into a parameterized PostgreSQL `WHERE` clause.
|
|
@@ -588,6 +654,13 @@ const result = toSql(
|
|
|
588
654
|
// result.joins => ['LEFT JOIN "User" AS "t1" ON "t1"."id" = "t0"."authorId"']
|
|
589
655
|
```
|
|
590
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
|
+
|
|
591
664
|
## Backend Support Matrix
|
|
592
665
|
|
|
593
666
|
Not every backend supports every rule shape.
|
|
@@ -599,13 +672,13 @@ Not every backend supports every rule shape.
|
|
|
599
672
|
| Logical operators | Yes | Yes | Yes |
|
|
600
673
|
| Array `all` / `any` / `none` | Yes | Yes | No |
|
|
601
674
|
| Array `atLeast` / `atMost` / `exactly` | Yes | Yes, with `map` + `model` | No |
|
|
602
|
-
| Array `empty` / `notEmpty` | Yes | Yes | Yes |
|
|
675
|
+
| Array `empty` / `notEmpty` | Yes | Yes | Yes (list and Json columns, not relations) |
|
|
603
676
|
| Aggregate `sum` / `avg` — primitive or object array | Yes | No | Yes |
|
|
604
677
|
| Aggregate `sum` / `avg` — relation list | Yes | Yes, with `map` + `model` | No |
|
|
605
678
|
| Date comparisons | Yes | Most | Yes |
|
|
606
679
|
| Date expressions (`ago`/`ahead`/`this`/`last`/`next`/`start`/`end`) + `within` | Yes | Yes | Yes |
|
|
607
680
|
| `dayIn` / `dayNotIn` | Yes | No | Yes |
|
|
608
|
-
| Windowing (`orderBy` / `take` / `skip`) | Yes | Extremal (`take:1`, aligned) | No |
|
|
681
|
+
| Windowing (`filter` / `orderBy` / `take` / `skip`) | Yes | Extremal (`take:1`, aligned, no `filter`) | No |
|
|
609
682
|
| `path: '$.field'` current-element / same-row refs | Yes | No | Yes |
|
|
610
683
|
| `offset` and unit amounts — value, bind or context | Yes | Yes | Yes |
|
|
611
684
|
| `offset` and unit amounts — `$.` row refs | Yes | No | Yes (not a date offset's) |
|
|
@@ -632,7 +705,8 @@ and `{ rel: { col: { equals: null } } }` only matches when the relation exists.
|
|
|
632
705
|
also carries `{ rel: { is: null } }` for each optional to-one hop on the path (licensed by the
|
|
633
706
|
relation entry's `isRequired: false`) — `profile.bio notEquals 'x'` compiles to
|
|
634
707
|
`{ OR: [{ profile: { bio: { not: 'x' } } }, { profile: { bio: { equals: null } } }, { profile: { is: null } }] }`,
|
|
635
|
-
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`.
|
|
636
710
|
|
|
637
711
|
`toPrisma()` can only add the null arm when it knows the column is nullable —
|
|
638
712
|
an `equals: null` on a NOT NULL column is a Prisma validation error. Nullability
|
|
@@ -667,8 +741,10 @@ positive operator, ask for them:
|
|
|
667
741
|
- `dayIn` and `dayNotIn` are not supported by Prisma output
|
|
668
742
|
- `path: '$.field'` column-to-column comparisons are not supported by Prisma `WHERE`; no scope ref (`$$.` path, prefixed `field`) compiles
|
|
669
743
|
- count-based and aggregate relation operators require `{ map, model }`
|
|
670
|
-
- aggregate rules with `notBetween` are not supported by Prisma output
|
|
671
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
|
|
672
748
|
|
|
673
749
|
### SQL Limitations
|
|
674
750
|
|
|
@@ -697,52 +773,108 @@ type Condition<TRuleValue = RuleValue, TDateValue = DateRuleValue> =
|
|
|
697
773
|
| boolean;
|
|
698
774
|
```
|
|
699
775
|
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
- `toSql`
|
|
706
|
-
- `validateRule`
|
|
707
|
-
- `
|
|
708
|
-
- `
|
|
709
|
-
- `
|
|
710
|
-
- `
|
|
711
|
-
- `
|
|
712
|
-
- `
|
|
713
|
-
- `Rule`
|
|
714
|
-
- `AggregateRule`
|
|
715
|
-
- `AggregateMode`
|
|
716
|
-
- `ArrayRule`
|
|
717
|
-
- `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`
|
|
718
789
|
|
|
719
790
|
Lens & bridges:
|
|
720
791
|
|
|
721
|
-
- `Lens`, `LensNarrowing`, `ModelNarrowing`, `ModelDefaultNarrowing`, `NarrowingDefaults`, `EnumNarrowing`
|
|
722
|
-
- `FieldMapSet`, `Bridge`, `BridgeEndpoint`, `BridgeCardinality`
|
|
723
|
-
- `createLens`, `stitchFieldMaps`, `
|
|
724
|
-
- `validateNarrowing`, `
|
|
725
|
-
- `
|
|
726
|
-
- `
|
|
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.
|
|
727
817
|
|
|
728
|
-
|
|
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
|
|
729
825
|
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
per model, `where` stripped. Use it as the server→client builder surface; it
|
|
734
|
-
never exposes the raw, un-narrowed lens.
|
|
735
|
-
- **Projection** (path-keyed view — graph flattened away): `projectByPath(lens)`
|
|
736
|
-
returns `Map<dottedPath, ProjectedVisit>` for per-path checks where sibling
|
|
737
|
-
paths to the same model diverge.
|
|
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'`).
|
|
738
829
|
|
|
739
|
-
|
|
830
|
+
```ts
|
|
831
|
+
import {
|
|
832
|
+
getAggregateOperators,
|
|
833
|
+
getArrayOperators,
|
|
834
|
+
getOperatorsForKind,
|
|
835
|
+
getValueShape,
|
|
836
|
+
} from '@inixiative/json-rules';
|
|
740
837
|
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
838
|
+
getOperatorsForKind('Int', 'toPrisma');
|
|
839
|
+
// { field: ['equals', 'notEquals', 'lessThan', …], date: [] } — operators that kind takes on that target
|
|
840
|
+
|
|
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.
|
|
746
878
|
|
|
747
879
|
## Error Handling
|
|
748
880
|
|
|
@@ -795,7 +927,7 @@ check(
|
|
|
795
927
|
|
|
796
928
|
## Lens & Multi-Source Data
|
|
797
929
|
|
|
798
|
-
> **For the full
|
|
930
|
+
> **For the full lens guide — including the three anchor layers for `where`
|
|
799
931
|
> (root, model-default, relation-descent), the `all` operator filter-first
|
|
800
932
|
> trick, per-model enum narrowing, and a validate-then-apply usage pattern —
|
|
801
933
|
> see [docs/LENS.md](./docs/LENS.md).**
|
|
@@ -810,10 +942,10 @@ trust boundaries (platform → org → space → subtenant → client). Each lay
|
|
|
810
942
|
boundary is **enforced, not documented**:
|
|
811
943
|
|
|
812
944
|
- a rule authored against a lens provably can't reference outside it —
|
|
813
|
-
`
|
|
945
|
+
`validateRuleInLens`, at author time;
|
|
814
946
|
- the row-scope **`where` is the grant, applied server-side at execution** via
|
|
815
|
-
`
|
|
816
|
-
- 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' })`.
|
|
817
949
|
|
|
818
950
|
A lens defines a **surface area**, reused for distinct, separately-enforced
|
|
819
951
|
constraints that may **diverge**: the *data-flow* surface (what you receive / pass
|
|
@@ -899,17 +1031,104 @@ const narrowing: LensNarrowing = {
|
|
|
899
1031
|
};
|
|
900
1032
|
```
|
|
901
1033
|
|
|
902
|
-
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.
|
|
903
1035
|
|
|
904
1036
|
### Lens Utilities
|
|
905
1037
|
|
|
906
1038
|
| Function | Purpose |
|
|
907
1039
|
| --- | --- |
|
|
908
|
-
| `validateNarrowing(narrowing)` |
|
|
909
|
-
| `
|
|
910
|
-
| `
|
|
911
|
-
| `
|
|
912
|
-
| `
|
|
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.
|
|
913
1132
|
|
|
914
1133
|
### Evaluating Across Bridges
|
|
915
1134
|
|
|
@@ -917,8 +1136,8 @@ Composition across chained narrowings is pure intersection. `where` clauses are
|
|
|
917
1136
|
|
|
918
1137
|
**Limitations to know:**
|
|
919
1138
|
|
|
920
|
-
- **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 —
|
|
921
|
-
- **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.
|
|
922
1141
|
|
|
923
1142
|
|
|
924
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:
|