From 1da67ce44c00fdf3086748d10485f91f8464fffe Mon Sep 17 00:00:00 2001 From: agreenspan Date: Thu, 8 Oct 2026 12:12:57 -0300 Subject: [PATCH] =?UTF-8?q?fix(lens):=203.5.1=20review=20fixes=20=E2=80=94?= =?UTF-8?q?=20bridged=20fetch=20reads,=20to-many=20paths=20refused=20at=20?= =?UTF-8?q?runtime,=20JSON=20re-check=20selects?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - toLensSelect selects a bridged source's local reads (and each bridge's local on key) as fetch-only columns, so materializeSources over its rows + the far side answers it. - A label/groupBy across a to-many relation is refused by toSourceQueries and materializeSources too: one shared toOnePathError check, so validateNarrowing is ok exactly when no posture refuses. - A re-check reading into a Json column selects the column whole; SQL refusal messages name the real reason. SourcePrismaQuery.steps is present for a column reference too. - Count operators across a bridge say so. - README snippets destructure query.prisma and run steps through executePrismaPlan (tested against the rails harness); orderRecords notes JS code-unit string order. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 32 ++++ README.md | 25 ++- package.json | 2 +- src/lens/lensRows.ts | 36 +++-- src/lens/narrowing.ts | 26 +-- src/lens/sourceOptions.ts | 89 ++++++++--- src/lens/toSourceQueries.ts | 70 ++++---- src/toPrisma/groupStep.ts | 5 + test/review.351.test.ts | 311 ++++++++++++++++++++++++++++++++++++ 9 files changed, 493 insertions(+), 103 deletions(-) create mode 100644 test/review.351.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f254935..e721a6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,37 @@ # Changelog +## 3.5.1 — review fixes: bridged fetches, to-many paths, JSON re-checks + +Fail-closed and doc findings from the 3.5.0 adversarial review. + +- **`toLensSelect` selects a bridged source's local reads.** It skipped every column a source across + a bridge reads, so `materializeSources` over its rows plus the far side threw `UsageError: lacks + 'age'` for `any: [age > 35, crm:Profile.tier = gold]` when the lens hides `age`. It now selects + what the source reads on this side, hidden or not, and each crossed bridge's local `on` key, as + fetch-only columns: `projectRows(…, { keepClampColumns: true })` keeps them and a viewer's + projection drops them. +- **A `label` or `groupBy` across a to-many relation (local or bridged) is refused by every + posture.** `validateNarrowing` reported it (`invalid_source`), but `toSourceQueries` and + `materializeSources` still ran and returned unlabeled or ungrouped options. One check + (`toOnePathError`) now serves validation and the source plans, so the postures throw the same + `LensRefusal` (`invalid_source`) and `validateNarrowing` is ok exactly when none refuses. The + plans' separate unresolvable-hop and to-many-clamp refusals fold into it. +- **A re-check reading into a Json column selects the column whole.** `toSourceQueries` emitted a + nested `select` into the Json column (`meta: { select: { a: … } }`), which the typed Prisma + client rejects. It selects `meta: true`; flat SQL rows hold that column, so the SQL query is no + longer refused for it. A read through a relation still is, and the message says why per read + (through a relation, or not a column of the model). +- **`SourcePrismaQuery.steps` is present whenever the where needs `executePrismaPlan`**: a count + step, as before, or a column reference (`{ __field }`) to resolve. A column-reference source used to + ship an unresolved where that Prisma rejects. +- **A count operator across a bridge says so**: "'crm:Event' crosses a bridge: count operators + aren't supported across a bridge", not "is not a relation … Count operators require a relation + field". +- README: both source-query snippets destructure `query.prisma` and run `steps` through + `executePrismaPlan` (the bridge snippet called `prisma[query.model].findMany(query.prisma)`, + which Prisma rejects); a test runs that shape against the rails harness. `orderRecords` notes + that strings sort in JavaScript code-unit order, which can differ from a database collation. + ## 3.5.0 — grant → clamp A lens `where` only ever narrows: a row is hidden unless it holds. "Grant" read as permission diff --git a/README.md b/README.md index bc792ee..1cf120a 100644 --- a/README.md +++ b/README.md @@ -1162,7 +1162,7 @@ Composition across chained narrowings is pure intersection: relations are turned | `lensVisit(lens, relationPath, options?)` | One visit as `projectLens` (by path) gives it — its shown fields with values and options, sources, labels and axes — at a dotted relation path from the anchor (`''` for the anchor), resolved on demand: nothing is enumerated, so it is cheap on any schema. `null` when a relation on the path isn't shown there (off, omitted, or outside the model-default tree). A builder walks a lens with it instead of re-deriving the lens's rules. | | `walkLensPath(lens, path)` | Resolves one dotted path through the lens hop by hop: `{ outcome: 'resolved', hops, terminal, jsonSubPath }`, or `hidden` (a column it doesn't keep, or a relation it doesn't turn on there) / `missing` / `pastScalar` with the failing `index`. | | `readLensValue(lens, row, path, options?)` | One value off a row, as the lens shows it: `{ ok: true, value }`, or `{ ok: false, reason }` — `hidden` / `missing` / `pastScalar` (the walk `validateRuleInLens` gates a field with), `relation` (the path ends on rows, not a value) or `list` (it crosses a to-many). Each row on the way, the root included, is checked against its visit's clamps: one a clamp hides, or a missing one, reads `null`. Only own properties are read, into a Json column too. `options` is what each clamp is checked with (`now`, `bindings`). For values a template interpolates. | -| `orderRecords(items, orderBy)` | Records sorted by an `OrderBy` exactly as a window orders them on every rail: each key in turn, own-property path reads, `dir` order, NULL or absent last in either direction, ties in input order. Returns a new array. | +| `orderRecords(items, orderBy)` | Records sorted by an `OrderBy` exactly as a window orders them on every rail: each key in turn, own-property path reads, `dir` order, NULL or absent last in either direction, ties in input order. Returns a new array. Strings sort in JavaScript code-unit order, which can differ from a database's collation (see [Where the Rails Differ](#where-the-rails-differ)). | | `narrowRule(rule, narrowing)` | Composes the user rule with the lens's `where` clauses, injecting each at its anchor in the rule tree. Under an `all`, the clamp goes into the rule's window `filter`, which `check()` evaluates and `toPrisma` folds into the rule (`toSql` compiles no relation arrays). Other rules pass to `check` / `toPrisma` / `toSql`. | | `coerceRule(rule, lens)` | Stamps each field rule with its field's `coerceType` from the lens (`Int`, `Float`, `Decimal`, `BigInt`, `DateTime`, `Boolean`, `String`). Leaves date rules, aggregate comparisons, rules that already carry a `coerceType`, and anything below a Json column alone. | @@ -1209,6 +1209,7 @@ to each field as `options`. ```ts import { + executePrismaPlan, materializeSourceQuery, materializeSources, projectLens, @@ -1230,8 +1231,13 @@ const [query] = toSourceQueries(narrowing); // query.prisma => { model: 'User', distinct: ['region'], select: { region: true }, where: { AND: [...] } } // query.sql => { sql: 'SELECT DISTINCT "t0"."region" FROM "User" AS "t0" WHERE (...)', params: ['t-42', true] } // A query with `recheck` (a source across a bridge) returns candidates; see "Sources across a bridge". -const { distinct, select, where } = query.prisma; -const rows = await prisma.user.findMany({ distinct, select, where }); +// `steps` (a count step, a column reference) resolve through executePrismaPlan first. +const { distinct, select, where, steps } = query.prisma; // query.model === 'User' +const rows = await prisma.user.findMany({ + distinct, + select, + where: steps ? await executePrismaPlan({ steps }, prisma) : where, +}); const values = materializeSourceQuery(query, rows); // { path, mapName, model, field, options: [{ value }] } // Or from rows already fetched under the lens (relations inline): @@ -1243,7 +1249,7 @@ const projection = projectLens(narrowing, { sourceValues: [values] }); | Function | Purpose | | --- | --- | -| `toSourceQueries(lensOrNarrowing, options?)` | `SourceQuery[]`, one per sourced field: `{ path, mapName, model, field, label?, groupBy?, composedWhere, prisma, sql, recheck? }`. `options` is the clock (`now`, `timeZone`, `weekStart`) a relative date in the where compiles with — required for one, a plain usage error without it; bind a lens's binds with `bindLens` first. `prisma.steps` is present when the where needs `executePrismaPlan`. `sql.sql` is `null` with an `error` when SQL can't express the where. A source that reads across a bridge gets an over-fetching query and a `recheck`: its rows are candidates, not options (see "Sources across a bridge"). `distinct` is the value and a sibling label column; a grouped source or a dotted label drops it, so every label comes back and the least one is picked. | +| `toSourceQueries(lensOrNarrowing, options?)` | `SourceQuery[]`, one per sourced field: `{ path, mapName, model, field, label?, groupBy?, composedWhere, prisma, sql, recheck? }`. `options` is the clock (`now`, `timeZone`, `weekStart`) a relative date in the where compiles with — required for one, a plain usage error without it; bind a lens's binds with `bindLens` first. `prisma.steps` is present when the where needs `executePrismaPlan` (a count step, or a column reference to resolve): query with the where it returns. `sql.sql` is `null` with an `error` when SQL can't express the where. A source that reads across a bridge gets an over-fetching query and a `recheck`: its rows are candidates, not options (see "Sources across a bridge"). `distinct` is the value and a sibling label column; a grouped source or a dotted label drops it, so every label comes back and the least one is picked. | | `materializeSourceQuery(query, rows, { rowShape?, lens?, now?, … })` | One query's fetched rows as `SourceValues`. `rowShape` is `'prisma'` (default: a dotted `label` and each `groupBy` axis come nested) or `'sql'` (they come flat as `__label` / `__group_i`). Options are deduplicated and sorted. A query with `recheck` needs `lens` and rows holding the far side: it re-checks each candidate (`check`, with the clock and bindings given) and reads a bridged label or axis from the far side; a missing far side is a `UsageError`. | | `materializeSources(lensOrNarrowing, rows, options?)` | `SourceValues[]` for every sourced field, from the rows the lens fetches — `toLensSelect`'s rows as fetched, or as `projectRows(…, { keepClampColumns: true })` keeps them. A viewer's projection drops what sources read: a row lacking any key a read walks — through each relation and list element to the column — that a source or a clamp on its path reads throws a `UsageError` (a fetch returns every key it selects, NULL as `null`). The path is walked down the rows — the tree is its link — each level's clamps met, and each row it reaches must meet, through `check()` with `options`, its visit's clamps, its source `where` narrowed as a rule is, the guards of the relations its label and axes cross and the values the lens allows — so it offers what `toSourceQueries` does. A scalar-list field gives one option per element; a value takes its least label. A `from: 'mapDefaults'` source throws (see below). | @@ -1334,12 +1340,19 @@ see, and marks it with `recheck`: clamps above it are carried back across the bridge through the far model's bridge field, and come back as its `recheck` — so each candidate holds the near rows inline (`{ id, industry, 'prisma:FanUser': [{ email, … }] }`). +- **A re-check reading into a Json column selects the column whole** (`meta: true`, never a nested + select into it); the re-check reads inside it in memory. - **SQL rows are flat**, so a bridged query whose re-check reads through a local relation has `sql.sql: null` (with `sql.error`); run the Prisma form. ```ts const [query] = toSourceQueries(lens, { now }); -const candidates = await prisma[query.model].findMany(query.prisma); // superset +const { distinct, select, where, steps } = query.prisma; // query.model === 'User' +const candidates = await prisma.user.findMany({ + distinct, + select, + where: steps ? await executePrismaPlan({ steps }, prisma) : where, +}); // a superset when `recheck` is present const rows = query.recheck === undefined ? candidates : await loadFarSide(candidates); // your join const values = materializeSourceQuery(query, rows, { lens, now }); ``` @@ -1365,7 +1378,7 @@ const holds = forRecheck.filter((row) => check(narrowRule(rule, narrowing), row, | Function | Purpose | | --- | --- | -| `toLensSelect(lensOrNarrowing, options?)` | `{ select }` for `findMany` at the base model. It selects each visit's visible columns and the relations turned on there (one that is off is not fetched; the model-default tree bounds it), and every column a `where` on the way reads. A to-many relation carries its visit's clamps compiled as its `where`, so related rows come pre-narrowed — unless a clamp reads that list: a clamp reads it whole, as the database does, so it is fetched whole and `projectRows` cuts it. A to-one relation takes no `where` in Prisma, so `projectRows` drops one its clamp hides. A relation that shows no column — one turned on with all its columns hidden, or one a clamp reads only for presence or a count — is fetched by its key alone — the join key, else `id` — even a hidden one, as a clamp's columns are; never another column. The fetch carries it for the re-check and a viewer's projection drops it; a model with no key is not fetched, and presence on it can't be re-checked from fetched rows. A root that shows no column is selected by its `id` likewise. Bridges are skipped. It also selects what each projected source reads — the value, its label and axes, and every column and relation its option query's condition reads — as it does a clamp's columns, so `materializeSources` over the fetched rows offers what the database does; a viewer's projection drops them. A to-many relation's clamp that needs a counting step (a count or an aggregate), or has a window toPrisma can't compile, is refused (a `LensRefusal`, which `validateNarrowing` reports) before anything compiles. `options` is the clock for compiling the clamps. The root's own clamps are the query's `where`: `toPrisma(rule, { lens })`. | +| `toLensSelect(lensOrNarrowing, options?)` | `{ select }` for `findMany` at the base model. It selects each visit's visible columns and the relations turned on there (one that is off is not fetched; the model-default tree bounds it), and every column a `where` on the way reads. A to-many relation carries its visit's clamps compiled as its `where`, so related rows come pre-narrowed — unless a clamp reads that list: a clamp reads it whole, as the database does, so it is fetched whole and `projectRows` cuts it. A to-one relation takes no `where` in Prisma, so `projectRows` drops one its clamp hides. A relation that shows no column — one turned on with all its columns hidden, or one a clamp reads only for presence or a count — is fetched by its key alone — the join key, else `id` — even a hidden one, as a clamp's columns are; never another column. The fetch carries it for the re-check and a viewer's projection drops it; a model with no key is not fetched, and presence on it can't be re-checked from fetched rows. A root that shows no column is selected by its `id` likewise. Bridges are skipped. It also selects what each projected source reads — the value, its label and axes, and every column and relation its option query's condition reads — as it does a clamp's columns, so `materializeSources` over the fetched rows offers what the database does; a viewer's projection drops them. For a source across a bridge that is its local reads: what it reads on this side, hidden or not, and each crossed bridge's local `on` key, so the rows plus the far side loaded under its bridge field answer it. A to-many relation's clamp that needs a counting step (a count or an aggregate), or has a window toPrisma can't compile, is refused (a `LensRefusal`, which `validateNarrowing` reports) before anything compiles. `options` is the clock for compiling the clamps. The root's own clamps are the query's `where`: `toPrisma(rule, { lens })`. | | `projectRows(lensOrNarrowing, rows, options?)` | Rows cut to what the lens shows, recursively. Hidden columns, and relations that are off or omitted, are removed. A row a visit's `where` hides is dropped from the root or a list, and a to-one row becomes `null`. `keepClampColumns: true` keeps the columns those `where`s and the projected sources read, even hidden ones, and a hidden to-one row, or a hidden row of a list a clamp reads, as those columns alone, so `check(narrowRule(rule, lens), row)` re-tests the clamps as the database does, for any rule the lens admits; that output carries hidden values, so never return it to a viewer. The other options (`now`, `bindings`) are what each `where` is checked with. Plain JSON in and out. | ### Evaluating Across Bridges diff --git a/package.json b/package.json index 9a6ca1e..9682b23 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@inixiative/json-rules", - "version": "3.5.0", + "version": "3.5.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/lensRows.ts b/src/lens/lensRows.ts index 2da2dc7..bd4c67a 100644 --- a/src/lens/lensRows.ts +++ b/src/lens/lensRows.ts @@ -17,7 +17,7 @@ import { } from './policy.ts'; import { compileOrRefuse, prismaRefusal } from './prismaRefusal.ts'; import { readPaths } from './readPaths.ts'; -import { type SourcePlan, sourcePlansWith } from './sourceOptions.ts'; +import { localReads, type SourcePlan, sourcePlansWith } from './sourceOptions.ts'; import type { Lens, LensNarrowing } from './types.ts'; /** A Prisma `select` tree: a column, or a relation with its own `select` and, to-many, `where`. */ @@ -55,22 +55,29 @@ const mergeTrees = (into: PathTree, from: PathTree): PathTree => { const clampPaths = (effect: VisitEffect): string[] => effect.whereClauses.flatMap(readPaths); // What each visit's sources read, by relation path: the value, its label and axes, and their -// condition at the visit — below it only: the fetched tree is the path above. The fetch and a -// re-check keep it as they keep clamp columns — hidden or not, never for a viewer — so -// materializeSources over fetched rows offers what the database does. +// condition at the visit — below it only: the fetched tree is the path above. A bridged source's +// reads are its local ones — a read across the bridge is that bridge's local `on` key — for the +// re-check over the rows with the far side loaded. The fetch and a re-check keep them as they keep +// clamp columns — hidden or not, never for a viewer — so materializeSources over fetched rows +// offers what the database does. type SourceReads = ReadonlyMap; -const sourceReads = (plans: readonly SourcePlan[]): SourceReads => { +const sourceReads = (policy: Policy, plans: readonly SourcePlan[]): SourceReads => { const out = new Map(); for (const plan of plans) { - // A model source's rows aren't the fetched ones, and a bridged one's aren't fetched at all. - if (plan.from || plan.bridged !== undefined) continue; + // A model source's rows aren't the fetched ones. + if (plan.from) continue; const key = plan.path.split('.').slice(1).join('.'); - out.set(key, [ - ...(out.get(key) ?? []), + const reads = [ plan.field, ...(plan.label === undefined ? [] : [plan.label]), ...(plan.groupBy ?? []), ...readPaths(plan.rowWhere), + ]; + out.set(key, [ + ...(out.get(key) ?? []), + ...(plan.bridged === undefined + ? reads + : localReads(policy.lens, plan.visit.mapName, plan.visit.model, reads)), ]); } return out; @@ -219,7 +226,14 @@ export const toLensSelectWith = ( options: LensSelectOptions = {}, plans: readonly SourcePlan[] = sourcePlansWith(policy), ): { select: LensSelect } => { - const select = selectAt(policy, rootVisit(policy), 'declared', {}, options, sourceReads(plans)); + const select = selectAt( + policy, + rootVisit(policy), + 'declared', + {}, + options, + sourceReads(policy, plans), + ); // Prisma can't select nothing: a root that shows no column is fetched by its `id`, hidden or not // (a viewer's projection drops it). A root model with no `id` stays as it is. if (!Object.values(select).some((selected) => selected === true)) { @@ -322,7 +336,7 @@ export const projectRows = ( const root = rootVisit(policy); // A viewer's rows carry nothing a source reads. const reads = keepClampColumns - ? sourceReads(sourcePlansWith(policy)) + ? sourceReads(policy, sourcePlansWith(policy)) : new Map(); return rows.flatMap((row) => { const kept = cutRow(policy, root, row, {}, keepClampColumns, options, reads); diff --git a/src/lens/narrowing.ts b/src/lens/narrowing.ts index eb3a704..97a6ebe 100644 --- a/src/lens/narrowing.ts +++ b/src/lens/narrowing.ts @@ -27,7 +27,6 @@ import { normalizeSource, OFF_PATH, type Policy, - relationHops, resolvePolicy, resolveVisit, type ShownVisit, @@ -37,7 +36,7 @@ import { } from './policy.ts'; import { projectModelsWith } from './projectModels.ts'; import { lensVisitWith, projectPathsWith } from './projectPaths.ts'; -import { type SourcePlan, sourcePlansWith } from './sourceOptions.ts'; +import { type SourcePlan, sourcePlansWith, toOnePathError } from './sourceOptions.ts'; import type { EnumNarrowing, LensNarrowing, @@ -148,29 +147,6 @@ const validateSourceTargetVisibility = ( } }; -// A materialization path — a groupBy axis or a dotted label — descends to-one relations -// only and must land on a scalar/enum column. `kind` names it in the error. -const toOnePathError = ( - path: string, - maps: Record, - mapName: string, - modelName: string, - kind: 'groupBy' | 'label', -): string | null => { - const segments = path.split('.'); - const { hops } = relationHops(maps, { mapName, modelName, relPath: [] }, path); - const toMany = hops.find((hop, i) => hop.isList && i < segments.length - 1); - if (toMany) return `${kind} cannot traverse to-many relation '${segments[hops.indexOf(toMany)]}'`; - if (hops.length === segments.length) - return `${kind} must end on a scalar column, '${segments.at(-1)}' is a relation`; - const at = hops.at(-1) ?? { map: mapName, model: modelName }; - const seg = segments[hops.length]; - const entry = fieldOf(own(maps, at.map), at.model, seg); - if (!entry) return `${kind} segment '${seg}' not on model '${at.model}'`; - if (isRelationEntry(entry)) return `${kind} relation '${seg}' has no resolvable target`; - return hops.length === segments.length - 1 ? null : `${kind} segment '${seg}' is not a relation`; -}; - // Why a field a layer names is already hidden: an ancestor's picks leave a column out or its omits // drop the field, or a path node's own-layer defaults do. Picks never govern a relation. const hiddenBy = ( diff --git a/src/lens/sourceOptions.ts b/src/lens/sourceOptions.ts index 11b7363..7d9cf07 100644 --- a/src/lens/sourceOptions.ts +++ b/src/lens/sourceOptions.ts @@ -1,7 +1,8 @@ import { endpointKey } from '../fieldMap/endpointKey'; +import { isRelationEntry } from '../fieldMap/entry'; import { resolveFieldMap } from '../fieldMap/resolveFieldMap'; -import type { SourceOption } from '../fieldMap/types'; -import { conditionTouchesBridge, hitsBridge } from '../fieldMap/walk'; +import type { FieldMap, SourceOption } from '../fieldMap/types'; +import { conditionTouchesBridge, hitsBridge, walkFieldPath } from '../fieldMap/walk'; import { fieldOf, own } from '../own'; import { readOwnPath } from '../scope'; import { inverseRelation } from '../toPrisma/relationUtils'; @@ -15,14 +16,38 @@ import { projectPathsWith } from './projectPaths.ts'; import { readPaths } from './readPaths.ts'; import type { Lens, LensNarrowing } from './types.ts'; +/** A materialization path — a groupBy axis or a dotted label — descends to-one relations only + * (local or across a bridge) and must land on a scalar/enum column; `kind` names it in the error. + * The one check: validateNarrowing reports it, and every posture's source plan refuses on it. */ +export const toOnePathError = ( + path: string, + maps: Record, + mapName: string, + modelName: string, + kind: 'groupBy' | 'label', +): string | null => { + const segments = path.split('.'); + const { hops } = relationHops(maps, { mapName, modelName, relPath: [] }, path); + const toMany = hops.find((hop, i) => hop.isList && i < segments.length - 1); + if (toMany) return `${kind} cannot traverse to-many relation '${segments[hops.indexOf(toMany)]}'`; + if (hops.length === segments.length) + return `${kind} must end on a scalar column, '${segments.at(-1)}' is a relation`; + const at = hops.at(-1) ?? { map: mapName, model: modelName }; + const seg = segments[hops.length]; + const entry = fieldOf(own(maps, at.map), at.model, seg); + if (!entry) return `${kind} segment '${seg}' not on model '${at.model}'`; + if (isRelationEntry(entry)) return `${kind} relation '${seg}' has no resolvable target`; + return hops.length === segments.length - 1 ? null : `${kind} segment '${seg}' is not a relation`; +}; + /** * Fold the traversal guards a materialization path — a groupBy axis or a dotted label (`kind` * names which) — picks up: every traversed model's effective narrowing `where` (tenancy/soft- * delete) — declared relation nodes AND mapDefaults, composed across all layers via * `resolveVisit` — re-rooted onto the sourced model, the same hop-where fold `narrowRule` * performs for rule paths. The compile always joins every hop the path names, so every hop must - * carry its guard whether or not the narrowing declares it; an unresolvable hop, or a to-many one - * carrying a clamp, is refused. `seen` dedups hops shared across paths: one guard fold per + * carry its guard whether or not the narrowing declares it; a path `toOnePathError` faults (a + * to-many or unresolvable hop) is refused `invalid_source`, as validateNarrowing reports it. `seen` dedups hops shared across paths: one guard fold per * traversed node. */ const foldPathGuards = ( @@ -35,33 +60,20 @@ const foldPathGuards = ( seen: Set, out: Condition[], ): void => { - const segments = dotted.split('.'); + const problem = toOnePathError(dotted, policy.lens.maps, mapName, modelName, kind); + if (problem) throw new LensRefusal(`${kind} '${dotted}': ${problem}`, 'invalid_source'); // The last segment is the column; guards live on the traversed models. const { hops } = relationHops( policy.lens.maps, { mapName, modelName, relPath: baseRelPath }, dotted, ); - const crossed = hops.filter((_, i) => i < segments.length - 1); - if (crossed.length < segments.length - 1) { - const hop = segments[crossed.length]; - const on = crossed.at(-1)?.model ?? modelName; - throw new LensRefusal( - `${kind} '${dotted}': hop '${hop}' is not a resolvable relation on '${on}' — cannot guard its join`, - 'invalid_source', - ); - } + const crossed = hops.slice(0, dotted.split('.').length - 1); for (const hop of crossed) { const hopKey = hop.relPath.join('.'); if (seen.has(hopKey)) continue; seen.add(hopKey); const effect = resolveVisit(policy, hop.map, hop.model, hop.relPath); - // A to-many hop has no single row to AND its clamp against: fail closed, as narrowRule does. - if (hop.isList && effect.whereClauses.length) - throw new LensRefusal( - `${kind} '${dotted}': cannot enforce the clamp on to-many relation '${hop.prefix}' in a dotted path`, - 'unsupported_clamp', - ); for (const where of effect.whereClauses) out.push(prefixConditionFields(where, hop.prefix)); } }; @@ -168,6 +180,43 @@ export const sortOptions = (byKey: Map): SourceOption[] => ); }); +// The local column a read across a bridge needs: the near endpoint's `on` key, under the local +// relations the read crosses before the bridge ('org.crm:Account.tier' → 'org.crmId'). +const bridgeKey = (lens: Lens, map: FieldMap, mapName: string, model: string, read: string) => { + const { hops } = walkFieldPath(read, map, model); + const parts = read.split('.'); + const near = hops.at(-1)?.entry.type ?? model; + const farKey = parts[hops.length]; + for (const { endpoints } of lens.bridges ?? []) { + const [a, b] = endpoints; + for (const [here, there] of [ + [a, b], + [b, a], + ]) + if (here.fieldMap === mapName && here.model === near && endpointKey(there) === farKey) + return [...parts.slice(0, hops.length), here.on].join('.'); + } + throw new LensRefusal(`'${read}' crosses no bridge the lens declares on ${near}`, 'not_in_lens'); +}; + +/** Reads on a source's model as the database holding its side sees them: a read across a bridge + * becomes that bridge's local `on` key — what a re-check needs fetched to load the far side. */ +export const localReads = ( + lens: Lens, + mapName: string, + model: string, + reads: readonly string[], +): string[] => { + const map = resolveFieldMap(lens, mapName, 'toPrisma') as FieldMap; + return [ + ...new Set( + reads.map((read) => + hitsBridge(read, map, model) ? bridgeKey(lens, map, mapName, model, read) : read, + ), + ), + ]; +}; + /** One sourced field to materialize: where it sits, its label and axes, and its eligibility — * its source where(s), the guards of every relation they or the label / axes cross, and, for a * value-gated field, the values the lens allows. */ diff --git a/src/lens/toSourceQueries.ts b/src/lens/toSourceQueries.ts index d70fd8f..c9a8b4f 100644 --- a/src/lens/toSourceQueries.ts +++ b/src/lens/toSourceQueries.ts @@ -1,4 +1,3 @@ -import { endpointKey } from '../fieldMap/endpointKey'; import { resolveFieldMap } from '../fieldMap/resolveFieldMap'; import type { FieldMap } from '../fieldMap/types'; import { walkFieldPath } from '../fieldMap/walk'; @@ -10,10 +9,10 @@ import { escapeIdentifier } from '../toSql/escape.ts'; import { builderState } from '../toSql/index.ts'; import { resolveFieldSql } from '../toSql/join.ts'; import type { Condition, DateConfig } from '../types.ts'; -import { LensRefusal, resolvePolicy } from './policy.ts'; +import { resolvePolicy } from './policy.ts'; import { compileOrRefuse } from './prismaRefusal.ts'; import { readPaths } from './readPaths.ts'; -import { sourcePlans } from './sourceOptions.ts'; +import { localReads, sourcePlans } from './sourceOptions.ts'; import type { Lens, LensNarrowing } from './types.ts'; /** Prisma `select` shape — nested for a grouped source's relation path. */ @@ -28,7 +27,8 @@ export type SourcePrismaQuery = { distinct?: string[]; select: SourceSelect; where: PrismaWhere; - /** Present only if the composed where used count operators (run via executePrismaPlan). */ + /** Present when the where needs `executePrismaPlan`: a count step, or a column reference to + * resolve. Run `executePrismaPlan({ steps }, prisma)` and query with the where it returns. */ steps?: PrismaStep[]; }; @@ -85,25 +85,6 @@ const mergeSelect = (into: SourceSelect, path: string[]): void => { into[head] = { select: nested }; }; -// The local column a read across a bridge needs: the near endpoint's `on` key, under the local -// relations the read crosses before the bridge ('org.crm:Account.tier' → 'org.crmId'). -const bridgeKey = (lens: Lens, map: FieldMap, mapName: string, model: string, read: string) => { - const { hops } = walkFieldPath(read, map, model); - const parts = read.split('.'); - const near = hops.at(-1)?.entry.type ?? model; - const farKey = parts[hops.length]; - for (const { endpoints } of lens.bridges ?? []) { - const [a, b] = endpoints; - for (const [here, there] of [ - [a, b], - [b, a], - ]) - if (here.fieldMap === mapName && here.model === near && endpointKey(there) === farKey) - return [...parts.slice(0, hops.length), here.on].join('.'); - } - throw new LensRefusal(`'${read}' crosses no bridge the lens declares on ${near}`, 'not_in_lens'); -}; - const compileOne = ( lens: Lens, path: string, @@ -122,27 +103,35 @@ const compileOne = ( toPrisma(where, { ...options, map: lens, mapName, model }), ); // A plan ends on its where step. - const prismaWhere = (plan.steps.at(-1) as WhereStep).where; - const groupBySteps = plan.steps.filter((s) => s.operation !== 'where'); + const whereStep = plan.steps.at(-1) as WhereStep; + const prismaWhere = whereStep.where; + // A count step, or a column reference in the where, resolves through executePrismaPlan. + const needsPlan = plan.steps.length > 1 || (whereStep.refs?.length ?? 0) > 0; const map = resolveFieldMap(lens, mapName, 'toPrisma') as FieldMap; const far = (read: string): boolean => recheck !== undefined && walkFieldPath(read, map, model).kind === 'bridge'; // A label or axis across a bridge is read from the far side; the query selects its local key. const localLabel = label !== undefined && !far(label) ? label : undefined; const localAxes = (groupBy ?? []).map((axis) => (far(axis) ? undefined : axis)); + // A read into a Json column selects the column whole: a select can't open a Json value. + const columnOf = (read: string): string => { + const walk = walkFieldPath(read, map, model); + return walk.kind === 'json-path' ? read.split('.').slice(0, walk.stopIndex).join('.') : read; + }; // What the re-check reads here: its local columns, and each bridge's local key. const recheckReads = recheck === undefined ? [] - : [ - ...new Set( - [ - ...readPaths(recheck), - ...(label !== undefined && localLabel === undefined ? [label] : []), - ...(groupBy ?? []).filter(far), - ].map((read) => (far(read) ? bridgeKey(lens, map, mapName, model, read) : read)), - ), - ].filter((read) => read !== field); + : localReads( + lens, + mapName, + model, + [ + ...readPaths(recheck), + ...(label !== undefined && localLabel === undefined ? [label] : []), + ...(groupBy ?? []).filter(far), + ].map(columnOf), + ).filter((read) => read !== field); // A dotted label materializes exactly like a groupBy axis — same nested select // (merged with any axis sharing its prefix), same joined SQL column. const labelPath = localLabel?.includes('.') ? localLabel : undefined; @@ -164,20 +153,21 @@ const compileOne = ( : { distinct: localLabel ? [field, localLabel] : [field] }), select, where: prismaWhere, - ...(groupBySteps.length ? { steps: plan.steps } : {}), + ...(needsPlan ? { steps: plan.steps } : {}), }; let sqlQuery: SourceSqlQuery; try { // Flat SQL rows hold a column of the model, not a relation to load a far side onto. - const nested = recheckReads.find((read) => { + for (const read of recheckReads) { const walk = walkFieldPath(read, map, model); - return walk.kind !== 'direct' || walk.hops.length > 0 || walk.entry.kind === 'object'; - }); - if (nested !== undefined) + if (walk.kind === 'direct' && !walk.hops.length && walk.entry.kind !== 'object') continue; throw new Error( - `the re-check reads '${nested}' through a relation, which flat SQL rows can't hold — run the Prisma query`, + walk.kind === 'direct' + ? `the re-check reads '${read}' through a relation, which flat SQL rows can't hold — run the Prisma query` + : `the re-check reads '${read}', which is not a column of ${model} — run the Prisma query`, ); + } // The where and the materialized columns resolve against one state, so a label or axis // path reuses (and extends) the where's joins. const state = builderState({ ...options, map: lens, mapName, model, alias: 't0' }); diff --git a/src/toPrisma/groupStep.ts b/src/toPrisma/groupStep.ts index 5cbed02..a2ff13f 100644 --- a/src/toPrisma/groupStep.ts +++ b/src/toPrisma/groupStep.ts @@ -29,6 +29,11 @@ export const groupPath = ( ): GroupPath => { const segments = field.split('.'); const walk = walkFieldPath(field, map, model); + // A count or aggregate groups the related rows in one database; across a bridge none holds them. + if (walk.kind === 'bridge') + throw new Error( + `'${field}' crosses a bridge: ${rule.toLowerCase()} aren't supported across a bridge — no one database holds both sides.`, + ); if (walk.kind !== 'direct' || walk.entry.kind !== 'object') { const on = walk.hops.at(-1)?.entry.type ?? model; throw new Error( diff --git a/test/review.351.test.ts b/test/review.351.test.ts new file mode 100644 index 0000000..2f9019f --- /dev/null +++ b/test/review.351.test.ts @@ -0,0 +1,311 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { + type Condition, + createLens, + executePrismaPlan, + type FieldMap, + indexBridges, + type LensNarrowing, + materializeSourceQuery, + materializeSources, + projectRows, + type SourceEntry, + type SourceQuery, + toLensSelect, + toSourceQueries, + validateNarrowing, +} from '../index'; +import { map, openRails } from './rails/harness'; + +// 3.5.1: fail-closed and doc findings from the 3.5.0 adversarial review. + +const rule = (r: object): Condition => r as Condition; +const s = (t: string) => ({ kind: 'scalar', type: t }) as const; + +let rails: Awaited>; +beforeAll(async () => { + rails = await openRails(); +}); +afterAll(async () => { + await rails.close(); +}); + +const crm: FieldMap = { + models: { Profile: { fields: { id: s('Int'), userId: s('Int'), tier: s('String') } } }, +}; +const bridges = [ + { + endpoints: [ + { fieldMap: 'prisma', model: 'User', on: 'id' }, + { fieldMap: 'crm', model: 'Profile', on: 'userId' }, + ], + cardinality: 'oneToOne', + }, +]; +const maps = { prisma: map, crm }; +const profiles = [ + { id: 1, userId: 1, tier: 'gold' }, + { id: 2, userId: 2, tier: 'silver' }, + { id: 3, userId: 4, tier: 'gold' }, +]; +const users = createLens({ maps, bridges, mapName: 'prisma', model: 'User' } as never); +const dict = indexBridges({ maps, bridges } as never, { 'crm:Profile': profiles }); +const profileOf = (row: Record) => + (dict.crm.Profile.userId as Record)[String(row.id)] ?? null; +const withProfile = (row: Record) => ({ ...row, 'crm:Profile': profileOf(row) }); + +describe('toLensSelect selects the local columns a bridged source re-checks', () => { + // `age` is hidden; the source reads it locally and the tier across the bridge. + const lens: LensNarrowing = { + parent: users, + root: { + omits: ['age'], + relations: { 'crm:Profile': {} }, + sources: { + name: rule({ + any: [ + { field: 'age', operator: 'greaterThan', value: 35 }, + { field: 'crm:Profile.tier', operator: 'equals', value: 'gold' }, + ], + }), + }, + }, + }; + + test('materializeSources over toLensSelect rows + far side answers it', async () => { + expect(validateNarrowing(lens).ok).toBe(true); + const { select } = toLensSelect(lens); + expect(select).toMatchObject({ age: true, id: true, name: true }); + const fetched = (await rails.prisma.user.findMany({ select } as never)) as Record< + string, + unknown + >[]; + // Ann and Dee are gold; Dee is 40. + expect( + materializeSources(lens, fetched.map(withProfile))[0].options.map((o) => o.value), + ).toEqual(['Ann', 'Dee']); + // As the re-check keeps them too. + const kept = projectRows(lens, fetched, { keepClampColumns: true }).map(withProfile); + expect(materializeSources(lens, kept)[0].options.map((o) => o.value)).toEqual(['Ann', 'Dee']); + }); + + test("a viewer's projection never carries the fetch-only column", async () => { + const { select } = toLensSelect(lens); + const fetched = (await rails.prisma.user.findMany({ select } as never)) as Record< + string, + unknown + >[]; + for (const row of projectRows(lens, fetched)) expect(Object.hasOwn(row, 'age')).toBe(false); + }); +}); + +describe('a label or axis across a to-many relation: validation and every posture refuse alike', () => { + const events: FieldMap = { + models: { Event: { fields: { id: s('Int'), userId: s('Int'), kind: s('String') } } }, + }; + const manyBridges = [ + { + endpoints: [ + { fieldMap: 'prisma', model: 'User', on: 'id' }, + { fieldMap: 'crm', model: 'Event', on: 'userId' }, + ], + cardinality: 'oneToMany', + }, + ]; + const withEvents = createLens({ + maps: { prisma: map, crm: events }, + bridges: manyBridges, + mapName: 'prisma', + model: 'User', + } as never); + const at = (parent: typeof users, relation: string, source: SourceEntry): LensNarrowing => ({ + parent, + root: { relations: { [relation]: {} }, sources: { name: source } }, + }); + const cases: [string, LensNarrowing][] = [ + ['a local label', at(users, 'posts', { label: 'posts.title' })], + ['a local axis', at(users, 'posts', { groupBy: 'posts.title' })], + ['a bridged label', at(withEvents, 'crm:Event', { label: 'crm:Event.kind' })], + ['a bridged axis', at(withEvents, 'crm:Event', { groupBy: 'crm:Event.kind' })], + ]; + const refusal = (run: () => unknown): { name: string; code?: string } | null => { + try { + run(); + return null; + } catch (error) { + return error as { name: string; code?: string }; + } + }; + + test.each(cases)('%s', (_, lens) => { + const result = validateNarrowing(lens); + expect(result.ok).toBe(false); + expect(result.errors.map((e) => e.code)).toContain('invalid_source'); + expect(result.errors.filter((e) => /to-many/.test(e.message)).length).toBe(1); + for (const run of [() => toSourceQueries(lens), () => materializeSources(lens, [])]) { + const thrown = refusal(run); + expect(thrown?.name).toBe('LensRefusal'); + expect(thrown?.code).toBe('invalid_source'); + } + }); + + test('validateNarrowing ok exactly when no posture refuses', () => { + const fine: LensNarrowing[] = [ + at(users, 'org', { label: 'org.name' }), + at(users, 'org', { groupBy: 'org.plan' }), + at(withEvents, 'crm:Event', true), + ]; + for (const lens of [...fine, ...cases.map(([, lens]) => lens)]) { + const refused = + refusal(() => toSourceQueries(lens)) !== null || + refusal(() => materializeSources(lens, [])) !== null; + expect(validateNarrowing(lens).ok).toBe(!refused); + } + }); +}); + +describe('a re-check reading JSON selects the Json column whole', () => { + const tierGold = { field: 'crm:Profile.tier', operator: 'equals', value: 'gold' }; + const source = (local: object): LensNarrowing => ({ + parent: users, + root: { + relations: { 'crm:Profile': {}, org: {} }, + sources: { name: rule({ any: [local, tierGold] }) }, + }, + }); + + test('a local Json read: the column, on both rails', async () => { + const lens = source({ + all: [ + { field: 'meta.a.b', operator: 'equals', value: 'X' }, + { field: 'meta.n', operator: 'isEmpty' }, + ], + }); + const [query] = toSourceQueries(lens); + expect(query.prisma.select).toEqual({ name: true, meta: true, id: true }); + expect(query.sql.sql).not.toBeNull(); + const { model: _model, steps: _steps, ...args } = query.prisma; + const prisma = (await rails.prisma.user.findMany(args as never)) as Record[]; + const sql = (await rails.db.query(query.sql.sql as string, query.sql.params)).rows as Record< + string, + unknown + >[]; + // Dee's meta.a.b is 'X'; Ann and Dee are gold. + for (const [shape, rows] of [ + ['prisma', prisma], + ['sql', sql], + ] as const) + expect( + materializeSourceQuery(query, rows.map(withProfile), { lens, rowShape: shape }).options.map( + (o) => o.value, + ), + ).toEqual(['Ann', 'Dee']); + }); + + test('a Json read through a relation: the column under it, and SQL says relation', async () => { + const lens = source({ field: 'org.settings.tier', operator: 'equals', value: 'gold' }); + const [query] = toSourceQueries(lens); + expect(query.prisma.select).toEqual({ + name: true, + org: { select: { settings: true } }, + id: true, + }); + expect(query.sql.error).toMatch(/reads 'org\.settings' through a relation/); + const { model: _model, steps: _steps, ...args } = query.prisma; + const rows = (await rails.prisma.user.findMany(args as never)) as Record[]; + // Ann's org (Acme) is gold; Dee is gold in the CRM. + expect( + materializeSourceQuery(query, rows.map(withProfile), { lens }).options.map((o) => o.value), + ).toEqual(['Ann', 'Dee']); + }); +}); + +test('a count operator across a bridge says a bridge, not "not a relation"', () => { + const events: FieldMap = { + models: { Event: { fields: { id: s('Int'), userId: s('Int'), kind: s('String') } } }, + }; + const lens: LensNarrowing = { + parent: createLens({ + maps: { prisma: map, crm: events }, + bridges: [ + { + endpoints: [ + { fieldMap: 'prisma', model: 'User', on: 'id' }, + { fieldMap: 'crm', model: 'Event', on: 'userId' }, + ], + cardinality: 'oneToMany', + }, + ], + mapName: 'prisma', + model: 'User', + } as never), + root: { + relations: { 'crm:Event': {} }, + sources: { + name: rule({ + field: 'crm:Event', + arrayOperator: 'atLeast', + count: 2, + condition: { field: 'kind', operator: 'equals', value: 'x' }, + }), + }, + }, + }; + const [issue] = validateNarrowing(lens).errors; + expect(issue.message).toMatch(/count operators aren't supported across a bridge/); + expect(issue.message).not.toMatch(/not a relation/); + expect(() => toSourceQueries(lens)).toThrow(/count operators aren't supported across a bridge/); +}); + +describe('the README source-query snippet runs as written', () => { + // README "Sources across a bridge" (and the snippet above it), against the rails client. + const runSnippet = async ( + prisma: typeof rails.prisma, + query: SourceQuery, + lens: LensNarrowing, + loadFarSide: (rows: Record[]) => Promise[]>, + ) => { + const { distinct, select, where, steps } = query.prisma; // query.model === 'User' + const candidates = await prisma.user.findMany({ + distinct, + select, + where: steps ? await executePrismaPlan({ steps }, prisma as never) : where, + } as never); + const rows = query.recheck === undefined ? candidates : await loadFarSide(candidates); + return materializeSourceQuery(query, rows, { lens }); + }; + const load = async (rows: Record[]) => rows.map(withProfile); + + test.each<[string, object, string[]]>([ + [ + 'a plain where', + { field: 'age', operator: 'greaterThan', value: 6 }, + ['Ann', 'Dee', 'nullable'], + ], + [ + 'a bridged where (recheck)', + { field: 'crm:Profile.tier', operator: 'equals', value: 'gold' }, + ['Ann', 'Dee'], + ], + [ + 'a count step', + { + field: 'posts', + arrayOperator: 'atLeast', + count: 2, + condition: { field: 'views', operator: 'greaterThan', value: 0 }, + }, + ['Ann'], + ], + ['a column reference', { field: 'age', operator: 'greaterThan', path: '$.orgId' }, ['Ann']], + ])('%s', async (_, where, truth) => { + const lens: LensNarrowing = { + parent: users, + root: { relations: { 'crm:Profile': {}, posts: {} }, sources: { name: rule(where) } }, + }; + expect(validateNarrowing(lens).ok).toBe(true); + const [query] = toSourceQueries(lens); + const { options } = await runSnippet(rails.prisma, query, lens, load); + expect(options.map((o) => o.value)).toEqual(truth); + }); +});