| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148 |
- /**
- * Lightweight SQL parser for telemetry event detection.
- *
- * [Sean] Replace this with a proper SQL parser like `@supabase/pg-parser` once a
- * browser-compatible version is available.
- */
- import { TABLE_EVENT_ACTIONS, TableEventAction } from 'common/telemetry-constants'
- export interface TableEventDetails {
- type: TableEventAction
- schema?: string
- tableName?: string
- }
- type Detector = {
- type: TableEventAction
- patterns: RegExp[]
- }
- export class SQLEventParser {
- private static DETECTORS: Detector[] = [
- {
- type: TABLE_EVENT_ACTIONS.TableCreated,
- patterns: [
- /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?<schema>(?:"[^"]+"|[\w]+)\.)?(?<table>(?:"(?:[^"]|"")+"|`(?:[^`]|``)+`|[\w]+))/i,
- /CREATE\s+TEMP(?:ORARY)?\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?<schema>(?:"[^"]+"|[\w]+)\.)?(?<table>(?:"(?:[^"]|"")+"|`(?:[^`]|``)+`|[\w]+))/i,
- /CREATE\s+UNLOGGED\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?<schema>(?:"[^"]+"|[\w]+)\.)?(?<table>(?:"(?:[^"]|"")+"|`(?:[^`]|``)+`|[\w]+))/i,
- /SELECT\s+.*?\s+INTO\s+(?<schema>(?:"[^"]+"|[\w]+)\.)?(?<table>(?:"(?:[^"]|"")+"|`(?:[^`]|``)+`|[\w]+))/is,
- /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?<schema>(?:"[^"]+"|[\w]+)\.)?(?<table>(?:"(?:[^"]|"")+"|`(?:[^`]|``)+`|[\w]+))\s+AS\s+SELECT/i,
- ],
- },
- {
- type: TABLE_EVENT_ACTIONS.TableDataAdded,
- patterns: [
- /INSERT\s+INTO\s+(?<schema>(?:"[^"]+"|[\w]+)\.)?(?<table>(?:"(?:[^"]|"")+"|`(?:[^`]|``)+`|[\w]+))/i,
- /COPY\s+(?<schema>(?:"[^"]+"|[\w]+)\.)?(?<table>(?:"(?:[^"]|"")+"|`(?:[^`]|``)+`|[\w]+))\s+FROM/i,
- ],
- },
- {
- type: TABLE_EVENT_ACTIONS.TableRLSEnabled,
- patterns: [
- /ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?(?:ONLY\s+)?(?<schema>(?:"[^"]+"|[\w]+)\.)?(?<table>(?:"(?:[^"]|"")+"|`(?:[^`]|``)+`|[\w]+)).*?ENABLE\s+ROW\s+LEVEL\s+SECURITY/i,
- /ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?(?:ONLY\s+)?(?<schema>(?:"[^"]+"|[\w]+)\.)?(?<table>(?:"(?:[^"]|"")+"|`(?:[^`]|``)+`|[\w]+)).*?ENABLE\s+RLS/i,
- ],
- },
- ]
- private cleanIdentifier(identifier?: string) {
- return identifier?.replace(/["`']/g, '').replace(/\.$/, '')
- }
- // Blank out the body of $tag$...$tag$ blocks (PL/pgSQL function bodies, DO
- // blocks, dollar-quoted string literals) so their contents aren't scanned for
- // DDL. A `select ... into var` inside a function body is variable assignment,
- // not table creation, and would otherwise trip the SELECT..INTO detector.
- //
- // The backreference \1 forces opening and closing tags to match, so a nested
- // inner block with a different tag (e.g. $fn$ containing $sql$...$sql$) is
- // consumed as part of the outer body instead of being paired as the outer.
- //
- // Must run before statement splitting — splitStatements' dollar-quote regex
- // doesn't enforce matching tags, so inner semicolons would otherwise leak
- // out and fragment the function body across statements.
- private stripDollarQuoteBodies(sql: string): string {
- return sql.replace(/(\$[a-zA-Z0-9_]*\$)[\s\S]*?\1/g, '$1$1')
- }
- private match(sql: string): TableEventDetails | null {
- for (const { type, patterns } of SQLEventParser.DETECTORS) {
- for (const pattern of patterns) {
- const match = sql.match(pattern)
- if (match?.groups) {
- return {
- type,
- schema: this.cleanIdentifier(match.groups.schema),
- tableName: this.cleanIdentifier(match.groups.table ?? match.groups.object),
- }
- }
- }
- }
- return null
- }
- private splitStatements(sql: string): string[] {
- // Regex matches:
- // - single quotes ('...') with escapes
- // - double quotes ("...")
- // - dollar-quoted blocks ($$...$$ or $tag$...$tag$)
- // - semicolons
- // - everything else
- const tokens =
- sql.match(
- /'([^']|'')*'|"([^"]|"")*"|\$[a-zA-Z0-9_]*\$[\s\S]*?\$[a-zA-Z0-9_]*\$|;|[^'"$;]+/g
- ) || []
- const statements: string[] = []
- let current = ''
- for (const token of tokens) {
- if (token === ';') {
- if (current.trim()) statements.push(current.trim())
- current = ''
- } else {
- current += token
- }
- }
- if (current.trim()) {
- statements.push(current.trim())
- }
- return statements
- }
- private deduplicate(events: TableEventDetails[]): TableEventDetails[] {
- const seen = new Set<string>()
- return events.filter((e) => {
- const key = `${e.type}:${e.schema || ''}:${e.tableName || ''}`
- if (seen.has(key)) return false
- seen.add(key)
- return true
- })
- }
- private removeComments(sql: string): string {
- return sql
- .replace(/--.*?$/gm, '') // line comments
- .replace(/\/\*[\s\S]*?\*\//g, '') // block comments
- }
- getTableEvents(sql: string): TableEventDetails[] {
- // Order matters: strip dollar-quote bodies first so comment syntax inside
- // a function body (which is just literal text in Postgres) isn't treated
- // as a comment by removeComments, and so inner semicolons inside the body
- // can't confuse splitStatements.
- const statements = this.splitStatements(this.removeComments(this.stripDollarQuoteBodies(sql)))
- const results: TableEventDetails[] = []
- for (const stmt of statements) {
- const event = this.match(stmt)
- if (event) results.push(event)
- }
- return this.deduplicate(results)
- }
- }
- export const sqlEventParser = new SQLEventParser()
|