mirror of
https://github.com/supabase/supabase.git
synced 2026-05-06 08:56:46 -04:00
Fix table creation transaction (#43909)
Fixes #42089 Supersedes #43352 and https://github.com/supabase/postgres-meta/pull/1049 ## Problem When creating a table in the Dashboard, if a column-specific error occurs (e.g., invalid enum value or broken foreign key), the Dashboard displays an error toast, but the side panel remains open. In the background, the table is actually created (partially), leading to a _42P07: relation already exists_ error if the user tries to click _Save_ again. ## Solution We actually had nested transactions which is not supported by Postgres. - [x] Allow generating SQL without transactions from `@supabase/pg-meta` - [x] Fix the table creation - [x] Fix some accessibility issues - [x] Add tests ## How to test 1. Click _New table_ in the Table editor 2. Add a column with an _int8_ type and set its default value to `bazinga` 3. Click _Save_ You should see an error. 4. Fix the default value by setting it to `10` 5. Click _Save_ Results: - You should see a success message about the table creation - The table should have the column with the correct default value
This commit is contained in:
+5
-2
@@ -5,11 +5,10 @@
|
||||
// with timeouts and a lot of unnecessary defensive guards - but these can go away when we port
|
||||
// the component over to the UI library
|
||||
|
||||
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
|
||||
import { noop } from 'lodash'
|
||||
import { List } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -19,6 +18,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
Input,
|
||||
} from 'ui'
|
||||
|
||||
import type { Suggestion } from './ColumnEditor.types'
|
||||
|
||||
const MAX_SUGGESTIONS = 3
|
||||
@@ -38,6 +38,7 @@ interface InputWithSuggestionsProps {
|
||||
onChange: (event: any) => void
|
||||
onSelectSuggestion: (suggestion: Suggestion) => void
|
||||
'data-testid'?: string
|
||||
'aria-label'?: string
|
||||
}
|
||||
|
||||
const InputWithSuggestions = ({
|
||||
@@ -55,6 +56,7 @@ const InputWithSuggestions = ({
|
||||
onChange = noop,
|
||||
onSelectSuggestion = noop,
|
||||
'data-testid': dataTestId,
|
||||
'aria-label': ariaLabel,
|
||||
}: InputWithSuggestionsProps) => {
|
||||
const ref = useRef(null)
|
||||
const [filteredSuggestions, setFilteredSuggestions] = useState<Suggestion[]>(suggestions)
|
||||
@@ -83,6 +85,7 @@ const InputWithSuggestions = ({
|
||||
<div ref={ref} className="relative">
|
||||
<Input
|
||||
label={label}
|
||||
aria-label={ariaLabel}
|
||||
descriptionText={description}
|
||||
placeholder={placeholder}
|
||||
size={size}
|
||||
|
||||
+3
-2
@@ -476,7 +476,7 @@ export const createTable = async ({
|
||||
const sqlStatements: string[] = []
|
||||
|
||||
// 1. Create table SQL
|
||||
const { sql: createTableSql } = pgMeta.tables.create(payload)
|
||||
const { sql: createTableSql } = pgMeta.tables.create({ ...payload, no_transaction: true })
|
||||
sqlStatements.push(createTableSql)
|
||||
|
||||
// 2. Enable RLS if configured
|
||||
@@ -507,6 +507,7 @@ export const createTable = async ({
|
||||
is_unique: columnPayload.isUnique,
|
||||
comment: columnPayload.comment,
|
||||
check: columnPayload.check,
|
||||
no_transaction: true,
|
||||
})
|
||||
sqlStatements.push(columnSQL)
|
||||
}
|
||||
@@ -544,7 +545,7 @@ export const createTable = async ({
|
||||
await executeSql({
|
||||
projectRef,
|
||||
connectionString,
|
||||
sql: sqlStatements.join(';\n'),
|
||||
sql: `BEGIN; ${sqlStatements.join(';\n')}; COMMIT;`,
|
||||
queryKey: ['table', 'create-with-columns'],
|
||||
})
|
||||
}
|
||||
|
||||
+19
-17
@@ -1,25 +1,25 @@
|
||||
import { Link, Menu, Plus, Settings, X } from 'lucide-react'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Checkbox,
|
||||
CommandGroup_Shadcn_,
|
||||
CommandItem_Shadcn_,
|
||||
CommandList_Shadcn_,
|
||||
CommandSeparator_Shadcn_,
|
||||
Command_Shadcn_,
|
||||
Input,
|
||||
PopoverContent_Shadcn_,
|
||||
PopoverTrigger_Shadcn_,
|
||||
Popover_Shadcn_,
|
||||
cn,
|
||||
} from 'ui'
|
||||
|
||||
import { useForeignKeyConstraintsQuery } from 'data/database/foreign-key-constraints-query'
|
||||
import type { EnumeratedType } from 'data/enumerated-types/enumerated-types-query'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { EMPTY_ARR, EMPTY_OBJ } from 'lib/void'
|
||||
import { Link, Menu, Plus, Settings, X } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Checkbox,
|
||||
cn,
|
||||
Command_Shadcn_,
|
||||
CommandGroup_Shadcn_,
|
||||
CommandItem_Shadcn_,
|
||||
CommandList_Shadcn_,
|
||||
CommandSeparator_Shadcn_,
|
||||
Input,
|
||||
Popover_Shadcn_,
|
||||
PopoverContent_Shadcn_,
|
||||
PopoverTrigger_Shadcn_,
|
||||
} from 'ui'
|
||||
|
||||
import { typeExpressionSuggestions } from '../ColumnEditor/ColumnEditor.constants'
|
||||
import type { Suggestion } from '../ColumnEditor/ColumnEditor.types'
|
||||
import ColumnType from '../ColumnEditor/ColumnType'
|
||||
@@ -115,6 +115,7 @@ const Column = ({
|
||||
<div className="w-[25%]">
|
||||
<div className="flex w-[95%] items-center justify-between">
|
||||
<Input
|
||||
aria-label="Column name"
|
||||
size="small"
|
||||
value={column.name}
|
||||
title={column.name}
|
||||
@@ -241,6 +242,7 @@ const Column = ({
|
||||
<div className={`${isNewRecord ? 'w-[25%]' : 'w-[30%]'}`}>
|
||||
<div className="w-[95%]">
|
||||
<InputWithSuggestions
|
||||
aria-label="Column default value"
|
||||
data-testid={`${column.name}-default-value`}
|
||||
placeholder={
|
||||
typeof column.defaultValue === 'string' && column.defaultValue.length === 0
|
||||
|
||||
@@ -258,7 +258,7 @@ test.describe('Database', () => {
|
||||
|
||||
// create a new table
|
||||
await page.getByRole('button', { name: 'New table' }).click()
|
||||
await page.getByLabel('Name').fill(databaseTableNameNew)
|
||||
await page.getByLabel('Name', { exact: true }).fill(databaseTableNameNew)
|
||||
const createTableWait = createApiResponseWaiter(
|
||||
page,
|
||||
'pg-meta',
|
||||
|
||||
@@ -90,17 +90,16 @@ testRunner('table editor', () => {
|
||||
.getByRole('button')
|
||||
.nth(2)
|
||||
.click()
|
||||
const schemaPromise = waitForApiResponse(page, 'pg-meta', ref, 'query?key=table-definition-')
|
||||
await page.getByRole('menuitem', { name: 'Copy table schema' }).click()
|
||||
await schemaPromise // wait for endpoint to generate schema
|
||||
await page.waitForTimeout(500)
|
||||
const copiedSchemaResult = await page.evaluate(() => navigator.clipboard.readText())
|
||||
expect(copiedSchemaResult).toBe(`create table public.pw_table_actions (
|
||||
await expect(async () => {
|
||||
const copiedSchemaResult = await page.evaluate(() => navigator.clipboard.readText())
|
||||
expect(copiedSchemaResult).toBe(`create table public.pw_table_actions (
|
||||
id bigint generated by default as identity not null,
|
||||
created_at timestamp with time zone null default now(),
|
||||
pw_column text null,
|
||||
constraint pw_table_actions_pkey primary key (id)
|
||||
) TABLESPACE pg_default;`)
|
||||
}).toPass({ timeout: 2000 })
|
||||
|
||||
// duplicates table
|
||||
await page
|
||||
@@ -213,7 +212,7 @@ testRunner('table editor', () => {
|
||||
await page.getByText('Is Nullable').click()
|
||||
await page.getByTestId('created_at-extra-options').click()
|
||||
await page.getByRole('button', { name: 'Add column' }).click()
|
||||
await page.getByRole('textbox', { name: 'column_name' }).fill(columnNameEnum)
|
||||
await page.getByLabel('Column name').nth(2).fill(columnNameEnum)
|
||||
await page.getByRole('combobox').filter({ hasText: 'Choose a column type...' }).click()
|
||||
await page.getByPlaceholder('Search types...').fill(enum_name)
|
||||
// wait for response, then click
|
||||
@@ -302,7 +301,7 @@ testRunner('table editor', () => {
|
||||
.click()
|
||||
await page.getByRole('menuitem', { name: 'Edit table' }).click()
|
||||
await page.getByTestId('table-name-input').fill(tableNameUpdated)
|
||||
await page.getByRole('textbox', { name: 'pw_column' }).fill(columnNameUpdated)
|
||||
await page.getByLabel('Column name').nth(2).fill(columnNameUpdated)
|
||||
const updateTablePromise = waitForApiResponse(page, 'pg-meta', ref, 'query?key=column-update', {
|
||||
method: 'POST',
|
||||
})
|
||||
@@ -814,7 +813,7 @@ testRunner('table editor', () => {
|
||||
|
||||
// Add boolean column
|
||||
await page.getByRole('button', { name: 'Add column' }).click()
|
||||
await page.getByRole('textbox', { name: 'column_name' }).fill(boolColName)
|
||||
await page.getByLabel('Column name').nth(3).fill(boolColName)
|
||||
await page.getByText('Choose a column type...').click()
|
||||
await page.getByPlaceholder('Search types...').fill('bool')
|
||||
await page.getByRole('option', { name: 'bool' }).first().click()
|
||||
@@ -928,7 +927,7 @@ testRunner('table editor', () => {
|
||||
|
||||
// Add nullable boolean column
|
||||
await page.getByRole('button', { name: 'Add column' }).click()
|
||||
await page.getByRole('textbox', { name: 'column_name' }).fill(boolColName)
|
||||
await page.getByLabel('Column name').nth(3).fill(boolColName)
|
||||
await page.getByText('Choose a column type...').click()
|
||||
await page.getByPlaceholder('Search types...').fill('bool')
|
||||
await page.getByRole('option', { name: 'bool' }).first().click()
|
||||
@@ -1214,4 +1213,49 @@ testRunner('table editor', () => {
|
||||
).toBeVisible()
|
||||
await expect(page.getByRole('gridcell', { name: 'drag drop value 1' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('create a table in a single transaction', async ({ page, ref }) => {
|
||||
const tableName = 'pw_table_create_transaction'
|
||||
|
||||
await using _ = await withSetupCleanup(
|
||||
async () => {
|
||||
// Nothing
|
||||
},
|
||||
async () => {
|
||||
await dropTable(tableName)
|
||||
}
|
||||
)
|
||||
await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
|
||||
await page.getByRole('button', { name: 'New table' }).click()
|
||||
await page.getByLabel('Name', { exact: true }).fill(tableName)
|
||||
await page.getByRole('button', { name: 'Add column' }).click()
|
||||
await page.getByLabel('Column name').nth(2).fill('pw_column')
|
||||
await page.getByRole('combobox').filter({ hasText: 'Choose a column type...' }).click()
|
||||
await page.getByRole('option').filter({ hasText: 'int8' }).click()
|
||||
await page.getByLabel('Column default value').nth(2).fill('invalid')
|
||||
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
await expect(page.getByText('invalid input syntax')).toBeVisible()
|
||||
await page.getByLabel('Column default value').nth(2).fill('10')
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
await expect(page.getByText(`Table ${tableName} is good to go!`)).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: `View ${tableName}`, exact: true })).toBeVisible()
|
||||
|
||||
// copies table schema to clipboard when copy schema option is clicked
|
||||
await page
|
||||
.getByRole('button', { name: `View ${tableName}`, exact: true })
|
||||
.getByRole('button')
|
||||
.nth(2)
|
||||
.click()
|
||||
await page.getByRole('menuitem', { name: 'Copy table schema' }).click()
|
||||
await expect(async () => {
|
||||
const copiedSchemaResult = await page.evaluate(() => navigator.clipboard.readText())
|
||||
expect(copiedSchemaResult).toBe(`create table public.${tableName} (
|
||||
id bigint generated by default as identity not null,
|
||||
created_at timestamp with time zone not null default now(),
|
||||
pw_column bigint null default '10'::bigint,
|
||||
constraint ${tableName}_pkey primary key (id)
|
||||
) TABLESPACE pg_default;`)
|
||||
}).toPass({ timeout: 2000 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -120,6 +120,7 @@ function create({
|
||||
is_unique = false,
|
||||
comment,
|
||||
check,
|
||||
no_transaction = false,
|
||||
}: {
|
||||
schema: string
|
||||
table: string
|
||||
@@ -134,6 +135,7 @@ function create({
|
||||
is_unique?: boolean
|
||||
comment?: string
|
||||
check?: string
|
||||
no_transaction?: boolean
|
||||
}): { sql: string } {
|
||||
let defaultValueClause = ''
|
||||
if (is_identity) {
|
||||
@@ -165,17 +167,24 @@ function create({
|
||||
: `COMMENT ON COLUMN ${ident(schema)}.${ident(table)}.${ident(name)} IS ${literal(comment)}`
|
||||
|
||||
const sql = `
|
||||
BEGIN;
|
||||
ALTER TABLE ${ident(schema)}.${ident(table)} ADD COLUMN ${ident(name)} ${typeIdent(type)}
|
||||
${defaultValueClause}
|
||||
${isNullableClause}
|
||||
${isPrimaryKeyClause}
|
||||
${isUniqueClause}
|
||||
${checkSql};
|
||||
${commentSql};
|
||||
COMMIT;`
|
||||
${commentSql};`
|
||||
|
||||
return { sql }
|
||||
if (no_transaction) {
|
||||
return { sql }
|
||||
}
|
||||
|
||||
return {
|
||||
sql: `
|
||||
BEGIN;
|
||||
${sql};
|
||||
COMMIT;`,
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: make this more robust - use type_id or type_schema + type_name instead of just type.
|
||||
|
||||
@@ -141,14 +141,22 @@ type TableCreateParams = {
|
||||
name: string
|
||||
schema?: string
|
||||
comment?: string | null
|
||||
no_transaction?: boolean
|
||||
}
|
||||
|
||||
function create({ name, schema = 'public', comment }: TableCreateParams): { sql: string } {
|
||||
function create({ name, schema = 'public', comment, no_transaction = false }: TableCreateParams): {
|
||||
sql: string
|
||||
} {
|
||||
const tableSql = `CREATE TABLE ${ident(schema)}.${ident(name)} ();`
|
||||
const commentSql =
|
||||
comment != undefined
|
||||
? `COMMENT ON TABLE ${ident(schema)}.${ident(name)} IS ${literal(comment)};`
|
||||
: ''
|
||||
|
||||
if (no_transaction) {
|
||||
const sql = `${tableSql} ${commentSql}`
|
||||
return { sql }
|
||||
}
|
||||
const sql = `BEGIN; ${tableSql} ${commentSql} COMMIT;`
|
||||
return { sql }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user