SQLEditor.utils.ts263 lines · main
| 1 | import { untrustedSql, type SafeSqlFragment } from '@supabase/pg-meta' |
| 2 | import { TABLE_EVENT_ACTIONS } from 'common/telemetry-constants' |
| 3 | |
| 4 | import { |
| 5 | alterDatabasePreventConnectionStatements, |
| 6 | destructiveSqlRegex, |
| 7 | NEW_SQL_SNIPPET_SKELETON, |
| 8 | sqlAiDisclaimerComment, |
| 9 | updateWithoutWhereRegex, |
| 10 | } from './SQLEditor.constants' |
| 11 | import { ContentDiff } from './SQLEditor.types' |
| 12 | import type { DatabaseEventTrigger } from '@/data/database-event-triggers/database-event-triggers-query' |
| 13 | import { generateUuid } from '@/lib/api/snippets.browser' |
| 14 | import { removeCommentsFromSql } from '@/lib/helpers' |
| 15 | import { sqlEventParser } from '@/lib/sql-event-parser' |
| 16 | import type { SnippetWithContent } from '@/state/sql-editor-v2' |
| 17 | |
| 18 | export type CreateTableWithoutRLS = { |
| 19 | schema?: string |
| 20 | tableName: string |
| 21 | } |
| 22 | |
| 23 | // The ensure_rls event trigger only auto-enables RLS on tables in the public |
| 24 | // schema (see AUTO_ENABLE_RLS_EVENT_TRIGGER_SQL). |
| 25 | const ENSURE_RLS_TRIGGER_SCHEMAS = new Set(['public']) |
| 26 | |
| 27 | export function hasActiveEnsureRLSTrigger(triggers: DatabaseEventTrigger[] | undefined) { |
| 28 | return ( |
| 29 | triggers?.some( |
| 30 | (t) => |
| 31 | (t.name === 'ensure_rls' || t.function_name === 'rls_auto_enable') && |
| 32 | t.enabled_mode !== 'DISABLED' |
| 33 | ) ?? false |
| 34 | ) |
| 35 | } |
| 36 | |
| 37 | /** |
| 38 | * Filters out CREATE TABLE entries that will be covered by the project's |
| 39 | * ensure_rls event trigger (which only handles tables in the public schema). |
| 40 | * Tables in any other schema are returned unchanged so the user is still warned. |
| 41 | */ |
| 42 | export function filterTablesCoveredByEnsureRLSTrigger( |
| 43 | tables: CreateTableWithoutRLS[], |
| 44 | hasTrigger: boolean |
| 45 | ): CreateTableWithoutRLS[] { |
| 46 | if (!hasTrigger) return tables |
| 47 | return tables.filter((t) => !ENSURE_RLS_TRIGGER_SCHEMAS.has((t.schema ?? 'public').toLowerCase())) |
| 48 | } |
| 49 | |
| 50 | export const createSqlSnippetSkeletonV2 = ({ |
| 51 | name, |
| 52 | sql, |
| 53 | owner_id, |
| 54 | project_id, |
| 55 | folder_id, |
| 56 | idOverride, |
| 57 | }: { |
| 58 | name: string |
| 59 | sql: string |
| 60 | owner_id: number |
| 61 | project_id: number |
| 62 | folder_id?: string |
| 63 | /** |
| 64 | * Optionally, provide a specific snippetId to use for the snippet. This is used to ensure the snippet is created |
| 65 | * with a known id, such as to prevent flicker in the SQL editor when adding new unsaved snippets. |
| 66 | */ |
| 67 | idOverride?: string |
| 68 | }): SnippetWithContent => { |
| 69 | const id = idOverride ?? generateUuid([folder_id, `${name}.sql`]) |
| 70 | |
| 71 | return { |
| 72 | ...NEW_SQL_SNIPPET_SKELETON, |
| 73 | id, |
| 74 | owner_id, |
| 75 | project_id, |
| 76 | name, |
| 77 | folder_id, |
| 78 | favorite: false, |
| 79 | inserted_at: new Date().toISOString(), |
| 80 | updated_at: new Date().toISOString(), |
| 81 | content: { |
| 82 | ...NEW_SQL_SNIPPET_SKELETON.content, |
| 83 | content_id: id ?? '', |
| 84 | unchecked_sql: untrustedSql(sql ?? ''), |
| 85 | } as any, |
| 86 | isNotSavedInDatabaseYet: true, |
| 87 | } |
| 88 | } |
| 89 | |
| 90 | export function checkDestructiveQuery(sql: string) { |
| 91 | const cleanedSql = removeCommentsFromSql(sql) |
| 92 | return destructiveSqlRegex.some((regex) => regex.test(cleanedSql)) |
| 93 | } |
| 94 | |
| 95 | // Replace the contents of single-quoted string literals and double-quoted |
| 96 | // identifiers with empty quotes, so a downstream `where` scan can't be fooled |
| 97 | // by tokens like `UPDATE "where table" SET ...` or `SET name = 'where x'`. |
| 98 | // Postgres uses doubled quotes to escape, so `''` and `""` are matched as |
| 99 | // part of the same span rather than terminating it. |
| 100 | const stripQuotedSpans = (sql: string) => |
| 101 | sql.replace(/'(?:''|[^'])*'/g, "''").replace(/"(?:""|[^"])*"/g, '""') |
| 102 | |
| 103 | // Function to check for UPDATE queries without WHERE clause |
| 104 | export function isUpdateWithoutWhere(sql: string): boolean { |
| 105 | const updateStatements = sql |
| 106 | .split(';') |
| 107 | .filter((statement) => statement.trim().toLowerCase().startsWith('update')) |
| 108 | return updateStatements.some( |
| 109 | (statement) => |
| 110 | updateWithoutWhereRegex.test(statement) && !/where\s/i.test(stripQuotedSpans(statement)) |
| 111 | ) |
| 112 | } |
| 113 | |
| 114 | /** |
| 115 | * Returns CREATE TABLE statements in `sql` that do not have a matching |
| 116 | * ALTER TABLE ... ENABLE ROW LEVEL SECURITY in the same SQL submission. |
| 117 | * |
| 118 | * Operates on the SQL passed in (which is the user's selection if any, or the |
| 119 | * full editor contents otherwise) so partial-execution selects work naturally. |
| 120 | */ |
| 121 | export function getCreateTablesMissingRLS(sql: string): CreateTableWithoutRLS[] { |
| 122 | const events = sqlEventParser.getTableEvents(sql) |
| 123 | |
| 124 | // Match case-sensitively. Lowercasing would let quoted identifiers like |
| 125 | // "MyTable" and "mytable" — which are different tables in Postgres — collide |
| 126 | // and silently suppress the warning. The trade-off is rare false positives |
| 127 | // when users mix case for *unquoted* identifiers (Postgres would have folded |
| 128 | // them anyway), which is annoying but safe. |
| 129 | const key = (e: { schema?: string; tableName?: string }) => `${e.schema ?? ''}.${e.tableName}` |
| 130 | |
| 131 | const rlsEnabled = new Set( |
| 132 | events.filter((e) => e.type === TABLE_EVENT_ACTIONS.TableRLSEnabled && e.tableName).map(key) |
| 133 | ) |
| 134 | |
| 135 | return events |
| 136 | .filter((e) => e.type === TABLE_EVENT_ACTIONS.TableCreated && e.tableName) |
| 137 | .filter((e) => !rlsEnabled.has(key(e))) |
| 138 | .map((e) => ({ schema: e.schema, tableName: e.tableName as string })) |
| 139 | } |
| 140 | |
| 141 | /** |
| 142 | * Appends `ALTER TABLE ... ENABLE ROW LEVEL SECURITY` statements to `sql` |
| 143 | * for each provided table. |
| 144 | */ |
| 145 | export function appendEnableRLSStatements(sql: string, tables: CreateTableWithoutRLS[]) { |
| 146 | if (tables.length === 0) return sql |
| 147 | |
| 148 | // Postgres folds unquoted identifiers to lowercase, so any identifier that |
| 149 | // isn't strictly lowercase-safe (e.g. "MyTable", "user table") must be quoted |
| 150 | // to refer back to the original table. |
| 151 | const quote = (identifier: string) => |
| 152 | /^[a-z_][a-z0-9_]*$/.test(identifier) ? identifier : `"${identifier.replace(/"/g, '""')}"` |
| 153 | |
| 154 | const additions = tables |
| 155 | .map(({ schema, tableName }) => { |
| 156 | const target = schema ? `${quote(schema)}.${quote(tableName)}` : quote(tableName) |
| 157 | return `ALTER TABLE ${target} ENABLE ROW LEVEL SECURITY;` |
| 158 | }) |
| 159 | .join('\n') |
| 160 | |
| 161 | const trimmed = sql.replace(/\s+$/, '') |
| 162 | // If the SQL ends with a line comment, the appended ';' would be swallowed, |
| 163 | // so put the terminator on its own line. |
| 164 | const endsWithLineComment = /--[^\r\n]*$/.test(trimmed) |
| 165 | const separator = trimmed.endsWith(';') ? '\n\n' : endsWithLineComment ? '\n;\n\n' : ';\n\n' |
| 166 | |
| 167 | return `${trimmed}${separator}-- Added by Briven: enable Row Level Security on newly created tables\n${additions}\n` |
| 168 | } |
| 169 | |
| 170 | export function checkAlterDatabaseConnection(sql: string): boolean { |
| 171 | const cleanedSql = removeCommentsFromSql(sql) |
| 172 | const statements = cleanedSql |
| 173 | .split(';') |
| 174 | .filter((statement) => statement.trim().toLowerCase().startsWith('alter database')) |
| 175 | return statements.some((statement) => |
| 176 | alterDatabasePreventConnectionStatements.some((x) => statement.toLowerCase().includes(x)) |
| 177 | ) |
| 178 | } |
| 179 | |
| 180 | export const generateMigrationCliCommand = (id: string, name: string, isNpx = false) => |
| 181 | ` |
| 182 | ${isNpx ? 'npx ' : ''}briven snippets download ${id} | |
| 183 | ${isNpx ? 'npx ' : ''}briven migration new ${name} |
| 184 | `.trim() |
| 185 | |
| 186 | export const generateSeedCliCommand = (id: string, isNpx = false) => |
| 187 | ` |
| 188 | ${isNpx ? 'npx ' : ''}briven snippets download ${id} >> \\ |
| 189 | briven/seed.sql |
| 190 | `.trim() |
| 191 | |
| 192 | export const generateFileCliCommand = (id: string, name: string, isNpx = false) => |
| 193 | ` |
| 194 | ${isNpx ? 'npx ' : ''}briven snippets download ${id} > \\ |
| 195 | ${name}.sql |
| 196 | `.trim() |
| 197 | |
| 198 | export const compareAsModification = (sqlDiff: ContentDiff) => { |
| 199 | const formattedModified = sqlDiff.modified.replace(sqlAiDisclaimerComment, '').trim() |
| 200 | |
| 201 | return { |
| 202 | original: sqlDiff.original, |
| 203 | modified: `${formattedModified}`, |
| 204 | } |
| 205 | } |
| 206 | |
| 207 | export const compareAsAddition = (sqlDiff: ContentDiff) => { |
| 208 | const formattedOriginal = sqlDiff.original.replace(sqlAiDisclaimerComment, '').trim() |
| 209 | const formattedModified = sqlDiff.modified.replace(sqlAiDisclaimerComment, '').trim() |
| 210 | const newModified = (formattedOriginal ? formattedOriginal + '\n\n' : '') + formattedModified |
| 211 | |
| 212 | return { |
| 213 | original: sqlDiff.original, |
| 214 | modified: newModified, |
| 215 | } |
| 216 | } |
| 217 | |
| 218 | export const compareAsNewSnippet = (sqlDiff: ContentDiff) => { |
| 219 | return { |
| 220 | original: '', |
| 221 | modified: sqlDiff.modified, |
| 222 | } |
| 223 | } |
| 224 | |
| 225 | // [Joshen] Just FYI as well the checks here on whether to append limit is quite restricted |
| 226 | // This is to prevent dashboard from accidentally appending limit to the end of a query |
| 227 | // thats not supposed to have any, since there's too many cases to cover. |
| 228 | // We can however look into making this logic better in the future |
| 229 | // i.e It's harder to append the limit param, than just leaving the query as it is |
| 230 | // Otherwise we'd need a full on parser to do this properly |
| 231 | export const checkIfAppendLimitRequired = (sql: string, limit: number = 0) => { |
| 232 | // Remove lines and whitespaces to use for checking |
| 233 | const cleanedSql = sql.trim().replaceAll('\n', ' ').replaceAll(/\s+/g, ' ') |
| 234 | |
| 235 | // Check how many queries |
| 236 | const regMatch = cleanedSql.matchAll(/[a-zA-Z]*[0-9]*[;]+/g) |
| 237 | const queries = new Array(...regMatch) |
| 238 | const indexSemiColon = cleanedSql.lastIndexOf(';') |
| 239 | const hasComments = cleanedSql.includes('--') |
| 240 | const hasMultipleQueries = |
| 241 | queries.length > 1 || (indexSemiColon > 0 && indexSemiColon !== cleanedSql.length - 1) |
| 242 | |
| 243 | // Check if need to auto limit rows |
| 244 | const appendAutoLimit = |
| 245 | limit > 0 && |
| 246 | !hasComments && |
| 247 | !hasMultipleQueries && |
| 248 | cleanedSql.toLowerCase().startsWith('select') && |
| 249 | !cleanedSql.toLowerCase().match(/fetch\s+first/i) && |
| 250 | !cleanedSql.match(/limit$/i) && |
| 251 | !cleanedSql.match(/limit;$/i) && |
| 252 | !cleanedSql.match(/limit [0-9]* offset [0-9]*[;]?$/i) && |
| 253 | !cleanedSql.match(/limit [0-9]*[;]?$/i) |
| 254 | return { cleanedSql, appendAutoLimit } |
| 255 | } |
| 256 | |
| 257 | export const suffixWithLimit = (sql: SafeSqlFragment, limit: number = 0): SafeSqlFragment => { |
| 258 | const { cleanedSql, appendAutoLimit } = checkIfAppendLimitRequired(sql, limit) |
| 259 | if (!appendAutoLimit) return sql |
| 260 | return ( |
| 261 | cleanedSql.endsWith(';') ? sql.replace(/[;]+$/, ` limit ${limit};`) : `${sql} limit ${limit};` |
| 262 | ) as SafeSqlFragment |
| 263 | } |