mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-22 13:28:04 -05:00
update comments with ai
This commit is contained in:
@@ -1,5 +1,11 @@
|
||||
/**
|
||||
* Limits and constants for user-defined tables
|
||||
* Limits and constants for user-defined tables.
|
||||
*
|
||||
* @module lib/table/constants
|
||||
*/
|
||||
|
||||
/**
|
||||
* Table and column limits for user-defined tables.
|
||||
*/
|
||||
export const TABLE_LIMITS = {
|
||||
MAX_TABLES_PER_WORKSPACE: 100,
|
||||
@@ -15,14 +21,18 @@ export const TABLE_LIMITS = {
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Valid column types for table schema
|
||||
* Valid column types for table schema.
|
||||
*/
|
||||
export const COLUMN_TYPES = ['string', 'number', 'boolean', 'date', 'json'] as const
|
||||
|
||||
/**
|
||||
* Type representing a valid column type.
|
||||
*/
|
||||
export type ColumnType = (typeof COLUMN_TYPES)[number]
|
||||
|
||||
/**
|
||||
* Regex pattern for valid table and column names
|
||||
* Must start with letter or underscore, followed by alphanumeric or underscore
|
||||
* Regex pattern for valid table and column names.
|
||||
*
|
||||
* Must start with letter or underscore, followed by alphanumeric or underscore.
|
||||
*/
|
||||
export const NAME_PATTERN = /^[a-z_][a-z0-9_]*$/i
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
/**
|
||||
* Shared utilities for filter builder UI components.
|
||||
*
|
||||
* Used by both the table data viewer and the block editor filter-format component.
|
||||
*
|
||||
* @module lib/table/filter-builder-utils
|
||||
*/
|
||||
|
||||
/**
|
||||
* Available comparison operators for filter conditions
|
||||
* JSON-serializable value types.
|
||||
*/
|
||||
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }
|
||||
|
||||
/**
|
||||
* Available comparison operators for filter conditions.
|
||||
*/
|
||||
export const COMPARISON_OPERATORS = [
|
||||
{ value: 'eq', label: 'equals' },
|
||||
@@ -18,7 +26,7 @@ export const COMPARISON_OPERATORS = [
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Logical operators for combining conditions
|
||||
* Logical operators for combining filter conditions.
|
||||
*/
|
||||
export const LOGICAL_OPERATORS = [
|
||||
{ value: 'and', label: 'and' },
|
||||
@@ -26,27 +34,38 @@ export const LOGICAL_OPERATORS = [
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Represents a single filter condition in builder format
|
||||
* Represents a single filter condition in builder format.
|
||||
*/
|
||||
export interface FilterCondition {
|
||||
/** Unique identifier for the condition */
|
||||
id: string
|
||||
/** Logical operator to combine with previous condition */
|
||||
logicalOperator: 'and' | 'or'
|
||||
/** Column name to filter on */
|
||||
column: string
|
||||
/** Comparison operator */
|
||||
operator: string
|
||||
/** Filter value as string */
|
||||
value: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a unique ID for filter conditions
|
||||
* Generates a unique ID for filter conditions.
|
||||
*
|
||||
* @returns Random alphanumeric string
|
||||
*/
|
||||
export function generateFilterId(): string {
|
||||
return Math.random().toString(36).substring(2, 9)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a value string into its appropriate type
|
||||
* Parses a value string into its appropriate type.
|
||||
*
|
||||
* @param value - The string value to parse
|
||||
* @param operator - The operator being used (affects parsing for 'in')
|
||||
* @returns Parsed value (string, number, boolean, null, or array)
|
||||
*/
|
||||
function parseValue(value: string, operator: string): any {
|
||||
function parseValue(value: string, operator: string): JsonValue {
|
||||
if (value === 'true') return true
|
||||
if (value === 'false') return false
|
||||
if (value === 'null') return null
|
||||
@@ -67,13 +86,18 @@ function parseValue(value: string, operator: string): any {
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts builder filter conditions to MongoDB-style filter object
|
||||
* Converts builder filter conditions to MongoDB-style filter object.
|
||||
*
|
||||
* @param conditions - Array of filter conditions from the builder UI
|
||||
* @returns Filter object or null if no conditions
|
||||
*/
|
||||
export function conditionsToFilter(conditions: FilterCondition[]): Record<string, any> | null {
|
||||
export function conditionsToFilter(
|
||||
conditions: FilterCondition[]
|
||||
): Record<string, JsonValue> | null {
|
||||
if (conditions.length === 0) return null
|
||||
|
||||
const orGroups: Record<string, any>[] = []
|
||||
let currentAndGroup: Record<string, any> = {}
|
||||
const orGroups: Record<string, JsonValue>[] = []
|
||||
let currentAndGroup: Record<string, JsonValue> = {}
|
||||
|
||||
conditions.forEach((condition, index) => {
|
||||
const { column, operator, value } = condition
|
||||
@@ -103,9 +127,12 @@ export function conditionsToFilter(conditions: FilterCondition[]): Record<string
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts MongoDB-style filter object to builder conditions
|
||||
* Converts MongoDB-style filter object to builder conditions.
|
||||
*
|
||||
* @param filter - Filter object to convert
|
||||
* @returns Array of filter conditions for the builder UI
|
||||
*/
|
||||
export function filterToConditions(filter: Record<string, any> | null): FilterCondition[] {
|
||||
export function filterToConditions(filter: Record<string, JsonValue> | null): FilterCondition[] {
|
||||
if (!filter) return []
|
||||
|
||||
const conditions: FilterCondition[] = []
|
||||
@@ -113,7 +140,10 @@ export function filterToConditions(filter: Record<string, any> | null): FilterCo
|
||||
// Handle $or at the top level
|
||||
if (filter.$or && Array.isArray(filter.$or)) {
|
||||
filter.$or.forEach((orGroup, groupIndex) => {
|
||||
const groupConditions = parseFilterGroup(orGroup)
|
||||
if (typeof orGroup !== 'object' || orGroup === null || Array.isArray(orGroup)) {
|
||||
return
|
||||
}
|
||||
const groupConditions = parseFilterGroup(orGroup as Record<string, JsonValue>)
|
||||
groupConditions.forEach((cond, condIndex) => {
|
||||
conditions.push({
|
||||
...cond,
|
||||
@@ -134,9 +164,12 @@ export function filterToConditions(filter: Record<string, any> | null): FilterCo
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a single filter group (AND conditions)
|
||||
* Parses a single filter group containing AND conditions.
|
||||
*
|
||||
* @param group - Filter group object
|
||||
* @returns Array of filter conditions
|
||||
*/
|
||||
function parseFilterGroup(group: Record<string, any>): FilterCondition[] {
|
||||
function parseFilterGroup(group: Record<string, JsonValue>): FilterCondition[] {
|
||||
const conditions: FilterCondition[] = []
|
||||
|
||||
for (const [column, value] of Object.entries(group)) {
|
||||
@@ -171,9 +204,12 @@ function parseFilterGroup(group: Record<string, any>): FilterCondition[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a value for display in the builder UI
|
||||
* Formats a value for display in the builder UI.
|
||||
*
|
||||
* @param value - Value to format
|
||||
* @returns String representation for the builder
|
||||
*/
|
||||
function formatValueForBuilder(value: any): string {
|
||||
function formatValueForBuilder(value: JsonValue): string {
|
||||
if (value === null) return 'null'
|
||||
if (typeof value === 'boolean') return String(value)
|
||||
if (Array.isArray(value)) return value.map(formatValueForBuilder).join(', ')
|
||||
@@ -181,7 +217,10 @@ function formatValueForBuilder(value: any): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts builder conditions to JSON string
|
||||
* Converts builder conditions to JSON string.
|
||||
*
|
||||
* @param conditions - Array of filter conditions
|
||||
* @returns JSON string representation
|
||||
*/
|
||||
export function conditionsToJsonString(conditions: FilterCondition[]): string {
|
||||
const filter = conditionsToFilter(conditions)
|
||||
@@ -190,7 +229,10 @@ export function conditionsToJsonString(conditions: FilterCondition[]): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts JSON string to builder conditions
|
||||
* Converts JSON string to builder conditions.
|
||||
*
|
||||
* @param jsonString - JSON string to parse
|
||||
* @returns Array of filter conditions or empty array if invalid
|
||||
*/
|
||||
export function jsonStringToConditions(jsonString: string): FilterCondition[] {
|
||||
if (!jsonString || !jsonString.trim()) return []
|
||||
@@ -204,7 +246,7 @@ export function jsonStringToConditions(jsonString: string): FilterCondition[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort direction options
|
||||
* Sort direction options.
|
||||
*/
|
||||
export const SORT_DIRECTIONS = [
|
||||
{ value: 'asc', label: 'ascending' },
|
||||
@@ -212,23 +254,31 @@ export const SORT_DIRECTIONS = [
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Represents a single sort condition in builder format
|
||||
* Represents a single sort condition in builder format.
|
||||
*/
|
||||
export interface SortCondition {
|
||||
/** Unique identifier for the sort condition */
|
||||
id: string
|
||||
/** Column name to sort by */
|
||||
column: string
|
||||
/** Sort direction */
|
||||
direction: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a unique ID for sort conditions
|
||||
* Generates a unique ID for sort conditions.
|
||||
*
|
||||
* @returns Random alphanumeric string
|
||||
*/
|
||||
export function generateSortId(): string {
|
||||
return Math.random().toString(36).substring(2, 9)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts builder sort conditions to sort object
|
||||
* Converts builder sort conditions to sort object.
|
||||
*
|
||||
* @param conditions - Array of sort conditions from the builder UI
|
||||
* @returns Sort object or null if no conditions
|
||||
*/
|
||||
export function sortConditionsToSort(conditions: SortCondition[]): Record<string, string> | null {
|
||||
if (conditions.length === 0) return null
|
||||
@@ -244,7 +294,10 @@ export function sortConditionsToSort(conditions: SortCondition[]): Record<string
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts sort object to builder conditions
|
||||
* Converts sort object to builder conditions.
|
||||
*
|
||||
* @param sort - Sort object to convert
|
||||
* @returns Array of sort conditions for the builder UI
|
||||
*/
|
||||
export function sortToConditions(sort: Record<string, string> | null): SortCondition[] {
|
||||
if (!sort) return []
|
||||
@@ -257,7 +310,10 @@ export function sortToConditions(sort: Record<string, string> | null): SortCondi
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts builder sort conditions to JSON string
|
||||
* Converts builder sort conditions to JSON string.
|
||||
*
|
||||
* @param conditions - Array of sort conditions
|
||||
* @returns JSON string representation
|
||||
*/
|
||||
export function sortConditionsToJsonString(conditions: SortCondition[]): string {
|
||||
const sort = sortConditionsToSort(conditions)
|
||||
@@ -266,7 +322,10 @@ export function sortConditionsToJsonString(conditions: SortCondition[]): string
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts JSON string to sort builder conditions
|
||||
* Converts JSON string to sort builder conditions.
|
||||
*
|
||||
* @param jsonString - JSON string to parse
|
||||
* @returns Array of sort conditions or empty array if invalid
|
||||
*/
|
||||
export function jsonStringToSortConditions(jsonString: string): SortCondition[] {
|
||||
if (!jsonString || !jsonString.trim()) return []
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
/**
|
||||
* Table utilities module.
|
||||
*
|
||||
* Provides validation, query building, and filter utilities for user-defined tables.
|
||||
*
|
||||
* @module lib/table
|
||||
*/
|
||||
|
||||
export * from './constants'
|
||||
export * from './query-builder'
|
||||
export * from './validation'
|
||||
|
||||
@@ -15,82 +15,166 @@
|
||||
import type { SQL } from 'drizzle-orm'
|
||||
import { sql } from 'drizzle-orm'
|
||||
|
||||
/**
|
||||
* JSON-serializable value types.
|
||||
*/
|
||||
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }
|
||||
|
||||
/**
|
||||
* Field condition operators for filtering.
|
||||
*/
|
||||
export interface FieldCondition {
|
||||
$eq?: any
|
||||
$ne?: any
|
||||
/** Equality */
|
||||
$eq?: JsonValue
|
||||
/** Not equal */
|
||||
$ne?: JsonValue
|
||||
/** Greater than */
|
||||
$gt?: number
|
||||
/** Greater than or equal */
|
||||
$gte?: number
|
||||
/** Less than */
|
||||
$lt?: number
|
||||
/** Less than or equal */
|
||||
$lte?: number
|
||||
$in?: any[]
|
||||
$nin?: any[]
|
||||
/** Value in array */
|
||||
$in?: JsonValue[]
|
||||
/** Value not in array */
|
||||
$nin?: JsonValue[]
|
||||
/** String contains (case-insensitive) */
|
||||
$contains?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Query filter object supporting logical operators and field conditions.
|
||||
*/
|
||||
export interface QueryFilter {
|
||||
/** OR conditions */
|
||||
$or?: QueryFilter[]
|
||||
/** AND conditions */
|
||||
$and?: QueryFilter[]
|
||||
[key: string]: any | FieldCondition | QueryFilter[] | undefined
|
||||
/** Field conditions keyed by column name */
|
||||
[key: string]: JsonValue | FieldCondition | QueryFilter[] | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a JSONB containment clause that can use the GIN index.
|
||||
* Creates: data @> '{"field": value}'::jsonb
|
||||
* Builds a JSONB containment clause that can use the GIN index.
|
||||
*
|
||||
* The containment operator (@>) checks if the left JSONB value contains the right JSONB value.
|
||||
* This is efficient because PostgreSQL can use a GIN index on the data column.
|
||||
*
|
||||
* Example: For field "age" with value 25, generates:
|
||||
* `table.data @> '{"age": 25}'::jsonb`
|
||||
*
|
||||
* This is equivalent to: WHERE data->>'age' = '25' but can use the GIN index.
|
||||
*
|
||||
* @param tableName - The table alias/name (e.g., "user_tables")
|
||||
* @param field - The field name within the JSONB data column
|
||||
* @param value - The value to check for containment
|
||||
* @returns SQL clause for containment check
|
||||
*/
|
||||
function buildContainmentClause(tableName: string, field: string, value: any): SQL {
|
||||
function buildContainmentClause(tableName: string, field: string, value: JsonValue): SQL {
|
||||
// Build the JSONB object for containment check
|
||||
// Example: { "age": 25 } becomes '{"age":25}'::jsonb
|
||||
const jsonObj = JSON.stringify({ [field]: value })
|
||||
return sql`${sql.raw(`${tableName}.data`)} @> ${jsonObj}::jsonb`
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a single field condition clause
|
||||
* Builds SQL conditions for a single field.
|
||||
*
|
||||
* This function handles two types of conditions:
|
||||
* 1. Direct value equality: `{ age: 25 }` -> uses containment operator (@>)
|
||||
* 2. Operator-based: `{ age: { $gt: 25 } }` -> uses text extraction (->>) for comparisons
|
||||
*
|
||||
* The function returns an array because some operators (like $in) generate multiple conditions.
|
||||
*
|
||||
* @param tableName - The table alias/name
|
||||
* @param field - The field name within the JSONB data column
|
||||
* @param condition - Either a direct value (JsonValue) or an operator object (FieldCondition)
|
||||
* @returns Array of SQL conditions (usually one, but can be multiple for $in/$nin)
|
||||
*/
|
||||
function buildFieldCondition(tableName: string, field: string, condition: any): SQL[] {
|
||||
function buildFieldCondition(
|
||||
tableName: string,
|
||||
field: string,
|
||||
condition: JsonValue | FieldCondition
|
||||
): SQL[] {
|
||||
const conditions: SQL[] = []
|
||||
// Escape single quotes in field name to prevent SQL injection
|
||||
// Example: "O'Brien" -> "O''Brien"
|
||||
const escapedField = field.replace(/'/g, "''")
|
||||
|
||||
// Check if condition is an operator object (e.g., { $gt: 25 })
|
||||
if (typeof condition === 'object' && condition !== null && !Array.isArray(condition)) {
|
||||
// Operator-based filter
|
||||
// Operator-based filter: iterate through operators like $eq, $gt, etc.
|
||||
for (const [op, value] of Object.entries(condition)) {
|
||||
switch (op) {
|
||||
case '$eq':
|
||||
conditions.push(buildContainmentClause(tableName, field, value))
|
||||
// Equality: uses containment operator for GIN index support
|
||||
// Example: { age: { $eq: 25 } } -> data @> '{"age": 25}'::jsonb
|
||||
conditions.push(buildContainmentClause(tableName, field, value as JsonValue))
|
||||
break
|
||||
|
||||
case '$ne':
|
||||
conditions.push(sql`NOT (${buildContainmentClause(tableName, field, value)})`)
|
||||
// Not equal: negation of containment
|
||||
// Example: { age: { $ne: 25 } } -> NOT (data @> '{"age": 25}'::jsonb)
|
||||
conditions.push(
|
||||
sql`NOT (${buildContainmentClause(tableName, field, value as JsonValue)})`
|
||||
)
|
||||
break
|
||||
|
||||
case '$gt':
|
||||
// Greater than: must use text extraction (->>) and cast to numeric
|
||||
// Cannot use containment operator for comparisons
|
||||
// Example: { age: { $gt: 25 } } -> (data->>'age')::numeric > 25
|
||||
conditions.push(
|
||||
sql`(${sql.raw(`${tableName}.data->>'${escapedField}'`)})::numeric > ${value}`
|
||||
)
|
||||
break
|
||||
|
||||
case '$gte':
|
||||
// Greater than or equal
|
||||
// Example: { age: { $gte: 25 } } -> (data->>'age')::numeric >= 25
|
||||
conditions.push(
|
||||
sql`(${sql.raw(`${tableName}.data->>'${escapedField}'`)})::numeric >= ${value}`
|
||||
)
|
||||
break
|
||||
|
||||
case '$lt':
|
||||
// Less than
|
||||
// Example: { age: { $lt: 25 } } -> (data->>'age')::numeric < 25
|
||||
conditions.push(
|
||||
sql`(${sql.raw(`${tableName}.data->>'${escapedField}'`)})::numeric < ${value}`
|
||||
)
|
||||
break
|
||||
|
||||
case '$lte':
|
||||
// Less than or equal
|
||||
// Example: { age: { $lte: 25 } } -> (data->>'age')::numeric <= 25
|
||||
conditions.push(
|
||||
sql`(${sql.raw(`${tableName}.data->>'${escapedField}'`)})::numeric <= ${value}`
|
||||
)
|
||||
break
|
||||
|
||||
case '$in':
|
||||
// Value in array: converts to OR of containment checks
|
||||
// Example: { age: { $in: [25, 30, 35] } }
|
||||
// -> (data @> '{"age": 25}'::jsonb OR data @> '{"age": 30}'::jsonb OR data @> '{"age": 35}'::jsonb)
|
||||
if (Array.isArray(value) && value.length > 0) {
|
||||
if (value.length === 1) {
|
||||
// Single value: just use containment directly
|
||||
conditions.push(buildContainmentClause(tableName, field, value[0]))
|
||||
} else {
|
||||
// Multiple values: create OR chain of containment checks
|
||||
const inConditions = value.map((v) => buildContainmentClause(tableName, field, v))
|
||||
conditions.push(sql`(${sql.join(inConditions, sql.raw(' OR '))})`)
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
case '$nin':
|
||||
// Value not in array: converts to AND of negated containment checks
|
||||
// Example: { age: { $nin: [25, 30] } }
|
||||
// -> (NOT (data @> '{"age": 25}'::jsonb) AND NOT (data @> '{"age": 30}'::jsonb))
|
||||
if (Array.isArray(value) && value.length > 0) {
|
||||
const ninConditions = value.map(
|
||||
(v) => sql`NOT (${buildContainmentClause(tableName, field, v)})`
|
||||
@@ -98,7 +182,11 @@ function buildFieldCondition(tableName: string, field: string, condition: any):
|
||||
conditions.push(sql`(${sql.join(ninConditions, sql.raw(' AND '))})`)
|
||||
}
|
||||
break
|
||||
|
||||
case '$contains':
|
||||
// String contains: uses ILIKE for case-insensitive pattern matching
|
||||
// Example: { name: { $contains: "john" } } -> data->>'name' ILIKE '%john%'
|
||||
// Note: This cannot use the GIN index, so it's slower on large datasets
|
||||
conditions.push(
|
||||
sql`${sql.raw(`${tableName}.data->>'${escapedField}'`)} ILIKE ${`%${value}%`}`
|
||||
)
|
||||
@@ -106,7 +194,9 @@ function buildFieldCondition(tableName: string, field: string, condition: any):
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Direct equality
|
||||
// Direct equality: condition is a primitive value (string, number, boolean, null)
|
||||
// Example: { age: 25 } -> data @> '{"age": 25}'::jsonb
|
||||
// This uses the containment operator for optimal performance with GIN index
|
||||
conditions.push(buildContainmentClause(tableName, field, condition))
|
||||
}
|
||||
|
||||
@@ -114,80 +204,134 @@ function buildFieldCondition(tableName: string, field: string, condition: any):
|
||||
}
|
||||
|
||||
/**
|
||||
* Build WHERE clause from filter object
|
||||
* Supports: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $contains, $or, $and
|
||||
* Builds a WHERE clause from a filter object.
|
||||
*
|
||||
* Uses GIN-index-compatible containment operator (@>) for:
|
||||
* - $eq (equality)
|
||||
* - Direct value equality
|
||||
* - $in (as OR of containment checks)
|
||||
* This is the main entry point for converting a QueryFilter object into SQL.
|
||||
* It recursively processes the filter, handling logical operators ($or, $and) and
|
||||
* field conditions.
|
||||
*
|
||||
* Uses text extraction (->>) for operators that require it:
|
||||
* - $ne (not equals - no containment equivalent)
|
||||
* - $gt, $gte, $lt, $lte (numeric comparisons)
|
||||
* - $nin (not in)
|
||||
* - $contains (pattern matching)
|
||||
* Examples:
|
||||
* 1. Simple filter: `{ age: 25, name: "John" }`
|
||||
* -> `(data @> '{"age": 25}'::jsonb) AND (data @> '{"name": "John"}'::jsonb)`
|
||||
*
|
||||
* Logical operators:
|
||||
* - $or: Array of filter objects, joined with OR
|
||||
* - $and: Array of filter objects, joined with AND
|
||||
* 2. With operators: `{ age: { $gt: 25 }, name: { $contains: "john" } }`
|
||||
* -> `((data->>'age')::numeric > 25) AND (data->>'name' ILIKE '%john%')`
|
||||
*
|
||||
* 3. With $or: `{ $or: [{ age: 25 }, { age: 30 }] }`
|
||||
* -> `((data @> '{"age": 25}'::jsonb) OR (data @> '{"age": 30}'::jsonb))`
|
||||
*
|
||||
* Performance notes:
|
||||
* - Uses GIN-index-compatible containment operator (@>) for: $eq, direct equality, $in
|
||||
* - Uses text extraction (->>) for: $ne, $gt, $gte, $lt, $lte, $nin, $contains
|
||||
* - Text extraction cannot use GIN index, so those queries are slower
|
||||
*
|
||||
* @param filter - The filter object to convert to SQL
|
||||
* @param tableName - The table alias/name (e.g., "user_tables")
|
||||
* @returns SQL WHERE clause or undefined if filter is empty
|
||||
*/
|
||||
export function buildFilterClause(filter: QueryFilter, tableName: string): SQL | undefined {
|
||||
const conditions: SQL[] = []
|
||||
|
||||
// Iterate through all fields in the filter object
|
||||
for (const [field, condition] of Object.entries(filter)) {
|
||||
// Handle $or operator
|
||||
// Skip undefined conditions (can happen with optional fields)
|
||||
if (condition === undefined) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle $or operator: creates OR group of sub-filters
|
||||
// Example: { $or: [{ age: 25 }, { name: "John" }] }
|
||||
// -> (age condition) OR (name condition)
|
||||
if (field === '$or' && Array.isArray(condition)) {
|
||||
const orConditions: SQL[] = []
|
||||
// Recursively process each sub-filter in the OR array
|
||||
for (const subFilter of condition) {
|
||||
const subClause = buildFilterClause(subFilter as QueryFilter, tableName)
|
||||
if (subClause) {
|
||||
orConditions.push(subClause)
|
||||
}
|
||||
}
|
||||
// Only add OR group if we have at least one condition
|
||||
if (orConditions.length > 0) {
|
||||
if (orConditions.length === 1) {
|
||||
// Single condition: no need for parentheses
|
||||
conditions.push(orConditions[0])
|
||||
} else {
|
||||
// Multiple conditions: wrap in parentheses and join with OR
|
||||
conditions.push(sql`(${sql.join(orConditions, sql.raw(' OR '))})`)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle $and operator
|
||||
// Handle $and operator: creates AND group of sub-filters
|
||||
// Example: { $and: [{ age: { $gt: 25 } }, { name: { $contains: "john" } }] }
|
||||
// -> (age condition) AND (name condition)
|
||||
if (field === '$and' && Array.isArray(condition)) {
|
||||
const andConditions: SQL[] = []
|
||||
// Recursively process each sub-filter in the AND array
|
||||
for (const subFilter of condition) {
|
||||
const subClause = buildFilterClause(subFilter as QueryFilter, tableName)
|
||||
if (subClause) {
|
||||
andConditions.push(subClause)
|
||||
}
|
||||
}
|
||||
// Only add AND group if we have at least one condition
|
||||
if (andConditions.length > 0) {
|
||||
if (andConditions.length === 1) {
|
||||
// Single condition: no need for parentheses
|
||||
conditions.push(andConditions[0])
|
||||
} else {
|
||||
// Multiple conditions: wrap in parentheses and join with AND
|
||||
conditions.push(sql`(${sql.join(andConditions, sql.raw(' AND '))})`)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle regular field conditions
|
||||
const fieldConditions = buildFieldCondition(tableName, field, condition)
|
||||
// Handle regular field conditions (not $or or $and)
|
||||
// This processes fields like "age", "name", etc. with their conditions
|
||||
// Skip if condition is QueryFilter[] (shouldn't happen for regular fields)
|
||||
if (Array.isArray(condition)) {
|
||||
continue
|
||||
}
|
||||
const fieldConditions = buildFieldCondition(
|
||||
tableName,
|
||||
field,
|
||||
condition as JsonValue | FieldCondition
|
||||
)
|
||||
conditions.push(...fieldConditions)
|
||||
}
|
||||
|
||||
// Return undefined if no conditions were generated
|
||||
if (conditions.length === 0) return undefined
|
||||
|
||||
// If only one condition, return it directly (no need to join)
|
||||
if (conditions.length === 1) return conditions[0]
|
||||
|
||||
// Multiple conditions: join with AND (default behavior)
|
||||
// Example: { age: 25, name: "John" } -> condition1 AND condition2
|
||||
return sql.join(conditions, sql.raw(' AND '))
|
||||
}
|
||||
|
||||
/**
|
||||
* Build ORDER BY clause from sort object
|
||||
* Format: {field: 'asc'|'desc'}
|
||||
* Builds an ORDER BY clause from a sort object.
|
||||
*
|
||||
* Supports sorting by:
|
||||
* 1. Built-in columns: createdAt, updatedAt (direct column access)
|
||||
* 2. JSONB fields: any field in the data column (uses text extraction)
|
||||
*
|
||||
* Examples:
|
||||
* - `{ createdAt: 'desc' }` -> `table.createdAt DESC`
|
||||
* - `{ age: 'asc', name: 'desc' }` -> `table.data->>'age' ASC, table.data->>'name' DESC`
|
||||
*
|
||||
* Note: Sorting by JSONB fields uses text extraction (->>), which means:
|
||||
* - Numbers are sorted as strings (e.g., "10" < "2")
|
||||
* - No index can be used, so sorting is slower on large datasets
|
||||
*
|
||||
* @param sort - Sort object with field names as keys and 'asc'|'desc' as values
|
||||
* @param tableName - The table alias/name (e.g., "user_tables")
|
||||
* @returns SQL ORDER BY clause or undefined if no sort specified
|
||||
*/
|
||||
export function buildSortClause(
|
||||
sort: Record<string, 'asc' | 'desc'>,
|
||||
@@ -195,19 +339,26 @@ export function buildSortClause(
|
||||
): SQL | undefined {
|
||||
const clauses: SQL[] = []
|
||||
|
||||
// Process each field in the sort object
|
||||
for (const [field, direction] of Object.entries(sort)) {
|
||||
// Escape field name to prevent SQL injection
|
||||
// Escape single quotes in field name to prevent SQL injection
|
||||
// Example: "O'Brien" -> "O''Brien"
|
||||
const escapedField = field.replace(/'/g, "''")
|
||||
|
||||
// Check if this is a built-in column (createdAt, updatedAt)
|
||||
// These are actual columns in the table, not JSONB fields
|
||||
if (field === 'createdAt' || field === 'updatedAt') {
|
||||
// Built-in columns
|
||||
// Built-in columns: direct column access
|
||||
// Example: { createdAt: 'desc' } -> table.createdAt DESC
|
||||
clauses.push(
|
||||
direction === 'asc'
|
||||
? sql.raw(`${tableName}.${escapedField} ASC`)
|
||||
: sql.raw(`${tableName}.${escapedField} DESC`)
|
||||
)
|
||||
} else {
|
||||
// JSONB fields
|
||||
// JSONB fields: use text extraction operator (->>)
|
||||
// Example: { age: 'asc' } -> table.data->>'age' ASC
|
||||
// Note: This extracts the value as text, so numeric sorting may not work as expected
|
||||
clauses.push(
|
||||
direction === 'asc'
|
||||
? sql.raw(`${tableName}.data->>'${escapedField}' ASC`)
|
||||
@@ -216,5 +367,7 @@ export function buildSortClause(
|
||||
}
|
||||
}
|
||||
|
||||
// Join multiple sort fields with commas
|
||||
// Example: { age: 'asc', name: 'desc' } -> "age ASC, name DESC"
|
||||
return clauses.length > 0 ? sql.join(clauses, sql.raw(', ')) : undefined
|
||||
}
|
||||
|
||||
@@ -1,24 +1,54 @@
|
||||
/**
|
||||
* Validation utilities for table schemas and row data.
|
||||
*
|
||||
* @module lib/table/validation
|
||||
*/
|
||||
|
||||
import type { ColumnType } from './constants'
|
||||
import { COLUMN_TYPES, NAME_PATTERN, TABLE_LIMITS } from './constants'
|
||||
|
||||
/**
|
||||
* Definition of a table column.
|
||||
*/
|
||||
export interface ColumnDefinition {
|
||||
/** Column name */
|
||||
name: string
|
||||
/** Column data type */
|
||||
type: ColumnType
|
||||
/** Whether the column is required */
|
||||
required?: boolean
|
||||
/** Whether the column values must be unique */
|
||||
unique?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Table schema definition.
|
||||
*/
|
||||
export interface TableSchema {
|
||||
/** Array of column definitions */
|
||||
columns: ColumnDefinition[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a validation operation.
|
||||
*/
|
||||
interface ValidationResult {
|
||||
/** Whether validation passed */
|
||||
valid: boolean
|
||||
/** Array of error messages */
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates table name against naming rules
|
||||
* Represents a row's data values.
|
||||
*/
|
||||
type RowData = Record<string, unknown>
|
||||
|
||||
/**
|
||||
* Validates a table name against naming rules.
|
||||
*
|
||||
* @param name - The table name to validate
|
||||
* @returns Validation result with errors if invalid
|
||||
*/
|
||||
export function validateTableName(name: string): ValidationResult {
|
||||
const errors: string[] = []
|
||||
@@ -47,7 +77,10 @@ export function validateTableName(name: string): ValidationResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates column definition
|
||||
* Validates a column definition.
|
||||
*
|
||||
* @param column - The column definition to validate
|
||||
* @returns Validation result with errors if invalid
|
||||
*/
|
||||
export function validateColumnDefinition(column: ColumnDefinition): ValidationResult {
|
||||
const errors: string[] = []
|
||||
@@ -82,7 +115,10 @@ export function validateColumnDefinition(column: ColumnDefinition): ValidationRe
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates table schema
|
||||
* Validates a table schema.
|
||||
*
|
||||
* @param schema - The schema to validate
|
||||
* @returns Validation result with errors if invalid
|
||||
*/
|
||||
export function validateTableSchema(schema: TableSchema): ValidationResult {
|
||||
const errors: string[] = []
|
||||
@@ -125,9 +161,12 @@ export function validateTableSchema(schema: TableSchema): ValidationResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates row size
|
||||
* Validates that row data size is within limits.
|
||||
*
|
||||
* @param data - The row data to validate
|
||||
* @returns Validation result with errors if size exceeds limit
|
||||
*/
|
||||
export function validateRowSize(data: Record<string, any>): ValidationResult {
|
||||
export function validateRowSize(data: RowData): ValidationResult {
|
||||
const size = JSON.stringify(data).length
|
||||
if (size > TABLE_LIMITS.MAX_ROW_SIZE_BYTES) {
|
||||
return {
|
||||
@@ -139,12 +178,13 @@ export function validateRowSize(data: Record<string, any>): ValidationResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates row data against schema
|
||||
* Validates row data against a table schema.
|
||||
*
|
||||
* @param data - The row data to validate
|
||||
* @param schema - The schema to validate against
|
||||
* @returns Validation result with errors if validation fails
|
||||
*/
|
||||
export function validateRowAgainstSchema(
|
||||
data: Record<string, any>,
|
||||
schema: TableSchema
|
||||
): ValidationResult {
|
||||
export function validateRowAgainstSchema(data: RowData, schema: TableSchema): ValidationResult {
|
||||
const errors: string[] = []
|
||||
|
||||
for (const column of schema.columns) {
|
||||
@@ -179,7 +219,10 @@ export function validateRowAgainstSchema(
|
||||
}
|
||||
break
|
||||
case 'date':
|
||||
if (!(value instanceof Date) && Number.isNaN(Date.parse(value))) {
|
||||
if (
|
||||
!(value instanceof Date) &&
|
||||
(typeof value !== 'string' || Number.isNaN(Date.parse(value)))
|
||||
) {
|
||||
errors.push(`${column.name} must be valid date`)
|
||||
}
|
||||
break
|
||||
@@ -200,20 +243,41 @@ export function validateRowAgainstSchema(
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets unique column definitions from schema
|
||||
* Gets all columns marked as unique from a schema.
|
||||
*
|
||||
* @param schema - The schema to extract unique columns from
|
||||
* @returns Array of unique column definitions
|
||||
*/
|
||||
export function getUniqueColumns(schema: TableSchema): ColumnDefinition[] {
|
||||
return schema.columns.filter((col) => col.unique === true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates unique constraints for row data
|
||||
* Checks if values for unique columns would violate uniqueness
|
||||
* Represents an existing row for uniqueness checking.
|
||||
*/
|
||||
interface ExistingRow {
|
||||
/** Row ID */
|
||||
id: string
|
||||
/** Row data values */
|
||||
data: RowData
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates unique constraints for row data.
|
||||
*
|
||||
* Checks if values for unique columns would violate uniqueness constraints
|
||||
* when compared against existing rows.
|
||||
*
|
||||
* @param data - The row data to validate
|
||||
* @param schema - The schema containing unique column definitions
|
||||
* @param existingRows - Array of existing rows to check against
|
||||
* @param excludeRowId - Optional row ID to exclude from uniqueness check (for updates)
|
||||
* @returns Validation result with errors if uniqueness constraints are violated
|
||||
*/
|
||||
export function validateUniqueConstraints(
|
||||
data: Record<string, any>,
|
||||
data: RowData,
|
||||
schema: TableSchema,
|
||||
existingRows: Array<{ id: string; data: Record<string, any> }>,
|
||||
existingRows: ExistingRow[],
|
||||
excludeRowId?: string
|
||||
): ValidationResult {
|
||||
const errors: string[] = []
|
||||
|
||||
@@ -2120,7 +2120,11 @@ export const userTableDefinitions = pgTable(
|
||||
.references(() => workspace.id, { onDelete: 'cascade' }),
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
schema: jsonb('schema').notNull(), // {columns: [{name, type, required}]}
|
||||
/**
|
||||
* @remarks
|
||||
* Stores the table schema definition. Example: { columns: [{ name: string, type: string, required: boolean }] }
|
||||
*/
|
||||
schema: jsonb('schema').notNull(),
|
||||
maxRows: integer('max_rows').notNull().default(10000),
|
||||
rowCount: integer('row_count').notNull().default(0),
|
||||
createdBy: text('created_by')
|
||||
|
||||
Reference in New Issue
Block a user