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:
Charis authored and GitHub committed 2026-07-02 13:24:54 -04:00
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()
})
})