Replace regex parser with @supabase/pg-parser for robust SQL parsing

- Use @supabase/pg-parser for proper AST-based SQL parsing
- Fix ReDoS vulnerability by using existing removeCommentsFromSql
- Add comprehensive tests for PostgreSQL edge cases:
  - Quoted identifiers with spaces, hyphens, numbers
  - CREATE TEMP/TEMPORARY/UNLOGGED TABLE variants
  - CREATE TABLE AS SELECT
  - INSERT ... SELECT with undefined row count
  - ALTER TABLE ENABLE ROW LEVEL SECURITY
- Handle async parsing with proper error handling
- Support schema-qualified table names with dots
- 55/56 tests passing (nested comments limitation is acceptable)
This commit is contained in:
Sean Oliver committed 2025-09-25 19:30:22 -04:00
1 parent 3ebc1fa331
commit bc8597336d
4 files changed
+643 -134

No files matched your search

+327 -66
View File
@@ -6,27 +6,27 @@ import {
} from './telemetry-sql-parser'
describe('getSqlToAnalyze', () => {
it('returns selection when provided', () => {
it('returns selection when provided', async () => {
const fullSql = 'SELECT * FROM users; CREATE TABLE posts (id int);'
const selection = 'CREATE TABLE posts (id int);'
const result = getSqlToAnalyze(fullSql, selection)
expect(result).toBe('CREATE TABLE posts (id int);')
})
it('returns full SQL when no selection provided', () => {
it('returns full SQL when no selection provided', async () => {
const fullSql = 'CREATE TABLE users (id int);'
const result = getSqlToAnalyze(fullSql)
expect(result).toBe('CREATE TABLE users (id int);')
})
it('trims whitespace from selection', () => {
it('trims whitespace from selection', async () => {
const fullSql = 'SELECT * FROM users;'
const selection = ' CREATE TABLE posts (id int); '
const result = getSqlToAnalyze(fullSql, selection)
expect(result).toBe('CREATE TABLE posts (id int);')
})
it('returns full SQL when selection is empty', () => {
it('returns full SQL when selection is empty', async () => {
const fullSql = 'CREATE TABLE users (id int);'
const selection = ' '
const result = getSqlToAnalyze(fullSql, selection)
@@ -35,9 +35,9 @@ describe('getSqlToAnalyze', () => {
})
describe('detectActivationFromSql - CREATE TABLE', () => {
it('detects simple CREATE TABLE', () => {
it('detects simple CREATE TABLE', async () => {
const sql = 'CREATE TABLE users (id int);'
const result = detectActivationFromSql(sql)
const result = await detectActivationFromSql(sql)
expect(result.detections).toHaveLength(1)
expect(result.detections[0]).toEqual({
type: 'table_created',
@@ -46,9 +46,9 @@ describe('detectActivationFromSql - CREATE TABLE', () => {
})
})
it('detects CREATE TABLE with schema', () => {
it('detects CREATE TABLE with schema', async () => {
const sql = 'CREATE TABLE myschema.users (id int);'
const result = detectActivationFromSql(sql)
const result = await detectActivationFromSql(sql)
expect(result.detections).toHaveLength(1)
expect(result.detections[0]).toEqual({
type: 'table_created',
@@ -57,9 +57,9 @@ describe('detectActivationFromSql - CREATE TABLE', () => {
})
})
it('detects CREATE TABLE IF NOT EXISTS', () => {
it('detects CREATE TABLE IF NOT EXISTS', async () => {
const sql = 'CREATE TABLE IF NOT EXISTS users (id int);'
const result = detectActivationFromSql(sql)
const result = await detectActivationFromSql(sql)
expect(result.detections).toHaveLength(1)
expect(result.detections[0]).toEqual({
type: 'table_created',
@@ -68,9 +68,9 @@ describe('detectActivationFromSql - CREATE TABLE', () => {
})
})
it('detects CREATE TABLE IF NOT EXISTS with schema', () => {
it('detects CREATE TABLE IF NOT EXISTS with schema', async () => {
const sql = 'CREATE TABLE IF NOT EXISTS myschema.users (id int);'
const result = detectActivationFromSql(sql)
const result = await detectActivationFromSql(sql)
expect(result.detections).toHaveLength(1)
expect(result.detections[0]).toEqual({
type: 'table_created',
@@ -79,22 +79,134 @@ describe('detectActivationFromSql - CREATE TABLE', () => {
})
})
it('handles CREATE TABLE with mixed case', () => {
it('handles CREATE TABLE with mixed case', async () => {
const sql = 'create table Users (id int);'
const result = detectActivationFromSql(sql)
const result = await detectActivationFromSql(sql)
expect(result.detections).toHaveLength(1)
expect(result.detections[0]).toEqual({
type: 'table_created',
schema: 'public',
tableName: 'Users',
tableName: 'users', // PostgreSQL lowercases unquoted identifiers
})
})
it('detects CREATE TEMP TABLE', async () => {
const sql = 'CREATE TEMP TABLE temp_users (id int);'
const result = await detectActivationFromSql(sql)
expect(result.detections).toHaveLength(1)
expect(result.detections[0]).toEqual({
type: 'table_created',
schema: 'public',
tableName: 'temp_users',
})
})
it('detects CREATE TEMPORARY TABLE', async () => {
const sql = 'CREATE TEMPORARY TABLE temp_users (id int);'
const result = await detectActivationFromSql(sql)
expect(result.detections).toHaveLength(1)
expect(result.detections[0]).toEqual({
type: 'table_created',
schema: 'public',
tableName: 'temp_users',
})
})
it('detects CREATE UNLOGGED TABLE', async () => {
const sql = 'CREATE UNLOGGED TABLE unlogged_users (id int);'
const result = await detectActivationFromSql(sql)
expect(result.detections).toHaveLength(1)
expect(result.detections[0]).toEqual({
type: 'table_created',
schema: 'public',
tableName: 'unlogged_users',
})
})
it('detects CREATE TABLE AS SELECT', async () => {
const sql = 'CREATE TABLE new_users AS SELECT * FROM old_users;'
const result = await detectActivationFromSql(sql)
expect(result.detections).toHaveLength(1)
expect(result.detections[0]).toEqual({
type: 'table_created',
schema: 'public',
tableName: 'new_users',
})
})
})
describe('detectActivationFromSql - Quoted Identifiers', () => {
it('handles quoted table names', async () => {
const sql = 'CREATE TABLE "my-table" (id int);'
const result = await detectActivationFromSql(sql)
expect(result.detections).toHaveLength(1)
expect(result.detections[0]).toEqual({
type: 'table_created',
schema: 'public',
tableName: 'my-table',
})
})
it('handles quoted table names with spaces', async () => {
const sql = 'CREATE TABLE "Table Name" (id int);'
const result = await detectActivationFromSql(sql)
expect(result.detections).toHaveLength(1)
expect(result.detections[0]).toEqual({
type: 'table_created',
schema: 'public',
tableName: 'Table Name',
})
})
it('handles quoted table names starting with number', async () => {
const sql = 'CREATE TABLE "123table" (id int);'
const result = await detectActivationFromSql(sql)
expect(result.detections).toHaveLength(1)
expect(result.detections[0]).toEqual({
type: 'table_created',
schema: 'public',
tableName: '123table',
})
})
it('handles quoted reserved keywords as names', async () => {
const sql = 'CREATE TABLE "select" (id int);'
const result = await detectActivationFromSql(sql)
expect(result.detections).toHaveLength(1)
expect(result.detections[0]).toEqual({
type: 'table_created',
schema: 'public',
tableName: 'select',
})
})
it('handles quoted schema with dots', async () => {
const sql = 'CREATE TABLE "schema.with.dots"."table" (id int);'
const result = await detectActivationFromSql(sql)
expect(result.detections).toHaveLength(1)
expect(result.detections[0]).toEqual({
type: 'table_created',
schema: 'schema.with.dots',
tableName: 'table',
})
})
it('handles mixed quoted and unquoted identifiers', async () => {
const sql = 'CREATE TABLE myschema."table" (id int);'
const result = await detectActivationFromSql(sql)
expect(result.detections).toHaveLength(1)
expect(result.detections[0]).toEqual({
type: 'table_created',
schema: 'myschema',
tableName: 'table',
})
})
})
describe('detectActivationFromSql - INSERT', () => {
it('detects INSERT with single row', () => {
const sql = 'INSERT INTO users (id, name) VALUES (1, \'John\');'
const result = detectActivationFromSql(sql)
it('detects INSERT with single row', async () => {
const sql = "INSERT INTO users (id, name) VALUES (1, 'John');"
const result = await detectActivationFromSql(sql)
expect(result.detections).toHaveLength(1)
expect(result.detections[0]).toEqual({
type: 'data_inserted',
@@ -104,9 +216,9 @@ describe('detectActivationFromSql - INSERT', () => {
})
})
it('detects INSERT with multiple rows', () => {
const sql = 'INSERT INTO users (id, name) VALUES (1, \'John\'), (2, \'Jane\'), (3, \'Bob\');'
const result = detectActivationFromSql(sql)
it('detects INSERT with multiple rows', async () => {
const sql = "INSERT INTO users (id, name) VALUES (1, 'John'), (2, 'Jane'), (3, 'Bob');"
const result = await detectActivationFromSql(sql)
expect(result.detections).toHaveLength(1)
expect(result.detections[0]).toEqual({
type: 'data_inserted',
@@ -116,9 +228,9 @@ describe('detectActivationFromSql - INSERT', () => {
})
})
it('detects INSERT with schema-qualified table', () => {
const sql = 'INSERT INTO myschema.users (id, name) VALUES (1, \'John\');'
const result = detectActivationFromSql(sql)
it('detects INSERT with schema-qualified table', async () => {
const sql = "INSERT INTO myschema.users (id, name) VALUES (1, 'John');"
const result = await detectActivationFromSql(sql)
expect(result.detections).toHaveLength(1)
expect(result.detections[0]).toEqual({
type: 'data_inserted',
@@ -128,23 +240,71 @@ describe('detectActivationFromSql - INSERT', () => {
})
})
it('handles INSERT with mixed case', () => {
const sql = 'insert into Users (id, name) values (1, \'John\');'
const result = detectActivationFromSql(sql)
it('handles INSERT with mixed case', async () => {
const sql = "insert into Users (id, name) values (1, 'John');"
const result = await detectActivationFromSql(sql)
expect(result.detections).toHaveLength(1)
expect(result.detections[0]).toEqual({
type: 'data_inserted',
schema: 'public',
tableName: 'Users',
tableName: 'users', // PostgreSQL lowercases unquoted identifiers
estimatedRowCount: 1,
})
})
it('handles INSERT ... SELECT with undefined row count', async () => {
const sql = 'INSERT INTO users SELECT * FROM other_users;'
const result = await detectActivationFromSql(sql)
expect(result.detections).toHaveLength(1)
expect(result.detections[0]).toEqual({
type: 'data_inserted',
schema: 'public',
tableName: 'users',
estimatedRowCount: undefined, // Can't determine row count for INSERT SELECT
})
})
it('handles INSERT ... DEFAULT VALUES', async () => {
const sql = 'INSERT INTO users DEFAULT VALUES;'
const result = await detectActivationFromSql(sql)
expect(result.detections).toHaveLength(1)
expect(result.detections[0]).toEqual({
type: 'data_inserted',
schema: 'public',
tableName: 'users',
estimatedRowCount: 1,
})
})
it('handles INSERT with ON CONFLICT', async () => {
const sql = "INSERT INTO users (id, name) VALUES (1, 'John') ON CONFLICT (id) DO NOTHING;"
const result = await detectActivationFromSql(sql)
expect(result.detections).toHaveLength(1)
expect(result.detections[0]).toEqual({
type: 'data_inserted',
schema: 'public',
tableName: 'users',
estimatedRowCount: 1,
})
})
it('handles INSERT with nested functions', async () => {
const sql = "INSERT INTO users (id, name, created_at) VALUES (1, 'John', NOW()), (2, 'Jane', CURRENT_TIMESTAMP);"
const result = await detectActivationFromSql(sql)
expect(result.detections).toHaveLength(1)
expect(result.detections[0]).toEqual({
type: 'data_inserted',
schema: 'public',
tableName: 'users',
estimatedRowCount: 2,
})
})
})
describe('detectActivationFromSql - RLS', () => {
it('detects ALTER TABLE ENABLE ROW LEVEL SECURITY', () => {
it('detects ALTER TABLE ENABLE ROW LEVEL SECURITY', async () => {
const sql = 'ALTER TABLE users ENABLE ROW LEVEL SECURITY;'
const result = detectActivationFromSql(sql)
const result = await detectActivationFromSql(sql)
expect(result.detections).toHaveLength(1)
expect(result.detections[0]).toEqual({
type: 'rls_enabled',
@@ -153,9 +313,9 @@ describe('detectActivationFromSql - RLS', () => {
})
})
it('detects RLS with schema-qualified table', () => {
it('detects RLS with schema-qualified table', async () => {
const sql = 'ALTER TABLE myschema.users ENABLE ROW LEVEL SECURITY;'
const result = detectActivationFromSql(sql)
const result = await detectActivationFromSql(sql)
expect(result.detections).toHaveLength(1)
expect(result.detections[0]).toEqual({
type: 'rls_enabled',
@@ -164,26 +324,26 @@ describe('detectActivationFromSql - RLS', () => {
})
})
it('handles RLS with mixed case', () => {
it('handles RLS with mixed case', async () => {
const sql = 'alter table Users enable row level security;'
const result = detectActivationFromSql(sql)
const result = await detectActivationFromSql(sql)
expect(result.detections).toHaveLength(1)
expect(result.detections[0]).toEqual({
type: 'rls_enabled',
schema: 'public',
tableName: 'Users',
tableName: 'users', // PostgreSQL lowercases unquoted identifiers
})
})
})
describe('detectActivationFromSql - Multiple Statements', () => {
it('detects multiple statements separated by semicolons', () => {
it('detects multiple statements separated by semicolons', async () => {
const sql = `
CREATE TABLE users (id int);
INSERT INTO users VALUES (1);
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
`
const result = detectActivationFromSql(sql)
const result = await detectActivationFromSql(sql)
expect(result.detections).toHaveLength(3)
expect(result.detections[0]).toEqual({
type: 'table_created',
@@ -203,12 +363,12 @@ describe('detectActivationFromSql - Multiple Statements', () => {
})
})
it('detects multiple CREATE TABLE statements', () => {
it('detects multiple CREATE TABLE statements', async () => {
const sql = `
CREATE TABLE users (id int);
CREATE TABLE posts (id int);
`
const result = detectActivationFromSql(sql)
const result = await detectActivationFromSql(sql)
expect(result.detections).toHaveLength(2)
expect(result.detections[0]).toEqual({
type: 'table_created',
@@ -222,13 +382,13 @@ describe('detectActivationFromSql - Multiple Statements', () => {
})
})
it('handles statements with different schemas', () => {
it('handles statements with different schemas', async () => {
const sql = `
CREATE TABLE public.users (id int);
INSERT INTO auth.users VALUES (1);
ALTER TABLE storage.buckets ENABLE ROW LEVEL SECURITY;
`
const result = detectActivationFromSql(sql)
const result = await detectActivationFromSql(sql)
expect(result.detections).toHaveLength(3)
expect(result.detections[0]).toEqual({
type: 'table_created',
@@ -247,16 +407,46 @@ describe('detectActivationFromSql - Multiple Statements', () => {
tableName: 'buckets',
})
})
it('handles semicolons in string literals correctly', async () => {
const sql = "CREATE TABLE users (id int, description text DEFAULT 'Hello; World');"
const result = await detectActivationFromSql(sql)
expect(result.detections).toHaveLength(1)
expect(result.detections[0]).toEqual({
type: 'table_created',
schema: 'public',
tableName: 'users',
})
})
it('handles semicolons in dollar-quoted strings', async () => {
const sql = `
CREATE TABLE users (id int);
CREATE FUNCTION test() RETURNS void AS $$
BEGIN
SELECT 1; SELECT 2;
END;
$$ LANGUAGE plpgsql;
`
const result = await detectActivationFromSql(sql)
// Should only detect the CREATE TABLE, not the statements inside the function
expect(result.detections).toHaveLength(1)
expect(result.detections[0]).toEqual({
type: 'table_created',
schema: 'public',
tableName: 'users',
})
})
})
describe('detectActivationFromSql - Comments Handling', () => {
it('strips single-line comments', () => {
it('strips single-line comments', async () => {
const sql = `
-- This is a comment
CREATE TABLE users (id int);
-- Another comment
`
const result = detectActivationFromSql(sql)
const result = await detectActivationFromSql(sql)
expect(result.detections).toHaveLength(1)
expect(result.detections[0]).toEqual({
type: 'table_created',
@@ -265,13 +455,13 @@ describe('detectActivationFromSql - Comments Handling', () => {
})
})
it('strips multi-line comments', () => {
it('strips multi-line comments', async () => {
const sql = `
/* This is a
multi-line comment */
CREATE TABLE users (id int);
`
const result = detectActivationFromSql(sql)
const result = await detectActivationFromSql(sql)
expect(result.detections).toHaveLength(1)
expect(result.detections[0]).toEqual({
type: 'table_created',
@@ -280,9 +470,9 @@ describe('detectActivationFromSql - Comments Handling', () => {
})
})
it('handles inline comments', () => {
it('handles inline comments', async () => {
const sql = 'CREATE TABLE users (id int); -- inline comment'
const result = detectActivationFromSql(sql)
const result = await detectActivationFromSql(sql)
expect(result.detections).toHaveLength(1)
expect(result.detections[0]).toEqual({
type: 'table_created',
@@ -290,57 +480,89 @@ describe('detectActivationFromSql - Comments Handling', () => {
tableName: 'users',
})
})
it('handles nested comments correctly', async () => {
const sql = `
/* outer /* nested */ comment */
CREATE TABLE users (id int);
`
const result = await detectActivationFromSql(sql)
// Note: The existing removeCommentsFromSql doesn't handle nested comments perfectly
// But it should still work for most cases
expect(result.detections).toHaveLength(1)
})
})
describe('detectActivationFromSql - Edge Cases', () => {
it('returns empty array for empty SQL', () => {
it('returns empty array for empty SQL', async () => {
const sql = ''
const result = detectActivationFromSql(sql)
const result = await detectActivationFromSql(sql)
expect(result.detections).toHaveLength(0)
})
it('returns empty array for whitespace-only SQL', () => {
it('returns empty array for whitespace-only SQL', async () => {
const sql = ' \n \t '
const result = detectActivationFromSql(sql)
const result = await detectActivationFromSql(sql)
expect(result.detections).toHaveLength(0)
})
it('returns empty array for SELECT statements', () => {
it('returns empty array for null input', async () => {
const result = await detectActivationFromSql(null as any)
expect(result.detections).toHaveLength(0)
})
it('returns empty array for undefined input', async () => {
const result = await detectActivationFromSql(undefined as any)
expect(result.detections).toHaveLength(0)
})
it('returns empty array for non-string input', async () => {
const result = await detectActivationFromSql(123 as any)
expect(result.detections).toHaveLength(0)
})
it('returns empty array for SQL exceeding MAX_SQL_LENGTH', async () => {
const longSql = 'CREATE TABLE users (id int);'.padEnd(10_485_761, ' ')
const result = await detectActivationFromSql(longSql)
expect(result.detections).toHaveLength(0)
})
it('returns empty array for SELECT statements', async () => {
const sql = 'SELECT * FROM users;'
const result = detectActivationFromSql(sql)
const result = await detectActivationFromSql(sql)
expect(result.detections).toHaveLength(0)
})
it('returns empty array for UPDATE statements', () => {
const sql = 'UPDATE users SET name = \'John\' WHERE id = 1;'
const result = detectActivationFromSql(sql)
it('returns empty array for UPDATE statements', async () => {
const sql = "UPDATE users SET name = 'John' WHERE id = 1;"
const result = await detectActivationFromSql(sql)
expect(result.detections).toHaveLength(0)
})
it('returns empty array for DELETE statements', () => {
it('returns empty array for DELETE statements', async () => {
const sql = 'DELETE FROM users WHERE id = 1;'
const result = detectActivationFromSql(sql)
const result = await detectActivationFromSql(sql)
expect(result.detections).toHaveLength(0)
})
it('handles SQL with only comments', () => {
it('handles SQL with only comments', async () => {
const sql = `
-- Just a comment
/* Another comment */
`
const result = detectActivationFromSql(sql)
const result = await detectActivationFromSql(sql)
expect(result.detections).toHaveLength(0)
})
it('handles trailing semicolons', () => {
it('handles trailing semicolons', async () => {
const sql = 'CREATE TABLE users (id int);;'
const result = detectActivationFromSql(sql)
const result = await detectActivationFromSql(sql)
expect(result.detections).toHaveLength(1)
})
it('handles table names with underscores', () => {
it('handles table names with underscores', async () => {
const sql = 'CREATE TABLE user_profiles (id int);'
const result = detectActivationFromSql(sql)
const result = await detectActivationFromSql(sql)
expect(result.detections).toHaveLength(1)
expect(result.detections[0]).toEqual({
type: 'table_created',
@@ -349,9 +571,9 @@ describe('detectActivationFromSql - Edge Cases', () => {
})
})
it('handles table names with numbers', () => {
it('handles table names with numbers', async () => {
const sql = 'CREATE TABLE users2 (id int);'
const result = detectActivationFromSql(sql)
const result = await detectActivationFromSql(sql)
expect(result.detections).toHaveLength(1)
expect(result.detections[0]).toEqual({
type: 'table_created',
@@ -359,4 +581,43 @@ describe('detectActivationFromSql - Edge Cases', () => {
tableName: 'users2',
})
})
it('handles malformed SQL gracefully', async () => {
const sql = 'CREATE TABLE'
const result = await detectActivationFromSql(sql)
expect(result.detections).toHaveLength(0)
})
it('handles SQL with syntax errors gracefully', async () => {
const sql = 'CREATE TABEL users (id int);' // Typo: TABEL instead of TABLE
const result = await detectActivationFromSql(sql)
expect(result.detections).toHaveLength(0)
})
it('handles nested parentheses in INSERT VALUES', async () => {
const sql = "INSERT INTO users (id, data) VALUES (1, '(nested (value))');"
const result = await detectActivationFromSql(sql)
expect(result.detections).toHaveLength(1)
expect(result.detections[0]).toEqual({
type: 'data_inserted',
schema: 'public',
tableName: 'users',
estimatedRowCount: 1,
})
})
it('handles complex nested structures', async () => {
const sql = `
CREATE TABLE users (
id SERIAL PRIMARY KEY,
data JSONB DEFAULT '{"key": "value; with semicolon"}'::jsonb,
CHECK (id > 0)
);
INSERT INTO users (data) VALUES ('{"nested": {"value": "data"}}');
`
const result = await detectActivationFromSql(sql)
expect(result.detections).toHaveLength(2)
expect(result.detections[0].type).toBe('table_created')
expect(result.detections[1].type).toBe('data_inserted')
})
})
+292 -61
View File
@@ -1,3 +1,11 @@
import { PgParser } from '@supabase/pg-parser'
import { removeCommentsFromSql } from 'lib/helpers'
// Constants
const DEFAULT_SCHEMA = 'public'
const MAX_SQL_LENGTH = 10_485_760 // 10 MB
// Type definitions for detection results
export interface TableCreatedDetection {
type: 'table_created'
schema: string
@@ -8,7 +16,7 @@ export interface DataInsertedDetection {
type: 'data_inserted'
schema: string
tableName: string
estimatedRowCount: number
estimatedRowCount: number | undefined
}
export interface RlsEnabledDetection {
@@ -26,35 +34,93 @@ export interface ActivationDetectionResult {
detections: ActivationDetection[]
}
const stripComments = (sql: string): string => {
return sql
.replace(/--[^\n]*/g, '')
.replace(/\/\*[\s\S]*?\*\//g, '')
// Type definitions for PostgreSQL AST nodes
interface RangeVar {
schemaname?: string
relname: string
}
const extractSchemaAndTable = (
identifier: string
): { schema: string; tableName: string } => {
const parts = identifier.trim().split('.')
if (parts.length === 2) {
return {
schema: parts[0].replace(/["`]/g, ''),
tableName: parts[1].replace(/["`]/g, ''),
interface CreateStmt {
CreateStmt: {
relation: RangeVar
tableElts?: unknown[]
oncommit?: string
tablespacename?: string
if_not_exists?: boolean
}
}
interface CreateTableAsStmt {
CreateTableAsStmt: {
into?: {
rel: RangeVar
skipData?: boolean
}
}
return {
schema: 'public',
tableName: identifier.replace(/["`]/g, ''),
query?: unknown
objtype?: string
is_select_into?: boolean
}
}
const detectCreateTable = (sql: string): TableCreatedDetection | null => {
const createTableRegex =
/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)?)/i
const match = sql.match(createTableRegex)
interface InsertStmt {
InsertStmt: {
relation: RangeVar
cols?: unknown[]
selectStmt?: {
SelectStmt?: {
valuesLists?: unknown[][]
}
}
returningList?: unknown[]
withClause?: unknown
onConflictClause?: unknown
override?: string
}
}
interface AlterTableStmt {
AlterTableStmt: {
relation: RangeVar
cmds: AlterTableCmd[]
relkind?: string
missing_ok?: boolean
}
}
interface AlterTableCmd {
AlterTableCmd: {
subtype: string // Changed from number to string
name?: string
newowner?: unknown
def?: unknown
behavior?: string
missing_ok?: boolean
}
}
type ParsedStatement = CreateStmt | CreateTableAsStmt | InsertStmt | AlterTableStmt | { [key: string]: unknown }
// Helper function to extract schema and table name from RangeVar
const extractSchemaAndTable = (rangeVar: RangeVar): { schema: string; tableName: string } => {
const schema = rangeVar.schemaname || DEFAULT_SCHEMA
const tableName = rangeVar.relname
// Remove quotes from identifiers if present
const cleanSchema = schema.replace(/^"|"$/g, '')
const cleanTableName = tableName.replace(/^"|"$/g, '')
return {
schema: cleanSchema,
tableName: cleanTableName,
}
}
// Detect CREATE TABLE statements
const detectCreateTable = (stmt: ParsedStatement): TableCreatedDetection | null => {
if ('CreateStmt' in stmt) {
const createStmt = stmt as CreateStmt
const { schema, tableName } = extractSchemaAndTable(createStmt.CreateStmt.relation)
if (match && match[1]) {
const { schema, tableName } = extractSchemaAndTable(match[1])
return {
type: 'table_created',
schema,
@@ -62,19 +128,49 @@ const detectCreateTable = (sql: string): TableCreatedDetection | null => {
}
}
// Handle CREATE TABLE AS SELECT
if ('CreateTableAsStmt' in stmt) {
const createTableAsStmt = stmt as CreateTableAsStmt
// The table info is usually in the 'into' field
if (createTableAsStmt.CreateTableAsStmt.into?.rel) {
const { schema, tableName } = extractSchemaAndTable(createTableAsStmt.CreateTableAsStmt.into.rel)
return {
type: 'table_created',
schema,
tableName,
}
}
}
return null
}
const detectInsert = (sql: string): DataInsertedDetection | null => {
const insertRegex =
/INSERT\s+INTO\s+([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)?)/i
const match = sql.match(insertRegex)
// Count rows in INSERT VALUES statement
const countInsertRows = (selectStmt: unknown): number | undefined => {
if (!selectStmt || typeof selectStmt !== 'object') {
return undefined
}
if (match && match[1]) {
const { schema, tableName } = extractSchemaAndTable(match[1])
const stmt = selectStmt as { SelectStmt?: { valuesLists?: unknown[][] } }
const commasBetweenParens = sql.match(/\)\s*,\s*\(/g)
const estimatedRowCount = commasBetweenParens ? commasBetweenParens.length + 1 : 1
if (stmt.SelectStmt?.valuesLists && Array.isArray(stmt.SelectStmt.valuesLists)) {
return stmt.SelectStmt.valuesLists.length
}
// For INSERT ... SELECT, we can't determine the row count
return undefined
}
// Detect INSERT statements
const detectInsert = (stmt: ParsedStatement): DataInsertedDetection | null => {
if ('InsertStmt' in stmt) {
const insertStmt = stmt as InsertStmt
const { schema, tableName } = extractSchemaAndTable(insertStmt.InsertStmt.relation)
// Try to estimate row count
const estimatedRowCount = insertStmt.InsertStmt.selectStmt
? countInsertRows(insertStmt.InsertStmt.selectStmt)
: 1 // Default VALUES or DEFAULT VALUES
return {
type: 'data_inserted',
@@ -87,23 +183,81 @@ const detectInsert = (sql: string): DataInsertedDetection | null => {
return null
}
const detectRlsEnabled = (sql: string): RlsEnabledDetection | null => {
const rlsRegex =
/ALTER\s+TABLE\s+([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)?)\s+ENABLE\s+ROW\s+LEVEL\s+SECURITY/i
const match = sql.match(rlsRegex)
// Detect ALTER TABLE ... ENABLE ROW LEVEL SECURITY
const detectRlsEnabled = (stmt: ParsedStatement): RlsEnabledDetection | null => {
if ('AlterTableStmt' in stmt) {
const alterStmt = stmt as AlterTableStmt
if (match && match[1]) {
const { schema, tableName } = extractSchemaAndTable(match[1])
return {
type: 'rls_enabled',
schema,
tableName,
// Check if any command is enabling RLS
const hasEnableRls = alterStmt.AlterTableStmt.cmds.some(
cmd => cmd.AlterTableCmd.subtype === 'AT_EnableRowSecurity'
)
if (hasEnableRls) {
const { schema, tableName } = extractSchemaAndTable(alterStmt.AlterTableStmt.relation)
return {
type: 'rls_enabled',
schema,
tableName,
}
}
}
return null
}
// Create a parser instance
const parser = new PgParser()
// Parse a single SQL statement and detect activations
const parseStatement = async (sql: string): Promise<ActivationDetection[]> => {
try {
const parseResult = await parser.parse(sql)
if (parseResult.error || !parseResult.tree) {
return []
}
const detections: ActivationDetection[] = []
// The tree structure has stmts array
if (!parseResult.tree.stmts) {
return []
}
for (const stmtWrapper of parseResult.tree.stmts) {
// Each statement is wrapped in a stmt object
const stmt = stmtWrapper.stmt
if (!stmt) continue
// Try each detector
const tableCreated = detectCreateTable(stmt as ParsedStatement)
if (tableCreated) {
detections.push(tableCreated)
continue
}
const dataInserted = detectInsert(stmt as ParsedStatement)
if (dataInserted) {
detections.push(dataInserted)
continue
}
const rlsEnabled = detectRlsEnabled(stmt as ParsedStatement)
if (rlsEnabled) {
detections.push(rlsEnabled)
}
}
return detections
} catch (error) {
// Return empty array on parse errors
return []
}
}
// Get the SQL to analyze (either selection or full SQL)
export const getSqlToAnalyze = (fullSql: string, selection?: string): string => {
if (selection && selection.trim().length > 0) {
return selection.trim()
@@ -111,9 +265,24 @@ export const getSqlToAnalyze = (fullSql: string, selection?: string): string =>
return fullSql.trim()
}
export const detectActivationFromSql = (sql: string): ActivationDetectionResult => {
const cleanedSql = stripComments(sql)
const statements = cleanedSql.split(';').filter((s) => s.trim().length > 0)
// Main function to detect activation events from SQL
export const detectActivationFromSql = async (sql: string): Promise<ActivationDetectionResult> => {
// Input validation
if (!sql || typeof sql !== 'string') {
return { detections: [] }
}
// Check SQL length
if (sql.length > MAX_SQL_LENGTH) {
return { detections: [] }
}
// Remove comments
const cleanedSql = removeCommentsFromSql(sql)
// Split by semicolons, but be careful with semicolons in strings
// The parser should handle this properly, but we'll split conservatively
const statements = splitSqlStatements(cleanedSql)
const detections: ActivationDetection[] = []
@@ -121,24 +290,86 @@ export const detectActivationFromSql = (sql: string): ActivationDetectionResult
const trimmedStatement = statement.trim()
if (!trimmedStatement) continue
const tableCreated = detectCreateTable(trimmedStatement)
if (tableCreated) {
detections.push(tableCreated)
continue
}
const dataInserted = detectInsert(trimmedStatement)
if (dataInserted) {
detections.push(dataInserted)
continue
}
const rlsEnabled = detectRlsEnabled(trimmedStatement)
if (rlsEnabled) {
detections.push(rlsEnabled)
continue
}
const stmtDetections = await parseStatement(trimmedStatement)
detections.push(...stmtDetections)
}
return { detections }
}
// Split SQL into individual statements, respecting string literals
const splitSqlStatements = (sql: string): string[] => {
const statements: string[] = []
let currentStatement = ''
let inSingleQuote = false
let inDoubleQuote = false
let inDollarQuote = false
let dollarQuoteTag = ''
for (let i = 0; i < sql.length; i++) {
const char = sql[i]
const nextChar = sql[i + 1]
// Handle escape sequences
if (char === '\\' && (inSingleQuote || inDoubleQuote)) {
currentStatement += char
if (nextChar) {
currentStatement += nextChar
i++
}
continue
}
// Handle dollar quotes
if (char === '$' && !inSingleQuote && !inDoubleQuote) {
const dollarMatch = sql.slice(i).match(/^\$([^$]*)\$/)
if (dollarMatch) {
if (inDollarQuote && dollarQuoteTag === dollarMatch[1]) {
// End of dollar quote
inDollarQuote = false
dollarQuoteTag = ''
} else if (!inDollarQuote) {
// Start of dollar quote
inDollarQuote = true
dollarQuoteTag = dollarMatch[1]
}
currentStatement += dollarMatch[0]
i += dollarMatch[0].length - 1
continue
}
}
// Handle regular quotes
if (!inDollarQuote) {
if (char === "'" && !inDoubleQuote) {
// Check for escaped single quote
if (nextChar === "'") {
currentStatement += "''"
i++
continue
}
inSingleQuote = !inSingleQuote
} else if (char === '"' && !inSingleQuote) {
inDoubleQuote = !inDoubleQuote
}
}
// Handle semicolons outside of quotes
if (char === ';' && !inSingleQuote && !inDoubleQuote && !inDollarQuote) {
if (currentStatement.trim()) {
statements.push(currentStatement.trim())
}
currentStatement = ''
continue
}
currentStatement += char
}
// Add the last statement if any
if (currentStatement.trim()) {
statements.push(currentStatement.trim())
}
return statements
}
+1
View File
@@ -56,6 +56,7 @@
"@std/path": "npm:@jsr/std__path@^1.0.8",
"@stripe/react-stripe-js": "^3.7.0",
"@stripe/stripe-js": "^7.5.0",
"@supabase/pg-parser": "^0.1.3",
"@supabase/auth-js": "catalog:",
"@supabase/mcp-server-supabase": "^0.5.4",
"@supabase/mcp-utils": "^0.2.0",
+23 -7
View File
@@ -382,7 +382,7 @@ importers:
version: 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@sentry/nextjs':
specifier: ^10.3.0
version: 10.3.0(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.2(@babel/core@7.26.10(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.53.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)(supports-color@8.1.1)(webpack@5.94.0)
version: 10.3.0(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.2(@opentelemetry/api@1.9.0)(@playwright/test@1.53.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)(supports-color@8.1.1)(webpack@5.94.0)
'@supabase/supabase-js':
specifier: 'catalog:'
version: 2.49.3
@@ -794,7 +794,7 @@ importers:
version: 1.1.3(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@sentry/nextjs':
specifier: ^10.3.0
version: 10.3.0(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.2(@babel/core@7.26.10(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.53.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)(supports-color@8.1.1)(webpack@5.94.0)
version: 10.3.0(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.2(@opentelemetry/api@1.9.0)(@playwright/test@1.53.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)(supports-color@8.1.1)(webpack@5.94.0)
'@std/path':
specifier: npm:@jsr/std__path@^1.0.8
version: '@jsr/std__path@1.0.8'
@@ -816,6 +816,9 @@ importers:
'@supabase/pg-meta':
specifier: workspace:*
version: link:../../packages/pg-meta
'@supabase/pg-parser':
specifier: ^0.1.3
version: 0.1.3
'@supabase/realtime-js':
specifier: 'catalog:'
version: 2.11.3
@@ -1206,7 +1209,7 @@ importers:
version: 2.4.11(typescript@5.9.2)
next-router-mock:
specifier: ^0.9.13
version: 0.9.13(next@15.5.2(@babel/core@7.26.10(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.53.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)
version: 0.9.13(next@15.5.2(@opentelemetry/api@1.9.0)(@playwright/test@1.53.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)
node-mocks-http:
specifier: ^1.17.2
version: 1.17.2(@types/node@22.13.14)
@@ -1567,7 +1570,7 @@ importers:
version: 1.0.5(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@sentry/nextjs':
specifier: ^10
version: 10.3.0(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.2(@babel/core@7.26.10(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.53.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)(supports-color@8.1.1)(webpack@5.94.0)
version: 10.3.0(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.2(@opentelemetry/api@1.9.0)(@playwright/test@1.53.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)(supports-color@8.1.1)(webpack@5.94.0)
'@supabase/supabase-js':
specifier: 'catalog:'
version: 2.49.3
@@ -2483,7 +2486,7 @@ importers:
version: link:../api-types
next-router-mock:
specifier: ^0.9.13
version: 0.9.13(next@15.5.2(@babel/core@7.26.10(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.53.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)
version: 0.9.13(next@15.5.2(@opentelemetry/api@1.9.0)(@playwright/test@1.53.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)
tsx:
specifier: ^4.19.3
version: 4.19.3
@@ -3176,6 +3179,9 @@ packages:
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'}
'@bjorn3/browser_wasi_shim@0.4.2':
resolution: {integrity: sha512-/iHkCVUG3VbcbmEHn5iIUpIrh7a7WPiwZ3sHy4HZKZzBdSadwdddYDZAII2zBvQYV0Lfi8naZngPCN7WPHI/hA==}
'@bundled-es-modules/cookie@2.0.1':
resolution: {integrity: sha512-8o+5fRPLNbjbdGRRmJj3h6Hh1AQJf2dk3qQ/5ZFb+PXkRNiSoMGGUKlsgLfrxneb72axVJyIYji64E2+nNfYyw==}
@@ -3979,6 +3985,7 @@ packages:
'@graphql-tools/prisma-loader@8.0.17':
resolution: {integrity: sha512-fnuTLeQhqRbA156pAyzJYN0KxCjKYRU5bz1q/SKOwElSnAU4k7/G1kyVsWLh7fneY78LoMNH5n+KlFV8iQlnyg==}
engines: {node: '>=16.0.0'}
deprecated: 'This package was intended to be used with an older versions of Prisma.\nThe newer versions of Prisma has a different approach to GraphQL integration.\nTherefore, this package is no longer needed and has been deprecated and removed.\nLearn more: https://www.prisma.io/graphql'
peerDependencies:
graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0
@@ -8138,6 +8145,9 @@ packages:
resolution: {integrity: sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ==}
engines: {node: 4.x || >=6.0.0}
'@supabase/pg-parser@0.1.3':
resolution: {integrity: sha512-pwksLlFg0Nzr+xOJx5emy1nchdzwlyR/Xstnn54XQRYvim+wfGeWy7olu737hDSQyKfH4dQLljJHDEcml3ERKw==}
'@supabase/postgres-meta@0.64.6':
resolution: {integrity: sha512-vz5gc6RKNfDVnIfRUmH2ssTMYFI0U3MYOVyQ9R4YkzOS2dKSanjC4rTEDGjlMFwGTCUPW3N3pbY7HJIW81wMyg==}
engines: {node: '>=16', npm: '>=8'}
@@ -19957,6 +19967,8 @@ snapshots:
'@bcoe/v8-coverage@1.0.2': {}
'@bjorn3/browser_wasi_shim@0.4.2': {}
'@bundled-es-modules/cookie@2.0.1':
dependencies:
cookie: 0.7.2
@@ -25449,7 +25461,7 @@ snapshots:
'@sentry/core@10.3.0': {}
'@sentry/nextjs@10.3.0(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.2(@babel/core@7.26.10(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.53.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)(supports-color@8.1.1)(webpack@5.94.0)':
'@sentry/nextjs@10.3.0(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.2(@opentelemetry/api@1.9.0)(@playwright/test@1.53.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)(supports-color@8.1.1)(webpack@5.94.0)':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/semantic-conventions': 1.36.0
@@ -26245,6 +26257,10 @@ snapshots:
dependencies:
whatwg-url: 5.0.0
'@supabase/pg-parser@0.1.3':
dependencies:
'@bjorn3/browser_wasi_shim': 0.4.2
'@supabase/postgres-meta@0.64.6(encoding@0.1.13)(supports-color@8.1.1)':
dependencies:
'@sinclair/typebox': 0.25.24
@@ -34161,7 +34177,7 @@ snapshots:
dependencies:
js-yaml-loader: 1.2.2
next-router-mock@0.9.13(next@15.5.2(@babel/core@7.26.10(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.53.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1):
next-router-mock@0.9.13(next@15.5.2(@opentelemetry/api@1.9.0)(@playwright/test@1.53.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1):
dependencies:
next: 15.5.2(@babel/core@7.26.10(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.53.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4)
react: 18.3.1