diff --git a/e2e/studio/features/column-editor-types.spec.ts b/e2e/studio/features/column-editor-types.spec.ts new file mode 100644 index 00000000000..02c47c91d3e --- /dev/null +++ b/e2e/studio/features/column-editor-types.spec.ts @@ -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 => { + 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.. + 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() + }) +})