mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
test(e2e): add coverage for complex scalar and array column types (#47536)
## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Test (E2E coverage addition). ## What is the current behavior? The SafeSqlFragment migration (#45903) reworked column type modeling in the table/column editor (`ColumnTypeRef`: format/formatSchema/isArray) and tightened the FK selector's type comparison to the full `(format, format_schema, isArray)` triple instead of comparing `format` alone. This was manually tested but had no E2E coverage for the more complex scalar/array type cases it affects (FE-3377). ## What is the new behavior? Adds `e2e/studio/features/column-editor-types.spec.ts` covering: - Editing an existing array column preserves its type across an unrelated save, and displays as `type[]` (not the raw `_type` pg format) in the Database > Tables column list. - Editing an existing enum column from a non-public schema preserves its schema-qualified type across an unrelated save, and displays as `schema.type`. - The FK selector flags a mismatch between an array column and a scalar column of the same base type. - The FK selector flags a mismatch between same-named enums from different schemas. - The FK selector allows and successfully creates a relation between two columns with matching array types (guards against over-flagging arrays as incompatible). ## Additional context Resolves FE-3377 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Tests** * Added end-to-end coverage for complex column types in the Studio UI. * Verified array and enum columns continue to display correctly after edits. * Added checks for foreign key type-mismatch warnings and save-button behavior. * Confirmed foreign keys can be created successfully when matching array types are used. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
1 parent
cdc2dc4e26
commit
dd3695814e
1 file changed
+368
@@ -0,0 +1,368 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { query } from '../utils/db/index.js'
|
||||
import { test, withSetupCleanup } from '../utils/test.js'
|
||||
import { toUrl } from '../utils/to-url.js'
|
||||
import { waitForApiResponseWithTimeout } from '../utils/wait-for-response-with-timeout.js'
|
||||
import { waitForApiResponse } from '../utils/wait-for-response.js'
|
||||
|
||||
const getTableId = async (tableName: string): Promise<number> => {
|
||||
const rows = await query<{ id: number }>(`select ('public.' || $1)::regclass::oid::int8 as id`, [
|
||||
tableName,
|
||||
])
|
||||
return rows[0].id
|
||||
}
|
||||
|
||||
test.describe('column editor complex types', () => {
|
||||
test('editing an array column preserves its type and displays it as type[]', async ({
|
||||
page,
|
||||
ref,
|
||||
}) => {
|
||||
const tableName = 'pw_ct_arr_rt'
|
||||
const colName = 'tags'
|
||||
|
||||
await using _ = await withSetupCleanup(
|
||||
() =>
|
||||
query(`
|
||||
drop table if exists public.${tableName} cascade;
|
||||
create table public.${tableName} (
|
||||
id bigint generated by default as identity primary key,
|
||||
${colName} int4[]
|
||||
);
|
||||
alter table public.${tableName} enable row level security;
|
||||
`),
|
||||
() => query(`drop table if exists public.${tableName} cascade;`)
|
||||
)
|
||||
|
||||
await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
|
||||
await page.getByRole('button', { name: `View ${tableName}`, exact: true }).click()
|
||||
await page.waitForURL(/\/editor\/\d+\?schema=public$/)
|
||||
|
||||
await page
|
||||
.getByRole('columnheader', { name: colName })
|
||||
.getByRole('button', { name: `Column ${colName} actions` })
|
||||
.click()
|
||||
await page.getByRole('menuitem', { name: 'Edit column' }).click()
|
||||
|
||||
const columnEditor = page.getByRole('dialog', {
|
||||
name: `Update column ${colName} from ${tableName}`,
|
||||
exact: true,
|
||||
})
|
||||
await expect(columnEditor).toBeVisible()
|
||||
|
||||
// The base type should render without the array-column's leading underscore
|
||||
// (pg-meta emits `_int4` for `int4[]`), and the array checkbox should reflect
|
||||
// the existing column's isArray state.
|
||||
await expect(
|
||||
columnEditor.getByRole('combobox').filter({ hasText: 'int4' }),
|
||||
'Type dropdown should show the bare base type, not the raw _int4 pg format'
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
columnEditor.getByRole('checkbox', { name: 'Define as Array' }),
|
||||
'Define as Array should be checked for an existing array column'
|
||||
).toBeChecked()
|
||||
|
||||
// Make an unrelated change and save - this must not corrupt the column's type.
|
||||
await columnEditor.getByLabel('Description').fill('an array column')
|
||||
const updatePromise = waitForApiResponse(page, 'pg-meta', ref, 'query?key=column-update', {
|
||||
method: 'POST',
|
||||
})
|
||||
await columnEditor.getByRole('button', { name: 'Save' }).click()
|
||||
await updatePromise
|
||||
await expect(columnEditor).not.toBeVisible()
|
||||
|
||||
// Verify the type survived the round trip via the Database > Tables column list,
|
||||
// which renders `displayColumnType` - arrays should show as `type[]`.
|
||||
const tableId = await getTableId(tableName)
|
||||
await page.goto(toUrl(`/project/${ref}/database/tables/${tableId}`))
|
||||
await expect(
|
||||
page.getByRole('row', { name: colName }).getByText('int4[]', { exact: true }),
|
||||
'Array column should display as int4[] in the table columns list'
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('editing a non-public-schema enum column preserves its schema-qualified type', async ({
|
||||
page,
|
||||
ref,
|
||||
}) => {
|
||||
const tableName = 'pw_ct_enum_rt'
|
||||
const schemaName = 'pw_ct_enum_schema'
|
||||
const enumName = 'pw_ct_status'
|
||||
const colName = 'status'
|
||||
const qualifiedType = `${schemaName}.${enumName}`
|
||||
|
||||
await using _ = await withSetupCleanup(
|
||||
() =>
|
||||
query(`
|
||||
drop table if exists public.${tableName} cascade;
|
||||
drop schema if exists ${schemaName} cascade;
|
||||
create schema ${schemaName};
|
||||
create type ${schemaName}.${enumName} as enum ('active', 'inactive');
|
||||
create table public.${tableName} (
|
||||
id bigint generated by default as identity primary key,
|
||||
${colName} ${schemaName}.${enumName}
|
||||
);
|
||||
alter table public.${tableName} enable row level security;
|
||||
`),
|
||||
() =>
|
||||
query(`
|
||||
drop table if exists public.${tableName} cascade;
|
||||
drop schema if exists ${schemaName} cascade;
|
||||
`)
|
||||
)
|
||||
|
||||
await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
|
||||
await page.getByRole('button', { name: `View ${tableName}`, exact: true }).click()
|
||||
await page.waitForURL(/\/editor\/\d+\?schema=public$/)
|
||||
|
||||
await page
|
||||
.getByRole('columnheader', { name: colName })
|
||||
.getByRole('button', { name: `Column ${colName} actions` })
|
||||
.click()
|
||||
await page.getByRole('menuitem', { name: 'Edit column' }).click()
|
||||
|
||||
const columnEditor = page.getByRole('dialog', {
|
||||
name: `Update column ${colName} from ${tableName}`,
|
||||
exact: true,
|
||||
})
|
||||
await expect(columnEditor).toBeVisible()
|
||||
|
||||
// The type dropdown must show the schema-qualified name, not the bare enum name -
|
||||
// otherwise a save would silently drop the format_schema and point at public.<enumName>.
|
||||
await expect(
|
||||
columnEditor.getByRole('combobox').filter({ hasText: qualifiedType }),
|
||||
'Type dropdown should show the schema-qualified enum name'
|
||||
).toBeVisible()
|
||||
|
||||
await columnEditor.getByLabel('Description').fill('a cross-schema enum column')
|
||||
const updatePromise = waitForApiResponse(page, 'pg-meta', ref, 'query?key=column-update', {
|
||||
method: 'POST',
|
||||
})
|
||||
await columnEditor.getByRole('button', { name: 'Save' }).click()
|
||||
await updatePromise
|
||||
await expect(columnEditor).not.toBeVisible()
|
||||
|
||||
const tableId = await getTableId(tableName)
|
||||
await page.goto(toUrl(`/project/${ref}/database/tables/${tableId}`))
|
||||
await expect(
|
||||
page.getByRole('row', { name: colName }).getByText(qualifiedType, { exact: true }),
|
||||
'Enum column from a non-public schema should display schema-qualified'
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('FK selector flags a mismatch between an array column and a scalar column of the same base type', async ({
|
||||
page,
|
||||
ref,
|
||||
}) => {
|
||||
const sourceTableName = 'pw_ct_fk_arr_mismatch_src'
|
||||
const targetTableName = 'pw_ct_fk_arr_mismatch_tgt'
|
||||
|
||||
await using _ = await withSetupCleanup(
|
||||
() =>
|
||||
query(`
|
||||
drop table if exists public.${sourceTableName} cascade;
|
||||
drop table if exists public.${targetTableName} cascade;
|
||||
create table public.${targetTableName} (
|
||||
id bigint generated by default as identity primary key,
|
||||
val int4 unique
|
||||
);
|
||||
create table public.${sourceTableName} (
|
||||
id bigint generated by default as identity primary key,
|
||||
vals int4[]
|
||||
);
|
||||
`),
|
||||
() =>
|
||||
query(`
|
||||
drop table if exists public.${sourceTableName} cascade;
|
||||
drop table if exists public.${targetTableName} cascade;
|
||||
`)
|
||||
)
|
||||
|
||||
await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
|
||||
await page.getByRole('button', { name: `View ${sourceTableName}`, exact: true }).click()
|
||||
await page.waitForURL(/\/editor\/\d+\?schema=public$/)
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: `View ${sourceTableName}`, exact: true })
|
||||
.locator('button[aria-haspopup="menu"]')
|
||||
.click()
|
||||
await page.getByRole('menuitem', { name: 'Edit table' }).click()
|
||||
|
||||
const tableQueryPromise = waitForApiResponseWithTimeout(page, (response) =>
|
||||
response.url().includes(`table-public-${targetTableName}`)
|
||||
)
|
||||
await page.getByRole('button', { name: 'Add foreign key relation' }).click()
|
||||
await page.getByRole('combobox', { name: 'Select a table to reference to' }).click()
|
||||
await page.getByRole('option', { name: `public ${targetTableName}` }).click()
|
||||
await tableQueryPromise
|
||||
|
||||
await page.getByRole('combobox', { name: `Column from public.${sourceTableName}` }).click()
|
||||
await page.getByRole('option', { name: 'vals int4[]' }).click()
|
||||
await page.getByRole('combobox', { name: `Column from public.${targetTableName}` }).click()
|
||||
await page.getByRole('option', { name: 'val int4' }).click()
|
||||
|
||||
await expect(
|
||||
page.getByText('Column types do not match'),
|
||||
'An array column and a scalar column of the same base type should be flagged as a mismatch'
|
||||
).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Save' }).last()).toBeDisabled()
|
||||
})
|
||||
|
||||
test('FK selector flags a mismatch between same-named enums from different schemas', async ({
|
||||
page,
|
||||
ref,
|
||||
}) => {
|
||||
const sourceTableName = 'pw_ct_fk_enum_mismatch_src'
|
||||
const targetTableName = 'pw_ct_fk_enum_mismatch_tgt'
|
||||
const schemaA = 'pw_ct_fk_enum_schema_a'
|
||||
const schemaB = 'pw_ct_fk_enum_schema_b'
|
||||
const enumName = 'pw_ct_fk_status'
|
||||
|
||||
await using _ = await withSetupCleanup(
|
||||
() =>
|
||||
query(`
|
||||
drop table if exists public.${sourceTableName} cascade;
|
||||
drop table if exists public.${targetTableName} cascade;
|
||||
drop schema if exists ${schemaA} cascade;
|
||||
drop schema if exists ${schemaB} cascade;
|
||||
create schema ${schemaA};
|
||||
create schema ${schemaB};
|
||||
create type ${schemaA}.${enumName} as enum ('a1', 'a2');
|
||||
create type ${schemaB}.${enumName} as enum ('b1', 'b2');
|
||||
create table public.${targetTableName} (
|
||||
id bigint generated by default as identity primary key,
|
||||
status ${schemaA}.${enumName} unique
|
||||
);
|
||||
create table public.${sourceTableName} (
|
||||
id bigint generated by default as identity primary key,
|
||||
status ${schemaB}.${enumName}
|
||||
);
|
||||
`),
|
||||
() =>
|
||||
query(`
|
||||
drop table if exists public.${sourceTableName} cascade;
|
||||
drop table if exists public.${targetTableName} cascade;
|
||||
drop schema if exists ${schemaA} cascade;
|
||||
drop schema if exists ${schemaB} cascade;
|
||||
`)
|
||||
)
|
||||
|
||||
await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
|
||||
await page.getByRole('button', { name: `View ${sourceTableName}`, exact: true }).click()
|
||||
await page.waitForURL(/\/editor\/\d+\?schema=public$/)
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: `View ${sourceTableName}`, exact: true })
|
||||
.locator('button[aria-haspopup="menu"]')
|
||||
.click()
|
||||
await page.getByRole('menuitem', { name: 'Edit table' }).click()
|
||||
|
||||
const tableQueryPromise = waitForApiResponseWithTimeout(page, (response) =>
|
||||
response.url().includes(`table-public-${targetTableName}`)
|
||||
)
|
||||
await page.getByRole('button', { name: 'Add foreign key relation' }).click()
|
||||
await page.getByRole('combobox', { name: 'Select a table to reference to' }).click()
|
||||
await page.getByRole('option', { name: `public ${targetTableName}` }).click()
|
||||
await tableQueryPromise
|
||||
|
||||
await page.getByRole('combobox', { name: `Column from public.${sourceTableName}` }).click()
|
||||
await page.getByRole('option', { name: `status ${schemaB}.${enumName}` }).click()
|
||||
await page.getByRole('combobox', { name: `Column from public.${targetTableName}` }).click()
|
||||
await page.getByRole('option', { name: `status ${schemaA}.${enumName}` }).click()
|
||||
|
||||
await expect(
|
||||
page.getByText('Column types do not match'),
|
||||
'Same-named enums from different schemas must not be treated as compatible'
|
||||
).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Save' }).last()).toBeDisabled()
|
||||
})
|
||||
|
||||
test('FK selector allows and creates a relation between two matching array columns', async ({
|
||||
page,
|
||||
ref,
|
||||
}) => {
|
||||
const sourceTableName = 'pw_ct_fk_arr_match_src'
|
||||
const targetTableName = 'pw_ct_fk_arr_match_tgt'
|
||||
|
||||
await using _ = await withSetupCleanup(
|
||||
() =>
|
||||
query(`
|
||||
drop table if exists public.${sourceTableName} cascade;
|
||||
drop table if exists public.${targetTableName} cascade;
|
||||
create table public.${targetTableName} (
|
||||
id bigint generated by default as identity primary key,
|
||||
tags int4[] unique
|
||||
);
|
||||
create table public.${sourceTableName} (
|
||||
id bigint generated by default as identity primary key,
|
||||
tags int4[]
|
||||
);
|
||||
`),
|
||||
() =>
|
||||
query(`
|
||||
drop table if exists public.${sourceTableName} cascade;
|
||||
drop table if exists public.${targetTableName} cascade;
|
||||
`)
|
||||
)
|
||||
|
||||
await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
|
||||
await page.getByRole('button', { name: `View ${sourceTableName}`, exact: true }).click()
|
||||
await page.waitForURL(/\/editor\/\d+\?schema=public$/)
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: `View ${sourceTableName}`, exact: true })
|
||||
.locator('button[aria-haspopup="menu"]')
|
||||
.click()
|
||||
await page.getByRole('menuitem', { name: 'Edit table' }).click()
|
||||
|
||||
const tableQueryPromise = waitForApiResponseWithTimeout(page, (response) =>
|
||||
response.url().includes(`table-public-${targetTableName}`)
|
||||
)
|
||||
await page.getByRole('button', { name: 'Add foreign key relation' }).click()
|
||||
await page.getByRole('combobox', { name: 'Select a table to reference to' }).click()
|
||||
await page.getByRole('option', { name: `public ${targetTableName}` }).click()
|
||||
await tableQueryPromise
|
||||
|
||||
await page.getByRole('combobox', { name: `Column from public.${sourceTableName}` }).click()
|
||||
await page.getByRole('option', { name: 'tags int4[]' }).click()
|
||||
await page.getByRole('combobox', { name: `Column from public.${targetTableName}` }).click()
|
||||
await page.getByRole('option', { name: 'tags int4[]' }).click()
|
||||
|
||||
await expect(page.getByText('Column types do not match')).not.toBeVisible()
|
||||
|
||||
const saveButton = page.getByRole('button', { name: 'Save' }).last()
|
||||
await expect(saveButton).toBeEnabled()
|
||||
|
||||
const fkCreatePromise = waitForApiResponseWithTimeout(page, (response) =>
|
||||
response.url().includes('query?key=')
|
||||
)
|
||||
await saveButton.click()
|
||||
await fkCreatePromise
|
||||
|
||||
await expect(
|
||||
page.getByRole('banner', { name: `Add foreign key relationship to ${sourceTableName}` })
|
||||
).not.toBeVisible()
|
||||
|
||||
const saveTablePromise = waitForApiResponseWithTimeout(
|
||||
page,
|
||||
(response) => response.url().includes('query?key=table-update'),
|
||||
30000
|
||||
)
|
||||
await page.getByRole('button', { name: 'Save' }).first().click()
|
||||
await saveTablePromise
|
||||
|
||||
await expect(page.getByTestId('table-editor-side-panel')).not.toBeVisible()
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: `View ${sourceTableName}`, exact: true })
|
||||
.locator('button[aria-haspopup="menu"]')
|
||||
.click()
|
||||
await page.getByRole('menuitem', { name: 'Edit table' }).click()
|
||||
await page.getByRole('heading', { name: 'Foreign keys' }).scrollIntoViewIfNeeded()
|
||||
await expect(
|
||||
page.getByRole('link', { name: `public.${targetTableName}` }),
|
||||
'Foreign key between matching array columns should have been created'
|
||||
).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user