@@ -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