From 876f850e2d6258c5f845f103a6d7c4ac8f86d573 Mon Sep 17 00:00:00 2001 From: lizzzzz777 <72645313+lizzzzz777@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:46:25 +0800 Subject: [PATCH] feat(editor): format SQL files on save --- apps/desktop/src/App.vue | 57 ++++++++++++++++--- .../editor/EditorSettingsDialog.vue | 15 +++++ apps/desktop/src/i18n/locales/en.ts | 2 + apps/desktop/src/i18n/locales/es.ts | 2 + apps/desktop/src/i18n/locales/it.ts | 2 + apps/desktop/src/i18n/locales/ja.ts | 2 + apps/desktop/src/i18n/locales/ko.ts | 2 + apps/desktop/src/i18n/locales/pt-BR.ts | 2 + apps/desktop/src/i18n/locales/zh-CN.ts | 2 + apps/desktop/src/i18n/locales/zh-TW.ts | 2 + .../lib/__tests__/sql/sqlFormatOnSave.spec.ts | 46 +++++++++++++++ .../settings/__tests__/settingsSearch.spec.ts | 1 + .../src/lib/settings/editorSettingsDraft.ts | 1 + .../src/lib/settings/settingsSearch.ts | 1 + apps/desktop/src/lib/sql/sqlFormatOnSave.ts | 6 ++ .../stores/__tests__/settingsStore.spec.ts | 8 +++ apps/desktop/src/stores/settingsStore.ts | 4 ++ 17 files changed, 148 insertions(+), 7 deletions(-) create mode 100644 apps/desktop/src/lib/__tests__/sql/sqlFormatOnSave.spec.ts create mode 100644 apps/desktop/src/lib/sql/sqlFormatOnSave.ts diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index 6a508a0f36..902162ba5c 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -119,7 +119,11 @@ import { initSavedSqlEditorPositions } from "@/lib/app/savedSqlEditorPosition"; import { hasTreeNodeDatabaseContext } from "@/lib/sidebar/treeNodeContext"; import { isSchemaAware, isSingleDatabase, usesTreeSchemaMode } from "@/lib/database/databaseFeatureSupport"; import { codeMirrorSqlDialect, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect"; -import { sqlFormatDialectForDbType } from "@/lib/sql/sqlFormatter"; +import { canFormatSqlForDatabaseType, formatSqlForEditing, sqlFormatDialectForDbType } from "@/lib/sql/sqlFormatter"; +import { formatSqlSnapshotForSave } from "@/lib/sql/sqlFormatOnSave"; +import { detectAndFormatStructured } from "@/lib/sql/autoFormat"; +import { formatMongoShellText } from "@/lib/mongo/mongoFormatter"; +import { detectAndFormatElasticsearchRequests } from "@/lib/elasticsearch/elasticsearchFormatter"; import { detectDatabaseFileType } from "@/lib/database/databaseFileDetection"; import { ensureJdbcxRuntimeDrivers } from "@/lib/database/jdbcxBuiltinDriver"; import { ensureRegisteredJdbcProductRuntimeDrivers } from "@/lib/database/jdbcProductProfiles"; @@ -1165,7 +1169,7 @@ function handleCloseActionPromptOpenChange(open: boolean) { async function writeExternalSqlTab(tab: QueryTab, options: { closeAfterSave?: boolean; expectedContentHash?: string; expectedMissing?: boolean } = {}): Promise<"saved" | "retry" | "failed"> { if (!tab.externalSqlPath || !isTauriRuntime()) return "failed"; try { - const result = await api.writeExternalSqlFile(tab.externalSqlPath, tab.sql, { + const result = await api.writeExternalSqlFile(tab.externalSqlPath, await formattedSqlForSave(tab), { expectedContentHash: options.expectedContentHash, expectedMissing: options.expectedMissing, }); @@ -1211,6 +1215,45 @@ function savedSqlTargetForSave(tab: QueryTab) { }); } +/** + * Applies the "format SQL when saving SQL files" editor setting: returns the + * formatted SQL that should be written to disk / the SQL library, mirroring the + * editor's own formatting logic (Mongo shell, Elasticsearch, structured JSON/XML + * and SQL) so a save never corrupts non-SQL content. When formatting changes the + * SQL, the tab content is also updated so the editor reflects exactly what was + * saved and stays clean if `markTabClean` runs afterwards. + */ +async function formattedSqlForSave(tab: QueryTab): Promise { + if (!settingsStore.editorSettings.formatSqlOnSqlFileSave) return tab.sql; + const sqlSnapshot = tab.sql; + if (!sqlSnapshot.trim()) return sqlSnapshot; + const connection = connectionStore.getConfig(tab.connectionId); + const databaseType = effectiveDatabaseTypeForConnection(connection) ?? connection?.db_type; + if (!canFormatSqlForDatabaseType(databaseType)) return sqlSnapshot; + try { + return await formatSqlSnapshotForSave( + sqlSnapshot, + () => tab.sql, + async (sql) => { + if (databaseType === "mongodb") return formatMongoShellText(sql, settingsStore.editorSettings.sqlFormatter); + const esRequest = detectAndFormatElasticsearchRequests(sql, databaseType, settingsStore.editorSettings.sqlFormatter.tabWidth); + if (esRequest.kind === "elasticsearch") return esRequest.formatted; + if (esRequest.kind === "unsupported") return sql; + const structured = detectAndFormatStructured(sql, { + indentSize: settingsStore.editorSettings.sqlFormatter.tabWidth, + useTabs: settingsStore.editorSettings.sqlFormatter.useTabs, + }); + if (structured.kind === "json" || structured.kind === "xml") return structured.formatted; + if (structured.kind === "unsupported") return sql; + return formatSqlForEditing(sql, sqlFormatDialectForDbType(databaseType), settingsStore.editorSettings.sqlFormatter); + }, + (formatted) => queryStore.updateSql(tab.id, formatted), + ); + } catch { + return tab.sql; + } +} + async function saveTabForCloseAll(tabId: string): Promise { const tab = queryStore.tabs.find((t) => t.id === tabId); if (!tab) return true; @@ -1237,7 +1280,7 @@ async function saveTabForCloseAll(tabId: string): Promise { database: target.database, catalog: target.catalog, schema: target.schema, - sql: tab.sql, + sql: await formattedSqlForSave(tab), }); queryStore.linkSavedSql(tab.id, saved.id, saved.name); queryStore.markTabClean(tab); @@ -1305,7 +1348,7 @@ async function handleSaveTab(tabId: string) { database: target.database, catalog: target.catalog, schema: target.schema, - sql: tab.sql, + sql: await formattedSqlForSave(tab), }); queryStore.linkSavedSql(tab.id, updated.id, updated.name); queryStore.markTabClean(tab); @@ -1347,7 +1390,7 @@ async function openSaveSqlDialog() { database: target.database, catalog: target.catalog, schema: target.schema, - sql: tab.sql, + sql: await formattedSqlForSave(tab), }); queryStore.linkSavedSql(tab.id, updated.id, updated.name); queryStore.markTabClean(tab); @@ -1446,7 +1489,7 @@ async function confirmSaveSqlToLibrary() { database: target.database, catalog: target.catalog, schema: target.schema, - sql: tab.sql, + sql: await formattedSqlForSave(tab), }); queryStore.linkSavedSql(tab.id, saved.id, saved.name); queryStore.markTabClean(tab); @@ -1461,7 +1504,7 @@ async function confirmSaveSqlToLibrary() { async function saveExternalSqlTabAs(tab: QueryTab): Promise { if (!canSaveSqlTab(tab) || !isTauriRuntime()) return false; try { - const saved = await api.saveExternalSqlFile(defaultSavedSqlName(tab.title), tab.sql); + const saved = await api.saveExternalSqlFile(defaultSavedSqlName(tab.title), await formattedSqlForSave(tab)); if (!saved) return false; queryStore.linkExternalSqlPath(tab.id, saved.path, sqlFileTitleFromPath(saved.path), saved.version); rememberExternalSqlFileTarget(saved.path, { connectionId: tab.connectionId, database: tab.database, catalog: tab.catalog }); diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index c3a9966517..8c32e347e2 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -451,6 +451,7 @@ const editDataTabReuseMode = ref(settingsStore.editorSettings. const editOpenDataTabsNextToActive = ref(settingsStore.editorSettings.openDataTabsNextToActive); const editPrefillNewQueryWithSelect = ref(settingsStore.editorSettings.prefillNewQueryWithSelect); const editGenerateSqlIncludeDatabaseName = ref(settingsStore.editorSettings.generateSqlIncludeDatabaseName); +const editFormatSqlOnSqlFileSave = ref(settingsStore.editorSettings.formatSqlOnSqlFileSave); const editClickTableNavigationTarget = ref(settingsStore.editorSettings.clickTableNavigationTarget); const editUpdateNotificationsEnabled = ref(settingsStore.editorSettings.updateNotificationsEnabled); const editSidebarHiddenTablePrefixes = ref(settingsStore.editorSettings.sidebarHiddenTablePrefixes.join("\n")); @@ -583,6 +584,7 @@ function currentEditorSettingsDraft(): EditorSettingsDraft { openDataTabsNextToActive: editOpenDataTabsNextToActive.value, prefillNewQueryWithSelect: editPrefillNewQueryWithSelect.value, generateSqlIncludeDatabaseName: editGenerateSqlIncludeDatabaseName.value, + formatSqlOnSqlFileSave: editFormatSqlOnSqlFileSave.value, updateNotificationsEnabled: editUpdateNotificationsEnabled.value, sidebarObjectInfoMode: editSidebarObjectInfoMode.value, sidebarAllowHorizontalScroll: editSidebarAllowHorizontalScroll.value, @@ -873,6 +875,7 @@ function syncEditorSettingsDraftFromStore() { editOpenDataTabsNextToActive.value = settingsStore.editorSettings.openDataTabsNextToActive; editPrefillNewQueryWithSelect.value = settingsStore.editorSettings.prefillNewQueryWithSelect; editGenerateSqlIncludeDatabaseName.value = settingsStore.editorSettings.generateSqlIncludeDatabaseName; + editFormatSqlOnSqlFileSave.value = settingsStore.editorSettings.formatSqlOnSqlFileSave; editClickTableNavigationTarget.value = settingsStore.editorSettings.clickTableNavigationTarget; editUpdateNotificationsEnabled.value = settingsStore.editorSettings.updateNotificationsEnabled; editSidebarHiddenTablePrefixes.value = settingsStore.editorSettings.sidebarHiddenTablePrefixes.join("\n"); @@ -1122,6 +1125,7 @@ function resetDefaultsForTab(tab: SettingsCategory) { editOpenDataTabsNextToActive.value = DEFAULT_EDITOR_SETTINGS.openDataTabsNextToActive; editPrefillNewQueryWithSelect.value = DEFAULT_EDITOR_SETTINGS.prefillNewQueryWithSelect; editGenerateSqlIncludeDatabaseName.value = DEFAULT_EDITOR_SETTINGS.generateSqlIncludeDatabaseName; + editFormatSqlOnSqlFileSave.value = DEFAULT_EDITOR_SETTINGS.formatSqlOnSqlFileSave; editClickTableNavigationTarget.value = DEFAULT_EDITOR_SETTINGS.clickTableNavigationTarget; editUpdateNotificationsEnabled.value = DEFAULT_EDITOR_SETTINGS.updateNotificationsEnabled; editSidebarObjectInfoMode.value = DEFAULT_EDITOR_SETTINGS.sidebarObjectInfoMode; @@ -1248,6 +1252,7 @@ function resetAllDefaults() { editOpenDataTabsNextToActive.value = DEFAULT_EDITOR_SETTINGS.openDataTabsNextToActive; editPrefillNewQueryWithSelect.value = DEFAULT_EDITOR_SETTINGS.prefillNewQueryWithSelect; editGenerateSqlIncludeDatabaseName.value = DEFAULT_EDITOR_SETTINGS.generateSqlIncludeDatabaseName; + editFormatSqlOnSqlFileSave.value = DEFAULT_EDITOR_SETTINGS.formatSqlOnSqlFileSave; editUpdateNotificationsEnabled.value = DEFAULT_EDITOR_SETTINGS.updateNotificationsEnabled; editSidebarObjectInfoMode.value = DEFAULT_EDITOR_SETTINGS.sidebarObjectInfoMode; editSidebarAllowHorizontalScroll.value = DEFAULT_EDITOR_SETTINGS.sidebarAllowHorizontalScroll; @@ -4208,6 +4213,16 @@ onUnmounted(() => { + +
+
+ +

+ {{ t("settings.formatSqlOnSqlFileSaveDescription") }} +

+
+ +
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 1fd2955c61..9a84f73547 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -6461,6 +6461,8 @@ export default { prefillNewQueryWithSelectDescription: "When creating a new query, prefill the editor with SELECT * FROM based on the active table tab or the table selected in the sidebar.", generateSqlIncludeDatabaseName: "Include database name in generated SQL", generateSqlIncludeDatabaseNameDescription: "When supported by the database, generated table SQL uses database.table instead of only the table name.", + formatSqlOnSqlFileSave: "Format SQL when saving SQL files", + formatSqlOnSqlFileSaveDescription: "Automatically format SQL when saving a SQL file, including saved SQL library files and queries saved as .sql files.", clickTableNavigationTarget: "Ctrl+Click Table Opens DDL", clickTableNavigationTargetDescription: "When enabled, Ctrl/Cmd+clicking a table name opens the table structure editor (DDL). When disabled, it opens the table data view.", sqlVariableSyntax: "SQL variable & placeholder substitution", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index f2b40b9406..bc9584fb87 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -6143,6 +6143,8 @@ export default withEnglishFallback({ prefillNewQueryWithSelectDescription: "Al crear una nueva consulta, rellena el editor con SELECT * FROM según la pestaña de tabla activa o la tabla seleccionada en la barra lateral.", generateSqlIncludeDatabaseName: "Incluir el nombre de la base de datos en el SQL generado", generateSqlIncludeDatabaseNameDescription: "Cuando la base de datos lo admite, el SQL de tabla generado usa base_de_datos.tabla en lugar de solo el nombre de la tabla.", + formatSqlOnSqlFileSave: "Formatear SQL al guardar archivos SQL", + formatSqlOnSqlFileSaveDescription: "Formatea automáticamente el SQL al guardar un archivo SQL, incluidos los archivos guardados en la biblioteca SQL y las consultas guardadas como archivos .sql.", clickTableNavigationTarget: "Ctrl+Clic abre DDL de tabla", clickTableNavigationTargetDescription: "Cuando está activado, Ctrl/Cmd+clic en el nombre de la tabla abre el editor de estructura (DDL). Cuando está desactivado, abre la vista de datos.", sqlVariableSyntax: "Sustitución de variables y marcadores SQL", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index a2ed92c5a3..5eff0ede5d 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -6143,6 +6143,8 @@ export default withEnglishFallback({ prefillNewQueryWithSelectDescription: "Alla creazione di una nuova query, precompila l'editor con SELECT * FROM in base alla scheda tabella attiva o alla tabella selezionata nella barra laterale.", generateSqlIncludeDatabaseName: "Includi il nome del database nell'SQL generato", generateSqlIncludeDatabaseNameDescription: "Quando supportato dal database, l'SQL della tabella generato usa database.tabella anziché solo il nome della tabella.", + formatSqlOnSqlFileSave: "Formatta SQL durante il salvataggio dei file SQL", + formatSqlOnSqlFileSaveDescription: "Formatta automaticamente l'SQL durante il salvataggio di un file SQL, inclusi i file salvati nella libreria SQL e le query salvate come file .sql.", clickTableNavigationTarget: "Ctrl+Clic apre DDL tabella", clickTableNavigationTargetDescription: "Quando attivato, Ctrl/Cmd+clic sul nome della tabella apre l'editor di struttura (DDL). Quando disattivato, apre la vista dati.", sqlVariableSyntax: "Sostituzione di variabili e segnaposto SQL", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 17ced84e36..9123822ed9 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -6164,6 +6164,8 @@ export default withEnglishFallback({ prefillNewQueryWithSelectDescription: "新規クエリ作成時、アクティブなテーブルタブまたはサイドバーで選択したテーブルに基づき、エディタに SELECT * FROM <テーブル名> を自動入力します。", generateSqlIncludeDatabaseName: "生成 SQL にデータベース名を含める", generateSqlIncludeDatabaseNameDescription: "データベースが対応している場合、生成するテーブル SQL でテーブル名のみではなく database.table を使用します。", + formatSqlOnSqlFileSave: "SQL ファイル保存時に SQL をフォーマット", + formatSqlOnSqlFileSaveDescription: "SQL ファイル保存時に SQL を自動フォーマットします。SQL ライブラリに保存されたファイルや、新規クエリを .sql ファイルとして保存する場合も含みます。", clickTableNavigationTarget: "Ctrl+クリックでテーブル構造を開く", clickTableNavigationTargetDescription: "有効にすると、Ctrl/Cmd+クリックでテーブル構造エディタ(DDL)を開きます。無効の場合はテーブルデータビューを開きます。", sqlVariableSyntax: "SQL 変数・プレースホルダー置換", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 03080f305c..6c5cf0a270 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -5846,6 +5846,8 @@ export default withEnglishFallback({ prefillNewQueryWithSelectDescription: "새 쿼리를 만들 때 활성 테이블 탭이나 사이드바에서 선택한 테이블을 기반으로 편집기에 SELECT * FROM
을 미리 채웁니다.", generateSqlIncludeDatabaseName: "생성된 SQL에 데이터베이스 이름 포함", generateSqlIncludeDatabaseNameDescription: "데이터베이스가 지원하는 경우 생성된 테이블 SQL에서 테이블 이름만이 아니라 database.table을 사용합니다.", + formatSqlOnSqlFileSave: "SQL 파일 저장 시 SQL 서식 지정", + formatSqlOnSqlFileSaveDescription: "SQL 파일을 저장할 때 SQL을 자동으로 서식 지정합니다. SQL 라이브러리에 저장된 파일과 새 쿼리를 .sql 파일로 저장하는 경우도 포함합니다.", sqlVariableSyntax: "SQL 변수 및 자리표시자 치환", sqlVariableSyntaxDescription: "데이터베이스 유형별로 SQL을 실행하기 전에 DBX가 치환할 변수와 자리표시자 문법을 선택하세요. 모두 기본적으로 활성화됩니다.", sqlVariableSubstitutionEnabled: "치환 사용", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index c2355cd54d..23b1daf80a 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -6145,6 +6145,8 @@ export default withEnglishFallback({ prefillNewQueryWithSelectDescription: "Ao criar uma nova consulta, preenche o editor com SELECT * FROM com base na aba de tabela ativa ou na tabela selecionada na barra lateral.", generateSqlIncludeDatabaseName: "Incluir nome do banco de dados no SQL gerado", generateSqlIncludeDatabaseNameDescription: "Quando compatível com o banco de dados, o SQL de tabela gerado usa banco.tabela em vez de somente o nome da tabela.", + formatSqlOnSqlFileSave: "Formatar SQL ao salvar arquivos SQL", + formatSqlOnSqlFileSaveDescription: "Formata automaticamente o SQL ao salvar um arquivo SQL, incluindo arquivos salvos na biblioteca SQL e consultas salvas como arquivos .sql.", clickTableNavigationTarget: "Ctrl+Clique abre DDL da tabela", clickTableNavigationTargetDescription: "Quando ativado, Ctrl/Cmd+clicar no nome da tabela abre o editor de estrutura (DDL). Quando desativado, abre a visualização de dados.", sqlVariableSyntax: "Substituição de variáveis e espaços reservados SQL", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 94a403df59..2794dea266 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -6444,6 +6444,8 @@ export default withEnglishFallback({ prefillNewQueryWithSelectDescription: "新建查询时,根据当前激活的数据表标签页或侧边栏选中的表,自动在编辑器中填充 SELECT * FROM <表名>。", generateSqlIncludeDatabaseName: "生成 SQL 时包含数据库名", generateSqlIncludeDatabaseNameDescription: "数据库支持时,生成的表 SQL 将使用“数据库名.表名”,而不只包含表名。", + formatSqlOnSqlFileSave: "保存 SQL 文件时格式化 SQL", + formatSqlOnSqlFileSaveDescription: "保存 SQL 文件时自动格式化 SQL,包括通过 SQL 库保存的文件以及将新查询保存为 .sql 文件等场景。", clickTableNavigationTarget: "Ctrl+点击表名打开 DDL", clickTableNavigationTargetDescription: "开启后,Ctrl/Cmd+点击表名将打开表结构编辑器(DDL);关闭则打开表数据视图。", sqlVariableSyntax: "SQL 变量与占位符替换", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 21bcd19691..58515aaf1e 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -5461,6 +5461,8 @@ export default withEnglishFallback({ prefillNewQueryWithSelectDescription: "新建查詢時,根據目前啟用的資料表分頁或側邊欄選取的資料表,自動在編輯器中填入 SELECT * FROM <資料表名稱>。", generateSqlIncludeDatabaseName: "產生 SQL 時包含資料庫名稱", generateSqlIncludeDatabaseNameDescription: "資料庫支援時,產生的資料表 SQL 會使用「資料庫名稱.資料表名稱」,而非只包含資料表名稱。", + formatSqlOnSqlFileSave: "儲存 SQL 檔案時格式化 SQL", + formatSqlOnSqlFileSaveDescription: "儲存 SQL 檔案時自動格式化 SQL,包括透過 SQL 庫儲存的檔案,以及將新查詢儲存為 .sql 檔案等場景。", clickTableNavigationTarget: "Ctrl+點擊表名開啟 DDL", clickTableNavigationTargetDescription: "開啟後,Ctrl/Cmd+點擊表名將開啟表結構編輯器(DDL);關閉則開啟表資料檢視。", sqlVariableSyntax: "SQL 變數與佔位符替換", diff --git a/apps/desktop/src/lib/__tests__/sql/sqlFormatOnSave.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlFormatOnSave.spec.ts new file mode 100644 index 0000000000..e7629bbc2b --- /dev/null +++ b/apps/desktop/src/lib/__tests__/sql/sqlFormatOnSave.spec.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { formatSqlSnapshotForSave } from "@/lib/sql/sqlFormatOnSave"; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +describe("formatSqlSnapshotForSave", () => { + it("does not overwrite edits made while formatting loads", async () => { + const formatting = deferred(); + let sql = "select 1"; + const pending = formatSqlSnapshotForSave( + sql, + () => sql, + () => formatting.promise, + (formatted) => { + sql = formatted; + }, + ); + + sql = "select 2"; + formatting.resolve("SELECT 1"); + + await expect(pending).resolves.toBe("select 2"); + expect(sql).toBe("select 2"); + }); + + it("applies and returns formatted SQL when the snapshot is unchanged", async () => { + let sql = "select 1"; + await expect( + formatSqlSnapshotForSave( + sql, + () => sql, + async () => "SELECT 1", + (formatted) => { + sql = formatted; + }, + ), + ).resolves.toBe("SELECT 1"); + expect(sql).toBe("SELECT 1"); + }); +}); diff --git a/apps/desktop/src/lib/settings/__tests__/settingsSearch.spec.ts b/apps/desktop/src/lib/settings/__tests__/settingsSearch.spec.ts index fde1e25fad..c2db39a3a5 100644 --- a/apps/desktop/src/lib/settings/__tests__/settingsSearch.spec.ts +++ b/apps/desktop/src/lib/settings/__tests__/settingsSearch.spec.ts @@ -227,6 +227,7 @@ describe("settings search", () => { { titleKey: "settings.autoAliasTables", category: "editor", targetId: "editor" }, { titleKey: "settings.clickTableNavigationTarget", category: "editor", targetId: "editor" }, { titleKey: "settings.generateSqlIncludeDatabaseName", category: "editor", targetId: "editor" }, + { titleKey: "settings.formatSqlOnSqlFileSave", category: "editor", targetId: "editor" }, { titleKey: "settings.sqlFormatterKeywordCase", category: "formatter", targetId: "formatter" }, { titleKey: "settings.sqlFormatterFunctionCase", category: "formatter", targetId: "formatter" }, { titleKey: "settings.sqlFormatterDataTypeCase", category: "formatter", targetId: "formatter" }, diff --git a/apps/desktop/src/lib/settings/editorSettingsDraft.ts b/apps/desktop/src/lib/settings/editorSettingsDraft.ts index 8501db81f5..5b725abc76 100644 --- a/apps/desktop/src/lib/settings/editorSettingsDraft.ts +++ b/apps/desktop/src/lib/settings/editorSettingsDraft.ts @@ -68,6 +68,7 @@ export const EDITOR_SETTINGS_DRAFT_KEYS = [ "openDataTabsNextToActive", "prefillNewQueryWithSelect", "generateSqlIncludeDatabaseName", + "formatSqlOnSqlFileSave", "updateNotificationsEnabled", "sidebarObjectInfoMode", "sidebarAllowHorizontalScroll", diff --git a/apps/desktop/src/lib/settings/settingsSearch.ts b/apps/desktop/src/lib/settings/settingsSearch.ts index b4b46e322b..cfbef7bc6a 100644 --- a/apps/desktop/src/lib/settings/settingsSearch.ts +++ b/apps/desktop/src/lib/settings/settingsSearch.ts @@ -122,6 +122,7 @@ export const SETTINGS_SEARCH_DEFINITIONS: readonly SettingsSearchDefinition[] = { id: "editor-app-close-unsaved-tabs", category: "editor", titleKey: "settings.appCloseUnsavedTabsMode", descriptionKey: "settings.appCloseUnsavedTabsModeDescription", targetId: "editor" }, { id: "editor-prefill-query", category: "editor", titleKey: "settings.prefillNewQueryWithSelect", descriptionKey: "settings.prefillNewQueryWithSelectDescription", targetId: "editor" }, { id: "editor-generate-sql-include-database", category: "editor", titleKey: "settings.generateSqlIncludeDatabaseName", descriptionKey: "settings.generateSqlIncludeDatabaseNameDescription", targetId: "editor" }, + { id: "editor-format-sql-on-sql-file-save", category: "editor", titleKey: "settings.formatSqlOnSqlFileSave", descriptionKey: "settings.formatSqlOnSqlFileSaveDescription", targetId: "editor" }, { id: "editor-diagnostics", category: "editor", titleKey: "settings.sqlSemanticDiagnosticsEnabled", descriptionKey: "settings.sqlSemanticDiagnosticsEnabledDescription", targetId: "editor" }, { id: "editor-sql-variables", category: "editor", titleKey: "settings.sqlVariableSyntax", descriptionKey: "settings.sqlVariableSyntaxDescription", targetId: "editor" }, { id: "editor-saved-sql-target", category: "editor", titleKey: "settings.savedSqlOpenTarget", targetId: "editor" }, diff --git a/apps/desktop/src/lib/sql/sqlFormatOnSave.ts b/apps/desktop/src/lib/sql/sqlFormatOnSave.ts new file mode 100644 index 0000000000..c9e3ecc7a1 --- /dev/null +++ b/apps/desktop/src/lib/sql/sqlFormatOnSave.ts @@ -0,0 +1,6 @@ +export async function formatSqlSnapshotForSave(sqlSnapshot: string, currentSql: () => string, format: (sql: string) => Promise, applyFormatted: (sql: string) => void): Promise { + const formatted = await format(sqlSnapshot); + if (currentSql() !== sqlSnapshot) return currentSql(); + if (formatted !== sqlSnapshot) applyFormatted(formatted); + return formatted; +} diff --git a/apps/desktop/src/stores/__tests__/settingsStore.spec.ts b/apps/desktop/src/stores/__tests__/settingsStore.spec.ts index f498681dce..12d55ea81d 100644 --- a/apps/desktop/src/stores/__tests__/settingsStore.spec.ts +++ b/apps/desktop/src/stores/__tests__/settingsStore.spec.ts @@ -50,6 +50,14 @@ describe("normalizeEditorSettings", () => { expect(normalizeEditorSettings({ openDataTabsNextToActive: null } as any).openDataTabsNextToActive).toBe(false); }); + it("keeps SQL-file save formatting disabled unless explicitly enabled", () => { + expect(normalizeEditorSettings({}).formatSqlOnSqlFileSave).toBe(false); + expect(normalizeEditorSettings({ formatSqlOnSqlFileSave: true }).formatSqlOnSqlFileSave).toBe(true); + expect(normalizeEditorSettings({ formatSqlOnSqlFileSave: false }).formatSqlOnSqlFileSave).toBe(false); + expect(normalizeEditorSettings({ formatSqlOnSqlFileSave: "true" } as any).formatSqlOnSqlFileSave).toBe(false); + expect(normalizeEditorSettings({ formatSqlOnSqlFileSave: null } as any).formatSqlOnSqlFileSave).toBe(false); + }); + it("defaults and bounds the regular expression match limit", () => { expect(normalizeEditorSettings({}).regexMaxMatchCount).toBe(1000); expect(normalizeEditorSettings({ regexMaxMatchCount: 2500 }).regexMaxMatchCount).toBe(2500); diff --git a/apps/desktop/src/stores/settingsStore.ts b/apps/desktop/src/stores/settingsStore.ts index ceea4f811b..970a2e7bde 100644 --- a/apps/desktop/src/stores/settingsStore.ts +++ b/apps/desktop/src/stores/settingsStore.ts @@ -604,6 +604,7 @@ export interface EditorSettings { openDataTabsNextToActive: boolean; prefillNewQueryWithSelect: boolean; generateSqlIncludeDatabaseName: boolean; + formatSqlOnSqlFileSave: boolean; updateNotificationsEnabled: boolean; sidebarHiddenTablePrefixes: string[]; sidebarObjectInfoMode: SidebarObjectInfoMode; @@ -813,6 +814,7 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = { openDataTabsNextToActive: false, prefillNewQueryWithSelect: true, generateSqlIncludeDatabaseName: false, + formatSqlOnSqlFileSave: false, updateNotificationsEnabled: true, sidebarHiddenTablePrefixes: [], sidebarObjectInfoMode: "comment-inline", @@ -1208,6 +1210,7 @@ export function normalizeEditorSettings(settings: Partial, exist openDataTabsNextToActive: typeof settings.openDataTabsNextToActive === "boolean" ? settings.openDataTabsNextToActive : DEFAULT_EDITOR_SETTINGS.openDataTabsNextToActive, prefillNewQueryWithSelect: typeof settings.prefillNewQueryWithSelect === "boolean" ? settings.prefillNewQueryWithSelect : DEFAULT_EDITOR_SETTINGS.prefillNewQueryWithSelect, generateSqlIncludeDatabaseName: settings.generateSqlIncludeDatabaseName === true, + formatSqlOnSqlFileSave: settings.formatSqlOnSqlFileSave === true, updateNotificationsEnabled: settings.updateNotificationsEnabled ?? DEFAULT_EDITOR_SETTINGS.updateNotificationsEnabled, sidebarHiddenTablePrefixes: normalizeSidebarHiddenTablePrefixes(settings.sidebarHiddenTablePrefixes), sidebarObjectInfoMode: normalizeSidebarObjectInfoMode( @@ -1800,6 +1803,7 @@ export const useSettingsStore = defineStore("settings", () => { if (partial.openDataTabsNextToActive !== undefined) editorSettings.value.openDataTabsNextToActive = partial.openDataTabsNextToActive === true; if (partial.prefillNewQueryWithSelect !== undefined) editorSettings.value.prefillNewQueryWithSelect = partial.prefillNewQueryWithSelect; if (partial.generateSqlIncludeDatabaseName !== undefined) editorSettings.value.generateSqlIncludeDatabaseName = partial.generateSqlIncludeDatabaseName === true; + if (partial.formatSqlOnSqlFileSave !== undefined) editorSettings.value.formatSqlOnSqlFileSave = partial.formatSqlOnSqlFileSave === true; if (partial.updateNotificationsEnabled !== undefined) editorSettings.value.updateNotificationsEnabled = partial.updateNotificationsEnabled; if (partial.sidebarHiddenTablePrefixes !== undefined) editorSettings.value.sidebarHiddenTablePrefixes = normalizeSidebarHiddenTablePrefixes(partial.sidebarHiddenTablePrefixes); if (partial.sidebarObjectInfoMode !== undefined) editorSettings.value.sidebarObjectInfoMode = normalizeSidebarObjectInfoMode(partial.sidebarObjectInfoMode);