diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 4a92866153..92b6fc411e 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -79,6 +79,7 @@ import ImagePreviewDialog from "@/components/grid/ImagePreviewDialog.vue"; import TemporalCellEditor from "@/components/grid/TemporalCellEditor.vue"; import type { QueryResult, ColumnInfo, DatabaseType, ForeignKeyInfo, IndexInfo, TriggerInfo } from "@/types/database"; import * as api from "@/lib/api"; +import { coerceDataGridCellValue, dataGridCellDisplayText, dataGridCellEditorText } from "@/lib/dataGridCellCoercion"; import { createColumnDrafts } from "@/lib/tableStructureEditorState"; import type { BuildSingleColumnAlterSqlOptions } from "@/lib/tableStructureEditorSql"; import { buildTableSelectSql, quoteTableIdentifier } from "@/lib/tableSelectSql"; @@ -246,11 +247,11 @@ const props = defineProps<{ onExecuteSql?: (sql: string) => Promise; fullExportResult?: () => Promise; customSave?: (changes: { - dirtyRows: Map>; - newRows: (string | number | boolean | null)[][]; + dirtyRows: Map>; + newRows: CellValue[][]; deletedRows: Set; columns: string[]; - rows: (string | number | boolean | null)[][]; + rows: CellValue[][]; }) => Promise; }>(); @@ -2193,7 +2194,6 @@ const { saveChanges, discardChanges, rowDataWithChanges, - coerceCellValue, canEditColumn, resetGridVerticalScroll, getResetScrollAfterResult, @@ -2249,6 +2249,15 @@ function tableColumnForGridColumn(columnIndex: number): ColumnInfo | undefined { return props.tableMeta?.columns.find((column) => column.name.toLowerCase() === columnName.toLowerCase()); } +function coerceDetailCellValue(value: string, oldValue: CellValue | undefined, columnIndex: number): CellValue { + return coerceDataGridCellValue({ + value, + oldValue, + databaseType: props.databaseType, + columnInfo: tableColumnForGridColumn(columnIndex), + }) as CellValue; +} + function temporalEditorKindForColumn(columnIndex: number): TemporalCellEditorKind | undefined { return temporalCellEditorKind(tableColumnForGridColumn(columnIndex)?.data_type, props.databaseType); } @@ -2904,7 +2913,11 @@ watch(activeCellDetail, (detail) => { resetDetailEdit(); return; } - detailEditValue.value = cellDetailEditorText(detail.value, detail.type); + detailEditValue.value = dataGridCellEditorText({ + value: detail.value, + databaseType: props.databaseType, + columnInfo: tableColumnForGridColumn(detail.colIndex), + }); syncEditorFromDetailEdit(); isEditingDetail.value = true; }); @@ -2984,7 +2997,11 @@ function closeCellDetails() { function startDetailEdit() { const detail = activeCellDetail.value; if (!detail || !detail.isEditable) return; - detailEditValue.value = cellDetailEditorText(detail.value, detail.type); + detailEditValue.value = dataGridCellEditorText({ + value: detail.value, + databaseType: props.databaseType, + columnInfo: tableColumnForGridColumn(detail.colIndex), + }); isEditingDetail.value = true; } @@ -2998,7 +3015,11 @@ function commitDetailEdit() { if (item.isNew && item.newIndex !== undefined) { const oldVal = newRows.value[item.newIndex]?.[detail.colIndex]; - newRows.value[item.newIndex][detail.colIndex] = coerceCellValue(detailEditValue.value, oldVal); + newRows.value[item.newIndex][detail.colIndex] = coerceDetailCellValue( + detailEditValue.value, + oldVal, + detail.colIndex, + ); return; } @@ -3006,7 +3027,7 @@ function commitDetailEdit() { if (!canEditExistingRows.value) return; const oldVal = props.result.rows[item.sourceIndex]?.[detail.colIndex]; - const newVal = coerceCellValue(detailEditValue.value, oldVal); + const newVal = coerceDetailCellValue(detailEditValue.value, oldVal, detail.colIndex); if (newVal !== oldVal) { if (!dirtyRows.value.has(item.sourceIndex)) dirtyRows.value.set(item.sourceIndex, new Map()); dirtyRows.value.get(item.sourceIndex)!.set(detail.colIndex, newVal); @@ -3035,7 +3056,11 @@ function syncEditorFromDetailEdit() { function cancelValueEditorEdit() { const detail = activeCellDetail.value; if (!detail || !detail.isEditable) return; - detailEditValue.value = cellDetailEditorText(detail.value, detail.type); + detailEditValue.value = dataGridCellEditorText({ + value: detail.value, + databaseType: props.databaseType, + columnInfo: tableColumnForGridColumn(detail.colIndex), + }); syncEditorFromDetailEdit(); isEditingDetail.value = true; } @@ -3067,7 +3092,11 @@ function restoreDetailOriginalValue() { dirtyRows.value = new Map(dirtyRows.value); } - detailEditValue.value = cellDetailEditorText(restoredValue, detail.type); + detailEditValue.value = dataGridCellEditorText({ + value: restoredValue, + databaseType: props.databaseType, + columnInfo: tableColumnForGridColumn(detail.colIndex), + }); syncEditorFromDetailEdit(); isEditingDetail.value = activeCellDetailTab.value === "valueEditor"; detailCell.value = { ...detailCell.value! }; @@ -3297,9 +3326,17 @@ function primitiveCellFormatKey(value: CellValue, columnIndex?: number): string function formatCell(value: CellValue, columnIndex?: number): string { const formatter = columnIndex === undefined ? undefined : resolvedColumnFormatters.value[columnIndex]; const columnName = columnIndex === undefined ? undefined : props.result.columns[columnIndex]; + const columnInfo = columnIndex === undefined ? undefined : tableColumnForGridColumn(columnIndex); + const arrayDisplay = formatter + ? undefined + : dataGridCellDisplayText({ value, databaseType: props.databaseType, columnInfo }); + if (arrayDisplay !== undefined) return arrayDisplay; const binaryDisplay = formatter ? null - : binaryCellDisplayText(value, columnName ? columnTypeMap.value.get(columnName) : undefined); + : binaryCellDisplayText( + value, + columnInfo?.data_type ?? (columnName ? columnTypeMap.value.get(columnName) : undefined), + ); if (binaryDisplay) return binaryDisplay; const s = applyColumnFormatter(value, formatter); return s.length > CELL_DISPLAY_MAX_LENGTH ? s.slice(0, CELL_DISPLAY_MAX_LENGTH) : s; diff --git a/apps/desktop/src/composables/useDataGridEditor.ts b/apps/desktop/src/composables/useDataGridEditor.ts index 5ad1fed1ab..45f8e2c82d 100644 --- a/apps/desktop/src/composables/useDataGridEditor.ts +++ b/apps/desktop/src/composables/useDataGridEditor.ts @@ -12,6 +12,8 @@ import { type Ref, } from "vue"; import * as api from "@/lib/api"; +import type { CellValue } from "@/lib/cellValue"; +import { coerceDataGridCellValue, dataGridCellEditorText } from "@/lib/dataGridCellCoercion"; import { normalizeDataGridSaveError } from "@/lib/dataGridSql"; import { rowStatusFilterAfterAddingRow, type RowStatusFilter } from "@/lib/gridRowStatus"; import { supportsDataGridTransaction } from "@/lib/tableEditing"; @@ -20,8 +22,6 @@ import { useHistoryStore } from "@/stores/historyStore"; import type { ColumnInfo, DatabaseType } from "@/types/database"; import { DBX_NEO4J_ELEMENT_ID_COLUMN, DBX_ROWID_COLUMN } from "@/lib/tableEditing"; -type CellValue = string | number | boolean | null; - interface RowItem { id: number; sourceIndex?: number; @@ -378,40 +378,19 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { } // --- Cell value coercion --- - function isNull(value: unknown): boolean { - return value === null; + function coerceCellValue(value: string, oldValue: CellValue | undefined, columnIndex: number): CellValue { + return coerceDataGridCellValue({ + value, + oldValue, + databaseType: databaseType.value, + columnInfo: tableColumnForGridColumn(columnIndex), + }) as CellValue; } - function coerceCellValue(value: string, oldVal: CellValue | undefined): CellValue { - if (value.toUpperCase() === "NULL") return null; - if (value === "" && isNull(oldVal)) return null; - if (typeof oldVal === "number") { - const num = Number(value); - if (!Number.isNaN(num)) return num; - } - if (typeof oldVal === "boolean") { - return value === "true" || value === "1"; - } - return normalizeSmartQuotedJsonInput(value); - } - - function normalizeSmartQuotedJsonInput(value: string): string { - if (!/[“”]/.test(value)) return value; - const trimmed = value.trim(); - if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return value; - try { - JSON.parse(value); - return value; - } catch { - // macOS smart punctuation can turn JSON delimiters into Chinese-style quotes. - } - const normalized = value.replace(/[“”]/g, '"'); - try { - JSON.parse(normalized); - return normalized; - } catch { - return value; - } + function tableColumnForGridColumn(columnIndex: number): ColumnInfo | undefined { + const columnName = sourceColumns.value?.[columnIndex] ?? result.value.columns[columnIndex]; + if (!columnName) return undefined; + return tableMeta.value?.columns.find((column) => column.name.toLowerCase() === columnName.toLowerCase()); } function canEditColumn(columnIndex: number): boolean { @@ -437,7 +416,11 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { suppressNextBlurCommit = false; editingCell.value = { rowId, col: colIdx }; const val = item?.data[colIdx] ?? null; - editValue.value = val === null ? "" : typeof val === "object" ? JSON.stringify(val) : String(val); + editValue.value = dataGridCellEditorText({ + value: val, + databaseType: databaseType.value, + columnInfo: tableColumnForGridColumn(colIdx), + }); focusEditInput(); } @@ -453,7 +436,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { if (item.isNew && item.newIndex !== undefined) { const oldVal = newRows.value[item.newIndex]?.[col]; - const newVal = coerceCellValue(editValue.value, oldVal); + const newVal = coerceCellValue(editValue.value, oldVal, col); if (newRows.value[item.newIndex]) { newRows.value[item.newIndex][col] = newVal; } @@ -471,7 +454,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { } const oldVal = result.value.rows[item.sourceIndex]?.[col]; - const newVal = coerceCellValue(editValue.value, oldVal); + const newVal = coerceCellValue(editValue.value, oldVal, col); if (newVal !== oldVal) { if (!dirtyRows.value.has(item.sourceIndex)) dirtyRows.value.set(item.sourceIndex, new Map()); dirtyRows.value.get(item.sourceIndex)!.set(col, newVal); @@ -501,7 +484,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { if (item.isNew && item.newIndex !== undefined) { const oldVal = newRows.value[item.newIndex]?.[col]; - newRows.value[item.newIndex][col] = value === null ? null : coerceCellValue(value, oldVal); + newRows.value[item.newIndex][col] = value === null ? null : coerceCellValue(value, oldVal, col); newRows.value = [...newRows.value]; return; } @@ -510,7 +493,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { if (!canEditExistingRows.value) return; const oldVal = result.value.rows[item.sourceIndex]?.[col]; - const newVal = value === null ? null : coerceCellValue(value, oldVal); + const newVal = value === null ? null : coerceCellValue(value, oldVal, col); if (newVal !== oldVal) { if (!dirtyRows.value.has(item.sourceIndex)) dirtyRows.value.set(item.sourceIndex, new Map()); dirtyRows.value.get(item.sourceIndex)!.set(col, newVal); diff --git a/apps/desktop/src/lib/dataGridCellCoercion.ts b/apps/desktop/src/lib/dataGridCellCoercion.ts new file mode 100644 index 0000000000..f5589eb85c --- /dev/null +++ b/apps/desktop/src/lib/dataGridCellCoercion.ts @@ -0,0 +1,117 @@ +import type { GridCellValue } from "@/lib/dataGridSql"; +import type { DatabaseType, ColumnInfo } from "@/types/database"; + +export interface CoerceDataGridCellValueOptions { + value: string; + oldValue: GridCellValue | undefined; + databaseType: DatabaseType | undefined; + columnInfo: Pick | undefined; +} + +export function coerceDataGridCellValue(options: CoerceDataGridCellValueOptions): GridCellValue { + const { value, oldValue } = options; + if (value.toUpperCase() === "NULL") return null; + if (value === "" && oldValue === null) return null; + const postgresArrayValue = coercePostgresArrayValue(options); + if (postgresArrayValue !== undefined) return postgresArrayValue; + if (typeof oldValue === "number") { + const num = Number(value); + if (!Number.isNaN(num)) return num; + } + if (typeof oldValue === "boolean") { + return value === "true" || value === "1"; + } + return normalizeSmartQuotedJsonInput(value); +} + +export function dataGridCellEditorText(options: { + value: GridCellValue | undefined; + databaseType: DatabaseType | undefined; + columnInfo: Pick | undefined; +}): string { + const value = options.value ?? null; + if (value === null) return ""; + if (Array.isArray(value) && options.databaseType === "postgres" && isPostgresArrayColumn(options.columnInfo, value)) { + return formatPostgresArrayText(value); + } + return typeof value === "object" ? JSON.stringify(value) : String(value); +} + +export function dataGridCellDisplayText(options: { + value: GridCellValue; + databaseType: DatabaseType | undefined; + columnInfo: Pick | undefined; +}): string | undefined { + if ( + Array.isArray(options.value) && + options.databaseType === "postgres" && + isPostgresArrayColumn(options.columnInfo, options.value) + ) { + return formatPostgresArrayText(options.value); + } + return undefined; +} + +function coercePostgresArrayValue(options: CoerceDataGridCellValueOptions): unknown[] | undefined { + 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; + } +} + +function isPostgresArrayColumn( + columnInfo: Pick | undefined, + oldValue: GridCellValue | undefined, +): boolean { + if (Array.isArray(oldValue)) return true; + const dataType = columnInfo?.data_type.trim().toLowerCase() ?? ""; + return dataType === "array" || dataType.endsWith("[]") || dataType.startsWith("_"); +} + +function normalizeSmartQuotedJsonInput(value: string): string { + if (!/[“”]/.test(value)) return value; + const trimmed = value.trim(); + if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return value; + try { + JSON.parse(value); + return value; + } catch { + // macOS smart punctuation can turn JSON delimiters into Chinese-style quotes. + } + const normalized = normalizeSmartQuotes(value); + try { + JSON.parse(normalized); + return normalized; + } catch { + return value; + } +} + +function normalizeSmartQuotes(value: string): string { + return value.replace(/[“”]/g, '"'); +} + +function formatPostgresArrayText(value: unknown[]): string { + return `{${value.map(formatPostgresArrayElement).join(",")}}`; +} + +function formatPostgresArrayElement(value: unknown): string { + if (Array.isArray(value)) return formatPostgresArrayText(value); + if (value === null) return "NULL"; + if (typeof value === "boolean") return value ? "true" : "false"; + if (typeof value === "number") return String(value); + const text = typeof value === "string" ? value : JSON.stringify(value); + if (text === undefined) return ""; + if (!needsQuotedPostgresArrayElement(text)) return text; + return `"${text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; +} + +function needsQuotedPostgresArrayElement(value: string): boolean { + return value === "" || /[\s,"{}\\]/.test(value) || value.toUpperCase() === "NULL"; +} diff --git a/apps/desktop/src/lib/dataGridSql.ts b/apps/desktop/src/lib/dataGridSql.ts index 19d9c0b5bc..52545e3a93 100644 --- a/apps/desktop/src/lib/dataGridSql.ts +++ b/apps/desktop/src/lib/dataGridSql.ts @@ -1,7 +1,7 @@ import type { DatabaseType } from "@/types/database"; import * as api from "@/lib/api"; -export type GridCellValue = string | number | boolean | null; +export type GridCellValue = string | number | boolean | null | unknown[] | { [key: string]: unknown }; export interface DataGridTableMeta { schema?: string; diff --git a/packages/app-tests/dataGridCellCoercion.test.ts b/packages/app-tests/dataGridCellCoercion.test.ts new file mode 100644 index 0000000000..20e6f116d1 --- /dev/null +++ b/packages/app-tests/dataGridCellCoercion.test.ts @@ -0,0 +1,40 @@ +import { strict as assert } from "node:assert"; +import { test } from "vitest"; +import { + coerceDataGridCellValue, + dataGridCellDisplayText, + dataGridCellEditorText, +} from "../../apps/desktop/src/lib/dataGridCellCoercion.ts"; + +test("formats Postgres array cells with native brace syntax", () => { + const columnInfo = { data_type: "_text" }; + + assert.equal( + dataGridCellDisplayText({ + value: ["draft", "发布", "needs space"], + databaseType: "postgres", + columnInfo, + }), + '{draft,发布,"needs space"}', + ); + assert.equal( + dataGridCellEditorText({ + value: ["draft", "发布", "needs space"], + databaseType: "postgres", + columnInfo, + }), + '{draft,发布,"needs space"}', + ); +}); + +test("coerces JSON style input for Postgres array columns", () => { + assert.deepEqual( + coerceDataGridCellValue({ + value: `["draft","发布"]`, + oldValue: "{legacy}", + databaseType: "postgres", + columnInfo: { data_type: "_text" }, + }), + ["draft", "发布"], + ); +}); diff --git a/packages/app-tests/dataGridEditor.test.ts b/packages/app-tests/dataGridEditor.test.ts index a663452faa..fc0bc66364 100644 --- a/packages/app-tests/dataGridEditor.test.ts +++ b/packages/app-tests/dataGridEditor.test.ts @@ -3,11 +3,10 @@ import { test } from "vitest"; import { computed, nextTick, ref } from "vue"; import { createPinia, setActivePinia } from "pinia"; import { useDataGridEditor } from "../../apps/desktop/src/composables/useDataGridEditor.ts"; +import type { CellValue } from "../../apps/desktop/src/lib/cellValue.ts"; import type { DataGridSaveStatementOptions } from "../../apps/desktop/src/lib/dataGridSql.ts"; import type { ColumnInfo } from "../../apps/desktop/src/types/database.ts"; -type CellValue = string | number | boolean | null; - function installBrowserTestGlobals() { globalThis.document = { querySelector: () => null } as unknown as Document; globalThis.localStorage = { @@ -82,10 +81,23 @@ function formatGridSqlLiteral(value: CellValue, databaseType?: string): string { if (value === null) return "NULL"; if (typeof value === "boolean") return value ? "TRUE" : "FALSE"; if (typeof value === "number") return String(value); + if (Array.isArray(value) && databaseType === "postgres") { + return `'${formatPgArrayLiteral(value)}'`; + } const escaped = `'${String(value).replace(/\\/g, "\\\\").replace(/'/g, "''")}'`; return databaseType === "sqlserver" ? `N${escaped}` : escaped; } +function formatPgArrayLiteral(value: CellValue[]): string { + return `{${value + .map((item) => { + if (Array.isArray(item)) return formatPgArrayLiteral(item); + if (item === null) return "NULL"; + return `"${String(item).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; + }) + .join(",")}}`; +} + function column(name: string, isPrimaryKey = false, extra: string | null = null): ColumnInfo { return { name, @@ -493,6 +505,61 @@ test("saving manually typed JSON from a MySQL grid normalizes smart quotes", asy ]); }); +test("saving manually typed JSON arrays from a Postgres array column uses array values", async () => { + setActivePinia(createPinia()); + installBrowserTestGlobals(); + + const result = computed(() => ({ + columns: ["id", "tags"], + rows: [[1, "{legacy}"] as CellValue[]], + })); + const rowStatusFilter = ref<"all" | "changed" | "edited" | "new" | "deleted">("all"); + const executedSql: string[] = []; + + const editor = useDataGridEditor({ + result, + editable: computed(() => true), + databaseType: computed(() => "postgres"), + connectionId: computed(() => undefined), + database: computed(() => undefined), + tableMeta: computed(() => ({ + tableName: "articles", + columns: [column("id", true), { ...column("tags"), data_type: "_text" }], + primaryKeys: ["id"], + })), + onExecuteSql: computed(() => async (sql: string) => { + executedSql.push(sql); + }), + customSave: computed(() => undefined), + sql: computed(() => "SELECT id, tags FROM articles"), + searchText: ref(""), + whereFilterInput: ref(""), + orderByInput: ref(""), + currentWhereInput: computed(() => undefined), + rowStatusFilter, + pageSize: ref(50), + currentPage: ref(1), + getRowItem: (rowId) => { + if (rowId !== 0) return undefined; + return { + id: 0, + sourceIndex: 0, + data: result.value.rows[0], + isNew: false, + isDeleted: false, + isDirtyCol: [false, false], + status: "clean", + }; + }, + emit: () => {}, + }); + + editor.applyCellValue(0, 1, `["draft","发布"]`); + await editor.saveChanges(); + + assert.deepEqual(executedSql, [`UPDATE "articles" SET "tags" = '{"draft","发布"}' WHERE "id" = 1;`]); +}); + test("failed table data save records a failed history entry", async () => { setActivePinia(createPinia()); installBrowserTestGlobals();