@tanstack/electric-db-collection
Version:
ElectricSQL collection for TanStack DB
1 lines • 17.7 kB
Source Map (JSON)
{"version":3,"file":"sql-compiler.cjs","sources":["../../src/sql-compiler.ts"],"sourcesContent":["import { serialize } from './pg-serializer'\nimport type { SubsetParams } from '@electric-sql/client'\nimport type { IR, LoadSubsetOptions } from '@tanstack/db'\n\nexport type CompiledSqlRecord = Omit<SubsetParams, `params`> & {\n params?: Array<unknown>\n}\n\nexport function compileSQL<T>(options: LoadSubsetOptions): SubsetParams {\n const { where, orderBy, limit } = options\n\n const params: Array<T> = []\n const compiledSQL: CompiledSqlRecord = { params }\n\n if (where) {\n // TODO: this only works when the where expression's PropRefs directly reference a column of the collection\n // doesn't work if it goes through aliases because then we need to know the entire query to be able to follow the reference until the base collection (cf. followRef function)\n compiledSQL.where = compileBasicExpression(where, params)\n }\n\n if (orderBy) {\n compiledSQL.orderBy = compileOrderBy(orderBy, params)\n }\n\n if (limit) {\n compiledSQL.limit = limit\n }\n\n // WORKAROUND for Electric bug: Empty subset requests don't load data\n // Add dummy \"true = true\" predicate when there's no where clause\n // This is always true so doesn't filter data, just tricks Electric into loading\n if (!where) {\n compiledSQL.where = `true = true`\n }\n\n // Serialize the values in the params array into PG formatted strings\n // and transform the array into a Record<string, string>\n const paramsRecord = params.reduce(\n (acc, param, index) => {\n const serialized = serialize(param)\n // Only include non-empty values in params\n // Empty strings from null/undefined should be omitted\n if (serialized !== ``) {\n acc[`${index + 1}`] = serialized\n }\n return acc\n },\n {} as Record<string, string>,\n )\n\n return {\n ...compiledSQL,\n params: paramsRecord,\n }\n}\n\n/**\n * Quote PostgreSQL identifiers to handle mixed case column names correctly.\n * Electric/Postgres requires quotes for case-sensitive identifiers.\n * @param name - The identifier to quote\n * @returns The quoted identifier\n */\nfunction quoteIdentifier(name: string): string {\n return `\"${name}\"`\n}\n\n/**\n * Compiles the expression to a SQL string and mutates the params array with the values.\n * @param exp - The expression to compile\n * @param params - The params array\n * @returns The compiled SQL string\n */\nfunction compileBasicExpression(\n exp: IR.BasicExpression<unknown>,\n params: Array<unknown>,\n): string {\n switch (exp.type) {\n case `val`:\n params.push(exp.value)\n return `$${params.length}`\n case `ref`:\n // TODO: doesn't yet support JSON(B) values which could be accessed with nested props\n if (exp.path.length !== 1) {\n throw new Error(\n `Compiler can't handle nested properties: ${exp.path.join(`.`)}`,\n )\n }\n return quoteIdentifier(exp.path[0]!)\n case `func`:\n return compileFunction(exp, params)\n default:\n throw new Error(`Unknown expression type`)\n }\n}\n\nfunction compileOrderBy(orderBy: IR.OrderBy, params: Array<unknown>): string {\n const compiledOrderByClauses = orderBy.map((clause: IR.OrderByClause) =>\n compileOrderByClause(clause, params),\n )\n return compiledOrderByClauses.join(`,`)\n}\n\nfunction compileOrderByClause(\n clause: IR.OrderByClause,\n params: Array<unknown>,\n): string {\n // FIXME: We should handle stringSort and locale.\n // Correctly supporting them is tricky as it depends on Postgres' collation\n const { expression, compareOptions } = clause\n let sql = compileBasicExpression(expression, params)\n\n if (compareOptions.direction === `desc`) {\n sql = `${sql} DESC`\n }\n\n if (compareOptions.nulls === `first`) {\n sql = `${sql} NULLS FIRST`\n }\n\n if (compareOptions.nulls === `last`) {\n sql = `${sql} NULLS LAST`\n }\n\n return sql\n}\n\n/**\n * Check if a BasicExpression represents a null/undefined value\n */\nfunction isNullValue(exp: IR.BasicExpression<unknown>): boolean {\n return exp.type === `val` && (exp.value === null || exp.value === undefined)\n}\n\nfunction compileFunction(\n exp: IR.Func<unknown>,\n params: Array<unknown> = [],\n): string {\n const { name, args } = exp\n\n const opName = getOpName(name)\n\n // Handle comparison operators with null/undefined values\n // These would create invalid queries with missing params (e.g., \"col = $1\" with empty params)\n // In SQL, all comparisons with NULL return UNKNOWN, so these are almost always mistakes\n if (isComparisonOp(name)) {\n const nullArgIndex = args.findIndex((arg: IR.BasicExpression) =>\n isNullValue(arg),\n )\n\n if (nullArgIndex !== -1) {\n // All comparison operators (including eq) throw an error for null values\n // Users should use isNull() or isUndefined() to check for null values\n throw new Error(\n `Cannot use null/undefined value with '${name}' operator. ` +\n `Comparisons with null always evaluate to UNKNOWN in SQL. ` +\n `Use isNull() or isUndefined() to check for null values, ` +\n `or filter out null values before building the query.`,\n )\n }\n }\n\n const compiledArgs = args.map((arg: IR.BasicExpression) =>\n compileBasicExpression(arg, params),\n )\n\n // Special case for IS NULL / IS NOT NULL - these are postfix operators\n if (name === `isNull` || name === `isUndefined`) {\n if (compiledArgs.length !== 1) {\n throw new Error(`${name} expects 1 argument`)\n }\n return `${compiledArgs[0]} ${opName}`\n }\n\n // Special case for NOT - unary prefix operator\n if (name === `not`) {\n if (compiledArgs.length !== 1) {\n throw new Error(`NOT expects 1 argument`)\n }\n // Check if the argument is IS NULL to generate IS NOT NULL\n const arg = args[0]\n if (arg && arg.type === `func`) {\n const funcArg = arg\n if (funcArg.name === `isNull` || funcArg.name === `isUndefined`) {\n const innerArg = compileBasicExpression(funcArg.args[0]!, params)\n return `${innerArg} IS NOT NULL`\n }\n }\n return `${opName} (${compiledArgs[0]})`\n }\n\n if (isBinaryOp(name)) {\n // Special handling for AND/OR which can be variadic\n if ((name === `and` || name === `or`) && compiledArgs.length > 2) {\n // Chain multiple arguments: (a AND b AND c) or (a OR b OR c)\n return compiledArgs.map((arg) => `(${arg})`).join(` ${opName} `)\n }\n\n if (compiledArgs.length !== 2) {\n throw new Error(`Binary operator ${name} expects 2 arguments`)\n }\n const [lhs, rhs] = compiledArgs\n\n // Special case for comparison operators with boolean values\n // PostgreSQL doesn't support < > <= >= on booleans\n // Transform to equivalent equality checks or constant expressions\n if (isBooleanComparisonOp(name)) {\n const lhsArg = args[0]\n const rhsArg = args[1]\n\n // Check if RHS is a boolean literal value\n if (\n rhsArg &&\n rhsArg.type === `val` &&\n typeof rhsArg.value === `boolean`\n ) {\n const boolValue = rhsArg.value\n // Remove the boolean param we just added since we'll transform the expression\n params.pop()\n\n // Transform based on operator and boolean value\n // Boolean ordering: false < true\n if (name === `lt`) {\n if (boolValue === true) {\n // lt(col, true) → col = false (only false is less than true)\n params.push(false)\n return `${lhs} = $${params.length}`\n } else {\n // lt(col, false) → nothing is less than false\n return `false`\n }\n } else if (name === `gt`) {\n if (boolValue === false) {\n // gt(col, false) → col = true (only true is greater than false)\n params.push(true)\n return `${lhs} = $${params.length}`\n } else {\n // gt(col, true) → nothing is greater than true\n return `false`\n }\n } else if (name === `lte`) {\n if (boolValue === true) {\n // lte(col, true) → everything is ≤ true\n return `true`\n } else {\n // lte(col, false) → col = false\n params.push(false)\n return `${lhs} = $${params.length}`\n }\n } else if (name === `gte`) {\n if (boolValue === false) {\n // gte(col, false) → everything is ≥ false\n return `true`\n } else {\n // gte(col, true) → col = true\n params.push(true)\n return `${lhs} = $${params.length}`\n }\n }\n }\n\n // Check if LHS is a boolean literal value (less common but handle it)\n if (\n lhsArg &&\n lhsArg.type === `val` &&\n typeof lhsArg.value === `boolean`\n ) {\n const boolValue = lhsArg.value\n // Remove params for this expression and rebuild\n params.pop() // remove RHS\n params.pop() // remove LHS (boolean)\n\n // Recompile RHS to get fresh param\n const rhsCompiled = compileBasicExpression(rhsArg!, params)\n\n // Transform: flip the comparison (val op col → col flipped_op val)\n if (name === `lt`) {\n // lt(true, col) → gt(col, true) → col > true → nothing is greater than true\n if (boolValue === true) {\n return `false`\n } else {\n // lt(false, col) → gt(col, false) → col = true\n params.push(true)\n return `${rhsCompiled} = $${params.length}`\n }\n } else if (name === `gt`) {\n // gt(true, col) → lt(col, true) → col = false\n if (boolValue === true) {\n params.push(false)\n return `${rhsCompiled} = $${params.length}`\n } else {\n // gt(false, col) → lt(col, false) → nothing is less than false\n return `false`\n }\n } else if (name === `lte`) {\n if (boolValue === false) {\n // lte(false, col) → gte(col, false) → everything\n return `true`\n } else {\n // lte(true, col) → gte(col, true) → col = true\n params.push(true)\n return `${rhsCompiled} = $${params.length}`\n }\n } else if (name === `gte`) {\n if (boolValue === true) {\n // gte(true, col) → lte(col, true) → everything\n return `true`\n } else {\n // gte(false, col) → lte(col, false) → col = false\n params.push(false)\n return `${rhsCompiled} = $${params.length}`\n }\n }\n }\n }\n\n // Special case for = ANY operator which needs parentheses around the array parameter\n if (name === `in`) {\n return `${lhs} ${opName}(${rhs})`\n }\n return `${lhs} ${opName} ${rhs}`\n }\n\n return `${opName}(${compiledArgs.join(`,`)})`\n}\n\nfunction isBinaryOp(name: string): boolean {\n const binaryOps = [\n `eq`,\n `gt`,\n `gte`,\n `lt`,\n `lte`,\n `and`,\n `or`,\n `in`,\n `like`,\n `ilike`,\n ]\n return binaryOps.includes(name)\n}\n\n/**\n * Check if operator is a comparison operator that takes two values\n * These operators cannot accept null/undefined as values\n * (null comparisons in SQL always evaluate to UNKNOWN)\n */\nfunction isComparisonOp(name: string): boolean {\n const comparisonOps = [`eq`, `gt`, `gte`, `lt`, `lte`, `like`, `ilike`]\n return comparisonOps.includes(name)\n}\n\n/**\n * Checks if the operator is a comparison operator (excluding eq)\n * These operators don't work on booleans in PostgreSQL without casting\n */\nfunction isBooleanComparisonOp(name: string): boolean {\n return [`gt`, `gte`, `lt`, `lte`].includes(name)\n}\n\nfunction getOpName(name: string): string {\n const opNames = {\n eq: `=`,\n gt: `>`,\n gte: `>=`,\n lt: `<`,\n lte: `<=`,\n add: `+`,\n and: `AND`,\n or: `OR`,\n not: `NOT`,\n isUndefined: `IS NULL`,\n isNull: `IS NULL`,\n in: `= ANY`, // Use = ANY syntax for array parameters\n like: `LIKE`,\n ilike: `ILIKE`,\n upper: `UPPER`,\n lower: `LOWER`,\n length: `LENGTH`,\n concat: `CONCAT`,\n coalesce: `COALESCE`,\n }\n\n const opName = opNames[name as keyof typeof opNames]\n\n if (!opName) {\n throw new Error(`Unknown operator/function: ${name}`)\n }\n\n return opName\n}\n"],"names":["serialize"],"mappings":"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"}