feat: format sql (#19)
Co-authored-by: SuLe <sule@SuLedeMacBook-Air.local>
This commit is contained in:
parent
ac76aadbed
commit
5ee4f9300f
|
|
@ -28,6 +28,7 @@
|
|||
"shadcn-vue": "^2.6.2",
|
||||
"simple-icons": "^16.18.0",
|
||||
"splitpanes": "^4.0.4",
|
||||
"sql-formatter": "^15.7.3",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"vue": "^3.5.0",
|
||||
|
|
|
|||
|
|
@ -62,6 +62,9 @@ importers:
|
|||
splitpanes:
|
||||
specifier: ^4.0.4
|
||||
version: 4.0.4(vue@3.5.33(typescript@5.6.3))
|
||||
sql-formatter:
|
||||
specifier: ^15.7.3
|
||||
version: 15.7.3
|
||||
tailwind-merge:
|
||||
specifier: ^3.5.0
|
||||
version: 3.5.0
|
||||
|
|
@ -1078,6 +1081,9 @@ packages:
|
|||
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
argparse@2.0.1:
|
||||
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
|
||||
|
||||
aria-hidden@1.2.6:
|
||||
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
|
||||
engines: {node: '>=10'}
|
||||
|
|
@ -1214,6 +1220,9 @@ packages:
|
|||
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
commander@2.20.3:
|
||||
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
|
||||
|
||||
concat-map@0.0.1:
|
||||
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
|
||||
|
||||
|
|
@ -1345,6 +1354,9 @@ packages:
|
|||
resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==}
|
||||
engines: {node: '>=0.3.1'}
|
||||
|
||||
discontinuous-range@1.0.0:
|
||||
resolution: {integrity: sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==}
|
||||
|
||||
dom-serializer@2.0.0:
|
||||
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
|
||||
|
||||
|
|
@ -1994,6 +2006,9 @@ packages:
|
|||
mitt@3.0.1:
|
||||
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
|
||||
|
||||
moo@0.5.3:
|
||||
resolution: {integrity: sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==}
|
||||
|
||||
ms@2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
|
|
@ -2008,6 +2023,10 @@ packages:
|
|||
natural-compare@1.4.0:
|
||||
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
||||
|
||||
nearley@2.20.1:
|
||||
resolution: {integrity: sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==}
|
||||
hasBin: true
|
||||
|
||||
negotiator@1.0.0:
|
||||
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
|
@ -2220,6 +2239,13 @@ packages:
|
|||
queue-microtask@1.2.3:
|
||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||
|
||||
railroad-diagrams@1.0.0:
|
||||
resolution: {integrity: sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==}
|
||||
|
||||
randexp@0.4.6:
|
||||
resolution: {integrity: sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
range-parser@1.2.1:
|
||||
resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
|
@ -2259,6 +2285,10 @@ packages:
|
|||
resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
ret@0.1.15:
|
||||
resolution: {integrity: sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
reusify@1.1.0:
|
||||
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
|
||||
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
|
||||
|
|
@ -2379,6 +2409,10 @@ packages:
|
|||
peerDependencies:
|
||||
vue: ^3.2.0
|
||||
|
||||
sql-formatter@15.7.3:
|
||||
resolution: {integrity: sha512-5+zl9Nqg5aNjss0tb1G+StpC4dJKbjv3+g8CL/+V+00PfZop+2RKGyi53ScFl0dr+Dkx1LjmUO54Q3N7K3EtMw==}
|
||||
hasBin: true
|
||||
|
||||
statuses@2.0.2:
|
||||
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
|
@ -3611,6 +3645,8 @@ snapshots:
|
|||
dependencies:
|
||||
color-convert: 2.0.1
|
||||
|
||||
argparse@2.0.1: {}
|
||||
|
||||
aria-hidden@1.2.6:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
|
@ -3751,6 +3787,8 @@ snapshots:
|
|||
|
||||
commander@14.0.3: {}
|
||||
|
||||
commander@2.20.3: {}
|
||||
|
||||
concat-map@0.0.1: {}
|
||||
|
||||
confbox@0.2.4: {}
|
||||
|
|
@ -3841,6 +3879,8 @@ snapshots:
|
|||
|
||||
diff@8.0.4: {}
|
||||
|
||||
discontinuous-range@1.0.0: {}
|
||||
|
||||
dom-serializer@2.0.0:
|
||||
dependencies:
|
||||
domelementtype: 2.3.0
|
||||
|
|
@ -4467,6 +4507,8 @@ snapshots:
|
|||
|
||||
mitt@3.0.1: {}
|
||||
|
||||
moo@0.5.3: {}
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
muggle-string@0.4.1: {}
|
||||
|
|
@ -4475,6 +4517,13 @@ snapshots:
|
|||
|
||||
natural-compare@1.4.0: {}
|
||||
|
||||
nearley@2.20.1:
|
||||
dependencies:
|
||||
commander: 2.20.3
|
||||
moo: 0.5.3
|
||||
railroad-diagrams: 1.0.0
|
||||
randexp: 0.4.6
|
||||
|
||||
negotiator@1.0.0: {}
|
||||
|
||||
node-fetch-native@1.6.7: {}
|
||||
|
|
@ -4676,6 +4725,13 @@ snapshots:
|
|||
|
||||
queue-microtask@1.2.3: {}
|
||||
|
||||
railroad-diagrams@1.0.0: {}
|
||||
|
||||
randexp@0.4.6:
|
||||
dependencies:
|
||||
discontinuous-range: 1.0.0
|
||||
ret: 0.1.15
|
||||
|
||||
range-parser@1.2.1: {}
|
||||
|
||||
raw-body@3.0.2:
|
||||
|
|
@ -4726,6 +4782,8 @@ snapshots:
|
|||
onetime: 7.0.0
|
||||
signal-exit: 4.1.0
|
||||
|
||||
ret@0.1.15: {}
|
||||
|
||||
reusify@1.1.0: {}
|
||||
|
||||
rfdc@1.4.1: {}
|
||||
|
|
@ -4923,6 +4981,11 @@ snapshots:
|
|||
dependencies:
|
||||
vue: 3.5.33(typescript@5.6.3)
|
||||
|
||||
sql-formatter@15.7.3:
|
||||
dependencies:
|
||||
argparse: 2.0.1
|
||||
nearley: 2.20.1
|
||||
|
||||
statuses@2.0.2: {}
|
||||
|
||||
stdin-discarder@0.3.2: {}
|
||||
|
|
|
|||
52
src/App.vue
52
src/App.vue
|
|
@ -1,7 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { DatabaseZap, FilePlus2, Play, Loader2, X, Globe, Moon, Sun, Upload, Download, Plus, History, Server, Table2, Database, Search, ShieldCheck, Sparkles, Pin } from "lucide-vue-next";
|
||||
import { DatabaseZap, FilePlus2, Play, Loader2, X, Globe, Moon, Sun, Upload, Download, Plus, History, Server, Table2, Database, Search, ShieldCheck, Sparkles, Pin, AlignLeft } from "lucide-vue-next";
|
||||
import { Splitpanes, Pane } from "splitpanes";
|
||||
import "splitpanes/dist/splitpanes.css";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -34,6 +34,7 @@ import { getCurrentWindow, type Theme } from "@tauri-apps/api/window";
|
|||
import { getCurrentWebview } from "@tauri-apps/api/webview";
|
||||
import * as api from "@/lib/tauri";
|
||||
import { resolveExecutableSql } from "@/lib/sqlExecutionTarget";
|
||||
import type { SqlFormatDialect } from "@/lib/sqlFormatter";
|
||||
|
||||
const { t } = useI18n();
|
||||
const connectionStore = useConnectionStore();
|
||||
|
|
@ -47,6 +48,7 @@ const showHistory = ref(false);
|
|||
const dangerSql = ref("");
|
||||
const pendingDangerSql = ref("");
|
||||
const selectedSql = ref("");
|
||||
const formatSqlRequestId = ref(0);
|
||||
const showDangerDialog = ref(false);
|
||||
const databaseOptions = ref<Record<string, string[]>>({});
|
||||
const loadingDatabaseOptions = ref<Record<string, boolean>>({});
|
||||
|
|
@ -84,6 +86,25 @@ const activeConnection = computed(() => {
|
|||
return tab ? connectionStore.getConfig(tab.connectionId) : undefined;
|
||||
});
|
||||
|
||||
const activeSqlFormatDialect = computed<SqlFormatDialect>(() => {
|
||||
switch (activeConnection.value?.db_type) {
|
||||
case "mysql":
|
||||
return "mysql";
|
||||
case "postgres":
|
||||
return "postgres";
|
||||
case "sqlite":
|
||||
return "sqlite";
|
||||
case "sqlserver":
|
||||
return "sqlserver";
|
||||
default:
|
||||
return "generic";
|
||||
}
|
||||
});
|
||||
|
||||
const editorDialect = computed<"mysql" | "postgres">(() =>
|
||||
activeConnection.value?.db_type === "postgres" ? "postgres" : "mysql"
|
||||
);
|
||||
|
||||
const activeTabContext = computed(() => {
|
||||
const tab = activeTab.value;
|
||||
const connection = activeConnection.value;
|
||||
|
|
@ -208,6 +229,16 @@ function onEditorSelectionChange(val: string) {
|
|||
selectedSql.value = val;
|
||||
}
|
||||
|
||||
function formatActiveSql() {
|
||||
const tab = activeTab.value;
|
||||
if (!tab || tab.mode !== "query" || !tab.sql.trim()) return;
|
||||
formatSqlRequestId.value++;
|
||||
}
|
||||
|
||||
function onFormatSqlError() {
|
||||
toast(t("toolbar.formatSqlFailed"));
|
||||
}
|
||||
|
||||
function newQuery() {
|
||||
if (!connectionStore.activeConnectionId) return;
|
||||
const conn = connectionStore.connections.find(
|
||||
|
|
@ -511,6 +542,21 @@ async function setupFileDrop() {
|
|||
<TooltipContent>{{ t('toolbar.executeShortcut') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
:disabled="!activeTab || activeTab.mode !== 'query' || activeTab.isExecuting || !activeTab.sql.trim()"
|
||||
@click="formatActiveSql"
|
||||
>
|
||||
<AlignLeft class="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t('toolbar.formatSql') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<div class="flex-1" />
|
||||
|
||||
<Tooltip>
|
||||
|
|
@ -689,8 +735,12 @@ async function setupFileDrop() {
|
|||
<QueryEditor
|
||||
class="flex-1"
|
||||
:model-value="activeTab.sql"
|
||||
:dialect="editorDialect"
|
||||
:format-dialect="activeSqlFormatDialect"
|
||||
:format-request-id="formatSqlRequestId"
|
||||
@update:model-value="onEditorUpdate"
|
||||
@selection-change="onEditorSelectionChange"
|
||||
@format-error="onFormatSqlError"
|
||||
@execute="tryExecute"
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,15 +2,19 @@
|
|||
import { ref, onMounted, onBeforeUnmount, watch, shallowRef } from "vue";
|
||||
import type { EditorView as EditorViewType } from "@codemirror/view";
|
||||
import { resolveExecutableSql } from "@/lib/sqlExecutionTarget";
|
||||
import { formatSqlText, type SqlFormatDialect } from "@/lib/sqlFormatter";
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string;
|
||||
dialect?: "mysql" | "postgres";
|
||||
formatDialect?: SqlFormatDialect;
|
||||
formatRequestId?: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [value: string];
|
||||
"selectionChange": [value: string];
|
||||
"formatError": [message: string];
|
||||
execute: [sql: string];
|
||||
}>();
|
||||
|
||||
|
|
@ -69,6 +73,31 @@ function executableSqlFromView(currentView: EditorViewType): string {
|
|||
return resolveExecutableSql(currentView.state.doc.toString(), selectedSqlFromView(currentView));
|
||||
}
|
||||
|
||||
async function formatCurrentSql() {
|
||||
const currentView = view.value;
|
||||
if (!currentView) return;
|
||||
|
||||
const selection = currentView.state.selection.main;
|
||||
const formatsSelection = !selection.empty;
|
||||
const from = formatsSelection ? selection.from : 0;
|
||||
const to = formatsSelection ? selection.to : currentView.state.doc.length;
|
||||
const source = currentView.state.sliceDoc(from, to);
|
||||
if (!source.trim()) return;
|
||||
|
||||
try {
|
||||
const formatted = await formatSqlText(source, props.formatDialect ?? props.dialect ?? "generic");
|
||||
if (formatted === source) return;
|
||||
currentView.dispatch({
|
||||
changes: { from, to, insert: formatted },
|
||||
selection: formatsSelection
|
||||
? { anchor: from, head: from + formatted.length }
|
||||
: { anchor: from + formatted.length },
|
||||
});
|
||||
} catch (e: any) {
|
||||
emit("formatError", String(e?.message || e));
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!editorRef.value) return;
|
||||
|
||||
|
|
@ -170,6 +199,13 @@ watch(
|
|||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.formatRequestId,
|
||||
(val, oldVal) => {
|
||||
if (val && val !== oldVal) formatCurrentSql();
|
||||
}
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
view.value?.destroy();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ export default {
|
|||
newQuery: "New Query",
|
||||
execute: "Execute",
|
||||
executeShortcut: "Execute selection/query (Cmd+Enter)",
|
||||
formatSql: "Format SQL",
|
||||
formatSqlFailed: "Failed to format SQL",
|
||||
},
|
||||
sidebar: {
|
||||
connections: "CONNECTIONS",
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ export default {
|
|||
newQuery: "新建查询",
|
||||
execute: "执行",
|
||||
executeShortcut: "执行选中/全部 (Cmd+Enter)",
|
||||
formatSql: "格式化 SQL",
|
||||
formatSqlFailed: "SQL 格式化失败",
|
||||
},
|
||||
sidebar: {
|
||||
connections: "连接",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
export type SqlFormatDialect = "mysql" | "postgres" | "sqlite" | "sqlserver" | "generic";
|
||||
|
||||
function formatterLanguage(dialect: SqlFormatDialect) {
|
||||
switch (dialect) {
|
||||
case "mysql":
|
||||
return "mysql";
|
||||
case "postgres":
|
||||
return "postgresql";
|
||||
case "sqlite":
|
||||
return "sqlite";
|
||||
case "sqlserver":
|
||||
return "transactsql";
|
||||
default:
|
||||
return "sql";
|
||||
}
|
||||
}
|
||||
|
||||
export async function formatSqlText(sql: string, dialect: SqlFormatDialect = "generic"): Promise<string> {
|
||||
if (!sql.trim()) return sql;
|
||||
const { format } = await import("sql-formatter");
|
||||
return format(sql, {
|
||||
language: formatterLanguage(dialect),
|
||||
keywordCase: "upper",
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
import { formatSqlText } from "../src/lib/sqlFormatter.js";
|
||||
|
||||
test("formats SQL with uppercase keywords and readable line breaks", async () => {
|
||||
const formatted = await formatSqlText("select id, name from users where active = 1 order by name", "postgres");
|
||||
|
||||
assert.match(formatted, /^SELECT\b/);
|
||||
assert.match(formatted, /\nFROM\b/);
|
||||
assert.match(formatted, /\nWHERE\b/);
|
||||
assert.match(formatted, /\nORDER BY\b/);
|
||||
});
|
||||
|
||||
test("leaves blank SQL unchanged", async () => {
|
||||
assert.equal(await formatSqlText(" \n\t", "mysql"), " \n\t");
|
||||
});
|
||||
Loading…
Reference in New Issue