Skip to content

Commit 5317b5a

Browse files
committed
fix(objectql): the where seam lowers type-blind for an object with no field map
declaredDatetimeLowering read "no field map" as "no datetime column" and left the whole-day rule to each driver's own copy. ADR-0053 D-D1 item 5 (as amended) retires those copies, and item 7 says what a seam that cannot read the declared type does: "applies the rewrite type-blind". So the absent-map branch now omits the reader and the whole-day rules apply to every column of such an object. An object with a field map keeps the typed scope byte-identical. Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0bd0e6d commit 5317b5a

2 files changed

Lines changed: 139 additions & 4 deletions

File tree

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#20822 · ADR-0053 D-D1 item 7, as amended] The engine's `where` seam has no
5+
* declared type to read for an object with no field map, and item 7 says what
6+
* such a seam does: "A seam that cannot applies the rewrite type-blind." So a
7+
* bare-day upper bound on ANY column of such an object leaves the engine
8+
* already lowered — `$lte` a day becomes `$lt` the next day, and `$between`
9+
* splits — on every verb that reads the object's declarations.
10+
*
11+
* Before #20822 this branch read "no field map" as "no datetime column" and
12+
* left the rule to the driver's own copy. Item 5 retires those copies, so that
13+
* reading would have dropped the whole-day bound on every such read: measured
14+
* on `driver-memory` with its copy deleted, an unregistered object's `$lte` a
15+
* day went from the whole day to midnight only.
16+
*
17+
* An object WITH a field map keeps the typed scope byte-identical (the control
18+
* below; `engine-shared-filter-lowering-seam.test.ts` pins the rest of it).
19+
*
20+
* The witness is the recording driver's `where`, as in the seam's own pin.
21+
*/
22+
23+
import { beforeEach, describe, expect, it } from 'vitest';
24+
import type { EngineAggregateOptions } from '@objectstack/spec/data';
25+
import { ObjectQL } from './engine.js';
26+
27+
const UNREGISTERED = 'no_field_map_probe';
28+
const REGISTERED = 'typed_probe';
29+
30+
const TYPED_SCHEMA = {
31+
name: REGISTERED,
32+
label: 'Typed probe',
33+
fields: {
34+
id: { name: 'id', type: 'text' },
35+
note: { name: 'note', type: 'text' },
36+
closed_at: { name: 'closed_at', type: 'datetime' },
37+
due_on: { name: 'due_on', type: 'date' },
38+
},
39+
};
40+
41+
interface Seen { verb: string; ast: any }
42+
43+
function makeRecordingDriver() {
44+
const seen: Seen[] = [];
45+
const driver: any = {
46+
name: 'recording', version: '0.0.0', supports: {},
47+
async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; },
48+
async find(_o: string, ast: any) { seen.push({ verb: 'find', ast }); return []; },
49+
async findOne(_o: string, ast: any) { seen.push({ verb: 'findOne', ast }); return null; },
50+
async count(_o: string, ast: any) { seen.push({ verb: 'count', ast }); return 0; },
51+
};
52+
return { driver, seen };
53+
}
54+
55+
describe('[#20822] an object with no field map: the engine seam lowers type-blind (ADR-0053 D-D1 item 7)', () => {
56+
let engine: ObjectQL;
57+
let seen: Seen[];
58+
59+
beforeEach(async () => {
60+
const rec = makeRecordingDriver();
61+
seen = rec.seen;
62+
engine = new ObjectQL();
63+
engine.registerDriver(rec.driver, true);
64+
await engine.init();
65+
engine.registry.registerObject(TYPED_SCHEMA as any, 'test');
66+
seen.length = 0;
67+
});
68+
69+
const lastWhere = (verb: string) => [...seen].reverse().find((s) => s.verb === verb)?.ast?.where;
70+
71+
it('the fixture: the probe object really has no registry entry', () => {
72+
expect(engine.registry.getObject(UNREGISTERED)).toBeUndefined();
73+
expect(engine.registry.getObject(REGISTERED)).toBeDefined();
74+
});
75+
76+
it('find: a bare-day `$lte` on any column reaches the driver as `$lt` the next day', async () => {
77+
await engine.find(UNREGISTERED, { where: { closed_at: { $lte: '2026-07-28' } } });
78+
expect(lastWhere('find')).toEqual({ closed_at: { $lt: '2026-07-29' } });
79+
// Type-blind means every column: no declaration says `note` is not a datetime.
80+
await engine.find(UNREGISTERED, { where: { note: { $lte: '2026-07-28' } } });
81+
expect(lastWhere('find')).toEqual({ note: { $lt: '2026-07-29' } });
82+
});
83+
84+
it('find: `$between` with a bare-day maximum splits and widens its upper end', async () => {
85+
await engine.find(UNREGISTERED, { where: { closed_at: { $between: ['2026-07-01', '2026-07-28'] } } });
86+
expect(lastWhere('find')).toEqual({ closed_at: { $gte: '2026-07-01', $lt: '2026-07-29' } });
87+
});
88+
89+
it('findOne and count take the same reading', async () => {
90+
await engine.findOne(UNREGISTERED, { where: { closed_at: { $lte: '2026-07-28' } } });
91+
expect(lastWhere('findOne') ?? lastWhere('find')).toEqual({ closed_at: { $lt: '2026-07-29' } });
92+
await engine.count(UNREGISTERED, { where: { closed_at: { $lte: '2026-07-28' } } });
93+
expect(lastWhere('count')).toEqual({ closed_at: { $lt: '2026-07-29' } });
94+
});
95+
96+
it('aggregate: its `where` takes the same reading', async () => {
97+
await engine.aggregate(UNREGISTERED, {
98+
where: { closed_at: { $lte: '2026-07-28' } },
99+
groupBy: ['note'],
100+
aggregations: [{ function: 'count', alias: 'n' }],
101+
} as EngineAggregateOptions);
102+
expect(lastWhere('find')).toEqual({ closed_at: { $lt: '2026-07-29' } });
103+
});
104+
105+
it('an instant, and `$gte` / `$lt` on a bare day, are never widened', async () => {
106+
const where = { closed_at: { $lte: '2026-07-28T12:00:00.000Z' }, note: { $gte: '2026-07-01', $lt: '2026-07-28' } };
107+
await engine.find(UNREGISTERED, { where });
108+
expect(lastWhere('find')).toBe(where);
109+
});
110+
111+
it('control — a field map keeps the typed scope: only the declared datetime is rewritten', async () => {
112+
const where = { closed_at: { $lte: '2026-07-28' }, due_on: { $lte: '2026-07-28' }, note: { $lte: '2026-07-28' } };
113+
await engine.find(REGISTERED, { where });
114+
expect(lastWhere('find')).toEqual({
115+
closed_at: { $lt: '2026-07-29' },
116+
due_on: { $lte: '2026-07-28' },
117+
note: { $lte: '2026-07-28' },
118+
});
119+
});
120+
121+
it('the judge runs the same stage and gains no verdict from it', () => {
122+
expect(engine.judgeFilter(UNREGISTERED, { closed_at: { $between: ['2026-07-01', '2026-07-28'] } })).toEqual({ ok: true });
123+
});
124+
});

‎packages/objectql/src/engine.ts‎

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1190,7 +1190,9 @@ function resolveWhereFilterTokens<W>(
11901190
* object's declarations, so the whole-day rule rewrites a declared
11911191
* `datetime` column only ({@link declaredDatetimeLowering}) — the scope
11921192
* `SqlDriver` holds, so a `date`, `time` or non-temporal column reaches every
1193-
* driver byte-identical to before (the amendment's item 7).
1193+
* driver byte-identical to before (the amendment's item 7). An object with no
1194+
* field map has no declarations to read, and there the rule applies
1195+
* type-blind (item 7's other half).
11941196
*
11951197
* Returns the input by reference when nothing resolved and nothing lowered.
11961198
*/
@@ -1207,12 +1209,21 @@ function resolveThenLowerWhere<W>(
12071209
* positions: a column is `datetime` exactly when the object's declared field
12081210
* map says `type: 'datetime'` — the same test `SqlDriver` indexes its
12091211
* `datetimeFields` by, so the columns this seam widens are a subset of the
1210-
* columns every face widens today. No field map (a registry-less host) reads
1211-
* no column as `datetime`: the whole-day rule is then left to the faces, as it
1212-
* was, rather than applied type-blind to columns no driver would widen.
1212+
* columns every face widens today.
1213+
*
1214+
* [#20822] No field map (an object the registry does not hold, a
1215+
* registry-less host) is a seam that cannot read the declared type, and item 7
1216+
* says what such a seam does: "applies the rewrite type-blind", as the
1217+
* type-blind emitters do (sound on `Field.date` text, where `< next-day`
1218+
* orders exactly as `<= day`). So the reader is omitted and the whole-day
1219+
* rules apply to every column. This branch used to read "no field map" as "no
1220+
* datetime column" and leave the rule to the faces; item 5 retires those
1221+
* copies, and with them gone that reading would have dropped the whole-day
1222+
* bound on every such read.
12131223
*/
12141224
function declaredDatetimeLowering(schema: unknown): FilterLoweringOptions {
12151225
const fields = (schema as { fields?: unknown } | undefined)?.fields;
1226+
if (fields === null || typeof fields !== 'object') return {};
12161227
return {
12171228
isDatetimeColumn: (column) =>
12181229
fields !== null

0 commit comments

Comments
 (0)