UNPKG

@electric-sql/client

Version:

Postgres everywhere - your data, in sync, wherever you need it.

383 lines (356 loc) 11.8 kB
import { Schema } from './types' type DbColumnName = string type AppColumnName = string /** * Quote a PostgreSQL identifier for safe use in query parameters. * * Wraps the identifier in double quotes and escapes any internal * double quotes by doubling them. This ensures identifiers with * special characters (commas, spaces, etc.) are handled correctly. * * @param identifier - The identifier to quote * @returns The quoted identifier * * @example * ```typescript * quoteIdentifier('user_id') // '"user_id"' * quoteIdentifier('foo,bar') // '"foo,bar"' * quoteIdentifier('has"quote') // '"has""quote"' * ``` * * @internal */ export function quoteIdentifier(identifier: string): string { // Escape internal double quotes by doubling them const escaped = identifier.replace(/"/g, `""`) return `"${escaped}"` } /** * A bidirectional column mapper that handles transforming column **names** * between database format (e.g., snake_case) and application format (e.g., camelCase). * * **Important**: ColumnMapper only transforms column names, not column values or types. * For type conversions (e.g., string → Date), use the `parser` option. * For value transformations (e.g., encryption), use the `transformer` option. * * @example * ```typescript * const mapper = snakeCamelMapper() * mapper.decode('user_id') // 'userId' * mapper.encode('userId') // 'user_id' * ``` */ export interface ColumnMapper { /** * Transform a column name from database format to application format. * Applied to column names in query results. */ decode: (dbColumnName: DbColumnName) => AppColumnName /** * Transform a column name from application format to database format. * Applied to column names in WHERE clauses and other query parameters. */ encode: (appColumnName: AppColumnName) => DbColumnName } /** * Converts a snake_case string to camelCase. * * Handles edge cases: * - Preserves leading underscores: `_user_id` → `_userId` * - Preserves trailing underscores: `user_id_` → `userId_` * - Collapses multiple underscores: `user__id` → `userId` * - Normalizes to lowercase first: `user_Column` → `userColumn` * * @example * snakeToCamel('user_id') // 'userId' * snakeToCamel('project_id') // 'projectId' * snakeToCamel('created_at') // 'createdAt' * snakeToCamel('_private') // '_private' * snakeToCamel('user__id') // 'userId' * snakeToCamel('user_id_') // 'userId_' */ export function snakeToCamel(str: string): string { // Preserve leading underscores const leadingUnderscores = str.match(/^_+/)?.[0] ?? `` const withoutLeading = str.slice(leadingUnderscores.length) // Preserve trailing underscores for round-trip safety const trailingUnderscores = withoutLeading.match(/_+$/)?.[0] ?? `` const core = trailingUnderscores ? withoutLeading.slice( 0, withoutLeading.length - trailingUnderscores.length ) : withoutLeading // Convert to lowercase const normalized = core.toLowerCase() // Convert snake_case to camelCase (handling multiple underscores) const camelCased = normalized.replace(/_+([a-z])/g, (_, letter) => letter.toUpperCase() ) return leadingUnderscores + camelCased + trailingUnderscores } /** * Converts a camelCase string to snake_case. * * Handles consecutive capitals (acronyms) properly: * - `userID` → `user_id` * - `userHTTPSURL` → `user_https_url` * * @example * camelToSnake('userId') // 'user_id' * camelToSnake('projectId') // 'project_id' * camelToSnake('createdAt') // 'created_at' * camelToSnake('userID') // 'user_id' * camelToSnake('parseHTMLString') // 'parse_html_string' */ export function camelToSnake(str: string): string { return ( str // Insert underscore before uppercase letters that follow lowercase letters // e.g., userId -> user_Id .replace(/([a-z])([A-Z])/g, `$1_$2`) // Insert underscore before uppercase letters that are followed by lowercase letters // This handles acronyms: userID -> user_ID, but parseHTMLString -> parse_HTML_String .replace(/([A-Z]+)([A-Z][a-z])/g, `$1_$2`) .toLowerCase() ) } /** * Creates a column mapper from an explicit mapping of database columns to application columns. * * @param mapping - Object mapping database column names (keys) to application column names (values) * @returns A ColumnMapper that can encode and decode column names bidirectionally * * @example * const mapper = createColumnMapper({ * user_id: 'userId', * project_id: 'projectId', * created_at: 'createdAt' * }) * * // Use with ShapeStream * const stream = new ShapeStream({ * url: 'http://localhost:3000/v1/shape', * params: { table: 'todos' }, * columnMapper: mapper * }) */ export function createColumnMapper( mapping: Record<string, string> ): ColumnMapper { // Build reverse mapping: app name -> db name const reverseMapping: Record<string, string> = {} for (const [dbName, appName] of Object.entries(mapping)) { reverseMapping[appName] = dbName } return { decode: (dbColumnName: string) => { return mapping[dbColumnName] ?? dbColumnName }, encode: (appColumnName: string) => { return reverseMapping[appColumnName] ?? appColumnName }, } } /** * Encodes column names in a WHERE clause using the provided encoder function. * Uses regex to identify column references and replace them. * * Handles common SQL patterns: * - Simple comparisons: columnName = $1 * - Function calls: LOWER(columnName) * - Qualified names: table.columnName * - Operators: columnName IS NULL, columnName IN (...) * - Quoted strings: Preserves string literals unchanged * * Note: This uses regex-based replacement which works for most common cases * but may not handle all complex SQL expressions perfectly. For complex queries, * test thoroughly or use database column names directly in WHERE clauses. * * @param whereClause - The WHERE clause string to encode * @param encode - Optional encoder function. If undefined, returns whereClause unchanged. * @returns The encoded WHERE clause * * @internal */ export function encodeWhereClause( whereClause: string | undefined, encode?: (columnName: string) => string ): string { if (!whereClause || !encode) return whereClause ?? `` // SQL keywords that should not be transformed (common ones) const sqlKeywords = new Set([ `SELECT`, `FROM`, `WHERE`, `AND`, `OR`, `NOT`, `IN`, `IS`, `NULL`, `NULLS`, `FIRST`, `LAST`, `TRUE`, `FALSE`, `LIKE`, `ILIKE`, `BETWEEN`, `ASC`, `DESC`, `LIMIT`, `OFFSET`, `ORDER`, `BY`, `GROUP`, `HAVING`, `DISTINCT`, `AS`, `ON`, `JOIN`, `LEFT`, `RIGHT`, `INNER`, `OUTER`, `CROSS`, `CASE`, `WHEN`, `THEN`, `ELSE`, `END`, `CAST`, `LOWER`, `UPPER`, `COALESCE`, `NULLIF`, ]) // Track positions of quoted strings and double-quoted identifiers to skip them const quotedRanges: Array<{ start: number; end: number }> = [] // Find all single-quoted strings and double-quoted identifiers let pos = 0 while (pos < whereClause.length) { const ch = whereClause[pos] if (ch === `'` || ch === `"`) { const start = pos const quoteChar = ch pos++ // Skip opening quote // Find closing quote, handling escaped quotes ('' or "") while (pos < whereClause.length) { if (whereClause[pos] === quoteChar) { if (whereClause[pos + 1] === quoteChar) { pos += 2 // Skip escaped quote } else { pos++ // Skip closing quote break } } else { pos++ } } quotedRanges.push({ start, end: pos }) } else { pos++ } } // Helper to check if position is within a quoted string or double-quoted identifier const isInQuotedString = (pos: number): boolean => { return quotedRanges.some((range) => pos >= range.start && pos < range.end) } // Pattern explanation: // (?<![a-zA-Z0-9_]) - negative lookbehind: not preceded by identifier char // ([a-zA-Z_][a-zA-Z0-9_]*) - capture: valid SQL identifier // (?![a-zA-Z0-9_]) - negative lookahead: not followed by identifier char // // This avoids matching: // - Parts of longer identifiers // - SQL keywords (handled by checking if result differs from input) const identifierPattern = /(?<![a-zA-Z0-9_])([a-zA-Z_][a-zA-Z0-9_]*)(?![a-zA-Z0-9_])/g return whereClause.replace(identifierPattern, (match, _p1, offset) => { // Don't transform if inside quoted string if (isInQuotedString(offset)) { return match } // Don't transform SQL keywords if (sqlKeywords.has(match.toUpperCase())) { return match } // Don't transform parameter placeholders ($1, $2, etc.) // This regex won't match them anyway, but being explicit if (match.startsWith(`$`)) { return match } // Apply encoding const encoded = encode(match) return encoded }) } /** * Creates a column mapper that automatically converts between snake_case and camelCase. * This is the most common use case for column mapping. * * When a schema is provided, it will only map columns that exist in the schema. * Otherwise, it will map any column name it encounters. * * **⚠️ Limitations and Edge Cases:** * - **WHERE clause encoding**: Uses regex-based parsing which may not handle all complex * SQL expressions. Test thoroughly with your queries, especially those with: * - Complex nested expressions * - Custom operators or functions * - Column names that conflict with SQL keywords * - Quoted identifiers (e.g., `"$price"`, `"user-id"`) - not supported * - Column names with special characters (non-alphanumeric except underscore) * - **Acronym ambiguity**: `userID` → `user_id` → `userId` (ID becomes Id after roundtrip) * Use `createColumnMapper()` with explicit mapping if you need exact control * - **Type conversion**: This only renames columns, not values. Use `parser` for type conversion * * **When to use explicit mapping instead:** * - You have column names that don't follow snake_case/camelCase patterns * - You need exact control over mappings (e.g., `id` → `identifier`) * - Your WHERE clauses are complex and automatic encoding fails * - You have quoted identifiers or column names with special characters * * @param schema - Optional database schema to constrain mapping to known columns * @returns A ColumnMapper for snake_case ↔ camelCase conversion * * @example * // Basic usage * const mapper = snakeCamelMapper() * * // With schema - only maps columns in schema (recommended) * const mapper = snakeCamelMapper(schema) * * // Use with ShapeStream * const stream = new ShapeStream({ * url: 'http://localhost:3000/v1/shape', * params: { table: 'todos' }, * columnMapper: snakeCamelMapper() * }) * * @example * // If automatic encoding fails, fall back to manual column names in WHERE clauses: * stream.requestSnapshot({ * where: "user_id = $1", // Use database column names directly if needed * params: { "1": "123" } * }) */ export function snakeCamelMapper(schema?: Schema): ColumnMapper { // If schema provided, build explicit mapping if (schema) { const mapping: Record<string, string> = {} for (const dbColumn of Object.keys(schema)) { mapping[dbColumn] = snakeToCamel(dbColumn) } return createColumnMapper(mapping) } // Otherwise, map dynamically return { decode: (dbColumnName: string) => { return snakeToCamel(dbColumnName) }, encode: (appColumnName: string) => { return camelToSnake(appColumnName) }, } }