Skip to content

Commit 025588a

Browse files
os-billclaude
andauthored
docs(spec): spell the FieldReference @example as a same-table comparand (#17394)
`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 <noreply@anthropic.com>
1 parent 776d64c commit 025588a

3 files changed

Lines changed: 108 additions & 4 deletions

File tree

‎.changeset/spicy-pears-count.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
Correct `FieldReferenceSchema`'s first TSDoc `@example`: a `{ $field }` comparand names a column of the SAME row, never a relation path.
6+
7+
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.

‎packages/spec/src/data/filter.test.ts‎

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
import { describe, it, expect } from 'vitest';
2+
import { readFileSync } from 'node:fs';
3+
import { dirname, resolve } from 'node:path';
4+
import { fileURLToPath } from 'node:url';
25
import {
36
FilterConditionSchema,
47
QueryFilterSchema,
@@ -1834,3 +1837,92 @@ describe('FieldReferenceSchema.addDays (#14104)', () => {
18341837
});
18351838
});
18361839
});
1840+
1841+
// ============================================================================
1842+
// [#16923] The docblock @examples are RUNNABLE, and same-table
1843+
// ============================================================================
1844+
1845+
/**
1846+
* [#16923] `FieldReferenceSchema`'s FIRST `@example` used to spell its `$field`
1847+
* comparand as the RELATION path `order.owner_id`, captioned as a join ON
1848+
* clause — while the same block's "Execution support" prose says a dotted path
1849+
* is refused by SQL push-down with `INVALID_FILTER`, and while `query.joins`
1850+
* (the only ON clause this protocol ever had) was removed in #4286. The
1851+
* example was the wrong half, established by RUNNING it rather than reading
1852+
* it: the schema door admits either spelling, so nothing here can be pinned by
1853+
* `safeParse` alone — the memory evaluator answers `false` for the dotted
1854+
* spelling on a flat row and the SQL compiler refuses it
1855+
* (`sql-driver-cross-field-reference.test.ts`, "a dotted relation path").
1856+
*
1857+
* These pins hold the docblock to its own prose from two directions:
1858+
*
1859+
* - The `FieldReferenceSchema` block's examples PARSE, on both the
1860+
* documentation copy and the enforced copy — an example nobody can run is
1861+
* how the previous one drifted.
1862+
* - No `@example` ANYWHERE in `filter.zod.ts` spells a `$field` value as a
1863+
* path. The probe is on the CLAIM (what does an example say a comparand
1864+
* looks like), not on one spelling: it is case-insensitive, quote-agnostic,
1865+
* and refuses `.` and `/` alike, so a slash-separated or unbackticked
1866+
* respelling trips it too.
1867+
*/
1868+
describe('filter.zod.ts docblock @examples (#16923)', () => {
1869+
const HERE = dirname(fileURLToPath(import.meta.url));
1870+
const SOURCE = readFileSync(resolve(HERE, 'filter.zod.ts'), 'utf8');
1871+
1872+
/**
1873+
* Every `@example` body in the file, as the raw comment text following the
1874+
* tag. An example body ends at the blank continuation line (` *`) that this
1875+
* file's convention puts after every one, or at the end of the docblock —
1876+
* so the body is the caption and the payload, never the prose after it.
1877+
*/
1878+
function exampleBlocks(source: string): string[] {
1879+
return source
1880+
.split('@example')
1881+
.slice(1)
1882+
.map((rest) => rest.split(/^[ \t]*\*[ \t]*$|\*\//m)[0]);
1883+
}
1884+
1885+
/** The JSON payload lines of one `@example` body — the lines that are the example. */
1886+
function payloads(block: string): string[] {
1887+
return block
1888+
.split('\n')
1889+
.map((line) => line.replace(/^\s*\*\s?/, '').trim())
1890+
.filter((line) => line.startsWith('{'));
1891+
}
1892+
1893+
/** Every `$field` VALUE inside a chunk of example text, any quote style, any case. */
1894+
function fieldValues(text: string): string[] {
1895+
return [...text.matchAll(/["']?\$field["']?\s*:\s*["']([^"']*)["']/gi)].map((m) => m[1]);
1896+
}
1897+
1898+
const blocks = exampleBlocks(SOURCE);
1899+
1900+
it('lit control — the file really has @example blocks carrying $field values', () => {
1901+
// A zero below must mean "no path spellings", never "nothing was read".
1902+
expect(blocks.length).toBeGreaterThan(1);
1903+
expect(fieldValues(blocks.join('\n')).length).toBeGreaterThan(1);
1904+
// Dark control — a spelling that is not in the file returns nothing.
1905+
expect(fieldValues('{ "$fieldd": "order.owner_id" }')).toEqual([]);
1906+
});
1907+
1908+
it('the FieldReferenceSchema block\'s examples parse — documentation copy AND enforced copy', () => {
1909+
const block = SOURCE.slice(0, SOURCE.indexOf('export const FieldReferenceSchema'));
1910+
const examples = exampleBlocks(block).flatMap(payloads);
1911+
expect(examples.length).toBeGreaterThanOrEqual(2);
1912+
for (const line of examples) {
1913+
const value: unknown = JSON.parse(line);
1914+
expect(ComparisonOperatorSchema.safeParse(value).success, line).toBe(true);
1915+
expect(FieldOperatorsSchema.safeParse(value).success, line).toBe(true);
1916+
// …and the whole thing is a legal condition on a field, which is where an
1917+
// author copies it to.
1918+
expect(FilterConditionSchema.safeParse({ amount: value }).success, line).toBe(true);
1919+
}
1920+
});
1921+
1922+
it('no @example in the file spells a $field comparand as a path (dot OR slash)', () => {
1923+
const offenders = blocks
1924+
.flatMap((block) => fieldValues(block).map((value) => ({ block: block.trim().slice(0, 80), value })))
1925+
.filter(({ value }) => /[./]/.test(value));
1926+
expect(offenders, 'a $field comparand is a column of the SAME row').toEqual([]);
1927+
});
1928+
});

‎packages/spec/src/data/filter.zod.ts‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,17 @@ import { bareDateRangePresetComparandMessage, isDateRangePresetName } from './da
2929

3030
/**
3131
* Field Reference
32-
* Represents a reference to another field/column instead of a literal value.
33-
* Used for joins (ON clause) and cross-field comparisons.
32+
* Represents a reference to another COLUMN OF THE SAME ROW instead of a
33+
* literal value. Used for cross-field comparisons. There is no ON clause to
34+
* write one into: `query.joins` was removed (#4286, ADR-0049) and related
35+
* records are read through `expand`, so a reference naming a relation path
36+
* (`order.owner_id`) is not a join — it is the dotted spelling "Execution
37+
* support" below says SQL push-down refuses with `INVALID_FILTER`.
3438
*
3539
* @example
36-
* // user.id = order.owner_id
37-
* { "$eq": { "$field": "order.owner_id" } }
40+
* // amount > budget — a SAME-TABLE cross-field comparison, the shape both
41+
* // execution paths compile (`cross-field-conformance-cases.ts` pins the rows)
42+
* { "$gt": { "$field": "budget" } }
3843
*
3944
* @example
4045
* // completed_at <= due_date + grace_days (#14104 — a SAME-TABLE offset

0 commit comments

Comments
 (0)