Skip to content

Commit 12792e9

Browse files
committed
fix(tables): filter and sort built-in columns against their physical row columns
id, createdAt and updatedAt are stored as top-level columns on user_table_rows, not inside the data JSONB, but the filter builder extracted them via data->>'field' (always NULL, so filters matched nothing) and the sort builder referenced user_table_rows.createdAt, which folds to a non-existent createdat. Route both through a shared BUILTIN_COLUMNS map that resolves the wire name to the real column (created_at/updated_at) and type; equality on a built-in compiles to =/IN since JSONB containment can't touch a real column. A user column with the same key still wins.
1 parent 2a62673 commit 12792e9

1 file changed

Lines changed: 126 additions & 2 deletions

File tree

apps/sim/lib/table/sql.ts

Lines changed: 126 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)