SQLEditor.utils.ts 9.6 KB

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