@@ -70,6 +70,27 @@ function buildColumnMap(columns: ColumnDefinition[]): ColumnMap {
7070 return new Map ( columns . map ( ( col ) => [ getColumnId ( col ) , col ] ) )
7171}
7272
73+ /**
74+ * Row columns that live as real top-level columns on `user_table_rows` rather than
75+ * inside the `data` JSONB. Filter and sort must reference these by their physical
76+ * column name (`created_at`, not the `createdAt` wire name) — extracting them from
77+ * JSONB (`data->>'createdAt'`) always yields NULL, so filtering on them silently
78+ * matched nothing and sorting pointed at a column that doesn't exist. `type` drives
79+ * the same value casts a user column of that type gets.
80+ */
81+ const BUILTIN_COLUMNS : Record < string , { column : string ; type : ColumnType } > = {
82+ id : { column : 'id' , type : 'string' } ,
83+ createdAt : { column : 'created_at' , type : 'date' } ,
84+ updatedAt : { column : 'updated_at' , type : 'date' } ,
85+ }
86+
87+ const RANGE_OPERATORS : Record < string , '>' | '>=' | '<' | '<=' > = {
88+ $gt : '>' ,
89+ $gte : '>=' ,
90+ $lt : '<' ,
91+ $lte : '<=' ,
92+ }
93+
7394/**
7495 * Whitelist of allowed operators for query filtering.
7596 * Only these operators can be used in filter conditions.
@@ -321,6 +342,13 @@ function buildFieldCondition(
321342) : SQL [ ] {
322343 validateFieldName ( field )
323344
345+ // Built-in columns (id/createdAt/updatedAt) live top-level, not in `data` — a user
346+ // column with the same key still wins so real cell data is never shadowed.
347+ const builtin = column ? undefined : BUILTIN_COLUMNS [ field ]
348+ if ( builtin ) {
349+ return buildBuiltinFieldCondition ( tableName , field , condition , builtin )
350+ }
351+
324352 const columnType = column ?. type
325353 const isSelect = columnType === 'select'
326354 const isMultiSelect = isSelect && column ?. multiple === true
@@ -456,6 +484,99 @@ function buildFieldCondition(
456484 return conditions
457485}
458486
487+ /**
488+ * Builds conditions for a {@link BUILTIN_COLUMNS} field against its physical
489+ * top-level column instead of the JSONB `data` blob. Equality compiles to `=`/`IN`
490+ * (the JSONB containment operator the data path uses can't touch a real column);
491+ * date columns cast the comparison value to `timestamptz` so ISO strings compare
492+ * chronologically, mirroring the `data` date path. Built-in columns are `NOT NULL`,
493+ * so `$empty` and the pattern operators need no null-inclusion handling.
494+ */
495+ function buildBuiltinFieldCondition (
496+ tableName : string ,
497+ field : string ,
498+ condition : JsonValue | ConditionOperators ,
499+ builtin : { column : string ; type : ColumnType }
500+ ) : SQL [ ] {
501+ const cell = sql . raw ( `${ tableName } .${ builtin . column } ` )
502+ const cast = jsonbCastForType ( builtin . type )
503+ const bind = ( value : JsonValue ) : SQL =>
504+ cast === 'timestamptz' ? sql `${ value } ::timestamptz` : sql `${ value } `
505+
506+ if ( ! isRecordLike ( condition ) ) {
507+ // Simple value — shorthand for equality, e.g. { createdAt: '2024-01-01T00:00:00Z' }.
508+ return [ sql `${ cell } = ${ bind ( condition as JsonValue ) } ` ]
509+ }
510+
511+ const conditions : SQL [ ] = [ ]
512+ for ( const [ op , value ] of Object . entries ( condition ) ) {
513+ validateOperator ( op )
514+
515+ const rangeOp = RANGE_OPERATORS [ op ]
516+ if ( rangeOp ) {
517+ if ( cast ) validateComparisonValue ( field , builtin . type , cast , value as number | string )
518+ conditions . push ( sql `${ cell } ${ sql . raw ( rangeOp ) } ${ bind ( value as JsonValue ) } ` )
519+ continue
520+ }
521+
522+ switch ( op ) {
523+ case '$eq' :
524+ conditions . push ( sql `${ cell } = ${ bind ( value as JsonValue ) } ` )
525+ break
526+
527+ case '$ne' :
528+ conditions . push ( sql `${ cell } <> ${ bind ( value as JsonValue ) } ` )
529+ break
530+
531+ case '$in' :
532+ if ( Array . isArray ( value ) && value . length > 0 ) {
533+ const items = value . map ( ( v ) => bind ( v as JsonValue ) )
534+ conditions . push ( sql `${ cell } IN (${ sql . join ( items , sql . raw ( ', ' ) ) } )` )
535+ }
536+ break
537+
538+ case '$nin' :
539+ if ( Array . isArray ( value ) && value . length > 0 ) {
540+ const items = value . map ( ( v ) => bind ( v as JsonValue ) )
541+ conditions . push ( sql `${ cell } NOT IN (${ sql . join ( items , sql . raw ( ', ' ) ) } )` )
542+ }
543+ break
544+
545+ case '$empty' :
546+ conditions . push (
547+ coerceEmptyFlag ( field , value ) ? sql `${ cell } IS NULL` : sql `${ cell } IS NOT NULL`
548+ )
549+ break
550+
551+ case '$contains' :
552+ case '$ncontains' :
553+ case '$startsWith' :
554+ case '$endsWith' : {
555+ const text = String ( value )
556+ if ( text . length === 0 ) {
557+ throw new TableQueryValidationError (
558+ `${ op } on column "${ field } " requires a non-empty value`
559+ )
560+ }
561+ const escaped = escapeLikePattern ( text )
562+ const pattern =
563+ op === '$startsWith' ? `${ escaped } %` : op === '$endsWith' ? `%${ escaped } ` : `%${ escaped } %`
564+ conditions . push (
565+ op === '$ncontains'
566+ ? sql `${ cell } ::text NOT ILIKE ${ pattern } `
567+ : sql `${ cell } ::text ILIKE ${ pattern } `
568+ )
569+ break
570+ }
571+
572+ default :
573+ throw new Error ( `Unsupported operator: ${ op } ` )
574+ }
575+ }
576+
577+ return conditions
578+ }
579+
459580/**
460581 * Builds SQL clauses from nested filters and joins them with the specified operator.
461582 *
@@ -654,8 +775,11 @@ function buildSortFieldClause(
654775 const escapedField = field . replace ( / ' / g, "''" )
655776 const directionSql = direction . toUpperCase ( )
656777
657- if ( field === 'createdAt' || field === 'updatedAt' ) {
658- return sql . raw ( `${ tableName } .${ escapedField } ${ directionSql } ` )
778+ // Built-in columns (id/createdAt/updatedAt) order by their physical top-level
779+ // column; a user column with the same key still wins. NOT NULL, so no NULLS LAST.
780+ const builtin = column ? undefined : BUILTIN_COLUMNS [ field ]
781+ if ( builtin ) {
782+ return sql . raw ( `${ tableName } .${ builtin . column } ${ directionSql } ` )
659783 }
660784
661785 const jsonbExtract = `${ tableName } .data->>'${ escapedField } '`
0 commit comments