diff --git a/apps/sim/app/api/table/[tableId]/rows/route.ts b/apps/sim/app/api/table/[tableId]/rows/route.ts index 9b0076a127d..e8dbfe97c3a 100644 --- a/apps/sim/app/api/table/[tableId]/rows/route.ts +++ b/apps/sim/app/api/table/[tableId]/rows/route.ts @@ -266,8 +266,10 @@ export const GET = withRouteHandler( eq(userTableRows.workspaceId, validated.workspaceId), ] + const schema = table.schema as TableSchema + if (validated.filter) { - const filterClause = buildFilterClause(validated.filter as Filter, USER_TABLE_ROWS_SQL_NAME) + const filterClause = buildFilterClause(validated.filter as Filter, USER_TABLE_ROWS_SQL_NAME, schema.columns) if (filterClause) { baseConditions.push(filterClause) } @@ -286,7 +288,6 @@ export const GET = withRouteHandler( .where(and(...baseConditions)) if (validated.sort) { - const schema = table.schema as TableSchema const sortClause = buildSortClause(validated.sort, USER_TABLE_ROWS_SQL_NAME, schema.columns) if (sortClause) { query = query.orderBy(sortClause) as typeof query @@ -509,6 +510,7 @@ export const DELETE = withRouteHandler( limit: validated.limit, workspaceId: validated.workspaceId, }, + table, requestId ) diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts index d4d9c448837..3ceed1cadd7 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts @@ -158,8 +158,10 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableR eq(userTableRows.workspaceId, validated.workspaceId), ] + const schema = table.schema as TableSchema + if (validated.filter) { - const filterClause = buildFilterClause(validated.filter as Filter, USER_TABLE_ROWS_SQL_NAME) + const filterClause = buildFilterClause(validated.filter as Filter, USER_TABLE_ROWS_SQL_NAME, schema.columns) if (filterClause) { baseConditions.push(filterClause) } @@ -177,7 +179,6 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableR .where(and(...baseConditions)) if (validated.sort) { - const schema = table.schema as TableSchema const sortClause = buildSortClause(validated.sort, USER_TABLE_ROWS_SQL_NAME, schema.columns) if (sortClause) { query = query.orderBy(sortClause) as typeof query @@ -490,6 +491,7 @@ export const DELETE = withRouteHandler( limit: validated.limit, workspaceId: validated.workspaceId, }, + table, requestId ) diff --git a/apps/sim/lib/copilot/tools/server/table/user-table.ts b/apps/sim/lib/copilot/tools/server/table/user-table.ts index e6464e87afd..e41ec62f270 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.ts @@ -474,6 +474,11 @@ export const userTableServerTool: BaseServerTool return { success: false, message: 'Workspace ID is required' } } + const table = await getTableById(args.tableId) + if (!table) { + return { success: false, message: `Table not found: ${args.tableId}` } + } + const requestId = generateId().slice(0, 8) const result = await queryRows( args.tableId, @@ -483,6 +488,7 @@ export const userTableServerTool: BaseServerTool sort: args.sort, limit: args.limit, offset: args.offset, + columns: table.schema.columns, }, requestId ) @@ -605,6 +611,11 @@ export const userTableServerTool: BaseServerTool return { success: false, message: 'Workspace ID is required' } } + const table = await getTableById(args.tableId) + if (!table) { + return { success: false, message: `Table not found: ${args.tableId}` } + } + const requestId = generateId().slice(0, 8) assertNotAborted() const result = await deleteRowsByFilter( @@ -614,6 +625,7 @@ export const userTableServerTool: BaseServerTool limit: args.limit, workspaceId, }, + table, requestId ) diff --git a/apps/sim/lib/table/__tests__/sql.test.ts b/apps/sim/lib/table/__tests__/sql.test.ts index 492e3e7fc8b..e59465421bd 100644 --- a/apps/sim/lib/table/__tests__/sql.test.ts +++ b/apps/sim/lib/table/__tests__/sql.test.ts @@ -5,10 +5,29 @@ * * Tests for the table SQL query builder utilities including filter and sort clause generation. */ +import type { SQL } from 'drizzle-orm' import { describe, expect, it } from 'vitest' import { buildFilterClause, buildSortClause } from '../sql' import type { Filter } from '../types' +/** + * Serializes a drizzle SQL object to a flat string for assertion. + * Works with both real SQL instances (queryChunks) and the test-environment mock (strings/values). + */ +function getRawSqlString(sqlObj: SQL): string { + const obj = sqlObj as unknown as Record + if (Array.isArray(obj['queryChunks'])) { + return (obj['queryChunks'] as unknown[]) + .map((chunk) => { + if (typeof chunk === 'string') return chunk + const raw = chunk as { value?: string[] } + return raw.value?.join('') ?? '' + }) + .join('') + } + return JSON.stringify(sqlObj) +} + describe('SQL Builder', () => { describe('buildFilterClause', () => { const tableName = 'user_table_rows' @@ -171,6 +190,83 @@ describe('SQL Builder', () => { expect(result).toBeDefined() }) + + describe('date column type', () => { + const dateColumns = [{ name: 'birthDate', type: 'date' as const }] + + it('should use ::timestamp cast for $gt with date column type', () => { + const filter: Filter = { birthDate: { $gt: '2024-01-01' } } + const result = buildFilterClause(filter, tableName, dateColumns) + + expect(result).toBeDefined() + const raw = getRawSqlString(result!) + expect(raw).toContain('::timestamp') + expect(raw).not.toContain('::numeric') + }) + + it('should use ::timestamp cast for $gte with date column type', () => { + const filter: Filter = { birthDate: { $gte: '2024-01-01' } } + const result = buildFilterClause(filter, tableName, dateColumns) + + expect(result).toBeDefined() + const raw = getRawSqlString(result!) + expect(raw).toContain('::timestamp') + expect(raw).not.toContain('::numeric') + }) + + it('should use ::timestamp cast for $lt with date column type', () => { + const filter: Filter = { birthDate: { $lt: '2024-12-31' } } + const result = buildFilterClause(filter, tableName, dateColumns) + + expect(result).toBeDefined() + const raw = getRawSqlString(result!) + expect(raw).toContain('::timestamp') + expect(raw).not.toContain('::numeric') + }) + + it('should use ::timestamp cast for $lte with date column type', () => { + const filter: Filter = { birthDate: { $lte: '2024-12-31' } } + const result = buildFilterClause(filter, tableName, dateColumns) + + expect(result).toBeDefined() + const raw = getRawSqlString(result!) + expect(raw).toContain('::timestamp') + expect(raw).not.toContain('::numeric') + }) + + it('should use ::timestamp cast for date range ($gte + $lte)', () => { + const filter: Filter = { birthDate: { $gte: '2024-01-01', $lte: '2024-12-31' } } + const result = buildFilterClause(filter, tableName, dateColumns) + + expect(result).toBeDefined() + const raw = getRawSqlString(result!) + expect(raw).toContain('::timestamp') + expect(raw).not.toContain('::numeric') + }) + + it('should use ::timestamp cast inside $and with date column type', () => { + const filter: Filter = { + $and: [{ birthDate: { $gte: '2024-01-01' } }, { birthDate: { $lte: '2024-12-31' } }], + } + const result = buildFilterClause(filter, tableName, dateColumns) + + expect(result).toBeDefined() + const raw = getRawSqlString(result!) + expect(raw).toContain('::timestamp') + expect(raw).not.toContain('::numeric') + }) + + it('should still use ::numeric cast for $gt on a number column', () => { + const numberColumns = [{ name: 'age', type: 'number' as const }] + const filter: Filter = { age: { $gt: 18 } } + const result = buildFilterClause(filter, tableName, numberColumns) + + expect(result).toBeDefined() + const raw = getRawSqlString(result!) + expect(raw).toContain('::numeric') + expect(raw).not.toContain('::timestamp') + }) + }) }) describe('buildSortClause', () => { diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index 972bb551026..d993f763270 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -1435,7 +1435,7 @@ export async function queryRows( let whereClause = baseConditions if (filter && Object.keys(filter).length > 0) { - const filterClause = buildFilterClause(filter, tableName) + const filterClause = buildFilterClause(filter, tableName, options.columns) if (filterClause) { whereClause = and(baseConditions, filterClause) } @@ -1453,7 +1453,7 @@ export async function queryRows( // Build ORDER BY clause (default to position ASC for stable ordering) let orderByClause if (sort && Object.keys(sort).length > 0) { - orderByClause = buildSortClause(sort, tableName) + orderByClause = buildSortClause(sort, tableName, options.columns) } // Execute query @@ -1818,7 +1818,7 @@ export async function updateRowsByFilter( ): Promise { const tableName = USER_TABLE_ROWS_SQL_NAME - const filterClause = buildFilterClause(data.filter, tableName) + const filterClause = buildFilterClause(data.filter, tableName, (table.schema as TableSchema).columns) if (!filterClause) { throw new Error('Filter is required for bulk update') } @@ -2119,17 +2119,19 @@ async function recompactPositions(tableId: string, trx: DbTransaction, minDelete * Deletes multiple rows matching a filter. * * @param data - Bulk delete data + * @param table - Table definition used to emit correct SQL casts in filter expressions * @param requestId - Request ID for logging * @returns Bulk operation result */ export async function deleteRowsByFilter( data: BulkDeleteData, + table: TableDefinition, requestId: string ): Promise { const tableName = USER_TABLE_ROWS_SQL_NAME // Build filter clause - const filterClause = buildFilterClause(data.filter, tableName) + const filterClause = buildFilterClause(data.filter, tableName, (table.schema as TableSchema).columns) if (!filterClause) { throw new Error('Filter is required for bulk delete') } diff --git a/apps/sim/lib/table/sql.ts b/apps/sim/lib/table/sql.ts index f854d2b5237..8e6bda455c5 100644 --- a/apps/sim/lib/table/sql.ts +++ b/apps/sim/lib/table/sql.ts @@ -64,8 +64,13 @@ const ALLOWED_OPERATORS = new Set([ * // Logical operators * buildFilterClause({ $or: [{ status: 'active' }, { verified: true }] }, 'user_table_rows') */ -export function buildFilterClause(filter: Filter, tableName: string): SQL | undefined { +export function buildFilterClause( + filter: Filter, + tableName: string, + columns?: ColumnDefinition[] +): SQL | undefined { const conditions: SQL[] = [] + const columnTypeMap = new Map(columns?.map((col) => [col.name, col.type])) for (const [field, condition] of Object.entries(filter)) { if (condition === undefined) { @@ -75,7 +80,7 @@ export function buildFilterClause(filter: Filter, tableName: string): SQL | unde // This represents a case where the filter is a logical OR of multiple filters // e.g. { $or: [{ status: 'active' }, { status: 'pending' }] } if (field === '$or' && Array.isArray(condition)) { - const orClause = buildLogicalClause(condition as Filter[], tableName, 'OR') + const orClause = buildLogicalClause(condition as Filter[], tableName, 'OR', columns) if (orClause) { conditions.push(orClause) } @@ -85,7 +90,7 @@ export function buildFilterClause(filter: Filter, tableName: string): SQL | unde // This represents a case where the filter is a logical AND of multiple filters // e.g. { $and: [{ status: 'active' }, { status: 'pending' }] } if (field === '$and' && Array.isArray(condition)) { - const andClause = buildLogicalClause(condition as Filter[], tableName, 'AND') + const andClause = buildLogicalClause(condition as Filter[], tableName, 'AND', columns) if (andClause) { conditions.push(andClause) } @@ -103,7 +108,8 @@ export function buildFilterClause(filter: Filter, tableName: string): SQL | unde const fieldConditions = buildFieldCondition( tableName, field, - condition as JsonValue | ConditionOperators + condition as JsonValue | ConditionOperators, + columnTypeMap.get(field) ) conditions.push(...fieldConditions) } @@ -208,7 +214,8 @@ function validateOperator(operator: string): void { function buildFieldCondition( tableName: string, field: string, - condition: JsonValue | ConditionOperators + condition: JsonValue | ConditionOperators, + columnType?: string ): SQL[] { validateFieldName(field) @@ -231,19 +238,19 @@ function buildFieldCondition( break case '$gt': - conditions.push(buildComparisonClause(tableName, field, '>', value as number)) + conditions.push(buildComparisonClause(tableName, field, '>', value as number | string, columnType)) break case '$gte': - conditions.push(buildComparisonClause(tableName, field, '>=', value as number)) + conditions.push(buildComparisonClause(tableName, field, '>=', value as number | string, columnType)) break case '$lt': - conditions.push(buildComparisonClause(tableName, field, '<', value as number)) + conditions.push(buildComparisonClause(tableName, field, '<', value as number | string, columnType)) break case '$lte': - conditions.push(buildComparisonClause(tableName, field, '<=', value as number)) + conditions.push(buildComparisonClause(tableName, field, '<=', value as number | string, columnType)) break case '$in': @@ -312,11 +319,12 @@ function buildFieldCondition( function buildLogicalClause( subFilters: Filter[], tableName: string, - operator: 'OR' | 'AND' + operator: 'OR' | 'AND', + columns?: ColumnDefinition[] ): SQL | undefined { const clauses: SQL[] = [] for (const subFilter of subFilters) { - const clause = buildFilterClause(subFilter, tableName) + const clause = buildFilterClause(subFilter, tableName, columns) if (clause) { clauses.push(clause) } @@ -334,15 +342,24 @@ function buildContainmentClause(tableName: string, field: string, value: JsonVal return sql`${sql.raw(`${tableName}.data`)} @> ${jsonObj}::jsonb` } -/** Builds numeric comparison: `(data->>'field')::numeric value` (cannot use GIN index) */ +/** + * Builds a range comparison: `(data->>'field'):: value` (cannot use GIN index). + * Uses `::timestamp` when `columnType === 'date'`, otherwise `::numeric`. + */ function buildComparisonClause( tableName: string, field: string, operator: '>' | '>=' | '<' | '<=', - value: number + value: number | string, + columnType?: string ): SQL { const escapedField = field.replace(/'/g, "''") - return sql`(${sql.raw(`${tableName}.data->>'${escapedField}'`)})::numeric ${sql.raw(operator)} ${value}` + const extract = sql.raw(`${tableName}.data->>'${escapedField}'`) + const isDate = columnType === 'date' + if (isDate) { + return sql`(${extract})::timestamp ${sql.raw(operator)} ${value}::timestamp` + } + return sql`(${extract})::numeric ${sql.raw(operator)} ${value}` } /** Escapes LIKE/ILIKE wildcard characters so they match literally */ diff --git a/apps/sim/lib/table/types.ts b/apps/sim/lib/table/types.ts index 5d6b90d8413..4d1103c7186 100644 --- a/apps/sim/lib/table/types.ts +++ b/apps/sim/lib/table/types.ts @@ -163,10 +163,10 @@ export interface TableRow { export interface ConditionOperators { $eq?: ColumnValue $ne?: ColumnValue - $gt?: number - $gte?: number - $lt?: number - $lte?: number + $gt?: number | string + $gte?: number | string + $lt?: number | string + $lte?: number | string $in?: ColumnValue[] $nin?: ColumnValue[] $contains?: string @@ -227,6 +227,8 @@ export interface QueryOptions { * is returned as `null` to signal it was not computed. */ includeTotal?: boolean + /** Column definitions used to emit correct SQL casts for date/number fields in filter expressions. */ + columns?: ColumnDefinition[] } export interface QueryResult {