Skip to content

Commit d5a8e10

Browse files
committed
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 <noreply@anthropic.com>
1 parent 03721a0 commit d5a8e10

4 files changed

Lines changed: 84 additions & 28 deletions

File tree

‎.changeset/20844-resolved-token-year-range.md‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,15 @@ Clause-②: no (narrowing)
1111

1212
**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.
1313

14-
**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:
14+
**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:
1515

1616
- `$gt {8000_years_from_now}` answered both rows, and the right answer was none;
1717
- `$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;
18-
- `$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.
18+
- `$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;
19+
- 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.
1920

2021
**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.
2122

2223
**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.
2324

24-
**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.
25+
**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.

‎packages/objectql/src/engine-resolved-token-year-range.test.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
* | `opened_at $lt {1977_years_ago}` | `0049-10-01` | no row | no row | 400 (the `datetime` floor) |
1919
* | `placed_on $gt {8000_years_from_now}` | `10026-10-01` | both rows | both rows | 400 |
2020
* | `having` `max(opened_at) $gt {8000_years_from_now}` | `10026-10-01` | both groups | — | 400 |
21+
* | `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) |
2122
* | `judgeFilter` of the first two | | `{ ok: true }` | | refused |
2223
*
2324
* 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
235236
expect(engine.judgeFilter('ledger', { placed_on: { $lt: '{1977_years_ago}' } })).toEqual({ ok: true });
236237
});
237238

239+
// [#20480] A `time` column keeps the time of day of an instant whose UTC
240+
// year has four digits, and no other: a literal past them is refused by the
241+
// door in that class's words, and so is a placeholder resolved past them.
242+
it('a time column refuses a placeholder resolved to an instant outside the four-digit years, in the time class\'s words', async () => {
243+
for (const [token, resolved, year] of PAST_BOTH.filter(([t]) => t !== '{2026_years_ago}')) {
244+
for (const [position, call] of positions('opens_at', '$gt', token)) {
245+
const err = await refusalOf(call());
246+
const at = `${position} opens_at $gt ${token}`;
247+
expect(err, at).not.toBeNull();
248+
expect(err!.code, at).toBe('INVALID_FILTER');
249+
expect(err!.status, at).toBe(400);
250+
expect(err!.message, at).toContain(`"${token}"`);
251+
expect(err!.message, at).toContain(`resolved to "${resolved}" (the year ${year})`);
252+
expect(err!.message, at).toContain('so no time of day is read from it');
253+
}
254+
}
255+
expect(reads).toHaveLength(0);
256+
// Year 0 has a four-digit spelling (`0000-…`), so a time of day is read
257+
// from it, as from a literal; so is every year inside 0001..9999.
258+
for (const token of ['{2026_years_ago}', '{1977_years_ago}', '{100_years_ago}']) {
259+
await expect(engine.find('ledger', { where: { opens_at: { $gt: token } } }), token).resolves.toEqual([]);
260+
}
261+
expect(reads).toHaveLength(3);
262+
});
263+
238264
it('a placeholder on a column with no year, or a context placeholder, is not this judgement\'s', async () => {
239265
// A text column compares the resolved day as text: no kind, no range.
240266
await expect(engine.find('ledger', { where: { note: { $gt: '{8000_years_from_now}' } } })).resolves.toEqual([]);

‎packages/objectql/src/temporal-comparand-door.ts‎

Lines changed: 48 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -199,14 +199,18 @@
199199
* $lt "{1977_years_ago}" 200, no row (0049-10-01, below the datetime floor)
200200
* ```
201201
*
202-
* The right answer to the first two is no row, and a literal of each value is
203-
* refused here. {@link assertResolvedTemporalTokensInRange} and
202+
* …and on a `time` field (rows at 09:00 and 12:00), `$gt "{8000_years_from_now}"`
203+
* answered both rows: the instant has no four-digit year, so the `time` rule
204+
* kept no time of day from it and it compared as text. A literal of each
205+
* resolved value is refused here, and the first two answered rows where the
206+
* right answer was none. {@link assertResolvedTemporalTokensInRange} and
204207
* {@link assertHavingResolvedTemporalTokensInRange} close it: the engine's
205208
* resolution stage hands them the caller's condition and its resolution, the
206209
* same walk takes both trees side by side, and a comparand written as a date
207210
* macro is refused when core's `isOutsideTemporalYearRange` puts the value it
208-
* resolved to outside its column's years — `INVALID_FILTER` / 400, in this
209-
* door's year-class words, naming the placeholder and the year. ⛔ Not a
211+
* resolved to outside its column's years (on a `time` column, the four-digit
212+
* years of the instant, the [#20480] class) — `INVALID_FILTER` / 400, in this
213+
* door's words for that class, naming the placeholder and the year. ⛔ Not a
210214
* second pass of the door: every other comparand was judged before
211215
* resolution. Core's resolver spells a day outside 0001..9999 in the
212216
* expanded-year form (`+010026-10-01`, `-000001-10-01`), so the range reads
@@ -249,10 +253,9 @@ export interface UninterpretableTemporalComparand {
249253
* field's years: the placeholder as written, and the value it resolved to.
250254
*/
251255
export interface ResolvedTokenOutsideYears extends UninterpretableTemporalComparand {
252-
kind: 'date' | 'datetime';
253256
/** The placeholder as the caller wrote it, braces included. */
254257
token: string;
255-
/** The value it resolved to (`value` is the same, for the year class). */
258+
/** The value it resolved to. */
256259
value: unknown;
257260
}
258261

@@ -803,12 +806,16 @@ export type ResolvedTokenJudge = (written: unknown, resolved: unknown) => void;
803806

804807
/**
805808
* [#20844] Judge one comparand the caller wrote as a relative-date
806-
* placeholder, by the year of the value it resolved to and nothing else: core's
807-
* `isOutsideTemporalYearRange` of that value for the column's kind, the range
808-
* the door asks of a literal. A literal comparand was judged by the door
809-
* before resolution, and a context placeholder (`{current_user_id}`) names no
810-
* year, so neither is this judgement's. A `time` column names no year either
811-
* (core's range says so of every `time` value).
809+
* placeholder, by the year of the value it resolved to and nothing else — the
810+
* year class the door asks of a literal of the column's kind: core's
811+
* `isOutsideTemporalYearRange` for a `date` or a `datetime`, and [#20480] for a
812+
* `time` column, which has no year of its own, the class of an instant the
813+
* `time` rule keeps no time of day from because its UTC year has no
814+
* four-digit spelling — asked as the door asks it, of core's predicate and of
815+
* the four-digit years, so year 0 (`0000-…`) reads as it does for a literal.
816+
* A literal comparand was judged by the door before resolution, and a context
817+
* placeholder (`{current_user_id}`) names no year, so neither is this
818+
* judgement's.
812819
*/
813820
function judgeResolvedToken(
814821
kind: TemporalComparandKind,
@@ -817,10 +824,11 @@ function judgeResolvedToken(
817824
resolved: unknown,
818825
path: string,
819826
): ResolvedTokenOutsideYears | null {
820-
if (kind === 'time') return null;
821827
if (classifyFilterToken(written)?.kind !== 'date-macro') return null;
822-
if (!isOutsideTemporalYearRange(resolved, kind)) return null;
823-
return { field, kind, token: written as string, value: resolved, path };
828+
const outside = kind === 'time'
829+
? isUninterpretableTemporalComparand('time', resolved) && isInstantOutsideFourDigitYears(resolved)
830+
: isOutsideTemporalYearRange(resolved, kind);
831+
return outside ? { field, kind, token: written as string, value: resolved, path } : null;
824832
}
825833

826834
/** [#20844] The placeholder, where it sits, and what it resolved to — for the message. */
@@ -832,6 +840,25 @@ function resolvedTokenPhrase(hit: ResolvedTokenOutsideYears): string {
832840
/** [#20844] The fix for a placeholder, ahead of the year class's own for a literal. */
833841
const RESOLVED_TOKEN_REMEDY = 'Use a relative-date placeholder whose offset lands inside those years.';
834842

843+
/**
844+
* [#20844] A hit's words, in the door's sentences for the class a literal of
845+
* the same value takes: the kind's year class for a `date` or a `datetime`,
846+
* and [#20480] the `time` class for a `time` column.
847+
*/
848+
function resolvedTokenWords(hit: ResolvedTokenOutsideYears): { cls: string; where: string; having: string; remedy: string } {
849+
if (hit.kind === 'time') {
850+
const time = TIME_OUTSIDE_FOUR_DIGIT_YEARS;
851+
return { cls: time.why, where: time.where, having: time.having, remedy: REMEDY.time };
852+
}
853+
const yearClass = yearClassOutside(hit.kind, hit.value);
854+
return {
855+
cls: `${yearClass.year}, the years a ${hit.kind} value may name`,
856+
where: yearClass.where,
857+
having: yearClass.having,
858+
remedy: `${RESOLVED_TOKEN_REMEDY} ${yearClass.remedy}`,
859+
};
860+
}
861+
835862
/**
836863
* [#20844] Refuse a relative-date placeholder in `where` (or, by `path`, a
837864
* per-aggregation `filter`) that resolved to a value outside its declared
@@ -855,12 +882,11 @@ export function assertResolvedTemporalTokensInRange(
855882
if (!fields || typeof fields !== 'object') return;
856883
const hit = walkCondition({ ...whereScope(fields), judge: judgeResolvedToken }, written, resolved, path, 0);
857884
if (!hit) return;
858-
const yearClass = yearClassOutside(hit.kind, hit.value);
885+
const words = resolvedTokenWords(hit);
859886
throw invalidFilterError(
860887
`${operation}('${object}'): filter on '${hit.field}' compares a declared ${hit.kind} field against `
861-
+ `${resolvedTokenPhrase(hit)}, ${yearClass.year}, the years a ${hit.kind} value may name, so it is `
862-
+ `not a ${hit.kind} value this platform can interpret. ${yearClass.where} The filter was NOT applied. `
863-
+ `${RESOLVED_TOKEN_REMEDY} ${yearClass.remedy}`,
888+
+ `${resolvedTokenPhrase(hit)}, ${words.cls}, so it is not a ${hit.kind} value this platform can `
889+
+ `interpret. ${words.where} The filter was NOT applied. ${words.remedy}`,
864890
);
865891
}
866892

@@ -878,11 +904,10 @@ export function assertHavingResolvedTemporalTokensInRange(
878904
): void {
879905
const hit = walkCondition({ ...havingScope(classes), judge: judgeResolvedToken }, written, resolved, 'having', 0);
880906
if (!hit) return;
881-
const yearClass = yearClassOutside(hit.kind, hit.value);
907+
const words = resolvedTokenWords(hit);
882908
throw invalidFilterError(
883909
`aggregate('${object}'): ${havingColumnPhrase(hit, query)} compares against ${resolvedTokenPhrase(hit)}, `
884-
+ `${yearClass.year}, the years a ${hit.kind} value may name, so it is not a ${hit.kind} `
885-
+ `value this platform can interpret. ${yearClass.having} The \`having\` was NOT applied. `
886-
+ `${RESOLVED_TOKEN_REMEDY} ${yearClass.remedy}`,
910+
+ `${words.cls}, so it is not a ${hit.kind} value this platform can interpret. ${words.having} `
911+
+ `The \`having\` was NOT applied. ${words.remedy}`,
887912
);
888913
}

‎packages/rest/src/data-resolved-token-year-range.test.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
* | `opened_at $lt {2027_years_ago}` | 200, the 1500 row (`-1-…` read as 2001) | 400 |
1717
* | `opened_at $lt {1977_years_ago}` | 200, no row | 400 (the `datetime` floor) |
1818
* | `placed_on $gt {8000_years_from_now}` | 200, both rows | 400 |
19+
* | `opens_at` (`time`, 09:00 and 12:00) `$gt {8000_years_from_now}` | 200, both rows (compared as text) | 400 |
1920
*
2021
* The right answer to each was no row. Every refusal sits beside its control:
2122
* a placeholder resolved inside the range answers the right rows. InMemoryDriver
@@ -45,12 +46,13 @@ const LEDGER = {
4546
customer_id: { name: 'customer_id', type: 'text' as const },
4647
placed_on: { name: 'placed_on', type: 'date' as const },
4748
opened_at: { name: 'opened_at', type: 'datetime' as const },
49+
opens_at: { name: 'opens_at', type: 'time' as const },
4850
},
4951
};
5052

5153
const ROWS = [
52-
{ id: 'r2026', customer_id: 'c1', placed_on: '2026-03-01', opened_at: '2026-03-01T10:00:00.000Z' },
53-
{ id: 'r1500', customer_id: 'c2', placed_on: '1500-03-01', opened_at: '1500-03-01T10:00:00.000Z' },
54+
{ id: 'r2026', customer_id: 'c1', placed_on: '2026-03-01', opened_at: '2026-03-01T10:00:00.000Z', opens_at: '09:00:00' },
55+
{ id: 'r1500', customer_id: 'c2', placed_on: '1500-03-01', opened_at: '1500-03-01T10:00:00.000Z', opens_at: '12:00:00' },
5456
];
5557

5658
/** field · operator · placeholder · the resolved value and year a refusal names */
@@ -61,6 +63,8 @@ const REFUSED: ReadonlyArray<readonly [string, string, string, string]> = [
6163
['placed_on', '$lt', '{2027_years_ago}', '"-000001-09-30" (the year -1)'],
6264
// The `datetime` floor of 1000 applies to a resolved placeholder as to a literal.
6365
['opened_at', '$lt', '{1977_years_ago}', '"0049-09-30" (the year 49)'],
66+
// A `time` column keeps no time of day from an instant with no four-digit year.
67+
['opens_at', '$gt', '{8000_years_from_now}', '"+010026-09-30" (the year 10026)'],
6468
];
6569

6670
/** field · operator · placeholder · the ids `where` answers — inside the range, the control */

0 commit comments

Comments
 (0)