Studio SQL export emits valid PostgreSQL literals (#44025)

## Summary
- fix the Studio SQL row formatter to emit raw boolean and numeric
literals instead of quoted strings
- serialize text array members as SQL string literals inside
`ARRAY[...]`
- escape fallback string formats outside `text`/`varchar`, and add
regression coverage for that path

Closes #44024

## Test plan
- [x] Ran a direct `tsx` smoke against `formatTableRowsToSQL()` for the
`storage.buckets` case and confirmed it now emits `true`, `false`,
`10485760`, and `ARRAY['image/*']`
- [x] Ran focused formatter smokes for JSON escaping, text arrays, and
fallback string formats like `citext`
- [x] `pnpm --filter studio test -- TableEntity.utils.test.ts`

Note: the targeted Vitest run is still blocked in this environment
before the test executes (`localStorage.getItem is not a function`).

---------

Co-authored-by: Alaister Young <alaister@users.noreply.github.com>
This commit is contained in:
MatejandAlaister Young authored and GitHub committed 2026-03-23 15:14:33 +00:00
1 parent 527c342837
commit b93733c811
3 files changed
+59 -7

No files matched your search

@@ -24,7 +24,7 @@ describe('TableEntity.utils: formatTableRowsToSQL', () => {
]
const result = formatTableRowsToSQL(table, rows)
const expected = `INSERT INTO "public"."people" ("id", "name") VALUES ('1', 'Person 1'), ('2', 'Person 2'), ('3', 'Person 3');`
const expected = `INSERT INTO "public"."people" ("id", "name") VALUES (1, 'Person 1'), (2, 'Person 2'), (3, 'Person 3');`
expect(result).toBe(expected)
})
@@ -48,7 +48,7 @@ describe('TableEntity.utils: formatTableRowsToSQL', () => {
]
const result = formatTableRowsToSQL(table, rows)
const expected = `INSERT INTO "public"."people" ("id", "name") VALUES ('1', 'Person 1'), ('2', null), ('3', 'Person 3');`
const expected = `INSERT INTO "public"."people" ("id", "name") VALUES (1, 'Person 1'), (2, null), (3, 'Person 3');`
expect(result).toBe(expected)
})
@@ -84,7 +84,55 @@ describe('TableEntity.utils: formatTableRowsToSQL', () => {
},
]
const result = formatTableRowsToSQL(table, rows)
const expected = `INSERT INTO "public"."demo" ("id", "name", "tags", "metadata") VALUES ('2', 'Person 1', ARRAY["tag-a","tag-c"], '{"version": 1}'), ('3', 'ONeil', ARRAY["tag-a"], '{"version": 1, "name": "O''Neil"}');`
const expected = `INSERT INTO "public"."demo" ("id", "name", "tags", "metadata") VALUES (2, 'Person 1', ARRAY['tag-a','tag-c'], '{"version": 1}'), (3, 'ONeil', ARRAY['tag-a'], '{"version": 1, "name": "O''Neil"}');`
expect(result).toBe(expected)
})
it('should emit valid Postgres literals for booleans, numbers and text arrays', () => {
const table: SupaTable = {
id: 1,
type: ENTITY_TYPE.TABLE,
columns: [
{ name: 'id', dataType: 'text', format: 'text', position: 0 },
{ name: 'public', dataType: 'bool', format: 'bool', position: 1 },
{ name: 'avif_autodetection', dataType: 'bool', format: 'bool', position: 2 },
{ name: 'file_size_limit', dataType: 'int8', format: 'int8', position: 3 },
{ name: 'allowed_mime_types', dataType: 'ARRAY', format: '_text', position: 4 },
],
name: 'buckets',
schema: 'storage',
comment: undefined,
estimateRowCount: 1,
}
const rows = [
{
id: 'emails',
public: true,
avif_autodetection: false,
file_size_limit: 10485760,
allowed_mime_types: ['image/*', "image/o'neil"],
},
]
const result = formatTableRowsToSQL(table, rows)
const expected = `INSERT INTO "storage"."buckets" ("id", "public", "avif_autodetection", "file_size_limit", "allowed_mime_types") VALUES ('emails', true, false, 10485760, ARRAY['image/*','image/o''neil']);`
expect(result).toBe(expected)
})
it('should escape fallback string formats outside text and varchar', () => {
const table: SupaTable = {
id: 1,
type: ENTITY_TYPE.TABLE,
columns: [{ name: 'email', dataType: 'USER-DEFINED', format: 'citext', position: 0 }],
name: 'users',
schema: 'public',
comment: undefined,
estimateRowCount: 1,
}
const rows = [{ email: "o'neil@example.com" }]
const result = formatTableRowsToSQL(table, rows)
const expected = `INSERT INTO "public"."users" ("email") VALUES ('o''neil@example.com');`
expect(result).toBe(expected)
})
@@ -124,7 +172,7 @@ describe('TableEntity.utils: formatTableRowsToSQL', () => {
]
const result = formatTableRowsToSQL(table, rows)
const expected = `INSERT INTO "public"."people" ("id", "name") VALUES ('1', 'Person 1'), ('2', 'Person 2');`
const expected = `INSERT INTO "public"."people" ("id", "name") VALUES (1, 'Person 1'), (2, 'Person 2');`
expect(result).toBe(expected)
})
})
@@ -56,6 +56,10 @@ export const formatTableRowsToSQL = (table: SupaTable, rows: any[]) => {
stringFormats.includes(format)
) {
return `'${val.replaceAll("'", "''")}'`
} else if (typeof val === 'number' || typeof val === 'boolean') {
return `${val}`
} else if (typeof val === 'string') {
return `'${val.replaceAll("'", "''")}'`
} else {
return `'${val}'`
}
@@ -83,7 +87,7 @@ const generateRandomTag = (): `$${string}$` => {
/**
* Wrap a string in dollar-quote tags, ensuring the tag does not appear in the string
*
* @throws Error if unable to generate a unique tag after multiple attempts
* @throws Error if unable to generate a unique dollar-quote tag after multiple attempts
*/
const safeDollarQuote = (str: string): string => {
let tag = generateRandomTag()
@@ -108,7 +112,7 @@ const formatArrayForSql = (arr: unknown[]): string => {
if (Array.isArray(item)) {
result += formatArrayForSql(item)
} else if (typeof item === 'string') {
result += `"${item.replace(/"/g, '""')}"`
result += `'${item.replaceAll("'", "''")}'`
} else if (!!item && typeof item === 'object') {
result += `${safeDollarQuote(JSON.stringify(item))}::json`
} else {
+1 -1
View File
@@ -665,7 +665,7 @@ testRunner('table editor', () => {
const downloadSqlPath = await downloadSql.path()
const sqlContent = fs.readFileSync(downloadSqlPath, 'utf-8')
expect(sqlContent).toBe(
`INSERT INTO "public"."${tableNameDataActions}" ("id", "created_at", "pw_column") VALUES ('4', '2025-01-01 12:00:00+00', 'value 4 to export'), ('5', '2025-01-01 12:00:00+00', 'value 5 to export'), ('6', '2025-01-01 12:00:00+00', 'value 6 to export');`
`INSERT INTO "public"."${tableNameDataActions}" ("id", "created_at", "pw_column") VALUES (4, '2025-01-01 12:00:00+00', 'value 4 to export'), (5, '2025-01-01 12:00:00+00', 'value 5 to export'), (6, '2025-01-01 12:00:00+00', 'value 6 to export');`
)
await page.waitForTimeout(1000) // wait for event processing to complete
fs.unlinkSync(downloadSqlPath)