mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-22 13:28:04 -05:00
better comments
This commit is contained in:
@@ -6,7 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import type { Filter, RowData, TableSchema } from '@/lib/table'
|
||||
import type { Filter, RowData, Sort, TableSchema } from '@/lib/table'
|
||||
import {
|
||||
getUniqueColumns,
|
||||
TABLE_LIMITS,
|
||||
@@ -357,14 +357,14 @@ export async function GET(request: NextRequest, { params }: TableRowsRouteParams
|
||||
const offset = searchParams.get('offset')
|
||||
|
||||
let filter: Record<string, unknown> | undefined
|
||||
let sort: Record<string, 'asc' | 'desc'> | undefined
|
||||
let sort: Sort | undefined
|
||||
|
||||
try {
|
||||
if (filterParam) {
|
||||
filter = JSON.parse(filterParam) as Record<string, unknown>
|
||||
}
|
||||
if (sortParam) {
|
||||
sort = JSON.parse(sortParam) as Record<string, 'asc' | 'desc'>
|
||||
sort = JSON.parse(sortParam) as Sort
|
||||
}
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid filter or sort JSON' }, { status: 400 })
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import type { SQL } from 'drizzle-orm'
|
||||
import { sql } from 'drizzle-orm'
|
||||
import { NAME_PATTERN } from './constants'
|
||||
import type { Filter, FilterOperator, JsonValue } from './types'
|
||||
import type { ConditionOperators, Filter, JsonValue, Sort } from './types'
|
||||
|
||||
/**
|
||||
* Whitelist of allowed operators for query filtering.
|
||||
@@ -26,6 +26,103 @@ const ALLOWED_OPERATORS = new Set([
|
||||
'$contains',
|
||||
])
|
||||
|
||||
/**
|
||||
* Builds a WHERE clause from a filter object.
|
||||
* Recursively processes logical operators ($or, $and) and field conditions.
|
||||
*
|
||||
* @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
|
||||
*
|
||||
* @example
|
||||
* // Simple equality
|
||||
* buildFilterClause({ name: 'John' }, 'user_table_rows')
|
||||
*
|
||||
* // Complex filter with operators
|
||||
* buildFilterClause({ age: { $gte: 18 }, status: { $in: ['active', 'pending'] } }, 'user_table_rows')
|
||||
*
|
||||
* // Logical operators
|
||||
* buildFilterClause({ $or: [{ status: 'active' }, { verified: true }] }, 'user_table_rows')
|
||||
*/
|
||||
export function buildFilterClause(filter: Filter, tableName: string): SQL | undefined {
|
||||
const conditions: SQL[] = []
|
||||
|
||||
for (const [field, condition] of Object.entries(filter)) {
|
||||
if (condition === undefined) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 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')
|
||||
if (orClause) {
|
||||
conditions.push(orClause)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// 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')
|
||||
if (andClause) {
|
||||
conditions.push(andClause)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip arrays for regular fields - arrays are only valid for $or and $and.
|
||||
// If we encounter an array here, it's likely malformed input (e.g., { name: [filter1, filter2] })
|
||||
// which doesn't have a clear semantic meaning, so we skip it.
|
||||
if (Array.isArray(condition)) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Build SQL conditions for this field. Returns array of SQL fragments for each operator.
|
||||
const fieldConditions = buildFieldCondition(
|
||||
tableName,
|
||||
field,
|
||||
condition as JsonValue | ConditionOperators
|
||||
)
|
||||
conditions.push(...fieldConditions)
|
||||
}
|
||||
|
||||
if (conditions.length === 0) return undefined
|
||||
if (conditions.length === 1) return conditions[0]
|
||||
|
||||
return sql.join(conditions, sql.raw(' AND '))
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an ORDER BY clause from a sort object.
|
||||
*
|
||||
* @param sort - Sort object with field names and directions
|
||||
* @param tableName - Table name for the query (e.g., 'user_table_rows')
|
||||
* @returns SQL ORDER BY clause or undefined if no sort specified
|
||||
* @throws Error if field name is invalid
|
||||
*
|
||||
* @example
|
||||
* buildSortClause({ name: 'asc', age: 'desc' }, 'user_table_rows')
|
||||
* // Returns: ORDER BY data->>'name' ASC, data->>'age' DESC
|
||||
*/
|
||||
export function buildSortClause(sort: Sort, tableName: string): SQL | undefined {
|
||||
const clauses: SQL[] = []
|
||||
|
||||
for (const [field, direction] of Object.entries(sort)) {
|
||||
validateFieldName(field)
|
||||
|
||||
if (direction !== 'asc' && direction !== 'desc') {
|
||||
throw new Error(`Invalid sort direction "${direction}". Must be "asc" or "desc".`)
|
||||
}
|
||||
|
||||
clauses.push(buildSortFieldClause(tableName, field, direction))
|
||||
}
|
||||
|
||||
return clauses.length > 0 ? sql.join(clauses, sql.raw(', ')) : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a field name to prevent SQL injection.
|
||||
* Field names must match the NAME_PATTERN (alphanumeric + underscore, starting with letter/underscore).
|
||||
@@ -59,29 +156,6 @@ function validateOperator(operator: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Builds JSONB containment clause: `data @> '{"field": value}'::jsonb` (uses GIN index) */
|
||||
function buildContainmentClause(tableName: string, field: string, value: JsonValue): SQL {
|
||||
const jsonObj = JSON.stringify({ [field]: value })
|
||||
return sql`${sql.raw(`${tableName}.data`)} @> ${jsonObj}::jsonb`
|
||||
}
|
||||
|
||||
/** Builds numeric comparison: `(data->>'field')::numeric <op> value` (cannot use GIN index) */
|
||||
function buildComparisonClause(
|
||||
tableName: string,
|
||||
field: string,
|
||||
operator: '>' | '>=' | '<' | '<=',
|
||||
value: number
|
||||
): SQL {
|
||||
const escapedField = field.replace(/'/g, "''")
|
||||
return sql`(${sql.raw(`${tableName}.data->>'${escapedField}'`)})::numeric ${sql.raw(operator)} ${value}`
|
||||
}
|
||||
|
||||
/** Builds case-insensitive pattern match: `data->>'field' ILIKE '%value%'` */
|
||||
function buildContainsClause(tableName: string, field: string, value: string): SQL {
|
||||
const escapedField = field.replace(/'/g, "''")
|
||||
return sql`${sql.raw(`${tableName}.data->>'${escapedField}'`)} ILIKE ${`%${value}%`}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds SQL conditions for a single field based on the provided condition.
|
||||
*
|
||||
@@ -92,7 +166,7 @@ function buildContainsClause(tableName: string, field: string, value: string): S
|
||||
*
|
||||
* @param tableName - The name of the table to query (used for SQL table reference)
|
||||
* @param field - The field name to filter on (must match NAME_PATTERN)
|
||||
* @param condition - Either a simple value (for equality) or a FilterOperator
|
||||
* @param condition - Either a simple value (for equality) or a ConditionOperators
|
||||
* 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.
|
||||
@@ -101,7 +175,7 @@ function buildContainsClause(tableName: string, field: string, value: string): S
|
||||
function buildFieldCondition(
|
||||
tableName: string,
|
||||
field: string,
|
||||
condition: JsonValue | FilterOperator
|
||||
condition: JsonValue | ConditionOperators
|
||||
): SQL[] {
|
||||
validateFieldName(field)
|
||||
|
||||
@@ -171,6 +245,8 @@ function buildFieldCondition(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Simple value (primitive or null) - shorthand for equality.
|
||||
// Example: { name: 'John' } is equivalent to { name: { $eq: 'John' } }
|
||||
conditions.push(buildContainmentClause(tableName, field, condition))
|
||||
}
|
||||
|
||||
@@ -179,6 +255,24 @@ function buildFieldCondition(
|
||||
|
||||
/**
|
||||
* Builds SQL clauses from nested filters and joins them with the specified operator.
|
||||
*
|
||||
* @example
|
||||
* // OR operator
|
||||
* buildLogicalClause(
|
||||
* [{ status: 'active' }, { status: 'pending' }],
|
||||
* 'user_table_rows',
|
||||
* 'OR'
|
||||
* )
|
||||
* // Returns: (data @> '{"status":"active"}'::jsonb OR data @> '{"status":"pending"}'::jsonb)
|
||||
*
|
||||
* @example
|
||||
* // AND operator
|
||||
* buildLogicalClause(
|
||||
* [{ age: { $gte: 18 } }, { verified: true }],
|
||||
* 'user_table_rows',
|
||||
* 'AND'
|
||||
* )
|
||||
* // Returns: ((data->>'age')::numeric >= 18 AND data @> '{"verified":true}'::jsonb)
|
||||
*/
|
||||
function buildLogicalClause(
|
||||
subFilters: Filter[],
|
||||
@@ -199,50 +293,27 @@ function buildLogicalClause(
|
||||
return sql`(${sql.join(clauses, sql.raw(` ${operator} `))})`
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a WHERE clause from a filter object.
|
||||
* Recursively processes logical operators ($or, $and) and field conditions.
|
||||
*/
|
||||
export function buildFilterClause(filter: Filter, tableName: string): SQL | undefined {
|
||||
const conditions: SQL[] = []
|
||||
/** Builds JSONB containment clause: `data @> '{"field": value}'::jsonb` (uses GIN index) */
|
||||
function buildContainmentClause(tableName: string, field: string, value: JsonValue): SQL {
|
||||
const jsonObj = JSON.stringify({ [field]: value })
|
||||
return sql`${sql.raw(`${tableName}.data`)} @> ${jsonObj}::jsonb`
|
||||
}
|
||||
|
||||
for (const [field, condition] of Object.entries(filter)) {
|
||||
if (condition === undefined) {
|
||||
continue
|
||||
}
|
||||
/** Builds numeric comparison: `(data->>'field')::numeric <op> value` (cannot use GIN index) */
|
||||
function buildComparisonClause(
|
||||
tableName: string,
|
||||
field: string,
|
||||
operator: '>' | '>=' | '<' | '<=',
|
||||
value: number
|
||||
): SQL {
|
||||
const escapedField = field.replace(/'/g, "''")
|
||||
return sql`(${sql.raw(`${tableName}.data->>'${escapedField}'`)})::numeric ${sql.raw(operator)} ${value}`
|
||||
}
|
||||
|
||||
if (field === '$or' && Array.isArray(condition)) {
|
||||
const orClause = buildLogicalClause(condition as Filter[], tableName, 'OR')
|
||||
if (orClause) {
|
||||
conditions.push(orClause)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (field === '$and' && Array.isArray(condition)) {
|
||||
const andClause = buildLogicalClause(condition as Filter[], tableName, 'AND')
|
||||
if (andClause) {
|
||||
conditions.push(andClause)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (Array.isArray(condition)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const fieldConditions = buildFieldCondition(
|
||||
tableName,
|
||||
field,
|
||||
condition as JsonValue | FilterOperator
|
||||
)
|
||||
conditions.push(...fieldConditions)
|
||||
}
|
||||
|
||||
if (conditions.length === 0) return undefined
|
||||
if (conditions.length === 1) return conditions[0]
|
||||
|
||||
return sql.join(conditions, sql.raw(' AND '))
|
||||
/** Builds case-insensitive pattern match: `data->>'field' ILIKE '%value%'` */
|
||||
function buildContainsClause(tableName: string, field: string, value: string): SQL {
|
||||
const escapedField = field.replace(/'/g, "''")
|
||||
return sql`${sql.raw(`${tableName}.data->>'${escapedField}'`)} ILIKE ${`%${value}%`}`
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -259,31 +330,3 @@ function buildSortFieldClause(tableName: string, field: string, direction: 'asc'
|
||||
|
||||
return sql.raw(`${tableName}.data->>'${escapedField}' ${directionSql}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an ORDER BY clause from a sort object.
|
||||
* Note: JSONB fields use text extraction, so numeric sorting may not work as expected.
|
||||
*
|
||||
* @param sort - Sort object with field names and directions
|
||||
* @param tableName - Table name for the query
|
||||
* @returns SQL ORDER BY clause or undefined if no sort specified
|
||||
* @throws Error if field name is invalid
|
||||
*/
|
||||
export function buildSortClause(
|
||||
sort: Record<string, 'asc' | 'desc'>,
|
||||
tableName: string
|
||||
): SQL | undefined {
|
||||
const clauses: SQL[] = []
|
||||
|
||||
for (const [field, direction] of Object.entries(sort)) {
|
||||
validateFieldName(field)
|
||||
|
||||
if (direction !== 'asc' && direction !== 'desc') {
|
||||
throw new Error(`Invalid sort direction "${direction}". Must be "asc" or "desc".`)
|
||||
}
|
||||
|
||||
clauses.push(buildSortFieldClause(tableName, field, direction))
|
||||
}
|
||||
|
||||
return clauses.length > 0 ? sql.join(clauses, sql.raw(', ')) : undefined
|
||||
}
|
||||
|
||||
@@ -66,10 +66,20 @@ export interface TableRow {
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter operator conditions for query filtering.
|
||||
* Operators that form a condition for a field.
|
||||
* Supports MongoDB-style query operators.
|
||||
*
|
||||
* @example
|
||||
* // Single operator
|
||||
* { $eq: 'John' } // field equals 'John'
|
||||
* { $gt: 18 } // field greater than 18
|
||||
* { $in: ['active', 'pending'] } // field in array
|
||||
* { $contains: 'search' } // field contains 'search' (case-insensitive)
|
||||
*
|
||||
* // Multiple operators (all must match)
|
||||
* { $gte: 18, $lt: 65 } // field >= 18 AND field < 65
|
||||
*/
|
||||
export interface FilterOperator {
|
||||
export interface ConditionOperators {
|
||||
$eq?: ColumnValue
|
||||
$ne?: ColumnValue
|
||||
$gt?: number
|
||||
@@ -83,12 +93,42 @@ export interface FilterOperator {
|
||||
|
||||
/**
|
||||
* Filter for querying table rows.
|
||||
* Keys are column names, values are either direct values or filter operators.
|
||||
* Keys are column names, values are either direct values (shorthand for equality)
|
||||
* or ConditionOperators objects for complex conditions.
|
||||
*
|
||||
* @example
|
||||
* // Simple equality (shorthand - equivalent to { name: { $eq: 'John' } })
|
||||
* { name: 'John' }
|
||||
*
|
||||
* // Using ConditionOperators for a single field
|
||||
* { age: { $gt: 18 } }
|
||||
* { status: { $in: ['active', 'pending'] } }
|
||||
*
|
||||
* // Multiple fields (AND logic)
|
||||
* { name: 'John', age: { $gte: 18 } } // name = 'John' AND age >= 18
|
||||
*
|
||||
* // Logical OR
|
||||
* { $or: [
|
||||
* { status: 'active' },
|
||||
* { status: 'pending' }
|
||||
* ]}
|
||||
*
|
||||
* // Logical AND
|
||||
* { $and: [
|
||||
* { age: { $gte: 18 } },
|
||||
* { verified: true }
|
||||
* ]}
|
||||
*
|
||||
* // Nested logical operators
|
||||
* { $or: [
|
||||
* { $and: [{ status: 'active' }, { age: { $gte: 18 } }] },
|
||||
* { role: 'admin' }
|
||||
* ]}
|
||||
*/
|
||||
export interface Filter {
|
||||
$or?: Filter[]
|
||||
$and?: Filter[]
|
||||
[key: string]: ColumnValue | FilterOperator | Filter[] | undefined
|
||||
[key: string]: ColumnValue | ConditionOperators | Filter[] | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
ColumnDefinition,
|
||||
Filter,
|
||||
RowData,
|
||||
Sort,
|
||||
TableDefinition,
|
||||
TableRow,
|
||||
TableSchema,
|
||||
@@ -41,7 +42,7 @@ export interface TableRowDeleteParams {
|
||||
export interface TableRowQueryParams {
|
||||
tableId: string
|
||||
filter?: Filter
|
||||
sort?: Record<string, 'asc' | 'desc'>
|
||||
sort?: Sort
|
||||
limit?: number
|
||||
offset?: number
|
||||
_context?: ToolExecutionContext
|
||||
|
||||
Reference in New Issue
Block a user