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", diff --git a/src/lens/applyLens.ts b/src/lens/applyLens.ts index 7349808..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'; @@ -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 => { @@ -178,13 +179,16 @@ 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 { @@ -192,18 +196,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/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 0656fa0..15cb72b 100644 --- a/src/lens/narrowing.ts +++ b/src/lens/narrowing.ts @@ -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(); + 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 ≠ @@ -133,6 +166,8 @@ const validateModelNode = ( enumRegistry: Record | undefined, position: string, errors: string[], + parentPolicy: Policy, + whereVisits: readonly WhereVisit[], isDefault = false, ): void => { if (narrowing.picks && narrowing.omits) { @@ -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]) { @@ -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, - 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 +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]; @@ -487,6 +478,8 @@ const validatePathNarrowing = ( fieldMap?.enums, position, errors, + parentPolicy, + [{ mapName, modelName, relPath }], false, ); @@ -542,6 +535,8 @@ const validatePathNarrowing = ( target.modelName, `${position}.relations.${relField}`, errors, + parentPolicy, + [...relPath, relField], ); } }; @@ -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]; @@ -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[], @@ -582,6 +587,8 @@ export const validateNarrowing = (narrowing: LensNarrowing): void => { fieldMap.enums, `mapDefaults.${mapName}.models.${modelName}`, errors, + parentPolicy, + whereVisits, true, ); validateEnumFieldAgainstChain( @@ -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) { 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/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.applyLens.windowFilterFirst.test.ts b/test/lens.applyLens.windowFilterFirst.test.ts new file mode 100644 index 0000000..b7d4fcd --- /dev/null +++ b/test/lens.applyLens.windowFilterFirst.test.ts @@ -0,0 +1,195 @@ +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('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', + 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.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/lens.narrowing.whereGate.test.ts b/test/lens.narrowing.whereGate.test.ts new file mode 100644 index 0000000..ec723bb --- /dev/null +++ b/test/lens.narrowing.whereGate.test.ts @@ -0,0 +1,275 @@ +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 ref to an ancestor-hidden related field → error', () => { + 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(/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, { + 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/); + }); +}); 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({}); + }); +});