diff --git a/apps/desktop/src/lib/dataGridCellCoercion.ts b/apps/desktop/src/lib/dataGridCellCoercion.ts index f5589eb85c..eb7e9e9d74 100644 --- a/apps/desktop/src/lib/dataGridCellCoercion.ts +++ b/apps/desktop/src/lib/dataGridCellCoercion.ts @@ -56,13 +56,33 @@ function coercePostgresArrayValue(options: CoerceDataGridCellValueOptions): unkn if (options.databaseType !== "postgres") return undefined; if (!isPostgresArrayColumn(options.columnInfo, options.oldValue)) return undefined; const trimmed = options.value.trim(); - if (!trimmed.startsWith("[")) return undefined; - try { - const parsed = JSON.parse(normalizeSmartQuotes(trimmed)); - return Array.isArray(parsed) ? parsed : undefined; - } catch { - return undefined; + + if (trimmed.startsWith("[")) { + try { + const parsed = JSON.parse(normalizeSmartQuotes(trimmed)); + return Array.isArray(parsed) ? parsed : undefined; + } catch { + return undefined; + } } + + if (trimmed.startsWith("{")) { + try { + const parsed = parsePostgresArrayText(trimmed); + if (Array.isArray(options.oldValue) && deepEqual(parsed, options.oldValue)) { + return options.oldValue; + } + return parsed; + } catch { + return undefined; + } + } + + return undefined; +} + +function deepEqual(a: unknown, b: unknown): boolean { + return JSON.stringify(a) === JSON.stringify(b); } function isPostgresArrayColumn( @@ -115,3 +135,74 @@ function formatPostgresArrayElement(value: unknown): string { function needsQuotedPostgresArrayElement(value: string): boolean { return value === "" || /[\s,"{}\\]/.test(value) || value.toUpperCase() === "NULL"; } + +function parsePostgresArrayText(value: string): unknown[] { + const trimmed = value.trim(); + if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) { + throw new Error("Invalid PG array literal"); + } + const inner = trimmed.slice(1, -1); + if (inner.length === 0) return []; + + const elements: unknown[] = []; + let i = 0; + while (i < inner.length) { + while (i < inner.length && inner[i] === " ") i++; + if (i >= inner.length) break; + + let element: unknown; + if (inner[i] === '"') { + i++; + let str = ""; + while (i < inner.length) { + if (inner[i] === "\\" && i + 1 < inner.length) { + i++; + str += inner[i]; + i++; + } else if (inner[i] === '"') { + i++; + break; + } else { + str += inner[i]; + i++; + } + } + element = str; + } else if (inner[i] === "{") { + let depth = 0; + const start = i; + while (i < inner.length) { + if (inner[i] === "{") depth++; + else if (inner[i] === "}") { + depth--; + if (depth === 0) { + i++; + break; + } + } + i++; + } + element = parsePostgresArrayText(inner.slice(start, i)); + } else { + let start = i; + while (i < inner.length && inner[i] !== "," && inner[i] !== "}") i++; + const token = inner.slice(start, i).trim(); + if (token.toUpperCase() === "NULL") { + element = null; + } else if (/^(true|false)$/i.test(token)) { + element = token.toLowerCase() === "true"; + } else if (/^-?\d+(\.\d+)?([eE][+-]?\d+)?$/.test(token)) { + element = Number(token); + } else { + element = token; + } + } + + elements.push(element); + + while (i < inner.length && inner[i] === " ") i++; + if (i < inner.length && inner[i] === ",") i++; + } + + return elements; +} diff --git a/packages/app-tests/dataGridCellCoercion.test.ts b/packages/app-tests/dataGridCellCoercion.test.ts index 20e6f116d1..aee1fcd418 100644 --- a/packages/app-tests/dataGridCellCoercion.test.ts +++ b/packages/app-tests/dataGridCellCoercion.test.ts @@ -38,3 +38,105 @@ test("coerces JSON style input for Postgres array columns", () => { ["draft", "发布"], ); }); + +test("coerces PG brace-style input for Postgres array columns", () => { + assert.deepEqual( + coerceDataGridCellValue({ + value: "{1,2,3}", + oldValue: [1, 2, 3], + databaseType: "postgres", + columnInfo: { data_type: "_int4" }, + }), + [1, 2, 3], + ); +}); + +test("coerces PG brace-style string array", () => { + assert.deepEqual( + coerceDataGridCellValue({ + value: `{draft,"needs space",发布}`, + oldValue: [], + databaseType: "postgres", + columnInfo: { data_type: "_text" }, + }), + ["draft", "needs space", "发布"], + ); +}); + +test("coerces PG brace-style array with NULL", () => { + assert.deepEqual( + coerceDataGridCellValue({ + value: "{1,NULL,3}", + oldValue: [], + databaseType: "postgres", + columnInfo: { data_type: "_int4" }, + }), + [1, null, 3], + ); +}); + +test("coerces empty PG brace-style array", () => { + assert.deepEqual( + coerceDataGridCellValue({ + value: "{}", + oldValue: [], + databaseType: "postgres", + columnInfo: { data_type: "_int4" }, + }), + [], + ); +}); + +test("PG array round-trip: format -> coerce -> same values", () => { + const original = ["draft", "发布", "needs space"]; + const columnInfo = { data_type: "_text" }; + + const formatted = dataGridCellEditorText({ + value: original, + databaseType: "postgres", + columnInfo, + }); + + const coerced = coerceDataGridCellValue({ + value: formatted, + oldValue: undefined, + databaseType: "postgres", + columnInfo, + }); + + assert.deepEqual(coerced as string[], original); +}); + +test("unchanged PG array value returns oldValue reference to avoid false dirty", () => { + const oldValue = [1, 2, 3]; + + const formatted = dataGridCellEditorText({ + value: oldValue, + databaseType: "postgres", + columnInfo: { data_type: "_int4" }, + }); + + const result = coerceDataGridCellValue({ + value: formatted, + oldValue, + databaseType: "postgres", + columnInfo: { data_type: "_int4" }, + }); + + assert.strictEqual(result, oldValue); +}); + +test("changed PG array value returns new array reference", () => { + const oldValue = [1, 2, 3]; + const newText = "{1,2,3,4}"; + + const result = coerceDataGridCellValue({ + value: newText, + oldValue, + databaseType: "postgres", + columnInfo: { data_type: "_int4" }, + }); + + assert.notStrictEqual(result, oldValue); + assert.deepEqual(result, [1, 2, 3, 4]); +});