sql-identifier-quoting.ts 3.1 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697
  1. import { POSTGRESQL_RESERVED_WORDS } from '@supabase/pg-meta/src/pg-format/reserved'
  2. import type { ColumnDef, ColumnRef, Node, RangeVar, ResTarget } from 'libpg-query'
  3. /**
  4. * Recursively traverse a libpg-query AST to extract all identifiers.
  5. * Collects table names from RangeVar and column names from ColumnRef/ResTarget.
  6. */
  7. export function extractIdentifiers(ast: Node | Node[]): string[] {
  8. const identifiers: string[] = []
  9. function extractFromRangeVar(rv: RangeVar): void {
  10. if (rv.relname) identifiers.push(rv.relname)
  11. if (rv.schemaname) identifiers.push(rv.schemaname)
  12. }
  13. function traverse(node: unknown): void {
  14. if (!node || typeof node !== 'object') return
  15. const obj = node as Record<string, unknown>
  16. // RangeVar - table references (wrapped form in SELECT)
  17. if ('RangeVar' in obj) {
  18. extractFromRangeVar(obj.RangeVar as RangeVar)
  19. }
  20. // relation - table references (unwrapped form in INSERT/UPDATE/DELETE)
  21. if ('relation' in obj && obj.relation && typeof obj.relation === 'object') {
  22. extractFromRangeVar(obj.relation as RangeVar)
  23. }
  24. // ColumnRef - column references in expressions
  25. if ('ColumnRef' in obj) {
  26. const cr = obj.ColumnRef as ColumnRef
  27. for (const field of cr.fields ?? []) {
  28. if ('String' in field) {
  29. const str = field.String as { sval?: string }
  30. if (str.sval) identifiers.push(str.sval)
  31. }
  32. }
  33. }
  34. // ResTarget - column targets in INSERT/UPDATE
  35. if ('ResTarget' in obj) {
  36. const rt = obj.ResTarget as ResTarget
  37. if (rt.name) identifiers.push(rt.name)
  38. }
  39. // ColumnDef - column definitions in CREATE TABLE
  40. if ('ColumnDef' in obj) {
  41. const cd = obj.ColumnDef as ColumnDef
  42. if (cd.colname) identifiers.push(cd.colname)
  43. }
  44. // Recurse into all values
  45. for (const value of Object.values(obj)) {
  46. if (Array.isArray(value)) {
  47. value.forEach(traverse)
  48. } else {
  49. traverse(value)
  50. }
  51. }
  52. }
  53. traverse(ast)
  54. return identifiers
  55. }
  56. export function needsQuoting(identifier: string): boolean {
  57. if (POSTGRESQL_RESERVED_WORDS.has(identifier.toUpperCase())) {
  58. return true
  59. }
  60. // Matches valid unquoted identifiers: starts with underscore or lowercase letter,
  61. // followed by digits, dollar signs, underscores, or lowercase letters
  62. // Examples: "users", "user_id", "_private", "col$name"
  63. const validUnquotedPattern = /^[_a-z][\d$_a-z]*$/
  64. if (validUnquotedPattern.test(identifier)) {
  65. return false
  66. }
  67. return true
  68. }
  69. export function isQuotedInSql(sql: string, identifier: string): boolean {
  70. // Escapes special regex characters so they're treated literally
  71. // Examples: "table.name" -> "table\.name", "col(value)" -> "col\(value\)"
  72. const escapedForRegex = identifier.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
  73. // PostgreSQL escapes quotes inside quoted identifiers as ""
  74. const escapedIdentifier = escapedForRegex.replace(/"/g, '""')
  75. // Matches quoted identifier: "identifier" (case-insensitive)
  76. // Examples: "MyTable", "my""table" (for identifier "my"table")
  77. const quotedPattern = new RegExp(`"${escapedIdentifier}"`, 'i')
  78. return quotedPattern.test(sql)
  79. }