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:
Gildas Garcia
2026-03-19 08:06:54 +01:00
committed by GitHub
parent 8499d30e98
commit df7e98bae6
7 changed files with 103 additions and 36 deletions
@@ -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}
@@ -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'],
})
}
@@ -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
+1 -1
View File
@@ -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',
+53 -9
View File
@@ -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 })
})
})
+13 -4
View File
@@ -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.
+9 -1
View File
@@ -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 }
}