import { POSTGRESQL_RESERVED_WORDS } from '@supabase/pg-meta/src/pg-format/reserved' import type { ColumnDef, ColumnRef, Node, RangeVar, ResTarget } from 'libpg-query' /** * Recursively traverse a libpg-query AST to extract all identifiers. * Collects table names from RangeVar and column names from ColumnRef/ResTarget. */ export function extractIdentifiers(ast: Node | Node[]): string[] { const identifiers: string[] = [] function extractFromRangeVar(rv: RangeVar): void { if (rv.relname) identifiers.push(rv.relname) if (rv.schemaname) identifiers.push(rv.schemaname) } function traverse(node: unknown): void { if (!node || typeof node !== 'object') return const obj = node as Record // RangeVar - table references (wrapped form in SELECT) if ('RangeVar' in obj) { extractFromRangeVar(obj.RangeVar as RangeVar) } // relation - table references (unwrapped form in INSERT/UPDATE/DELETE) if ('relation' in obj && obj.relation && typeof obj.relation === 'object') { extractFromRangeVar(obj.relation as RangeVar) } // ColumnRef - column references in expressions if ('ColumnRef' in obj) { const cr = obj.ColumnRef as ColumnRef for (const field of cr.fields ?? []) { if ('String' in field) { const str = field.String as { sval?: string } if (str.sval) identifiers.push(str.sval) } } } // ResTarget - column targets in INSERT/UPDATE if ('ResTarget' in obj) { const rt = obj.ResTarget as ResTarget if (rt.name) identifiers.push(rt.name) } // ColumnDef - column definitions in CREATE TABLE if ('ColumnDef' in obj) { const cd = obj.ColumnDef as ColumnDef if (cd.colname) identifiers.push(cd.colname) } // Recurse into all values for (const value of Object.values(obj)) { if (Array.isArray(value)) { value.forEach(traverse) } else { traverse(value) } } } traverse(ast) return identifiers } export function needsQuoting(identifier: string): boolean { if (POSTGRESQL_RESERVED_WORDS.has(identifier.toUpperCase())) { return true } // Matches valid unquoted identifiers: starts with underscore or lowercase letter, // followed by digits, dollar signs, underscores, or lowercase letters // Examples: "users", "user_id", "_private", "col$name" const validUnquotedPattern = /^[_a-z][\d$_a-z]*$/ if (validUnquotedPattern.test(identifier)) { return false } return true } export function isQuotedInSql(sql: string, identifier: string): boolean { // Escapes special regex characters so they're treated literally // Examples: "table.name" -> "table\.name", "col(value)" -> "col\(value\)" const escapedForRegex = identifier.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // PostgreSQL escapes quotes inside quoted identifiers as "" const escapedIdentifier = escapedForRegex.replace(/"/g, '""') // Matches quoted identifier: "identifier" (case-insensitive) // Examples: "MyTable", "my""table" (for identifier "my"table") const quotedPattern = new RegExp(`"${escapedIdentifier}"`, 'i') return quotedPattern.test(sql) }