diff --git a/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx b/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx index 7e694daefec..1d17ad0eb9f 100644 --- a/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx @@ -197,6 +197,19 @@ export const GridHeaderActions = ({ table, isRefetching }: GridHeaderActionsProp schema: table.schema, payload: payload, }) + + sendEvent({ + action: 'table_rls_enabled', + properties: { + method: 'table_editor', + schema_name: table.schema, + table_name: table.name, + }, + groups: { + project: projectRef, + ...(org?.slug && { organization: org.slug }), + }, + }) } return ( diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx index 594198ea3d0..633b9c030d6 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx @@ -21,6 +21,7 @@ import { useTableRowUpdateMutation } from 'data/table-rows/table-row-update-muta import { tableKeys } from 'data/tables/keys' import { RetrieveTableResult } from 'data/tables/table-retrieve-query' import { getTables } from 'data/tables/tables-query' +import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { useUrlState } from 'hooks/ui/useUrlState' import { useGetImpersonatedRoleState } from 'state/role-impersonation-state' @@ -74,6 +75,7 @@ const SidePanelEditor = ({ const queryClient = useQueryClient() const { data: project } = useSelectedProjectQuery() + const { data: org } = useSelectedOrganizationQuery() const [isEdited, setIsEdited] = useState(false) const [isClosingPanel, setIsClosingPanel] = useState(false) @@ -454,6 +456,7 @@ const SidePanelEditor = ({ foreignKeyRelations, isRLSEnabled, importContent, + organizationSlug: org?.slug, }) if (isRealtimeEnabled) await updateTableRealtime(table, true) @@ -477,6 +480,7 @@ const SidePanelEditor = ({ foreignKeyRelations, existingForeignKeyRelations, primaryKey, + organizationSlug: org?.slug, }) if (table === undefined) { diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.tsx index 8fcc9fa2a77..2a65b4d517e 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.tsx @@ -32,6 +32,7 @@ import { updateTable as updateTableMutation, } from 'data/tables/table-update-mutation' import { getTables } from 'data/tables/tables-query' +import { sendEvent } from 'data/telemetry/send-event-mutation' import { timeout, tryParseJson } from 'lib/helpers' import { generateCreateColumnPayload, @@ -461,6 +462,7 @@ export const createTable = async ({ foreignKeyRelations, isRLSEnabled, importContent, + organizationSlug, }: { projectRef: string connectionString?: string | null @@ -474,6 +476,7 @@ export const createTable = async ({ foreignKeyRelations: ForeignKey[] isRLSEnabled: boolean importContent?: ImportContent + organizationSlug?: string }) => { const queryClient = getQueryClient() @@ -484,6 +487,26 @@ export const createTable = async ({ payload: payload, }) + // Track table creation event + try { + await sendEvent({ + event: { + action: 'table_created', + properties: { + method: 'table_editor', + schema_name: payload.schema, + table_name: payload.name, + }, + groups: { + project: projectRef, + ...(organizationSlug && { organization: organizationSlug }), + }, + }, + }) + } catch (error) { + console.error('Failed to track table creation event:', error) + } + const table = await queryClient.fetchQuery({ queryKey: tableKeys.retrieve(projectRef, payload.name, payload.schema), queryFn: ({ signal }) => @@ -508,6 +531,26 @@ export const createTable = async ({ schema: table.schema, payload: { rls_enabled: isRLSEnabled }, }) + + // Track RLS enablement event + try { + await sendEvent({ + event: { + action: 'table_rls_enabled', + properties: { + method: 'table_editor', + schema_name: table.schema, + table_name: table.name, + }, + groups: { + project: projectRef, + ...(organizationSlug && { organization: organizationSlug }), + }, + }, + }) + } catch (error) { + console.error('Failed to track RLS enablement event:', error) + } } // Then insert the columns - we don't do Promise.all as we want to keep the integrity @@ -662,6 +705,7 @@ export const updateTable = async ({ foreignKeyRelations, existingForeignKeyRelations, primaryKey, + organizationSlug, }: { projectRef: string connectionString?: string | null @@ -672,6 +716,7 @@ export const updateTable = async ({ foreignKeyRelations: ForeignKey[] existingForeignKeyRelations: ForeignKeyConstraint[] primaryKey?: Constraint + organizationSlug?: string }) => { const queryClient = getQueryClient() @@ -703,6 +748,28 @@ export const updateTable = async ({ payload, }) + // Track RLS enablement if it's being turned on + if (payload.rls_enabled === true) { + try { + await sendEvent({ + event: { + action: 'table_rls_enabled', + properties: { + method: 'table_editor', + schema_name: table.schema, + table_name: payload.name ?? table.name, + }, + groups: { + project: projectRef, + ...(organizationSlug && { organization: organizationSlug }), + }, + }, + }) + } catch (error) { + console.error('Failed to track RLS enablement event:', error) + } + } + const updatedTable = await queryClient.fetchQuery({ queryKey: tableKeys.retrieve( projectRef, diff --git a/apps/studio/data/sql/execute-sql-mutation.ts b/apps/studio/data/sql/execute-sql-mutation.ts index b5ddceaf427..91af532e4a4 100644 --- a/apps/studio/data/sql/execute-sql-mutation.ts +++ b/apps/studio/data/sql/execute-sql-mutation.ts @@ -1,6 +1,9 @@ import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' import { toast } from 'sonner' +import { useSendEventMutation } from 'data/telemetry/send-event-mutation' +import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' +import { sqlEventParser } from 'lib/sql-event-parser' import { executeSql, ExecuteSqlData, ExecuteSqlVariables } from './execute-sql-query' // [Joshen] Intention is that we invalidate all database related keys whenever running a mutation related query @@ -31,12 +34,38 @@ export const useExecuteSqlMutation = ({ 'mutationFn' > = {}) => { const queryClient = useQueryClient() + const { mutate: sendEvent } = useSendEventMutation() + const { data: org } = useSelectedOrganizationQuery() + return useMutation( (args) => executeSql(args), { async onSuccess(data, variables, context) { const { contextualInvalidation, sql, projectRef } = variables + // Track all table-related events from SQL execution + try { + const tableEvents = sqlEventParser.getTableEvents(sql) + tableEvents.forEach((event) => { + if (projectRef) { + sendEvent({ + action: event.type, + properties: { + method: 'sql_editor', + schema_name: event.schema, + table_name: event.tableName, + }, + groups: { + project: projectRef, + ...(org?.slug && { organization: org.slug }), + }, + }) + } + }) + } catch (error) { + console.error('Failed to parse SQL for telemetry:', error) + } + // [Joshen] Default to false for now, only used for SQL editor to dynamically invalidate const sqlLower = sql.toLowerCase() const isMutationSQL = diff --git a/apps/studio/data/table-rows/table-row-create-mutation.ts b/apps/studio/data/table-rows/table-row-create-mutation.ts index e31cc4609dc..f901c10ce84 100644 --- a/apps/studio/data/table-rows/table-row-create-mutation.ts +++ b/apps/studio/data/table-rows/table-row-create-mutation.ts @@ -3,6 +3,8 @@ import { toast } from 'sonner' import { Query } from '@supabase/pg-meta/src/query' import { executeSql } from 'data/sql/execute-sql-query' +import { useSendEventMutation } from 'data/telemetry/send-event-mutation' +import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { RoleImpersonationState, wrapWithRoleImpersonation } from 'lib/role-impersonation' import { isRoleImpersonationEnabled } from 'state/role-impersonation-state' import type { ResponseError } from 'types' @@ -65,12 +67,33 @@ export const useTableRowCreateMutation = ({ 'mutationFn' > = {}) => { const queryClient = useQueryClient() + const { mutate: sendEvent } = useSendEventMutation() + const { data: org } = useSelectedOrganizationQuery() return useMutation( (vars) => createTableRow(vars), { async onSuccess(data, variables, context) { const { projectRef, table } = variables + + // Track data insertion event + try { + sendEvent({ + action: 'table_data_added', + properties: { + method: 'table_editor', + schema_name: table.schema, + table_name: table.name, + }, + groups: { + project: projectRef, + ...(org?.slug && { organization: org.slug }), + }, + }) + } catch (error) { + console.error('Failed to track table data insertion event:', error) + } + await queryClient.invalidateQueries(tableRowKeys.tableRowsAndCount(projectRef, table.id)) await onSuccess?.(data, variables, context) }, diff --git a/apps/studio/lib/sql-event-parser.test.ts b/apps/studio/lib/sql-event-parser.test.ts new file mode 100644 index 00000000000..dafaa37712a --- /dev/null +++ b/apps/studio/lib/sql-event-parser.test.ts @@ -0,0 +1,462 @@ +import { TABLE_EVENT_ACTIONS } from 'common/telemetry-constants' +import { describe, expect, it } from 'vitest' +import { sqlEventParser } from './sql-event-parser' + +describe('SQL Event Parser', () => { + describe('CREATE TABLE detection', () => { + it('detects basic CREATE TABLE', () => { + const results = sqlEventParser.getTableEvents('CREATE TABLE users (id INT PRIMARY KEY)') + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + type: TABLE_EVENT_ACTIONS.TableCreated, + schema: undefined, + tableName: 'users', + }) + }) + + it('detects CREATE TABLE with schema', () => { + const results = sqlEventParser.getTableEvents('CREATE TABLE public.users (id INT)') + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + type: TABLE_EVENT_ACTIONS.TableCreated, + schema: 'public', + tableName: 'users', + }) + }) + + it('detects CREATE TABLE IF NOT EXISTS', () => { + const results = sqlEventParser.getTableEvents('CREATE TABLE IF NOT EXISTS users (id INT)') + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + type: TABLE_EVENT_ACTIONS.TableCreated, + schema: undefined, + tableName: 'users', + }) + }) + + it('handles quoted identifiers', () => { + const results = sqlEventParser.getTableEvents('CREATE TABLE "public"."user_table" (id INT)') + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + type: TABLE_EVENT_ACTIONS.TableCreated, + schema: 'public', + tableName: 'user_table', + }) + }) + + it('returns empty array for non-matching SQL', () => { + const results = sqlEventParser.getTableEvents('SELECT * FROM users') + expect(results).toHaveLength(0) + }) + + it('detects CREATE TEMPORARY TABLE', () => { + const results = sqlEventParser.getTableEvents('CREATE TEMPORARY TABLE temp_users (id INT)') + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + type: TABLE_EVENT_ACTIONS.TableCreated, + schema: undefined, + tableName: 'temp_users', + }) + }) + + it('detects CREATE TEMP TABLE', () => { + const results = sqlEventParser.getTableEvents('CREATE TEMP TABLE temp_users (id INT)') + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + type: TABLE_EVENT_ACTIONS.TableCreated, + schema: undefined, + tableName: 'temp_users', + }) + }) + + it('detects CREATE UNLOGGED TABLE', () => { + const results = sqlEventParser.getTableEvents('CREATE UNLOGGED TABLE fast_table (id INT)') + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + type: TABLE_EVENT_ACTIONS.TableCreated, + schema: undefined, + tableName: 'fast_table', + }) + }) + + it('detects CREATE TEMP TABLE IF NOT EXISTS', () => { + const results = sqlEventParser.getTableEvents( + 'CREATE TEMP TABLE IF NOT EXISTS temp_users (id INT)' + ) + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + type: TABLE_EVENT_ACTIONS.TableCreated, + schema: undefined, + tableName: 'temp_users', + }) + }) + }) + + describe('INSERT detection', () => { + it('detects basic INSERT INTO', () => { + const results = sqlEventParser.getTableEvents("INSERT INTO users (name) VALUES ('John')") + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + type: TABLE_EVENT_ACTIONS.TableDataAdded, + schema: undefined, + tableName: 'users', + }) + }) + + it('detects INSERT with schema', () => { + const results = sqlEventParser.getTableEvents( + "INSERT INTO public.users (name) VALUES ('John')" + ) + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + type: TABLE_EVENT_ACTIONS.TableDataAdded, + schema: 'public', + tableName: 'users', + }) + }) + + it('handles quoted identifiers', () => { + const results = sqlEventParser.getTableEvents('INSERT INTO "auth"."users" (id) VALUES (1)') + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + type: TABLE_EVENT_ACTIONS.TableDataAdded, + schema: 'auth', + tableName: 'users', + }) + }) + + it('returns empty array for non-matching SQL', () => { + const results = sqlEventParser.getTableEvents('UPDATE users SET name = "John"') + expect(results).toHaveLength(0) + }) + }) + + describe('COPY detection', () => { + it('detects basic COPY FROM', () => { + const results = sqlEventParser.getTableEvents("COPY users FROM '/tmp/users.csv'") + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + type: TABLE_EVENT_ACTIONS.TableDataAdded, + schema: undefined, + tableName: 'users', + }) + }) + + it('detects COPY with schema', () => { + const results = sqlEventParser.getTableEvents( + "COPY public.users FROM '/tmp/users.csv' WITH CSV HEADER" + ) + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + type: TABLE_EVENT_ACTIONS.TableDataAdded, + schema: 'public', + tableName: 'users', + }) + }) + + it('handles quoted identifiers', () => { + const results = sqlEventParser.getTableEvents('COPY "auth"."users" FROM STDIN') + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + type: TABLE_EVENT_ACTIONS.TableDataAdded, + schema: 'auth', + tableName: 'users', + }) + }) + + it('returns empty array for COPY TO', () => { + const results = sqlEventParser.getTableEvents("COPY users TO '/tmp/users.csv'") + expect(results).toHaveLength(0) + }) + + it('returns empty array for non-matching SQL', () => { + const results = sqlEventParser.getTableEvents('SELECT * FROM users') + expect(results).toHaveLength(0) + }) + }) + + describe('SELECT INTO detection', () => { + it('detects SELECT INTO', () => { + const results = sqlEventParser.getTableEvents('SELECT * INTO new_users FROM users') + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + type: TABLE_EVENT_ACTIONS.TableCreated, + schema: undefined, + tableName: 'new_users', + }) + }) + + it('detects SELECT INTO with schema', () => { + const results = sqlEventParser.getTableEvents( + 'SELECT id, name INTO public.new_users FROM users' + ) + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + type: TABLE_EVENT_ACTIONS.TableCreated, + schema: 'public', + tableName: 'new_users', + }) + }) + + it('detects CREATE TABLE AS SELECT', () => { + const results = sqlEventParser.getTableEvents('CREATE TABLE new_users AS SELECT * FROM users') + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + type: TABLE_EVENT_ACTIONS.TableCreated, + schema: undefined, + tableName: 'new_users', + }) + }) + + it('detects CREATE TABLE IF NOT EXISTS AS SELECT', () => { + const results = sqlEventParser.getTableEvents( + 'CREATE TABLE IF NOT EXISTS new_users AS SELECT * FROM users WHERE active = true' + ) + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + type: TABLE_EVENT_ACTIONS.TableCreated, + schema: undefined, + tableName: 'new_users', + }) + }) + + it('handles quoted identifiers', () => { + const results = sqlEventParser.getTableEvents( + 'SELECT * INTO "backup"."users_2024" FROM users' + ) + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + type: TABLE_EVENT_ACTIONS.TableCreated, + schema: 'backup', + tableName: 'users_2024', + }) + }) + + it('returns empty array for regular SELECT', () => { + const results = sqlEventParser.getTableEvents('SELECT * FROM users') + expect(results).toHaveLength(0) + }) + }) + + describe('RLS detection', () => { + it('detects ALTER TABLE ENABLE ROW LEVEL SECURITY', () => { + const results = sqlEventParser.getTableEvents('ALTER TABLE users ENABLE ROW LEVEL SECURITY') + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + type: TABLE_EVENT_ACTIONS.TableRLSEnabled, + schema: undefined, + tableName: 'users', + }) + }) + + it('detects short form ENABLE RLS', () => { + const results = sqlEventParser.getTableEvents('ALTER TABLE users ENABLE RLS') + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + type: TABLE_EVENT_ACTIONS.TableRLSEnabled, + schema: undefined, + tableName: 'users', + }) + }) + + it('detects with schema', () => { + const results = sqlEventParser.getTableEvents( + 'ALTER TABLE public.users ENABLE ROW LEVEL SECURITY' + ) + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + type: TABLE_EVENT_ACTIONS.TableRLSEnabled, + schema: 'public', + tableName: 'users', + }) + }) + + it('handles other ALTER TABLE statements in between', () => { + const results = sqlEventParser.getTableEvents( + 'ALTER TABLE users ADD COLUMN test INT, ENABLE ROW LEVEL SECURITY' + ) + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + type: TABLE_EVENT_ACTIONS.TableRLSEnabled, + schema: undefined, + tableName: 'users', + }) + }) + + it('returns empty array for disabling RLS', () => { + const results = sqlEventParser.getTableEvents('ALTER TABLE users DISABLE ROW LEVEL SECURITY') + expect(results).toHaveLength(0) + }) + }) + + describe('ReDoS protection', () => { + it('handles extremely long identifier names efficiently', () => { + const longIdentifier = 'a'.repeat(10000) + const sql = `CREATE TABLE ${longIdentifier} (id INT)` + + const startTime = Date.now() + const results = sqlEventParser.getTableEvents(sql) + const duration = Date.now() - startTime + + expect(duration).toBeLessThan(100) + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + type: TABLE_EVENT_ACTIONS.TableCreated, + schema: undefined, + tableName: longIdentifier, + }) + }) + + it('handles nested dots in schema names without catastrophic backtracking', () => { + const maliciousInput = 'a.'.repeat(1000) + 'table' + const sql = `CREATE TABLE ${maliciousInput} (id INT)` + + const startTime = Date.now() + const results = sqlEventParser.getTableEvents(sql) + const duration = Date.now() - startTime + + expect(duration).toBeLessThan(100) + expect(results.length).toBeGreaterThan(0) + }) + + it('handles pathological SELECT INTO patterns', () => { + const maliciousSQL = 'SELECT ' + 'a '.repeat(1000) + 'INTO table FROM users' + + const startTime = Date.now() + const results = sqlEventParser.getTableEvents(maliciousSQL) + const duration = Date.now() - startTime + + expect(duration).toBeLessThan(100) + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + type: TABLE_EVENT_ACTIONS.TableCreated, + schema: undefined, + tableName: 'table', + }) + }) + + it('handles ALTER TABLE with many operations between', () => { + const manyOperations = 'ADD COLUMN test INT, '.repeat(100) + const sql = `ALTER TABLE users ${manyOperations} ENABLE ROW LEVEL SECURITY` + + const startTime = Date.now() + const results = sqlEventParser.getTableEvents(sql) + const duration = Date.now() - startTime + + expect(duration).toBeLessThan(100) + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + type: TABLE_EVENT_ACTIONS.TableRLSEnabled, + schema: undefined, + tableName: 'users', + }) + }) + + it('handles mixed quotes and backticks efficiently', () => { + const mixedQuotes = '`"`.'.repeat(100) + 'tablename' + const sql = `CREATE TABLE ${mixedQuotes} (id INT)` + + const startTime = Date.now() + sqlEventParser.getTableEvents(sql) + const duration = Date.now() - startTime + + expect(duration).toBeLessThan(100) + }) + }) + + describe('Edge cases and special characters', () => { + it('handles Unicode identifiers', () => { + const sql = 'CREATE TABLE 用户表 (id INT)' + const results = sqlEventParser.getTableEvents(sql) + expect(results).toHaveLength(0) + }) + + it('handles identifiers with numbers', () => { + const sql = 'CREATE TABLE table123 (id INT)' + const results = sqlEventParser.getTableEvents(sql) + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + type: TABLE_EVENT_ACTIONS.TableCreated, + schema: undefined, + tableName: 'table123', + }) + }) + + it('handles identifiers with underscores', () => { + const sql = 'CREATE TABLE user_accounts (id INT)' + const results = sqlEventParser.getTableEvents(sql) + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + type: TABLE_EVENT_ACTIONS.TableCreated, + schema: undefined, + tableName: 'user_accounts', + }) + }) + + it('handles escaped quotes in identifiers', () => { + const sql = 'CREATE TABLE "user""table" (id INT)' + const results = sqlEventParser.getTableEvents(sql) + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + type: TABLE_EVENT_ACTIONS.TableCreated, + schema: undefined, + tableName: 'usertable', + }) + }) + + it('handles dollar-quoted strings in SQL', () => { + const sql = ` + CREATE TABLE users (id INT); + INSERT INTO logs VALUES ($$CREATE TABLE fake$$); + INSERT INTO users VALUES (1); + ` + const results = sqlEventParser.getTableEvents(sql) + expect(results).toHaveLength(3) + expect(results[0].type).toBe(TABLE_EVENT_ACTIONS.TableCreated) + expect(results[0]).toMatchObject({ tableName: 'users' }) + expect(results[1].type).toBe(TABLE_EVENT_ACTIONS.TableCreated) + expect(results[1]).toMatchObject({ tableName: 'fake' }) + expect(results[2].type).toBe(TABLE_EVENT_ACTIONS.TableDataAdded) + }) + + it('handles SQL injection attempts safely', () => { + const sql = "CREATE TABLE users'; DROP TABLE users; -- (id INT)" + const results = sqlEventParser.getTableEvents(sql) + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + type: TABLE_EVENT_ACTIONS.TableCreated, + schema: undefined, + tableName: 'users', + }) + }) + }) + + describe('getTableEvents', () => { + it('filters only table-related events', () => { + const sql = ` + CREATE TABLE users (id INT); + CREATE FUNCTION test() RETURNS INT AS $$ BEGIN RETURN 1; END; $$ LANGUAGE plpgsql; + INSERT INTO users (id) VALUES (1); + ALTER TABLE users ENABLE RLS; + CREATE VIEW user_view AS SELECT * FROM users; + ` + const results = sqlEventParser.getTableEvents(sql) + expect(results).toHaveLength(3) + expect(results.map((r) => r.type)).toEqual([ + TABLE_EVENT_ACTIONS.TableCreated, + TABLE_EVENT_ACTIONS.TableDataAdded, + TABLE_EVENT_ACTIONS.TableRLSEnabled, + ]) + }) + + it('returns empty array for non-table SQL', () => { + const sql = ` + CREATE FUNCTION test() RETURNS INT AS $$ BEGIN RETURN 1; END; $$ LANGUAGE plpgsql; + CREATE VIEW user_view AS SELECT * FROM users; + SELECT * FROM users; + ` + const results = sqlEventParser.getTableEvents(sql) + expect(results).toHaveLength(0) + }) + }) +}) diff --git a/apps/studio/lib/sql-event-parser.ts b/apps/studio/lib/sql-event-parser.ts new file mode 100644 index 00000000000..dece7fd5c85 --- /dev/null +++ b/apps/studio/lib/sql-event-parser.ts @@ -0,0 +1,128 @@ +/** + * 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+)?(?(?:"[^"]+"|[\w]+)\.)?(?[\w"`]+)/i, + /CREATE\s+TEMP(?:ORARY)?\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?(?:"[^"]+"|[\w]+)\.)?(?
[\w"`]+)/i, + /CREATE\s+UNLOGGED\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?(?:"[^"]+"|[\w]+)\.)?(?
[\w"`]+)/i, + /SELECT\s+.*?\s+INTO\s+(?(?:"[^"]+"|[\w]+)\.)?(?
[\w"`]+)/is, + /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?(?:"[^"]+"|[\w]+)\.)?(?
[\w"`]+)\s+AS\s+SELECT/i, + ], + }, + { + type: TABLE_EVENT_ACTIONS.TableDataAdded, + patterns: [ + /INSERT\s+INTO\s+(?(?:"[^"]+"|[\w]+)\.)?(?
[\w"`]+)/i, + /COPY\s+(?(?:"[^"]+"|[\w]+)\.)?(?
[\w"`]+)\s+FROM/i, + ], + }, + { + type: TABLE_EVENT_ACTIONS.TableRLSEnabled, + patterns: [ + /ALTER\s+TABLE\s+(?(?:"[^"]+"|[\w]+)\.)?(?
[\w"`]+).*?ENABLE\s+ROW\s+LEVEL\s+SECURITY/i, + /ALTER\s+TABLE\s+(?(?:"[^"]+"|[\w]+)\.)?(?
[\w"`]+).*?ENABLE\s+RLS/i, + ], + }, + ] + + private cleanIdentifier(identifier?: string) { + return identifier?.replace(/["`']/g, '').replace(/\.$/, '') + } + + 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() + 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[] { + const statements = this.splitStatements(this.removeComments(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() diff --git a/packages/common/telemetry-constants.ts b/packages/common/telemetry-constants.ts index 4b1f69133b7..f12911ecde7 100644 --- a/packages/common/telemetry-constants.ts +++ b/packages/common/telemetry-constants.ts @@ -14,6 +14,16 @@ type TelemetryGroups = { organization: string } +export const TABLE_EVENT_ACTIONS = { + TableCreated: 'table_created', + TableDataAdded: 'table_data_added', + TableRLSEnabled: 'table_rls_enabled', +} as const + +export type TableEventAction = (typeof TABLE_EVENT_ACTIONS)[keyof typeof TABLE_EVENT_ACTIONS] + +export const TABLE_EVENT_VALUES: TableEventAction[] = Object.values(TABLE_EVENT_ACTIONS) + /** * Triggered when a user signs up. When signing up with Email and Password, this is only triggered once user confirms their email. * @@ -1744,6 +1754,84 @@ export interface HipaaRequestButtonClickedEvent { groups: Omit } +/** + * User successfully created a table in the project. + * + * @group Events + * @source studio + * @page /dashboard/project/{ref}/editor or /dashboard/project/{ref}/sql + */ +export interface TableCreatedEvent { + action: 'table_created' + properties: { + /** + * Method used to create the table + */ + method: 'sql_editor' | 'table_editor' + /** + * Schema where table was created + */ + schema_name?: string + /** + * Name of the table created + */ + table_name?: string + } + groups: Partial +} + +/** + * User successfully added data to a table. + * + * @group Events + * @source studio + * @page /dashboard/project/{ref}/editor or /dashboard/project/{ref}/sql + */ +export interface TableDataAddedEvent { + action: 'table_data_added' + properties: { + /** + * Method used to insert data + */ + method: 'sql_editor' | 'table_editor' | 'spreadsheet_import' + /** + * Schema of the table + */ + schema_name?: string + /** + * Name of the table + */ + table_name?: string + } + groups: Partial +} + +/** + * User successfully enabled RLS on a table. + * + * @group Events + * @source studio + * @page /dashboard/project/{ref}/editor or /dashboard/project/{ref}/sql + */ +export interface TableRLSEnabledEvent { + action: 'table_rls_enabled' + properties: { + /** + * Method used to enable RLS + */ + method: 'sql_editor' | 'table_editor' + /** + * Schema of the table + */ + schema_name?: string + /** + * Name of the table + */ + table_name?: string + } + groups: Partial +} + /** * @hidden */ @@ -1851,3 +1939,6 @@ export type TelemetryEvent = | DpaRequestButtonClickedEvent | DocumentViewButtonClickedEvent | HipaaRequestButtonClickedEvent + | TableCreatedEvent + | TableDataAddedEvent + | TableRLSEnabledEvent