Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions apps/sim/app/api/table/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,10 @@ async function checkTableAccess(tableId: string, userId: string): Promise<TableA
return { hasAccess: true, table }
}

return { hasAccess: false, reason: 'User does not have access to this table' }
return {
hasAccess: false,
reason: 'User does not have access to this table',
}
}

/**
Expand All @@ -175,7 +178,10 @@ async function checkTableWriteAccess(tableId: string, userId: string): Promise<T
return { hasAccess: true, table }
}

return { hasAccess: false, reason: 'User does not have write access to this table' }
return {
hasAccess: false,
reason: 'User does not have write access to this table',
}
}

/**
Expand Down Expand Up @@ -281,3 +287,10 @@ export function normalizeColumn(col: ColumnDefinition): ColumnDefinition {
...(col.workflowGroupId ? { workflowGroupId: col.workflowGroupId } : {}),
}
}

export function isBuiltInDateField(field: string): boolean {
if (['created_at', 'updated_at', 'createdAt', 'updatedAt'].includes(field)) {
return true
}
return false
}
Comment thread
tusharmotwani1718 marked this conversation as resolved.
32 changes: 29 additions & 3 deletions apps/sim/lib/table/__tests__/sql.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,9 @@ describe('SQL Builder', () => {
it('handles nested $or and $and', () => {
const out = render(
buildFilterClause(
{ $or: [{ $and: [{ status: 'active' }, { verified: true }] }, { role: 'admin' }] },
{
$or: [{ $and: [{ status: 'active' }, { verified: true }] }, { role: 'admin' }],
},
TABLE,
NO_COLUMNS
)
Expand Down Expand Up @@ -297,7 +299,9 @@ describe('SQL Builder', () => {
it('propagates date cast through nested $and', () => {
const out = render(
buildFilterClause(
{ $and: [{ birthDate: { $gte: '2024-01-01' } }, { birthDate: { $lt: '2025-01-01' } }] },
{
$and: [{ birthDate: { $gte: '2024-01-01' } }, { birthDate: { $lt: '2025-01-01' } }],
},
TABLE,
dateCols
)
Expand All @@ -309,7 +313,9 @@ describe('SQL Builder', () => {
it('propagates date cast through nested $or', () => {
const out = render(
buildFilterClause(
{ $or: [{ birthDate: { $lt: '2000-01-01' } }, { birthDate: { $gt: '2024-01-01' } }] },
{
$or: [{ birthDate: { $lt: '2000-01-01' } }, { birthDate: { $gt: '2024-01-01' } }],
},
TABLE,
dateCols
)
Expand Down Expand Up @@ -399,6 +405,26 @@ describe('SQL Builder', () => {
)
})

it('uses direct timestamp column for created_at comparisons', () => {
const out = render(
buildFilterClause({ created_at: { $gt: '2026-07-20' } }, TABLE, NO_COLUMNS)
)

expect(out).toContain(`${TABLE}.created_at`)
expect(out).not.toContain(`data->>'created_at'`)
expect(out).toContain('::timestamptz')
})

it('uses direct column equality for created_at', () => {
const out = render(
buildFilterClause({ created_at: { $eq: '2026-07-20' } }, TABLE, NO_COLUMNS)
)

expect(out).toContain(`${TABLE}.created_at`)
expect(out).toContain('=')
expect(out).not.toContain(`${TABLE}.data @>`)
})

it('combines multiple sort fields with commas', () => {
const cols: ColumnDefinition[] = [
{ name: 'name', type: 'string' },
Expand Down
21 changes: 19 additions & 2 deletions apps/sim/lib/table/sql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type {
JsonValue,
Sort,
} from '@/lib/table/types'
import { isBuiltInDateField } from '@/app/api/table/utils.ts'

/**
* Error thrown when caller-supplied filter or sort input is malformed.
Expand Down Expand Up @@ -380,7 +381,9 @@ function buildFieldCondition(

case '$ncontains':
conditions.push(
buildLikeClause(tableName, field, value as string, 'contains', { negate: true })
buildLikeClause(tableName, field, value as string, 'contains', {
negate: true,
})
)
break

Expand Down Expand Up @@ -458,8 +461,15 @@ function buildLogicalClause(

/** Builds JSONB containment clause: `data @> '{"field": value}'::jsonb` (uses GIN index) */
function buildContainmentClause(tableName: string, field: string, value: JsonValue): SQL {
if (isBuiltInDateField(field)) {
return sql`${sql.raw(`${tableName}.${field}`)} = ${value}::timestamptz`
}

const jsonObj = JSON.stringify({ [field]: value })
return sql`${sql.raw(`${tableName}.data`)} @> ${jsonObj}::jsonb`

// const jsonObj = JSON.stringify({ [field]: value })
// return sql`${sql.raw(`${tableName}.data`)} @> ${jsonObj}::jsonb`
}

/**
Expand All @@ -485,10 +495,17 @@ function buildComparisonClause(
value: number | string,
columnType: ColumnType | undefined
): SQL {
if (isBuiltInDateField(field)) {
columnType = 'date'
}

const escapedField = field.replace(/'/g, "''")
const cast = jsonbCastForType(columnType) ?? 'numeric'
validateComparisonValue(field, columnType, cast, value)
const cell = sql.raw(`(${tableName}.data->>'${escapedField}')::${cast}`)

const cell = isBuiltInDateField(field)
? sql.raw(`${tableName}.${field}`)
: sql.raw(`(${tableName}.data->>'${escapedField}')::${cast}`)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CamelCase fields hit wrong SQL columns

High Severity

isBuiltInDateField treats createdAt/updatedAt as built-in columns, then buildContainmentClause and buildComparisonClause interpolate that name via sql.raw into SQL. Postgres columns are created_at/updated_at, so camelCase filters resolve to a non-existent column and the query fails. The UI and sort path already use camelCase.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 56cb60b. Configure here.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the columns created_at/updated_at exist by default in a table. while fetching rows by query_rows, they exist in row.created_at/row.updated_at not inside row.data.columnName as for normal columns.

return cast === 'timestamptz'
? sql`${cell} ${sql.raw(operator)} ${value}::timestamptz`
: sql`${cell} ${sql.raw(operator)} ${value}`
Expand Down