From 0d04df56f0f53d7ee996e32a12ba9f01fd7e0142 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 10:46:57 +0000 Subject: [PATCH] docs(spec): spell the FieldReference @example as a same-table comparand `FieldReferenceSchema`'s first `@example` spelled its `{ $field }` comparand as the relation path `order.owner_id`, captioned as a join ON clause, while the same docblock's "Execution support" prose says a dotted path is refused by SQL push-down with `INVALID_FILTER`. Running it establishes which half was wrong: the schema admits either spelling, the memory evaluator answers `false` for the dotted one on a flat row, the SQL compiler refuses it, and the ON clause the caption framed it as no longer exists (`query.joins` was removed in #4286). The example is now the same-table comparison both execution paths compile, and the block header no longer advertises a join surface. A pin holds the block's examples runnable and holds every `@example` in the file to a same-table `$field` value, probing on the claim rather than on one spelling: it is case-insensitive, quote-agnostic, and refuses `.` and `/` alike. Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude --- .changeset/spicy-pears-count.md | 7 ++ packages/spec/src/data/filter.test.ts | 92 +++++++++++++++++++++++++++ packages/spec/src/data/filter.zod.ts | 13 ++-- 3 files changed, 108 insertions(+), 4 deletions(-) create mode 100644 .changeset/spicy-pears-count.md diff --git a/.changeset/spicy-pears-count.md b/.changeset/spicy-pears-count.md new file mode 100644 index 00000000000..3476abdd66c --- /dev/null +++ b/.changeset/spicy-pears-count.md @@ -0,0 +1,7 @@ +--- +'@objectstack/spec': patch +--- + +Correct `FieldReferenceSchema`'s first TSDoc `@example`: a `{ $field }` comparand names a column of the SAME row, never a relation path. + +The example spelled its comparand as `{ "$eq": { "$field": "order.owner_id" } }` and captioned it as a join ON clause, while the same docblock's "Execution support" prose states that a dotted path is refused by SQL push-down with `INVALID_FILTER` (HTTP 400). Copied as written it does not fail at the schema door — both spellings parse — so it fails later and quietly: the in-memory evaluator answers `false` for a flat row, and SQL push-down refuses. The ON clause it advertised no longer exists either; `query.joins` was removed and related records are read through `expand`. The example is now the same-table cross-field comparison both execution paths compile, and the docblock header no longer advertises a join surface. `@objectstack/spec` publishes `src/**/*.zod.ts`, so this docblock ships to authors and to IDE hover. diff --git a/packages/spec/src/data/filter.test.ts b/packages/spec/src/data/filter.test.ts index 3df71c9e625..e0cfefde1d1 100644 --- a/packages/spec/src/data/filter.test.ts +++ b/packages/spec/src/data/filter.test.ts @@ -1,4 +1,7 @@ import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { FilterConditionSchema, QueryFilterSchema, @@ -1834,3 +1837,92 @@ describe('FieldReferenceSchema.addDays (#14104)', () => { }); }); }); + +// ============================================================================ +// [#16923] The docblock @examples are RUNNABLE, and same-table +// ============================================================================ + +/** + * [#16923] `FieldReferenceSchema`'s FIRST `@example` used to spell its `$field` + * comparand as the RELATION path `order.owner_id`, captioned as a join ON + * clause — while the same block's "Execution support" prose says a dotted path + * is refused by SQL push-down with `INVALID_FILTER`, and while `query.joins` + * (the only ON clause this protocol ever had) was removed in #4286. The + * example was the wrong half, established by RUNNING it rather than reading + * it: the schema door admits either spelling, so nothing here can be pinned by + * `safeParse` alone — the memory evaluator answers `false` for the dotted + * spelling on a flat row and the SQL compiler refuses it + * (`sql-driver-cross-field-reference.test.ts`, "a dotted relation path"). + * + * These pins hold the docblock to its own prose from two directions: + * + * - The `FieldReferenceSchema` block's examples PARSE, on both the + * documentation copy and the enforced copy — an example nobody can run is + * how the previous one drifted. + * - No `@example` ANYWHERE in `filter.zod.ts` spells a `$field` value as a + * path. The probe is on the CLAIM (what does an example say a comparand + * looks like), not on one spelling: it is case-insensitive, quote-agnostic, + * and refuses `.` and `/` alike, so a slash-separated or unbackticked + * respelling trips it too. + */ +describe('filter.zod.ts docblock @examples (#16923)', () => { + const HERE = dirname(fileURLToPath(import.meta.url)); + const SOURCE = readFileSync(resolve(HERE, 'filter.zod.ts'), 'utf8'); + + /** + * Every `@example` body in the file, as the raw comment text following the + * tag. An example body ends at the blank continuation line (` *`) that this + * file's convention puts after every one, or at the end of the docblock — + * so the body is the caption and the payload, never the prose after it. + */ + function exampleBlocks(source: string): string[] { + return source + .split('@example') + .slice(1) + .map((rest) => rest.split(/^[ \t]*\*[ \t]*$|\*\//m)[0]); + } + + /** The JSON payload lines of one `@example` body — the lines that are the example. */ + function payloads(block: string): string[] { + return block + .split('\n') + .map((line) => line.replace(/^\s*\*\s?/, '').trim()) + .filter((line) => line.startsWith('{')); + } + + /** Every `$field` VALUE inside a chunk of example text, any quote style, any case. */ + function fieldValues(text: string): string[] { + return [...text.matchAll(/["']?\$field["']?\s*:\s*["']([^"']*)["']/gi)].map((m) => m[1]); + } + + const blocks = exampleBlocks(SOURCE); + + it('lit control — the file really has @example blocks carrying $field values', () => { + // A zero below must mean "no path spellings", never "nothing was read". + expect(blocks.length).toBeGreaterThan(1); + expect(fieldValues(blocks.join('\n')).length).toBeGreaterThan(1); + // Dark control — a spelling that is not in the file returns nothing. + expect(fieldValues('{ "$fieldd": "order.owner_id" }')).toEqual([]); + }); + + it('the FieldReferenceSchema block\'s examples parse — documentation copy AND enforced copy', () => { + const block = SOURCE.slice(0, SOURCE.indexOf('export const FieldReferenceSchema')); + const examples = exampleBlocks(block).flatMap(payloads); + expect(examples.length).toBeGreaterThanOrEqual(2); + for (const line of examples) { + const value: unknown = JSON.parse(line); + expect(ComparisonOperatorSchema.safeParse(value).success, line).toBe(true); + expect(FieldOperatorsSchema.safeParse(value).success, line).toBe(true); + // …and the whole thing is a legal condition on a field, which is where an + // author copies it to. + expect(FilterConditionSchema.safeParse({ amount: value }).success, line).toBe(true); + } + }); + + it('no @example in the file spells a $field comparand as a path (dot OR slash)', () => { + const offenders = blocks + .flatMap((block) => fieldValues(block).map((value) => ({ block: block.trim().slice(0, 80), value }))) + .filter(({ value }) => /[./]/.test(value)); + expect(offenders, 'a $field comparand is a column of the SAME row').toEqual([]); + }); +}); diff --git a/packages/spec/src/data/filter.zod.ts b/packages/spec/src/data/filter.zod.ts index 866ae925ab9..0783f9256dd 100644 --- a/packages/spec/src/data/filter.zod.ts +++ b/packages/spec/src/data/filter.zod.ts @@ -29,12 +29,17 @@ import { bareDateRangePresetComparandMessage, isDateRangePresetName } from './da /** * Field Reference - * Represents a reference to another field/column instead of a literal value. - * Used for joins (ON clause) and cross-field comparisons. + * Represents a reference to another COLUMN OF THE SAME ROW instead of a + * literal value. Used for cross-field comparisons. There is no ON clause to + * write one into: `query.joins` was removed (#4286, ADR-0049) and related + * records are read through `expand`, so a reference naming a relation path + * (`order.owner_id`) is not a join — it is the dotted spelling "Execution + * support" below says SQL push-down refuses with `INVALID_FILTER`. * * @example - * // user.id = order.owner_id - * { "$eq": { "$field": "order.owner_id" } } + * // amount > budget — a SAME-TABLE cross-field comparison, the shape both + * // execution paths compile (`cross-field-conformance-cases.ts` pins the rows) + * { "$gt": { "$field": "budget" } } * * @example * // completed_at <= due_date + grace_days (#14104 — a SAME-TABLE offset