fix(postgres): support editing array cells

This commit is contained in:
t8y2
2026-06-08 18:31:05 +08:00
parent 2628fb8374
commit 5ec50ba15b
6 changed files with 297 additions and 53 deletions
+48 -11
View File
@@ -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<void>;
fullExportResult?: () => Promise<QueryResult | undefined>;
customSave?: (changes: {
dirtyRows: Map<number, Map<number, string | number | boolean | null>>;
newRows: (string | number | boolean | null)[][];
dirtyRows: Map<number, Map<number, CellValue>>;
newRows: CellValue[][];
deletedRows: Set<number>;
columns: string[];
rows: (string | number | boolean | null)[][];
rows: CellValue[][];
}) => Promise<void>;
}>();
@@ -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;
@@ -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);
@@ -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<ColumnInfo, "data_type"> | 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<ColumnInfo, "data_type"> | 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<ColumnInfo, "data_type"> | 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<ColumnInfo, "data_type"> | 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";
}
+1 -1
View File
@@ -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;
@@ -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", "发布"],
);
});
+69 -2
View File
@@ -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();