SidePanelEditor.utils.tsx 38 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255
  1. import * as Sentry from '@sentry/nextjs'
  2. import pgMeta, {
  3. getAddForeignKeySQL,
  4. getAddPrimaryKeySQL,
  5. getDropConstraintSQL,
  6. getDuplicateIdentitySequenceSQL,
  7. getDuplicateRowsSQL,
  8. getDuplicateTableSQL,
  9. getEnableRLSSQL,
  10. getRemoveForeignKeySQL,
  11. getUpdateIdentitySequenceSQL,
  12. type ForeignKey,
  13. } from '@supabase/pg-meta'
  14. import type { PGTablePrimaryKey } from '@supabase/pg-meta'
  15. import { joinSqlFragments, safeSql, type SafeSqlFragment } from '@supabase/pg-meta/src/pg-format'
  16. import { Query } from '@supabase/pg-meta/src/query'
  17. import { chunk, find, isEmpty, isEqual } from 'lodash'
  18. import Papa from 'papaparse'
  19. import { toast } from 'sonner'
  20. import {
  21. generateCreateColumnPayload,
  22. generateUpdateColumnPayload,
  23. } from './ColumnEditor/ColumnEditor.utils'
  24. import type { ColumnField, CreateColumnPayload, UpdateColumnPayload } from './SidePanelEditor.types'
  25. import { checkIfRelationChanged } from './TableEditor/ForeignKeysManagement/ForeignKeysManagement.utils'
  26. import type { ImportContent } from './TableEditor/TableEditor.types'
  27. import type { SupaRow } from '@/components/grid/types'
  28. import { type AcceptedGeneratedPolicy } from '@/components/interfaces/Auth/Policies/Policies.utils'
  29. import SparkBar from '@/components/ui/SparkBar'
  30. import { createDatabaseColumn } from '@/data/database-columns/database-column-create-mutation'
  31. import { deleteDatabaseColumn } from '@/data/database-columns/database-column-delete-mutation'
  32. import { updateDatabaseColumn } from '@/data/database-columns/database-column-update-mutation'
  33. import { createDatabasePolicy } from '@/data/database-policies/database-policy-create-mutation'
  34. import type { Constraint } from '@/data/database/constraints-query'
  35. import { ForeignKeyConstraint } from '@/data/database/foreign-key-constraints-query'
  36. import { databaseKeys } from '@/data/database/keys'
  37. import { entityTypeKeys } from '@/data/entity-types/keys'
  38. import { lintKeys } from '@/data/lint/keys'
  39. import { prefetchEditorTablePage } from '@/data/prefetchers/project.$ref.editor.$id'
  40. import { getQueryClient } from '@/data/query-client'
  41. import { executeSql } from '@/data/sql/execute-sql-query'
  42. import { tableEditorKeys } from '@/data/table-editor/keys'
  43. import { prefetchTableEditor } from '@/data/table-editor/table-editor-query'
  44. import { tableRowKeys } from '@/data/table-rows/keys'
  45. import { executeWithRetry } from '@/data/table-rows/table-rows-query'
  46. import { tableKeys } from '@/data/tables/keys'
  47. import { getTable, getTableQuery, RetrieveTableResult } from '@/data/tables/table-retrieve-query'
  48. import {
  49. UpdateTableBody,
  50. updateTable as updateTableMutation,
  51. } from '@/data/tables/table-update-mutation'
  52. import { getTables } from '@/data/tables/tables-query'
  53. import { sendEvent } from '@/data/telemetry/send-event-mutation'
  54. import { isObject, isObjectContainingKeys, timeout, tryParseJson } from '@/lib/helpers'
  55. import type { SafePostgresColumn } from '@/lib/postgres-types'
  56. import type { DeepReadonly } from '@/lib/type-helpers'
  57. import type { SidePanel } from '@/state/table-editor'
  58. const BATCH_SIZE = 1000
  59. const CHUNK_SIZE = 1024 * 1024 * 0.1 // 0.1MB
  60. /**
  61. * Extracts the row data from the current side panel state.
  62. * Used when queuing cell edit operations to get the row being edited.
  63. * Accepts both mutable and readonly (valtio snapshot) versions of SidePanel.
  64. *
  65. * @param sidePanel - The current side panel state (can be readonly from valtio snapshot)
  66. * @returns The row data if available, undefined otherwise
  67. */
  68. export function getRowFromSidePanel(
  69. sidePanel: SidePanel | DeepReadonly<SidePanel> | undefined
  70. ): SupaRow | undefined {
  71. if (!sidePanel) return undefined
  72. switch (sidePanel.type) {
  73. case 'json':
  74. return sidePanel.jsonValue.row as SupaRow | undefined
  75. case 'cell':
  76. return sidePanel.value?.row as SupaRow | undefined
  77. case 'row':
  78. return sidePanel.row as SupaRow | undefined
  79. case 'foreign-row-selector':
  80. return sidePanel.foreignKey.row as SupaRow | undefined
  81. default:
  82. return undefined
  83. }
  84. }
  85. const addPrimaryKey = async (
  86. projectRef: string,
  87. connectionString: string | undefined | null,
  88. schema: string,
  89. table: string,
  90. columns: string[]
  91. ) => {
  92. const query = getAddPrimaryKeySQL({ schema, table, columns })
  93. return await executeSql({
  94. projectRef: projectRef,
  95. connectionString: connectionString,
  96. sql: query,
  97. queryKey: ['primary-keys'],
  98. })
  99. }
  100. const dropConstraint = async (
  101. projectRef: string,
  102. connectionString: string | undefined | null,
  103. schema: string,
  104. table: string,
  105. name: string
  106. ) => {
  107. const query = getDropConstraintSQL({ schema, table, name })
  108. return await executeSql({
  109. projectRef: projectRef,
  110. connectionString: connectionString,
  111. sql: query,
  112. queryKey: ['drop-constraint'],
  113. })
  114. }
  115. const addForeignKey = async ({
  116. projectRef,
  117. connectionString,
  118. table,
  119. foreignKeys,
  120. }: {
  121. projectRef: string
  122. connectionString?: string | null
  123. table: { schema: string; name: string }
  124. foreignKeys: ForeignKey[]
  125. }) => {
  126. const query = getAddForeignKeySQL({ table, foreignKeys })
  127. return await executeSql({
  128. projectRef: projectRef,
  129. connectionString: connectionString,
  130. sql: query,
  131. queryKey: ['foreign-keys'],
  132. })
  133. }
  134. const removeForeignKey = async ({
  135. projectRef,
  136. connectionString,
  137. table,
  138. foreignKeys,
  139. }: {
  140. projectRef: string
  141. connectionString?: string | null
  142. table: { schema: string; name: string }
  143. foreignKeys: ForeignKey[]
  144. }) => {
  145. const query = getRemoveForeignKeySQL({ table, foreignKeys })
  146. return await executeSql({
  147. projectRef: projectRef,
  148. connectionString: connectionString,
  149. sql: query,
  150. queryKey: ['foreign-keys'],
  151. })
  152. }
  153. const updateForeignKey = async ({
  154. projectRef,
  155. connectionString,
  156. table,
  157. foreignKeys,
  158. }: {
  159. projectRef: string
  160. connectionString?: string | null
  161. table: { schema: string; name: string }
  162. foreignKeys: ForeignKey[]
  163. }) => {
  164. const query = safeSql`${getRemoveForeignKeySQL({ table, foreignKeys })} ${getAddForeignKeySQL({ table, foreignKeys })}`
  165. return await executeSql({
  166. projectRef: projectRef,
  167. connectionString: connectionString,
  168. sql: query,
  169. queryKey: ['foreign-keys'],
  170. })
  171. }
  172. /**
  173. * The methods below involve several contexts due to the UI flow of the
  174. * dashboard and hence do not sit within their own stores
  175. */
  176. /** TODO: Refactor to do in a single transaction */
  177. export const createColumn = async ({
  178. projectRef,
  179. connectionString,
  180. payload,
  181. selectedTable,
  182. primaryKey,
  183. foreignKeyRelations = [],
  184. skipSuccessMessage = false,
  185. toastId: _toastId,
  186. }: {
  187. projectRef: string
  188. connectionString?: string | null
  189. payload: CreateColumnPayload
  190. selectedTable: RetrieveTableResult
  191. primaryKey?: Constraint
  192. foreignKeyRelations?: ForeignKey[]
  193. skipSuccessMessage?: boolean
  194. toastId?: string | number
  195. }) => {
  196. const toastId = _toastId ?? toast.loading(`Creating column "${payload.name}"...`)
  197. try {
  198. // Once pg-meta supports composite keys, we can remove this logic
  199. const { isPrimaryKey, ...formattedPayload } = payload
  200. await createDatabaseColumn({
  201. projectRef: projectRef,
  202. connectionString: connectionString,
  203. payload: formattedPayload,
  204. })
  205. // Firing createColumn in createTable will bypass this block
  206. if (isPrimaryKey) {
  207. toast.loading('Assigning primary key to column...', { id: toastId })
  208. // Same logic in createTable: Remove any primary key constraints first (we'll add it back later)
  209. const existingPrimaryKeys = selectedTable.primary_keys.map((x) => x.name)
  210. if (existingPrimaryKeys.length > 0 && primaryKey !== undefined) {
  211. await dropConstraint(
  212. projectRef,
  213. connectionString,
  214. payload.schema,
  215. payload.table,
  216. primaryKey.name
  217. )
  218. }
  219. const primaryKeyColumns = existingPrimaryKeys.concat([formattedPayload.name])
  220. await addPrimaryKey(
  221. projectRef,
  222. connectionString,
  223. payload.schema,
  224. payload.table,
  225. primaryKeyColumns
  226. )
  227. }
  228. // Then add the foreign key constraints here
  229. if (foreignKeyRelations.length > 0) {
  230. await addForeignKey({
  231. projectRef,
  232. connectionString,
  233. table: { schema: payload.schema, name: payload.table },
  234. foreignKeys: foreignKeyRelations,
  235. })
  236. }
  237. if (!skipSuccessMessage) {
  238. toast.success(`Successfully created column "${formattedPayload.name}"`, { id: toastId })
  239. }
  240. return { error: undefined }
  241. } catch (error) {
  242. toast.error(`An error occurred while creating the column "${payload.name}"`, { id: toastId })
  243. return { error }
  244. }
  245. }
  246. /** TODO: Refactor to do in a single transaction */
  247. export const updateColumn = async ({
  248. projectRef,
  249. connectionString,
  250. originalColumn,
  251. payload,
  252. selectedTable,
  253. primaryKey,
  254. foreignKeyRelations = [],
  255. existingForeignKeyRelations = [],
  256. skipPKCreation,
  257. skipSuccessMessage = false,
  258. }: {
  259. projectRef: string
  260. connectionString?: string | null
  261. originalColumn: DeepReadonly<SafePostgresColumn>
  262. payload: UpdateColumnPayload
  263. selectedTable: RetrieveTableResult
  264. primaryKey?: Constraint
  265. foreignKeyRelations?: ForeignKey[]
  266. existingForeignKeyRelations?: ForeignKeyConstraint[]
  267. skipPKCreation?: boolean
  268. skipSuccessMessage?: boolean
  269. }) => {
  270. try {
  271. const { isPrimaryKey, ...formattedPayload } = payload
  272. await updateDatabaseColumn({
  273. projectRef,
  274. connectionString,
  275. originalColumn,
  276. payload: formattedPayload,
  277. })
  278. if (!skipPKCreation && isPrimaryKey !== undefined) {
  279. const existingPrimaryKeys = selectedTable.primary_keys.map((x) => x.name)
  280. // Primary key is getting updated for the column
  281. if (existingPrimaryKeys.length > 0 && primaryKey !== undefined) {
  282. await dropConstraint(
  283. projectRef,
  284. connectionString,
  285. originalColumn.schema,
  286. originalColumn.table,
  287. primaryKey.name
  288. )
  289. }
  290. const columnName = formattedPayload.name ?? originalColumn.name
  291. const primaryKeyColumns = isPrimaryKey
  292. ? existingPrimaryKeys.concat([columnName])
  293. : existingPrimaryKeys.filter((x) => x !== columnName)
  294. if (primaryKeyColumns.length) {
  295. await addPrimaryKey(
  296. projectRef,
  297. connectionString,
  298. originalColumn.schema,
  299. originalColumn.table,
  300. primaryKeyColumns
  301. )
  302. }
  303. }
  304. // Then update foreign keys
  305. if (foreignKeyRelations.length > 0) {
  306. await updateForeignKeys({
  307. projectRef,
  308. connectionString,
  309. table: { schema: originalColumn.schema, name: originalColumn.table },
  310. foreignKeys: foreignKeyRelations,
  311. existingForeignKeyRelations,
  312. })
  313. }
  314. if (!skipSuccessMessage) toast.success(`Successfully updated column "${originalColumn.name}"`)
  315. } catch (error: any) {
  316. return { error }
  317. }
  318. }
  319. /** TODO: Refactor to do in a single transaction */
  320. export const duplicateTable = async (
  321. projectRef: string,
  322. connectionString: string | undefined | null,
  323. payload: { name: string; comment?: string | null },
  324. metadata: {
  325. duplicateTable: RetrieveTableResult
  326. isRLSEnabled: boolean
  327. isDuplicateRows: boolean
  328. foreignKeyRelations: ForeignKey[]
  329. }
  330. ) => {
  331. const queryClient = getQueryClient()
  332. const { duplicateTable, isRLSEnabled, isDuplicateRows, foreignKeyRelations } = metadata
  333. const { name: sourceTableName, schema: sourceTableSchema } = duplicateTable
  334. const duplicatedTableName = payload.name
  335. // The following query will copy the structure of the table along with indexes, constraints and
  336. // triggers. However, foreign key constraints are not duplicated over - has to be done separately
  337. await executeSql({
  338. projectRef,
  339. connectionString,
  340. sql: getDuplicateTableSQL({
  341. sourceTableName,
  342. sourceTableSchema,
  343. duplicatedTableName,
  344. comment: payload.comment,
  345. }),
  346. })
  347. await queryClient.invalidateQueries({ queryKey: tableKeys.list(projectRef, sourceTableSchema) })
  348. // Duplicate foreign key constraints over
  349. if (foreignKeyRelations.length > 0) {
  350. await addForeignKey({
  351. projectRef,
  352. connectionString,
  353. table: { ...duplicateTable, name: payload.name },
  354. foreignKeys: foreignKeyRelations,
  355. })
  356. }
  357. // Duplicate rows if needed
  358. if (isDuplicateRows) {
  359. await executeSql({
  360. projectRef,
  361. connectionString,
  362. sql: getDuplicateRowsSQL({
  363. sourceTableName,
  364. sourceTableSchema,
  365. duplicatedTableName,
  366. }),
  367. })
  368. // Insert into does not copy over auto increment sequences, so we manually do it next if any
  369. const columns = duplicateTable.columns ?? []
  370. const identityColumns = columns.filter((column) => column.identity_generation !== null)
  371. identityColumns.map(async (column) => {
  372. await executeSql({
  373. projectRef,
  374. connectionString,
  375. sql: getDuplicateIdentitySequenceSQL({
  376. sourceTableName,
  377. sourceTableSchema,
  378. duplicatedTableName,
  379. columnName: column.name,
  380. }),
  381. })
  382. })
  383. }
  384. const tables = await queryClient.fetchQuery({
  385. queryKey: tableKeys.list(projectRef, sourceTableSchema),
  386. queryFn: ({ signal }) =>
  387. getTables({ projectRef, connectionString, schema: sourceTableSchema }, signal),
  388. })
  389. const duplicatedTable = find(tables, { schema: sourceTableSchema, name: duplicatedTableName })!
  390. if (isRLSEnabled) {
  391. await updateTableMutation({
  392. projectRef,
  393. connectionString,
  394. id: duplicatedTable?.id!,
  395. name: duplicatedTable?.name!,
  396. schema: duplicatedTable?.schema!,
  397. payload: { rls_enabled: isRLSEnabled },
  398. })
  399. }
  400. return duplicatedTable
  401. }
  402. export const createTable = async ({
  403. projectRef,
  404. connectionString,
  405. toastId,
  406. payload,
  407. columns = [],
  408. foreignKeyRelations,
  409. isRLSEnabled,
  410. importContent,
  411. organizationSlug,
  412. generatedPolicies = [],
  413. onCreatePoliciesSuccess,
  414. }: {
  415. projectRef: string
  416. connectionString?: string | null
  417. toastId: string | number
  418. payload: {
  419. name: string
  420. schema: string
  421. comment?: string | null
  422. }
  423. columns: ColumnField[]
  424. foreignKeyRelations: ForeignKey[]
  425. isRLSEnabled: boolean
  426. importContent?: ImportContent
  427. organizationSlug?: string
  428. generatedPolicies?: AcceptedGeneratedPolicy[]
  429. onCreatePoliciesSuccess?: () => void
  430. }) => {
  431. const queryClient = getQueryClient()
  432. // Build all SQL statements for table creation, columns, and constraints
  433. // to execute in a single transaction for better performance and atomicity
  434. const sqlStatements: Array<SafeSqlFragment> = []
  435. // 1. Create table SQL
  436. const { sql: createTableSql } = pgMeta.tables.create({ ...payload, no_transaction: true })
  437. sqlStatements.push(createTableSql)
  438. // 2. Enable RLS if configured
  439. if (isRLSEnabled) {
  440. const enableRLSSQL = getEnableRLSSQL({
  441. schema: payload.schema,
  442. table: payload.name,
  443. })
  444. sqlStatements.push(enableRLSSQL)
  445. }
  446. // 3. Add columns SQL (without primary keys - those are added as constraints)
  447. for (const column of columns) {
  448. const columnPayload = generateCreateColumnPayload(
  449. { schema: payload.schema, name: payload.name } as RetrieveTableResult,
  450. { ...column, isPrimaryKey: false }
  451. )
  452. const { sql: columnSQL } = pgMeta.columns.create({
  453. schema: columnPayload.schema,
  454. table: columnPayload.table,
  455. name: columnPayload.name,
  456. type: columnPayload.type,
  457. default_value: columnPayload.defaultValue,
  458. default_value_format: columnPayload.defaultValueFormat,
  459. is_identity: columnPayload.isIdentity,
  460. is_nullable: columnPayload.isNullable,
  461. is_primary_key: columnPayload.isPrimaryKey,
  462. is_unique: columnPayload.isUnique,
  463. comment: columnPayload.comment,
  464. check: columnPayload.check,
  465. no_transaction: true,
  466. })
  467. sqlStatements.push(columnSQL)
  468. }
  469. // 4. Add primary key constraint (supports composite keys)
  470. const primaryKeyColumns = columns
  471. .filter((column) => column.isPrimaryKey)
  472. .map((column) => column.name)
  473. if (primaryKeyColumns.length > 0) {
  474. const primaryKeySQL = getAddPrimaryKeySQL({
  475. schema: payload.schema,
  476. table: payload.name,
  477. columns: primaryKeyColumns,
  478. })
  479. sqlStatements.push(primaryKeySQL)
  480. }
  481. // 5. Add foreign key constraints
  482. if (foreignKeyRelations.length > 0) {
  483. const fkSql = getAddForeignKeySQL({
  484. table: { schema: payload.schema, name: payload.name },
  485. foreignKeys: foreignKeyRelations,
  486. })
  487. const fkSqlWithoutTrailingSemicolon = fkSql.replace(/;+$/, '') as SafeSqlFragment
  488. sqlStatements.push(fkSqlWithoutTrailingSemicolon)
  489. }
  490. // Execute all table creation SQL in a single transaction
  491. toast.loading(`Creating table ${payload.name}...`, { id: toastId })
  492. await Sentry.startSpan(
  493. { name: 'create_table.execute_sql', op: 'db.sql.transaction' },
  494. async (span) => {
  495. span.setAttribute('sql.statement_count', sqlStatements.length)
  496. await executeSql({
  497. projectRef,
  498. connectionString,
  499. sql: safeSql`BEGIN; ${joinSqlFragments(sqlStatements, ';\n')}; COMMIT;`,
  500. queryKey: ['table', 'create-with-columns'],
  501. })
  502. }
  503. )
  504. // 6. Create generated RLS policies if any
  505. // [Joshen] Possible area for optimization to create all policies in a single query call
  506. // Can be subsequently added to the table creation SQL as well for a single transaction
  507. const failedPolicies: AcceptedGeneratedPolicy[] = []
  508. if (generatedPolicies.length > 0 && isRLSEnabled) {
  509. await Sentry.startSpan(
  510. { name: 'create_table.create_policies', op: 'db.policies.create' },
  511. async (span) => {
  512. span.setAttribute('policies.count', generatedPolicies.length)
  513. toast.loading(`Creating ${generatedPolicies.length} policies for table...`, { id: toastId })
  514. await Promise.all(
  515. generatedPolicies.map(async (policy) => {
  516. try {
  517. return await createDatabasePolicy({
  518. projectRef,
  519. connectionString,
  520. payload: {
  521. name: policy.name,
  522. table: policy.table,
  523. schema: policy.schema,
  524. definition: policy.definition,
  525. check: policy.check,
  526. action: policy.action,
  527. command: policy.command,
  528. roles: policy.roles,
  529. },
  530. })
  531. } catch (error: any) {
  532. console.error('Failed to generate policy', error.message)
  533. failedPolicies.push(policy)
  534. }
  535. })
  536. )
  537. span.setAttribute('policies.failed_count', failedPolicies.length)
  538. onCreatePoliciesSuccess?.()
  539. }
  540. )
  541. }
  542. // Track table creation event (fire-and-forget to avoid blocking)
  543. sendEvent({
  544. event: {
  545. action: 'table_created',
  546. properties: {
  547. method: 'table_editor',
  548. schema_name: payload.schema,
  549. table_name: payload.name,
  550. has_generated_policies: generatedPolicies.length > 0 && isRLSEnabled,
  551. },
  552. groups: {
  553. project: projectRef,
  554. ...(organizationSlug && { organization: organizationSlug }),
  555. },
  556. },
  557. }).catch((error) => {
  558. console.error('Failed to track table creation event:', error)
  559. })
  560. // Track RLS enablement event if enabled (fire-and-forget)
  561. if (isRLSEnabled) {
  562. sendEvent({
  563. event: {
  564. action: 'table_rls_enabled',
  565. properties: {
  566. method: 'table_editor',
  567. schema_name: payload.schema,
  568. table_name: payload.name,
  569. },
  570. groups: {
  571. project: projectRef,
  572. ...(organizationSlug && { organization: organizationSlug }),
  573. },
  574. },
  575. }).catch((error) => {
  576. console.error('Failed to track RLS enablement event:', error)
  577. })
  578. }
  579. // Fetch the created table
  580. const table = await Sentry.startSpan(
  581. { name: 'create_table.fetch_table', op: 'db.table.fetch' },
  582. async () => {
  583. return await getTableQuery({
  584. projectRef,
  585. connectionString,
  586. name: payload.name,
  587. schema: payload.schema,
  588. })
  589. }
  590. )
  591. // If the user is importing data via a spreadsheet
  592. if (importContent !== undefined) {
  593. await Sentry.startSpan(
  594. { name: 'create_table.import_data', op: 'db.table.import' },
  595. async (span) => {
  596. const rowCount = importContent.file
  597. ? importContent.rowCount
  598. : (importContent.rows?.length ?? 0)
  599. span.setAttribute('import.row_count', rowCount)
  600. span.setAttribute('import.method', importContent.file ? 'csv' : 'paste')
  601. if (importContent.file && importContent.rowCount > 0) {
  602. // Via a CSV file
  603. const { error } = await insertRowsViaSpreadsheet({
  604. projectRef,
  605. connectionString,
  606. file: importContent.file,
  607. table,
  608. selectedHeaders: importContent.selectedHeaders,
  609. onProgressUpdate: (progress: number) => {
  610. toast.loading(
  611. <div className="flex flex-col space-y-2" style={{ minWidth: '220px' }}>
  612. <SparkBar
  613. value={progress}
  614. max={100}
  615. type="horizontal"
  616. barClass="bg-brand"
  617. labelBottom={`Adding ${importContent.rowCount.toLocaleString()} rows to ${table.name}`}
  618. labelBottomClass=""
  619. labelTop={`${progress.toFixed(2)}%`}
  620. labelTopClass="tabular-nums"
  621. />
  622. </div>,
  623. { id: toastId }
  624. )
  625. },
  626. emptyStringAsNullHeaders: importContent.emptyStringAsNullHeaders,
  627. })
  628. if (error !== undefined) {
  629. span.setAttribute('import.error', 1)
  630. toast.error('Do check your spreadsheet if there are any discrepancies.')
  631. const message = isObjectContainingKeys(error, ['message'])
  632. ? String(error.message)
  633. : 'An unknown error occurred during data import.'
  634. toast.error(message)
  635. console.error('Error:', { error, message })
  636. }
  637. } else {
  638. // Via text copy and paste
  639. await insertTableRows({
  640. projectRef,
  641. connectionString,
  642. table,
  643. rows: importContent.rows,
  644. selectedHeaders: importContent.selectedHeaders,
  645. onProgressUpdate: (progress: number) => {
  646. toast.loading(
  647. <div className="flex flex-col space-y-2" style={{ minWidth: '220px' }}>
  648. <SparkBar
  649. value={progress}
  650. max={100}
  651. type="horizontal"
  652. barClass="bg-brand"
  653. labelBottom={`Adding ${importContent.rows.length.toLocaleString()} rows to ${table.name}`}
  654. labelTop={`${progress.toFixed(2)}%`}
  655. labelTopClass="tabular-nums"
  656. />
  657. </div>,
  658. { id: toastId }
  659. )
  660. },
  661. emptyStringAsNullHeaders: importContent.emptyStringAsNullHeaders,
  662. })
  663. }
  664. // For identity columns, manually raise the sequences (batched for performance)
  665. const identityColumns = columns.filter((column) => column.isIdentity)
  666. if (identityColumns.length > 0) {
  667. const updateSequenceSQL = joinSqlFragments(
  668. identityColumns.map((column) =>
  669. getUpdateIdentitySequenceSQL({
  670. schema: table.schema,
  671. table: table.name,
  672. column: column.name,
  673. })
  674. ),
  675. ';\n'
  676. )
  677. await executeSql({
  678. projectRef,
  679. connectionString,
  680. sql: updateSequenceSQL,
  681. queryKey: ['sequences', 'update-batch'],
  682. })
  683. }
  684. }
  685. )
  686. }
  687. await Sentry.startSpan(
  688. { name: 'create_table.prefetch_editor', op: 'db.table.prefetch' },
  689. async () => {
  690. await prefetchEditorTablePage({
  691. queryClient,
  692. projectRef,
  693. connectionString,
  694. id: table.id,
  695. })
  696. }
  697. )
  698. // Finally, return the created table
  699. return { table, failedPolicies }
  700. }
  701. /** TODO: Refactor to do in a single transaction */
  702. export const updateTable = async ({
  703. projectRef,
  704. connectionString,
  705. toastId,
  706. table,
  707. payload,
  708. columns,
  709. foreignKeyRelations,
  710. existingForeignKeyRelations,
  711. primaryKey,
  712. organizationSlug,
  713. }: {
  714. projectRef: string
  715. connectionString?: string | null
  716. toastId: string | number
  717. table: RetrieveTableResult
  718. payload: UpdateTableBody
  719. columns: ColumnField[]
  720. foreignKeyRelations: ForeignKey[]
  721. existingForeignKeyRelations: ForeignKeyConstraint[]
  722. primaryKey?: Constraint
  723. organizationSlug?: string
  724. }) => {
  725. const queryClient = getQueryClient()
  726. // Prepare a check to see if primary keys to the tables were updated or not
  727. const primaryKeyColumns = columns
  728. .filter((column) => column.isPrimaryKey)
  729. .map((column) => column.name)
  730. const existingPrimaryKeyColumns = table.primary_keys.map((pk: PGTablePrimaryKey) => pk.name)
  731. const isPrimaryKeyUpdated = !isEqual(primaryKeyColumns, existingPrimaryKeyColumns)
  732. if (isPrimaryKeyUpdated) {
  733. // Remove any primary key constraints first (we'll add it back later)
  734. // If we do it later, and if the user deleted a PK column, we'd need to do
  735. // an additional check when removing PK if the column in the PK was removed
  736. // So doing this one step earlier, lets us skip that additional check.
  737. if (primaryKey !== undefined) {
  738. await dropConstraint(projectRef, connectionString, table.schema, table.name, primaryKey.name)
  739. }
  740. }
  741. if (Object.keys(payload).length > 0) {
  742. await updateTableMutation({
  743. projectRef,
  744. connectionString,
  745. id: table.id,
  746. name: table.name,
  747. schema: table.schema,
  748. payload,
  749. })
  750. }
  751. // Track RLS enablement if it's being turned on
  752. if (payload.rls_enabled === true) {
  753. try {
  754. await sendEvent({
  755. event: {
  756. action: 'table_rls_enabled',
  757. properties: {
  758. method: 'table_editor',
  759. schema_name: table.schema,
  760. table_name: payload.name ?? table.name,
  761. },
  762. groups: {
  763. project: projectRef,
  764. ...(organizationSlug && { organization: organizationSlug }),
  765. },
  766. },
  767. })
  768. } catch (error) {
  769. console.error('Failed to track RLS enablement event:', error)
  770. }
  771. }
  772. const updatedTable = await queryClient.fetchQuery({
  773. queryKey: tableKeys.retrieve(
  774. projectRef,
  775. payload.name ?? table.name,
  776. payload.schema ?? table.schema
  777. ),
  778. queryFn: ({ signal }) =>
  779. getTable(
  780. {
  781. projectRef,
  782. connectionString,
  783. name: payload.name ?? table.name,
  784. schema: payload.schema ?? table.schema,
  785. },
  786. signal
  787. ),
  788. })
  789. const originalColumns = updatedTable.columns ?? []
  790. const columnIds = columns.map((column) => column.id)
  791. // Delete any removed columns
  792. const columnsToRemove = originalColumns.filter((column) => !columnIds.includes(column.id))
  793. for (const column of columnsToRemove) {
  794. toast.loading(`Removing column ${column.name} from ${updatedTable.name}`, { id: toastId })
  795. await deleteDatabaseColumn({
  796. projectRef,
  797. connectionString,
  798. column,
  799. })
  800. }
  801. // Add any new columns / Update any existing columns
  802. let hasError = false
  803. for (const column of columns) {
  804. if (!column.id.includes(table.id.toString())) {
  805. toast.loading(`Adding column ${column.name} to ${updatedTable.name}`, { id: toastId })
  806. // Ensure that columns do not created as primary key first, cause the primary key will
  807. // be added later on further down in the code
  808. const columnPayload = generateCreateColumnPayload(updatedTable, {
  809. ...column,
  810. isPrimaryKey: false,
  811. })
  812. const { error } = await createColumn({
  813. projectRef: projectRef,
  814. connectionString: connectionString,
  815. payload: columnPayload,
  816. selectedTable: updatedTable,
  817. skipSuccessMessage: true,
  818. toastId,
  819. })
  820. if (!!error) hasError = true
  821. } else {
  822. const originalColumn = find(table.columns, { id: column.id })
  823. if (originalColumn) {
  824. const columnPayload = generateUpdateColumnPayload(originalColumn, updatedTable, column)
  825. if (!isEmpty(columnPayload)) {
  826. toast.loading(`Updating column ${column.name} from ${updatedTable.name}`, { id: toastId })
  827. const res = await updateColumn({
  828. projectRef: projectRef,
  829. connectionString: connectionString,
  830. // Use the updated table name and schema since the table might have been renamed
  831. originalColumn: {
  832. ...originalColumn,
  833. table: updatedTable.name,
  834. schema: updatedTable.schema,
  835. },
  836. payload: columnPayload,
  837. selectedTable: updatedTable,
  838. skipPKCreation: true,
  839. skipSuccessMessage: true,
  840. })
  841. if (res?.error) {
  842. hasError = true
  843. toast.error(`Failed to update column "${column.name}": ${res.error.message}`)
  844. }
  845. }
  846. }
  847. }
  848. }
  849. // Then add back the primary keys again
  850. if (isPrimaryKeyUpdated && primaryKeyColumns.length > 0) {
  851. await addPrimaryKey(
  852. projectRef,
  853. connectionString,
  854. updatedTable.schema,
  855. updatedTable.name,
  856. primaryKeyColumns
  857. )
  858. }
  859. // Foreign keys will get updated here accordingly
  860. await updateForeignKeys({
  861. projectRef,
  862. connectionString,
  863. table: updatedTable,
  864. foreignKeys: foreignKeyRelations,
  865. existingForeignKeyRelations,
  866. })
  867. await Promise.all([
  868. queryClient.invalidateQueries({ queryKey: tableEditorKeys.tableEditor(projectRef, table.id) }),
  869. queryClient.invalidateQueries({
  870. queryKey: databaseKeys.foreignKeyConstraints(projectRef, table.schema),
  871. }),
  872. queryClient.invalidateQueries({ queryKey: databaseKeys.tableDefinition(projectRef, table.id) }),
  873. queryClient.invalidateQueries({ queryKey: entityTypeKeys.list(projectRef) }),
  874. queryClient.invalidateQueries({ queryKey: tableKeys.list(projectRef, table.schema, true) }),
  875. queryClient.invalidateQueries({ queryKey: lintKeys.lint(projectRef) }),
  876. ])
  877. // We need to invalidate tableRowsAndCount after tableEditor
  878. // to ensure the query sent is correct
  879. await queryClient.invalidateQueries({
  880. queryKey: tableRowKeys.tableRowsAndCount(projectRef, table.id),
  881. })
  882. return {
  883. table: await prefetchTableEditor(queryClient, {
  884. projectRef,
  885. connectionString,
  886. id: table.id,
  887. }),
  888. hasError,
  889. }
  890. }
  891. /**
  892. * Used in insertRowsViaSpreadsheet + insertTableRows
  893. */
  894. export const formatRowsForInsert = ({
  895. rows,
  896. headers,
  897. columns = [],
  898. emptyStringAsNullHeaders = headers,
  899. }: {
  900. rows: unknown[]
  901. headers: string[]
  902. columns?: RetrieveTableResult['columns']
  903. emptyStringAsNullHeaders?: string[]
  904. }) => {
  905. return rows.map((row) => {
  906. const formattedRow: Record<string, unknown> = {}
  907. if (!isObject(row)) {
  908. return formattedRow
  909. }
  910. headers.forEach((header) => {
  911. const column = columns?.find((c) => c.name === header)
  912. const originalValue = row[header]
  913. if ((column?.format ?? '').includes('json')) {
  914. formattedRow[header] = tryParseJson(originalValue)
  915. } else if ((column?.data_type ?? '') === 'ARRAY') {
  916. if (
  917. typeof originalValue === 'string' &&
  918. originalValue.startsWith('{') &&
  919. originalValue.endsWith('}')
  920. ) {
  921. const formattedPostgresArraytoJsonArray = `[${originalValue.slice(1, originalValue.length - 1)}]`
  922. formattedRow[header] = tryParseJson(formattedPostgresArraytoJsonArray)
  923. } else {
  924. formattedRow[header] = tryParseJson(originalValue)
  925. }
  926. } else if (originalValue === '') {
  927. formattedRow[header] =
  928. column?.is_nullable && emptyStringAsNullHeaders.includes(header) ? null : ''
  929. } else {
  930. formattedRow[header] = originalValue
  931. }
  932. })
  933. return formattedRow
  934. })
  935. }
  936. export async function insertRowsViaSpreadsheet({
  937. projectRef,
  938. connectionString,
  939. file,
  940. table,
  941. selectedHeaders,
  942. emptyStringAsNullHeaders = selectedHeaders,
  943. onProgressUpdate,
  944. }: {
  945. projectRef: string
  946. connectionString: string | undefined | null
  947. file: File
  948. table: RetrieveTableResult
  949. selectedHeaders: string[]
  950. emptyStringAsNullHeaders?: string[]
  951. onProgressUpdate: (progress: number) => void
  952. }): Promise<{ error: unknown }> {
  953. let chunkNumber = 0
  954. let insertError: unknown = undefined
  955. const t1 = new Date()
  956. return new Promise((resolve) => {
  957. Papa.parse(file, {
  958. header: true,
  959. // dynamicTyping has to be disabled so that "00001" doesn't get parsed as 1.
  960. dynamicTyping: false,
  961. skipEmptyLines: true,
  962. chunkSize: CHUNK_SIZE,
  963. quoteChar: file.type === 'text/tab-separated-values' ? '' : '"',
  964. chunk: async (results, parser) => {
  965. parser.pause()
  966. const formattedData = formatRowsForInsert({
  967. rows: results.data,
  968. headers: selectedHeaders,
  969. columns: table.columns,
  970. emptyStringAsNullHeaders,
  971. })
  972. const insertQuery = new Query().from(table.name, table.schema).insert(formattedData).toSql()
  973. try {
  974. await executeWithRetry(() =>
  975. executeSql({ projectRef, connectionString, sql: insertQuery })
  976. )
  977. } catch (error) {
  978. console.warn(error)
  979. insertError = error
  980. parser.abort()
  981. }
  982. chunkNumber += 1
  983. const progress = (chunkNumber * CHUNK_SIZE) / file.size
  984. const progressPercentage = progress > 1 ? 100 : progress * 100
  985. onProgressUpdate(progressPercentage)
  986. parser.resume()
  987. },
  988. complete: () => {
  989. const t2 = new Date()
  990. console.log(
  991. `Total time taken for importing spreadsheet: ${(t2.getTime() - t1.getTime()) / 1000} seconds`
  992. )
  993. if (insertError === undefined) {
  994. const sequenceColumns = (table.columns ?? []).filter(
  995. (column) =>
  996. column.is_identity ||
  997. (typeof column.default_value === 'string' &&
  998. column.default_value.includes('nextval('))
  999. )
  1000. if (sequenceColumns.length === 0) {
  1001. resolve({ error: insertError })
  1002. return
  1003. }
  1004. const updateSequenceSQL = joinSqlFragments(
  1005. sequenceColumns.map((column) =>
  1006. getUpdateIdentitySequenceSQL({
  1007. schema: table.schema,
  1008. table: table.name,
  1009. column: column.name,
  1010. })
  1011. ),
  1012. ';\n'
  1013. )
  1014. executeSql({
  1015. projectRef,
  1016. connectionString,
  1017. sql: updateSequenceSQL,
  1018. queryKey: ['sequences', 'update-batch'],
  1019. })
  1020. .then(() => resolve({ error: insertError }))
  1021. .catch((error) => resolve({ error }))
  1022. return
  1023. }
  1024. resolve({ error: insertError })
  1025. },
  1026. })
  1027. })
  1028. }
  1029. export async function insertTableRows({
  1030. projectRef,
  1031. connectionString,
  1032. table,
  1033. rows,
  1034. selectedHeaders,
  1035. emptyStringAsNullHeaders = selectedHeaders,
  1036. onProgressUpdate,
  1037. }: {
  1038. projectRef: string
  1039. connectionString: string | undefined | null
  1040. table: RetrieveTableResult
  1041. rows: unknown[]
  1042. selectedHeaders: string[]
  1043. emptyStringAsNullHeaders?: string[]
  1044. onProgressUpdate: (progress: number) => void
  1045. }): Promise<{ error: unknown }> {
  1046. let insertError: unknown = undefined
  1047. let insertProgress = 0
  1048. const formattedRows = formatRowsForInsert({
  1049. rows,
  1050. headers: selectedHeaders,
  1051. columns: table.columns,
  1052. emptyStringAsNullHeaders,
  1053. })
  1054. const batches = chunk(formattedRows, BATCH_SIZE)
  1055. const tasks = batches.map((batch) => {
  1056. return () => {
  1057. return Promise.race([
  1058. new Promise(async (resolve, reject) => {
  1059. const insertQuery = new Query().from(table.name, table.schema).insert(batch).toSql()
  1060. try {
  1061. await executeSql({ projectRef, connectionString, sql: insertQuery })
  1062. } catch (error) {
  1063. insertError = error
  1064. reject(error)
  1065. }
  1066. insertProgress = insertProgress + batch.length / rows.length
  1067. resolve({})
  1068. }),
  1069. timeout(30_000),
  1070. ])
  1071. }
  1072. })
  1073. const batchedPromises = chunk(tasks, 10)
  1074. for (const batchedPromise of batchedPromises) {
  1075. const res = await Promise.allSettled(batchedPromise.map((batch) => batch()))
  1076. const failedBatch = res.find((result) => result.status === 'rejected')
  1077. if (failedBatch?.status === 'rejected') {
  1078. if (insertError === undefined) insertError = failedBatch.reason
  1079. break
  1080. }
  1081. onProgressUpdate(insertProgress * 100)
  1082. }
  1083. if (insertError !== undefined) {
  1084. return { error: insertError }
  1085. }
  1086. const sequenceColumns = (table.columns ?? []).filter(
  1087. (column) =>
  1088. column.is_identity ||
  1089. (typeof column.default_value === 'string' && column.default_value.includes('nextval('))
  1090. )
  1091. if (sequenceColumns.length === 0) {
  1092. return { error: insertError }
  1093. }
  1094. const updateSequenceSQL = joinSqlFragments(
  1095. sequenceColumns.map((column) =>
  1096. getUpdateIdentitySequenceSQL({
  1097. schema: table.schema,
  1098. table: table.name,
  1099. column: column.name,
  1100. })
  1101. ),
  1102. ';\n'
  1103. )
  1104. try {
  1105. await executeSql({
  1106. projectRef,
  1107. connectionString,
  1108. sql: updateSequenceSQL,
  1109. queryKey: ['sequences', 'update-batch'],
  1110. })
  1111. return { error: insertError }
  1112. } catch (error) {
  1113. return { error }
  1114. }
  1115. }
  1116. const updateForeignKeys = async ({
  1117. projectRef,
  1118. connectionString,
  1119. table,
  1120. foreignKeys,
  1121. existingForeignKeyRelations,
  1122. }: {
  1123. projectRef: string
  1124. connectionString?: string | null
  1125. table: { schema: string; name: string }
  1126. foreignKeys: ForeignKey[]
  1127. existingForeignKeyRelations: ForeignKeyConstraint[]
  1128. }) => {
  1129. // Foreign keys will get updated here accordingly
  1130. const relationsToAdd = foreignKeys.filter((x) => typeof x.id === 'string')
  1131. if (relationsToAdd.length > 0) {
  1132. await addForeignKey({
  1133. projectRef,
  1134. connectionString,
  1135. table,
  1136. foreignKeys: relationsToAdd,
  1137. })
  1138. }
  1139. const relationsToRemove = foreignKeys.filter((x) => x.toRemove)
  1140. if (relationsToRemove.length > 0) {
  1141. await removeForeignKey({
  1142. projectRef,
  1143. connectionString,
  1144. table,
  1145. foreignKeys: relationsToRemove,
  1146. })
  1147. }
  1148. const remainingRelations = foreignKeys.filter((x) => typeof x.id === 'number' && !x.toRemove)
  1149. const relationsToUpdate = remainingRelations.filter((x) => {
  1150. const existingRelation = existingForeignKeyRelations.find((y) => x.id === y.id)
  1151. if (existingRelation !== undefined) {
  1152. return checkIfRelationChanged(existingRelation as unknown as ForeignKeyConstraint, x)
  1153. } else return false
  1154. })
  1155. if (relationsToUpdate.length > 0) {
  1156. await updateForeignKey({
  1157. projectRef,
  1158. connectionString,
  1159. table,
  1160. foreignKeys: relationsToUpdate,
  1161. })
  1162. }
  1163. }