Skip to content

Commit 4dcb910

Browse files
committed
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 <noreply@anthropic.com>
1 parent 68febc1 commit 4dcb910

3 files changed

Lines changed: 506 additions & 0 deletions

File tree

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#20844] A date macro that lands outside the years 0001..9999 resolves to a
4+
// day spelled in the expanded-year form (`+010026-09-30`, `-000001-09-30`),
5+
// so core's one range reads the year it resolved to, on every host. Before,
6+
// it took the storage rule's unpadded spelling (`10026-09-30`, `-1-09-30`),
7+
// which `Date.parse` reads through the host's legacy parser, in the host's
8+
// zone: `-1-09-30` read as a day in 2001, so `isOutsideTemporalYearRange`
9+
// judged `{2027_years_ago}` inside the range, for both kinds.
10+
//
11+
// Pins: both sides of 0001..9999, year 0, the edges inside, a 2026 control and
12+
// a sub-day instant (spelled by `toISOString` already), in UTC and in
13+
// Asia/Shanghai. The engine refuses each one outside its field's years;
14+
// objectql's `engine-resolved-token-year-range.test.ts` pins that half.
15+
16+
import { describe, it, expect, beforeEach, afterAll } from 'vitest';
17+
import { resolveFilterToken, resolveFilterTokens } from './filter-tokens.js';
18+
import { isOutsideTemporalYearRange } from './temporal-storage-form.js';
19+
20+
// Wed 2026-09-30 12:00 UTC: the same calendar day in UTC and in Asia/Shanghai.
21+
const NOW = new Date('2026-09-30T12:00:00.000Z');
22+
23+
const at = (token: string, timezone?: string) => resolveFilterToken(token, { now: NOW, timezone });
24+
25+
/** The UTC year `Date.parse` reads a resolved value as. */
26+
const parsedYear = (value: unknown) => new Date(Date.parse(String(value))).getUTCFullYear();
27+
28+
const HOSTS = ['UTC', 'Asia/Shanghai'] as const;
29+
const originalTz = process.env.TZ;
30+
afterAll(() => {
31+
if (originalTz === undefined) delete process.env.TZ;
32+
else process.env.TZ = originalTz;
33+
});
34+
35+
describe.each(HOSTS)('on a %s host', (host) => {
36+
beforeEach(() => {
37+
process.env.TZ = host;
38+
expect(Intl.DateTimeFormat().resolvedOptions().timeZone).toBe(host);
39+
});
40+
41+
describe('[#20844] a day outside 0001..9999 is spelled so the range reads the year it resolved to', () => {
42+
it.each([
43+
['8000_years_from_now', '+010026-09-30', 10026],
44+
['7974_years_from_now', '+010000-09-30', 10000],
45+
['2027_years_ago', '-000001-09-30', -1],
46+
['2026_years_ago', '0000-09-30', 0],
47+
['100000_months_from_now', '+010360-01-30', 10360],
48+
])('{%s} is %s, year %i, outside both kinds\' years', (token, expected, year) => {
49+
for (const tz of [undefined, 'UTC', 'Asia/Shanghai']) {
50+
const day = at(token, tz);
51+
expect(day, `${token} in ${tz ?? 'the default zone'}`).toBe(expected);
52+
expect(parsedYear(day)).toBe(year);
53+
expect(isOutsideTemporalYearRange(day, 'date')).toBe(true);
54+
expect(isOutsideTemporalYearRange(day, 'datetime')).toBe(true);
55+
}
56+
});
57+
58+
it.each([
59+
['7973_years_from_now', '9999-09-30', false],
60+
['2025_years_ago', '0001-09-30', true],
61+
['1026_years_ago', '1000-09-30', false],
62+
['1027_years_ago', '0999-09-30', true],
63+
['1_year_ago', '2025-09-30', false],
64+
])('{%s} is %s: inside a date\'s years, and outside a datetime\'s: %s', (token, expected, outsideDatetime) => {
65+
const day = at(token);
66+
expect(day).toBe(expected);
67+
expect(isOutsideTemporalYearRange(day, 'date')).toBe(false);
68+
expect(isOutsideTemporalYearRange(day, 'datetime')).toBe(outsideDatetime);
69+
});
70+
71+
it('a sub-day placeholder past 9999 keeps the instant toISOString spells', () => {
72+
const instant = at('80000000_hours_from_now');
73+
expect(instant).toBe(new Date(NOW.getTime() + 80_000_000 * 3_600_000).toISOString());
74+
expect(String(instant).startsWith('+011153-')).toBe(true);
75+
expect(isOutsideTemporalYearRange(instant, 'datetime')).toBe(true);
76+
});
77+
78+
it('resolveFilterTokens carries the same spelling into a filter tree', () => {
79+
expect(resolveFilterTokens({ opened_at: { $lt: '{2027_years_ago}' } }, { now: NOW })).toEqual({
80+
opened_at: { $lt: '-000001-09-30' },
81+
});
82+
});
83+
});
84+
});
Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#20844] A relative-date placeholder is judged by the year of the value it
5+
* resolved to: outside its column's years (a `date` 0001..9999, a `datetime`
6+
* 1000..9999 — `@objectstack/core`'s `isOutsideTemporalYearRange`) it is
7+
* refused `INVALID_FILTER` / 400, naming the placeholder and the year, on
8+
* every position the resolver serves, before any driver read.
9+
*
10+
* Measured on the base (`2f2fa11d7`) through `engine.find` on InMemoryDriver
11+
* and `POST /api/v1/data/:object/query` on SqlDriver over SQLite, two rows
12+
* (`opened_at` 2026-03-01T10:00Z and 1500-03-01T10:00Z):
13+
*
14+
* | `where` | resolved to | memory | SQLite | now |
15+
* |:--|:--|:--|:--|:--|
16+
* | `opened_at $gt {8000_years_from_now}` | `10026-10-01` | both rows | both rows | 400 |
17+
* | `opened_at $lt {2027_years_ago}` | `-1-10-01`, read as 2001-01-10 | the 1500 row | the 1500 row | 400 |
18+
* | `opened_at $lt {1977_years_ago}` | `0049-10-01` | no row | no row | 400 (the `datetime` floor) |
19+
* | `placed_on $gt {8000_years_from_now}` | `10026-10-01` | both rows | both rows | 400 |
20+
* | `having` `max(opened_at) $gt {8000_years_from_now}` | `10026-10-01` | both groups | — | 400 |
21+
* | `judgeFilter` of the first two | | `{ ok: true }` | | refused |
22+
*
23+
* The right answer to each `$gt` / `$lt` above was no row; a literal of each
24+
* resolved value was already refused by the temporal-comparand door, which
25+
* steps around a placeholder. The refusal sits in front of every driver, so
26+
* this file's recording driver is enough to pin it (the memory row by
27+
* construction: the refusal answers before a driver is asked); the REST door
28+
* over SQLite, with the rows, is `packages/rest/src/data-resolved-token-year-range.test.ts`.
29+
* Every refusal sits beside its control: a placeholder that resolves inside
30+
* the range reaches the driver as the day it names.
31+
*/
32+
33+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
34+
import { ObjectQL } from './engine.js';
35+
36+
// Wed 2026-09-30 12:00 UTC: every resolved day below is read off this instant.
37+
const PINNED_NOW = new Date('2026-09-30T12:00:00.000Z');
38+
39+
const ledger = {
40+
name: 'ledger',
41+
label: 'Ledger',
42+
fields: {
43+
id: { name: 'id', type: 'text' as const, primaryKey: true },
44+
customer_id: { name: 'customer_id', type: 'text' as const },
45+
placed_on: { name: 'placed_on', type: 'date' as const },
46+
opened_at: { name: 'opened_at', type: 'datetime' as const },
47+
opens_at: { name: 'opens_at', type: 'time' as const },
48+
note: { name: 'note', type: 'text' as const },
49+
},
50+
};
51+
52+
/** placeholder · what it resolves to off PINNED_NOW · the year a refusal names */
53+
const PAST_BOTH: ReadonlyArray<readonly [string, string, string]> = [
54+
['{8000_years_from_now}', '+010026-09-30', '10026'],
55+
['{7974_years_from_now}', '+010000-09-30', '10000'],
56+
['{2027_years_ago}', '-000001-09-30', '-1'],
57+
['{2026_years_ago}', '0000-09-30', '0'],
58+
// A sub-day placeholder resolves to an instant.
59+
['{80000000_hours_from_now}', new Date(PINNED_NOW.getTime() + 80_000_000 * 3_600_000).toISOString(), '11153'],
60+
];
61+
62+
/** Outside a `datetime`'s years only: before its floor, inside a `date`'s. */
63+
const BEFORE_DATETIME_FLOOR: ReadonlyArray<readonly [string, string, string]> = [
64+
['{1977_years_ago}', '0049-09-30', '49'],
65+
['{1027_years_ago}', '0999-09-30', '999'],
66+
];
67+
68+
/** Inside both kinds' years — the edges and a control — each reaches the driver as the day it names. */
69+
const INSIDE: ReadonlyArray<readonly [string, string]> = [
70+
['{1026_years_ago}', '1000-09-30'],
71+
['{7973_years_from_now}', '9999-09-30'],
72+
['{100_years_ago}', '1926-09-30'],
73+
];
74+
75+
/** A driver that records every read, and answers none. */
76+
function makeRecordingDriver() {
77+
const reads: unknown[] = [];
78+
const driver: any = {
79+
name: 'recording', version: '0.0.0', supports: {},
80+
async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; },
81+
async find(_o: string, ast: unknown) { reads.push(ast); return []; },
82+
async findOne(_o: string, ast: unknown) { reads.push(ast); return null; },
83+
async count(_o: string, ast: unknown) { reads.push(ast); return 0; },
84+
async aggregate(_o: string, ast: unknown) { reads.push(ast); return []; },
85+
async create(_o: string, data: Record<string, unknown>) { return { ...data }; },
86+
async update(_o: string, id: string, data: Record<string, unknown>) { return { ...data, id }; },
87+
async updateMany(_o: string, ast: unknown) { reads.push(ast); return 0; },
88+
async delete() { return true; },
89+
async deleteMany(_o: string, ast: unknown) { reads.push(ast); return 0; },
90+
async bulkCreate(_o: string, batch: Record<string, unknown>[]) { return batch; },
91+
async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; },
92+
async commit() {}, async rollback() {},
93+
};
94+
return { driver, reads };
95+
}
96+
97+
const refusalOf = async (p: Promise<unknown>) =>
98+
p.then(() => null, (e: any) => e as Error & { code?: string; status?: number });
99+
100+
describe('[#20844] a relative-date placeholder resolved outside its column\'s years is refused, on every position, before any read', () => {
101+
let engine: ObjectQL;
102+
let reads: unknown[];
103+
104+
beforeEach(async () => {
105+
vi.useFakeTimers({ toFake: ['Date'] });
106+
vi.setSystemTime(PINNED_NOW);
107+
const rec = makeRecordingDriver();
108+
reads = rec.reads;
109+
engine = new ObjectQL();
110+
engine.registerDriver(rec.driver, true);
111+
await engine.init();
112+
engine.registry.registerObject(ledger, 'test');
113+
});
114+
115+
afterEach(() => {
116+
vi.useRealTimers();
117+
});
118+
119+
/** Every verb and position that resolves a placeholder in a caller's condition. */
120+
const positions = (field: string, op: string, token: string) => [
121+
['find where', () => engine.find('ledger', { where: { [field]: { [op]: token } } })],
122+
['findOne where', () => engine.findOne('ledger', { where: { [field]: { [op]: token } } })],
123+
['count where', () => engine.count('ledger', { where: { [field]: { [op]: token } } })],
124+
['update where', () => engine.update('ledger', { note: 'x' }, { where: { [field]: { [op]: token } }, multi: true } as never)],
125+
['delete where', () => engine.delete('ledger', { where: { [field]: { [op]: token } }, multi: true } as never)],
126+
['aggregate where', () => engine.aggregate('ledger', {
127+
where: { [field]: { [op]: token } },
128+
aggregations: [{ function: 'count', alias: 'n' }],
129+
} as never)],
130+
['per-aggregation filter', () => engine.aggregate('ledger', {
131+
aggregations: [{ function: 'count', alias: 'n' }, { function: 'count', alias: 'm', filter: { [field]: { [op]: token } } }],
132+
} as never)],
133+
['having', () => engine.aggregate('ledger', {
134+
groupBy: ['customer_id'],
135+
aggregations: [{ function: 'max', field, alias: 'last' }],
136+
having: { last: { [op]: token } },
137+
} as never)],
138+
] as const;
139+
140+
for (const [field, kind] of [['opened_at', 'datetime'], ['placed_on', 'date']] as const) {
141+
it(`${kind}: a placeholder resolved outside 0001..9999 is INVALID_FILTER / 400 on every position, naming the placeholder and the year`, async () => {
142+
for (const [token, resolved, year] of PAST_BOTH) {
143+
for (const op of ['$gt', '$lt'] as const) {
144+
for (const [position, call] of positions(field, op, token)) {
145+
const err = await refusalOf(call());
146+
const at = `${position} ${field} ${op} ${token}`;
147+
expect(err, at).not.toBeNull();
148+
expect(err!.code, at).toBe('INVALID_FILTER');
149+
expect(err!.status, at).toBe(400);
150+
expect(err!.message, at).toContain(`"${token}"`);
151+
expect(err!.message, at).toContain(`resolved to "${resolved}"`);
152+
expect(err!.message, at).toContain(`(the year ${year})`);
153+
expect(err!.message, at).toContain(kind === 'date' ? 'the years 0001 to 9999' : 'the years 1000 to 9999');
154+
}
155+
}
156+
}
157+
expect(reads, 'no read — every refusal precedes the driver').toHaveLength(0);
158+
});
159+
}
160+
161+
it('the refusal is rooted at the position the placeholder sits in, in the door\'s words for that position', async () => {
162+
const where = await refusalOf(engine.find('ledger', { where: { opened_at: { $gt: '{8000_years_from_now}' } } }));
163+
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");
164+
expect(where!.message).toContain('does not sort as an instant');
165+
expect(where!.message).toContain('The filter was NOT applied.');
166+
const filter = await refusalOf(engine.aggregate('ledger', {
167+
aggregations: [{ function: 'count', alias: 'n' }, { function: 'count', alias: 'm', filter: { opened_at: { $gt: '{8000_years_from_now}' } } }],
168+
} as never));
169+
expect(filter!.message).toContain('at aggregations[1].filter.opened_at.$gt');
170+
const having = await refusalOf(engine.aggregate('ledger', {
171+
groupBy: ['customer_id'],
172+
aggregations: [{ function: 'max', field: 'opened_at', alias: 'last' }],
173+
having: { last: { $lt: '{2027_years_ago}' } },
174+
} as never));
175+
expect(having!.message).toContain("`having` on 'last' (max(opened_at), a datetime column) compares against \"{2027_years_ago}\" at having.last.$lt");
176+
expect(having!.message).toContain('The `having` was NOT applied.');
177+
expect(reads).toHaveLength(0);
178+
});
179+
180+
it('a list member, a range bound, the implicit-equality slot and a nested branch are judged too', async () => {
181+
for (const where of [
182+
{ opened_at: '{8000_years_from_now}' },
183+
{ opened_at: { $in: ['{100_years_ago}', '{2027_years_ago}'] } },
184+
{ opened_at: { $between: ['{100_years_ago}', '{8000_years_from_now}'] } },
185+
{ $or: [{ customer_id: 'x' }, { $not: { opened_at: { $gte: '{2027_years_ago}' } } }] },
186+
[['opened_at', '>', '{8000_years_from_now}']],
187+
]) {
188+
const err = await refusalOf(engine.find('ledger', { where: where as never }));
189+
expect(err, JSON.stringify(where)).toMatchObject({ code: 'INVALID_FILTER', status: 400 });
190+
}
191+
expect(reads).toHaveLength(0);
192+
});
193+
194+
it('the datetime floor of 1000 applies to a resolved placeholder as to a literal — a date keeps those years', async () => {
195+
for (const [token, resolved, year] of BEFORE_DATETIME_FLOOR) {
196+
const err = await refusalOf(engine.find('ledger', { where: { opened_at: { $lt: token } } }));
197+
expect(err, token).toMatchObject({ code: 'INVALID_FILTER', status: 400 });
198+
expect(err!.message).toContain(`resolved to "${resolved}" (the year ${year})`);
199+
expect(err!.message).toContain('Before year 1000');
200+
expect(err!.message).not.toContain('does not sort');
201+
// The control: the same placeholder on a `date` reaches the driver as its day.
202+
const before = reads.length;
203+
await engine.find('ledger', { where: { placed_on: { $lt: token } } });
204+
expect(reads.length, `${token} on a date reached the driver`).toBe(before + 1);
205+
expect(JSON.stringify(reads[before]), token).toContain(`"${resolved}"`);
206+
}
207+
});
208+
209+
it('the control: a placeholder that resolves inside the range reaches the driver as the day it names, on every position', async () => {
210+
for (const [token, resolved] of INSIDE) {
211+
for (const field of ['opened_at', 'placed_on'] as const) {
212+
for (const [position, call] of positions(field, '$gt', token)) {
213+
const before = reads.length;
214+
await expect(call(), `${position} ${field} ${token}`).resolves.toBeDefined();
215+
// A per-aggregation filter and `having` are evaluated by the engine
216+
// over the rows the driver reads; the resolved day reaches the driver
217+
// on the rest. (The REST suite reads their counts over SQLite.)
218+
if (position === 'having' || position === 'per-aggregation filter') continue;
219+
expect(reads.length, `${position} ${field} ${token} reached the driver`).toBeGreaterThan(before);
220+
expect(JSON.stringify(reads.slice(before)), `${position} ${field} ${token}`).toContain(resolved);
221+
}
222+
}
223+
}
224+
});
225+
226+
it('the judge (`judgeFilter`) refuses what execution refuses, with the same code, status and message', async () => {
227+
for (const [token] of PAST_BOTH) {
228+
const where = { opened_at: { $gt: token } };
229+
const judged = engine.judgeFilter('ledger', where);
230+
const executed = await refusalOf(engine.find('ledger', { where }));
231+
expect(judged, token).toEqual({ ok: false, code: 'INVALID_FILTER', status: 400, message: executed!.message });
232+
}
233+
expect(engine.judgeFilter('ledger', { opened_at: { $gt: '{100_years_ago}' } })).toEqual({ ok: true });
234+
expect(engine.judgeFilter('ledger', { opened_at: { $lt: '{1977_years_ago}' } })).toMatchObject({ ok: false, code: 'INVALID_FILTER', status: 400 });
235+
expect(engine.judgeFilter('ledger', { placed_on: { $lt: '{1977_years_ago}' } })).toEqual({ ok: true });
236+
});
237+
238+
it('a placeholder on a column with no year, or a context placeholder, is not this judgement\'s', async () => {
239+
// A text column compares the resolved day as text: no kind, no range.
240+
await expect(engine.find('ledger', { where: { note: { $gt: '{8000_years_from_now}' } } })).resolves.toEqual([]);
241+
expect(JSON.stringify(reads.at(-1))).toContain('+010026-09-30');
242+
// A context placeholder names no year.
243+
await expect(engine.find('ledger', { where: { customer_id: '{current_user_id}' }, context: { userId: 'u1' } } as never)).resolves.toEqual([]);
244+
expect(reads).toHaveLength(2);
245+
});
246+
});

0 commit comments

Comments
 (0)