From 68febc12d291687b2f4e9cff2a4ee6a8416fe4a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 02:46:24 +0000 Subject: [PATCH 1/4] wip(core,objectql): judge a resolved relative-date placeholder by its column's years Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG Co-authored-by: Claude --- packages/core/src/utils/filter-tokens.ts | 27 +- packages/objectql/src/engine.ts | 71 ++++- .../objectql/src/temporal-comparand-door.ts | 280 +++++++++++++++--- 3 files changed, 326 insertions(+), 52 deletions(-) diff --git a/packages/core/src/utils/filter-tokens.ts b/packages/core/src/utils/filter-tokens.ts index f000fa5d1b2..95d178a361c 100644 --- a/packages/core/src/utils/filter-tokens.ts +++ b/packages/core/src/utils/filter-tokens.ts @@ -31,6 +31,12 @@ * a driver-native value here would fork that convention into a second source of * truth and break the moment a query crosses datasources. * + * [#20844] A day outside the years 0001..9999 has no `YYYY-MM-DD` form; it is + * spelled in the expanded-year form instead (`+010026-10-01`), so every reader + * reads the year it resolved to — see {@link asYmd}. Which years a field takes + * is the engine's question, not this module's: it refuses a resolved token + * outside its field's years. + * * # Period `_end` resolves to a calendar DAY — its WIDTH is ADR-0053 D-D * * `{current_year_end}` resolves to `2026-12-31`, per the spec's own @@ -92,7 +98,7 @@ import { type DateMacroUnit, } from '@objectstack/spec/data'; import { calendarPartsInTzOrUtc, wallClockToUtcMs } from './datetime.js'; -import { temporalStorageForm } from './temporal-storage-form.js'; +import { isOutsideTemporalYearRange, temporalStorageForm } from './temporal-storage-form.js'; /** * The slice of an execution context the resolver reads. Structural on purpose — @@ -202,8 +208,25 @@ function proxyDay(now: Date, timezone?: string): Date { * day). [#20599] Before the proxy dates kept their year, a step into * 0001..0099 came out in the 1900s instead, so this spelling was never reached * for those years; a step into 0100..0999 was already spelled unpadded. + * + * [#20844] A day outside 0001..9999 has no `YYYY-MM-DD` form, and the storage + * rule's spelling of one (`10026-10-01`, `0-10-01`, `-1-10-01`) is read by + * nothing as the day it names: `Date.parse` takes it through the host's + * legacy parser, in the host's zone, and reads `-1-10-01` as 2001-01-10. So + * `{2027_years_ago}` was judged inside the range by core's + * `isOutsideTemporalYearRange` and compared as a day in 2001. Such a day is + * spelled in the expanded-year form of ECMAScript's date time string format + * instead (`+010026-10-01`, `-000001-10-01`), the day half of what + * `toISOString` spells for its instant: every reader reads it as that UTC day, + * on every host. The range is core's one range, asked of the day itself, never + * re-derived here. A step past the instants a `Date` holds is not one of these: + * it has no day at all, and keeps the rule's spelling of an invalid `Date`. */ -const asYmd = (d: Date): string => String(temporalStorageForm(d, 'date')); +function asYmd(d: Date): string { + if (!isOutsideTemporalYearRange(d, 'date')) return String(temporalStorageForm(d, 'date')); + const iso = d.toISOString(); + return iso.slice(0, iso.indexOf('T')); +} type PeriodKind = 'week' | 'month' | 'quarter' | 'year'; diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 743ff917c84..1be49a6558b 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -49,8 +49,11 @@ import { MAX_BULK_PER_ROW_HOOK_ROWS, resolveBulkPerRowHookBudget } from '@object import { ActionActivationProjection, type ActionActivationRow, type ActionActivationStore } from './action-activation.js'; import { assertListComparandShapes, assertFilterIsMaterializable, invalidFilterError } from './filter-comparand-shape.js'; import { + assertHavingResolvedTemporalTokensInRange, assertHavingTemporalComparandsInterpretable, + assertResolvedTemporalTokensInRange, assertTemporalComparandsInterpretable, + type ResolvedTokenJudge, } from './temporal-comparand-door.js'; import { assertTextOperatorTargetsAreStringCapable } from './text-operator-declared-type-door.js'; import { @@ -1158,13 +1161,38 @@ function lowerWhereFilterArray( * context carries no value for throws `FILTER_TOKEN_UNRESOLVED`. Neither ever * resolves to `null` (see `@objectstack/core`'s `filter-tokens.ts`). * - * Returns the input by reference when it holds no placeholder. + * [#20844] …and judges what resolved: `judgeResolved` is the position's + * {@link ResolvedTokenJudge}, handed the condition before and after, and a + * date macro that resolved outside its column's years is refused there + * (`INVALID_FILTER` / 400). Required, so no position resolves without it; the + * resolver is field-agnostic, so the column's kind comes from the position. + * + * Returns the input by reference when it holds no placeholder, and then + * judges nothing. */ function resolveWhereFilterTokens( where: W, context: Parameters[0], + judgeResolved: ResolvedTokenJudge, ): W { - return resolveFilterTokens(where, filterTokenContextFrom(context)); + const resolved = resolveFilterTokens(where, filterTokenContextFrom(context)); + if (resolved !== where) judgeResolved(where, resolved); + return resolved; +} + +/** + * [#20844] The `where` position's {@link ResolvedTokenJudge}: each key's kind + * is its declared field's in `schema`. `path` roots the refusal, as the door's + * does — `aggregations[i].filter` for a per-aggregation filter. + */ +function whereResolvedTokenJudge( + object: string, + operation: string, + schema: unknown, + path = 'where', +): ResolvedTokenJudge { + return (written, resolved) => + assertResolvedTemporalTokensInRange(object, operation, schema, written, resolved, path); } /** @@ -1194,14 +1222,18 @@ function resolveWhereFilterTokens( * field map has no declarations to read, and there the rule applies * type-blind (item 7's other half). * + * [#20844] `judgeResolved` is the position's other declared-type reader: the + * year range of what resolved ({@link resolveWhereFilterTokens}). + * * Returns the input by reference when nothing resolved and nothing lowered. */ function resolveThenLowerWhere( where: W, context: Parameters[0], lowering: FilterLoweringOptions, + judgeResolved: ResolvedTokenJudge, ): W { - return lowerFilterCondition(resolveWhereFilterTokens(where, context), lowering); + return lowerFilterCondition(resolveWhereFilterTokens(where, context, judgeResolved), lowering); } /** @@ -1269,7 +1301,8 @@ function admissionRefusalOf( * (#20351) → `normalizeFilterComparandTypes`, or (array form) `isFilterAST` * → `parseFilterAST` → the same four field-map doors on the lowered * condition. - * 2. {@link resolveWhereFilterTokens}: the placeholder resolver. + * 2. {@link resolveWhereFilterTokens}: the placeholder resolver, and [#20844] + * the year range of each date macro it resolved. * * [#20802] Execution then lowers each nested-relation condition by READING * the related object (`ObjectQL.lowerRelationConditions`); that read admits the @@ -1307,7 +1340,9 @@ function judgeWhereAdmission( ): EngineFilterJudgement { try { const admitted = lowerWhereFilterArray(object, operation, { where }, schema, schemaOf); - const resolved = resolveWhereFilterTokens(admitted.where, context); + const resolved = resolveWhereFilterTokens( + admitted.where, context, whereResolvedTokenJudge(object, operation, schema), + ); // [#20802] Each nested-relation condition the door admitted: execution // reads the related object with it (`ObjectQL.lowerRelationConditions`), // and that read admits it through the related object's own doors and @@ -11253,7 +11288,15 @@ export class ObjectQL implements IObjectQLEngine { if (!ast || ast[position] == null) return; // [#20157] Through the stage function the judge also calls. if (position === 'having') { - ast[position] = resolveThenLowerWhere(ast[position], execCtx, lowering); + // [#20844] The year range of a resolved date macro, by each aggregated + // column's class — the reading the `having` temporal door takes. Built + // only when something resolved. + ast[position] = resolveThenLowerWhere(ast[position], execCtx, lowering, (written, resolved) => { + const fields = (this._registry.getObject(ast.object) as { fields?: Record } | undefined)?.fields; + assertHavingResolvedTemporalTokensInRange( + ast.object, written, resolved, aggregatedRowColumnClasses(ast.groupBy, ast.aggregations, fields), ast, + ); + }); return; } // [#20802] `where` serves the nested-relation form: resolve, then lower @@ -11284,7 +11327,9 @@ export class ObjectQL implements IObjectQLEngine { execCtx: ExecutionContext | undefined, lowering: FilterLoweringOptions, ): Promise { - const resolved = resolveWhereFilterTokens(where, execCtx); + const resolved = resolveWhereFilterTokens( + where, execCtx, whereResolvedTokenJudge(object, operation, this._registry.getObject(object)), + ); const related = await this.lowerRelationConditions(object, operation, resolved, execCtx); return lowerFilterCondition(related, lowering); } @@ -17167,10 +17212,13 @@ export class ObjectQL implements IObjectQLEngine { { const astAggs = (opCtx.ast as QueryAST).aggregations; if (Array.isArray(astAggs) && astAggs.some((a) => (a as { filter?: unknown })?.filter != null)) { - (opCtx.ast as QueryAST).aggregations = astAggs.map((a) => { + (opCtx.ast as QueryAST).aggregations = astAggs.map((a, i) => { const f = (a as { filter?: unknown })?.filter; if (f == null) return a; - const resolved = resolveThenLowerWhere(f as any, opCtx.context, rowLowering); + const resolved = resolveThenLowerWhere( + f as any, opCtx.context, rowLowering, + whereResolvedTokenJudge(object, 'aggregate', this._registry.getObject(object), `aggregations[${i}].filter`), + ); return resolved === f ? a : { ...(a as object), filter: resolved } as typeof a; }); } @@ -17182,8 +17230,9 @@ export class ObjectQL implements IObjectQLEngine { // one call covers the native and the rows path, before any driver read. // After every `having` door above, as `where`'s resolution follows its // doors: the temporal door steps around a `{placeholder}` exactly as - // `where`'s does (so, as there, the resolved value is not judged again), - // and the other doors judged a string that resolves to a string. + // `where`'s does (so, as there, the resolved value is judged only for + // its year, by the resolution stage — #20844), and the other doors + // judged a string that resolves to a string. { const havingColumnTypes = aggregatedRowColumnTypes(query.groupBy, query.aggregations, declaredFields); await this.resolveWhereTokens( diff --git a/packages/objectql/src/temporal-comparand-door.ts b/packages/objectql/src/temporal-comparand-door.ts index 2b6573f1ec6..00719e75fd6 100644 --- a/packages/objectql/src/temporal-comparand-door.ts +++ b/packages/objectql/src/temporal-comparand-door.ts @@ -94,7 +94,8 @@ * BEFORE `resolveWhereTokens` (which is where it must run — the refusal has * to precede the driver), so judging one would refuse `{30_days_ago}`, the * platform's own correct spelling. Unknown tokens keep their existing loud - * refusal one layer down. + * refusal one layer down, and [#20844] a known one is judged once resolved, + * by its year alone (below). * - **Non-string comparands are not judged, save the year classes.** A number * is epoch milliseconds and a `Date` is an instant; the `datetime` and `time` * rules read both, [#20480] a `time` column only when the instant's UTC year @@ -185,6 +186,32 @@ * beneath it stays answered there, so this door steps over those operators * rather than answer them in the words #15661 retired. * + * ## [#20844] A relative-date placeholder, judged by its year once resolved + * + * The door steps around a `{placeholder}`, so the year range never saw what + * one resolved to. Measured before this on InMemoryDriver and SqlDriver on + * SQLite, through `engine.find` and `POST /data/:object/query`, a `datetime` + * field and two rows, one in 2026 and one in 1500: + * + * ``` + * $gt "{8000_years_from_now}" 200, both rows (resolved to 10026-10-01) + * $lt "{2027_years_ago}" 200, the 1500 row (resolved to -1-10-01, read as 2001-01-10) + * $lt "{1977_years_ago}" 200, no row (0049-10-01, below the datetime floor) + * ``` + * + * The right answer to the first two is no row, and a literal of each value is + * refused here. {@link assertResolvedTemporalTokensInRange} and + * {@link assertHavingResolvedTemporalTokensInRange} close it: the engine's + * resolution stage hands them the caller's condition and its resolution, the + * same walk takes both trees side by side, and a comparand written as a date + * macro is refused when core's `isOutsideTemporalYearRange` puts the value it + * resolved to outside its column's years — `INVALID_FILTER` / 400, in this + * door's year-class words, naming the placeholder and the year. ⛔ Not a + * second pass of the door: every other comparand was judged before + * resolution. Core's resolver spells a day outside 0001..9999 in the + * expanded-year form (`+010026-10-01`, `-000001-10-01`), so the range reads + * the year it names on every host. + * * @see `@objectstack/core`'s `temporal-comparand.ts` — the value-half predicate, * shared with the analytics raw-SQL decline so one rule cannot exist twice. * @see https://github.com/objectstack-ai/objectstack/issues/8690 @@ -197,7 +224,7 @@ import { temporalStorageForm, type TemporalComparandKind, } from '@objectstack/core'; -import { isTextFilterOperator } from '@objectstack/spec/data'; +import { classifyFilterToken, isTextFilterOperator } from '@objectstack/spec/data'; import { invalidFilterError } from './filter-comparand-shape.js'; import { temporalKindOf, type AggregatedColumnClass } from './having-filter.js'; @@ -217,16 +244,77 @@ export interface UninterpretableTemporalComparand { path: string; } +/** + * [#20844] A relative-date placeholder that resolved to a value outside its + * field's years: the placeholder as written, and the value it resolved to. + */ +export interface ResolvedTokenOutsideYears extends UninterpretableTemporalComparand { + kind: 'date' | 'datetime'; + /** The placeholder as the caller wrote it, braces included. */ + token: string; + /** The value it resolved to (`value` is the same, for the year class). */ + value: unknown; +} + /** * [#20263] What one filter position supplies to the walk: the storage kind of * the column a KEY names (`null` = not temporal, or not known), and whether an * operator's comparands are judged at all. */ -interface WalkScope { +interface PositionScope { kindOf: (key: string) => TemporalComparandKind | null; judgesOperator: (op: string) => boolean; } +/** + * [#20844] …and what the walk asks of each comparand it reaches: `value` where + * it sits in the walked tree, `twin` the comparand at the same path in the + * tree walked beside it. The door walks one tree beside itself; the + * resolved-token judge walks the caller's tree beside its resolution, which + * has the same shape (the resolver replaces a placeholder string and copies + * everything else). + */ +interface WalkScope extends PositionScope { + judge: (kind: TemporalComparandKind, field: string, value: unknown, twin: unknown, path: string) => H | null; +} + +/** The `where` position's scope: each key's kind is its declared field's. */ +function whereScope(fields: Record): PositionScope { + return { + kindOf: (key) => temporalComparandKind((fields[key] as { type?: unknown } | undefined)?.type), + judgesOperator: () => true, + }; +} + +/** [#20263] The `having` position's scope — see the module note's `having` section. */ +function havingScope(classes: ReadonlyMap): PositionScope { + return { + kindOf: (key) => temporalKindOf(classes.get(key)) ?? null, + judgesOperator: (op) => !isTextFilterOperator(op), + }; +} + +/** The door's own judgement of one comparand — the twin is the comparand itself. */ +function judgeAsWritten( + kind: TemporalComparandKind, + field: string, + value: unknown, + _twin: unknown, + path: string, +): UninterpretableTemporalComparand | null { + return judgeComparand(kind, field, value, path); +} + +/** The value at `key` in a twin node, or `undefined` when the twin has none there. */ +function twinAt(twin: unknown, key: string): unknown { + return isFilterNode(twin) && Object.prototype.hasOwnProperty.call(twin, key) ? twin[key] : undefined; +} + +/** The member at `index` of a twin list, or `undefined`. */ +function twinMember(twin: unknown, index: number): unknown { + return Array.isArray(twin) ? twin[index] : undefined; +} + /** * A plain object — filter STRUCTURE rather than a comparand. Same * classification the #5869 gate and `driver-memory`'s own gate make: a `Date` @@ -271,43 +359,39 @@ export function findUninterpretableTemporalComparand( // see — the same early return `assertFilterIsMaterializable` makes. const fields = (schema as { fields?: Record } | undefined)?.fields; if (!fields || typeof fields !== 'object') return null; - return walkCondition( - { - kindOf: (key) => temporalComparandKind((fields[key] as { type?: unknown } | undefined)?.type), - judgesOperator: () => true, - }, - where, - path, - depth, - ); + return walkCondition({ ...whereScope(fields), judge: judgeAsWritten }, where, where, path, depth); } /** * The walk itself, shared by every position: the node structure is judged the * same way wherever the condition sits; only the {@link WalkScope} differs. + * [#20844] `twin` is walked beside `node`, step for step, and each comparand's + * twin is handed to the scope's judge. */ -function walkCondition( - scope: WalkScope, +function walkCondition( + scope: WalkScope, node: unknown, + twin: unknown, path: string, depth: number, -): UninterpretableTemporalComparand | null { +): H | null { if (depth > 32) return null; if (!isFilterNode(node)) return null; for (const [key, value] of Object.entries(node)) { const here = `${path}.${key}`; + const twinValue = twinAt(twin, key); if (key === '$and' || key === '$or') { if (Array.isArray(value)) { for (const [index, arm] of value.entries()) { - const hit = walkCondition(scope, arm, `${here}[${index}]`, depth + 1); + const hit = walkCondition(scope, arm, twinMember(twinValue, index), `${here}[${index}]`, depth + 1); if (hit) return hit; } } continue; } if (key === '$not') { - const hit = walkCondition(scope, value, here, depth + 1); + const hit = walkCondition(scope, value, twinValue, here, depth + 1); if (hit) return hit; continue; } @@ -315,22 +399,23 @@ function walkCondition( if (key.includes('.')) continue; const kind = scope.kindOf(key); if (!kind) continue; - const hit = judgeFieldComparands(kind, key, value, here, scope.judgesOperator); + const hit = judgeFieldComparands(scope, kind, key, value, twinValue, here); if (hit) return hit; } return null; } /** One temporal field's constraint: `{ at: }`. */ -function judgeFieldComparands( +function judgeFieldComparands( + scope: WalkScope, kind: TemporalComparandKind, field: string, spec: unknown, + twin: unknown, path: string, - judgesOperator: (op: string) => boolean, -): UninterpretableTemporalComparand | null { +): H | null { // Not filter structure → an implicit-equality comparand, judged at this path. - if (!isFilterNode(spec)) return judgeComparand(kind, field, spec, path); + if (!isFilterNode(spec)) return scope.judge(kind, field, spec, twin, path); // A field spec with no `$` key is a deep-equality / nested-relation condition; // the #5869 gate records why descending into one would invent a contract no // backend agrees with. @@ -339,18 +424,19 @@ function judgeFieldComparands( if (isFieldReference(spec)) return null; for (const op of keys) { if (!op.startsWith('$')) continue; - if (!judgesOperator(op)) continue; + if (!scope.judgesOperator(op)) continue; const comparand = spec[op]; + const twinComparand = twinAt(twin, op); // Every MEMBER of a list operator is a comparand in its own right — the // same split the #7872 type door makes at the shared compile face. if (Array.isArray(comparand)) { for (const [index, member] of comparand.entries()) { - const hit = judgeComparand(kind, field, member, `${path}.${op}[${index}]`); + const hit = scope.judge(kind, field, member, twinMember(twinComparand, index), `${path}.${op}[${index}]`); if (hit) return hit; } continue; } - const hit = judgeComparand(kind, field, comparand, `${path}.${op}`); + const hit = scope.judge(kind, field, comparand, twinComparand, `${path}.${op}`); if (hit) return hit; } return null; @@ -467,10 +553,27 @@ const DATETIME_BEFORE_YEAR_1000: YearClass = { * (`9999-12-31T23:00:00-02:00` names year 10000 in UTC). */ function isInstantOutsideFourDigitYears(value: unknown): boolean { - const instant = typeof value === 'string' ? Date.parse(String(temporalStorageForm(value, 'datetime'))) : value; + const instant = typeof value === 'string' ? instantMsOf(value) : value; return isOutsideTemporalYearRange(instant, 'date'); } +/** The epoch milliseconds the `datetime` rule reads a string as (`NaN` for none). */ +function instantMsOf(value: string): number { + return Date.parse(String(temporalStorageForm(value, 'datetime'))); +} + +/** + * [#20844] The UTC year of the instant a resolved placeholder names — for the + * message only. A resolver day (`YYYY-MM-DD`, or `+010026-10-01` past the + * four-digit years) is read at midnight UTC, so its year is the day's own; an + * instant (`{N_hours_ago}`) names its UTC year, which is the year both rules + * take of it. + */ +function resolvedYearOf(value: unknown): string { + const ms = typeof value === 'string' ? instantMsOf(value) : Number.NaN; + return Number.isFinite(ms) ? String(new Date(ms).getUTCFullYear()) : 'unknown'; +} + /** * [#20264] The year class of a hit, or `undefined` when the comparand is * refused for being unreadable at all — the range itself is core's @@ -478,8 +581,13 @@ function isInstantOutsideFourDigitYears(value: unknown): boolean { */ function yearClassOf(hit: UninterpretableTemporalComparand): YearClass | undefined { if (hit.kind === 'time' || !isOutsideTemporalYearRange(hit.value, hit.kind)) return undefined; - if (hit.kind === 'datetime' && !isInstantOutsideFourDigitYears(hit.value)) return DATETIME_BEFORE_YEAR_1000; - return YEAR_CLASS[hit.kind]; + return yearClassOutside(hit.kind, hit.value); +} + +/** The year class of a value core's range already put outside its kind's years. */ +function yearClassOutside(kind: 'date' | 'datetime', value: unknown): YearClass { + if (kind === 'datetime' && !isInstantOutsideFourDigitYears(value)) return DATETIME_BEFORE_YEAR_1000; + return YEAR_CLASS[kind]; } /** @@ -629,6 +737,15 @@ function havingColumnSource(column: string, groupBy: unknown, aggregations: unkn return 'an aggregated column'; } +/** [#20263] How a `having` refusal names the column a hit sits on. */ +function havingColumnPhrase( + hit: UninterpretableTemporalComparand, + query: { groupBy?: unknown; aggregations?: unknown }, +): string { + return `\`having\` on '${hit.field}' (${havingColumnSource(hit.field, query.groupBy, query.aggregations)}, ` + + `a ${hit.kind} column)`; +} + /** * [#20263] Refuse every `having` comparand its aggregated column's storage rule * cannot read, before any driver is asked for a row. @@ -646,18 +763,9 @@ export function assertHavingTemporalComparandsInterpretable( classes: ReadonlyMap, query: { groupBy?: unknown; aggregations?: unknown }, ): void { - const hit = walkCondition( - { - kindOf: (key) => temporalKindOf(classes.get(key)) ?? null, - judgesOperator: (op) => !isTextFilterOperator(op), - }, - having, - 'having', - 0, - ); + const hit = walkCondition({ ...havingScope(classes), judge: judgeAsWritten }, having, having, 'having', 0); if (!hit) return; - const column = `\`having\` on '${hit.field}' (${havingColumnSource(hit.field, query.groupBy, query.aggregations)}, ` - + `a ${hit.kind} column)`; + const column = havingColumnPhrase(hit, query); // The year class, in its own words, as on `where` (#20240, #20264, #20280). const yearClass = yearClassOf(hit); if (yearClass) { @@ -684,3 +792,97 @@ export function assertHavingTemporalComparandsInterpretable( + `The \`having\` was NOT applied. ${REMEDY[hit.kind]}`, ); } + +/** + * [#20844] What one filter position does with the placeholders it resolved: + * `written` is the position's condition before resolution, `resolved` the + * same condition after. The engine's resolution stage calls one whenever + * something resolved — see the module note's resolved-token section. + */ +export type ResolvedTokenJudge = (written: unknown, resolved: unknown) => void; + +/** + * [#20844] Judge one comparand the caller wrote as a relative-date + * placeholder, by the year of the value it resolved to and nothing else: core's + * `isOutsideTemporalYearRange` of that value for the column's kind, the range + * the door asks of a literal. A literal comparand was judged by the door + * before resolution, and a context placeholder (`{current_user_id}`) names no + * year, so neither is this judgement's. A `time` column names no year either + * (core's range says so of every `time` value). + */ +function judgeResolvedToken( + kind: TemporalComparandKind, + field: string, + written: unknown, + resolved: unknown, + path: string, +): ResolvedTokenOutsideYears | null { + if (kind === 'time') return null; + if (classifyFilterToken(written)?.kind !== 'date-macro') return null; + if (!isOutsideTemporalYearRange(resolved, kind)) return null; + return { field, kind, token: written as string, value: resolved, path }; +} + +/** [#20844] The placeholder, where it sits, and what it resolved to — for the message. */ +function resolvedTokenPhrase(hit: ResolvedTokenOutsideYears): string { + return `${preview(hit.token)} at ${hit.path}, a relative-date placeholder that resolved to ` + + `${preview(hit.value)} (the year ${resolvedYearOf(hit.value)})`; +} + +/** [#20844] The fix for a placeholder, ahead of the year class's own for a literal. */ +const RESOLVED_TOKEN_REMEDY = 'Use a relative-date placeholder whose offset lands inside those years.'; + +/** + * [#20844] Refuse a relative-date placeholder in `where` (or, by `path`, a + * per-aggregation `filter`) that resolved to a value outside its declared + * field's years — `INVALID_FILTER` / 400, in the door's year-class words, + * naming the placeholder and the year it resolved to. + * + * Called by the engine's resolution stage on the caller's condition and its + * resolution, so the walk keeps the door's paths and skips what the door + * skips. ⛔ Not a second pass of the door: every other comparand was judged + * as written, before resolution, and only a placeholder's year is new here. + */ +export function assertResolvedTemporalTokensInRange( + object: string, + operation: string, + schema: unknown, + written: unknown, + resolved: unknown, + path = 'where', +): void { + const fields = (schema as { fields?: Record } | undefined)?.fields; + if (!fields || typeof fields !== 'object') return; + const hit = walkCondition({ ...whereScope(fields), judge: judgeResolvedToken }, written, resolved, path, 0); + if (!hit) return; + const yearClass = yearClassOutside(hit.kind, hit.value); + throw invalidFilterError( + `${operation}('${object}'): filter on '${hit.field}' compares a declared ${hit.kind} field against ` + + `${resolvedTokenPhrase(hit)}, ${yearClass.year}, the years a ${hit.kind} value may name, so it is ` + + `not a ${hit.kind} value this platform can interpret. ${yearClass.where} The filter was NOT applied. ` + + `${RESOLVED_TOKEN_REMEDY} ${yearClass.remedy}`, + ); +} + +/** + * [#20844] The same refusal on `having`, by each aggregated column's class + * (`classes`, #20127's `aggregatedRowColumnClasses`), stepping over the text + * operators as the `having` door does. + */ +export function assertHavingResolvedTemporalTokensInRange( + object: string, + written: unknown, + resolved: unknown, + classes: ReadonlyMap, + query: { groupBy?: unknown; aggregations?: unknown }, +): void { + const hit = walkCondition({ ...havingScope(classes), judge: judgeResolvedToken }, written, resolved, 'having', 0); + if (!hit) return; + const yearClass = yearClassOutside(hit.kind, hit.value); + throw invalidFilterError( + `aggregate('${object}'): ${havingColumnPhrase(hit, query)} compares against ${resolvedTokenPhrase(hit)}, ` + + `${yearClass.year}, the years a ${hit.kind} value may name, so it is not a ${hit.kind} ` + + `value this platform can interpret. ${yearClass.having} The \`having\` was NOT applied. ` + + `${RESOLVED_TOKEN_REMEDY} ${yearClass.remedy}`, + ); +} From 4dcb910f1936f7df1065a043cebcde98b6a0d756 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 02:56:22 +0000 Subject: [PATCH 2/4] test(core,objectql,rest): pin a resolved relative-date placeholder's year range on every position Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG Co-authored-by: Claude --- .../filter-tokens-year-outside-range.test.ts | 84 ++++++ .../engine-resolved-token-year-range.test.ts | 246 ++++++++++++++++++ .../data-resolved-token-year-range.test.ts | 176 +++++++++++++ 3 files changed, 506 insertions(+) create mode 100644 packages/core/src/utils/filter-tokens-year-outside-range.test.ts create mode 100644 packages/objectql/src/engine-resolved-token-year-range.test.ts create mode 100644 packages/rest/src/data-resolved-token-year-range.test.ts diff --git a/packages/core/src/utils/filter-tokens-year-outside-range.test.ts b/packages/core/src/utils/filter-tokens-year-outside-range.test.ts new file mode 100644 index 00000000000..86d1e559fbe --- /dev/null +++ b/packages/core/src/utils/filter-tokens-year-outside-range.test.ts @@ -0,0 +1,84 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#20844] A date macro that lands outside the years 0001..9999 resolves to a +// day spelled in the expanded-year form (`+010026-09-30`, `-000001-09-30`), +// so core's one range reads the year it resolved to, on every host. Before, +// it took the storage rule's unpadded spelling (`10026-09-30`, `-1-09-30`), +// which `Date.parse` reads through the host's legacy parser, in the host's +// zone: `-1-09-30` read as a day in 2001, so `isOutsideTemporalYearRange` +// judged `{2027_years_ago}` inside the range, for both kinds. +// +// Pins: both sides of 0001..9999, year 0, the edges inside, a 2026 control and +// a sub-day instant (spelled by `toISOString` already), in UTC and in +// Asia/Shanghai. The engine refuses each one outside its field's years; +// objectql's `engine-resolved-token-year-range.test.ts` pins that half. + +import { describe, it, expect, beforeEach, afterAll } from 'vitest'; +import { resolveFilterToken, resolveFilterTokens } from './filter-tokens.js'; +import { isOutsideTemporalYearRange } from './temporal-storage-form.js'; + +// Wed 2026-09-30 12:00 UTC: the same calendar day in UTC and in Asia/Shanghai. +const NOW = new Date('2026-09-30T12:00:00.000Z'); + +const at = (token: string, timezone?: string) => resolveFilterToken(token, { now: NOW, timezone }); + +/** The UTC year `Date.parse` reads a resolved value as. */ +const parsedYear = (value: unknown) => new Date(Date.parse(String(value))).getUTCFullYear(); + +const HOSTS = ['UTC', 'Asia/Shanghai'] as const; +const originalTz = process.env.TZ; +afterAll(() => { + if (originalTz === undefined) delete process.env.TZ; + else process.env.TZ = originalTz; +}); + +describe.each(HOSTS)('on a %s host', (host) => { + beforeEach(() => { + process.env.TZ = host; + expect(Intl.DateTimeFormat().resolvedOptions().timeZone).toBe(host); + }); + + describe('[#20844] a day outside 0001..9999 is spelled so the range reads the year it resolved to', () => { + it.each([ + ['8000_years_from_now', '+010026-09-30', 10026], + ['7974_years_from_now', '+010000-09-30', 10000], + ['2027_years_ago', '-000001-09-30', -1], + ['2026_years_ago', '0000-09-30', 0], + ['100000_months_from_now', '+010360-01-30', 10360], + ])('{%s} is %s, year %i, outside both kinds\' years', (token, expected, year) => { + for (const tz of [undefined, 'UTC', 'Asia/Shanghai']) { + const day = at(token, tz); + expect(day, `${token} in ${tz ?? 'the default zone'}`).toBe(expected); + expect(parsedYear(day)).toBe(year); + expect(isOutsideTemporalYearRange(day, 'date')).toBe(true); + expect(isOutsideTemporalYearRange(day, 'datetime')).toBe(true); + } + }); + + it.each([ + ['7973_years_from_now', '9999-09-30', false], + ['2025_years_ago', '0001-09-30', true], + ['1026_years_ago', '1000-09-30', false], + ['1027_years_ago', '0999-09-30', true], + ['1_year_ago', '2025-09-30', false], + ])('{%s} is %s: inside a date\'s years, and outside a datetime\'s: %s', (token, expected, outsideDatetime) => { + const day = at(token); + expect(day).toBe(expected); + expect(isOutsideTemporalYearRange(day, 'date')).toBe(false); + expect(isOutsideTemporalYearRange(day, 'datetime')).toBe(outsideDatetime); + }); + + it('a sub-day placeholder past 9999 keeps the instant toISOString spells', () => { + const instant = at('80000000_hours_from_now'); + expect(instant).toBe(new Date(NOW.getTime() + 80_000_000 * 3_600_000).toISOString()); + expect(String(instant).startsWith('+011153-')).toBe(true); + expect(isOutsideTemporalYearRange(instant, 'datetime')).toBe(true); + }); + + it('resolveFilterTokens carries the same spelling into a filter tree', () => { + expect(resolveFilterTokens({ opened_at: { $lt: '{2027_years_ago}' } }, { now: NOW })).toEqual({ + opened_at: { $lt: '-000001-09-30' }, + }); + }); + }); +}); diff --git a/packages/objectql/src/engine-resolved-token-year-range.test.ts b/packages/objectql/src/engine-resolved-token-year-range.test.ts new file mode 100644 index 00000000000..809ed375d45 --- /dev/null +++ b/packages/objectql/src/engine-resolved-token-year-range.test.ts @@ -0,0 +1,246 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20844] A relative-date placeholder is judged by the year of the value it + * resolved to: outside its column's years (a `date` 0001..9999, a `datetime` + * 1000..9999 — `@objectstack/core`'s `isOutsideTemporalYearRange`) it is + * refused `INVALID_FILTER` / 400, naming the placeholder and the year, on + * every position the resolver serves, before any driver read. + * + * Measured on the base (`2f2fa11d7`) through `engine.find` on InMemoryDriver + * and `POST /api/v1/data/:object/query` on SqlDriver over SQLite, two rows + * (`opened_at` 2026-03-01T10:00Z and 1500-03-01T10:00Z): + * + * | `where` | resolved to | memory | SQLite | now | + * |:--|:--|:--|:--|:--| + * | `opened_at $gt {8000_years_from_now}` | `10026-10-01` | both rows | both rows | 400 | + * | `opened_at $lt {2027_years_ago}` | `-1-10-01`, read as 2001-01-10 | the 1500 row | the 1500 row | 400 | + * | `opened_at $lt {1977_years_ago}` | `0049-10-01` | no row | no row | 400 (the `datetime` floor) | + * | `placed_on $gt {8000_years_from_now}` | `10026-10-01` | both rows | both rows | 400 | + * | `having` `max(opened_at) $gt {8000_years_from_now}` | `10026-10-01` | both groups | — | 400 | + * | `judgeFilter` of the first two | | `{ ok: true }` | | refused | + * + * The right answer to each `$gt` / `$lt` above was no row; a literal of each + * resolved value was already refused by the temporal-comparand door, which + * steps around a placeholder. The refusal sits in front of every driver, so + * this file's recording driver is enough to pin it (the memory row by + * construction: the refusal answers before a driver is asked); the REST door + * over SQLite, with the rows, is `packages/rest/src/data-resolved-token-year-range.test.ts`. + * Every refusal sits beside its control: a placeholder that resolves inside + * the range reaches the driver as the day it names. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { ObjectQL } from './engine.js'; + +// Wed 2026-09-30 12:00 UTC: every resolved day below is read off this instant. +const PINNED_NOW = new Date('2026-09-30T12:00:00.000Z'); + +const ledger = { + name: 'ledger', + label: 'Ledger', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + customer_id: { name: 'customer_id', type: 'text' as const }, + placed_on: { name: 'placed_on', type: 'date' as const }, + opened_at: { name: 'opened_at', type: 'datetime' as const }, + opens_at: { name: 'opens_at', type: 'time' as const }, + note: { name: 'note', type: 'text' as const }, + }, +}; + +/** placeholder · what it resolves to off PINNED_NOW · the year a refusal names */ +const PAST_BOTH: ReadonlyArray = [ + ['{8000_years_from_now}', '+010026-09-30', '10026'], + ['{7974_years_from_now}', '+010000-09-30', '10000'], + ['{2027_years_ago}', '-000001-09-30', '-1'], + ['{2026_years_ago}', '0000-09-30', '0'], + // A sub-day placeholder resolves to an instant. + ['{80000000_hours_from_now}', new Date(PINNED_NOW.getTime() + 80_000_000 * 3_600_000).toISOString(), '11153'], +]; + +/** Outside a `datetime`'s years only: before its floor, inside a `date`'s. */ +const BEFORE_DATETIME_FLOOR: ReadonlyArray = [ + ['{1977_years_ago}', '0049-09-30', '49'], + ['{1027_years_ago}', '0999-09-30', '999'], +]; + +/** Inside both kinds' years — the edges and a control — each reaches the driver as the day it names. */ +const INSIDE: ReadonlyArray = [ + ['{1026_years_ago}', '1000-09-30'], + ['{7973_years_from_now}', '9999-09-30'], + ['{100_years_ago}', '1926-09-30'], +]; + +/** A driver that records every read, and answers none. */ +function makeRecordingDriver() { + const reads: unknown[] = []; + const driver: any = { + name: 'recording', version: '0.0.0', supports: {}, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; }, + async find(_o: string, ast: unknown) { reads.push(ast); return []; }, + async findOne(_o: string, ast: unknown) { reads.push(ast); return null; }, + async count(_o: string, ast: unknown) { reads.push(ast); return 0; }, + async aggregate(_o: string, ast: unknown) { reads.push(ast); return []; }, + async create(_o: string, data: Record) { return { ...data }; }, + async update(_o: string, id: string, data: Record) { return { ...data, id }; }, + async updateMany(_o: string, ast: unknown) { reads.push(ast); return 0; }, + async delete() { return true; }, + async deleteMany(_o: string, ast: unknown) { reads.push(ast); return 0; }, + async bulkCreate(_o: string, batch: Record[]) { return batch; }, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; + return { driver, reads }; +} + +const refusalOf = async (p: Promise) => + p.then(() => null, (e: any) => e as Error & { code?: string; status?: number }); + +describe('[#20844] a relative-date placeholder resolved outside its column\'s years is refused, on every position, before any read', () => { + let engine: ObjectQL; + let reads: unknown[]; + + beforeEach(async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(PINNED_NOW); + const rec = makeRecordingDriver(); + reads = rec.reads; + engine = new ObjectQL(); + engine.registerDriver(rec.driver, true); + await engine.init(); + engine.registry.registerObject(ledger, 'test'); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + /** Every verb and position that resolves a placeholder in a caller's condition. */ + const positions = (field: string, op: string, token: string) => [ + ['find where', () => engine.find('ledger', { where: { [field]: { [op]: token } } })], + ['findOne where', () => engine.findOne('ledger', { where: { [field]: { [op]: token } } })], + ['count where', () => engine.count('ledger', { where: { [field]: { [op]: token } } })], + ['update where', () => engine.update('ledger', { note: 'x' }, { where: { [field]: { [op]: token } }, multi: true } as never)], + ['delete where', () => engine.delete('ledger', { where: { [field]: { [op]: token } }, multi: true } as never)], + ['aggregate where', () => engine.aggregate('ledger', { + where: { [field]: { [op]: token } }, + aggregations: [{ function: 'count', alias: 'n' }], + } as never)], + ['per-aggregation filter', () => engine.aggregate('ledger', { + aggregations: [{ function: 'count', alias: 'n' }, { function: 'count', alias: 'm', filter: { [field]: { [op]: token } } }], + } as never)], + ['having', () => engine.aggregate('ledger', { + groupBy: ['customer_id'], + aggregations: [{ function: 'max', field, alias: 'last' }], + having: { last: { [op]: token } }, + } as never)], + ] as const; + + for (const [field, kind] of [['opened_at', 'datetime'], ['placed_on', 'date']] as const) { + it(`${kind}: a placeholder resolved outside 0001..9999 is INVALID_FILTER / 400 on every position, naming the placeholder and the year`, async () => { + for (const [token, resolved, year] of PAST_BOTH) { + for (const op of ['$gt', '$lt'] as const) { + for (const [position, call] of positions(field, op, token)) { + const err = await refusalOf(call()); + const at = `${position} ${field} ${op} ${token}`; + expect(err, at).not.toBeNull(); + expect(err!.code, at).toBe('INVALID_FILTER'); + expect(err!.status, at).toBe(400); + expect(err!.message, at).toContain(`"${token}"`); + expect(err!.message, at).toContain(`resolved to "${resolved}"`); + expect(err!.message, at).toContain(`(the year ${year})`); + expect(err!.message, at).toContain(kind === 'date' ? 'the years 0001 to 9999' : 'the years 1000 to 9999'); + } + } + } + expect(reads, 'no read — every refusal precedes the driver').toHaveLength(0); + }); + } + + it('the refusal is rooted at the position the placeholder sits in, in the door\'s words for that position', async () => { + const where = await refusalOf(engine.find('ledger', { where: { opened_at: { $gt: '{8000_years_from_now}' } } })); + expect(where!.message).toContain("find('ledger'): filter on 'opened_at' compares a declared datetime field against \"{8000_years_from_now}\" at where.opened_at.$gt"); + expect(where!.message).toContain('does not sort as an instant'); + expect(where!.message).toContain('The filter was NOT applied.'); + const filter = await refusalOf(engine.aggregate('ledger', { + aggregations: [{ function: 'count', alias: 'n' }, { function: 'count', alias: 'm', filter: { opened_at: { $gt: '{8000_years_from_now}' } } }], + } as never)); + expect(filter!.message).toContain('at aggregations[1].filter.opened_at.$gt'); + const having = await refusalOf(engine.aggregate('ledger', { + groupBy: ['customer_id'], + aggregations: [{ function: 'max', field: 'opened_at', alias: 'last' }], + having: { last: { $lt: '{2027_years_ago}' } }, + } as never)); + expect(having!.message).toContain("`having` on 'last' (max(opened_at), a datetime column) compares against \"{2027_years_ago}\" at having.last.$lt"); + expect(having!.message).toContain('The `having` was NOT applied.'); + expect(reads).toHaveLength(0); + }); + + it('a list member, a range bound, the implicit-equality slot and a nested branch are judged too', async () => { + for (const where of [ + { opened_at: '{8000_years_from_now}' }, + { opened_at: { $in: ['{100_years_ago}', '{2027_years_ago}'] } }, + { opened_at: { $between: ['{100_years_ago}', '{8000_years_from_now}'] } }, + { $or: [{ customer_id: 'x' }, { $not: { opened_at: { $gte: '{2027_years_ago}' } } }] }, + [['opened_at', '>', '{8000_years_from_now}']], + ]) { + const err = await refusalOf(engine.find('ledger', { where: where as never })); + expect(err, JSON.stringify(where)).toMatchObject({ code: 'INVALID_FILTER', status: 400 }); + } + expect(reads).toHaveLength(0); + }); + + it('the datetime floor of 1000 applies to a resolved placeholder as to a literal — a date keeps those years', async () => { + for (const [token, resolved, year] of BEFORE_DATETIME_FLOOR) { + const err = await refusalOf(engine.find('ledger', { where: { opened_at: { $lt: token } } })); + expect(err, token).toMatchObject({ code: 'INVALID_FILTER', status: 400 }); + expect(err!.message).toContain(`resolved to "${resolved}" (the year ${year})`); + expect(err!.message).toContain('Before year 1000'); + expect(err!.message).not.toContain('does not sort'); + // The control: the same placeholder on a `date` reaches the driver as its day. + const before = reads.length; + await engine.find('ledger', { where: { placed_on: { $lt: token } } }); + expect(reads.length, `${token} on a date reached the driver`).toBe(before + 1); + expect(JSON.stringify(reads[before]), token).toContain(`"${resolved}"`); + } + }); + + it('the control: a placeholder that resolves inside the range reaches the driver as the day it names, on every position', async () => { + for (const [token, resolved] of INSIDE) { + for (const field of ['opened_at', 'placed_on'] as const) { + for (const [position, call] of positions(field, '$gt', token)) { + const before = reads.length; + await expect(call(), `${position} ${field} ${token}`).resolves.toBeDefined(); + // A per-aggregation filter and `having` are evaluated by the engine + // over the rows the driver reads; the resolved day reaches the driver + // on the rest. (The REST suite reads their counts over SQLite.) + if (position === 'having' || position === 'per-aggregation filter') continue; + expect(reads.length, `${position} ${field} ${token} reached the driver`).toBeGreaterThan(before); + expect(JSON.stringify(reads.slice(before)), `${position} ${field} ${token}`).toContain(resolved); + } + } + } + }); + + it('the judge (`judgeFilter`) refuses what execution refuses, with the same code, status and message', async () => { + for (const [token] of PAST_BOTH) { + const where = { opened_at: { $gt: token } }; + const judged = engine.judgeFilter('ledger', where); + const executed = await refusalOf(engine.find('ledger', { where })); + expect(judged, token).toEqual({ ok: false, code: 'INVALID_FILTER', status: 400, message: executed!.message }); + } + expect(engine.judgeFilter('ledger', { opened_at: { $gt: '{100_years_ago}' } })).toEqual({ ok: true }); + expect(engine.judgeFilter('ledger', { opened_at: { $lt: '{1977_years_ago}' } })).toMatchObject({ ok: false, code: 'INVALID_FILTER', status: 400 }); + expect(engine.judgeFilter('ledger', { placed_on: { $lt: '{1977_years_ago}' } })).toEqual({ ok: true }); + }); + + it('a placeholder on a column with no year, or a context placeholder, is not this judgement\'s', async () => { + // A text column compares the resolved day as text: no kind, no range. + await expect(engine.find('ledger', { where: { note: { $gt: '{8000_years_from_now}' } } })).resolves.toEqual([]); + expect(JSON.stringify(reads.at(-1))).toContain('+010026-09-30'); + // A context placeholder names no year. + await expect(engine.find('ledger', { where: { customer_id: '{current_user_id}' }, context: { userId: 'u1' } } as never)).resolves.toEqual([]); + expect(reads).toHaveLength(2); + }); +}); diff --git a/packages/rest/src/data-resolved-token-year-range.test.ts b/packages/rest/src/data-resolved-token-year-range.test.ts new file mode 100644 index 00000000000..3e7ee4aab1a --- /dev/null +++ b/packages/rest/src/data-resolved-token-year-range.test.ts @@ -0,0 +1,176 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20844] A relative-date placeholder that resolves outside its field's + * years — a `date` 0001..9999, a `datetime` 1000..9999 — is refused at the + * public door: `POST /api/v1/data/:object/query` answers `400 INVALID_FILTER` + * naming the placeholder and the year it resolved to, on `where`, a + * per-aggregation `filter` and `having`, over a real `SqlDriver` on SQLite. + * + * Measured on the base (`2f2fa11d7`) through this door, the card's two rows + * (`opened_at` 2026-03-01T10:00Z and 1500-03-01T10:00Z): + * + * | `where` | base | now | + * |:--|:--|:--| + * | `opened_at $gt {8000_years_from_now}` | 200, both rows | 400 | + * | `opened_at $lt {2027_years_ago}` | 200, the 1500 row (`-1-…` read as 2001) | 400 | + * | `opened_at $lt {1977_years_ago}` | 200, no row | 400 (the `datetime` floor) | + * | `placed_on $gt {8000_years_from_now}` | 200, both rows | 400 | + * + * The right answer to each was no row. Every refusal sits beside its control: + * a placeholder resolved inside the range answers the right rows. InMemoryDriver + * answered as SQLite on the base; the refusal sits in the engine, in front of + * every driver, and the engine-level pin with a recording driver is + * `packages/objectql/src/engine-resolved-token-year-range.test.ts`. SQLite is + * this file's only cell: the refusal precedes the driver, so a dialect adds + * nothing to it. + */ + +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import type { EngineAggregateOptions, FilterCondition } from '@objectstack/spec/data'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { RestServer } from './rest-server'; + +// Wed 2026-09-30 12:00 UTC: every resolved day below is read off this instant. +const PINNED_NOW = new Date('2026-09-30T12:00:00.000Z'); + +const OBJECT = 'rest_resolved_token_20844'; + +const LEDGER = { + name: OBJECT, + label: 'Ledger 20844', + fields: { + customer_id: { name: 'customer_id', type: 'text' as const }, + placed_on: { name: 'placed_on', type: 'date' as const }, + opened_at: { name: 'opened_at', type: 'datetime' as const }, + }, +}; + +const ROWS = [ + { id: 'r2026', customer_id: 'c1', placed_on: '2026-03-01', opened_at: '2026-03-01T10:00:00.000Z' }, + { id: 'r1500', customer_id: 'c2', placed_on: '1500-03-01', opened_at: '1500-03-01T10:00:00.000Z' }, +]; + +/** field · operator · placeholder · the resolved value and year a refusal names */ +const REFUSED: ReadonlyArray = [ + ['opened_at', '$gt', '{8000_years_from_now}', '"+010026-09-30" (the year 10026)'], + ['opened_at', '$lt', '{2027_years_ago}', '"-000001-09-30" (the year -1)'], + ['placed_on', '$gt', '{8000_years_from_now}', '"+010026-09-30" (the year 10026)'], + ['placed_on', '$lt', '{2027_years_ago}', '"-000001-09-30" (the year -1)'], + // The `datetime` floor of 1000 applies to a resolved placeholder as to a literal. + ['opened_at', '$lt', '{1977_years_ago}', '"0049-09-30" (the year 49)'], +]; + +/** field · operator · placeholder · the ids `where` answers — inside the range, the control */ +const ANSWERED: ReadonlyArray = [ + ['opened_at', '$lt', '{100_years_ago}', ['r1500']], + ['opened_at', '$gt', '{100_years_ago}', ['r2026']], + ['opened_at', '$gt', '{1026_years_ago}', ['r1500', 'r2026']], + ['placed_on', '$lt', '{100_years_ago}', ['r1500']], + // A `date` keeps the years before 1000. + ['placed_on', '$gt', '{1977_years_ago}', ['r1500', 'r2026']], +]; + +function createMockServer() { + const noop = () => {}; + return { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} }; +} + +function makeRes() { + const res: any = { + write: () => true, end: () => {}, + header: () => res, + status: (code: number) => { res._status = code; return res; }, + json: (body: any) => { res._json = body; return res; }, + }; + return res; +} + +const perAggregation = (filter: FilterCondition): EngineAggregateOptions => ({ + aggregations: [{ function: 'count', alias: 'n' }, { function: 'count', alias: 'm', filter }], +}); +const grouped = (field: string, having: FilterCondition): EngineAggregateOptions => ({ + groupBy: ['customer_id'], + aggregations: [{ function: 'max', field, alias: 'last' }], + having, +}); + +describe('[#20844] a relative-date placeholder resolved outside its field\'s years, at the public door — sqlite', () => { + let engine: ObjectQL; + const reads = { n: 0 }; + let call: (method: string, path: string, params: Record, body: unknown) => Promise<{ status: number; body: any }>; + const query = (body: Record) => + call('POST', '/api/v1/data/:object/query', { object: OBJECT }, JSON.parse(JSON.stringify(body))); + + beforeAll(async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(PINNED_NOW); + const driver: any = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true } as any); + engine = new ObjectQL(); + engine.registerDriver(driver, true); + await engine.init(); + engine.registry.registerObject(LEDGER as any); + await engine.syncSchemas(); + for (const row of ROWS) await engine.insert(OBJECT, { ...row } as any); + + // Reads of THIS object — the protocol's own metadata traffic is not the question. + for (const verb of ['find', 'findOne', 'count', 'aggregate'] as const) { + const real = driver[verb].bind(driver); + driver[verb] = (o: string, ...rest: unknown[]) => { if (o === OBJECT) reads.n += 1; return real(o, ...rest); }; + } + + const protocol = new ObjectStackProtocolImplementation(engine as any); + const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); + call = async (method, path, params, body) => { + const route = rest.getRoutes().find((r: any) => r.method === method && r.path === path); + expect(route, `${method} ${path}`).toBeDefined(); + const res = makeRes(); + await route!.handler({ params, body, query: {}, headers: {} } as any, res); + return { status: res._status ?? 200, body: res._json }; + }; + }); + + afterAll(async () => { + vi.useRealTimers(); + try { await engine?.destroy(); } catch { /* noop */ } + }); + + it('400 INVALID_FILTER at where, the per-aggregation filter and having, naming the placeholder and the year — no read', async () => { + const before = reads.n; + for (const [field, op, token, resolved] of REFUSED) { + const where = { [field]: { [op]: token } } as FilterCondition; + for (const [position, body] of [ + ['where', { where }], + ['filter', perAggregation(where)], + ['having', grouped(field, { last: { [op]: token } } as FilterCondition)], + ] as const) { + const res = await query(body as Record); + const at = `${position}, ${field} ${op} ${token}: ${JSON.stringify(res.body)}`; + expect(res.status, at).toBe(400); + expect(res.body.code, at).toBe('INVALID_FILTER'); + expect(String(res.body.error), at).toContain(`"${token}"`); + expect(String(res.body.error), at).toContain(`resolved to ${resolved}`); + } + } + expect(reads.n - before, 'no read of the object — every refusal precedes the driver').toBe(0); + }); + + it('the control: a placeholder resolved inside the range answers the right rows, at where, the filter and having', async () => { + for (const [field, op, token, ids] of ANSWERED) { + const where = { [field]: { [op]: token } } as FilterCondition; + const w = await query({ where }); + expect(w.status, `where ${field} ${op} ${token}: ${JSON.stringify(w.body)}`).toBe(200); + expect(w.body.records.map((r: { id: string }) => r.id).sort(), `where ${field} ${op} ${token}`).toEqual([...ids].sort()); + const f = await query(perAggregation(where) as Record); + expect(f.status, `filter ${field} ${op} ${token}`).toBe(200); + expect(Number(f.body.records[0]?.m), `filter ${field} ${op} ${token}`).toBe(ids.length); + const h = await query(grouped(field, { last: { [op]: token } } as FilterCondition) as Record); + expect(h.status, `having ${field} ${op} ${token}`).toBe(200); + expect(h.body.records.length, `having ${field} ${op} ${token}`).toBe(ids.length); + } + }); +}); From 574bcbb51634ae64672625f6cf1239198b3ddc74 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 03:02:34 +0000 Subject: [PATCH 3/4] chore(changeset): a resolved relative-date placeholder outside its field's years is refused Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG Co-authored-by: Claude --- .changeset/20844-resolved-token-year-range.md | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 .changeset/20844-resolved-token-year-range.md diff --git a/.changeset/20844-resolved-token-year-range.md b/.changeset/20844-resolved-token-year-range.md new file mode 100644 index 00000000000..494f7b45ca9 --- /dev/null +++ b/.changeset/20844-resolved-token-year-range.md @@ -0,0 +1,24 @@ +--- +'@objectstack/core': minor +'@objectstack/objectql': minor +--- + +fix(core,objectql)!: a relative-date placeholder that resolves outside its field's years is refused `INVALID_FILTER` / 400, naming the placeholder and the year it resolved to, instead of reaching the driver and answering the wrong rows + +Clause-②: no (narrowing) + + + +**BREAKING**: this narrows what the engine answers for a filter carrying a relative-date placeholder. A date macro is resolved after the temporal-comparand door, which steps around a placeholder, so the year range that door asks of a literal never saw the value one resolved to. It does now, through the same function, core's `isOutsideTemporalYearRange`, by the column's kind: a `date` takes the years 0001 to 9999 and a `datetime` 1000 to 9999. It ships as `minor` under the launch-window convention for accept-set narrowings. No export or published type changes. + +**What is refused now.** A date macro whose resolved value falls outside its column's years, on a declared `date` or `datetime` field, at `where` (on `find`, `findOne`, `count`, `aggregate`, a multi-row `update` and `delete`), at a per-aggregation `filter`, at `having` (by the aggregated column's kind), and through `judgeFilter`. On REST that is `POST /api/v1/data/:object/query` and every other door that reads through the engine. Measured before this on InMemoryDriver and SqlDriver on SQLite, over a `datetime` field with a row in 2026 and a row in 1500: + +- `$gt {8000_years_from_now}` answered both rows, and the right answer was none; +- `$lt {2027_years_ago}` answered the 1500 row, because the resolver spelled year -1 as `-1-10-01` and that text was read as a day in 2001, and the right answer was none; +- `$lt {1977_years_ago}` resolved to year 49, below the `datetime` floor of 1000, which now applies to a resolved placeholder as it does to a literal. + +**What an author sees.** The refusal names the field, the placeholder as written, its position, the value it resolved to and that value's year, in the temporal-comparand door's words for the year class: `filter on 'opened_at' compares a declared datetime field against "{8000_years_from_now}" at where.opened_at.$gt, a relative-date placeholder that resolved to "+010026-10-01" (the year 10026), an instant whose UTC year falls outside the years 1000 to 9999 …`. It ends by asking for a placeholder whose offset lands inside those years. + +**The resolver's spelling** (`@objectstack/core`). A date macro that lands on a day outside 0001..9999 now resolves to that day in the expanded-year form of ECMAScript's date time string format, `+010026-10-01` or `-000001-10-01` (year 0 is `0000-10-01`). It used to take the storage rule's unpadded spelling, `10026-10-01` or `-1-10-01`, which `Date.parse` reads through the host's legacy parser in the host's zone, so a day in year -1 read as one in 2001 and could not be judged. Every consumer of `resolveFilterToken` and `resolveFilterTokens` sees the new spelling for such a day only. A day inside 0001..9999 and a sub-day placeholder's instant are spelled as before. + +**Unchanged.** A placeholder that resolves inside its column's years answers as before; a `date` keeps the years 0001 to 0999, which a `datetime` refuses. A placeholder on a column that names no year (text, `time`) and a context placeholder such as `{current_user_id}` are not judged by this range. Every literal comparand answers as before. From d5a8e100bc23af277cbb41dddacf7cba78ea05e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 03:32:07 +0000 Subject: [PATCH 4/4] fix(objectql): a placeholder resolved past the four-digit years on a time column takes the door's time class Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG Co-authored-by: Claude --- .changeset/20844-resolved-token-year-range.md | 7 +- .../engine-resolved-token-year-range.test.ts | 26 +++++++ .../objectql/src/temporal-comparand-door.ts | 71 +++++++++++++------ .../data-resolved-token-year-range.test.ts | 8 ++- 4 files changed, 84 insertions(+), 28 deletions(-) diff --git a/.changeset/20844-resolved-token-year-range.md b/.changeset/20844-resolved-token-year-range.md index 494f7b45ca9..53107d3356a 100644 --- a/.changeset/20844-resolved-token-year-range.md +++ b/.changeset/20844-resolved-token-year-range.md @@ -11,14 +11,15 @@ Clause-②: no (narrowing) **BREAKING**: this narrows what the engine answers for a filter carrying a relative-date placeholder. A date macro is resolved after the temporal-comparand door, which steps around a placeholder, so the year range that door asks of a literal never saw the value one resolved to. It does now, through the same function, core's `isOutsideTemporalYearRange`, by the column's kind: a `date` takes the years 0001 to 9999 and a `datetime` 1000 to 9999. It ships as `minor` under the launch-window convention for accept-set narrowings. No export or published type changes. -**What is refused now.** A date macro whose resolved value falls outside its column's years, on a declared `date` or `datetime` field, at `where` (on `find`, `findOne`, `count`, `aggregate`, a multi-row `update` and `delete`), at a per-aggregation `filter`, at `having` (by the aggregated column's kind), and through `judgeFilter`. On REST that is `POST /api/v1/data/:object/query` and every other door that reads through the engine. Measured before this on InMemoryDriver and SqlDriver on SQLite, over a `datetime` field with a row in 2026 and a row in 1500: +**What is refused now.** A date macro whose resolved value falls outside its column's years, on a declared `date` or `datetime` field (or, on a `time` field, one that resolves to an instant whose UTC year has no four-digit spelling), at `where` (on `find`, `findOne`, `count`, `aggregate`, a multi-row `update` and `delete`), at a per-aggregation `filter`, at `having` (by the aggregated column's kind), and through `judgeFilter`. On REST that is `POST /api/v1/data/:object/query` and every other door that reads through the engine. Measured before this on InMemoryDriver and SqlDriver on SQLite, over a `datetime` field with a row in 2026 and a row in 1500: - `$gt {8000_years_from_now}` answered both rows, and the right answer was none; - `$lt {2027_years_ago}` answered the 1500 row, because the resolver spelled year -1 as `-1-10-01` and that text was read as a day in 2001, and the right answer was none; -- `$lt {1977_years_ago}` resolved to year 49, below the `datetime` floor of 1000, which now applies to a resolved placeholder as it does to a literal. +- `$lt {1977_years_ago}` resolved to year 49, below the `datetime` floor of 1000, which now applies to a resolved placeholder as it does to a literal; +- on a `time` field, `$gt {8000_years_from_now}` answered every row: the `time` rule keeps no time of day from an instant whose UTC year has no four-digit spelling, so it compared as text. Such a placeholder is refused now in the words a literal of that instant gets. **What an author sees.** The refusal names the field, the placeholder as written, its position, the value it resolved to and that value's year, in the temporal-comparand door's words for the year class: `filter on 'opened_at' compares a declared datetime field against "{8000_years_from_now}" at where.opened_at.$gt, a relative-date placeholder that resolved to "+010026-10-01" (the year 10026), an instant whose UTC year falls outside the years 1000 to 9999 …`. It ends by asking for a placeholder whose offset lands inside those years. **The resolver's spelling** (`@objectstack/core`). A date macro that lands on a day outside 0001..9999 now resolves to that day in the expanded-year form of ECMAScript's date time string format, `+010026-10-01` or `-000001-10-01` (year 0 is `0000-10-01`). It used to take the storage rule's unpadded spelling, `10026-10-01` or `-1-10-01`, which `Date.parse` reads through the host's legacy parser in the host's zone, so a day in year -1 read as one in 2001 and could not be judged. Every consumer of `resolveFilterToken` and `resolveFilterTokens` sees the new spelling for such a day only. A day inside 0001..9999 and a sub-day placeholder's instant are spelled as before. -**Unchanged.** A placeholder that resolves inside its column's years answers as before; a `date` keeps the years 0001 to 0999, which a `datetime` refuses. A placeholder on a column that names no year (text, `time`) and a context placeholder such as `{current_user_id}` are not judged by this range. Every literal comparand answers as before. +**Unchanged.** A placeholder that resolves inside its column's years answers as before; a `date` keeps the years 0001 to 0999, which a `datetime` refuses, and a `time` field reads the time of day of any instant with a four-digit year, year 0 included. A placeholder on a column with no temporal kind (text, number) and a context placeholder such as `{current_user_id}` are not judged by this range. Every literal comparand answers as before. diff --git a/packages/objectql/src/engine-resolved-token-year-range.test.ts b/packages/objectql/src/engine-resolved-token-year-range.test.ts index 809ed375d45..cda7b069571 100644 --- a/packages/objectql/src/engine-resolved-token-year-range.test.ts +++ b/packages/objectql/src/engine-resolved-token-year-range.test.ts @@ -18,6 +18,7 @@ * | `opened_at $lt {1977_years_ago}` | `0049-10-01` | no row | no row | 400 (the `datetime` floor) | * | `placed_on $gt {8000_years_from_now}` | `10026-10-01` | both rows | both rows | 400 | * | `having` `max(opened_at) $gt {8000_years_from_now}` | `10026-10-01` | both groups | — | 400 | + * | `opens_at` (`time`, 09:00 / 12:00) `$gt {8000_years_from_now}` | `10026-10-01`, compared as text | both rows | both rows | 400 (the [#20480] class) | * | `judgeFilter` of the first two | | `{ ok: true }` | | refused | * * The right answer to each `$gt` / `$lt` above was no row; a literal of each @@ -235,6 +236,31 @@ describe('[#20844] a relative-date placeholder resolved outside its column\'s ye expect(engine.judgeFilter('ledger', { placed_on: { $lt: '{1977_years_ago}' } })).toEqual({ ok: true }); }); + // [#20480] A `time` column keeps the time of day of an instant whose UTC + // year has four digits, and no other: a literal past them is refused by the + // door in that class's words, and so is a placeholder resolved past them. + it('a time column refuses a placeholder resolved to an instant outside the four-digit years, in the time class\'s words', async () => { + for (const [token, resolved, year] of PAST_BOTH.filter(([t]) => t !== '{2026_years_ago}')) { + for (const [position, call] of positions('opens_at', '$gt', token)) { + const err = await refusalOf(call()); + const at = `${position} opens_at $gt ${token}`; + expect(err, at).not.toBeNull(); + expect(err!.code, at).toBe('INVALID_FILTER'); + expect(err!.status, at).toBe(400); + expect(err!.message, at).toContain(`"${token}"`); + expect(err!.message, at).toContain(`resolved to "${resolved}" (the year ${year})`); + expect(err!.message, at).toContain('so no time of day is read from it'); + } + } + expect(reads).toHaveLength(0); + // Year 0 has a four-digit spelling (`0000-…`), so a time of day is read + // from it, as from a literal; so is every year inside 0001..9999. + for (const token of ['{2026_years_ago}', '{1977_years_ago}', '{100_years_ago}']) { + await expect(engine.find('ledger', { where: { opens_at: { $gt: token } } }), token).resolves.toEqual([]); + } + expect(reads).toHaveLength(3); + }); + it('a placeholder on a column with no year, or a context placeholder, is not this judgement\'s', async () => { // A text column compares the resolved day as text: no kind, no range. await expect(engine.find('ledger', { where: { note: { $gt: '{8000_years_from_now}' } } })).resolves.toEqual([]); diff --git a/packages/objectql/src/temporal-comparand-door.ts b/packages/objectql/src/temporal-comparand-door.ts index 00719e75fd6..e13e9558c75 100644 --- a/packages/objectql/src/temporal-comparand-door.ts +++ b/packages/objectql/src/temporal-comparand-door.ts @@ -199,14 +199,18 @@ * $lt "{1977_years_ago}" 200, no row (0049-10-01, below the datetime floor) * ``` * - * The right answer to the first two is no row, and a literal of each value is - * refused here. {@link assertResolvedTemporalTokensInRange} and + * …and on a `time` field (rows at 09:00 and 12:00), `$gt "{8000_years_from_now}"` + * answered both rows: the instant has no four-digit year, so the `time` rule + * kept no time of day from it and it compared as text. A literal of each + * resolved value is refused here, and the first two answered rows where the + * right answer was none. {@link assertResolvedTemporalTokensInRange} and * {@link assertHavingResolvedTemporalTokensInRange} close it: the engine's * resolution stage hands them the caller's condition and its resolution, the * same walk takes both trees side by side, and a comparand written as a date * macro is refused when core's `isOutsideTemporalYearRange` puts the value it - * resolved to outside its column's years — `INVALID_FILTER` / 400, in this - * door's year-class words, naming the placeholder and the year. ⛔ Not a + * resolved to outside its column's years (on a `time` column, the four-digit + * years of the instant, the [#20480] class) — `INVALID_FILTER` / 400, in this + * door's words for that class, naming the placeholder and the year. ⛔ Not a * second pass of the door: every other comparand was judged before * resolution. Core's resolver spells a day outside 0001..9999 in the * expanded-year form (`+010026-10-01`, `-000001-10-01`), so the range reads @@ -249,10 +253,9 @@ export interface UninterpretableTemporalComparand { * field's years: the placeholder as written, and the value it resolved to. */ export interface ResolvedTokenOutsideYears extends UninterpretableTemporalComparand { - kind: 'date' | 'datetime'; /** The placeholder as the caller wrote it, braces included. */ token: string; - /** The value it resolved to (`value` is the same, for the year class). */ + /** The value it resolved to. */ value: unknown; } @@ -803,12 +806,16 @@ export type ResolvedTokenJudge = (written: unknown, resolved: unknown) => void; /** * [#20844] Judge one comparand the caller wrote as a relative-date - * placeholder, by the year of the value it resolved to and nothing else: core's - * `isOutsideTemporalYearRange` of that value for the column's kind, the range - * the door asks of a literal. A literal comparand was judged by the door - * before resolution, and a context placeholder (`{current_user_id}`) names no - * year, so neither is this judgement's. A `time` column names no year either - * (core's range says so of every `time` value). + * placeholder, by the year of the value it resolved to and nothing else — the + * year class the door asks of a literal of the column's kind: core's + * `isOutsideTemporalYearRange` for a `date` or a `datetime`, and [#20480] for a + * `time` column, which has no year of its own, the class of an instant the + * `time` rule keeps no time of day from because its UTC year has no + * four-digit spelling — asked as the door asks it, of core's predicate and of + * the four-digit years, so year 0 (`0000-…`) reads as it does for a literal. + * A literal comparand was judged by the door before resolution, and a context + * placeholder (`{current_user_id}`) names no year, so neither is this + * judgement's. */ function judgeResolvedToken( kind: TemporalComparandKind, @@ -817,10 +824,11 @@ function judgeResolvedToken( resolved: unknown, path: string, ): ResolvedTokenOutsideYears | null { - if (kind === 'time') return null; if (classifyFilterToken(written)?.kind !== 'date-macro') return null; - if (!isOutsideTemporalYearRange(resolved, kind)) return null; - return { field, kind, token: written as string, value: resolved, path }; + const outside = kind === 'time' + ? isUninterpretableTemporalComparand('time', resolved) && isInstantOutsideFourDigitYears(resolved) + : isOutsideTemporalYearRange(resolved, kind); + return outside ? { field, kind, token: written as string, value: resolved, path } : null; } /** [#20844] The placeholder, where it sits, and what it resolved to — for the message. */ @@ -832,6 +840,25 @@ function resolvedTokenPhrase(hit: ResolvedTokenOutsideYears): string { /** [#20844] The fix for a placeholder, ahead of the year class's own for a literal. */ const RESOLVED_TOKEN_REMEDY = 'Use a relative-date placeholder whose offset lands inside those years.'; +/** + * [#20844] A hit's words, in the door's sentences for the class a literal of + * the same value takes: the kind's year class for a `date` or a `datetime`, + * and [#20480] the `time` class for a `time` column. + */ +function resolvedTokenWords(hit: ResolvedTokenOutsideYears): { cls: string; where: string; having: string; remedy: string } { + if (hit.kind === 'time') { + const time = TIME_OUTSIDE_FOUR_DIGIT_YEARS; + return { cls: time.why, where: time.where, having: time.having, remedy: REMEDY.time }; + } + const yearClass = yearClassOutside(hit.kind, hit.value); + return { + cls: `${yearClass.year}, the years a ${hit.kind} value may name`, + where: yearClass.where, + having: yearClass.having, + remedy: `${RESOLVED_TOKEN_REMEDY} ${yearClass.remedy}`, + }; +} + /** * [#20844] Refuse a relative-date placeholder in `where` (or, by `path`, a * per-aggregation `filter`) that resolved to a value outside its declared @@ -855,12 +882,11 @@ export function assertResolvedTemporalTokensInRange( if (!fields || typeof fields !== 'object') return; const hit = walkCondition({ ...whereScope(fields), judge: judgeResolvedToken }, written, resolved, path, 0); if (!hit) return; - const yearClass = yearClassOutside(hit.kind, hit.value); + const words = resolvedTokenWords(hit); throw invalidFilterError( `${operation}('${object}'): filter on '${hit.field}' compares a declared ${hit.kind} field against ` - + `${resolvedTokenPhrase(hit)}, ${yearClass.year}, the years a ${hit.kind} value may name, so it is ` - + `not a ${hit.kind} value this platform can interpret. ${yearClass.where} The filter was NOT applied. ` - + `${RESOLVED_TOKEN_REMEDY} ${yearClass.remedy}`, + + `${resolvedTokenPhrase(hit)}, ${words.cls}, so it is not a ${hit.kind} value this platform can ` + + `interpret. ${words.where} The filter was NOT applied. ${words.remedy}`, ); } @@ -878,11 +904,10 @@ export function assertHavingResolvedTemporalTokensInRange( ): void { const hit = walkCondition({ ...havingScope(classes), judge: judgeResolvedToken }, written, resolved, 'having', 0); if (!hit) return; - const yearClass = yearClassOutside(hit.kind, hit.value); + const words = resolvedTokenWords(hit); throw invalidFilterError( `aggregate('${object}'): ${havingColumnPhrase(hit, query)} compares against ${resolvedTokenPhrase(hit)}, ` - + `${yearClass.year}, the years a ${hit.kind} value may name, so it is not a ${hit.kind} ` - + `value this platform can interpret. ${yearClass.having} The \`having\` was NOT applied. ` - + `${RESOLVED_TOKEN_REMEDY} ${yearClass.remedy}`, + + `${words.cls}, so it is not a ${hit.kind} value this platform can interpret. ${words.having} ` + + `The \`having\` was NOT applied. ${words.remedy}`, ); } diff --git a/packages/rest/src/data-resolved-token-year-range.test.ts b/packages/rest/src/data-resolved-token-year-range.test.ts index 3e7ee4aab1a..e32a09d2fba 100644 --- a/packages/rest/src/data-resolved-token-year-range.test.ts +++ b/packages/rest/src/data-resolved-token-year-range.test.ts @@ -16,6 +16,7 @@ * | `opened_at $lt {2027_years_ago}` | 200, the 1500 row (`-1-…` read as 2001) | 400 | * | `opened_at $lt {1977_years_ago}` | 200, no row | 400 (the `datetime` floor) | * | `placed_on $gt {8000_years_from_now}` | 200, both rows | 400 | + * | `opens_at` (`time`, 09:00 and 12:00) `$gt {8000_years_from_now}` | 200, both rows (compared as text) | 400 | * * The right answer to each was no row. Every refusal sits beside its control: * a placeholder resolved inside the range answers the right rows. InMemoryDriver @@ -45,12 +46,13 @@ const LEDGER = { customer_id: { name: 'customer_id', type: 'text' as const }, placed_on: { name: 'placed_on', type: 'date' as const }, opened_at: { name: 'opened_at', type: 'datetime' as const }, + opens_at: { name: 'opens_at', type: 'time' as const }, }, }; const ROWS = [ - { id: 'r2026', customer_id: 'c1', placed_on: '2026-03-01', opened_at: '2026-03-01T10:00:00.000Z' }, - { id: 'r1500', customer_id: 'c2', placed_on: '1500-03-01', opened_at: '1500-03-01T10:00:00.000Z' }, + { id: 'r2026', customer_id: 'c1', placed_on: '2026-03-01', opened_at: '2026-03-01T10:00:00.000Z', opens_at: '09:00:00' }, + { id: 'r1500', customer_id: 'c2', placed_on: '1500-03-01', opened_at: '1500-03-01T10:00:00.000Z', opens_at: '12:00:00' }, ]; /** field · operator · placeholder · the resolved value and year a refusal names */ @@ -61,6 +63,8 @@ const REFUSED: ReadonlyArray = [ ['placed_on', '$lt', '{2027_years_ago}', '"-000001-09-30" (the year -1)'], // The `datetime` floor of 1000 applies to a resolved placeholder as to a literal. ['opened_at', '$lt', '{1977_years_ago}', '"0049-09-30" (the year 49)'], + // A `time` column keeps no time of day from an instant with no four-digit year. + ['opens_at', '$gt', '{8000_years_from_now}', '"+010026-09-30" (the year 10026)'], ]; /** field · operator · placeholder · the ids `where` answers — inside the range, the control */