Refactor table editor logic for handling null and undefined values (#44331)

## Context

Resolves https://github.com/supabase/supabase/issues/43548

There's currently an issue with the Table Editor where if you have, for
example, a nullable `text` column with a default value, inserting a new
row and selecting "Set to NULL" doesn't do anything, and saving will
insert the row with the default value
<img width="700" height="258" alt="image"
src="https://github.com/user-attachments/assets/6a284ebb-c346-40a6-9a30-793118844084"
/>

This stems from a legacy logic in the Table Editor whereby we treat
`null` values as "no input" - which is incorrect as `null` values are
also valid values. So the PR here changes a few things to resolve this
properly:

## Changes involved
Main fix: 
- `undefined` will be the "no input" value instead, and it'll be the
default value when generating the row object for inserting a new row
- `NULL` or even empty string like `''` will be treated as they are
(valid inputs)

Secondary adjustments:
- (Queue operations) Queueing an insert with no value but default value
is NULL, will show the placeholder as `DEFAULT` instead of `NULL` for
better accuracy in representation
<img width="892" height="96" alt="image"
src="https://github.com/user-attachments/assets/02cf86bf-c17b-4e25-9a8f-17960b1d2575"
/>
- Added a `Set to Default` CTA here, but will only show up if adding a
new row or updating a queued insert row operation, which will set the
value of the input field back to `undefined` for PG to handle it as the
default value
<img width="734" height="208" alt="image"
src="https://github.com/user-attachments/assets/23887c0c-533e-4494-acbe-61309ff5d7c5"
/>


## To test
Verify within the Table Editor (along with queue operation feature
preview)
- For inserting a new row, setting value to NULL and setting value to
Default works
- For updating a row, setting value to NULL works
This commit is contained in:
Joshen Lim authored and GitHub committed 2026-03-30 23:33:53 +08:00
1 parent 4295e41e81
commit a1abc2d00f
9 files changed
+187 -84

No files matched your search

@@ -200,15 +200,12 @@ function withPendingAddPlaceholders(
const value = props.row[props.column.key]
if (isPendingAddRow(props.row) && (value === undefined || value === null || value === '')) {
if (columnDef.defaultValue !== undefined && columnDef.defaultValue !== null) {
return <DefaultValue />
}
if (columnDef.isIdentity || columnDef.isGeneratable) {
return <DefaultValue />
}
if (columnDef.isNullable) {
if (value === null) {
return <NullValue />
}
if (columnDef.defaultValue !== undefined || columnDef.isIdentity || columnDef.isGeneratable) {
return <DefaultValue />
}
}
return <Formatter {...props} />
@@ -1,11 +1,11 @@
import { includes, noop } from 'lodash'
import { Edit, Eye } from 'lucide-react'
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
Input,
Select,
@@ -17,6 +17,7 @@ import {
SelectValue_Shadcn_,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { DATETIME_TYPES, JSON_TYPES, TEXT_TYPES } from '../SidePanelEditor.constants'
import { DateTimeInput } from './DateTimeInput'
import type { EditValue, RowField } from './RowEditor.types'
@@ -28,6 +29,7 @@ const TRUNCATE_DESCRIPTION =
export interface InputFieldProps {
field: RowField
errors: any
isNewRow?: boolean
isEditable?: boolean
onUpdateField?: (changes: object) => void
onEditJson?: (data: any) => void
@@ -38,6 +40,7 @@ export interface InputFieldProps {
export const InputField = ({
field,
errors,
isNewRow = false,
isEditable = true,
onUpdateField = noop,
onEditJson = noop,
@@ -146,6 +149,11 @@ export const InputField = ({
if (includes(TEXT_TYPES, field.format)) {
const isTruncated = isValueTruncated(field.value)
/**
* Handle `undefined` as the default value of the input field
* Otherwise, NULL should be treated as NULL, empty strings should be treated as empty strings
*/
return (
<div className="text-area-text-sm">
<Input.TextArea
@@ -166,24 +174,38 @@ export const InputField = ({
rows={5}
value={field.value ?? ''}
placeholder={
field.value === null && field.defaultValue === null
field.value === null
? 'NULL'
: field.value === '' ||
(typeof field.defaultValue === 'string' && field.defaultValue.length === 0)
? 'EMPTY'
: `Default: ${field.defaultValue}`
: `Default: ${field.defaultValue === null ? 'NULL' : field.defaultValue}`
}
actions={
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="default" icon={<Edit />} className="px-1.5" />
<Button
data-testid={`${field.name}-field-actions`}
type="default"
icon={<Edit />}
className="px-1.5"
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-28">
{isEditable && (
<DropdownMenuItem onClick={() => onUpdateField({ [field.name]: null })}>
Set to NULL
</DropdownMenuItem>
<>
<DropdownMenuItem onClick={() => onUpdateField({ [field.name]: null })}>
Set to NULL
</DropdownMenuItem>
{isNewRow && (
<DropdownMenuItem onClick={() => onUpdateField({ [field.name]: undefined })}>
Set to Default
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem
onClick={() => onEditText({ column: field.name, value: field.value || '' })}
>
@@ -133,7 +133,7 @@ export const RowEditor = ({
updateEditorDirty()
const payload = isNewRecord
? generateRowObjectFromFields(rowFields)
? generateRowObjectFromFields({ fields: rowFields })
: generateUpdateRowPayload(row, rowFields)
const configuration = { identifiers: {}, rowIdx: -1 }
@@ -226,6 +226,7 @@ export const RowEditor = ({
onEditText={setSelectedValueForTextEdit}
onSelectForeignKey={() => onOpenForeignRowSelector(field)}
isEditable={editable}
isNewRow={isNewRecord || '__tempId' in row}
/>
)
})}
@@ -254,6 +255,7 @@ export const RowEditor = ({
onEditJson={setSelectedValueForJsonEdit}
onSelectForeignKey={() => onOpenForeignRowSelector(field)}
isEditable={editable}
isNewRow={isNewRecord || '__tempId' in row}
/>
)
})}
@@ -12,7 +12,7 @@ export interface RowField {
comment: string
format: string
enums: string[]
value: string | null
value: string | null | undefined
defaultValue: string | null
foreignKey?: PostgresRelationship
isNullable: boolean
@@ -108,7 +108,7 @@ describe('generateRowObjectFromFields', () => {
{
id: '1',
name: 'id',
value: '',
value: undefined,
comment: '',
defaultValue: null,
format: 'int8',
@@ -120,7 +120,7 @@ describe('generateRowObjectFromFields', () => {
{
id: '2',
name: 'time_not_null',
value: '',
value: undefined,
comment: '',
defaultValue: 'now()',
format: 'timestamptz',
@@ -132,7 +132,7 @@ describe('generateRowObjectFromFields', () => {
{
id: '3',
name: 'time_nullable',
value: '',
value: undefined,
comment: '',
defaultValue: 'now()',
format: 'timestamptz',
@@ -142,7 +142,7 @@ describe('generateRowObjectFromFields', () => {
isPrimaryKey: false,
},
]
const result = generateRowObjectFromFields(sampleRowFields)
const result = generateRowObjectFromFields({ fields: sampleRowFields })
expect(result).toEqual({})
})
it('should discern EMPTY values for text', () => {
@@ -150,7 +150,7 @@ describe('generateRowObjectFromFields', () => {
{
id: '1',
name: 'id',
value: '',
value: undefined,
comment: '',
defaultValue: null,
format: 'int8',
@@ -172,7 +172,7 @@ describe('generateRowObjectFromFields', () => {
isPrimaryKey: false,
},
]
const result = generateRowObjectFromFields(sampleRowFields)
const result = generateRowObjectFromFields({ fields: sampleRowFields })
expect(result).toEqual({ name: '' })
})
it('should discern NULL values for text', () => {
@@ -180,7 +180,7 @@ describe('generateRowObjectFromFields', () => {
{
id: '1',
name: 'id',
value: '',
value: undefined,
comment: '',
defaultValue: null,
format: 'int8',
@@ -202,15 +202,15 @@ describe('generateRowObjectFromFields', () => {
isPrimaryKey: false,
},
]
const result = generateRowObjectFromFields(sampleRowFields)
expect(result).toEqual({})
const result = generateRowObjectFromFields({ fields: sampleRowFields })
expect(result).toEqual({ name: null })
})
it('should discern NULL values for booleans', () => {
const sampleRowFields: RowField[] = [
{
id: '1',
name: 'id',
value: '',
value: undefined,
comment: '',
defaultValue: null,
format: 'int8',
@@ -232,8 +232,8 @@ describe('generateRowObjectFromFields', () => {
isPrimaryKey: false,
},
]
const result = generateRowObjectFromFields(sampleRowFields)
expect(result).toEqual({})
const result = generateRowObjectFromFields({ fields: sampleRowFields })
expect(result).toEqual({ 'bool-test': null })
})
})
@@ -527,7 +527,7 @@ describe('generateRowObjectFromFields - additional cases', () => {
value: '["tag1", "tag2"]',
}),
]
expect(generateRowObjectFromFields(fields)).toEqual({ tags: ['tag1', 'tag2'] })
expect(generateRowObjectFromFields({ fields })).toEqual({ tags: ['tag1', 'tag2'] })
})
it('should handle null array fields', () => {
@@ -538,7 +538,18 @@ describe('generateRowObjectFromFields - additional cases', () => {
value: null,
}),
]
expect(generateRowObjectFromFields(fields)).toEqual({})
expect(generateRowObjectFromFields({ fields })).toEqual({ tags: null })
})
it('should handle undefined array fields', () => {
const fields: RowField[] = [
createField({
name: 'tags',
format: '_text',
value: undefined,
}),
]
expect(generateRowObjectFromFields({ fields })).toEqual({})
})
it('should handle JSON fields', () => {
@@ -549,7 +560,7 @@ describe('generateRowObjectFromFields - additional cases', () => {
value: '{"key": "value"}',
}),
]
expect(generateRowObjectFromFields(fields)).toEqual({ metadata: { key: 'value' } })
expect(generateRowObjectFromFields({ fields })).toEqual({ metadata: { key: 'value' } })
})
it('should handle JSON fields with object values', () => {
@@ -560,7 +571,7 @@ describe('generateRowObjectFromFields - additional cases', () => {
value: { key: 'value' } as any,
}),
]
expect(generateRowObjectFromFields(fields)).toEqual({ metadata: { key: 'value' } })
expect(generateRowObjectFromFields({ fields })).toEqual({ metadata: { key: 'value' } })
})
it('should handle boolean true/false/null', () => {
@@ -581,9 +592,9 @@ describe('generateRowObjectFromFields - additional cases', () => {
value: 'null',
}),
]
// By default, null values are omitted unless includeNullProperties is true
const result = generateRowObjectFromFields(fields)
expect(result).toEqual({ active: true, deleted: false })
// Null values are kept (explicit NULL), only undefined values are omitted
const result = generateRowObjectFromFields({ fields })
expect(result).toEqual({ active: true, deleted: false, optional: null })
})
it('should handle boolean true/false/null with includeNullProperties', () => {
@@ -604,19 +615,19 @@ describe('generateRowObjectFromFields - additional cases', () => {
value: 'null',
}),
]
const result = generateRowObjectFromFields(fields, true)
const result = generateRowObjectFromFields({ fields, includeUndefinedValues: true })
expect(result).toEqual({ active: true, deleted: false, optional: null })
})
it('should handle boolean with empty value', () => {
it('should handle boolean with undefined value', () => {
const fields: RowField[] = [
createField({
name: 'active',
format: 'bool',
value: '',
value: undefined,
}),
]
const result = generateRowObjectFromFields(fields)
const result = generateRowObjectFromFields({ fields })
expect(result).toEqual({})
})
@@ -628,7 +639,7 @@ describe('generateRowObjectFromFields - additional cases', () => {
value: '2023-12-01T10:30:45',
}),
]
const result: any = generateRowObjectFromFields(fields)
const result: any = generateRowObjectFromFields({ fields })
expect(result.created_at).toBeDefined()
expect(typeof result.created_at).toBe('string')
})
@@ -641,17 +652,17 @@ describe('generateRowObjectFromFields - additional cases', () => {
value: '2023-12-01T10:30',
}),
]
const result: any = generateRowObjectFromFields(fields)
const result: any = generateRowObjectFromFields({ fields })
expect(result.created_at).toBeDefined()
expect(typeof result.created_at).toBe('string')
})
it('should include null properties when includeNullProperties is true', () => {
it('should include undefined properties when includeUndefinedValues is true', () => {
const fields: RowField[] = [
createField({
name: 'optional_field',
format: 'text',
value: null,
value: undefined,
}),
createField({
name: 'active_field',
@@ -659,15 +670,32 @@ describe('generateRowObjectFromFields - additional cases', () => {
value: 'value',
}),
]
const result = generateRowObjectFromFields(fields, true)
expect(result).toEqual({ optional_field: null, active_field: 'value' })
const result = generateRowObjectFromFields({ fields, includeUndefinedValues: true })
expect(result).toEqual({ optional_field: undefined, active_field: 'value' })
})
it('should omit null properties when includeNullProperties is false', () => {
it('should omit undefined properties by default', () => {
const fields: RowField[] = [
createField({
name: 'optional_field',
format: 'text',
value: undefined,
}),
createField({
name: 'active_field',
format: 'text',
value: 'value',
}),
]
const result = generateRowObjectFromFields({ fields })
expect(result).toEqual({ active_field: 'value' })
})
it('should keep null properties (explicit NULL) even when includeUndefinedValues is false', () => {
const fields: RowField[] = [
createField({
name: 'nullable_field',
format: 'text',
value: null,
}),
createField({
@@ -676,8 +704,8 @@ describe('generateRowObjectFromFields - additional cases', () => {
value: 'value',
}),
]
const result = generateRowObjectFromFields(fields, false)
expect(result).toEqual({ active_field: 'value' })
const result = generateRowObjectFromFields({ fields })
expect(result).toEqual({ nullable_field: null, active_field: 'value' })
})
it('should preserve empty strings for text types', () => {
@@ -693,24 +721,24 @@ describe('generateRowObjectFromFields - additional cases', () => {
value: '',
}),
]
const result = generateRowObjectFromFields(fields)
const result = generateRowObjectFromFields({ fields })
expect(result).toEqual({ description: '', title: '' })
})
it('should convert empty values to null for non-text types', () => {
it('should omit undefined values for non-text types', () => {
const fields: RowField[] = [
createField({
name: 'count',
format: 'int4',
value: '',
value: undefined,
}),
createField({
name: 'price',
format: 'numeric',
value: '',
value: undefined,
}),
]
const result = generateRowObjectFromFields(fields)
const result = generateRowObjectFromFields({ fields })
expect(result).toEqual({})
})
})
@@ -2,7 +2,7 @@ import { MAX_ARRAY_SIZE, MAX_CHARACTERS } from '@supabase/pg-meta/src/query/tabl
import type { PostgresColumn, PostgresRelationship, PostgresTable } from '@supabase/postgres-meta'
import dayjs from 'dayjs'
import { minifyJSON, tryParseJson } from 'lib/helpers'
import { compact, isEqual, isNull, isString, omitBy } from 'lodash'
import { compact, isEqual, isNull, isString, isUndefined, omitBy } from 'lodash'
import type { Dictionary } from 'types'
import { ForeignKey } from '../ForeignKeySelector/ForeignKeySelector.types'
@@ -10,8 +10,8 @@ import {
DATETIME_TYPES,
JSON_TYPES,
TEXT_TYPES,
TIMESTAMP_TYPES,
TIME_TYPES,
TIMESTAMP_TYPES,
} from '../SidePanelEditor.constants'
import type { RowField } from './RowEditor.types'
@@ -20,7 +20,7 @@ const getRowValue = ({ column, row }: { column: PostgresColumn; row?: Dictionary
if (isNewRow) {
if (TEXT_TYPES.includes(column.format)) {
return null
return undefined
} else if (column.format === 'bool') {
if (column.default_value) {
return column.default_value
@@ -28,7 +28,7 @@ const getRowValue = ({ column, row }: { column: PostgresColumn; row?: Dictionary
return 'null'
} else return null
} else {
return ''
return undefined
}
} else {
if (column.format === 'bool' && row[column.name] === null) {
@@ -165,8 +165,11 @@ const convertPostgresDatetimeToInputDatetime = (format: string, value: string) =
}
}
const convertInputDatetimeToPostgresDatetime = (format: string, value: string | null) => {
if (!value || value.length == 0) return null
const convertInputDatetimeToPostgresDatetime = (
format: string,
value: string | null | undefined
) => {
if (!value || value.length === 0) return value
switch (format) {
case 'timestamptz':
@@ -182,26 +185,20 @@ const convertInputDatetimeToPostgresDatetime = (format: string, value: string |
}
}
// [Joshen] JFYI this presents a small problem in particular when creating a new row
// given that we don't include null properties. Because of that if the column has a default
// value, the column value will then always be the default value, instead of null
// which may be considered a bug if e.g for a boolean column the user specifically selects "NULL" option
// This would probably also apply to other column types like numbers (e.g user specifically wants a null value)
export const generateRowObjectFromFields = (
fields: RowField[],
includeNullProperties = false
): object => {
/**
* Treat `undefined` as "no input" - all else values as valid inputs (e.g NULL or empty string)
*/
export const generateRowObjectFromFields = ({
fields,
includeUndefinedValues = false,
}: {
fields: RowField[]
includeUndefinedValues?: boolean
}): object => {
const rowObject = {} as any
fields.forEach((field) => {
const isArray = field.format.startsWith('_')
// Do not convert empty field inputs to NULL for text types
// so that we discern NULL and EMPTY
const value = TEXT_TYPES.includes(field.format)
? field.value
: (field?.value ?? '').length === 0
? null
: field.value
const value = field.value
if (isArray && value !== null) {
rowObject[field.name] = tryParseJson(value)
@@ -224,12 +221,13 @@ export const generateRowObjectFromFields = (
rowObject[field.name] = value
}
})
return includeNullProperties ? rowObject : omitBy(rowObject, isNull)
const results = includeUndefinedValues ? rowObject : omitBy(rowObject, isUndefined)
return results
}
export const generateUpdateRowPayload = (originalRow: any, fields: RowField[]) => {
const includeNullProperties = true
const rowObject = generateRowObjectFromFields(fields, includeNullProperties) as any
const rowObject = generateRowObjectFromFields({ fields, includeUndefinedValues: true }) as any
const payload = {} as any
const properties = Object.keys(rowObject)
@@ -251,7 +249,7 @@ export const generateUpdateRowPayload = (originalRow: any, fields: RowField[]) =
payload[property] = rowObject[property]
}
} else {
const originalValue = originalRow[property] === undefined ? null : originalRow[property]
const originalValue = originalRow[property]
const newValue = rowObject[property]
if (!isEqual(originalValue, newValue)) {
payload[property] = newValue
@@ -3,8 +3,8 @@ import type { PostgresColumn, PostgresTable } from '@supabase/postgres-meta'
import { useQueryClient } from '@tanstack/react-query'
import { useParams } from 'common'
import { useTableRowOperations } from 'components/grid/hooks/useTableRowOperations'
import { type GeneratedPolicy } from 'components/interfaces/Auth/Policies/Policies.utils'
import { useIsQueueOperationsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { type GeneratedPolicy } from 'components/interfaces/Auth/Policies/Policies.utils'
import { DiscardChangesConfirmationDialog } from 'components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog'
import { databasePoliciesKeys } from 'data/database-policies/keys'
import { useDatabasePublicationCreateMutation } from 'data/database-publications/database-publications-create-mutation'
@@ -1,13 +1,13 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { Query } from '@supabase/pg-meta/src/query'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { executeSql } from 'data/sql/execute-sql-query'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { RoleImpersonationState, wrapWithRoleImpersonation } from 'lib/role-impersonation'
import { toast } from 'sonner'
import { isRoleImpersonationEnabled } from 'state/role-impersonation-state'
import type { ResponseError, UseCustomMutationOptions } from 'types'
import { tableRowKeys } from './keys'
export type TableRowCreateVariables = {
+56
View File
@@ -1522,6 +1522,62 @@ testRunner('table editor', () => {
})
})
test('inserting a row with NULL value for nullable text column with default', async ({
page,
ref,
}) => {
const tableName = 'pw_table_null_text_insert'
const columnName = 'description'
await using _ = await withSetupCleanup(
async () => {
await query(
`CREATE TABLE IF NOT EXISTS ${tableName} (
id bigint generated by default as identity primary key,
created_at timestamp with time zone null default now(),
${columnName} text null default 'hello world'
)`
)
},
async () => {
await dropTable(tableName)
}
)
await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
await waitForTableToLoad(page, ref)
await page.getByRole('button', { name: `View ${tableName}`, exact: true }).click()
await page.waitForURL(/\/editor\/\d+\?schema=public$/)
// Open side panel to insert a new row
await page.getByTestId('table-editor-insert-new-row').click()
await page.getByRole('menuitem', { name: 'Insert row Insert a new row' }).click()
// Open the field actions dropdown and set the text column to NULL
await page.getByTestId(`${columnName}-field-actions`).click()
await page.getByRole('menuitem', { name: 'Set to NULL' }).click()
// Save the row
const insertPromise = waitForApiResponse(page, 'pg-meta', ref, 'query?key=', {
method: 'POST',
})
await page.getByTestId('action-bar-save-row').click()
await insertPromise
// Should show success toast
await expect(
page.getByText('Successfully created row'),
'Success toast should appear after row creation'
).toBeVisible({ timeout: 10000 })
// The new row's text column should show NULL in the grid
await expect(
page.getByRole('gridcell', { name: 'NULL' }).first(),
'Text column should display NULL after setting to NULL via row editor'
).toBeVisible()
})
test('copying cell content from referencing record peek copies the correct value', async ({
page,
ref,