fix: enum quotes (#45023)

## TL;DR

fixes enum create/update failures when names contain quotes
(also added a smol e2e)

## Ex:

<table>
  <tr>
    <td><strong>Before</strong></td>
    <td><strong>After</strong></td>
  </tr>
  <tr>
    <td>
<img width="424" height="178" alt="Before"
src="https://github.com/user-attachments/assets/d1815f4e-3879-4f8d-8d24-40d2c1f5563d"
/>
    </td>
    <td>
<img width="233" height="75" alt="After fix"
src="https://github.com/user-attachments/assets/f3f9b53c-b234-4e18-9b2d-db97ca4713d5"
/>
    </td>
  </tr>
</table>

## ref:

- closes https://github.com/supabase/supabase/issues/45022


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Fixed enumerated type description handling to preserve special
characters (quotes and apostrophes) without unintended escaping.

* **Tests**
* Extended enumerated types test coverage to include creation, updates,
and deletion of types with special characters in names and descriptions.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Vaibhav
2026-04-20 20:37:36 +05:30
committed by GitHub
parent 8f69a10cc9
commit 8c4ae77ece
4 changed files with 78 additions and 21 deletions
@@ -114,7 +114,7 @@ const CreateEnumeratedTypeSidePanel = ({
connectionString: project.connectionString,
schema,
name: data.name,
description: data.description?.replaceAll("'", "''"),
description: data.description,
values: data.values.filter((x) => x.value.length > 0).map((x) => x.value.trim()),
})
}
@@ -126,7 +126,7 @@ const EditEnumeratedTypeSidePanel = ({
isNew: x.isNew,
})),
...(data.description !== selectedEnumeratedType.comment
? { description: data.description?.replaceAll("'", "''") }
? { description: data.description }
: {}),
}
+59 -3
View File
@@ -1051,12 +1051,31 @@ test.describe('Database Enumerated Types', () => {
const databaseEnumValue1Name = 'pw_database_value1'
const databaseEnumValue2Name = 'pw_database_value2'
const databaseEnumValue3Name = 'pw_database_value3'
const quotedEnumName = 'pw_database_enum_"quoted"'
const updatedQuotedEnumName = 'pw_database_enum_"updated"'
const quotedEnumValue1Name = 'pw_database_value_"double"'
const quotedEnumValue2Name = `pw_database_value's_apostrophe`
const quotedEnumValue3Name = `pw_database_value_"combo"'s`
const quotedEnumTypes = [quotedEnumName, updatedQuotedEnumName].map(
(name) => `public."${name.replaceAll('"', '""')}"`
)
await using _ = await withSetupCleanup(
async () => {
for (const typeName of quotedEnumTypes) {
await query(`drop type if exists ${typeName};`)
}
},
async () => {
for (const typeName of quotedEnumTypes) {
await query(`drop type if exists ${typeName};`)
}
}
)
const wait = createApiResponseWaiter(page, 'pg-meta', ref, 'query?key=schemas')
await page.goto(toUrl(`/project/${env.PROJECT_REF}/database/types?schema=public`))
// Wait for database roles list to be populated
await wait
await page.waitForLoadState('networkidle')
// if enum exists, delete it.
if ((await page.getByRole('cell', { name: databaseEnumName, exact: true }).count()) > 0) {
@@ -1105,6 +1124,43 @@ test.describe('Database Enumerated Types', () => {
await expect(page.getByText(`Successfully deleted type "${databaseEnumName}"`)).toBeVisible({
timeout: 50000,
})
await page.getByRole('button', { name: 'Create type' }).click()
await page.getByRole('textbox', { name: 'Name' }).fill(quotedEnumName)
await page.locator('input[name="values.0.value"]').fill(quotedEnumValue1Name)
await page.getByRole('button', { name: 'Add value' }).click()
await page.locator('input[name="values.1.value"]').fill(quotedEnumValue2Name)
const quotedEnumCreateWait = createApiResponseWaiter(page, 'pg-meta', ref, 'types')
await page.getByRole('button', { name: 'Create type' }).click()
await quotedEnumCreateWait
const quotedEnumRow = page.getByRole('row', { name: `${quotedEnumName}` })
await expect(quotedEnumRow).toContainText(quotedEnumName)
await expect(quotedEnumRow).toContainText(
`${quotedEnumValue1Name}, ${quotedEnumValue2Name}`
)
await quotedEnumRow.getByRole('button').click()
await page.getByRole('menuitem', { name: 'Update type' }).click()
await page.getByRole('textbox', { name: 'Name' }).fill(updatedQuotedEnumName)
await page.getByRole('button', { name: 'Add value' }).click()
await page.locator('input[name="values.2.updatedValue"]').fill(quotedEnumValue3Name)
await page.getByRole('button', { name: 'Update type' }).click()
const updatedQuotedEnumRow = page.getByRole('row', { name: `${updatedQuotedEnumName}` })
await expect(updatedQuotedEnumRow).toContainText(updatedQuotedEnumName)
await expect(updatedQuotedEnumRow).toContainText(
`${quotedEnumValue1Name}, ${quotedEnumValue2Name}, ${quotedEnumValue3Name}`
)
await updatedQuotedEnumRow.getByRole('button').click()
await page.getByRole('menuitem', { name: 'Delete type' }).click()
await page.getByRole('heading', { name: 'Confirm to delete enumerated' }).click()
await page.getByRole('button', { name: 'Confirm delete' }).click()
await expect(
page.getByText(`Successfully deleted type "${updatedQuotedEnumName}"`)
).toBeVisible({
timeout: 50000,
})
})
})
@@ -1,3 +1,4 @@
import { ident, literal } from '../../../pg-format'
import { wrapWithTransaction } from '../../../query'
export const getCreateEnumeratedTypeSQL = ({
@@ -11,17 +12,15 @@ export const getCreateEnumeratedTypeSQL = ({
values: string[]
description?: string
}) => {
const createSql = `create type "${schema}"."${name}" as enum (${values
.map((x) => `'${x}'`)
.join(', ')});`
const typeSql = `${ident(schema)}.${ident(name)}`
const createSql = `create type ${typeSql} as enum (${values.map(literal).join(', ')});`
const commentSql =
description !== undefined ? `comment on type "${schema}"."${name}" is '${description}';` : ''
const sql = wrapWithTransaction(`${createSql} ${commentSql}`)
return sql
description !== undefined ? `comment on type ${typeSql} is ${literal(description)};` : ''
return wrapWithTransaction(`${createSql} ${commentSql}`)
}
export const getDeleteEnumeratedTypeSQL = ({ schema, name }: { schema: string; name: string }) => {
return `drop type if exists ${schema}."${name}"`
return `drop type if exists ${ident(schema)}.${ident(name)}`
}
export const getUpdateEnumeratedTypeSQL = ({
@@ -36,9 +35,14 @@ export const getUpdateEnumeratedTypeSQL = ({
values?: { original: string; updated: string; isNew: boolean }[]
}) => {
const statements: string[] = []
const typeSql = `${ident(schema)}.${ident(name.updated)}`
if (name.original !== name.updated) {
statements.push(`alter type "${schema}"."${name.original}" rename to "${name.updated}";`)
statements.push(
`alter type ${ident(schema)}.${ident(name.original)} rename to ${ident(name.updated)};`
)
}
if (values.length > 0) {
values.forEach((x, idx) => {
if (x.isNew) {
@@ -46,26 +50,23 @@ export const getUpdateEnumeratedTypeSQL = ({
// Consider if any new enums were added before any existing enums
const firstExistingEnumValue = values.find((x) => !x.isNew)
statements.push(
`alter type "${schema}"."${name.updated}" add value '${x.updated}' before '${firstExistingEnumValue?.original}';`
`alter type ${typeSql} add value ${literal(x.updated)} before ${literal(firstExistingEnumValue?.original)};`
)
} else {
statements.push(
`alter type "${schema}"."${name.updated}" add value '${x.updated}' after '${
values[idx - 1].updated
}';`
`alter type ${typeSql} add value ${literal(x.updated)} after ${literal(values[idx - 1].updated)};`
)
}
} else if (x.original !== x.updated) {
statements.push(
`alter type "${schema}"."${name.updated}" rename value '${x.original}' to '${x.updated}';`
`alter type ${typeSql} rename value ${literal(x.original)} to ${literal(x.updated)};`
)
}
})
}
if (description !== undefined) {
statements.push(`comment on type "${schema}"."${name.updated}" is '${description}';`)
statements.push(`comment on type ${typeSql} is ${literal(description)};`)
}
const sql = wrapWithTransaction(statements.join(' '))
return sql
return wrapWithTransaction(statements.join(' '))
}