Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@inixiative/json-rules",
"version": "2.21.0",
"version": "2.21.1",
"description": "TypeScript-first JSON rules engine with intuitive syntax and detailed error messages",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
Expand Down
32 changes: 21 additions & 11 deletions src/lens/applyLens.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ArrayOperator } from '../operator.ts';
import { own } from '../own';
import type { Condition } from '../types.ts';
import type { Condition, WindowFields } from '../types.ts';
import { hasWindow } from '../window.ts';
import type { Policy } from './policy.ts';
import { resolvePolicy, resolveVisit } from './policy.ts';
import type { Lens, LensNarrowing } from './types.ts';
Expand All @@ -16,7 +17,7 @@ import { resolveRelationTarget } from './walk.ts';
//
// Operator-specific injection inside an arrayRule:
// - any/none/atLeast/atMost/exactly/aggregate.condition: AND with original condition
// - all: filter-first via the array rule's window `filter` (drops out-of-scope rows before
// - all and windowed rules: filter-first via the array rule's window `filter` (drops out-of-scope rows before
// order/take/skip and before the all-check) — never a per-row negate implication

const wrapWithWheres = (rule: Condition, wheres: Condition[]): Condition => {
Expand Down Expand Up @@ -178,32 +179,41 @@ const rewriteRule = (
const effectAtDescent = resolveVisit(policy, curMap, curModel, curRelPath);
let inner = rewriteRule(rule.condition, policy, curMap, curModel, curRelPath);
const arrayOp = 'arrayOperator' in rule ? (rule.arrayOperator as ArrayOperator) : undefined;
const allGrants: Condition[] = [];
// `hasWindow` is the compilers' notion of a window (an empty `orderBy` is none), so an
// un-windowed grant keeps the AND injection that compiles on every rail.
const filterFirst = arrayOp === ArrayOperator.all || hasWindow(rule as WindowFields);
const filterGrants: Condition[] = [];
for (const whereClause of effectAtDescent.whereClauses) {
if (arrayOp === ArrayOperator.all) {
if (filterFirst) {
// Filter-first: an `all` grant drops out-of-scope rows via the window `filter`, which
// `check` applies before order/take/skip AND before the all-check. A per-row `negate`
// implication is unsound under a window and under partial (missing-field) semantics.
allGrants.push(whereClause);
filterGrants.push(whereClause);
} else if (arrayOp) {
inner = injectIntoArrayCondition(inner, whereClause);
} else {
// aggregate condition: AND injection
inner = { all: [whereClause, inner] };
}
}
const existingFilter = (rule as { filter?: Condition }).filter;
const rawFilter = (rule as { filter?: Condition }).filter;
const existingFilter =
rawFilter === undefined
? undefined
: rewriteRule(rawFilter, policy, curMap, curModel, curRelPath);
const rewritten = (
allGrants.length
filterGrants.length || existingFilter !== undefined
? {
...rule,
condition: inner,
filter:
existingFilter !== undefined
? { all: [existingFilter, ...allGrants] }
: allGrants.length === 1
? allGrants[0]
: { all: allGrants },
? filterGrants.length
? { all: [existingFilter, ...filterGrants] }
: existingFilter
: filterGrants.length === 1
? filterGrants[0]
: { all: filterGrants },
}
: { ...rule, condition: inner }
) as Condition;
Expand Down
27 changes: 25 additions & 2 deletions src/lens/checkRule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,13 +186,36 @@ const visit = (
}
};

/**
* Gate a condition whose `field` refs are relative to the visit (mapName, modelName, relPath)
* of `policy` — the shape of a narrowing `where` anchored at a relation node or a model
* default. The policy keeps its real anchor, so a bare `path` ref still resolves at the lens
* root (check()'s root context) and a `$.` ref at the visit. Internal to the lens layer.
*/
export const checkConditionAtVisit = (
cond: Condition,
policy: Policy,
mapName: string,
modelName: string,
relPath: readonly string[],
): RuleLensViolation[] => {
const violations: RuleLensViolation[] = [];
visit(cond, policy, mapName, modelName, relPath, violations);
return violations;
};

export const checkRuleAgainstLens = (
rule: Condition,
lensOrNarrowing: Lens | LensNarrowing,
): RuleLensCheck => {
const policy = resolvePolicy(lensOrNarrowing);
const violations: RuleLensViolation[] = [];
visit(rule, policy, policy.lens.mapName, policy.lens.model, [], violations);
const violations = checkConditionAtVisit(
rule,
policy,
policy.lens.mapName,
policy.lens.model,
[],
);
// Quickly validate that root visit doesn't have issues either (touches resolveVisit for the side effect, but mainly to ensure policy resolves)
resolveVisit(policy, policy.lens.mapName, policy.lens.model, []);
return { ok: violations.length === 0, violations };
Expand Down
5 changes: 1 addition & 4 deletions src/lens/exposedSurface.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,13 @@
import type { Bridge, FieldMapSet } from '../fieldMap/types.ts';
import type { FieldMap, FieldMapEntry, SourceOption } from '../toPrisma/types.ts';
import { isFieldVisible, type Policy, resolvePolicy, resolveVisit } from './policy.ts';
import { isFieldVisible, OFF_PATH, type Policy, resolvePolicy, resolveVisit } from './policy.ts';
import type { ProjectOptions } from './projectByPath.ts';
import { optionKey } from './sourceOptions.ts';
import type { Lens, LensNarrowing } from './types.ts';
import { resolveRelationTarget } from './walk.ts';

const modelKey = (mapName: string, modelName: string): string => `${mapName}::${modelName}`;

// A relPath matching no declared root.relations path → resolveVisit applies mapDefaults only.
const OFF_PATH: readonly string[] = ['__offpath__'];

type SurfaceModel = { mapName: string; modelName: string; fields: Map<string, FieldMapEntry> };

const unionFieldInto = (
Expand Down
115 changes: 58 additions & 57 deletions src/lens/narrowing.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,49 @@
import { own } from '../own';
import type { FieldMap, FieldMapEntry } from '../toPrisma/types.ts';
import type { Condition } from '../types.ts';
import { validateBindNames } from './bindings.ts';
import { checkRuleAgainstLens } from './checkRule.ts';
import { checkConditionAtVisit } from './checkRule.ts';
import {
augmentPicksWithRelations,
intersectStringSet,
normalizeGroupBy,
normalizeSource,
OFF_PATH,
type Policy,
resolvePolicy,
} from './policy.ts';
import { projectByPath } from './projectByPath.ts';
import type { LensNarrowing, ModelDefaultNarrowing, ModelNarrowing } from './types.ts';
import { collectChain, getRoot, resolveRelationTarget } from './walk.ts';

/** A visit of the PARENT surface a `where` is validated at: the where's own model, reached
* at `relPath` (a declared path, `[]` for the anchor, or `OFF_PATH` for the model-intrinsic
* visit a model default gets everywhere else). */
type WhereVisit = { mapName: string; modelName: string; relPath: readonly string[] };

// A where filters incoming rows, so its refs must resolve on the parent surface at the visit it
// is anchored to — never against this layer's own picks/omits, and never against a re-anchored
// lens: the policy keeps its real root so a bare `path` ref (check()'s root context) is gated at
// the lens anchor while `field` and `$.` refs are gated at the visit.
const validateWhere = (
condition: Condition | undefined,
parentPolicy: Policy,
visits: readonly WhereVisit[],
position: string,
errors: string[],
): void => {
if (condition === undefined) return;
const seen = new Set<string>();
for (const { mapName, modelName, relPath } of visits) {
for (const v of checkConditionAtVisit(condition, parentPolicy, mapName, modelName, relPath)) {
const message = `${position}: '${v.path}' ${v.reason}`;
if (seen.has(message)) continue;
seen.add(message);
errors.push(message);
}
}
};

// A parent layer's removals bind descendant materialization targets: group keys and
// label columns are client-visible option data, so a child source may not reference
// what an ancestor removed. The declaring layer itself stays free — visibility ≠
Expand Down Expand Up @@ -133,6 +166,8 @@ const validateModelNode = (
enumRegistry: Record<string, readonly string[]> | undefined,
position: string,
errors: string[],
parentPolicy: Policy,
whereVisits: readonly WhereVisit[],
isDefault = false,
): void => {
if (narrowing.picks && narrowing.omits) {
Expand Down Expand Up @@ -231,10 +266,7 @@ const validateModelNode = (
validateEnumOp('enumOmits', field, vals);
}

if (narrowing.where !== undefined && narrowing.where !== true && narrowing.where !== false) {
const result = checkWhereAgainstModel(narrowing.where, modelFields, modelName);
for (const err of result) errors.push(`${position}.where: ${err}`);
}
validateWhere(narrowing.where, parentPolicy, whereVisits, `${position}.where`, errors);

for (const [field, entry] of Object.entries(narrowing.sources ?? {})) {
if (!modelFields[field]) {
Expand Down Expand Up @@ -273,53 +305,10 @@ const validateModelNode = (
}
}
}
const where = spec.where;
if (where !== undefined && where !== true && where !== false) {
const result = checkWhereAgainstModel(where, modelFields, modelName);
for (const err of result) errors.push(`${position}.sources.${field}: ${err}`);
}
validateWhere(spec.where, parentPolicy, whereVisits, `${position}.sources.${field}`, errors);
}
};

const checkWhereAgainstModel = (
cond: unknown,
modelFields: Record<string, FieldMapEntry>,
modelName: string,
): string[] => {
const errors: string[] = [];
const visit = (c: unknown): void => {
if (c === null || typeof c !== 'object') return;
if (Array.isArray(c)) {
for (const x of c) visit(x);
return;
}
const obj = c as Record<string, unknown>;
if ('all' in obj && Array.isArray(obj.all)) {
for (const x of obj.all) visit(x);
return;
}
if ('any' in obj && Array.isArray(obj.any)) {
for (const x of obj.any) visit(x);
return;
}
if ('if' in obj) {
visit(obj.if);
visit(obj.then);
if (obj.else !== undefined) visit(obj.else);
return;
}
if ('field' in obj && typeof obj.field === 'string' && obj.field !== '') {
const top = obj.field.split('.')[0];
if (!modelFields[top]) {
errors.push(`'${obj.field}' not on model ${modelName}`);
}
}
if ('condition' in obj && obj.condition !== undefined) visit(obj.condition);
};
visit(cond);
return errors;
};

const validateDefaultsEnums = (
mapName: string,
defaultsEnums: Record<string, { picks?: readonly string[]; omits?: readonly string[] }>,
Expand Down Expand Up @@ -457,6 +446,8 @@ const validatePathNarrowing = (
modelName: string,
position: string,
errors: string[],
parentPolicy: Policy,
relPath: readonly string[],
): void => {
const fieldMap = maps[mapName];
const model = fieldMap?.models[modelName];
Expand Down Expand Up @@ -487,6 +478,8 @@ const validatePathNarrowing = (
fieldMap?.enums,
position,
errors,
parentPolicy,
[{ mapName, modelName, relPath }],
false,
);

Expand Down Expand Up @@ -542,6 +535,8 @@ const validatePathNarrowing = (
target.modelName,
`${position}.relations.${relField}`,
errors,
parentPolicy,
[...relPath, relField],
);
}
};
Expand All @@ -550,6 +545,8 @@ export const validateNarrowing = (narrowing: LensNarrowing): void => {
const errors: string[] = [];
const set = getRoot(narrowing);
const ancestors = collectChain(narrowing.parent);
const parentPolicy = resolvePolicy(narrowing.parent);
const parentVisits = projectByPath(narrowing.parent);

for (const [mapName, defaults] of Object.entries(narrowing.mapDefaults ?? {})) {
const fieldMap = set.maps[mapName];
Expand All @@ -571,6 +568,14 @@ export const validateNarrowing = (narrowing: LensNarrowing): void => {
const ancestorDefaultsForModel = ancestors
.map((anc) => anc.mapDefaults?.[mapName]?.models?.[modelName])
.filter((x): x is ModelDefaultNarrowing => x !== undefined);
// A model default applies at EVERY visit of the model: the model-intrinsic (off-path)
// visit plus each path the parent declares for it, so its where must resolve at all.
const whereVisits: WhereVisit[] = [{ mapName, modelName, relPath: OFF_PATH }];
for (const [path, visit] of parentVisits) {
if (visit.mapName === mapName && visit.modelName === modelName) {
whereVisits.push({ mapName, modelName, relPath: path.split('.').slice(1) });
}
}
validateModelNode(
dflt,
ancestorDefaultsForModel as ModelNarrowing[],
Expand All @@ -582,6 +587,8 @@ export const validateNarrowing = (narrowing: LensNarrowing): void => {
fieldMap.enums,
`mapDefaults.${mapName}.models.${modelName}`,
errors,
parentPolicy,
whereVisits,
true,
);
validateEnumFieldAgainstChain(
Expand Down Expand Up @@ -634,18 +641,12 @@ export const validateNarrowing = (narrowing: LensNarrowing): void => {
lensModel,
'root',
errors,
parentPolicy,
[],
);
}
}

if (narrowing.root?.where !== undefined) {
// where filters incoming rows → validate against the parent surface, not this layer's own picks
const check = checkRuleAgainstLens(narrowing.root.where, narrowing.parent);
for (const v of check.violations) {
errors.push(`root.where: '${v.path}' ${v.reason}`);
}
}

for (const e of validateBindNames(narrowing)) errors.push(e);

if (errors.length) {
Expand Down
4 changes: 4 additions & 0 deletions src/lens/policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ export type Policy = {
chain: LensNarrowing[];
};

/** A relPath matching no declared `root.relations` path — `resolveVisit` then applies
* mapDefaults only: the model-intrinsic visit a model gets wherever it is reached off-path. */
export const OFF_PATH: readonly string[] = ['__offpath__'];

export const resolvePolicy = (lensOrNarrowing: Lens | LensNarrowing): Policy => {
const lens = getRoot(lensOrNarrowing);
const chain =
Expand Down
4 changes: 3 additions & 1 deletion src/toPrisma/condition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ export const buildCondition = (
options?: BuildOptions,
state?: PrismaBuildState,
): PrismaWhere => {
// Prisma's empty OR matches nothing — `false` compiles, same as toSql's FALSE.
// Prisma's empty OR matches nothing — `false` compiles, same as toSql's FALSE. `{}` is
// match-all only at the top level and under AND; the logical builders fold both
// constants so neither ever lands under OR or NOT (see logical.ts).
if (typeof condition === 'boolean') {
return condition ? {} : { OR: [] };
}
Expand Down
Loading
Loading