sql-event-parser.ts 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148
  1. /**
  2. * Lightweight SQL parser for telemetry event detection.
  3. *
  4. * [Sean] Replace this with a proper SQL parser like `@supabase/pg-parser` once a
  5. * browser-compatible version is available.
  6. */
  7. import { TABLE_EVENT_ACTIONS, TableEventAction } from 'common/telemetry-constants'
  8. export interface TableEventDetails {
  9. type: TableEventAction
  10. schema?: string
  11. tableName?: string
  12. }
  13. type Detector = {
  14. type: TableEventAction
  15. patterns: RegExp[]
  16. }
  17. export class SQLEventParser {
  18. private static DETECTORS: Detector[] = [
  19. {
  20. type: TABLE_EVENT_ACTIONS.TableCreated,
  21. patterns: [
  22. /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?<schema>(?:"[^"]+"|[\w]+)\.)?(?<table>(?:"(?:[^"]|"")+"|`(?:[^`]|``)+`|[\w]+))/i,
  23. /CREATE\s+TEMP(?:ORARY)?\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?<schema>(?:"[^"]+"|[\w]+)\.)?(?<table>(?:"(?:[^"]|"")+"|`(?:[^`]|``)+`|[\w]+))/i,
  24. /CREATE\s+UNLOGGED\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?<schema>(?:"[^"]+"|[\w]+)\.)?(?<table>(?:"(?:[^"]|"")+"|`(?:[^`]|``)+`|[\w]+))/i,
  25. /SELECT\s+.*?\s+INTO\s+(?<schema>(?:"[^"]+"|[\w]+)\.)?(?<table>(?:"(?:[^"]|"")+"|`(?:[^`]|``)+`|[\w]+))/is,
  26. /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?<schema>(?:"[^"]+"|[\w]+)\.)?(?<table>(?:"(?:[^"]|"")+"|`(?:[^`]|``)+`|[\w]+))\s+AS\s+SELECT/i,
  27. ],
  28. },
  29. {
  30. type: TABLE_EVENT_ACTIONS.TableDataAdded,
  31. patterns: [
  32. /INSERT\s+INTO\s+(?<schema>(?:"[^"]+"|[\w]+)\.)?(?<table>(?:"(?:[^"]|"")+"|`(?:[^`]|``)+`|[\w]+))/i,
  33. /COPY\s+(?<schema>(?:"[^"]+"|[\w]+)\.)?(?<table>(?:"(?:[^"]|"")+"|`(?:[^`]|``)+`|[\w]+))\s+FROM/i,
  34. ],
  35. },
  36. {
  37. type: TABLE_EVENT_ACTIONS.TableRLSEnabled,
  38. patterns: [
  39. /ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?(?:ONLY\s+)?(?<schema>(?:"[^"]+"|[\w]+)\.)?(?<table>(?:"(?:[^"]|"")+"|`(?:[^`]|``)+`|[\w]+)).*?ENABLE\s+ROW\s+LEVEL\s+SECURITY/i,
  40. /ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?(?:ONLY\s+)?(?<schema>(?:"[^"]+"|[\w]+)\.)?(?<table>(?:"(?:[^"]|"")+"|`(?:[^`]|``)+`|[\w]+)).*?ENABLE\s+RLS/i,
  41. ],
  42. },
  43. ]
  44. private cleanIdentifier(identifier?: string) {
  45. return identifier?.replace(/["`']/g, '').replace(/\.$/, '')
  46. }
  47. // Blank out the body of $tag$...$tag$ blocks (PL/pgSQL function bodies, DO
  48. // blocks, dollar-quoted string literals) so their contents aren't scanned for
  49. // DDL. A `select ... into var` inside a function body is variable assignment,
  50. // not table creation, and would otherwise trip the SELECT..INTO detector.
  51. //
  52. // The backreference \1 forces opening and closing tags to match, so a nested
  53. // inner block with a different tag (e.g. $fn$ containing $sql$...$sql$) is
  54. // consumed as part of the outer body instead of being paired as the outer.
  55. //
  56. // Must run before statement splitting — splitStatements' dollar-quote regex
  57. // doesn't enforce matching tags, so inner semicolons would otherwise leak
  58. // out and fragment the function body across statements.
  59. private stripDollarQuoteBodies(sql: string): string {
  60. return sql.replace(/(\$[a-zA-Z0-9_]*\$)[\s\S]*?\1/g, '$1$1')
  61. }
  62. private match(sql: string): TableEventDetails | null {
  63. for (const { type, patterns } of SQLEventParser.DETECTORS) {
  64. for (const pattern of patterns) {
  65. const match = sql.match(pattern)
  66. if (match?.groups) {
  67. return {
  68. type,
  69. schema: this.cleanIdentifier(match.groups.schema),
  70. tableName: this.cleanIdentifier(match.groups.table ?? match.groups.object),
  71. }
  72. }
  73. }
  74. }
  75. return null
  76. }
  77. private splitStatements(sql: string): string[] {
  78. // Regex matches:
  79. // - single quotes ('...') with escapes
  80. // - double quotes ("...")
  81. // - dollar-quoted blocks ($$...$$ or $tag$...$tag$)
  82. // - semicolons
  83. // - everything else
  84. const tokens =
  85. sql.match(
  86. /'([^']|'')*'|"([^"]|"")*"|\$[a-zA-Z0-9_]*\$[\s\S]*?\$[a-zA-Z0-9_]*\$|;|[^'"$;]+/g
  87. ) || []
  88. const statements: string[] = []
  89. let current = ''
  90. for (const token of tokens) {
  91. if (token === ';') {
  92. if (current.trim()) statements.push(current.trim())
  93. current = ''
  94. } else {
  95. current += token
  96. }
  97. }
  98. if (current.trim()) {
  99. statements.push(current.trim())
  100. }
  101. return statements
  102. }
  103. private deduplicate(events: TableEventDetails[]): TableEventDetails[] {
  104. const seen = new Set<string>()
  105. return events.filter((e) => {
  106. const key = `${e.type}:${e.schema || ''}:${e.tableName || ''}`
  107. if (seen.has(key)) return false
  108. seen.add(key)
  109. return true
  110. })
  111. }
  112. private removeComments(sql: string): string {
  113. return sql
  114. .replace(/--.*?$/gm, '') // line comments
  115. .replace(/\/\*[\s\S]*?\*\//g, '') // block comments
  116. }
  117. getTableEvents(sql: string): TableEventDetails[] {
  118. // Order matters: strip dollar-quote bodies first so comment syntax inside
  119. // a function body (which is just literal text in Postgres) isn't treated
  120. // as a comment by removeComments, and so inner semicolons inside the body
  121. // can't confuse splitStatements.
  122. const statements = this.splitStatements(this.removeComments(this.stripDollarQuoteBodies(sql)))
  123. const results: TableEventDetails[] = []
  124. for (const stmt of statements) {
  125. const event = this.match(stmt)
  126. if (event) results.push(event)
  127. }
  128. return this.deduplicate(results)
  129. }
  130. }
  131. export const sqlEventParser = new SQLEventParser()