diff --git a/apps/sim/lib/table/constants.ts b/apps/sim/lib/table/constants.ts index 404eb0c10..cf6e5e4b5 100644 --- a/apps/sim/lib/table/constants.ts +++ b/apps/sim/lib/table/constants.ts @@ -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 diff --git a/apps/sim/lib/table/filter-builder-utils.ts b/apps/sim/lib/table/filter-builder-utils.ts index 5282b51c9..87d364511 100644 --- a/apps/sim/lib/table/filter-builder-utils.ts +++ b/apps/sim/lib/table/filter-builder-utils.ts @@ -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 | null { +export function conditionsToFilter( + conditions: FilterCondition[] +): Record | null { if (conditions.length === 0) return null - const orGroups: Record[] = [] - let currentAndGroup: Record = {} + const orGroups: Record[] = [] + let currentAndGroup: Record = {} conditions.forEach((condition, index) => { const { column, operator, value } = condition @@ -103,9 +127,12 @@ export function conditionsToFilter(conditions: FilterCondition[]): Record | null): FilterCondition[] { +export function filterToConditions(filter: Record | null): FilterCondition[] { if (!filter) return [] const conditions: FilterCondition[] = [] @@ -113,7 +140,10 @@ export function filterToConditions(filter: Record | 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) groupConditions.forEach((cond, condIndex) => { conditions.push({ ...cond, @@ -134,9 +164,12 @@ export function filterToConditions(filter: Record | 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): FilterCondition[] { +function parseFilterGroup(group: Record): FilterCondition[] { const conditions: FilterCondition[] = [] for (const [column, value] of Object.entries(group)) { @@ -171,9 +204,12 @@ function parseFilterGroup(group: Record): 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 | null { if (conditions.length === 0) return null @@ -244,7 +294,10 @@ export function sortConditionsToSort(conditions: SortCondition[]): Record | null): SortCondition[] { if (!sort) return [] @@ -257,7 +310,10 @@ export function sortToConditions(sort: Record | 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 [] diff --git a/apps/sim/lib/table/index.ts b/apps/sim/lib/table/index.ts index cc4d1bc5a..d85c8504f 100644 --- a/apps/sim/lib/table/index.ts +++ b/apps/sim/lib/table/index.ts @@ -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' diff --git a/apps/sim/lib/table/query-builder.ts b/apps/sim/lib/table/query-builder.ts index 27c199155..17dd4f491 100644 --- a/apps/sim/lib/table/query-builder.ts +++ b/apps/sim/lib/table/query-builder.ts @@ -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, @@ -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 } diff --git a/apps/sim/lib/table/validation.ts b/apps/sim/lib/table/validation.ts index bef7f41f4..6b49fd491 100644 --- a/apps/sim/lib/table/validation.ts +++ b/apps/sim/lib/table/validation.ts @@ -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 + +/** + * 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): 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): 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, - 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, + data: RowData, schema: TableSchema, - existingRows: Array<{ id: string; data: Record }>, + existingRows: ExistingRow[], excludeRowId?: string ): ValidationResult { const errors: string[] = [] diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 6244b4c79..54aa0a7da 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -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')