diff --git a/apps/desktop/src/components/objects/ObjectBrowser.vue b/apps/desktop/src/components/objects/ObjectBrowser.vue index fc676dafa5..20c255eb6c 100644 --- a/apps/desktop/src/components/objects/ObjectBrowser.vue +++ b/apps/desktop/src/components/objects/ObjectBrowser.vue @@ -62,7 +62,7 @@ import { getTableMetadataCapabilities, type TableMetadataCapabilities } from "@/ import { buildTableSelectSql } from "@/lib/table/tableSelectSql"; import { buildDropObjectSql, buildDropTableSql, buildDuplicateTableStructureSql, buildCopyTableDataSql, buildEmptyTableSql, buildTruncateTableSql, supportsDropTableCascade, supportsTruncateTableCascade, type TableAdminSqlOptions } from "@/lib/database/dbAdminSql"; import { useToast } from "@/composables/useToast"; -import { buildExecutableObjectSourceStatements, buildRoutineRenameObjectSourceStatements, executeObjectSourceSave, supportsSourceBackedRoutineRename } from "@/lib/table/objectSourceEditor"; +import { buildExecutableObjectSourceStatements, buildRoutineRenameObjectSourceStatements, executeObjectSourceSave, formatObjectSourceSaveError, supportsSourceBackedRoutineRename } from "@/lib/table/objectSourceEditor"; import { buildRenameObjectSql, supportsObjectRename } from "@/lib/table/objectRenameSql"; import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; import { generateDatabaseExportId } from "@/lib/export/databaseExport"; @@ -2037,9 +2037,9 @@ async function saveSource() { sourceEditing.value = false; sourceDraft.value = ""; await openSource(row); - } catch (e: any) { + } catch (e: unknown) { if (sidePanelGuard.isStale(epoch)) return; - sourceSaveError.value = e?.message || String(e); + sourceSaveError.value = formatObjectSourceSaveError(e, effectiveDatabaseType.value, row.type as ObjectSourceKind, t("objects.postgresViewColumnChangeHint")); } finally { if (sidePanelGuard.isFresh(epoch)) sourceSaving.value = false; } @@ -2890,7 +2890,7 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] { force-word-wrap @save="saveSource" /> -
+
{{ sourceSaveError }}
diff --git a/apps/desktop/src/components/objects/ObjectSourceDialog.vue b/apps/desktop/src/components/objects/ObjectSourceDialog.vue index f0bdb097d5..6cf5e4b526 100644 --- a/apps/desktop/src/components/objects/ObjectSourceDialog.vue +++ b/apps/desktop/src/components/objects/ObjectSourceDialog.vue @@ -7,7 +7,7 @@ import { useConnectionStore } from "@/stores/connectionStore"; import { useSettingsStore } from "@/stores/settingsStore"; import { copyToClipboard } from "@/lib/common/clipboard"; import { formatSqlForDisplay, type SqlFormatDialect } from "@/lib/sql/sqlFormatter"; -import { buildEditableObjectSource, buildExecutableObjectSourceStatements, executeObjectSourceSave } from "@/lib/table/objectSourceEditor"; +import { buildEditableObjectSource, buildExecutableObjectSourceStatements, executeObjectSourceSave, formatObjectSourceSaveError } from "@/lib/table/objectSourceEditor"; import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard"; import * as api from "@/lib/backend/api"; import QueryEditor from "@/components/editor/QueryEditor.vue"; @@ -169,8 +169,8 @@ async function saveSource() { toast(t("objects.sourceSaved")); emit("saved"); await loadSource(false); - } catch (e: any) { - saveError.value = e?.message || String(e); + } catch (e: unknown) { + saveError.value = formatObjectSourceSaveError(e, databaseType, props.objectType, t("objects.postgresViewColumnChangeHint")); } finally { saving.value = false; } @@ -213,7 +213,7 @@ function closeDialog() { hide-execution-controls @save="saveSource" /> -
+
{{ saveError }}
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index d6006920fd..03781d4031 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -2325,6 +2325,8 @@ export default { cancelEdit: "Cancel", sourceSaved: "Source saved", sourceSaveFailed: "Failed to save source: {message}", + postgresViewColumnChangeHint: + "PostgreSQL cannot use CREATE OR REPLACE VIEW to remove, reorder, rename, or change the type of existing view columns. Keep existing column count, order, names, and types unchanged; new columns can only be appended. Use ALTER VIEW ... RENAME COLUMN to rename a column. To remove a column, review dependencies and recreate the view manually.", sourceReadOnly: "This source is read-only and cannot be edited.", schemaColumn: "Schema", comment: "Comment", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 348feaf886..4b7eb3e106 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -2175,6 +2175,8 @@ export default withEnglishFallback({ cancelEdit: "Cancelar", sourceSaved: "Código fuente guardado", sourceSaveFailed: "Error al guardar el código fuente: {message}", + postgresViewColumnChangeHint: + "PostgreSQL no permite usar CREATE OR REPLACE VIEW para eliminar, reordenar, renombrar o cambiar el tipo de las columnas existentes de una vista. Mantenga sin cambios la cantidad, el orden, los nombres y los tipos de las columnas existentes; las columnas nuevas solo se pueden agregar al final. Use ALTER VIEW ... RENAME COLUMN para renombrar una columna. Para eliminar una columna, revise las dependencias y vuelva a crear la vista manualmente.", schemaColumn: "Esquema", comment: "Comentario", loadingSchemas: "Cargando esquemas...", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index ba6a46f776..7085f21734 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -2173,6 +2173,8 @@ export default withEnglishFallback({ cancelEdit: "Annulla", sourceSaved: "Sorgente salvato", sourceSaveFailed: "Impossibile salvare il sorgente: {message}", + postgresViewColumnChangeHint: + "PostgreSQL non consente di usare CREATE OR REPLACE VIEW per eliminare, riordinare, rinominare o cambiare il tipo delle colonne esistenti di una vista. Mantieni invariati il numero, l'ordine, i nomi e i tipi delle colonne esistenti; le nuove colonne possono essere aggiunte solo alla fine. Usa ALTER VIEW ... RENAME COLUMN per rinominare una colonna. Per eliminare una colonna, verifica le dipendenze e ricrea manualmente la vista.", schemaColumn: "Schema", comment: "Commento", loadingSchemas: "Caricamento schemi...", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 8afccd7127..65da9be1ba 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -2208,6 +2208,8 @@ export default withEnglishFallback({ cancelEdit: "キャンセル", sourceSaved: "ソースを保存しました", sourceSaveFailed: "ソースの保存に失敗しました: {message}", + postgresViewColumnChangeHint: + "PostgreSQL では CREATE OR REPLACE VIEW を使用して、既存のビュー列の削除、並べ替え、名前変更、または型変更を行うことはできません。既存列の数、順序、名前、型を維持してください。新しい列は末尾にのみ追加できます。列名の変更には ALTER VIEW ... RENAME COLUMN を使用してください。列を削除する場合は、依存関係を確認してビューを手動で再作成してください。", schemaColumn: "スキーマ", comment: "コメント", loadingSchemas: "スキーマを読み込み中...", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 7c4037e217..a8ffea37f2 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -2175,6 +2175,8 @@ export default withEnglishFallback({ cancelEdit: "Cancelar", sourceSaved: "Código-fonte salvo", sourceSaveFailed: "Falha ao salvar código-fonte: {message}", + postgresViewColumnChangeHint: + "O PostgreSQL não permite usar CREATE OR REPLACE VIEW para remover, reordenar, renomear ou alterar o tipo das colunas existentes de uma view. Mantenha inalterados a quantidade, a ordem, os nomes e os tipos das colunas existentes; novas colunas só podem ser adicionadas ao final. Use ALTER VIEW ... RENAME COLUMN para renomear uma coluna. Para remover uma coluna, revise as dependências e recrie a view manualmente.", schemaColumn: "Schema", comment: "Comentário", loadingSchemas: "Carregando schemas...", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 19cc103480..09b5b6e14d 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -2314,6 +2314,7 @@ export default withEnglishFallback({ cancelEdit: "取消", sourceSaved: "源码已保存", sourceSaveFailed: "保存源码失败:{message}", + postgresViewColumnChangeHint: "PostgreSQL 无法通过 CREATE OR REPLACE VIEW 删除、重排、重命名已有视图字段,或修改字段类型。请保持原字段的数量、顺序、名称和类型不变;新增字段只能追加在末尾。重命名字段请使用 ALTER VIEW ... RENAME COLUMN;删除字段前请检查依赖并手动重建视图。", sourceReadOnly: "该源码为只读,不能编辑。", schemaColumn: "Schema", comment: "注释", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index af2c9f4e5a..df60f16965 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -2044,6 +2044,7 @@ export default withEnglishFallback({ cancelEdit: "取消", sourceSaved: "原始碼已儲存", sourceSaveFailed: "儲存原始碼失敗:{message}", + postgresViewColumnChangeHint: "PostgreSQL 無法透過 CREATE OR REPLACE VIEW 刪除、重新排序、重新命名既有檢視欄位,或修改欄位型別。請保持原欄位的數量、順序、名稱和型別不變;新增欄位只能附加在末尾。重新命名欄位請使用 ALTER VIEW ... RENAME COLUMN;刪除欄位前請檢查相依性並手動重建檢視。", sourceReadOnly: "此原始碼為唯讀,不能編輯。", schemaColumn: "Schema", comment: "註解", diff --git a/apps/desktop/src/lib/__tests__/table/objectSourceEditor.spec.ts b/apps/desktop/src/lib/__tests__/table/objectSourceEditor.spec.ts index 78e692d394..31695a6d8d 100644 --- a/apps/desktop/src/lib/__tests__/table/objectSourceEditor.spec.ts +++ b/apps/desktop/src/lib/__tests__/table/objectSourceEditor.spec.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import * as api from "@/lib/backend/api"; -import { executeObjectSourceSave } from "@/lib/table/objectSourceEditor"; +import { executeObjectSourceSave, formatObjectSourceSaveError } from "@/lib/table/objectSourceEditor"; vi.mock("@/lib/backend/api", () => ({ executeInTransaction: vi.fn().mockResolvedValue({}), @@ -32,3 +32,33 @@ describe("executeObjectSourceSave", () => { expect(api.executeScript).not.toHaveBeenCalled(); }); }); + +describe("formatObjectSourceSaveError", () => { + const hint = "Keep existing view columns unchanged and append new columns at the end."; + + it.each(["ERROR: cannot drop columns from view", 'ERROR: cannot change name of view column "old_name" to "new_name"', 'ERROR: cannot change data type of view column "amount" from integer to bigint', "ERROR: ビューからは列を削除できません", "错误:无法从视图中删除列"])( + "appends guidance for PostgreSQL view column replacement errors: %s", + (message) => { + expect(formatObjectSourceSaveError(new Error(message), "postgres", "VIEW", hint)).toBe(`${message}\n\n${hint}`); + }, + ); + + it("supports PostgreSQL-compatible databases", () => { + expect(formatObjectSourceSaveError("cannot change name of view column", "kingbase", "VIEW", hint)).toBe(`cannot change name of view column\n\n${hint}`); + }); + + it("leaves unrelated errors unchanged", () => { + expect(formatObjectSourceSaveError(new Error("syntax error at or near SELECT"), "postgres", "VIEW", hint)).toBe("syntax error at or near SELECT"); + expect(formatObjectSourceSaveError(new Error("SQLSTATE 42P16"), "mysql", "VIEW", hint)).toBe("SQLSTATE 42P16"); + expect(formatObjectSourceSaveError(new Error("SQLSTATE 42P16"), "postgres", "PROCEDURE", hint)).toBe("SQLSTATE 42P16"); + }); + + it.each(["Agent RPC error: SQLSTATE 42P16", "ERROR: ON COMMIT can only be used on temporary tables (SQLSTATE 42P16)"])("does not treat unrelated invalid_table_definition errors as view column changes: %s", (message) => { + expect(formatObjectSourceSaveError(new Error(message), "postgres", "VIEW", hint)).toBe(message); + }); + + it("does not append the same guidance twice", () => { + const formatted = formatObjectSourceSaveError("ERROR: cannot drop columns from view", "postgres", "VIEW", hint); + expect(formatObjectSourceSaveError(formatted, "postgres", "VIEW", hint)).toBe(formatted); + }); +}); diff --git a/apps/desktop/src/lib/table/objectSourceEditor.ts b/apps/desktop/src/lib/table/objectSourceEditor.ts index 23397ca376..84fa30a4e6 100644 --- a/apps/desktop/src/lib/table/objectSourceEditor.ts +++ b/apps/desktop/src/lib/table/objectSourceEditor.ts @@ -16,9 +16,32 @@ export type BuildRoutineRenameObjectSourceInput = BuildEditableObjectSourceSqlIn export type ObjectSourceSaveExecutionMode = "single" | "script"; const postgresLikeRoutineRenameTypes = new Set(["postgres", "redshift", "gaussdb", "kwdb", "kingbase", "highgo", "vastbase"]); +const postgresLikeViewTypes = new Set(["postgres", "redshift", "gaussdb", "kwdb", "opengauss", "questdb", "kingbase", "highgo", "vastbase"]); const mysqlLikeRoutineRenameTypes = new Set(["mysql", "goldendb"]); const oracleLikeRoutineRenameTypes = new Set(["oracle", "dameng"]); +// SQLSTATE 42P16 covers unrelated invalid table definitions, so only match the +// confirmed PostgreSQL view-column errors and their localized equivalents. +const postgresViewColumnChangeErrorPatterns = [/cannot drop columns? from view/i, /cannot change name of view column/i, /cannot change data type of view column/i, /ビューからは列を削除できません/, /(?:无法|不能)从视图中删除列/]; + +function errorMessage(error: unknown): string { + if (error instanceof Error) return error.message; + if (error && typeof error === "object" && "message" in error) { + const message = (error as { message?: unknown }).message; + if (message !== undefined && message !== null) return String(message); + } + return String(error); +} + +export function formatObjectSourceSaveError(error: unknown, databaseType: DatabaseType, objectType: ObjectSourceKind, postgresViewColumnChangeHint: string): string { + const message = errorMessage(error); + if (objectType !== "VIEW" || !postgresLikeViewTypes.has(databaseType) || !postgresViewColumnChangeErrorPatterns.some((pattern) => pattern.test(message)) || message.includes(postgresViewColumnChangeHint)) { + return message; + } + + return `${message}\n\n${postgresViewColumnChangeHint}`; +} + export function supportsSourceBackedRoutineRename(databaseType: DatabaseType | undefined, objectType: ObjectSourceKind): boolean { if (objectType !== "FUNCTION" && objectType !== "PROCEDURE") return false; if (!databaseType || databaseType === "sqlserver") return false;