diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index d478d563a..29a09b19b 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -99,7 +99,7 @@ import { normalizeSqlFormatterSettings, type SqlFormatterSettings } from "@/lib/ import { validateConfigName, generateId, type AiConfigItem, type ConfigNameValidationResult } from "@/lib/ai/aiConfigList"; import { currentExecutableStatementRange, type SqlTextRange } from "@/lib/sql/sqlStatementRanges"; import { executableStatementRangeCacheForDoc, executableStatementRangeStartingAt, type ExecutableStatementRangeCache } from "@/lib/sql/executableStatementRangeCache"; -import { EMPTY_TABLE_COLUMN_TEMPLATE_DATA_TYPE, parseTableColumnTemplateFields, TABLE_COLUMN_TEMPLATE_DATABASE_TYPES } from "@/lib/table/tableColumnTemplates"; +import { EMPTY_TABLE_COLUMN_TEMPLATE_DATA_TYPE, parseTableColumnTemplateFields, TABLE_COLUMN_TEMPLATE_DATABASE_TYPES, tableColumnTemplateRowsToSettings } from "@/lib/table/tableColumnTemplates"; import { DEFAULT_SQL_VARIABLE_SYNTAX_TOGGLES, normalizeSqlVariableSyntaxOverrides, SQL_VARIABLE_SYNTAX_DATABASE_TYPES, SQL_VARIABLE_SYNTAX_KEYS, SQL_VARIABLE_SYNTAX_TOKENS, type SqlVariableSyntaxOverrides, type SqlVariableSyntaxToggles } from "@/lib/sql/sqlVariableSyntax"; import { buildMcpCherryStudioConfig, buildMcpCodexConfig, buildMcpJsonConfig, buildMcpOpenCodeConfig, buildMcpTraeConfig, buildMcpVsCodeConfig, mcpWebBackendUrl, type McpLaunchConfig } from "@/lib/mcp/mcpConfigTemplates"; import { beginMcpStatusRequest, mcpUpdateAvailability } from "@/lib/mcp/mcpUpdateStatus"; @@ -249,35 +249,6 @@ function tableColumnTemplateRowsFromSettings(lines: readonly string[]): TableCol })); } -function tableColumnTemplateRowsToSettings(rows: readonly TableColumnTemplateGridRow[]): string[] { - const seenNames = new Set(); - const settings: string[] = []; - for (const row of rows) { - const name = row.name.trim(); - if (!name) continue; - const key = name.toLowerCase(); - if (seenNames.has(key)) continue; - seenNames.add(key); - - const parts = [name]; - - const seenDatabaseTypes = new Set(); - for (const override of row.overrides) { - const dataType = override.dataType.trim(); - if (seenDatabaseTypes.has(override.databaseType)) continue; - seenDatabaseTypes.add(override.databaseType); - parts.push(`${override.databaseType}:${dataType || EMPTY_TABLE_COLUMN_TEMPLATE_DATA_TYPE}`); - } - if (!row.required) parts.push("required:false"); - const defaultValue = row.defaultValue.trim(); - if (defaultValue) parts.push(`default:${defaultValue}`); - const comment = row.comment.trim(); - if (comment) parts.push(`comment:${comment}`); - settings.push(parts.join(" | ")); - } - return settings; -} - function createEmptyTableColumnTemplateRow(): TableColumnTemplateGridRow { return { id: uuid(), diff --git a/apps/desktop/src/lib/table/tableColumnTemplates.ts b/apps/desktop/src/lib/table/tableColumnTemplates.ts index 3ab4ea277..14ba0e2aa 100644 --- a/apps/desktop/src/lib/table/tableColumnTemplates.ts +++ b/apps/desktop/src/lib/table/tableColumnTemplates.ts @@ -17,6 +17,14 @@ export interface TableColumnTemplateField { comment?: string; } +export interface TableColumnTemplateGridRowInput { + name: string; + required: boolean; + defaultValue: string; + comment: string; + overrides: readonly { databaseType: DatabaseType; dataType: string }[]; +} + export const PRESET_FIELDS_TEMPLATE_ID = "preset-fields"; export const EMPTY_TABLE_COLUMN_TEMPLATE_DATA_TYPE = ""; export const TABLE_COLUMN_TEMPLATE_DATABASE_TYPES: DatabaseType[] = manifestDatabaseTypes().filter(isTableColumnTemplateDatabaseType); @@ -24,21 +32,27 @@ export const DEFAULT_TABLE_COLUMN_TEMPLATE_FIELDS: string[] = []; export function normalizeTableColumnTemplateFields(value: unknown): string[] { if (!Array.isArray(value)) return [...DEFAULT_TABLE_COLUMN_TEMPLATE_FIELDS]; - const fields: string[] = []; - const seen = new Set(); - for (const item of value) { - if (typeof item !== "string") continue; - const field = item.trim(); - const name = field.split("|")[0]?.trim(); - if (!field || !name || seen.has(name.toLowerCase())) continue; - seen.add(name.toLowerCase()); - fields.push(field); - } - return fields; + return parseTableColumnTemplateFieldLines(value).map(serializeTableColumnTemplateField); } export function parseTableColumnTemplateFields(value: unknown): TableColumnTemplateField[] { - return normalizeTableColumnTemplateFields(value).map(parseTableColumnTemplateField); + if (!Array.isArray(value)) return parseTableColumnTemplateFieldLines(DEFAULT_TABLE_COLUMN_TEMPLATE_FIELDS); + return parseTableColumnTemplateFieldLines(value); +} + +/** + * Serialize settings-grid rows. + * UI is filtered per database, so the same field name can appear as separate rows (one per + * engine). Merge those into one stored field with per-DB types — dropping later names made + * Apply look unchanged (#5579). + */ +export function tableColumnTemplateRowsToSettings(rows: readonly TableColumnTemplateGridRowInput[]): string[] { + const fields: TableColumnTemplateField[] = []; + for (const row of rows) { + const field = fieldFromGridRow(row); + if (field) fields.push(field); + } + return mergeTableColumnTemplateFields(fields).map(serializeTableColumnTemplateField); } export function tableColumnTemplates(columnNames: readonly string[] = DEFAULT_TABLE_COLUMN_TEMPLATE_FIELDS): TableColumnTemplate[] { @@ -58,6 +72,80 @@ export function createTableColumnTemplateDrafts(options: { templateId: string; d return presetFieldColumns(options.databaseType, parseTableColumnTemplateFields([...(options.columnNames ?? DEFAULT_TABLE_COLUMN_TEMPLATE_FIELDS)]), options.createId).filter((column) => !existingNames.has(column.name.toLowerCase())); } +/** Case-insensitive name merge: first wins for field props; per-DB types are unioned. */ +function mergeTableColumnTemplateFields(fields: readonly TableColumnTemplateField[]): TableColumnTemplateField[] { + const order: string[] = []; + const byName = new Map(); + + for (const field of fields) { + const name = field.name.trim(); + if (!name) continue; + const key = name.toLowerCase(); + const existing = byName.get(key); + if (!existing) { + byName.set(key, { + name, + dataTypesByDatabase: { ...field.dataTypesByDatabase }, + isNullable: field.isNullable, + defaultValue: field.defaultValue, + comment: field.comment, + }); + order.push(key); + continue; + } + if (!existing.defaultValue && field.defaultValue) existing.defaultValue = field.defaultValue; + if (!existing.comment && field.comment) existing.comment = field.comment; + for (const [databaseType, dataType] of Object.entries(field.dataTypesByDatabase) as [DatabaseType, string][]) { + if (existing.dataTypesByDatabase[databaseType] !== undefined) continue; + existing.dataTypesByDatabase[databaseType] = dataType; + } + } + + return order.map((key) => byName.get(key)!); +} + +function serializeTableColumnTemplateField(field: TableColumnTemplateField): string { + const parts = [field.name]; + for (const [databaseType, dataType] of Object.entries(field.dataTypesByDatabase)) { + if (!dataType) continue; + parts.push(`${databaseType}:${dataType}`); + } + if (field.isNullable === true) parts.push("required:false"); + if (field.defaultValue) parts.push(`default:${field.defaultValue}`); + if (field.comment) parts.push(`comment:${field.comment}`); + return parts.join(" | "); +} + +function fieldFromGridRow(row: TableColumnTemplateGridRowInput): TableColumnTemplateField | null { + const name = row.name.trim(); + if (!name) return null; + + const dataTypesByDatabase: Partial> = {}; + for (const override of row.overrides) { + if (dataTypesByDatabase[override.databaseType] !== undefined) continue; + dataTypesByDatabase[override.databaseType] = override.dataType.trim() || EMPTY_TABLE_COLUMN_TEMPLATE_DATA_TYPE; + } + + return { + name, + dataTypesByDatabase, + isNullable: row.required ? undefined : true, + defaultValue: row.defaultValue.trim() || undefined, + comment: row.comment.trim() || undefined, + }; +} + +function parseTableColumnTemplateFieldLines(value: readonly unknown[]): TableColumnTemplateField[] { + const parsed: TableColumnTemplateField[] = []; + for (const item of value) { + if (typeof item !== "string") continue; + const trimmed = item.trim(); + if (!trimmed || !trimmed.split("|")[0]?.trim()) continue; + parsed.push(parseTableColumnTemplateField(trimmed)); + } + return mergeTableColumnTemplateFields(parsed); +} + function presetFieldColumns(databaseType: DatabaseType | undefined, fields: readonly TableColumnTemplateField[], createId: () => string): EditableStructureColumn[] { return fields .filter((field) => isTableColumnTemplateFieldApplicable(field, databaseType)) diff --git a/packages/app-tests/tableColumnTemplates.test.ts b/packages/app-tests/tableColumnTemplates.test.ts index d7a1a9b58..40e31ab07 100644 --- a/packages/app-tests/tableColumnTemplates.test.ts +++ b/packages/app-tests/tableColumnTemplates.test.ts @@ -1,6 +1,15 @@ import { strict as assert } from "node:assert"; import { test } from "vitest"; -import { createTableColumnTemplateDrafts, DEFAULT_TABLE_COLUMN_TEMPLATE_FIELDS, normalizeTableColumnTemplateFields, parseTableColumnTemplateFields, PRESET_FIELDS_TEMPLATE_ID, tableColumnTemplates, TABLE_COLUMN_TEMPLATE_DATABASE_TYPES } from "../../apps/desktop/src/lib/table/tableColumnTemplates.ts"; +import { + createTableColumnTemplateDrafts, + DEFAULT_TABLE_COLUMN_TEMPLATE_FIELDS, + normalizeTableColumnTemplateFields, + parseTableColumnTemplateFields, + PRESET_FIELDS_TEMPLATE_ID, + tableColumnTemplateRowsToSettings, + tableColumnTemplates, + TABLE_COLUMN_TEMPLATE_DATABASE_TYPES, +} from "../../apps/desktop/src/lib/table/tableColumnTemplates.ts"; const sixCustomFields = [ "tenant_id | mysql:bigint | postgres:uuid | default:0 | comment:Tenant", @@ -115,3 +124,46 @@ test("skips configured preset fields that already exist", () => { ["request_id", "created_time", "modified_time", "creator_id"], ); }); + +test("merges same-name fields across database types (#5579)", () => { + const normalized = normalizeTableColumnTemplateFields(["id | sqlite:INTEGER", "id | mysql:BIGINT", "create_time | sqlite:TEXT | default:CURRENT_TIMESTAMP", "create_time | mysql:DATETIME"]); + assert.deepEqual(normalized, ["id | sqlite:INTEGER | mysql:BIGINT", "create_time | sqlite:TEXT | mysql:DATETIME | default:CURRENT_TIMESTAMP"]); + + assert.deepEqual( + createTableColumnTemplateDrafts({ + templateId: PRESET_FIELDS_TEMPLATE_ID, + databaseType: "mysql", + columnNames: normalized, + createId: () => "x", + }).map((column) => ({ name: column.name, dataType: column.dataType })), + [ + { name: "id", dataType: "BIGINT" }, + { name: "create_time", dataType: "DATETIME" }, + ], + ); +}); + +test("tableColumnTemplateRowsToSettings serializes multi-db rows and merges duplicate names", () => { + assert.deepEqual( + tableColumnTemplateRowsToSettings([ + { + name: "id", + required: true, + defaultValue: "", + comment: "", + overrides: [ + { databaseType: "sqlite", dataType: "INTEGER" }, + { databaseType: "mysql", dataType: "BIGINT" }, + ], + }, + { + name: "id", + required: false, + defaultValue: "0", + comment: "pk", + overrides: [{ databaseType: "postgres", dataType: "bigint" }], + }, + ]), + ["id | sqlite:INTEGER | mysql:BIGINT | postgres:bigint | default:0 | comment:pk"], + ); +});