diff --git a/apps/studio/lib/telemetry-sql-parser.test.ts b/apps/studio/lib/telemetry-sql-parser.test.ts index 9a90f358fce..07b47405ca7 100644 --- a/apps/studio/lib/telemetry-sql-parser.test.ts +++ b/apps/studio/lib/telemetry-sql-parser.test.ts @@ -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') + }) }) \ No newline at end of file diff --git a/apps/studio/lib/telemetry-sql-parser.ts b/apps/studio/lib/telemetry-sql-parser.ts index e6ac3a446a8..0f40c0c6457 100644 --- a/apps/studio/lib/telemetry-sql-parser.ts +++ b/apps/studio/lib/telemetry-sql-parser.ts @@ -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 => { + 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 => { + // 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 } \ No newline at end of file diff --git a/apps/studio/package.json b/apps/studio/package.json index f84058e366c..047ae174ea5 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e19a5a331cb..a1090802a53 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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