Skip to content
Merged
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
14 changes: 13 additions & 1 deletion apps/sim/app/api/table/[tableId]/rows/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import {
validateRowData,
validateRowSize,
} from '@/lib/table'
import { buildFilterClause, buildSortClause } from '@/lib/table/sql'
import { buildFilterClause, buildSortClause, TableQueryValidationError } from '@/lib/table/sql'
import { accessError, checkAccess } from '@/app/api/table/utils'

const logger = createLogger('TableRowsAPI')
Expand Down Expand Up @@ -336,6 +336,10 @@ export const GET = withRouteHandler(
return validationErrorResponse(error)
}

if (error instanceof TableQueryValidationError) {
return NextResponse.json({ error: error.message }, { status: 400 })
}

logger.error(`[${requestId}] Error querying rows:`, error)
return NextResponse.json({ error: 'Failed to query rows' }, { status: 500 })
}
Expand Down Expand Up @@ -421,6 +425,10 @@ export const PUT = withRouteHandler(
return validationErrorResponse(error)
}

if (error instanceof TableQueryValidationError) {
return NextResponse.json({ error: error.message }, { status: 400 })
}

const errorMessage = toError(error).message

if (
Expand Down Expand Up @@ -520,6 +528,10 @@ export const DELETE = withRouteHandler(
return validationErrorResponse(error)
}

if (error instanceof TableQueryValidationError) {
return NextResponse.json({ error: error.message }, { status: 400 })
}

const errorMessage = toError(error).message

if (errorMessage.includes('Filter is required')) {
Expand Down
14 changes: 13 additions & 1 deletion apps/sim/app/api/v1/tables/[tableId]/rows/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import {
validateRowData,
validateRowSize,
} from '@/lib/table'
import { buildFilterClause, buildSortClause } from '@/lib/table/sql'
import { buildFilterClause, buildSortClause, TableQueryValidationError } from '@/lib/table/sql'
import { accessError, checkAccess } from '@/app/api/table/utils'
import {
checkRateLimit,
Expand Down Expand Up @@ -240,6 +240,10 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableR
const validationResponse = validationErrorResponseFromError(error)
if (validationResponse) return validationResponse

if (error instanceof TableQueryValidationError) {
return NextResponse.json({ error: error.message }, { status: 400 })
}

logger.error(`[${requestId}] Error querying rows:`, error)
return NextResponse.json({ error: 'Failed to query rows' }, { status: 500 })
}
Expand Down Expand Up @@ -407,6 +411,10 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: TableR
const validationResponse = validationErrorResponseFromError(error)
if (validationResponse) return validationResponse

if (error instanceof TableQueryValidationError) {
return NextResponse.json({ error: error.message }, { status: 400 })
}

const errorMessage = toError(error).message

if (
Expand Down Expand Up @@ -500,6 +508,10 @@ export const DELETE = withRouteHandler(
const validationResponse = validationErrorResponseFromError(error)
if (validationResponse) return validationResponse

if (error instanceof TableQueryValidationError) {
return NextResponse.json({ error: error.message }, { status: 400 })
}

const errorMessage = toError(error).message

if (errorMessage.includes('Filter is required')) {
Expand Down
35 changes: 25 additions & 10 deletions apps/sim/lib/table/sql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,17 @@ import { sql } from 'drizzle-orm'
import { NAME_PATTERN } from './constants'
import type { ColumnDefinition, ConditionOperators, Filter, JsonValue, Sort } from './types'

/**
* Error thrown when caller-supplied filter or sort input is malformed.
* Routes should map this to HTTP 400 with the message preserved.
*/
export class TableQueryValidationError extends Error {
constructor(message: string) {
super(message)
this.name = 'TableQueryValidationError'
}
}

/**
* Whitelist of allowed operators for query filtering.
* Only these operators can be used in filter conditions.
Expand Down Expand Up @@ -41,7 +52,7 @@ const ALLOWED_OPERATORS = new Set([
* @param filter - Filter object with field conditions and logical operators
* @param tableName - Table name for the query (e.g., 'user_table_rows')
* @returns SQL WHERE clause or undefined if no filter specified
* @throws Error if field name is invalid or operator is not allowed
* @throws {TableQueryValidationError} if field name is invalid or operator is not allowed
*
* @example
* // Simple equality
Expand Down Expand Up @@ -110,7 +121,7 @@ export function buildFilterClause(filter: Filter, tableName: string): SQL | unde
* @param tableName - Table name for the query (e.g., 'user_table_rows')
* @param columns - Optional column definitions for type-aware sorting
* @returns SQL ORDER BY clause or undefined if no sort specified
* @throws Error if field name is invalid
* @throws {TableQueryValidationError} if field name or sort direction is invalid
*
* @example
* buildSortClause({ name: 'asc', age: 'desc' }, 'user_table_rows')
Expand All @@ -133,7 +144,9 @@ export function buildSortClause(
validateFieldName(field)

if (direction !== 'asc' && direction !== 'desc') {
throw new Error(`Invalid sort direction "${direction}". Must be "asc" or "desc".`)
throw new TableQueryValidationError(
`Invalid sort direction "${direction}". Must be "asc" or "desc".`
)
}

const columnType = columnTypeMap.get(field)
Expand All @@ -148,15 +161,15 @@ export function buildSortClause(
* Field names must match the NAME_PATTERN (alphanumeric + underscore, starting with letter/underscore).
*
* @param field - The field name to validate
* @throws Error if field name is invalid
* @throws {TableQueryValidationError} if field name is invalid
*/
function validateFieldName(field: string): void {
if (!field || typeof field !== 'string') {
throw new Error('Field name must be a non-empty string')
throw new TableQueryValidationError('Field name must be a non-empty string')
}

if (!NAME_PATTERN.test(field)) {
throw new Error(
throw new TableQueryValidationError(
`Invalid field name "${field}". Field names must start with a letter or underscore, followed by alphanumeric characters or underscores.`
)
}
Expand All @@ -166,11 +179,11 @@ function validateFieldName(field: string): void {
* Validates an operator to ensure it's in the allowed list.
*
* @param operator - The operator to validate
* @throws Error if operator is not allowed
* @throws {TableQueryValidationError} if operator is not allowed
*/
function validateOperator(operator: string): void {
if (!ALLOWED_OPERATORS.has(operator)) {
throw new Error(
throw new TableQueryValidationError(
`Invalid operator "${operator}". Allowed operators: ${Array.from(ALLOWED_OPERATORS).join(', ')}`
)
}
Expand All @@ -190,7 +203,7 @@ function validateOperator(operator: string): void {
* object with operators like $eq, $gt, $in, etc.
* @returns Array of SQL condition fragments. Multiple conditions are returned
* when the condition object contains multiple operators.
* @throws Error if field name is invalid or operator is not allowed
* @throws {TableQueryValidationError} if field name is invalid or operator is not allowed
*/
function buildFieldCondition(
tableName: string,
Expand Down Expand Up @@ -260,7 +273,9 @@ function buildFieldCondition(
break

default:
// This should never happen due to validateOperator, but added for completeness
// This should never happen due to validateOperator, but added for completeness.
// Throw a plain Error (→ 500) since reaching this default means the switch
// and ALLOWED_OPERATORS have drifted — that's a programmer error, not a caller error.
throw new Error(`Unsupported operator: ${op}`)
}
}
Expand Down
Loading