This commit is contained in:
Lakee Sivaraya
2026-01-15 15:52:44 -08:00
parent ed543a71f9
commit e503408825
5 changed files with 60 additions and 115 deletions

View File

@@ -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 { QueryFilter, RowData, TableSchema } from '@/lib/table'
import type { Filter, RowData, TableSchema } from '@/lib/table'
import {
getUniqueColumns,
TABLE_LIMITS,
@@ -409,7 +409,7 @@ export async function GET(request: NextRequest, { params }: TableRowsRouteParams
// Add filter conditions if provided
if (validated.filter) {
const filterClause = buildFilterClause(validated.filter as QueryFilter, 'user_table_rows')
const filterClause = buildFilterClause(validated.filter as Filter, 'user_table_rows')
if (filterClause) {
baseConditions.push(filterClause)
}
@@ -546,7 +546,7 @@ export async function PUT(request: NextRequest, { params }: TableRowsRouteParams
]
// Add filter conditions
const filterClause = buildFilterClause(validated.filter as QueryFilter, 'user_table_rows')
const filterClause = buildFilterClause(validated.filter as Filter, 'user_table_rows')
if (filterClause) {
baseConditions.push(filterClause)
}
@@ -740,7 +740,7 @@ export async function DELETE(request: NextRequest, { params }: TableRowsRoutePar
]
// Add filter conditions
const filterClause = buildFilterClause(validated.filter as QueryFilter, 'user_table_rows')
const filterClause = buildFilterClause(validated.filter as Filter, 'user_table_rows')
if (filterClause) {
baseConditions.push(filterClause)
}

View File

@@ -7,7 +7,7 @@
*/
import { describe, expect, it } from 'vitest'
import { buildFilterClause, buildSortClause } from './query-builder'
import type { QueryFilter } from './types'
import type { Filter } from './types'
describe('Query Builder', () => {
describe('buildFilterClause', () => {
@@ -19,84 +19,84 @@ describe('Query Builder', () => {
})
it('should handle simple equality filter', () => {
const filter: QueryFilter = { name: 'John' }
const filter: Filter = { name: 'John' }
const result = buildFilterClause(filter, tableName)
expect(result).toBeDefined()
})
it('should handle $eq operator', () => {
const filter: QueryFilter = { status: { $eq: 'active' } }
const filter: Filter = { status: { $eq: 'active' } }
const result = buildFilterClause(filter, tableName)
expect(result).toBeDefined()
})
it('should handle $ne operator', () => {
const filter: QueryFilter = { status: { $ne: 'deleted' } }
const filter: Filter = { status: { $ne: 'deleted' } }
const result = buildFilterClause(filter, tableName)
expect(result).toBeDefined()
})
it('should handle $gt operator', () => {
const filter: QueryFilter = { age: { $gt: 18 } }
const filter: Filter = { age: { $gt: 18 } }
const result = buildFilterClause(filter, tableName)
expect(result).toBeDefined()
})
it('should handle $gte operator', () => {
const filter: QueryFilter = { age: { $gte: 18 } }
const filter: Filter = { age: { $gte: 18 } }
const result = buildFilterClause(filter, tableName)
expect(result).toBeDefined()
})
it('should handle $lt operator', () => {
const filter: QueryFilter = { age: { $lt: 65 } }
const filter: Filter = { age: { $lt: 65 } }
const result = buildFilterClause(filter, tableName)
expect(result).toBeDefined()
})
it('should handle $lte operator', () => {
const filter: QueryFilter = { age: { $lte: 65 } }
const filter: Filter = { age: { $lte: 65 } }
const result = buildFilterClause(filter, tableName)
expect(result).toBeDefined()
})
it('should handle $in operator with single value', () => {
const filter: QueryFilter = { status: { $in: ['active'] } }
const filter: Filter = { status: { $in: ['active'] } }
const result = buildFilterClause(filter, tableName)
expect(result).toBeDefined()
})
it('should handle $in operator with multiple values', () => {
const filter: QueryFilter = { status: { $in: ['active', 'pending'] } }
const filter: Filter = { status: { $in: ['active', 'pending'] } }
const result = buildFilterClause(filter, tableName)
expect(result).toBeDefined()
})
it('should handle $nin operator', () => {
const filter: QueryFilter = { status: { $nin: ['deleted', 'archived'] } }
const filter: Filter = { status: { $nin: ['deleted', 'archived'] } }
const result = buildFilterClause(filter, tableName)
expect(result).toBeDefined()
})
it('should handle $contains operator', () => {
const filter: QueryFilter = { name: { $contains: 'john' } }
const filter: Filter = { name: { $contains: 'john' } }
const result = buildFilterClause(filter, tableName)
expect(result).toBeDefined()
})
it('should handle $or logical operator', () => {
const filter: QueryFilter = {
const filter: Filter = {
$or: [{ status: 'active' }, { status: 'pending' }],
}
const result = buildFilterClause(filter, tableName)
@@ -105,7 +105,7 @@ describe('Query Builder', () => {
})
it('should handle $and logical operator', () => {
const filter: QueryFilter = {
const filter: Filter = {
$and: [{ status: 'active' }, { age: { $gt: 18 } }],
}
const result = buildFilterClause(filter, tableName)
@@ -114,7 +114,7 @@ describe('Query Builder', () => {
})
it('should handle multiple conditions combined with AND', () => {
const filter: QueryFilter = {
const filter: Filter = {
status: 'active',
age: { $gt: 18 },
}
@@ -124,7 +124,7 @@ describe('Query Builder', () => {
})
it('should handle nested $or and $and', () => {
const filter: QueryFilter = {
const filter: Filter = {
$or: [{ $and: [{ status: 'active' }, { verified: true }] }, { role: 'admin' }],
}
const result = buildFilterClause(filter, tableName)
@@ -133,40 +133,40 @@ describe('Query Builder', () => {
})
it('should throw error for invalid field name', () => {
const filter: QueryFilter = { 'invalid-field': 'value' }
const filter: Filter = { 'invalid-field': 'value' }
expect(() => buildFilterClause(filter, tableName)).toThrow('Invalid field name')
})
it('should throw error for invalid operator', () => {
const filter = { name: { $invalid: 'value' } } as unknown as QueryFilter
const filter = { name: { $invalid: 'value' } } as unknown as Filter
expect(() => buildFilterClause(filter, tableName)).toThrow('Invalid operator')
})
it('should skip undefined values', () => {
const filter: QueryFilter = { name: undefined, status: 'active' }
const filter: Filter = { name: undefined, status: 'active' }
const result = buildFilterClause(filter, tableName)
expect(result).toBeDefined()
})
it('should handle boolean values', () => {
const filter: QueryFilter = { active: true }
const filter: Filter = { active: true }
const result = buildFilterClause(filter, tableName)
expect(result).toBeDefined()
})
it('should handle null values', () => {
const filter: QueryFilter = { deleted_at: null }
const filter: Filter = { deleted_at: null }
const result = buildFilterClause(filter, tableName)
expect(result).toBeDefined()
})
it('should handle numeric values', () => {
const filter: QueryFilter = { count: 42 }
const filter: Filter = { count: 42 }
const result = buildFilterClause(filter, tableName)
expect(result).toBeDefined()
@@ -236,13 +236,13 @@ describe('Query Builder', () => {
const validNames = ['name', 'user_id', '_private', 'Count123', 'a']
for (const name of validNames) {
const filter: QueryFilter = { [name]: 'value' }
const filter: Filter = { [name]: 'value' }
expect(() => buildFilterClause(filter, tableName)).not.toThrow()
}
})
it('should reject field names starting with number', () => {
const filter: QueryFilter = { '123name': 'value' }
const filter: Filter = { '123name': 'value' }
expect(() => buildFilterClause(filter, tableName)).toThrow('Invalid field name')
})
@@ -250,7 +250,7 @@ describe('Query Builder', () => {
const invalidNames = ['field-name', 'field.name', 'field name', 'field@name']
for (const name of invalidNames) {
const filter: QueryFilter = { [name]: 'value' }
const filter: Filter = { [name]: 'value' }
expect(() => buildFilterClause(filter, tableName)).toThrow('Invalid field name')
}
})
@@ -259,7 +259,7 @@ describe('Query Builder', () => {
const sqlInjectionAttempts = ["'; DROP TABLE users; --", 'name OR 1=1', 'name; DELETE FROM']
for (const attempt of sqlInjectionAttempts) {
const filter: QueryFilter = { [attempt]: 'value' }
const filter: Filter = { [attempt]: 'value' }
expect(() => buildFilterClause(filter, tableName)).toThrow('Invalid field name')
}
})

View File

@@ -8,7 +8,7 @@
import type { SQL } from 'drizzle-orm'
import { sql } from 'drizzle-orm'
import { NAME_PATTERN } from './constants'
import type { FilterOperators, JsonValue, QueryFilter } from './types'
import type { Filter, FilterOperator, JsonValue } from './types'
/**
* Whitelist of allowed operators for query filtering.
@@ -92,7 +92,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 FilterOperators
* @param condition - Either a simple value (for equality) or a FilterOperator
* 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 +101,7 @@ function buildContainsClause(tableName: string, field: string, value: string): S
function buildFieldCondition(
tableName: string,
field: string,
condition: JsonValue | FilterOperators
condition: JsonValue | FilterOperator
): SQL[] {
validateFieldName(field)
@@ -181,7 +181,7 @@ function buildFieldCondition(
* Builds SQL clauses from nested filters and joins them with the specified operator.
*/
function buildLogicalClause(
subFilters: QueryFilter[],
subFilters: Filter[],
tableName: string,
operator: 'OR' | 'AND'
): SQL | undefined {
@@ -203,7 +203,7 @@ function buildLogicalClause(
* Builds a WHERE clause from a filter object.
* Recursively processes logical operators ($or, $and) and field conditions.
*/
export function buildFilterClause(filter: QueryFilter, tableName: string): SQL | undefined {
export function buildFilterClause(filter: Filter, tableName: string): SQL | undefined {
const conditions: SQL[] = []
for (const [field, condition] of Object.entries(filter)) {
@@ -212,7 +212,7 @@ export function buildFilterClause(filter: QueryFilter, tableName: string): SQL |
}
if (field === '$or' && Array.isArray(condition)) {
const orClause = buildLogicalClause(condition as QueryFilter[], tableName, 'OR')
const orClause = buildLogicalClause(condition as Filter[], tableName, 'OR')
if (orClause) {
conditions.push(orClause)
}
@@ -220,7 +220,7 @@ export function buildFilterClause(filter: QueryFilter, tableName: string): SQL |
}
if (field === '$and' && Array.isArray(condition)) {
const andClause = buildLogicalClause(condition as QueryFilter[], tableName, 'AND')
const andClause = buildLogicalClause(condition as Filter[], tableName, 'AND')
if (andClause) {
conditions.push(andClause)
}
@@ -234,7 +234,7 @@ export function buildFilterClause(filter: QueryFilter, tableName: string): SQL |
const fieldConditions = buildFieldCondition(
tableName,
field,
condition as JsonValue | FilterOperators
condition as JsonValue | FilterOperator
)
conditions.push(...fieldConditions)
}

View File

@@ -8,30 +8,28 @@ import type { COLUMN_TYPES } from './constants'
/** Primitive values that can be stored in table columns */
export type ColumnValue = string | number | boolean | null | Date
/** JSON-compatible value for complex column types */
export type JsonValue = ColumnValue | JsonValue[] | { [key: string]: JsonValue }
/** Row data structure for insert/update operations */
/** Row data structure for insert/update operations
* key is the column name and value is the value of the column
* value is a JSON-compatible value */
export type RowData = Record<string, JsonValue>
/** Sort direction for query operations */
export type SortDirection = 'asc' | 'desc'
/** Sort specification mapping column names to sort direction */
export type SortSpec = Record<string, SortDirection>
/** Sort specification mapping column names to sort direction
* "asc" for ascending and "desc" for descending
* key is the column name and value is the sort direction */
export type Sort = Record<string, SortDirection>
/**
* Column definition within a table schema.
*/
export interface ColumnDefinition {
/** Column name (must match NAME_PATTERN) */
name: string
/** Data type for the column */
type: (typeof COLUMN_TYPES)[number]
/** Whether the column is required (non-null) */
required?: boolean
/** Whether the column must have unique values */
unique?: boolean
}
@@ -39,7 +37,6 @@ export interface ColumnDefinition {
* Table schema definition containing column specifications.
*/
export interface TableSchema {
/** Array of column definitions */
columns: ColumnDefinition[]
}
@@ -47,23 +44,14 @@ export interface TableSchema {
* Complete table definition including metadata.
*/
export interface TableDefinition {
/** Unique table identifier */
id: string
/** Human-readable table name */
name: string
/** Optional table description */
description?: string | null
/** Table schema with column definitions */
schema: TableSchema
/** Current number of rows in the table */
rowCount: number
/** Maximum allowed rows (from TABLE_LIMITS) */
maxRows: number
/** Workspace the table belongs to */
workspaceId: string
/** ISO timestamp of creation */
createdAt: Date | string
/** ISO timestamp of last update */
updatedAt: Date | string
}
@@ -71,13 +59,9 @@ export interface TableDefinition {
* Row stored in a user-defined table.
*/
export interface TableRow {
/** Unique row identifier */
id: string
/** Row data as key-value pairs */
data: RowData
/** ISO timestamp of creation */
createdAt: Date | string
/** ISO timestamp of last update */
updatedAt: Date | string
}
@@ -85,47 +69,33 @@ export interface TableRow {
* Filter operator conditions for query filtering.
* Supports MongoDB-style query operators.
*/
export interface FilterOperators {
/** Equal to */
export interface FilterOperator {
$eq?: ColumnValue
/** Not equal to */
$ne?: ColumnValue
/** Greater than (numbers only) */
$gt?: number
/** Greater than or equal (numbers only) */
$gte?: number
/** Less than (numbers only) */
$lt?: number
/** Less than or equal (numbers only) */
$lte?: number
/** Value in array */
$in?: ColumnValue[]
/** Value not in array */
$nin?: ColumnValue[]
/** String contains (case-insensitive) */
$contains?: string
}
/**
* Query filter for table rows.
* Filter for querying table rows.
* Keys are column names, values are either direct values or filter operators.
*/
export interface QueryFilter {
/** Logical OR of multiple filters */
$or?: QueryFilter[]
/** Logical AND of multiple filters */
$and?: QueryFilter[]
/** Column filters */
[key: string]: ColumnValue | FilterOperators | QueryFilter[] | undefined
export interface Filter {
$or?: Filter[]
$and?: Filter[]
[key: string]: ColumnValue | FilterOperator | Filter[] | undefined
}
/**
* Result of a validation operation.
* Result of a validation operation. The list of errors are used to display to the user.
*/
export interface ValidationResult {
/** Whether validation passed */
valid: boolean
/** Array of error messages (empty if valid) */
errors: string[]
}
@@ -133,13 +103,9 @@ export interface ValidationResult {
* Options for querying table rows.
*/
export interface QueryOptions {
/** Filter criteria */
filter?: QueryFilter
/** Sort specification */
sort?: SortSpec
/** Maximum rows to return */
filter?: Filter
sort?: Sort
limit?: number
/** Number of rows to skip */
offset?: number
}
@@ -163,9 +129,7 @@ export interface QueryResult {
* Result of a bulk operation (update/delete by filter).
*/
export interface BulkOperationResult {
/** Number of rows affected */
affectedCount: number
/** IDs of affected rows */
affectedRowIds: string[]
}
@@ -189,11 +153,8 @@ export interface CreateTableData {
* Data required to insert a row.
*/
export interface InsertRowData {
/** Table ID */
tableId: string
/** Row data */
data: RowData
/** Workspace ID */
workspaceId: string
}
@@ -201,11 +162,8 @@ export interface InsertRowData {
* Data required for batch row insertion.
*/
export interface BatchInsertData {
/** Table ID */
tableId: string
/** Array of row data */
rows: RowData[]
/** Workspace ID */
workspaceId: string
}
@@ -213,13 +171,9 @@ export interface BatchInsertData {
* Data required to update a row.
*/
export interface UpdateRowData {
/** Table ID */
tableId: string
/** Row ID to update */
rowId: string
/** Full row data replacement */
data: RowData
/** Workspace ID */
workspaceId: string
}
@@ -227,15 +181,10 @@ export interface UpdateRowData {
* Data required for bulk update by filter.
*/
export interface BulkUpdateData {
/** Table ID */
tableId: string
/** Filter to match rows */
filter: QueryFilter
/** Data to apply to matched rows */
filter: Filter
data: RowData
/** Maximum rows to update */
limit?: number
/** Workspace ID */
workspaceId: string
}
@@ -243,12 +192,8 @@ export interface BulkUpdateData {
* Data required for bulk delete by filter.
*/
export interface BulkDeleteData {
/** Table ID */
tableId: string
/** Filter to match rows */
filter: QueryFilter
/** Maximum rows to delete */
filter: Filter
limit?: number
/** Workspace ID */
workspaceId: string
}

View File

@@ -1,6 +1,6 @@
import type {
ColumnDefinition,
QueryFilter,
Filter,
RowData,
TableDefinition,
TableRow,
@@ -40,7 +40,7 @@ export interface TableRowDeleteParams {
export interface TableRowQueryParams {
tableId: string
filter?: QueryFilter
filter?: Filter
sort?: Record<string, 'asc' | 'desc'>
limit?: number
offset?: number
@@ -107,7 +107,7 @@ export interface TableBatchInsertResponse extends ToolResponse {
export interface TableUpdateByFilterParams {
tableId: string
filter: QueryFilter
filter: Filter
data: RowData
limit?: number
_context?: ToolExecutionContext
@@ -115,7 +115,7 @@ export interface TableUpdateByFilterParams {
export interface TableDeleteByFilterParams {
tableId: string
filter: QueryFilter
filter: Filter
limit?: number
_context?: ToolExecutionContext
}