filtering ui

This commit is contained in:
Lakee Sivaraya
2026-01-13 16:19:22 -08:00
parent 7e4fc32d82
commit 0872314fbf
3 changed files with 527 additions and 268 deletions

View File

@@ -0,0 +1,369 @@
'use client'
import { useCallback, useMemo, useState } from 'react'
import { ArrowDownAZ, ArrowUpAZ, Plus, X } from 'lucide-react'
import { Button, Combobox, Input } from '@/components/emcn'
/**
* Available comparison operators for filter conditions
*/
const COMPARISON_OPERATORS = [
{ value: 'eq', label: 'equals' },
{ value: 'ne', label: 'not equals' },
{ value: 'gt', label: 'greater than' },
{ value: 'gte', label: 'greater or equal' },
{ value: 'lt', label: 'less than' },
{ value: 'lte', label: 'less or equal' },
{ value: 'contains', label: 'contains' },
{ value: 'in', label: 'in array' },
] as const
/**
* Logical operators for combining conditions (for subsequent filters)
*/
const LOGICAL_OPERATORS = [
{ value: 'and', label: 'and' },
{ value: 'or', label: 'or' },
] as const
/**
* Sort direction options
*/
const SORT_DIRECTIONS = [
{ value: 'asc', label: 'ascending' },
{ value: 'desc', label: 'descending' },
] as const
/**
* Represents a single filter condition
*/
export interface FilterCondition {
id: string
logicalOperator: 'and' | 'or'
column: string
operator: string
value: string
}
/**
* Represents a sort configuration
*/
export interface SortConfig {
column: string
direction: 'asc' | 'desc'
}
/**
* Query options for the table
*/
export interface QueryOptions {
filter: Record<string, any> | null
sort: SortConfig | null
}
interface Column {
name: string
type: string
}
interface FilterBuilderProps {
columns: Column[]
onApply: (options: QueryOptions) => void
onAddRow: () => void
}
/**
* Generates a unique ID for filter conditions
*/
function generateId(): string {
return Math.random().toString(36).substring(2, 9)
}
/**
* Converts filter conditions to MongoDB-style filter object
*/
function conditionsToFilter(conditions: FilterCondition[]): Record<string, any> | null {
if (conditions.length === 0) return null
const orGroups: Record<string, any>[] = []
let currentAndGroup: Record<string, any> = {}
conditions.forEach((condition, index) => {
const { column, operator, value } = condition
const operatorKey = `$${operator}`
let parsedValue: any = value
if (value === 'true') parsedValue = true
else if (value === 'false') parsedValue = false
else if (value === 'null') parsedValue = null
else if (!isNaN(Number(value)) && value !== '') parsedValue = Number(value)
else if (operator === 'in') {
parsedValue = value.split(',').map((v) => {
const trimmed = v.trim()
if (trimmed === 'true') return true
if (trimmed === 'false') return false
if (trimmed === 'null') return null
if (!isNaN(Number(trimmed)) && trimmed !== '') return Number(trimmed)
return trimmed
})
}
const conditionObj = operator === 'eq' ? parsedValue : { [operatorKey]: parsedValue }
if (index === 0 || condition.logicalOperator === 'and') {
currentAndGroup[column] = conditionObj
} else if (condition.logicalOperator === 'or') {
if (Object.keys(currentAndGroup).length > 0) {
orGroups.push({ ...currentAndGroup })
}
currentAndGroup = { [column]: conditionObj }
}
})
if (Object.keys(currentAndGroup).length > 0) {
orGroups.push(currentAndGroup)
}
if (orGroups.length > 1) {
return { $or: orGroups }
}
return orGroups[0] || null
}
export function FilterBuilder({ columns, onApply, onAddRow }: FilterBuilderProps) {
const [conditions, setConditions] = useState<FilterCondition[]>([])
const [sortConfig, setSortConfig] = useState<SortConfig | null>(null)
const columnOptions = useMemo(
() => columns.map((col) => ({ value: col.name, label: col.name })),
[columns]
)
const comparisonOptions = useMemo(
() => COMPARISON_OPERATORS.map((op) => ({ value: op.value, label: op.label })),
[]
)
const logicalOptions = useMemo(
() => LOGICAL_OPERATORS.map((op) => ({ value: op.value, label: op.label })),
[]
)
const sortDirectionOptions = useMemo(
() => SORT_DIRECTIONS.map((d) => ({ value: d.value, label: d.label })),
[]
)
const handleAddCondition = useCallback(() => {
const newCondition: FilterCondition = {
id: generateId(),
logicalOperator: 'and',
column: columns[0]?.name || '',
operator: 'eq',
value: '',
}
setConditions((prev) => [...prev, newCondition])
}, [columns])
const handleRemoveCondition = useCallback((id: string) => {
setConditions((prev) => prev.filter((c) => c.id !== id))
}, [])
const handleUpdateCondition = useCallback(
(id: string, field: keyof FilterCondition, value: string) => {
setConditions((prev) => prev.map((c) => (c.id === id ? { ...c, [field]: value } : c)))
},
[]
)
const handleAddSort = useCallback(() => {
setSortConfig({
column: columns[0]?.name || '',
direction: 'asc',
})
}, [columns])
const handleRemoveSort = useCallback(() => {
setSortConfig(null)
}, [])
const handleApply = useCallback(() => {
const filter = conditionsToFilter(conditions)
onApply({
filter,
sort: sortConfig,
})
}, [conditions, sortConfig, onApply])
const handleClear = useCallback(() => {
setConditions([])
setSortConfig(null)
onApply({
filter: null,
sort: null,
})
}, [onApply])
const hasChanges = conditions.length > 0 || sortConfig !== null
return (
<div className='flex flex-col gap-[8px]'>
{/* Filter Conditions */}
{conditions.map((condition, index) => (
<div key={condition.id} className='flex items-center gap-[8px]'>
<Button
variant='ghost'
size='sm'
onClick={() => handleRemoveCondition(condition.id)}
className='h-[28px] w-[28px] shrink-0 p-0 text-[var(--text-tertiary)] hover:text-[var(--text-primary)]'
>
<X className='h-[12px] w-[12px]' />
</Button>
<div className='w-[80px] shrink-0'>
{index === 0 ? (
<Combobox
size='sm'
options={[{ value: 'where', label: 'where' }]}
value='where'
disabled
/>
) : (
<Combobox
size='sm'
options={logicalOptions}
value={condition.logicalOperator}
onChange={(value) =>
handleUpdateCondition(condition.id, 'logicalOperator', value as 'and' | 'or')
}
/>
)}
</div>
<div className='w-[140px] shrink-0'>
<Combobox
size='sm'
options={columnOptions}
value={condition.column}
onChange={(value) => handleUpdateCondition(condition.id, 'column', value)}
placeholder='Column'
/>
</div>
<div className='w-[130px] shrink-0'>
<Combobox
size='sm'
options={comparisonOptions}
value={condition.operator}
onChange={(value) => handleUpdateCondition(condition.id, 'operator', value)}
/>
</div>
<Input
className='h-[28px] min-w-[200px] flex-1 text-[12px]'
value={condition.value}
onChange={(e) => handleUpdateCondition(condition.id, 'value', e.target.value)}
placeholder='Value'
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleApply()
}
}}
/>
</div>
))}
{/* Sort Row */}
{sortConfig && (
<div className='flex items-center gap-[8px]'>
<Button
variant='ghost'
size='sm'
onClick={handleRemoveSort}
className='h-[28px] w-[28px] shrink-0 p-0 text-[var(--text-tertiary)] hover:text-[var(--text-primary)]'
>
<X className='h-[12px] w-[12px]' />
</Button>
<div className='w-[80px] shrink-0'>
<Combobox
size='sm'
options={[{ value: 'order', label: 'order' }]}
value='order'
disabled
/>
</div>
<div className='w-[140px] shrink-0'>
<Combobox
size='sm'
options={columnOptions}
value={sortConfig.column}
onChange={(value) =>
setSortConfig((prev) => (prev ? { ...prev, column: value } : null))
}
placeholder='Column'
/>
</div>
<div className='w-[130px] shrink-0'>
<Combobox
size='sm'
options={sortDirectionOptions}
value={sortConfig.direction}
onChange={(value) =>
setSortConfig((prev) =>
prev ? { ...prev, direction: value as 'asc' | 'desc' } : null
)
}
/>
</div>
<div className='flex items-center text-[12px] text-[var(--text-tertiary)]'>
{sortConfig.direction === 'asc' ? (
<ArrowUpAZ className='h-[14px] w-[14px]' />
) : (
<ArrowDownAZ className='h-[14px] w-[14px]' />
)}
</div>
</div>
)}
{/* Action Buttons */}
<div className='flex items-center gap-[8px]'>
<Button variant='default' size='sm' onClick={onAddRow}>
<Plus className='mr-[4px] h-[12px] w-[12px]' />
Add row
</Button>
<Button variant='default' size='sm' onClick={handleAddCondition}>
<Plus className='mr-[4px] h-[12px] w-[12px]' />
Add filter
</Button>
{!sortConfig && (
<Button variant='default' size='sm' onClick={handleAddSort}>
<ArrowUpAZ className='mr-[4px] h-[12px] w-[12px]' />
Add sort
</Button>
)}
{hasChanges && (
<>
<Button variant='default' size='sm' onClick={handleApply}>
Apply
</Button>
<button
onClick={handleClear}
className='text-[12px] text-[var(--text-tertiary)] transition-colors hover:text-[var(--text-primary)]'
>
Clear all
</button>
</>
)}
</div>
</div>
)
}

View File

@@ -3,31 +3,15 @@
import { useCallback, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useQuery } from '@tanstack/react-query'
import {
ChevronLeft,
ChevronRight,
Columns,
Copy,
Edit,
Filter,
HelpCircle,
Plus,
RefreshCw,
Trash2,
X,
} from 'lucide-react'
import { Columns, Copy, Edit, Plus, RefreshCw, Trash2, X } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import {
Badge,
Button,
Checkbox,
Input,
Modal,
ModalBody,
ModalContent,
Popover,
PopoverContent,
PopoverTrigger,
Table,
TableBody,
TableCell,
@@ -42,6 +26,7 @@ import type { TableSchema } from '@/lib/table'
import { AddRowModal } from './components/add-row-modal'
import { DeleteRowModal } from './components/delete-row-modal'
import { EditRowModal } from './components/edit-row-modal'
import { FilterBuilder, type QueryOptions } from './components/filter-builder'
import { TableActionBar } from './components/table-action-bar'
const logger = createLogger('TableDataViewer')
@@ -82,9 +67,10 @@ export function TableDataViewer() {
const tableId = params.tableId as string
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set())
const [filterInput, setFilterInput] = useState('')
const [appliedFilter, setAppliedFilter] = useState<Record<string, any> | null>(null)
const [filterError, setFilterError] = useState<string | null>(null)
const [queryOptions, setQueryOptions] = useState<QueryOptions>({
filter: null,
sort: null,
})
const [currentPage, setCurrentPage] = useState(0)
const [showAddModal, setShowAddModal] = useState(false)
const [editingRow, setEditingRow] = useState<TableRowData | null>(null)
@@ -104,25 +90,31 @@ export function TableDataViewer() {
},
})
// Fetch table rows with filter
// Fetch table rows with filter and sort
const {
data: rowsData,
isLoading: isLoadingRows,
refetch: refetchRows,
} = useQuery({
queryKey: ['table-rows', tableId, currentPage, appliedFilter],
queryKey: ['table-rows', tableId, queryOptions, currentPage],
queryFn: async () => {
const queryParams = new URLSearchParams({
const params = new URLSearchParams({
workspaceId,
limit: String(ROWS_PER_PAGE),
offset: String(currentPage * ROWS_PER_PAGE),
})
if (appliedFilter) {
queryParams.set('filter', JSON.stringify(appliedFilter))
if (queryOptions.filter) {
params.set('filter', JSON.stringify(queryOptions.filter))
}
const res = await fetch(`/api/table/${tableId}/rows?${queryParams}`)
if (queryOptions.sort) {
// Convert from {column, direction} to {column: direction} format expected by API
const sortParam = { [queryOptions.sort.column]: queryOptions.sort.direction }
params.set('sort', JSON.stringify(sortParam))
}
const res = await fetch(`/api/table/${tableId}/rows?${params}`)
if (!res.ok) throw new Error('Failed to fetch rows')
return res.json()
},
@@ -134,28 +126,8 @@ export function TableDataViewer() {
const totalCount = rowsData?.totalCount || 0
const totalPages = Math.ceil(totalCount / ROWS_PER_PAGE)
const handleApplyFilter = useCallback(() => {
setFilterError(null)
if (!filterInput.trim()) {
setAppliedFilter(null)
setCurrentPage(0)
return
}
try {
const parsed = JSON.parse(filterInput)
setAppliedFilter(parsed)
setCurrentPage(0)
} catch (err) {
setFilterError('Invalid JSON. Use format: {"column": {"$eq": "value"}}')
}
}, [filterInput])
const handleClearFilter = useCallback(() => {
setFilterInput('')
setAppliedFilter(null)
setFilterError(null)
const handleApplyQueryOptions = useCallback((options: QueryOptions) => {
setQueryOptions(options)
setCurrentPage(0)
}, [])
@@ -338,145 +310,16 @@ export function TableDataViewer() {
</Tooltip.Trigger>
<Tooltip.Content>Refresh</Tooltip.Content>
</Tooltip.Root>
<Button variant='default' size='sm' onClick={() => setShowAddModal(true)}>
<Plus className='mr-[4px] h-[12px] w-[12px]' />
Add Row
</Button>
</div>
</div>
{/* Filter Bar */}
<div className='flex shrink-0 flex-col gap-[8px] border-[var(--border)] border-b px-[16px] py-[10px]'>
<div className='flex items-center gap-[8px]'>
<Filter className='h-[14px] w-[14px] text-[var(--text-tertiary)]' />
<Input
value={filterInput}
onChange={(e) => setFilterInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleApplyFilter()
}
}}
placeholder='{"column": {"$eq": "value"}}'
className={cn(
'h-[32px] flex-1 font-mono text-[11px]',
filterError && 'border-[var(--text-error)]'
)}
/>
<Button variant='default' size='sm' onClick={handleApplyFilter}>
Apply
</Button>
{appliedFilter && (
<Button variant='ghost' size='sm' onClick={handleClearFilter}>
<X className='h-[12px] w-[12px]' />
</Button>
)}
<Popover>
<PopoverTrigger asChild>
<Button variant='ghost' size='sm'>
<HelpCircle className='h-[14px] w-[14px]' />
</Button>
</PopoverTrigger>
<PopoverContent className='w-[360px] p-[12px]' align='end'>
<div className='flex flex-col gap-[12px]'>
<div>
<h4 className='font-medium text-[12px] text-[var(--text-primary)]'>
Filter Operators
</h4>
<p className='text-[11px] text-[var(--text-tertiary)]'>
Use MongoDB-style operators to filter rows
</p>
</div>
<div className='flex flex-col gap-[6px] font-mono text-[10px]'>
<div className='flex items-start gap-[8px]'>
<code className='rounded bg-[var(--surface-5)] px-[4px] py-[2px] text-[var(--brand-secondary)]'>
$eq
</code>
<span className='text-[var(--text-secondary)]'>
Equals: {`{"status": "active"}`}
</span>
</div>
<div className='flex items-start gap-[8px]'>
<code className='rounded bg-[var(--surface-5)] px-[4px] py-[2px] text-[var(--brand-secondary)]'>
$ne
</code>
<span className='text-[var(--text-secondary)]'>
Not equals: {`{"status": {"$ne": "deleted"}}`}
</span>
</div>
<div className='flex items-start gap-[8px]'>
<code className='rounded bg-[var(--surface-5)] px-[4px] py-[2px] text-[var(--brand-secondary)]'>
$gt
</code>
<span className='text-[var(--text-secondary)]'>
Greater than: {`{"age": {"$gt": 18}}`}
</span>
</div>
<div className='flex items-start gap-[8px]'>
<code className='rounded bg-[var(--surface-5)] px-[4px] py-[2px] text-[var(--brand-secondary)]'>
$gte
</code>
<span className='text-[var(--text-secondary)]'>
Greater or equal: {`{"age": {"$gte": 21}}`}
</span>
</div>
<div className='flex items-start gap-[8px]'>
<code className='rounded bg-[var(--surface-5)] px-[4px] py-[2px] text-[var(--brand-secondary)]'>
$lt
</code>
<span className='text-[var(--text-secondary)]'>
Less than: {`{"price": {"$lt": 100}}`}
</span>
</div>
<div className='flex items-start gap-[8px]'>
<code className='rounded bg-[var(--surface-5)] px-[4px] py-[2px] text-[var(--brand-secondary)]'>
$lte
</code>
<span className='text-[var(--text-secondary)]'>
Less or equal: {`{"qty": {"$lte": 10}}`}
</span>
</div>
<div className='flex items-start gap-[8px]'>
<code className='rounded bg-[var(--surface-5)] px-[4px] py-[2px] text-[var(--brand-secondary)]'>
$in
</code>
<span className='text-[var(--text-secondary)]'>
In array: {`{"status": {"$in": ["a", "b"]}}`}
</span>
</div>
<div className='flex items-start gap-[8px]'>
<code className='rounded bg-[var(--surface-5)] px-[4px] py-[2px] text-[var(--brand-secondary)]'>
$contains
</code>
<span className='text-[var(--text-secondary)]'>
String contains: {`{"email": {"$contains": "@"}}`}
</span>
</div>
</div>
<div className='border-[var(--border)] border-t pt-[8px]'>
<p className='text-[10px] text-[var(--text-tertiary)]'>
Combine multiple conditions:{' '}
<code className='text-[var(--text-secondary)]'>
{`{"age": {"$gte": 18}, "active": true}`}
</code>
</p>
</div>
</div>
</PopoverContent>
</Popover>
</div>
{filterError && <span className='text-[11px] text-[var(--text-error)]'>{filterError}</span>}
{appliedFilter && (
<div className='flex items-center gap-[6px]'>
<Badge variant='blue' size='sm'>
Filter active
</Badge>
<span className='font-mono text-[10px] text-[var(--text-tertiary)]'>
{JSON.stringify(appliedFilter)}
</span>
</div>
)}
<FilterBuilder
columns={columns}
onApply={handleApplyQueryOptions}
onAddRow={() => setShowAddModal(true)}
/>
{selectedRows.size > 0 && (
<span className='text-[11px] text-[var(--text-tertiary)]'>
{selectedRows.size} selected
@@ -565,9 +408,9 @@ export function TableDataViewer() {
<TableCell colSpan={columns.length + 2} className='h-[160px] text-center'>
<div className='flex flex-col items-center gap-[12px]'>
<span className='text-[13px] text-[var(--text-tertiary)]'>
{appliedFilter ? 'No rows match your filter' : 'No data'}
{queryOptions.filter ? 'No rows match your filter' : 'No data'}
</span>
{!appliedFilter && (
{!queryOptions.filter && (
<Button variant='default' size='sm' onClick={() => setShowAddModal(true)}>
<Plus className='mr-[4px] h-[12px] w-[12px]' />
Add first row
@@ -635,7 +478,7 @@ export function TableDataViewer() {
{totalPages > 1 && (
<div className='flex h-[40px] shrink-0 items-center justify-between border-[var(--border)] border-t px-[16px]'>
<span className='text-[11px] text-[var(--text-tertiary)]'>
Page {currentPage + 1} of {totalPages}
Page {currentPage + 1} of {totalPages} ({totalCount} rows)
</span>
<div className='flex items-center gap-[4px]'>
<Button
@@ -644,7 +487,7 @@ export function TableDataViewer() {
onClick={() => setCurrentPage((p) => Math.max(0, p - 1))}
disabled={currentPage === 0}
>
<ChevronLeft className='h-[14px] w-[14px]' />
Previous
</Button>
<Button
variant='ghost'
@@ -652,7 +495,7 @@ export function TableDataViewer() {
onClick={() => setCurrentPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={currentPage === totalPages - 1}
>
<ChevronRight className='h-[14px] w-[14px]' />
Next
</Button>
</div>
</div>

View File

@@ -15,20 +15,22 @@
import type { SQL } from 'drizzle-orm'
import { sql } from 'drizzle-orm'
export interface FieldCondition {
$eq?: any
$ne?: any
$gt?: number
$gte?: number
$lt?: number
$lte?: number
$in?: any[]
$nin?: any[]
$contains?: string
}
export interface QueryFilter {
[key: string]:
| any
| {
$eq?: any
$ne?: any
$gt?: number
$gte?: number
$lt?: number
$lte?: number
$in?: any[]
$nin?: any[]
$contains?: string
}
$or?: QueryFilter[]
$and?: QueryFilter[]
[key: string]: any | FieldCondition | QueryFilter[] | undefined
}
/**
@@ -41,9 +43,79 @@ function buildContainmentClause(tableName: string, field: string, value: any): S
return sql`${sql.raw(`${tableName}.data`)} @> ${jsonObj}::jsonb`
}
/**
* Build a single field condition clause
*/
function buildFieldCondition(tableName: string, field: string, condition: any): SQL[] {
const conditions: SQL[] = []
const escapedField = field.replace(/'/g, "''")
if (typeof condition === 'object' && condition !== null && !Array.isArray(condition)) {
// Operator-based filter
for (const [op, value] of Object.entries(condition)) {
switch (op) {
case '$eq':
conditions.push(buildContainmentClause(tableName, field, value))
break
case '$ne':
conditions.push(sql`NOT (${buildContainmentClause(tableName, field, value)})`)
break
case '$gt':
conditions.push(
sql`(${sql.raw(`${tableName}.data->>'${escapedField}'`)})::numeric > ${value}`
)
break
case '$gte':
conditions.push(
sql`(${sql.raw(`${tableName}.data->>'${escapedField}'`)})::numeric >= ${value}`
)
break
case '$lt':
conditions.push(
sql`(${sql.raw(`${tableName}.data->>'${escapedField}'`)})::numeric < ${value}`
)
break
case '$lte':
conditions.push(
sql`(${sql.raw(`${tableName}.data->>'${escapedField}'`)})::numeric <= ${value}`
)
break
case '$in':
if (Array.isArray(value) && value.length > 0) {
if (value.length === 1) {
conditions.push(buildContainmentClause(tableName, field, value[0]))
} else {
const inConditions = value.map((v) => buildContainmentClause(tableName, field, v))
conditions.push(sql`(${sql.join(inConditions, sql.raw(' OR '))})`)
}
}
break
case '$nin':
if (Array.isArray(value) && value.length > 0) {
const ninConditions = value.map(
(v) => sql`NOT (${buildContainmentClause(tableName, field, v)})`
)
conditions.push(sql`(${sql.join(ninConditions, sql.raw(' AND '))})`)
}
break
case '$contains':
conditions.push(
sql`${sql.raw(`${tableName}.data->>'${escapedField}'`)} ILIKE ${`%${value}%`}`
)
break
}
}
} else {
// Direct equality
conditions.push(buildContainmentClause(tableName, field, condition))
}
return conditions
}
/**
* Build WHERE clause from filter object
* Supports: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $contains
* Supports: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $contains, $or, $and
*
* Uses GIN-index-compatible containment operator (@>) for:
* - $eq (equality)
@@ -55,81 +127,56 @@ function buildContainmentClause(tableName: string, field: string, value: any): S
* - $gt, $gte, $lt, $lte (numeric comparisons)
* - $nin (not in)
* - $contains (pattern matching)
*
* Logical operators:
* - $or: Array of filter objects, joined with OR
* - $and: Array of filter objects, joined with AND
*/
export function buildFilterClause(filter: QueryFilter, tableName: string): SQL | undefined {
const conditions: SQL[] = []
for (const [field, condition] of Object.entries(filter)) {
// Escape field name to prevent SQL injection (for ->> operators)
const escapedField = field.replace(/'/g, "''")
if (typeof condition === 'object' && condition !== null && !Array.isArray(condition)) {
// Operator-based filter
for (const [op, value] of Object.entries(condition)) {
switch (op) {
case '$eq':
// Use containment operator for GIN index support
conditions.push(buildContainmentClause(tableName, field, value))
break
case '$ne':
// NOT containment - still uses GIN index for the containment check
conditions.push(sql`NOT (${buildContainmentClause(tableName, field, value)})`)
break
case '$gt':
// Numeric comparison requires text extraction (no GIN support)
conditions.push(
sql`(${sql.raw(`${tableName}.data->>'${escapedField}'`)})::numeric > ${value}`
)
break
case '$gte':
conditions.push(
sql`(${sql.raw(`${tableName}.data->>'${escapedField}'`)})::numeric >= ${value}`
)
break
case '$lt':
conditions.push(
sql`(${sql.raw(`${tableName}.data->>'${escapedField}'`)})::numeric < ${value}`
)
break
case '$lte':
conditions.push(
sql`(${sql.raw(`${tableName}.data->>'${escapedField}'`)})::numeric <= ${value}`
)
break
case '$in':
// Use OR of containment checks for GIN index support
if (Array.isArray(value) && value.length > 0) {
if (value.length === 1) {
// Single value - just use containment
conditions.push(buildContainmentClause(tableName, field, value[0]))
} else {
// Multiple values - OR of containment checks
const inConditions = value.map((v) => buildContainmentClause(tableName, field, v))
conditions.push(sql`(${sql.join(inConditions, sql.raw(' OR '))})`)
}
}
break
case '$nin':
// NOT IN requires checking none of the values match
if (Array.isArray(value) && value.length > 0) {
const ninConditions = value.map(
(v) => sql`NOT (${buildContainmentClause(tableName, field, v)})`
)
conditions.push(sql`(${sql.join(ninConditions, sql.raw(' AND '))})`)
}
break
case '$contains':
// Pattern matching requires text extraction (no GIN support)
conditions.push(
sql`${sql.raw(`${tableName}.data->>'${escapedField}'`)} ILIKE ${`%${value}%`}`
)
break
// Handle $or operator
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)
}
}
} else {
// Direct equality - use containment operator for GIN index support
conditions.push(buildContainmentClause(tableName, field, condition))
if (orConditions.length > 0) {
if (orConditions.length === 1) {
conditions.push(orConditions[0])
} else {
conditions.push(sql`(${sql.join(orConditions, sql.raw(' OR '))})`)
}
}
continue
}
// Handle $and operator
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) {
conditions.push(andConditions[0])
} else {
conditions.push(sql`(${sql.join(andConditions, sql.raw(' AND '))})`)
}
}
continue
}
// Handle regular field conditions
const fieldConditions = buildFieldCondition(tableName, field, condition)
conditions.push(...fieldConditions)
}
if (conditions.length === 0) return undefined