update comments with ai

This commit is contained in:
Lakee Sivaraya
2026-01-14 12:46:26 -08:00
parent 4d176c0717
commit e287388b03
6 changed files with 382 additions and 84 deletions

View File

@@ -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

View File

@@ -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 []

View File

@@ -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'

View File

@@ -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
}

View File

@@ -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[] = []

View File

@@ -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')