From badf16be07d1776a68bc03e6e683603c3926846e Mon Sep 17 00:00:00 2001 From: Alaister Young Date: Wed, 22 Jul 2026 22:19:36 +0800 Subject: [PATCH] [FE-3909] fix(studio): exclude generated columns from row insert form (#48195) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inserting a row through the table editor failed on any table with a `GENERATED ALWAYS AS (...) STORED` column — the row editor sent an explicit value for the generated column (e.g. `false` for booleans, since the bool `Select` never hits the empty-string default heuristic from #46826), which Postgres rejects with `428C9: cannot insert a non-DEFAULT value into column`. **Changed:** - `RowField` now carries `isGenerated` (from pg-meta's `is_generated`, previously unused by Studio) - Generated columns are hidden from the row editor form (they're always computed by the database, so there's nothing to input) but stay in `rowFields` state so primary-key identifier logic is unaffected - `generateRowObjectFromFields` skips generated fields, so they're omitted from both insert and update payloads - `validateFields` skips generated fields — an error on a hidden field would be unfixable **Added:** - e2e test covering inserting a row into a table with a generated boolean column - unit tests for generated-column omission in insert/update payloads and validation ## To test 1. Create a table with a generated column: ```sql create table t ( id bigint generated by default as identity primary key, base_price int, discounted_price int, is_discounted boolean generated always as ( base_price is distinct from discounted_price ) stored ); ``` 2. Table Editor → `t` → Insert row — `is_discounted` should not appear in the form 3. Fill the other fields and save — the insert should succeed and the grid should show the computed value 4. Edit an existing row and save — should still work (generated column untouched) 5. Sanity-check a normal table with identity/default columns — clearing a default field on insert should still fall back to the default (#46826 behavior) Addresses [FE-3909](https://linear.app/supabase/issue/FE-3909/studio-insert-form-fails-on-generated-boolean-columns) ## Summary by CodeRabbit * **New Features** * Added support for generated columns in the table editor. * Generated columns are automatically computed and excluded from insert and update forms. * Generated values now appear correctly in the table after saving a row. * **Bug Fixes** * Prevented validation errors for non-editable generated fields. * Ensured generated columns are excluded from submitted row data. Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com> --- .../SidePanelEditor/RowEditor/RowEditor.tsx | 4 +- .../RowEditor/RowEditor.types.ts | 1 + .../RowEditor/RowEditor.utils.test.ts | 92 +++++++++++++++++++ .../RowEditor/RowEditor.utils.ts | 8 ++ e2e/studio/features/table-editor.spec.ts | 59 ++++++++++++ 5 files changed, 163 insertions(+), 1 deletion(-) diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.tsx index 6c4b5a08f37..638871cbd03 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.tsx @@ -65,8 +65,10 @@ export const RowEditor = ({ const [loading, setLoading] = useState(false) const [createMore, setCreateMore] = useState(false) + // Generated columns are always computed by the database, so they are excluded from the form. + // They remain in rowFields as primary key identifiers may rely on them. const [requiredFields, optionalFields] = partition( - rowFields, + rowFields.filter((rowField) => !rowField.isGenerated), (rowField: any) => !rowField.isNullable ) diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.types.ts b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.types.ts index f46ccd01f43..942cbddc9f4 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.types.ts +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.types.ts @@ -17,5 +17,6 @@ export interface RowField { foreignKey?: PGTableRelationship isNullable: boolean isIdentity: boolean + isGenerated: boolean isPrimaryKey: boolean } diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.utils.test.ts b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.utils.test.ts index 4735666c5ab..7fbe7e41464 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.utils.test.ts +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.utils.test.ts @@ -4,6 +4,7 @@ import { RowField } from '@/components/interfaces/TableGridEditor/SidePanelEdito import { convertByteaToHex, generateRowObjectFromFields, + generateUpdateRowPayload, isValueTruncated, parseValue, validateFields, @@ -116,6 +117,7 @@ describe('generateRowObjectFromFields', () => { enums: [], isNullable: false, isIdentity: false, + isGenerated: false, isPrimaryKey: false, }, { @@ -128,6 +130,7 @@ describe('generateRowObjectFromFields', () => { isNullable: false, enums: [], isIdentity: false, + isGenerated: false, isPrimaryKey: false, }, { @@ -140,6 +143,7 @@ describe('generateRowObjectFromFields', () => { isNullable: true, enums: [], isIdentity: false, + isGenerated: false, isPrimaryKey: false, }, ] @@ -158,6 +162,7 @@ describe('generateRowObjectFromFields', () => { enums: [], isNullable: false, isIdentity: false, + isGenerated: false, isPrimaryKey: false, }, { @@ -170,6 +175,7 @@ describe('generateRowObjectFromFields', () => { enums: [], isNullable: false, isIdentity: false, + isGenerated: false, isPrimaryKey: false, }, ] @@ -188,6 +194,7 @@ describe('generateRowObjectFromFields', () => { enums: [], isNullable: false, isIdentity: true, + isGenerated: false, isPrimaryKey: true, }, { @@ -200,6 +207,7 @@ describe('generateRowObjectFromFields', () => { enums: [], isNullable: false, isIdentity: false, + isGenerated: false, isPrimaryKey: false, }, { @@ -212,6 +220,7 @@ describe('generateRowObjectFromFields', () => { enums: [], isNullable: false, isIdentity: false, + isGenerated: false, isPrimaryKey: false, }, ] @@ -233,6 +242,7 @@ describe('generateRowObjectFromFields', () => { enums: [], isNullable: false, isIdentity: false, + isGenerated: false, isPrimaryKey: false, }, { @@ -245,6 +255,7 @@ describe('generateRowObjectFromFields', () => { enums: [], isNullable: false, isIdentity: false, + isGenerated: false, isPrimaryKey: false, }, ] @@ -263,6 +274,7 @@ describe('generateRowObjectFromFields', () => { enums: [], isNullable: false, isIdentity: false, + isGenerated: false, isPrimaryKey: false, }, { @@ -275,6 +287,7 @@ describe('generateRowObjectFromFields', () => { enums: [], isNullable: false, isIdentity: false, + isGenerated: false, isPrimaryKey: false, }, ] @@ -419,6 +432,7 @@ describe('validateFields', () => { defaultValue: null, isNullable: true, isIdentity: false, + isGenerated: false, isPrimaryKey: false, ...overrides, }) @@ -531,6 +545,18 @@ describe('validateFields', () => { expect(validateFields(fields)).toEqual({}) }) + it('should skip validation for generated columns', () => { + const fields: RowField[] = [ + createField({ + name: 'gen_tags', + format: '_text', + value: '[invalid array', + isGenerated: true, + }), + ] + expect(validateFields(fields)).toEqual({}) + }) + it('should validate multiple fields and return all errors', () => { const fields: RowField[] = [ createField({ @@ -567,6 +593,7 @@ describe('generateRowObjectFromFields - additional cases', () => { defaultValue: null, isNullable: true, isIdentity: false, + isGenerated: false, isPrimaryKey: false, ...overrides, }) @@ -793,4 +820,69 @@ describe('generateRowObjectFromFields - additional cases', () => { const result = generateRowObjectFromFields({ fields }) expect(result).toEqual({}) }) + + it('should omit generated columns even when they hold a value', () => { + const fields: RowField[] = [ + createField({ + name: 'price', + format: 'int4', + value: '100', + }), + createField({ + name: 'is_discounted', + format: 'bool', + value: 'false', + // pg-meta populates default_value with the generation expression for generated columns + defaultValue: '(price < 100)', + isGenerated: true, + }), + ] + const result = generateRowObjectFromFields({ fields, useDefaultForEmptyValues: true }) + expect(result).toEqual({ price: '100' }) + }) + + it('should omit generated columns when includeUndefinedValues is true', () => { + const fields: RowField[] = [ + createField({ + name: 'price', + format: 'int4', + value: '100', + }), + createField({ + name: 'is_discounted', + format: 'bool', + value: 'true', + isGenerated: true, + }), + ] + const result = generateRowObjectFromFields({ fields, includeUndefinedValues: true }) + expect(result).toEqual({ price: '100' }) + }) +}) + +describe('generateUpdateRowPayload', () => { + const createField = (overrides: Partial): RowField => ({ + id: '1', + name: 'test_field', + comment: '', + format: 'text', + enums: [], + value: '', + defaultValue: null, + isNullable: true, + isIdentity: false, + isGenerated: false, + isPrimaryKey: false, + ...overrides, + }) + + it('should not include generated columns in update payloads', () => { + const fields: RowField[] = [ + createField({ name: 'id', format: 'int8', value: '1', isPrimaryKey: true }), + createField({ name: 'price', format: 'int4', value: '90' }), + createField({ name: 'is_discounted', format: 'bool', value: 'true', isGenerated: true }), + ] + const payload = generateUpdateRowPayload({ id: '1', price: '100', is_discounted: true }, fields) + expect(payload).toEqual({ price: '90' }) + }) }) diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.utils.ts b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.utils.ts index d9e83aae425..90466bf8eb2 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.utils.ts +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.utils.ts @@ -79,6 +79,7 @@ export const generateRowFields = ( defaultValue: column?.default_value as string | null, isNullable: column.is_nullable, isIdentity: column.is_identity, + isGenerated: column.is_generated, isPrimaryKey: primaryKeyColumns.includes(column.name), } }) @@ -87,6 +88,9 @@ export const generateRowFields = ( export const validateFields = (fields: RowField[]) => { const errors = {} as any fields.forEach((field) => { + // Generated columns are not shown in the form, so any error on them would not be fixable + if (field.isGenerated) return + const isArray = field.format.startsWith('_') if (isArray && field.value) { @@ -199,6 +203,10 @@ export const generateRowObjectFromFields = ({ }): object => { const rowObject = {} as any fields.forEach((field) => { + // Generated columns are always computed by the database - Postgres rejects + // inserts and updates that provide an explicit value for them + if (field.isGenerated) return + const isArray = field.format.startsWith('_') const value = field.value const shouldUseDefaultValue = diff --git a/e2e/studio/features/table-editor.spec.ts b/e2e/studio/features/table-editor.spec.ts index 649f527acc5..3cf3afea341 100644 --- a/e2e/studio/features/table-editor.spec.ts +++ b/e2e/studio/features/table-editor.spec.ts @@ -1153,6 +1153,65 @@ testRunner('table editor', () => { ).toBeVisible() }) + test('can insert a row into a table with a generated column', async ({ page, ref }) => { + const tableName = 'pw_table_generated_column' + + await using _ = await withSetupCleanup( + async () => { + await query(` + create table public.${tableName} ( + id bigint generated by default as identity primary key, + base_price int, + discounted_price int, + is_discounted boolean generated always as ( + base_price is not null + and discounted_price is not null + and base_price is distinct from discounted_price + ) stored + ); + `) + }, + async () => { + await dropTable(tableName) + } + ) + + 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$/) + + // insert a row, filling only the non-generated columns + await page.getByTestId('table-editor-insert-new-row').click() + await page.getByRole('menuitem', { name: 'Insert row' }).click() + + const sidePanel = page.getByTestId('side-panel-row-editor') + await page.getByTestId('base_price-input').fill('100') + await page.getByTestId('discounted_price-input').fill('80') + const insertPromise = waitForApiResponse(page, 'pg-meta', ref, 'query?key=', { + method: 'POST', + }) + await page.getByTestId('action-bar-save-row').click() + await insertPromise + + await expect( + sidePanel, + 'Row editor should close after inserting a row into a table with a generated column' + ).not.toBeVisible() + await expect( + page.getByRole('gridcell', { name: 'TRUE' }), + 'Generated column value should be computed by the database' + ).toBeVisible() + + // the generated column should not be editable in the insert form + await page.getByTestId('table-editor-insert-new-row').click() + await page.getByRole('menuitem', { name: 'Insert row' }).click() + await expect(sidePanel.getByText('base_price')).toBeVisible() + await expect( + sidePanel.getByText('is_discounted'), + 'Generated column should not be shown in the insert form' + ).not.toBeVisible() + }) + test('can create and remove foreign key with column selection', async ({ page, ref }) => { const sourceTableName = 'pw_table_fk_source' const targetTableName = 'pw_table_fk_target'