From b93733c811c5f81b076ae3eb076c39c1a4aeeb91 Mon Sep 17 00:00:00 2001 From: Matej <49211125+voiys@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:14:33 +0100 Subject: [PATCH] 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 --- .../TableGridEditor/TableEntity.utils.test.ts | 56 +++++++++++++++++-- .../TableGridEditor/TableEntity.utils.ts | 8 ++- e2e/studio/features/table-editor.spec.ts | 2 +- 3 files changed, 59 insertions(+), 7 deletions(-) diff --git a/apps/studio/components/interfaces/TableGridEditor/TableEntity.utils.test.ts b/apps/studio/components/interfaces/TableGridEditor/TableEntity.utils.test.ts index 24b0133b77b..756eea86af4 100644 --- a/apps/studio/components/interfaces/TableGridEditor/TableEntity.utils.test.ts +++ b/apps/studio/components/interfaces/TableGridEditor/TableEntity.utils.test.ts @@ -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) }) }) diff --git a/apps/studio/components/interfaces/TableGridEditor/TableEntity.utils.ts b/apps/studio/components/interfaces/TableGridEditor/TableEntity.utils.ts index c091fb6bbc6..565174ed7cd 100644 --- a/apps/studio/components/interfaces/TableGridEditor/TableEntity.utils.ts +++ b/apps/studio/components/interfaces/TableGridEditor/TableEntity.utils.ts @@ -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 { diff --git a/e2e/studio/features/table-editor.spec.ts b/e2e/studio/features/table-editor.spec.ts index 652bd6b1e77..fb8aaf26a8d 100644 --- a/e2e/studio/features/table-editor.spec.ts +++ b/e2e/studio/features/table-editor.spec.ts @@ -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)