From 71fb30a093c28f9c8afce3e7fc333c124e65b960 Mon Sep 17 00:00:00 2001 From: Aron Greenspan Date: Sun, 6 Sep 2026 19:50:15 -0300 Subject: [PATCH 1/4] fix: preserve inherited lens limits in narrowing and windows --- src/lens/applyLens.ts | 32 ++- src/lens/narrowing.ts | 107 ++++---- test/lens.applyLens.windowFilterFirst.test.ts | 182 ++++++++++++++ test/lens.narrowing.whereGate.test.ts | 236 ++++++++++++++++++ 4 files changed, 490 insertions(+), 67 deletions(-) create mode 100644 test/lens.applyLens.windowFilterFirst.test.ts create mode 100644 test/lens.narrowing.whereGate.test.ts diff --git a/src/lens/applyLens.ts b/src/lens/applyLens.ts index 7349808..b1f5f96 100644 --- a/src/lens/applyLens.ts +++ b/src/lens/applyLens.ts @@ -16,7 +16,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 => { @@ -178,13 +178,19 @@ 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[] = []; + const filterFirst = + arrayOp === ArrayOperator.all || + ('filter' in rule && rule.filter !== undefined) || + ('orderBy' in rule && rule.orderBy !== undefined) || + ('take' in rule && rule.take !== undefined) || + ('skip' in rule && rule.skip !== undefined); + 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 { @@ -192,18 +198,24 @@ const rewriteRule = ( 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; diff --git a/src/lens/narrowing.ts b/src/lens/narrowing.ts index 0656fa0..68b5366 100644 --- a/src/lens/narrowing.ts +++ b/src/lens/narrowing.ts @@ -1,5 +1,6 @@ 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 { @@ -8,9 +9,40 @@ import { normalizeGroupBy, normalizeSource, } from './policy.ts'; -import type { LensNarrowing, ModelDefaultNarrowing, ModelNarrowing } from './types.ts'; +import { projectByPath } from './projectByPath.ts'; +import type { Lens, LensNarrowing, ModelDefaultNarrowing, ModelNarrowing } from './types.ts'; import { collectChain, getRoot, resolveRelationTarget } from './walk.ts'; +const parentAtVisit = ( + root: Lens, + ancestors: readonly LensNarrowing[], + mapName: string, + modelName: string, + relPath?: readonly string[], +): Lens | LensNarrowing => { + let parent: Lens | LensNarrowing = { ...root, mapName, model: modelName }; + for (const ancestor of ancestors) { + let node = relPath === undefined ? undefined : ancestor.root; + for (const segment of relPath ?? []) node = node?.relations?.[segment]; + parent = { parent, mapDefaults: ancestor.mapDefaults, root: node }; + } + return parent; +}; + +const validateWhere = ( + condition: Condition | undefined, + parents: readonly (Lens | LensNarrowing)[], + position: string, + errors: string[], +): void => { + if (condition === undefined) return; + for (const parent of parents) { + for (const violation of checkRuleAgainstLens(condition, parent).violations) { + errors.push(`${position}: '${violation.path}' ${violation.reason}`); + } + } +}; + // 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 ≠ @@ -133,6 +165,7 @@ const validateModelNode = ( enumRegistry: Record | undefined, position: string, errors: string[], + parentSurfaces: readonly (Lens | LensNarrowing)[], isDefault = false, ): void => { if (narrowing.picks && narrowing.omits) { @@ -231,10 +264,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, parentSurfaces, `${position}.where`, errors); for (const [field, entry] of Object.entries(narrowing.sources ?? {})) { if (!modelFields[field]) { @@ -273,53 +303,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, parentSurfaces, `${position}.sources.${field}`, errors); } }; -const checkWhereAgainstModel = ( - cond: unknown, - modelFields: Record, - 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; - 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, @@ -457,6 +444,7 @@ const validatePathNarrowing = ( modelName: string, position: string, errors: string[], + relPath: readonly string[], ): void => { const fieldMap = maps[mapName]; const model = fieldMap?.models[modelName]; @@ -487,6 +475,7 @@ const validatePathNarrowing = ( fieldMap?.enums, position, errors, + [parentAtVisit(getRoot(current), chain, mapName, modelName, relPath)], false, ); @@ -542,6 +531,7 @@ const validatePathNarrowing = ( target.modelName, `${position}.relations.${relField}`, errors, + [...relPath, relField], ); } }; @@ -550,6 +540,7 @@ export const validateNarrowing = (narrowing: LensNarrowing): void => { const errors: string[] = []; const set = getRoot(narrowing); const ancestors = collectChain(narrowing.parent); + const parentVisits = projectByPath(narrowing.parent); for (const [mapName, defaults] of Object.entries(narrowing.mapDefaults ?? {})) { const fieldMap = set.maps[mapName]; @@ -571,6 +562,14 @@ export const validateNarrowing = (narrowing: LensNarrowing): void => { const ancestorDefaultsForModel = ancestors .map((anc) => anc.mapDefaults?.[mapName]?.models?.[modelName]) .filter((x): x is ModelDefaultNarrowing => x !== undefined); + const parentSurfaces = [parentAtVisit(set, ancestors, mapName, modelName)]; + for (const [path, visit] of parentVisits) { + if (visit.mapName === mapName && visit.modelName === modelName) { + parentSurfaces.push( + parentAtVisit(set, ancestors, mapName, modelName, path.split('.').slice(1)), + ); + } + } validateModelNode( dflt, ancestorDefaultsForModel as ModelNarrowing[], @@ -582,6 +581,7 @@ export const validateNarrowing = (narrowing: LensNarrowing): void => { fieldMap.enums, `mapDefaults.${mapName}.models.${modelName}`, errors, + parentSurfaces, true, ); validateEnumFieldAgainstChain( @@ -634,18 +634,11 @@ export const validateNarrowing = (narrowing: LensNarrowing): void => { lensModel, 'root', errors, + [], ); } } - 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) { diff --git a/test/lens.applyLens.windowFilterFirst.test.ts b/test/lens.applyLens.windowFilterFirst.test.ts new file mode 100644 index 0000000..44340f3 --- /dev/null +++ b/test/lens.applyLens.windowFilterFirst.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, test } from 'bun:test'; +import { check } from '../src/check'; +import { applyLens } from '../src/lens/applyLens'; +import type { Lens, LensNarrowing } from '../src/lens/types'; +import { ArrayOperator, Operator } from '../src/operator'; +import { toPrisma } from '../src/toPrisma'; +import type { FieldMap } from '../src/toPrisma/types'; +import { toSql } from '../src/toSql'; +import type { Condition } from '../src/types'; +import { getWhere } from './fixtures/helpers'; + +const map: FieldMap = { + models: { + Customer: { + fields: { + id: { kind: 'scalar', type: 'String' }, + orders: { kind: 'object', type: 'Order', isList: true }, + }, + }, + Order: { + fields: { + id: { kind: 'scalar', type: 'String' }, + customerId: { kind: 'scalar', type: 'String' }, + total: { kind: 'scalar', type: 'Int' }, + status: { kind: 'scalar', type: 'String' }, + deletedAt: { kind: 'scalar', type: 'DateTime' }, + customer: { + kind: 'object', + type: 'Customer', + fromFields: ['customerId'], + toFields: ['id'], + }, + }, + }, + }, +}; +const lens: Lens = { maps: { prisma: map }, mapName: 'prisma', model: 'Customer' }; +const scope: Condition = { field: 'deletedAt', operator: Operator.isEmpty }; +const scoped: LensNarrowing = { + parent: lens, + mapDefaults: { prisma: { models: { Order: { where: scope } } } }, +}; +const paid: Condition = { field: 'status', operator: Operator.equals, value: 'paid' }; +const prismaOpts = { map: lens, mapName: 'prisma', model: 'Customer' }; + +describe('applyLens — a windowed rule takes its grant as the window filter (filter-first)', () => { + test('windowed any: the grant is the filter, the user condition is untouched', () => { + const rule = { + field: 'orders', + arrayOperator: ArrayOperator.any, + orderBy: [{ field: 'total', dir: 'desc' }], + take: 1, + condition: paid, + } as unknown as Condition; + const composed = applyLens(rule, scoped) as { filter?: Condition; condition?: Condition }; + expect(composed.filter).toEqual(scope); + expect(composed.condition).toEqual(paid); + // A deleted top row must not displace the in-scope top row. + const data = { + orders: [ + { total: 999, status: 'unpaid', deletedAt: '2020-01-01' }, + { total: 10, status: 'paid' }, + ], + }; + expect(check(composed as Condition, data)).toBe(true); + }); + + test('windowed none: a deleted top row cannot mask an in-scope violating row', () => { + const rule = { + field: 'orders', + arrayOperator: ArrayOperator.none, + orderBy: [{ field: 'total', dir: 'desc' }], + take: 1, + condition: { field: 'status', operator: Operator.equals, value: 'refunded' }, + } as unknown as Condition; + const composed = applyLens(rule, scoped); + const data = { + orders: [ + { total: 999, status: 'paid', deletedAt: '2020-01-01' }, + { total: 10, status: 'refunded' }, // the in-scope top row — none() must see it + ], + }; + expect(check(composed, data)).not.toBe(true); + }); + + test('windowed aggregate: the window runs over in-scope rows only', () => { + const rule = { + field: 'orders', + aggregate: { mode: 'sum', field: 'total' }, + condition: true, + orderBy: [{ field: 'total', dir: 'desc' }], + take: 2, + operator: Operator.greaterThan, + value: 100, + } as unknown as Condition; + const composed = applyLens(rule, scoped) as { filter?: Condition; condition?: Condition }; + expect(composed.filter).toEqual(scope); + expect(composed.condition).toBe(true); + const data = { orders: [{ total: 999, deletedAt: '2020-01-01' }, { total: 10 }, { total: 5 }] }; + // in-scope top-2 sum = 15, not > 100 (the deleted 999 must not be in the window) + expect(check(composed as Condition, data)).not.toBe(true); + }); + + test('windowed rule with a user filter: the grant is AND-ed into the filter', () => { + const userFilter: Condition = { field: 'total', operator: Operator.greaterThan, value: 0 }; + const rule = { + field: 'orders', + arrayOperator: ArrayOperator.any, + filter: userFilter, + take: 1, + condition: paid, + } as unknown as Condition; + const composed = applyLens(rule, scoped) as { filter?: Condition; condition?: Condition }; + expect(composed.filter).toEqual({ all: [userFilter, scope] }); + expect(composed.condition).toEqual(paid); + }); + + test('relations inside the user filter retain their own scope before selection', () => { + const customerScoped: LensNarrowing = { + parent: scoped, + mapDefaults: { + prisma: { + models: { + Customer: { + where: { + field: 'id', + operator: Operator.equals, + value: 'c1', + }, + }, + }, + }, + }, + }; + const rule = { + field: 'orders', + arrayOperator: ArrayOperator.any, + filter: { field: 'customer.id', operator: Operator.notEquals, value: 'none' }, + orderBy: [{ field: 'total', dir: 'desc' }], + take: 1, + condition: paid, + } as Condition; + expect( + check(applyLens(rule, customerScoped), { + id: 'c1', + orders: [ + { total: 100, status: 'unpaid', customer: { id: 'c2' } }, + { total: 10, status: 'paid', customer: { id: 'c1' } }, + ], + }), + ).toBe(true); + }); + + test('compilers reject the scoped window when they cannot preserve its filter', () => { + const composed = applyLens( + { + field: 'orders', + arrayOperator: ArrayOperator.any, + orderBy: [{ field: 'total', dir: 'desc' }], + take: 1, + condition: paid, + } as Condition, + scoped, + ); + expect(() => toPrisma(composed, prismaOpts)).toThrow(/Windowing/); + expect(() => toSql(composed, { map, model: 'Customer' })).toThrow(/Windowing/); + }); + + test('un-windowed any retains its scoped Prisma query', () => { + const rule = { + field: 'orders', + arrayOperator: ArrayOperator.any, + condition: paid, + } as Condition; + const composed = applyLens(rule, scoped) as { filter?: Condition; condition: Condition }; + expect(composed.filter).toBeUndefined(); + expect(composed.condition).toEqual({ all: [scope, paid] }); + expect(getWhere(toPrisma(composed as Condition, prismaOpts))).toEqual({ + orders: { some: { AND: [{ deletedAt: { equals: null } }, { status: { equals: 'paid' } }] } }, + }); + }); +}); diff --git a/test/lens.narrowing.whereGate.test.ts b/test/lens.narrowing.whereGate.test.ts new file mode 100644 index 0000000..56064c0 --- /dev/null +++ b/test/lens.narrowing.whereGate.test.ts @@ -0,0 +1,236 @@ +import { describe, expect, test } from 'bun:test'; +import { validateNarrowing } from '../src/lens/narrowing'; +import type { Lens, LensNarrowing } from '../src/lens/types'; +import { ArrayOperator, Operator } from '../src/operator'; +import type { FieldMap } from '../src/toPrisma/types'; + +// Every `where` a layer declares is a grant its author gets to filter rows by. `root.where` +// has always been validated against the PARENT surface (a child may not filter on a column an +// ancestor hid — that is a value oracle over hidden data). The other where positions — +// `root.relations[..].where`, `mapDefaults[..].models[..].where`, and `sources` wheres — went +// through a model-local "top segment exists" check instead, which both leaked (ancestor-hidden +// fields and enum values accepted) and false-rejected legitimate nested relation conditions. +// One lens-aware walk at the where's anchor visit, against the parent policy, closes both. + +const map: FieldMap = { + models: { + Customer: { + fields: { + id: { kind: 'scalar', type: 'String' }, + email: { kind: 'scalar', type: 'String' }, + internalScore: { kind: 'scalar', type: 'Int' }, + tier: { kind: 'enum', type: 'Tier' }, + orders: { kind: 'object', type: 'Order', isList: true }, + account: { kind: 'object', type: 'Account' }, + }, + }, + Order: { + fields: { + id: { kind: 'scalar', type: 'String' }, + status: { kind: 'scalar', type: 'String' }, + secretMargin: { kind: 'scalar', type: 'Int' }, + }, + }, + Account: { + fields: { + id: { kind: 'scalar', type: 'String' }, + region: { kind: 'scalar', type: 'String' }, + }, + }, + }, + enums: { Tier: ['gold', 'silver', 'internal'] }, +}; +const lens: Lens = { maps: { prisma: map }, mapName: 'prisma', model: 'Customer' }; + +const withParent = ( + parent: Lens | LensNarrowing, + rest: Omit, +): LensNarrowing => ({ parent, ...rest }); + +const marginOver50 = { field: 'secretMargin', operator: Operator.greaterThan, value: 50 }; + +describe('validateNarrowing — every where position is gated against the parent surface', () => { + test('a model default cannot bypass an ancestor path-specific omission', () => { + const platform = withParent(lens, { + root: { relations: { orders: { omits: ['secretMargin'] } } }, + }); + expect(() => + validateNarrowing( + withParent(platform, { + mapDefaults: { prisma: { models: { Order: { where: marginOver50 } } } }, + }), + ), + ).toThrow(/secretMargin.*does not resolve/); + }); + + test('a relation where comparison reference is checked at the related model', () => { + const platform = withParent(lens, { + root: { relations: { orders: { omits: ['secretMargin'] } } }, + }); + expect(() => + validateNarrowing( + withParent(platform, { + root: { + relations: { + orders: { + where: { + field: 'id', + operator: Operator.equals, + path: 'secretMargin', + }, + }, + }, + }, + }), + ), + ).toThrow(/secretMargin.*comparison ref/); + }); + test('root.relations[R].where on a field the ancestor hid at that path → error', () => { + const platform = withParent(lens, { + root: { relations: { orders: { omits: ['secretMargin'] } } }, + }); + const org = withParent(platform, { + root: { relations: { orders: { where: marginOver50 } } }, + }); + expect(() => validateNarrowing(org)).toThrow( + /root\.relations\.orders\.where: 'secretMargin' .*does not resolve/, + ); + }); + + test('mapDefaults.models[M].where on a field the ancestor mapDefaults hid → error', () => { + const platform = withParent(lens, { + mapDefaults: { prisma: { models: { Order: { omits: ['secretMargin'] } } } }, + }); + const org = withParent(platform, { + mapDefaults: { prisma: { models: { Order: { where: marginOver50 } } } }, + }); + expect(() => validateNarrowing(org)).toThrow( + /mapDefaults\.prisma\.models\.Order\.where: 'secretMargin' .*does not resolve/, + ); + }); + + test('mapDefaults.models[M].where naming an enum value the ancestor removed → error', () => { + const platform = withParent(lens, { + mapDefaults: { prisma: { enums: { Tier: { omits: ['internal'] } } } }, + }); + const org = withParent(platform, { + mapDefaults: { + prisma: { + models: { + Customer: { where: { field: 'tier', operator: Operator.equals, value: 'internal' } }, + }, + }, + }, + }); + expect(() => validateNarrowing(org)).toThrow( + /Customer\.where: .*'internal' is not in the allowed set/, + ); + }); + + test('a sources where on a field the ancestor hid → error', () => { + const platform = withParent(lens, { + mapDefaults: { prisma: { models: { Order: { omits: ['secretMargin'] } } } }, + }); + const org = withParent(platform, { + mapDefaults: { prisma: { models: { Order: { sources: { status: marginOver50 } } } } }, + }); + expect(() => validateNarrowing(org)).toThrow( + /mapDefaults\.prisma\.models\.Order\.sources\.status: 'secretMargin' .*does not resolve/, + ); + }); + + test('root.where on an ancestor-hidden field still errors (unchanged)', () => { + const platform = withParent(lens, { root: { omits: ['internalScore'] } }); + const org = withParent(platform, { + root: { where: { field: 'internalScore', operator: Operator.greaterThan, value: 1 } }, + }); + expect(() => validateNarrowing(org)).toThrow(/root\.where: 'internalScore' .*does not resolve/); + }); +}); + +describe('validateNarrowing — where paths resolve through relations, not against the anchor model', () => { + test('a mapDefaults where with a nested relation condition is accepted', () => { + // Previously rejected: "'status' not on model Customer" — the nested condition was + // checked against Customer instead of the descended Order. + const n = withParent(lens, { + mapDefaults: { + prisma: { + models: { + Customer: { + where: { + field: 'orders', + arrayOperator: ArrayOperator.any, + condition: { field: 'status', operator: Operator.equals, value: 'paid' }, + }, + }, + }, + }, + }, + }); + expect(() => validateNarrowing(n)).not.toThrow(); + }); + + test('a relation-node where with a bogus nested field → error at the descended model', () => { + const n = withParent(lens, { + root: { + where: { + field: 'orders', + arrayOperator: ArrayOperator.any, + condition: { field: 'nope', operator: Operator.equals, value: 'x' }, + }, + }, + }); + expect(() => validateNarrowing(n)).toThrow(/root\.where: 'nope' .*does not resolve/); + }); + + test('a dotted to-one path resolves; a bogus tail is rejected', () => { + const ok = withParent(lens, { + mapDefaults: { + prisma: { + models: { + Customer: { + where: { field: 'account.region', operator: Operator.equals, value: 'us' }, + }, + }, + }, + }, + }); + expect(() => validateNarrowing(ok)).not.toThrow(); + + const bad = withParent(lens, { + mapDefaults: { + prisma: { + models: { + Customer: { where: { field: 'account.nope', operator: Operator.equals, value: 'us' } }, + }, + }, + }, + }); + expect(() => validateNarrowing(bad)).toThrow( + /Customer\.where: 'account\.nope' .*does not resolve/, + ); + }); + + test('a where may still name a field the SAME layer hides (validated against the parent)', () => { + const n = withParent(lens, { + mapDefaults: { + prisma: { models: { Order: { omits: ['secretMargin'], where: marginOver50 } } }, + }, + }); + expect(() => validateNarrowing(n)).not.toThrow(); + }); + + test('a mapDefaults where on a model that is not the anchor validates against that model', () => { + const n = withParent(lens, { + mapDefaults: { + prisma: { + models: { + // 'region' is on Account, not Order + Order: { where: { field: 'region', operator: Operator.equals, value: 'us' } }, + }, + }, + }, + }); + expect(() => validateNarrowing(n)).toThrow(/Order\.where: 'region' .*does not resolve/); + }); +}); From 421930a4ab9687e48e984e1f31ffaff77c33acb1 Mon Sep 17 00:00:00 2001 From: Aron Greenspan Date: Sun, 6 Sep 2026 20:03:26 -0300 Subject: [PATCH 2/4] fix(lens): gate narrowing wheres at their visit, not a re-anchored lens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The where validation added in the previous commit built a synthetic Lens anchored at the where's own model, so a bare `path` comparison ref — which check() resolves at the ROOT context and applyLens injects unchanged into a to-many grant — was gated at the related model instead of the lens anchor. A legal grant like `Order.where = { contactEmail equals path: 'email' }` (Customer.email) was rejected, and a ref to a related-only column was accepted although it resolves to nothing at the root. checkRule now exposes an internal `checkConditionAtVisit` that starts the existing visit at (mapName, modelName, relPath) on the REAL parent policy: `field` and `$.` refs resolve at the visit, a bare `path` at the anchor. narrowing.ts validates every where through it — relation nodes at their relPath, root at [], model defaults at the off-path visit plus each declared parent visit (OFF_PATH moves to policy.ts and is shared with exposedSurface). applyLens: `filterFirst` uses `hasWindow`, the compilers' notion of a window, so an empty `orderBy` keeps the AND injection that still compiles. Co-Authored-By: Claude Fable 5.1 --- src/lens/applyLens.ts | 12 ++-- src/lens/checkRule.ts | 27 +++++++- src/lens/exposedSurface.ts | 5 +- src/lens/narrowing.ts | 68 +++++++++++-------- src/lens/policy.ts | 4 ++ test/lens.applyLens.windowFilterFirst.test.ts | 13 ++++ test/lens.narrowing.whereGate.test.ts | 53 +++++++++++++-- 7 files changed, 132 insertions(+), 50 deletions(-) diff --git a/src/lens/applyLens.ts b/src/lens/applyLens.ts index b1f5f96..d75bb76 100644 --- a/src/lens/applyLens.ts +++ b/src/lens/applyLens.ts @@ -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'; @@ -178,12 +179,9 @@ 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 filterFirst = - arrayOp === ArrayOperator.all || - ('filter' in rule && rule.filter !== undefined) || - ('orderBy' in rule && rule.orderBy !== undefined) || - ('take' in rule && rule.take !== undefined) || - ('skip' in rule && rule.skip !== undefined); + // `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 (filterFirst) { diff --git a/src/lens/checkRule.ts b/src/lens/checkRule.ts index a907c5e..c502832 100644 --- a/src/lens/checkRule.ts +++ b/src/lens/checkRule.ts @@ -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 }; diff --git a/src/lens/exposedSurface.ts b/src/lens/exposedSurface.ts index 2935dda..5569e23 100644 --- a/src/lens/exposedSurface.ts +++ b/src/lens/exposedSurface.ts @@ -1,6 +1,6 @@ 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'; @@ -8,9 +8,6 @@ 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 }; const unionFieldInto = ( diff --git a/src/lens/narrowing.ts b/src/lens/narrowing.ts index 68b5366..15cb72b 100644 --- a/src/lens/narrowing.ts +++ b/src/lens/narrowing.ts @@ -2,43 +2,44 @@ 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 { Lens, LensNarrowing, ModelDefaultNarrowing, ModelNarrowing } from './types.ts'; +import type { LensNarrowing, ModelDefaultNarrowing, ModelNarrowing } from './types.ts'; import { collectChain, getRoot, resolveRelationTarget } from './walk.ts'; -const parentAtVisit = ( - root: Lens, - ancestors: readonly LensNarrowing[], - mapName: string, - modelName: string, - relPath?: readonly string[], -): Lens | LensNarrowing => { - let parent: Lens | LensNarrowing = { ...root, mapName, model: modelName }; - for (const ancestor of ancestors) { - let node = relPath === undefined ? undefined : ancestor.root; - for (const segment of relPath ?? []) node = node?.relations?.[segment]; - parent = { parent, mapDefaults: ancestor.mapDefaults, root: node }; - } - return parent; -}; +/** 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, - parents: readonly (Lens | LensNarrowing)[], + parentPolicy: Policy, + visits: readonly WhereVisit[], position: string, errors: string[], ): void => { if (condition === undefined) return; - for (const parent of parents) { - for (const violation of checkRuleAgainstLens(condition, parent).violations) { - errors.push(`${position}: '${violation.path}' ${violation.reason}`); + const seen = new Set(); + 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); } } }; @@ -165,7 +166,8 @@ const validateModelNode = ( enumRegistry: Record | undefined, position: string, errors: string[], - parentSurfaces: readonly (Lens | LensNarrowing)[], + parentPolicy: Policy, + whereVisits: readonly WhereVisit[], isDefault = false, ): void => { if (narrowing.picks && narrowing.omits) { @@ -264,7 +266,7 @@ const validateModelNode = ( validateEnumOp('enumOmits', field, vals); } - validateWhere(narrowing.where, parentSurfaces, `${position}.where`, errors); + validateWhere(narrowing.where, parentPolicy, whereVisits, `${position}.where`, errors); for (const [field, entry] of Object.entries(narrowing.sources ?? {})) { if (!modelFields[field]) { @@ -303,7 +305,7 @@ const validateModelNode = ( } } } - validateWhere(spec.where, parentSurfaces, `${position}.sources.${field}`, errors); + validateWhere(spec.where, parentPolicy, whereVisits, `${position}.sources.${field}`, errors); } }; @@ -444,6 +446,7 @@ const validatePathNarrowing = ( modelName: string, position: string, errors: string[], + parentPolicy: Policy, relPath: readonly string[], ): void => { const fieldMap = maps[mapName]; @@ -475,7 +478,8 @@ const validatePathNarrowing = ( fieldMap?.enums, position, errors, - [parentAtVisit(getRoot(current), chain, mapName, modelName, relPath)], + parentPolicy, + [{ mapName, modelName, relPath }], false, ); @@ -531,6 +535,7 @@ const validatePathNarrowing = ( target.modelName, `${position}.relations.${relField}`, errors, + parentPolicy, [...relPath, relField], ); } @@ -540,6 +545,7 @@ 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 ?? {})) { @@ -562,12 +568,12 @@ export const validateNarrowing = (narrowing: LensNarrowing): void => { const ancestorDefaultsForModel = ancestors .map((anc) => anc.mapDefaults?.[mapName]?.models?.[modelName]) .filter((x): x is ModelDefaultNarrowing => x !== undefined); - const parentSurfaces = [parentAtVisit(set, ancestors, mapName, modelName)]; + // 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) { - parentSurfaces.push( - parentAtVisit(set, ancestors, mapName, modelName, path.split('.').slice(1)), - ); + whereVisits.push({ mapName, modelName, relPath: path.split('.').slice(1) }); } } validateModelNode( @@ -581,7 +587,8 @@ export const validateNarrowing = (narrowing: LensNarrowing): void => { fieldMap.enums, `mapDefaults.${mapName}.models.${modelName}`, errors, - parentSurfaces, + parentPolicy, + whereVisits, true, ); validateEnumFieldAgainstChain( @@ -634,6 +641,7 @@ export const validateNarrowing = (narrowing: LensNarrowing): void => { lensModel, 'root', errors, + parentPolicy, [], ); } diff --git a/src/lens/policy.ts b/src/lens/policy.ts index e8e24a8..8cf57e4 100644 --- a/src/lens/policy.ts +++ b/src/lens/policy.ts @@ -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 = diff --git a/test/lens.applyLens.windowFilterFirst.test.ts b/test/lens.applyLens.windowFilterFirst.test.ts index 44340f3..b7d4fcd 100644 --- a/test/lens.applyLens.windowFilterFirst.test.ts +++ b/test/lens.applyLens.windowFilterFirst.test.ts @@ -166,6 +166,19 @@ describe('applyLens — a windowed rule takes its grant as the window filter (fi expect(() => toSql(composed, { map, model: 'Customer' })).toThrow(/Windowing/); }); + test('an empty orderBy is not a window: AND injection is kept and still compiles', () => { + const rule = { + field: 'orders', + arrayOperator: ArrayOperator.any, + orderBy: [], + condition: paid, + } as unknown as Condition; + const composed = applyLens(rule, scoped) as { filter?: Condition; condition: Condition }; + expect(composed.filter).toBeUndefined(); + expect(composed.condition).toEqual({ all: [scope, paid] }); + expect(() => toPrisma(composed as Condition, prismaOpts)).not.toThrow(); + }); + test('un-windowed any retains its scoped Prisma query', () => { const rule = { field: 'orders', diff --git a/test/lens.narrowing.whereGate.test.ts b/test/lens.narrowing.whereGate.test.ts index 56064c0..ec723bb 100644 --- a/test/lens.narrowing.whereGate.test.ts +++ b/test/lens.narrowing.whereGate.test.ts @@ -63,7 +63,7 @@ describe('validateNarrowing — every where position is gated against the parent ).toThrow(/secretMargin.*does not resolve/); }); - test('a relation where comparison reference is checked at the related model', () => { + test('a relation where `$.` comparison ref to an ancestor-hidden related field → error', () => { const platform = withParent(lens, { root: { relations: { orders: { omits: ['secretMargin'] } } }, }); @@ -73,17 +73,56 @@ describe('validateNarrowing — every where position is gated against the parent root: { relations: { orders: { - where: { - field: 'id', - operator: Operator.equals, - path: 'secretMargin', - }, + where: { field: 'id', operator: Operator.equals, path: '$.secretMargin' }, }, }, }, }), ), - ).toThrow(/secretMargin.*comparison ref/); + ).toThrow(/orders\.where: '\$\.secretMargin' .*comparison ref/); + }); + + test('a bare comparison ref is root context: gated at the lens anchor, not the related model', () => { + // check() resolves a bare `path` against the root row and applyLens injects a to-many + // grant unchanged, so `path: 'email'` in an Order grant means Customer.email — legal even + // though Order has no `email` column. + const rootRef = withParent(lens, { + mapDefaults: { + prisma: { + models: { + Order: { where: { field: 'status', operator: Operator.equals, path: 'email' } }, + }, + }, + }, + }); + expect(() => validateNarrowing(rootRef)).not.toThrow(); + + // ...and it is gated by the ANCESTOR's anchor surface, like any root ref. + const platform = withParent(lens, { root: { omits: ['internalScore'] } }); + const hiddenRootRef = withParent(platform, { + mapDefaults: { + prisma: { + models: { + Order: { + where: { field: 'secretMargin', operator: Operator.equals, path: 'internalScore' }, + }, + }, + }, + }, + }); + expect(() => validateNarrowing(hiddenRootRef)).toThrow( + /Order\.where: 'internalScore' .*comparison ref/, + ); + + // A bare ref naming a related-model-only column resolves to nothing at the root. + const relatedOnly = withParent(lens, { + root: { + relations: { + orders: { where: { field: 'id', operator: Operator.equals, path: 'secretMargin' } }, + }, + }, + }); + expect(() => validateNarrowing(relatedOnly)).toThrow(/'secretMargin' .*comparison ref/); }); test('root.relations[R].where on a field the ancestor hid at that path → error', () => { const platform = withParent(lens, { From 6f2b5edbcecaaddd19522b4690f1b268add16573 Mon Sep 17 00:00:00 2001 From: Aron Greenspan Date: Sun, 6 Sep 2026 23:17:56 -0300 Subject: [PATCH 3/4] fix(toPrisma): fold boolean constants through AND/OR/NOT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `true` compiles to `{}`, which Prisma reads as match-all only at the top level and under AND. Inside OR Prisma drops the arm — `OR: [x, {}]` is just `x` — so `{ any: [customerId = mateo, true] }` returned zero rows for a customer scoped to sofia (verified against Prisma SQLite) while check() passed every row. `NOT: {}` is likewise not NOT(true). The logical builders now fold constants on compiled output instead of nesting them: a true arm absorbs an OR, a false arm absorbs an AND, either drops out of the other, and NOT of a constant is the other constant. This covers constants that arrive indirectly too — `all: []`, `any: []`, `atLeast 0`, and the bridge over-fetch sentinel, which under `any` now over-fetches the disjunction instead of being dropped and under-fetching. Match-nothing is Prisma's documented `{ OR: [] }` everywhere; the `{ id: null } AND { id: { not: null } }` self-contradiction sentinel is gone. Arms are still compiled exactly once, in order, before folding, so groupBy step refs stay positional and no step is duplicated; an arm that folds away leaves its step behind, which is executed and ignored. The bridge short-circuit in if/then/else is unchanged — NOT of the over-fetch sentinel must stay unknown, not become match-nothing. Co-Authored-By: Claude Fable 5.1 --- src/toPrisma/condition.ts | 4 +- src/toPrisma/logical.ts | 111 ++++++++----- test/lens.bridge.test.ts | 14 +- test/toPrisma.booleanFold.test.ts | 256 ++++++++++++++++++++++++++++++ 4 files changed, 337 insertions(+), 48 deletions(-) create mode 100644 test/toPrisma.booleanFold.test.ts diff --git a/src/toPrisma/condition.ts b/src/toPrisma/condition.ts index 2c7fb8e..94717ec 100644 --- a/src/toPrisma/condition.ts +++ b/src/toPrisma/condition.ts @@ -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: [] }; } diff --git a/src/toPrisma/logical.ts b/src/toPrisma/logical.ts index c82faab..3ae78fe 100644 --- a/src/toPrisma/logical.ts +++ b/src/toPrisma/logical.ts @@ -38,9 +38,12 @@ const resolveRelationTargetModel = ( * Does this condition (recursively) hit a bridge field? * * Bridge predicates compile to `{}` in toPrisma (the over-fetch sentinel). - * In direct AND/OR contexts that's a no-op or harmless over-fetch. But in - * `if/then`, the implication is encoded as `NOT(if) OR then` — and Prisma - * evaluates `NOT: {}` as match-nothing, which corrupts the implication. + * Under AND the fold drops it (no-op); under OR the fold absorbs the whole + * disjunction into `{}` (over-fetch, safe). But in `if/then`, the implication is + * encoded as `NOT(if) OR then`, and NOT of the sentinel is where the two meanings of + * `{}` part ways: NOT(true) is match-nothing, while NOT(unknown) must stay unknown. + * Folding would under-fetch, so a bridge anywhere in the implication over-fetches + * the whole expression instead. * * Recurses into arrayRule.condition and aggregate.condition, flipping the * model context to the relation target so nested fields resolve correctly. @@ -76,24 +79,58 @@ const conditionTouchesBridge = (cond: Condition, options?: BuildOptions): boolea return false; }; -export const buildAll = ( - all: All, - options?: BuildOptions, - state?: PrismaBuildState, -): PrismaWhere => { - if (all.all.length === 0) return {}; - return { AND: all.all.map((c) => buildCondition(c, options, state)) }; +/** + * The two boolean constants and how Prisma reads them. + * + * `{}` is match-all only where Prisma treats an empty filter as "no constraint": the top + * level, an AND arm, a relation filter (`some: {}`). Inside an OR Prisma DROPS it — `OR: [x, {}]` + * is just `x` — and `NOT: {}` is not the negation of true. `{ OR: [] }` is the documented + * match-nothing and composes correctly everywhere. + * + * So the logical builders never nest a constant under OR or NOT; they fold it instead: + * a `true` arm absorbs an OR, a `false` arm absorbs an AND, either drops out of the other, + * and NOT of a constant is the other constant. The fold runs on compiled output, so it also + * covers constants that arrive indirectly — `all: []`, `any: []`, `atLeast 0`, and the bridge + * over-fetch sentinel (a bridge under `any` now over-fetches the disjunction instead of being + * dropped by Prisma and under-fetching). + * + * Every arm is compiled exactly once, in order, BEFORE folding. Count operators push a + * groupBy step into `state` as they compile, so an arm that then folds away leaves its step + * behind — harmless (an unreferenced step is executed and ignored) and necessary: step refs + * are positional, so nothing may be rebuilt or renumbered. + */ +const matchAll = (): PrismaWhere => ({}); +const matchNothing = (): PrismaWhere => ({ OR: [] }); +const isMatchAll = (where: PrismaWhere): boolean => Object.keys(where).length === 0; +const isMatchNothing = (where: PrismaWhere): boolean => { + const keys = Object.keys(where); + return keys.length === 1 && keys[0] === 'OR' && Array.isArray(where.OR) && where.OR.length === 0; }; -export const buildAny = ( - any: Any, - options?: BuildOptions, - state?: PrismaBuildState, -): PrismaWhere => { - if (any.any.length === 0) return { AND: [{ id: null }, { id: { not: null } }] }; - return { OR: any.any.map((c) => buildCondition(c, options, state)) }; +const andWhere = (arms: PrismaWhere[]): PrismaWhere => { + if (arms.some(isMatchNothing)) return matchNothing(); + const rest = arms.filter((arm) => !isMatchAll(arm)); + return rest.length === 0 ? matchAll() : { AND: rest }; }; +const orWhere = (arms: PrismaWhere[]): PrismaWhere => { + if (arms.some(isMatchAll)) return matchAll(); + const rest = arms.filter((arm) => !isMatchNothing(arm)); + return rest.length === 0 ? matchNothing() : { OR: rest }; +}; + +const notWhere = (where: PrismaWhere): PrismaWhere => { + if (isMatchAll(where)) return matchNothing(); + if (isMatchNothing(where)) return matchAll(); + return { NOT: where }; +}; + +export const buildAll = (all: All, options?: BuildOptions, state?: PrismaBuildState): PrismaWhere => + andWhere(all.all.map((c) => buildCondition(c, options, state))); + +export const buildAny = (any: Any, options?: BuildOptions, state?: PrismaBuildState): PrismaWhere => + orWhere(any.any.map((c) => buildCondition(c, options, state))); + export const buildIfThenElse = ( cond: IfThenElse, options?: BuildOptions, @@ -102,13 +139,13 @@ export const buildIfThenElse = ( // if → then is equivalent to: NOT(if) OR then // With else: (NOT(if) OR then) AND (if OR else) // - // When any sub-clause hits a bridge, the precise compilation breaks: - // - bridge in `if`: `NOT({})` becomes match-nothing in Prisma, corrupting the implication. - // - bridge in `then` with `else`: `OR[NOT(if), {}]` collapses to match-all, then - // AND-ed with `OR[if, else]` silently drops the `then` branch. - // - bridge in `else`: symmetric — drops the `else` branch. - // Over-fetch the whole expression and let the caller's check() filter against - // hydrated cross-source data. + // When any sub-clause hits a bridge, the precise compilation breaks. The sentinel `{}` + // means "unknown here" for a bridge, and neither Prisma nor the constant fold can carry + // that through a negation: Prisma ignores `NOT: {}` (measured: it matches everything), + // and the fold would read it as NOT(true) = match-nothing. Either way an `if` bridge + // collapses the implication to `then` alone, and a bridge in `then`/`else` with the + // other branch present drops that branch. Over-fetch the whole expression and let the + // caller's check() filter against hydrated cross-source data. if ( conditionTouchesBridge(cond.if, options) || conditionTouchesBridge(cond.then, options) || @@ -117,27 +154,19 @@ export const buildIfThenElse = ( return {}; } - // Build the `if` clause once to avoid pushing duplicate GroupBySteps into state - // when the `if` clause contains a count-based array operator (atLeast/atMost/exactly). + // Each clause is built exactly once: a count-based array operator in `if` pushes a + // GroupByStep as it compiles, and the same clause is reused in both conjuncts below. + // Boolean branches (`then: true`, `else: false`, …) are compiled like any other + // condition and folded by orWhere/andWhere/notWhere so no constant lands under OR/NOT. const ifClause = buildCondition(cond.if, options, state); - const notIf = { NOT: ifClause }; - // `false` as a then/else branch is a legal deny — buildCondition(false) would - // throw, so emit the match-nothing pattern that buildAny uses for empty `any: []`. - const thenClause = - cond.then === false ? MATCH_NOTHING : buildCondition(cond.then, options, state); + const thenClause = buildCondition(cond.then, options, state); + const implication = orWhere([notWhere(ifClause), thenClause]); // !== undefined so `else: false` (deny branch) is emitted rather than skipped. if (cond.else !== undefined) { - const elseClause = - cond.else === false ? MATCH_NOTHING : buildCondition(cond.else, options, state); - return { - AND: [{ OR: [notIf, thenClause] }, { OR: [ifClause, elseClause] }], - }; + const elseClause = buildCondition(cond.else, options, state); + return andWhere([implication, orWhere([ifClause, elseClause])]); } - return { OR: [notIf, thenClause] }; + return implication; }; - -// Prisma WHERE that matches no rows. Same self-contradiction shape used by buildAny's -// empty-array path; relies on the model having an `id` field (true for ~all Prisma models). -const MATCH_NOTHING: PrismaWhere = { AND: [{ id: null }, { id: { not: null } }] }; diff --git a/test/lens.bridge.test.ts b/test/lens.bridge.test.ts index 6dc5d20..a14d94e 100644 --- a/test/lens.bridge.test.ts +++ b/test/lens.bridge.test.ts @@ -61,7 +61,7 @@ describe('lens + bridge: toPrisma compiles only the Prisma-pushable subset', () expect((where as { where: object }).where).toEqual({}); }); - test('AND of prisma-pushable + bridge: bridge slot becomes {}', () => { + test('AND of prisma-pushable + bridge: the bridge slot folds away, local arm stays', () => { const rule = { all: [ { field: 'email', operator: Operator.equals, value: 'foo@bar.com' }, @@ -71,10 +71,13 @@ describe('lens + bridge: toPrisma compiles only the Prisma-pushable subset', () const result = toPrisma(rule, { map: lens, mapName: lens.mapName, model: lens.model }); const where = (result.steps[result.steps.length - 1] as unknown as { where: { AND: object[] } }) .where; - expect(where.AND).toEqual([{ email: { equals: 'foo@bar.com' } }, {}]); + expect(where.AND).toEqual([{ email: { equals: 'foo@bar.com' } }]); }); - test('OR of prisma-pushable + bridge: bridge slot becomes {} (over-fetch)', () => { + test('OR of prisma-pushable + bridge: the whole disjunction over-fetches', () => { + // Prisma drops a `{}` arm inside OR, so `OR: [email, {}]` would silently UNDER-fetch + // to the email arm alone. The bridge sentinel is `true` for the pushable rail, and a + // true arm absorbs the OR: match-all, then the caller's check() filters precisely. const rule = { any: [ { field: 'email', operator: Operator.equals, value: 'foo@bar.com' }, @@ -82,9 +85,8 @@ describe('lens + bridge: toPrisma compiles only the Prisma-pushable subset', () ], }; const result = toPrisma(rule, { map: lens, mapName: lens.mapName, model: lens.model }); - const where = (result.steps[result.steps.length - 1] as unknown as { where: { OR: object[] } }) - .where; - expect(where.OR).toEqual([{ email: { equals: 'foo@bar.com' } }, {}]); + const where = (result.steps[result.steps.length - 1] as unknown as { where: object }).where; + expect(where).toEqual({}); }); }); diff --git a/test/toPrisma.booleanFold.test.ts b/test/toPrisma.booleanFold.test.ts new file mode 100644 index 0000000..c5ecfe2 --- /dev/null +++ b/test/toPrisma.booleanFold.test.ts @@ -0,0 +1,256 @@ +import { describe, expect, it } from 'bun:test'; +import { ArrayOperator, check, Operator, toPrisma } from '../index'; +import { stitchFieldMaps } from '../src/fieldMap/stitch'; +import type { Bridge } from '../src/fieldMap/types'; +import type { FieldMap, GroupByStep } from '../src/toPrisma/types'; +import { getWhere } from './fixtures/helpers'; + +// `true` compiles to `{}`, which Prisma only reads as match-all at the top level and +// inside AND. Inside OR Prisma drops it — `OR: [x, {}]` is just `x` (verified against +// Prisma SQLite: `{ any: [customerId = mateo, true] }` returned zero rows for a +// customer scoped to sofia while check() passed every row) — and `NOT: {}` is not +// NOT(true). The logical builders therefore fold the boolean constants: a `true` +// arm absorbs an OR, a `false` arm absorbs an AND, and `NOT` of a constant is the +// other constant. `{ OR: [] }` is Prisma's documented match-nothing and is the only +// false shape emitted — no `{ id: null }` self-contradiction sentinel. + +const mateo = { field: 'customerId', operator: Operator.equals, value: 'mateo' }; +const gold = { field: 'tier', operator: Operator.equals, value: 'gold' }; +const silver = { field: 'tier', operator: Operator.equals, value: 'silver' }; +const MATEO = { customerId: { equals: 'mateo' } }; +const GOLD = { tier: { equals: 'gold' } }; +const SILVER = { tier: { equals: 'silver' } }; +const NOTHING = { OR: [] }; + +/** Every `{}` or `{ OR: [] }` that sits under an OR arm or a NOT — the shapes Prisma + * misreads. Walks the whole compiled where, including relation filters. */ +const nestedConstants = (where: unknown, path = '$'): string[] => { + if (Array.isArray(where)) return where.flatMap((w, i) => nestedConstants(w, `${path}[${i}]`)); + if (where === null || typeof where !== 'object') return []; + const rec = where as Record; + const out: string[] = []; + for (const [key, val] of Object.entries(rec)) { + if (key === 'OR' && Array.isArray(val)) { + for (const [i, arm] of val.entries()) { + if (isConstant(arm)) out.push(`${path}.OR[${i}]`); + } + } + if (key === 'NOT' && isConstant(val)) out.push(`${path}.NOT`); + out.push(...nestedConstants(val, `${path}.${key}`)); + } + return out; +}; +const isConstant = (w: unknown): boolean => { + if (w === null || typeof w !== 'object' || Array.isArray(w)) return false; + const keys = Object.keys(w); + if (keys.length === 0) return true; + const or = (w as Record).OR; + return keys.length === 1 && Array.isArray(or) && or.length === 0; +}; + +describe('toPrisma folds boolean constants through OR', () => { + it('reproducer: `any: [x, true]` matches everything, like check()', () => { + const rule = { any: [mateo, true] }; + expect(check(rule, { customerId: 'sofia' })).toBe(true); + expect(getWhere(toPrisma(rule))).toEqual({}); + }); + + it('a nested empty `all` is `true` and absorbs the OR too', () => { + expect(getWhere(toPrisma({ any: [mateo, { all: [] }] }))).toEqual({}); + }); + + it('`false` arms drop out of an OR', () => { + expect(getWhere(toPrisma({ any: [mateo, false] }))).toEqual({ OR: [MATEO] }); + expect(getWhere(toPrisma({ any: [false, { any: [] }] }))).toEqual(NOTHING); + }); + + it('empty `any` is the documented match-nothing, not an id sentinel', () => { + expect(getWhere(toPrisma({ any: [] }))).toEqual(NOTHING); + }); +}); + +describe('toPrisma folds boolean constants through AND', () => { + it('`true` arms drop out of an AND', () => { + expect(getWhere(toPrisma({ all: [mateo, true] }))).toEqual({ AND: [MATEO] }); + expect(getWhere(toPrisma({ all: [true, { all: [] }] }))).toEqual({}); + }); + + it('a `false` arm absorbs the AND', () => { + expect(getWhere(toPrisma({ all: [mateo, false] }))).toEqual(NOTHING); + expect(getWhere(toPrisma({ all: [mateo, { any: [] }] }))).toEqual(NOTHING); + }); +}); + +describe('toPrisma folds boolean constants through the implication', () => { + it('`if: true` never emits `NOT: {}`', () => { + expect(getWhere(toPrisma({ if: true, then: gold }))).toEqual({ OR: [GOLD] }); + expect(getWhere(toPrisma({ if: true, then: gold, else: silver }))).toEqual({ + AND: [{ OR: [GOLD] }], + }); + }); + + it('`if: false` is vacuous without else and selects else with it', () => { + expect(getWhere(toPrisma({ if: false, then: gold }))).toEqual({}); + expect(getWhere(toPrisma({ if: false, then: gold, else: silver }))).toEqual({ + AND: [{ OR: [SILVER] }], + }); + }); + + it('`then: true` is vacuous; `then: false` is the negated antecedent', () => { + expect(getWhere(toPrisma({ if: mateo, then: true }))).toEqual({}); + expect(getWhere(toPrisma({ if: mateo, then: false }))).toEqual({ OR: [{ NOT: MATEO }] }); + }); + + it('`else: false` keeps the deny branch without an id sentinel', () => { + expect(getWhere(toPrisma({ if: mateo, then: gold, else: false }))).toEqual({ + AND: [{ OR: [{ NOT: MATEO }, GOLD] }, { OR: [MATEO] }], + }); + }); + + it('`else: true` drops the else arm', () => { + expect(getWhere(toPrisma({ if: mateo, then: gold, else: true }))).toEqual({ + AND: [{ OR: [{ NOT: MATEO }, GOLD] }], + }); + }); +}); + +describe('toPrisma never nests a constant under OR or NOT', () => { + const leaves = [mateo, gold]; + const constants = [true, false, { all: [] }, { any: [] }]; + const shapes: unknown[] = []; + for (const c of constants) { + for (const leaf of leaves) { + shapes.push({ any: [leaf, c] }, { any: [c, leaf] }, { all: [leaf, c] }); + shapes.push({ if: c, then: leaf }, { if: leaf, then: c }, { if: leaf, then: gold, else: c }); + shapes.push({ if: c, then: leaf, else: silver }, { if: leaf, then: c, else: silver }); + shapes.push({ any: [{ all: [leaf, c] }, { if: c, then: leaf }] }); + shapes.push({ + field: 'posts', + arrayOperator: ArrayOperator.any, + condition: { any: [leaf, c] }, + }); + } + } + + it.each(shapes.map((s) => [JSON.stringify(s), s] as const))('%s', (_, shape) => { + expect(nestedConstants(getWhere(toPrisma(shape as never)))).toEqual([]); + }); +}); + +// A bridge predicate compiles to `{}` as the over-fetch sentinel. Under an OR that +// sentinel used to be dropped by Prisma, silently UNDER-fetching; folding it as `true` +// over-fetches the whole disjunction, which is the contract check() relies on. +describe('toPrisma bridge sentinel inside `any` over-fetches', () => { + const prismaMap: FieldMap = { + models: { + FanUser: { + fields: { + id: { kind: 'scalar', type: 'String' }, + crmId: { kind: 'scalar', type: 'String' }, + tier: { kind: 'scalar', type: 'String' }, + }, + }, + }, + }; + const salesforceMap: FieldMap = { + models: { + Contact: { + fields: { + id: { kind: 'scalar', type: 'String' }, + industry: { kind: 'scalar', type: 'String' }, + }, + }, + }, + }; + const bridge: Bridge = { + endpoints: [ + { fieldMap: 'salesforce', model: 'Contact', on: 'id' }, + { fieldMap: 'prisma', model: 'FanUser', on: 'crmId' }, + ], + cardinality: 'oneToOne', + }; + const stitched = stitchFieldMaps({ + maps: { prisma: prismaMap, salesforce: salesforceMap }, + bridges: [bridge], + }); + const opts = { map: stitched.maps.prisma, model: 'FanUser' }; + const tech = { field: 'salesforce:Contact.industry', operator: Operator.equals, value: 'tech' }; + + it('`any: [bridge, x]` compiles to match-all, not `OR: [{}, x]`', () => { + expect(getWhere(toPrisma({ any: [tech, gold] }, opts))).toEqual({}); + }); + + it('`all: [bridge, x]` keeps only the local arm', () => { + expect(getWhere(toPrisma({ all: [tech, gold] }, opts))).toEqual({ AND: [GOLD] }); + }); +}); + +// Count operators push a groupBy step into the build state as a side effect. Folding +// must not build an arm twice (duplicate steps) and must leave every emitted step ref +// pointing at the step that produced it. +describe('toPrisma folding keeps groupBy step state coherent', () => { + const map: FieldMap = { + models: { + User: { + fields: { + id: { kind: 'scalar', type: 'String' }, + tier: { kind: 'scalar', type: 'String' }, + posts: { kind: 'object', type: 'Post', isList: true, relationName: 'PostToUser' }, + }, + }, + Post: { + fields: { + id: { kind: 'scalar', type: 'String' }, + authorId: { kind: 'scalar', type: 'String' }, + published: { kind: 'scalar', type: 'Boolean' }, + author: { + kind: 'object', + type: 'User', + relationName: 'PostToUser', + fromFields: ['authorId'], + toFields: ['id'], + }, + }, + }, + }, + }; + const opts = { map, model: 'User' }; + const published = { field: 'published', operator: Operator.equals, value: true }; + const twoPosts = { + field: 'posts', + arrayOperator: ArrayOperator.atLeast, + count: 2, + condition: published, + }; + const groupBySteps = (plan: ReturnType) => + plan.steps.filter((s): s is GroupByStep => s.operation === 'groupBy'); + + it('`if: count, then: true` builds the antecedent once and folds to match-all', () => { + const plan = toPrisma({ if: twoPosts, then: true }, opts); + expect(groupBySteps(plan)).toHaveLength(1); + expect(getWhere(plan)).toEqual({}); + }); + + it('`any: [count, true]` folds to match-all; the built step is left in place', () => { + const plan = toPrisma({ any: [twoPosts, true] }, opts); + expect(groupBySteps(plan)).toHaveLength(1); + expect(getWhere(plan)).toEqual({}); + }); + + it('a surviving step ref still points at its own step after a sibling folds away', () => { + const onePost = { ...twoPosts, count: 1 }; + const plan = toPrisma({ all: [{ any: [twoPosts, false] }, { all: [onePost, true] }] }, opts); + const steps = groupBySteps(plan); + expect(steps).toHaveLength(2); + expect(steps[0].args.having).toEqual({ authorId: { _count: { gte: 2 } } }); + expect(steps[1].args.having).toEqual({ authorId: { _count: { gte: 1 } } }); + expect(getWhere(plan)).toEqual({ + AND: [{ OR: [{ id: { in: { __step: 0 } } }] }, { AND: [{ id: { in: { __step: 1 } } }] }], + }); + }); + + it('`condition: true` on a count operator is still an empty groupBy where', () => { + const plan = toPrisma({ ...twoPosts, condition: true }, opts); + expect(groupBySteps(plan)[0].args.where).toEqual({}); + }); +}); From a67e49f71df163e4428aeb4121631cd3bf26aa29 Mon Sep 17 00:00:00 2001 From: Aron Greenspan Date: Sun, 6 Sep 2026 23:32:05 -0300 Subject: [PATCH 4/4] chore: release json-rules 2.21.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 235eaf5..8b90768 100644 --- a/package.json +++ b/package.json @@ -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",