@inixiative/json-rules 3.6.0 → 3.9.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 CHANGED
@@ -645,6 +645,7 @@ import { engineGlobals } from '@inixiative/json-rules';
645
645
  engineGlobals.set('string.caseInsensitive', true);
646
646
  engineGlobals.get('string.caseInsensitive'); // true
647
647
  engineGlobals.set('prismaOptions.datasource.provider', 'mysql');
648
+ engineGlobals.set('sql.dialect', SqlDialect.mysql); // what toWhereSql / toLensWhereSql write
648
649
  engineGlobals.reset(); // back to the defaults
649
650
 
650
651
  // A scoped override: merged over the current state for the duration of a synchronous callback.
@@ -694,6 +695,115 @@ as `toPrisma()` takes it. A set without `mapName` throws.
694
695
  toSql(rule, { map: lens, mapName: 'prisma', model: 'Post' });
695
696
  ```
696
697
 
698
+ ## Inline Predicates — `toWhereSql`, `toLensWhereSql`
699
+
700
+ Two small helpers write a condition, or a lens's row filters, as a SQL predicate for a caller
701
+ that names its own columns and writes its own joins (an Omni topic, for one). They write the
702
+ engine's `sql.dialect` — `SqlDialect.postgres` by default, `SqlDialect.mysql` the other — and
703
+ are not a second `toSql`: `toSql` and `toPrisma` are unchanged.
704
+
705
+ ```ts
706
+ import { ArrayOperator, engineGlobals, Operator, SqlDialect, toLensWhereSql, toWhereSql } from '@inixiative/json-rules';
707
+
708
+ engineGlobals.set('sql.dialect', SqlDialect.mysql);
709
+
710
+ toWhereSql(
711
+ { field: 'affiliation', operator: Operator.in, value: ['advocate', 'consultant'] },
712
+ { columns: { affiliation: '${fan_user.affiliation}' }, map, mapName: 'prisma', model: 'FanUser' },
713
+ );
714
+ // => "${fan_user.affiliation} IN ('advocate', 'consultant')"
715
+
716
+ toWhereSql(
717
+ {
718
+ field: 'affiliations',
719
+ arrayOperator: ArrayOperator.any,
720
+ condition: { field: 'affiliation', operator: Operator.equals, value: 'advocate' },
721
+ },
722
+ {
723
+ columns: {
724
+ uuid: '${fan_users.uuid}',
725
+ 'affiliations.fanUserUuid': 'a.fanUserUuid',
726
+ 'affiliations.affiliation': 'a.affiliation',
727
+ },
728
+ tables: { affiliations: 'FanUserAffiliation AS a' },
729
+ map,
730
+ mapName: 'prisma',
731
+ model: 'FanUser',
732
+ },
733
+ );
734
+ // => "EXISTS (SELECT 1 FROM FanUserAffiliation AS a WHERE a.fanUserUuid = ${fan_users.uuid} AND (a.affiliation = 'advocate'))"
735
+
736
+ toLensWhereSql(lens, { columns, tables });
737
+ // => { root: '…' | null, relations: {}, mapDefaults: { prisma: { models: { BrandReferenceRequestFanUser: "${brand_reference_request_fan_user.affiliation} = 'advocate'" } } } }
738
+ ```
739
+
740
+ - **Operators:** `equals`, `notEquals`, `in`, `notIn`, `exists`, `notExists`, inside `all` / `any`
741
+ (and `true` / `false`), and `any` / `all` / `none` / `empty` / `notEmpty` over a to-many relation
742
+ (below). Anything else — another operator, a date rule, a count or windowed array rule, an
743
+ aggregate, `if` / `then` / `else` — throws, naming it. So do binds, `path` (column-to-column),
744
+ offsets, `coerceType`, case-insensitivity, a path across a bridge, a field rule through a to-many
745
+ relation, anything Json (a stored JSON null is a value to SQL and null to `check()`), and a
746
+ DateTime or list *value*. A DateTime's or list's existence compiles: `deletedAt notExists`, a
747
+ lens's soft-delete `where`, is `… IS NULL`. A rule with no `value` throws, as on every rail.
748
+ - **To-many relations:** an array rule on a to-many relation of the model is one subquery on the
749
+ relation's table, keyed to the row: `any` / `notEmpty` is `EXISTS`, `none` / `empty` is
750
+ `NOT EXISTS`, and `all` is `NOT EXISTS` of a row whose condition `IS NOT TRUE` — a NULL result
751
+ fails, as in `check()`. The map gives the keys (`relationKeys`; a composite key ANDs every pair):
752
+ the row's side reads `columns['<key>']`, the related side `columns['<relation>.<key>']`.
753
+ `tables['<relation>']` is the table, written after `FROM` as given, with the alias its columns
754
+ read. The condition compiles as above and reads `columns['<relation>.<field>']`; a to-many
755
+ relation of the related rows nests (`tables['<relation>.<relation>']`). A relation behind
756
+ another one (`profile.affiliations`), a relation missing from `tables`, `atLeast` / `atMost` /
757
+ `exactly`, a window (`filter` / `orderBy` / `take` / `skip`) and a fieldless array rule throw.
758
+ - **Map:** `map` / `mapName` / `model` are required. A value is checked against its column's
759
+ kind (`leafFitViolations`, as `validateNarrowing` checks it) and an undeclared path is refused —
760
+ MySQL casts a mistyped literal instead of erroring, so `score = TRUE` would match 1 where
761
+ `check()` matches nothing. The map also finds a to-one relation's key for its `exists`
762
+ (`columns['<relation>.<key>']`: the related row's key, NULL when there is none), and says which
763
+ values have no form here.
764
+ - **Columns:** `columns` maps a field path to the caller's SQL, used as written; the engine writes
765
+ no joins and no identifiers. A path missing from it throws, naming the path — it never falls back
766
+ to a quoted name, which MySQL's default mode reads as a string literal.
767
+ - **Values** are written inline — there are no placeholders to fill. A string doubles `'`; on
768
+ MySQL it doubles `\` too (MySQL's default mode reads a backslash as an escape;
769
+ `NO_BACKSLASH_ESCAPES` is not supported). `in` is `IN (…)`; an enum compares as written, with no
770
+ cast.
771
+ - **NULL rows:** negations keep them, as on every rail: `notEquals` / `notIn` are ORed with
772
+ `IS NULL`; `in [x, null]` matches NULL, `notIn [x, null]` doesn't.
773
+ - **Collation:** `=` and `IN` compare under the column's collation. check() compares strings
774
+ exactly, so a `_ci` column (MySQL's default) also matches `'Advocate'` for `'advocate'`; compare a
775
+ binary-collated column where that matters.
776
+
777
+ `toLensWhereSql(lens, { columns, tables? })` mirrors the lens: the root path's `where`s under `root` (null
778
+ when it has none), each relation path's own under `relations`, keyed by the path, and each model
779
+ default's (`mapDefaults[map].models[model].where`) under `mapDefaults`, keyed the same way — one
780
+ predicate per model however many paths reach it. Each is the `where` as its layer wrote it: a
781
+ parent layer's clamp on a hop is reported at that hop's path, never folded into what a later
782
+ layer wrote through it. A `where` that reads through a relation the lens hides throws — that
783
+ hop's clamps have no path to report at, and `check()` still carries them. A lens that spans maps
784
+ reports every map's defaults; a caller writing one database reads its own map's. A path `where` reads `columns` by its path from the lens model (`status` at the root,
785
+ `brandReferenceRequestFanUsers.id` below it); a model default by `'<Model>.<field>'`
786
+ (`BrandReferenceRequestFanUser.affiliation`). It does not compile through `toSql`'s `{ lens }`
787
+ path, which would fold the lens's `where`s into the predicate a second time.
788
+
789
+ An array rule on a to-many relation has no join to put that relation's clamps on, so its subquery
790
+ carries them: the relation path's own `where` and the related model's default, ANDed beside the
791
+ key (outside the `IS NOT TRUE` of an `all`, as `check()` filters the rows before it tests them).
792
+ It carries only the clamps of the layers above the layer that wrote the `where`, which is what
793
+ `check()` reads it through: a layer's clamps narrow the layers after it, never its own conditions.
794
+ A clamp in the same layer or a later one stays out, so a later layer can't widen what a `none` or
795
+ an `empty` admitted. Each clamp reads its own relations through the layers above its own. The
796
+ clamps are still reported at their own path and model. `tables` reads like `columns`: by the
797
+ relation's path from the lens model in a path `where` (`affiliations`), by
798
+ `'<Model>.<relation>'` in a model default (`FanUser.affiliations`). An array rule on a relation
799
+ the lens hides throws, and its condition's fields are checked from the relation, not from the
800
+ model it sits on. A model default compiles to one predicate for every path that reaches its
801
+ model, so it throws when a to-many relation it reads carries different clamps on two of them.
802
+
803
+ **Absent, not hidden.** A row a relation's `where` filters out is absent from the SQL join, so a
804
+ negation through that relation holds for it; `check()` under the lens treats the row as hidden.
805
+ The lens is a projection: the difference is accepted and documented.
806
+
697
807
  ## Backend Support Matrix
698
808
 
699
809
  Not every backend supports every rule shape.
@@ -845,6 +955,7 @@ Lens & bridges:
845
955
  - `projectLens`, `walkLensPath`, `readLensValue`, `LensValue`, `describeRule`, `describeRuleSources`
846
956
  - `toLensSelect`, `projectRows`, `LensSelect`, `LensRelationSelect`, `LensSelectOptions`, `ProjectRowsOptions`
847
957
  - `toSourceQueries`, `materializeSources`, `materializeSourceQuery`
958
+ - `toLensWhereSql`, `LensWhereSql`, `LensWhereSqlOptions`
848
959
  - `PathProjection`, `ProjectedVisit`, `ProjectLensOptions`, `LensPathHop`, `LensPathResolution`, `RuleDescription`, `RuleSourceDescription`, `SourceQuery`, `SourcePrismaQuery`, `SourceSqlQuery`, `SourceSelect`, `SourceValues`, `SourceRowShape`, `MaterializeSourceQueryOptions`
849
960
 
850
961
  A lens has three forms, each with its own job: