Skip to content

Commit d48a0d6

Browse files
os-zhuangCopilot
andcommitted
fix(driver-sql): coerce ISO/Date filter values for Field.datetime columns
Field.datetime() values stored via better-sqlite3 land as INTEGER milliseconds, but filter macros and most user input arrive as ISO date strings ('2026-01-01') or JS Date objects. SQLite's type affinity rules turn TEXT-vs-INTEGER comparisons into numeric compares of leading digits, so WHERE published_at >= '2026-01-01' -- 2026 < 1778… → no rows silently matched nothing. Charts that bypass filters happened to work, masking the issue everywhere except KPI / metric widgets. This change tracks date/datetime columns per table during initObjects() and adds coerceFilterValue() to normalise the right-hand side of every operator inside applyFilters() and applyFilterCondition(): - Field.datetime → INTEGER milliseconds (Date.getTime(), parsed ISO, numeric strings, or already-numeric inputs all converge). - Field.date → YYYY-MM-DD TEXT (so JS Date objects don't leak as full ISO timestamps and break lexicographic comparison). Adds sql-driver-datetime-filter.test.ts covering $gte/$lt range, full ISO timestamps, JS Date and numeric epoch inputs across both column types. All 118 existing driver-sql tests still pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent b4c74a9 commit d48a0d6

2 files changed

Lines changed: 217 additions & 20 deletions

File tree

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Regression: filters on `Field.datetime()` columns must work even when
5+
* the comparand is an ISO date string. The platform stores datetime
6+
* values as INTEGER milliseconds (via better-sqlite3's Date binding),
7+
* so `where: { published_at: { $gte: '2026-01-01' } }` used to compile
8+
* to a TEXT-vs-INTEGER affinity compare that never matched.
9+
*
10+
* This suite covers ISO string, full ISO timestamp, JS Date and numeric
11+
* inputs for both `Field.datetime()` and `Field.date()` columns.
12+
*/
13+
14+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
15+
import { SqlDriver } from '../src/index.js';
16+
17+
describe('SqlDriver datetime filter coercion', () => {
18+
let driver: SqlDriver;
19+
20+
beforeEach(async () => {
21+
driver = new SqlDriver({
22+
client: 'better-sqlite3',
23+
connection: { filename: ':memory:' },
24+
useNullAsDefault: true,
25+
});
26+
27+
await driver.initObjects([
28+
{
29+
name: 'publication',
30+
fields: {
31+
title: { type: 'string' },
32+
published_at: { type: 'datetime' },
33+
period_start: { type: 'date' },
34+
views: { type: 'integer' },
35+
},
36+
},
37+
]);
38+
39+
// Insert with real Date objects so better-sqlite3 stores them as
40+
// INTEGER milliseconds — the exact path the seed loader takes via
41+
// `cel\`daysAgo(N)\``.
42+
await driver.create('publication', {
43+
id: 'p1', title: 'Old', views: 100,
44+
published_at: new Date('2025-01-15T00:00:00Z'),
45+
period_start: '2025-01-15',
46+
}, { bypassTenantAudit: true });
47+
await driver.create('publication', {
48+
id: 'p2', title: 'New', views: 200,
49+
published_at: new Date('2026-03-20T12:00:00Z'),
50+
period_start: '2026-03-20',
51+
}, { bypassTenantAudit: true });
52+
await driver.create('publication', {
53+
id: 'p3', title: 'Newer', views: 300,
54+
published_at: new Date('2026-05-25T08:00:00Z'),
55+
period_start: '2026-05-25',
56+
}, { bypassTenantAudit: true });
57+
});
58+
59+
afterEach(async () => {
60+
await driver.disconnect();
61+
});
62+
63+
it('matches datetime $gte against an ISO date string', async () => {
64+
const rows = await driver.find('publication', {
65+
where: { published_at: { $gte: '2026-01-01' } },
66+
orderBy: [{ field: 'published_at', order: 'asc' }],
67+
});
68+
expect(rows.map((r: any) => r.id)).toEqual(['p2', 'p3']);
69+
});
70+
71+
it('matches datetime range with $gte / $lt', async () => {
72+
const rows = await driver.find('publication', {
73+
where: { published_at: { $gte: '2026-01-01', $lt: '2026-05-01' } },
74+
});
75+
expect(rows.map((r: any) => r.id)).toEqual(['p2']);
76+
});
77+
78+
it('accepts a full ISO timestamp', async () => {
79+
const rows = await driver.find('publication', {
80+
where: { published_at: { $gte: '2026-05-25T00:00:00.000Z' } },
81+
});
82+
expect(rows.map((r: any) => r.id)).toEqual(['p3']);
83+
});
84+
85+
it('accepts a JS Date object', async () => {
86+
const rows = await driver.find('publication', {
87+
where: { published_at: { $gte: new Date('2026-01-01T00:00:00Z') } },
88+
});
89+
expect(rows.map((r: any) => r.id).sort()).toEqual(['p2', 'p3']);
90+
});
91+
92+
it('accepts a numeric epoch millisecond value', async () => {
93+
const ms = Date.parse('2026-01-01T00:00:00Z');
94+
const rows = await driver.find('publication', {
95+
where: { published_at: { $gte: ms } },
96+
});
97+
expect(rows.map((r: any) => r.id).sort()).toEqual(['p2', 'p3']);
98+
});
99+
100+
it('still filters non-date columns normally', async () => {
101+
const rows = await driver.find('publication', {
102+
where: { views: { $gte: 200 } },
103+
});
104+
expect(rows.map((r: any) => r.id).sort()).toEqual(['p2', 'p3']);
105+
});
106+
107+
it('matches date (YYYY-MM-DD) columns with ISO comparand', async () => {
108+
const rows = await driver.find('publication', {
109+
where: { period_start: { $gte: '2026-01-01' } },
110+
});
111+
expect(rows.map((r: any) => r.id).sort()).toEqual(['p2', 'p3']);
112+
});
113+
});

packages/plugins/driver-sql/src/sql-driver.ts

Lines changed: 104 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,8 @@ export class SqlDriver implements IDataDriver {
141141
protected config: Knex.Config;
142142
protected jsonFields: Record<string, string[]> = {};
143143
protected booleanFields: Record<string, string[]> = {};
144+
protected dateFields: Record<string, Set<string>> = {};
145+
protected datetimeFields: Record<string, Set<string>> = {};
144146
protected tablesWithTimestamps: Set<string> = new Set();
145147
/**
146148
* Autonumber field configs per table, captured during initObjects.
@@ -1019,6 +1021,12 @@ export class SqlDriver implements IDataDriver {
10191021
if (type === 'boolean') {
10201022
booleanCols.push(name);
10211023
}
1024+
if (type === 'date') {
1025+
(this.dateFields[tableName] ??= new Set()).add(name);
1026+
}
1027+
if (type === 'datetime') {
1028+
(this.datetimeFields[tableName] ??= new Set()).add(name);
1029+
}
10221030
if (type === 'auto_number' || type === 'autonumber') {
10231031
const fmt = typeof field.format === 'string' && field.format
10241032
? field.format
@@ -1247,8 +1255,81 @@ export class SqlDriver implements IDataDriver {
12471255

12481256
// ── Filter helpers ──────────────────────────────────────────────────────────
12491257

1258+
/**
1259+
* Resolve the underlying table name for a Knex query builder so we can
1260+
* look up column type metadata (date/datetime maps populated during
1261+
* `initObjects`). Returns null when the builder is not table-scoped yet.
1262+
*/
1263+
protected tableNameForBuilder(builder: any): string | null {
1264+
const t = builder?._single?.table;
1265+
if (typeof t === 'string') return t;
1266+
return null;
1267+
}
1268+
1269+
/**
1270+
* Normalise a filter value for a single column so the comparison the
1271+
* driver sends to SQLite matches the on-disk representation.
1272+
*
1273+
* The platform stores `Field.datetime()` values as INTEGER milliseconds
1274+
* (the result of passing a JS `Date` through better-sqlite3) but date
1275+
* macros like `{last_quarter_start}` expand to an ISO `YYYY-MM-DD` string
1276+
* client-side. Without coercion the SQL becomes `published_at >= '2026-…'`
1277+
* which collapses to a TEXT-vs-INTEGER affinity compare and never
1278+
* matches. We translate the ISO/Date/numeric inputs into the storage
1279+
* type so the comparison works.
1280+
*
1281+
* For `Field.date()` we keep ISO TEXT but normalise Date objects to
1282+
* `YYYY-MM-DD` for the same reason.
1283+
*/
1284+
protected coerceFilterValue(table: string | null, field: string, value: any): any {
1285+
if (value == null || !table) return value;
1286+
if (Array.isArray(value)) return value.map((v) => this.coerceFilterValue(table, field, v));
1287+
1288+
const isDatetime = this.datetimeFields[table]?.has(field);
1289+
const isDate = this.dateFields[table]?.has(field);
1290+
if (!isDatetime && !isDate) return value;
1291+
1292+
const toMs = (v: any): number | null => {
1293+
if (v instanceof Date) return v.getTime();
1294+
if (typeof v === 'number' && Number.isFinite(v)) return v;
1295+
if (typeof v === 'string') {
1296+
const trimmed = v.trim();
1297+
if (trimmed === '') return null;
1298+
if (/^-?\d+$/.test(trimmed)) {
1299+
const n = Number(trimmed);
1300+
if (Number.isFinite(n)) return n;
1301+
}
1302+
// Treat bare YYYY-MM-DD as start-of-day UTC; full ISO is parsed
1303+
// as-is so timezones round-trip correctly.
1304+
const iso = /^\d{4}-\d{2}-\d{2}$/.test(trimmed) ? `${trimmed}T00:00:00.000Z` : trimmed;
1305+
const n = Date.parse(iso);
1306+
return Number.isFinite(n) ? n : null;
1307+
}
1308+
return null;
1309+
};
1310+
1311+
if (isDatetime) {
1312+
const ms = toMs(value);
1313+
return ms == null ? value : ms;
1314+
}
1315+
1316+
// Field.date — normalise to YYYY-MM-DD.
1317+
if (value instanceof Date) {
1318+
const y = value.getUTCFullYear();
1319+
const m = String(value.getUTCMonth() + 1).padStart(2, '0');
1320+
const d = String(value.getUTCDate()).padStart(2, '0');
1321+
return `${y}-${m}-${d}`;
1322+
}
1323+
if (typeof value === 'string') {
1324+
const trimmed = value.trim();
1325+
if (/^\d{4}-\d{2}-\d{2}/.test(trimmed)) return trimmed.slice(0, 10);
1326+
}
1327+
return value;
1328+
}
1329+
12501330
protected applyFilters(builder: Knex.QueryBuilder, filters: any) {
12511331
if (!filters) return;
1332+
const table = this.tableNameForBuilder(builder);
12521333

12531334
if (!Array.isArray(filters) && typeof filters === 'object') {
12541335
const hasMongoOperators = Object.keys(filters).some(
@@ -1260,13 +1341,13 @@ export class SqlDriver implements IDataDriver {
12601341
);
12611342

12621343
if (hasMongoOperators) {
1263-
this.applyFilterCondition(builder, filters);
1344+
this.applyFilterCondition(builder, filters, 'and', table);
12641345
return;
12651346
}
12661347

12671348
for (const [key, value] of Object.entries(filters)) {
12681349
if (['limit', 'offset', 'fields', 'orderBy'].includes(key)) continue;
1269-
builder.where(key, value as any);
1350+
builder.where(key, this.coerceFilterValue(table, key, value) as any);
12701351
}
12711352
return;
12721353
}
@@ -1288,6 +1369,7 @@ export class SqlDriver implements IDataDriver {
12881369

12891370
if (isCriterion) {
12901371
const field = this.mapSortField(fieldRaw);
1372+
const coerced = this.coerceFilterValue(table, field, value);
12911373
const apply = (b: any) => {
12921374
const method = nextJoin === 'or' ? 'orWhere' : 'where';
12931375
const methodIn = nextJoin === 'or' ? 'orWhereIn' : 'whereIn';
@@ -1300,19 +1382,19 @@ export class SqlDriver implements IDataDriver {
13001382

13011383
switch (op) {
13021384
case '=':
1303-
b[method](field, value);
1385+
b[method](field, coerced);
13041386
break;
13051387
case '!=':
1306-
b[method](field, '<>', value);
1388+
b[method](field, '<>', coerced);
13071389
break;
13081390
case 'in':
1309-
b[methodIn](field, value);
1391+
b[methodIn](field, coerced);
13101392
break;
13111393
case 'nin':
1312-
b[methodNotIn](field, value);
1394+
b[methodNotIn](field, coerced);
13131395
break;
13141396
default:
1315-
b[method](field, op, value);
1397+
b[method](field, op, coerced);
13161398
}
13171399
};
13181400
apply(builder);
@@ -1328,15 +1410,16 @@ export class SqlDriver implements IDataDriver {
13281410
}
13291411
}
13301412

1331-
protected applyFilterCondition(builder: Knex.QueryBuilder, condition: any, logicalOp: 'and' | 'or' = 'and') {
1413+
protected applyFilterCondition(builder: Knex.QueryBuilder, condition: any, logicalOp: 'and' | 'or' = 'and', tableHint?: string | null) {
13321414
if (!condition || typeof condition !== 'object') return;
1415+
const table = tableHint ?? this.tableNameForBuilder(builder);
13331416

13341417
for (const [key, value] of Object.entries(condition)) {
13351418
if (key === '$and' && Array.isArray(value)) {
13361419
builder.where((qb) => {
13371420
for (const sub of value) {
13381421
qb.where((subQb) => {
1339-
this.applyFilterCondition(subQb, sub, 'and');
1422+
this.applyFilterCondition(subQb, sub, 'and', table);
13401423
});
13411424
}
13421425
});
@@ -1345,54 +1428,55 @@ export class SqlDriver implements IDataDriver {
13451428
(builder as any)[method]((qb: any) => {
13461429
for (const sub of value) {
13471430
qb.orWhere((subQb: any) => {
1348-
this.applyFilterCondition(subQb, sub, 'or');
1431+
this.applyFilterCondition(subQb, sub, 'or', table);
13491432
});
13501433
}
13511434
});
13521435
} else if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
13531436
const field = this.mapSortField(key);
13541437
for (const [op, opValue] of Object.entries(value as Record<string, any>)) {
13551438
const method = logicalOp === 'or' ? 'orWhere' : 'where';
1439+
const coerced = this.coerceFilterValue(table, field, opValue);
13561440
switch (op) {
13571441
case '$eq':
1358-
(builder as any)[method](field, opValue);
1442+
(builder as any)[method](field, coerced);
13591443
break;
13601444
case '$ne':
1361-
(builder as any)[method](field, '<>', opValue);
1445+
(builder as any)[method](field, '<>', coerced);
13621446
break;
13631447
case '$gt':
1364-
(builder as any)[method](field, '>', opValue);
1448+
(builder as any)[method](field, '>', coerced);
13651449
break;
13661450
case '$gte':
1367-
(builder as any)[method](field, '>=', opValue);
1451+
(builder as any)[method](field, '>=', coerced);
13681452
break;
13691453
case '$lt':
1370-
(builder as any)[method](field, '<', opValue);
1454+
(builder as any)[method](field, '<', coerced);
13711455
break;
13721456
case '$lte':
1373-
(builder as any)[method](field, '<=', opValue);
1457+
(builder as any)[method](field, '<=', coerced);
13741458
break;
13751459
case '$in': {
13761460
const mIn = logicalOp === 'or' ? 'orWhereIn' : 'whereIn';
1377-
(builder as any)[mIn](field, opValue as any[]);
1461+
(builder as any)[mIn](field, coerced as any[]);
13781462
break;
13791463
}
13801464
case '$nin': {
13811465
const mNotIn = logicalOp === 'or' ? 'orWhereNotIn' : 'whereNotIn';
1382-
(builder as any)[mNotIn](field, opValue as any[]);
1466+
(builder as any)[mNotIn](field, coerced as any[]);
13831467
break;
13841468
}
13851469
case '$contains':
13861470
(builder as any)[method](field, 'like', `%${opValue}%`);
13871471
break;
13881472
default:
1389-
(builder as any)[method](field, opValue);
1473+
(builder as any)[method](field, coerced);
13901474
}
13911475
}
13921476
} else {
13931477
const field = this.mapSortField(key);
13941478
const method = logicalOp === 'or' ? 'orWhere' : 'where';
1395-
(builder as any)[method](field, value as any);
1479+
(builder as any)[method](field, this.coerceFilterValue(table, field, value) as any);
13961480
}
13971481
}
13981482
}

0 commit comments

Comments
 (0)