mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
feat: add table activation tracking (#39090)
Add telemetry tracking for activation-related table operations - Implement SQL event parser to detect table creation, data insertion, and RLS enablement - Add telemetry tracking for these operations in table editor as well - Add test coverage for SQL event parser
This commit is contained in:
1 parent
9ba9c08ef4
commit
e978a084b2
8 files changed
+817
No files matched your search
@@ -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 (
|
||||
|
||||
@@ -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<boolean>(false)
|
||||
const [isClosingPanel, setIsClosingPanel] = useState<boolean>(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) {
|
||||
|
||||
+67
@@ -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,
|
||||
|
||||
@@ -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<ExecuteSqlData, QueryResponseError, ExecuteSqlVariables>(
|
||||
(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 =
|
||||
|
||||
@@ -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<TableRowCreateData, ResponseError, TableRowCreateVariables>(
|
||||
(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)
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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+)?(?<schema>(?:"[^"]+"|[\w]+)\.)?(?<table>[\w"`]+)/i,
|
||||
/CREATE\s+TEMP(?:ORARY)?\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?<schema>(?:"[^"]+"|[\w]+)\.)?(?<table>[\w"`]+)/i,
|
||||
/CREATE\s+UNLOGGED\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?<schema>(?:"[^"]+"|[\w]+)\.)?(?<table>[\w"`]+)/i,
|
||||
/SELECT\s+.*?\s+INTO\s+(?<schema>(?:"[^"]+"|[\w]+)\.)?(?<table>[\w"`]+)/is,
|
||||
/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?<schema>(?:"[^"]+"|[\w]+)\.)?(?<table>[\w"`]+)\s+AS\s+SELECT/i,
|
||||
],
|
||||
},
|
||||
{
|
||||
type: TABLE_EVENT_ACTIONS.TableDataAdded,
|
||||
patterns: [
|
||||
/INSERT\s+INTO\s+(?<schema>(?:"[^"]+"|[\w]+)\.)?(?<table>[\w"`]+)/i,
|
||||
/COPY\s+(?<schema>(?:"[^"]+"|[\w]+)\.)?(?<table>[\w"`]+)\s+FROM/i,
|
||||
],
|
||||
},
|
||||
{
|
||||
type: TABLE_EVENT_ACTIONS.TableRLSEnabled,
|
||||
patterns: [
|
||||
/ALTER\s+TABLE\s+(?<schema>(?:"[^"]+"|[\w]+)\.)?(?<table>[\w"`]+).*?ENABLE\s+ROW\s+LEVEL\s+SECURITY/i,
|
||||
/ALTER\s+TABLE\s+(?<schema>(?:"[^"]+"|[\w]+)\.)?(?<table>[\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<string>()
|
||||
return events.filter((e) => {
|
||||
const key = `${e.type}:${e.schema || ''}:${e.tableName || ''}`
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
private removeComments(sql: string): string {
|
||||
return sql
|
||||
.replace(/--.*?$/gm, '') // line comments
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '') // block comments
|
||||
}
|
||||
|
||||
getTableEvents(sql: string): TableEventDetails[] {
|
||||
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()
|
||||
@@ -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<TelemetryGroups, 'project'>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<TelemetryGroups>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<TelemetryGroups>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<TelemetryGroups>
|
||||
}
|
||||
|
||||
/**
|
||||
* @hidden
|
||||
*/
|
||||
@@ -1851,3 +1939,6 @@ export type TelemetryEvent =
|
||||
| DpaRequestButtonClickedEvent
|
||||
| DocumentViewButtonClickedEvent
|
||||
| HipaaRequestButtonClickedEvent
|
||||
| TableCreatedEvent
|
||||
| TableDataAddedEvent
|
||||
| TableRLSEnabledEvent
|
||||
Reference in new issue
Block a user