This commit is contained in:
Lakee Sivaraya
2026-01-14 18:09:35 -08:00
parent cfbc8d7211
commit b3ca0c947c
16 changed files with 131 additions and 188 deletions

View File

@@ -7,7 +7,7 @@ import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { TABLE_LIMITS, validateTableName, validateTableSchema } from '@/lib/table'
import type { TableSchema } from '@/lib/table/validation'
import type { TableSchema } from '@/lib/table/validation/validation'
import type { TableColumnData, TableSchemaData } from './utils'
const logger = createLogger('TableAPI')

View File

@@ -3,8 +3,8 @@
import { useCallback, useMemo, useState } from 'react'
import { ArrowDownAZ, ArrowUpAZ, Plus, X } from 'lucide-react'
import { Button, Combobox, Input } from '@/components/emcn'
import type { FilterCondition } from '@/lib/table/filter-constants'
import { useFilterBuilder } from '@/lib/table/use-filter-builder'
import type { FilterCondition } from '@/lib/table/filters/filter-constants'
import { useFilterBuilder } from '@/lib/table/filters/use-filter-builder'
/**
* Represents a sort configuration.

View File

@@ -4,9 +4,12 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { Plus, X } from 'lucide-react'
import { Button, Combobox, type ComboboxOption, Input } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { conditionsToJsonString, jsonStringToConditions } from '@/lib/table/filter-builder-utils'
import type { FilterCondition } from '@/lib/table/filter-constants'
import { useFilterBuilder } from '@/lib/table/use-filter-builder'
import {
conditionsToJsonString,
jsonStringToConditions,
} from '@/lib/table/filters/filter-builder-utils'
import type { FilterCondition } from '@/lib/table/filters/filter-constants'
import { useFilterBuilder } from '@/lib/table/filters/use-filter-builder'
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
import { SubBlockInputController } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sub-block-input-controller'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'

View File

@@ -9,7 +9,7 @@ import {
SORT_DIRECTIONS,
type SortCondition,
sortConditionsToJsonString,
} from '@/lib/table/filter-builder-utils'
} from '@/lib/table/filters/filter-builder-utils'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
interface SortFormatProps {

View File

@@ -3,7 +3,7 @@ import { AlertTriangle, Wand2 } from 'lucide-react'
import { Label, Tooltip } from '@/components/emcn/components'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/core/utils/cn'
import type { FilterCondition, SortCondition } from '@/lib/table/filter-builder-utils'
import type { FilterCondition, SortCondition } from '@/lib/table/filters/filter-builder-utils'
import type { FieldDiffStatus } from '@/lib/workflows/diff/types'
import {
CheckboxList,

View File

@@ -1,5 +1,5 @@
import { TableIcon } from '@/components/icons'
import { conditionsToFilter, sortConditionsToSort } from '@/lib/table/filter-builder-utils'
import { conditionsToFilter, sortConditionsToSort } from '@/lib/table/filters/filter-builder-utils'
import type { BlockConfig } from '@/blocks/types'
import type { TableQueryResponse } from '@/tools/table/types'

View File

@@ -1,65 +1,20 @@
/**
* React Query hooks for managing user-defined tables.
*
* Provides hooks for fetching, creating, and deleting tables within a workspace.
* Tables are user-defined data structures that can store rows of data in JSONB format.
*
* @module hooks/queries/use-tables
*/
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import type { TableDefinition } from '@/tools/table/types'
/**
* Query key factories for table-related queries.
* Ensures consistent cache invalidation across the app.
*/
/**
* Query keys for table-related queries in React Query.
* Use these keys to ensure correct cache scoping and invalidation
* in queries or mutations dealing with user-defined tables.
*/
export const tableKeys = {
/**
* Base key for all table queries.
* Example: ['tables']
*/
all: ['tables'] as const,
/**
* Key for all lists of tables.
* Useful for cache invalidation across all table lists.
* Example: ['tables', 'list']
*/
lists: () => [...tableKeys.all, 'list'] as const,
/**
* Key for the list of tables in a specific workspace.
* @param workspaceId - The workspace ID to scope the list to.
* Example: ['tables', 'list', 'workspace_abc123']
*/
list: (workspaceId?: string) => [...tableKeys.lists(), workspaceId ?? ''] as const,
/**
* Key for all individual table detail queries.
* Useful for cache invalidation for all details.
* Example: ['tables', 'detail']
*/
details: () => [...tableKeys.all, 'detail'] as const,
/**
* Key for a specific table's detail.
* @param tableId - The table ID to scope the detail to.
* Example: ['tables', 'detail', 'table_abc123']
*/
detail: (tableId: string) => [...tableKeys.details(), tableId] as const,
}
/**
* Hook to fetch all tables for a workspace.
*
* @param workspaceId - The workspace ID to fetch tables for. If undefined, the query is disabled.
* @returns React Query result containing the list of tables.
* Fetch all tables for a workspace.
*/
export function useTablesList(workspaceId?: string) {
return useQuery({
@@ -78,17 +33,12 @@ export function useTablesList(workspaceId?: string) {
return (response.data?.tables || []) as TableDefinition[]
},
enabled: Boolean(workspaceId),
staleTime: 30 * 1000, // Cache data for 30 seconds before refetching
staleTime: 30 * 1000,
})
}
/**
* Hook to create a new table in a workspace.
*
* @param workspaceId - The workspace ID where the table will be created.
* @returns React Query mutation object with mutationFn and onSuccess handler.
* The mutationFn accepts table creation parameters (name, description, schema).
* On success, invalidates the tables list query to refresh the UI.
* Create a new table in a workspace.
*/
export function useCreateTable(workspaceId: string) {
const queryClient = useQueryClient()
@@ -113,19 +63,13 @@ export function useCreateTable(workspaceId: string) {
return res.json()
},
onSuccess: () => {
// Invalidate the tables list query to refresh the UI
queryClient.invalidateQueries({ queryKey: tableKeys.list(workspaceId) })
},
})
}
/**
* Hook to delete a table from a workspace.
*
* @param workspaceId - The workspace ID containing the table to delete.
* @returns React Query mutation object with mutationFn and onSuccess handler.
* The mutationFn accepts a tableId string.
* On success, invalidates the tables list query to refresh the UI.
* Delete a table from a workspace.
*/
export function useDeleteTable(workspaceId: string) {
const queryClient = useQueryClient()
@@ -147,7 +91,6 @@ export function useDeleteTable(workspaceId: string) {
return res.json()
},
onSuccess: () => {
// Invalidate the tables list query to refresh the UI
queryClient.invalidateQueries({ queryKey: tableKeys.list(workspaceId) })
},
})

View File

@@ -1,7 +1,7 @@
/**
* Shared utilities for filter builder UI components.
*
* @module lib/table/filter-builder-utils
* @module lib/table/filters/filter-builder-utils
*/
// Re-export shared constants and types for backward compatibility

View File

@@ -1,11 +1,8 @@
/**
* Shared constants and types for table filtering and sorting.
*
* @module lib/table/filter-constants
* @module lib/table/filters/filter-constants
*
* @remarks
* This is the single source of truth for all filter and sort constants.
* All components should import from here to ensure consistency.
*/
/**

View File

@@ -0,0 +1,9 @@
/**
* Filter utilities for table queries.
*
* @module lib/table/filters
*/
export * from './filter-builder-utils'
export * from './filter-constants'
export * from './use-filter-builder'

View File

@@ -4,7 +4,7 @@
* Provides reusable filter condition management logic shared between
* the table data viewer's FilterBuilder and workflow block's FilterFormat.
*
* @module lib/table/use-filter-builder
* @module lib/table/filters/use-filter-builder
*/
import { useCallback, useMemo } from 'react'

View File

@@ -7,7 +7,6 @@
*/
export * from './constants'
export * from './filters'
export * from './query-builder'
export * from './use-filter-builder'
export * from './validation'
export * from './validation-helpers'

View File

@@ -87,16 +87,53 @@ function buildContainmentClause(tableName: string, field: string, value: JsonVal
return sql`${sql.raw(`${tableName}.data`)} @> ${jsonObj}::jsonb`
}
/**
* Builds a numeric comparison clause for JSONB fields.
* Generates: `(table.data->>'field')::numeric <operator> value`
*/
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 a case-insensitive pattern matching clause for JSONB text fields.
* Generates: `table.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.
*
* Supports both simple equality checks (using JSONB containment) and complex
* operators like comparison, membership, and pattern matching. Field names are
* validated to prevent SQL injection, and operators are validated against an
* allowed whitelist.
*
* @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 FieldCondition
* 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
*/
function buildFieldCondition(
tableName: string,
field: string,
condition: JsonValue | FieldCondition
): SQL[] {
// Validate field name to prevent SQL injection
validateFieldName(field)
const conditions: SQL[] = []
const escapedField = field.replace(/'/g, "''")
if (typeof condition === 'object' && condition !== null && !Array.isArray(condition)) {
for (const [op, value] of Object.entries(condition)) {
@@ -115,34 +152,28 @@ function buildFieldCondition(
break
case '$gt':
conditions.push(
sql`(${sql.raw(`${tableName}.data->>'${escapedField}'`)})::numeric > ${value}`
)
conditions.push(buildComparisonClause(tableName, field, '>', value as number))
break
case '$gte':
conditions.push(
sql`(${sql.raw(`${tableName}.data->>'${escapedField}'`)})::numeric >= ${value}`
)
conditions.push(buildComparisonClause(tableName, field, '>=', value as number))
break
case '$lt':
conditions.push(
sql`(${sql.raw(`${tableName}.data->>'${escapedField}'`)})::numeric < ${value}`
)
conditions.push(buildComparisonClause(tableName, field, '<', value as number))
break
case '$lte':
conditions.push(
sql`(${sql.raw(`${tableName}.data->>'${escapedField}'`)})::numeric <= ${value}`
)
conditions.push(buildComparisonClause(tableName, field, '<=', value as number))
break
case '$in':
if (Array.isArray(value) && value.length > 0) {
if (value.length === 1) {
// Single value then use containment clause
conditions.push(buildContainmentClause(tableName, field, value[0]))
} else {
// Multiple values then use OR clause
const inConditions = value.map((v) => buildContainmentClause(tableName, field, v))
conditions.push(sql`(${sql.join(inConditions, sql.raw(' OR '))})`)
}
@@ -159,9 +190,7 @@ function buildFieldCondition(
break
case '$contains':
conditions.push(
sql`${sql.raw(`${tableName}.data->>'${escapedField}'`)} ILIKE ${`%${value}%`}`
)
conditions.push(buildContainsClause(tableName, field, value as string))
break
default:
@@ -176,6 +205,28 @@ function buildFieldCondition(
return conditions
}
/**
* Builds SQL clauses from nested filters and joins them with the specified operator.
*/
function buildLogicalClause(
subFilters: QueryFilter[],
tableName: string,
operator: 'OR' | 'AND'
): SQL | undefined {
const clauses: SQL[] = []
for (const subFilter of subFilters) {
const clause = buildFilterClause(subFilter, tableName)
if (clause) {
clauses.push(clause)
}
}
if (clauses.length === 0) return undefined
if (clauses.length === 1) return clauses[0]
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.
@@ -183,82 +234,31 @@ function buildFieldCondition(
export function buildFilterClause(filter: QueryFilter, tableName: string): SQL | undefined {
const conditions: SQL[] = []
/**
* Iterate over each field and its associated condition in the filter object.
*
* The filter is expected to be an object where keys are either field names or logical operators
* ('$or', '$and'), and values are the conditions to apply or arrays of nested filter objects.
*/
for (const [field, condition] of Object.entries(filter)) {
// Skip undefined conditions (e.g., unused or programmatically removed filters)
if (condition === undefined) {
continue
}
/**
* Handle the logical OR operator: { $or: [filter1, filter2, ...] }
* Recursively build SQL clauses for each sub-filter,
* then join them with an OR. If there is only one sub-filter,
* no need for OR grouping.
*/
if (field === '$or' && Array.isArray(condition)) {
const orConditions: SQL[] = []
for (const subFilter of condition) {
const subClause = buildFilterClause(subFilter as QueryFilter, tableName)
if (subClause) {
orConditions.push(subClause)
}
}
if (orConditions.length > 0) {
if (orConditions.length === 1) {
// Only one condition; no need to wrap in OR
conditions.push(orConditions[0])
} else {
// Multiple conditions; join by OR
conditions.push(sql`(${sql.join(orConditions, sql.raw(' OR '))})`)
}
const orClause = buildLogicalClause(condition as QueryFilter[], tableName, 'OR')
if (orClause) {
conditions.push(orClause)
}
continue
}
/**
* Handle the logical AND operator: { $and: [filter1, filter2, ...] }
* Recursively build SQL clauses for each sub-filter,
* then join them with an AND. If there is only one sub-filter,
* no need for AND grouping.
*/
if (field === '$and' && Array.isArray(condition)) {
const andConditions: SQL[] = []
for (const subFilter of condition) {
const subClause = buildFilterClause(subFilter as QueryFilter, tableName)
if (subClause) {
andConditions.push(subClause)
}
}
if (andConditions.length > 0) {
if (andConditions.length === 1) {
// Only one condition; no need to wrap in AND
conditions.push(andConditions[0])
} else {
// Multiple conditions; join by AND
conditions.push(sql`(${sql.join(andConditions, sql.raw(' AND '))})`)
}
const andClause = buildLogicalClause(condition as QueryFilter[], tableName, 'AND')
if (andClause) {
conditions.push(andClause)
}
continue
}
/**
* If the condition is an array, but not a logical operator,
* skip it (invalid filter structure).
*/
if (Array.isArray(condition)) {
continue
}
/**
* Build conditions for regular fields.
* This delegates to buildFieldCondition, which handles comparisons like $eq, $gt, etc.
*/
const fieldConditions = buildFieldCondition(
tableName,
field,
@@ -267,17 +267,27 @@ export function buildFilterClause(filter: QueryFilter, tableName: string): SQL |
conditions.push(...fieldConditions)
}
/**
* If no conditions were built, return undefined to indicate no filter.
* If only one condition exists, return it directly.
* Otherwise, join all conditions using AND.
*/
if (conditions.length === 0) return undefined
if (conditions.length === 1) return conditions[0]
return sql.join(conditions, sql.raw(' AND '))
}
/**
* Builds a single ORDER BY clause for a field.
* Timestamp fields use direct column access, others use JSONB text extraction.
*/
function buildSortFieldClause(tableName: string, field: string, direction: 'asc' | 'desc'): SQL {
const escapedField = field.replace(/'/g, "''")
const directionSql = direction.toUpperCase()
if (field === 'createdAt' || field === 'updatedAt') {
return sql.raw(`${tableName}.${escapedField} ${directionSql}`)
}
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.
@@ -293,40 +303,14 @@ export function buildSortClause(
): SQL | undefined {
const clauses: SQL[] = []
/**
* Build ORDER BY SQL clauses based on the sort object keys and directions.
* - For `createdAt` and `updatedAt`, use the top-level table columns for proper type sorting.
* - For all other fields, treat them as keys in the table's data JSONB column.
* Extraction is performed with ->> to return text, which is then sorted.
* - Field names are validated to prevent SQL injection.
*/
for (const [field, direction] of Object.entries(sort)) {
// Validate field name to prevent SQL injection
validateFieldName(field)
// Validate direction
if (direction !== 'asc' && direction !== 'desc') {
throw new Error(`Invalid sort direction "${direction}". Must be "asc" or "desc".`)
}
// Escape single quotes for SQL safety (defense in depth)
const escapedField = field.replace(/'/g, "''")
if (field === 'createdAt' || field === 'updatedAt') {
// Use regular column for timestamp sorting
clauses.push(
direction === 'asc'
? sql.raw(`${tableName}.${escapedField} ASC`)
: sql.raw(`${tableName}.${escapedField} DESC`)
)
} else {
// Use text extraction for JSONB field sorting
clauses.push(
direction === 'asc'
? sql.raw(`${tableName}.data->>'${escapedField}' ASC`)
: sql.raw(`${tableName}.data->>'${escapedField}' DESC`)
)
}
clauses.push(buildSortFieldClause(tableName, field, direction))
}
return clauses.length > 0 ? sql.join(clauses, sql.raw(', ')) : undefined

View File

@@ -0,0 +1,8 @@
/**
* Validation utilities for table schemas and row data.
*
* @module lib/table/validation
*/
export * from './validation'
export * from './validation-helpers'

View File

@@ -4,7 +4,7 @@
* These helpers consolidate common validation patterns (size, schema, uniqueness)
* into reusable functions that return formatted error responses.
*
* @module lib/table/validation-helpers
* @module lib/table/validation/validation-helpers
*/
import { db } from '@sim/db'

View File

@@ -1,11 +1,11 @@
/**
* Validation utilities for table schemas and row data.
*
* @module lib/table/validation
* @module lib/table/validation/validation
*/
import type { ColumnType } from './constants'
import { COLUMN_TYPES, NAME_PATTERN, TABLE_LIMITS } from './constants'
import type { ColumnType } from '../constants'
import { COLUMN_TYPES, NAME_PATTERN, TABLE_LIMITS } from '../constants'
export interface ColumnDefinition {
name: string