Merge pull request #55 from SuLea-IT/codex/issue-51-sql-table-ux

[codex] Fix SQL editor and table query UX
This commit is contained in:
skyler 2026-05-02 16:28:52 +08:00 committed by GitHub
commit 4efd7c2517
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 235 additions and 61 deletions

View File

@ -50,6 +50,7 @@ import { getVersion } from "@tauri-apps/api/app";
import * as api from "@/lib/tauri";
import { canCancelQueryExecution, queryExecutionLabelKey } from "@/lib/queryExecutionState";
import { resolveExecutableSql } from "@/lib/sqlExecutionTarget";
import { buildTableSelectSql, quoteTableIdentifier } from "@/lib/tableSelectSql";
import type { SqlFormatDialect } from "@/lib/sqlFormatter";
import { isCloseTabShortcut, isExecuteSqlShortcut } from "@/lib/keyboardShortcuts";
@ -305,6 +306,14 @@ function tabDisplayTitle(tab: typeof queryStore.tabs[number]): string {
return tab.title;
}
function tabModeLabel(tab: typeof queryStore.tabs[number]): string {
if (tab.mode === "data") return t("tabs.table");
if (tab.mode === "query") return t("tabs.sql");
if (tab.mode === "mongo") return t("tabs.mongo");
if (tab.mode === "redis") return t("tabs.redis");
return tab.mode;
}
function databaseDisplayNameForTab(connectionId: string, database: string): string {
const connection = connectionStore.getConfig(connectionId);
if (connection?.db_type === "redis" && database !== "") return `db${database}`;
@ -470,7 +479,8 @@ async function changeActiveConnection(connectionId: any) {
async function onExecuteSql(sql: string) {
const tab = activeTab.value;
if (!tab) return;
await api.executeQuery(tab.connectionId, tab.database, sql);
queryStore.updateSql(tab.id, sql);
await queryStore.executeTabSql(tab.id, sql);
}
async function onReloadData() {
@ -486,61 +496,36 @@ type ActiveTab = NonNullable<typeof activeTab.value>;
function quoteIdent(tab: ActiveTab, name: string): string {
const config = connectionStore.getConfig(tab.connectionId);
return config?.db_type === "mysql"
? `\`${name.replace(/`/g, "``")}\``
: `"${name.replace(/"/g, '""')}"`;
}
function qualifiedTableName(tab: NonNullable<typeof activeTab.value>): string {
const config = connectionStore.getConfig(tab.connectionId);
if (!tab.tableMeta) return "";
if ((config?.db_type === "postgres" || config?.db_type === "oracle" || config?.db_type === "sqlserver") && tab.tableMeta.schema) {
return `${quoteIdent(tab, tab.tableMeta.schema)}.${quoteIdent(tab, tab.tableMeta.tableName)}`;
}
return quoteIdent(tab, tab.tableMeta.tableName);
}
function defaultOrderBy(tab: NonNullable<typeof activeTab.value>): string | undefined {
const primaryKeys = tab.tableMeta?.primaryKeys ?? [];
if (primaryKeys.length === 0) return undefined;
return primaryKeys.map((pk) => `${quoteIdent(tab, pk)} ASC`).join(", ");
return quoteTableIdentifier(config?.db_type, name);
}
function buildTableSql(
tab: NonNullable<typeof activeTab.value>,
options: { orderBy?: string; limit?: number; offset?: number } = {},
options: { orderBy?: string; limit?: number; offset?: number; whereInput?: string } = {},
): string {
const config = connectionStore.getConfig(tab.connectionId);
const limit = options.limit ?? 100;
const orderBy = options.orderBy ?? defaultOrderBy(tab);
const order = orderBy ? ` ORDER BY ${orderBy}` : "";
if (config?.db_type === "oracle") {
const offset = options.offset ? ` OFFSET ${options.offset} ROWS` : "";
return `SELECT * FROM ${qualifiedTableName(tab)}${order}${offset} FETCH FIRST ${limit} ROWS ONLY`;
}
if (config?.db_type === "sqlserver") {
return `SELECT TOP ${limit} * FROM ${qualifiedTableName(tab)}${order}`;
}
const offset = options.offset ? ` OFFSET ${options.offset}` : "";
return `SELECT * FROM ${qualifiedTableName(tab)}${order} LIMIT ${limit}${offset};`;
return buildTableSelectSql({
databaseType: config?.db_type,
schema: tab.tableMeta?.schema,
tableName: tab.tableMeta?.tableName ?? "",
primaryKeys: tab.tableMeta?.primaryKeys,
...options,
});
}
async function onPaginate(offset: number, limit: number) {
async function onPaginate(offset: number, limit: number, whereInput?: string) {
const tab = activeTab.value;
if (!tab?.tableMeta) return;
const sql = buildTableSql(tab, { limit, offset });
const sql = buildTableSql(tab, { limit, offset, whereInput });
queryStore.updateSql(tab.id, sql);
await queryStore.executeCurrentTab();
}
async function onSort(column: string, direction: "asc" | "desc" | null) {
async function onSort(column: string, direction: "asc" | "desc" | null, whereInput?: string) {
const tab = activeTab.value;
if (!tab?.tableMeta) return;
const orderBy = direction ? `${quoteIdent(tab, column)} ${direction.toUpperCase()}` : defaultOrderBy(tab);
const sql = buildTableSql(tab, { orderBy });
const orderBy = direction ? `${quoteIdent(tab, column)} ${direction.toUpperCase()}` : undefined;
const sql = buildTableSql(tab, { orderBy, whereInput });
queryStore.updateSql(tab.id, sql);
await queryStore.executeCurrentTab();
}
@ -852,7 +837,18 @@ async function setupFileDrop() {
@click="queryStore.activeTabId = tab.id"
>
<span class="h-4 w-1 rounded-full shrink-0" :style="{ backgroundColor: connectionColor(tab.connectionId) || '#9ca3af' }" />
<component
:is="tab.mode === 'data' ? Table2 : FileCode"
class="h-3.5 w-3.5 shrink-0"
:class="tab.mode === 'data' ? 'text-emerald-600' : 'text-blue-600'"
/>
<span class="min-w-0 truncate">{{ tabDisplayTitle(tab) }}</span>
<span
class="shrink-0 rounded border px-1 text-[10px] leading-4"
:class="tab.mode === 'data' ? 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-300' : 'border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900 dark:bg-blue-950 dark:text-blue-300'"
>
{{ tabModeLabel(tab) }}
</span>
<Tooltip>
<TooltipTrigger as-child>
<button
@ -1017,13 +1013,13 @@ async function setupFileDrop() {
{{ t('ai.fixWithAi') }}
</button>
</div>
<div v-else-if="activeTab.isExecuting" class="flex-1 min-h-0 flex flex-col items-center justify-center gap-3 text-muted-foreground text-sm">
<div v-else-if="!activeTab.result && activeTab.isExecuting" class="flex-1 min-h-0 flex flex-col items-center justify-center gap-3 text-muted-foreground text-sm">
<div class="flex items-center">
<Loader2 class="h-5 w-5 animate-spin mr-2" />
{{ t(queryExecutionLabelKey(activeTab)) }}
</div>
</div>
<div v-else class="flex-1 min-h-0 flex items-center justify-center text-muted-foreground text-sm">
<div v-else-if="!activeTab.result" class="flex-1 min-h-0 flex items-center justify-center text-muted-foreground text-sm">
{{ t('editor.pressToExecute') }}
</div>
</div>
@ -1033,9 +1029,24 @@ async function setupFileDrop() {
<!-- Data mode: full-height grid -->
<template v-else-if="activeTab.mode === 'data'">
<div class="flex-1 min-h-0">
<div class="flex-1 min-h-0 flex flex-col">
<div class="h-9 shrink-0 border-b bg-background/80 px-3 flex items-center gap-2 text-xs">
<span class="inline-flex items-center gap-1 rounded border border-emerald-200 bg-emerald-50 px-2 py-0.5 font-medium text-emerald-700 dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-300">
<Table2 class="h-3.5 w-3.5" />
{{ t('tabs.tableData') }}
</span>
<span class="font-medium truncate">{{ activeTab.tableMeta?.tableName || activeTab.title }}</span>
<span class="text-muted-foreground truncate">
{{ databaseDisplayNameForTab(activeTab.connectionId, activeTab.database) }}
<template v-if="activeTab.tableMeta?.schema"> · {{ activeTab.tableMeta.schema }}</template>
</span>
<span v-if="activeTab.tableMeta" class="ml-auto text-muted-foreground">
{{ activeTab.tableMeta.columns.length }} {{ t('tree.columns') }}
</span>
</div>
<DataGrid
v-if="activeTab.result"
class="flex-1 min-h-0"
:key="activeTab.id"
:result="activeTab.result"
:sql="activeTab.sql"

View File

@ -34,6 +34,7 @@ import {
type CellPosition,
type CellSelectionRange,
} from "@/lib/gridSelection";
import { buildTableSelectSql, normalizeWhereInput } from "@/lib/tableSelectSql";
import { useToast } from "@/composables/useToast";
@ -59,8 +60,8 @@ const props = defineProps<{
const emit = defineEmits<{
reload: [];
paginate: [offset: number, limit: number];
sort: [column: string, direction: "asc" | "desc" | null];
paginate: [offset: number, limit: number, whereInput?: string];
sort: [column: string, direction: "asc" | "desc" | null, whereInput?: string];
}>();
const hasData = computed(() => props.result.columns.length > 0);
@ -133,6 +134,8 @@ const showTranspose = ref(false);
const sortCol = ref<string | null>(null);
const sortDir = ref<"asc" | "desc">("asc");
const searchText = ref("");
const saveError = ref("");
const isApplyingWhere = ref(false);
const columnWidths = ref<number[]>([]);
const gridRef = ref<HTMLDivElement>();
const headerRef = ref<HTMLDivElement>();
@ -203,21 +206,26 @@ watch(() => props.result.columns.length, initColumnWidths);
const pageSize = ref(100);
const currentPage = ref(1);
const isFullPage = computed(() => props.result.rows.length >= pageSize.value);
const canUseWhereSearch = computed(() => !!props.tableMeta && !!props.onExecuteSql);
const isWhereSearch = computed(() => canUseWhereSearch.value && /^\s*where\b/i.test(searchText.value));
const wherePredicate = computed(() => normalizeWhereInput(searchText.value));
const activeWhereInput = computed(() => isWhereSearch.value && wherePredicate.value ? searchText.value : undefined);
const clientSearchText = computed(() => isWhereSearch.value ? "" : searchText.value);
function prevPage() {
if (currentPage.value <= 1) return;
currentPage.value--;
emit("paginate", (currentPage.value - 1) * pageSize.value, pageSize.value);
emit("paginate", (currentPage.value - 1) * pageSize.value, pageSize.value, activeWhereInput.value);
}
function nextPage() {
if (!isFullPage.value) return;
currentPage.value++;
emit("paginate", (currentPage.value - 1) * pageSize.value, pageSize.value);
emit("paginate", (currentPage.value - 1) * pageSize.value, pageSize.value, activeWhereInput.value);
}
function changePageSize(size: number) {
pageSize.value = size;
currentPage.value = 1;
emit("paginate", 0, size);
emit("paginate", 0, size, activeWhereInput.value);
}
// --- Editing ---
@ -240,8 +248,8 @@ const hasPendingChanges = computed(() =>
const sortedRows = computed(() => {
let rows = props.result.rows.map((row, sourceIndex) => ({ row, sourceIndex }));
if (searchText.value) {
const q = searchText.value.toLowerCase();
if (clientSearchText.value) {
const q = clientSearchText.value.toLowerCase();
rows = rows.filter(({ row, sourceIndex }) => {
const data = rowDataWithChanges(row, sourceIndex);
return data.some((cell) => cell !== null && String(cell).toLowerCase().includes(q));
@ -282,8 +290,8 @@ const displayItems = computed<RowItem[]>(() => {
return items;
});
const hasVisibleRows = computed(() => displayItems.value.length > 0);
const emptyTitle = computed(() => searchText.value ? t('grid.noSearchResults') : t('grid.noRows'));
const emptyDescription = computed(() => searchText.value ? t('grid.noSearchResultsDescription') : t('grid.noRowsDescription'));
const emptyTitle = computed(() => clientSearchText.value ? t('grid.noSearchResults') : t('grid.noRows'));
const emptyDescription = computed(() => clientSearchText.value ? t('grid.noSearchResultsDescription') : t('grid.noRowsDescription'));
const selectedRange = computed<CellSelectionRange | null>(() => {
if (!selectionAnchor.value || !selectionFocus.value) return null;
return normalizeSelectionRange(selectionAnchor.value, selectionFocus.value);
@ -328,12 +336,41 @@ const activeCellDetail = computed(() => {
function toggleSort(colName: string) {
if (isResizing) return;
if (sortCol.value === colName) {
if (sortDir.value === "asc") { sortDir.value = "desc"; emit("sort", colName, "desc"); }
else { sortCol.value = null; sortDir.value = "asc"; emit("sort", colName, null); }
if (sortDir.value === "asc") { sortDir.value = "desc"; emit("sort", colName, "desc", activeWhereInput.value); }
else { sortCol.value = null; sortDir.value = "asc"; emit("sort", colName, null, activeWhereInput.value); }
} else {
sortCol.value = colName;
sortDir.value = "asc";
emit("sort", colName, "asc");
emit("sort", colName, "asc", activeWhereInput.value);
}
}
function onSearchEnter(event: KeyboardEvent) {
if (!isWhereSearch.value) return;
event.preventDefault();
void applyWhereSearch();
}
async function applyWhereSearch() {
if (!props.tableMeta || !props.onExecuteSql || !wherePredicate.value) return;
isApplyingWhere.value = true;
saveError.value = "";
currentPage.value = 1;
try {
const sql = buildTableSelectSql({
databaseType: props.databaseType,
schema: props.tableMeta.schema,
tableName: props.tableMeta.tableName,
primaryKeys: props.tableMeta.primaryKeys,
orderBy: sortCol.value ? `${quoteIdent(sortCol.value)} ${sortDir.value.toUpperCase()}` : undefined,
limit: pageSize.value,
whereInput: searchText.value,
});
await props.onExecuteSql(sql);
} catch (e: any) {
saveError.value = String(e?.message || e);
} finally {
isApplyingWhere.value = false;
}
}
@ -604,8 +641,6 @@ function generateSaveStatements(): string[] {
return stmts;
}
const saveError = ref("");
async function saveChanges() {
const stmts = generateSaveStatements();
if (stmts.length === 0) return;
@ -956,11 +991,24 @@ function escapeAndHighlightKeywords(s: string): string {
<input
v-model="searchText"
class="flex-1 h-5 text-xs bg-transparent outline-none placeholder:text-muted-foreground"
:placeholder="t('grid.search')"
:placeholder="canUseWhereSearch ? t('grid.searchOrWhere') : t('grid.search')"
@keydown.enter="onSearchEnter"
/>
<span v-if="searchText" class="text-xs text-muted-foreground">
<span v-if="clientSearchText" class="text-xs text-muted-foreground">
{{ sortedRows.length }}/{{ result.rows.length }}
</span>
<Button
v-if="isWhereSearch"
variant="ghost"
size="sm"
class="h-5 text-xs px-1.5 shrink-0"
:disabled="isApplyingWhere || !wherePredicate"
@click="applyWhereSearch"
>
<Loader2 v-if="isApplyingWhere" class="w-3 h-3 mr-1 animate-spin" />
<Search v-else class="w-3 h-3 mr-1" />
{{ t('grid.applyWhere') }}
</Button>
<Button v-if="editable && tableMeta" variant="ghost" size="sm" class="h-5 text-xs px-1.5 shrink-0" @click="addRow">
<Plus class="w-3 h-3 mr-1" /> {{ t('grid.addRow') }}
</Button>
@ -1297,7 +1345,7 @@ function escapeAndHighlightKeywords(s: string): string {
<!-- Bottom status bar -->
<div class="flex items-center gap-2 px-3 py-1 border-t text-xs text-muted-foreground bg-muted/30 shrink-0">
<span v-if="hasData">{{ t('grid.rows', { count: result.rows.length }) }}</span>
<span v-if="hasData">{{ t('grid.totalRows', { count: result.rows.length }) }}</span>
<span v-else>{{ t('grid.rowsAffected', { count: result.affected_rows }) }}</span>
<span>{{ result.execution_time_ms }}ms</span>
<span v-if="hasCellSelection" class="text-foreground">{{ selectionSummary }}</span>

View File

@ -78,8 +78,16 @@ export default {
selectConnection: "Select connection",
selectDatabase: "Select database",
},
tabs: {
sql: "SQL",
table: "Table",
tableData: "Table Data",
redis: "Redis",
mongo: "Mongo",
},
grid: {
rows: "{count} rows",
totalRows: "Total {count} rows",
rowsAffected: "{count} rows affected",
querySuccess: "Query executed successfully",
noRows: "No data",
@ -99,6 +107,8 @@ export default {
exportMarkdown: "Export Markdown",
copied: "Copied!",
search: "Search...",
searchOrWhere: "Search, or enter a WHERE clause...",
applyWhere: "Apply WHERE",
page: "Page {page}",
rowsPerPage: "Rows per page",
save: "Save",

View File

@ -80,8 +80,16 @@ export default {
selectConnection: "选择连接",
selectDatabase: "选择数据库",
},
tabs: {
sql: "SQL",
table: "表",
tableData: "数据表",
redis: "Redis",
mongo: "Mongo",
},
grid: {
rows: "{count} 行",
totalRows: "共 {count} 行",
rowsAffected: "影响 {count} 行",
querySuccess: "查询执行成功",
noRows: "暂无数据",
@ -101,6 +109,8 @@ export default {
exportMarkdown: "导出 Markdown",
copied: "已复制!",
search: "搜索...",
searchOrWhere: "搜索,或输入 WHERE 条件...",
applyWhere: "应用 WHERE",
page: "第 {page} 页",
rowsPerPage: "每页行数",
save: "保存",

View File

@ -83,7 +83,7 @@ export function buildSqlCompletionItemsFromContext(
items.push(...buildTableItems(context.prefix, input.tables));
}
if (!context.qualifier) {
if (!context.qualifier && !context.suggestTables) {
items.push(...buildKeywordItems(context.prefix));
}

55
src/lib/tableSelectSql.ts Normal file
View File

@ -0,0 +1,55 @@
import type { DatabaseType } from "../types/database.ts";
export interface BuildTableSelectSqlOptions {
databaseType?: DatabaseType;
schema?: string;
tableName: string;
primaryKeys?: string[];
orderBy?: string;
limit?: number;
offset?: number;
whereInput?: string;
}
export function quoteTableIdentifier(databaseType: DatabaseType | undefined, name: string): string {
if (databaseType === "mysql") return `\`${name.replace(/`/g, "``")}\``;
return `"${name.replace(/"/g, '""')}"`;
}
export function qualifiedTableName(options: Pick<BuildTableSelectSqlOptions, "databaseType" | "schema" | "tableName">): string {
const { databaseType, schema, tableName } = options;
if ((databaseType === "postgres" || databaseType === "oracle" || databaseType === "sqlserver") && schema) {
return `${quoteTableIdentifier(databaseType, schema)}.${quoteTableIdentifier(databaseType, tableName)}`;
}
return quoteTableIdentifier(databaseType, tableName);
}
export function normalizeWhereInput(whereInput?: string): string {
const withoutSemicolon = whereInput?.trim().replace(/;+$/, "").trim() ?? "";
return withoutSemicolon.replace(/^where\b/i, "").trim();
}
export function buildTableSelectSql(options: BuildTableSelectSqlOptions): string {
const databaseType = options.databaseType;
const limit = options.limit ?? 100;
const table = qualifiedTableName(options);
const predicate = normalizeWhereInput(options.whereInput);
const where = predicate ? ` WHERE (${predicate})` : "";
const defaultOrderBy = options.primaryKeys?.length
? options.primaryKeys.map((pk) => `${quoteTableIdentifier(databaseType, pk)} ASC`).join(", ")
: undefined;
const orderBy = options.orderBy ?? defaultOrderBy;
const order = orderBy ? ` ORDER BY ${orderBy}` : "";
if (databaseType === "oracle") {
const offset = options.offset ? ` OFFSET ${options.offset} ROWS` : "";
return `SELECT * FROM ${table}${where}${order}${offset} FETCH FIRST ${limit} ROWS ONLY`;
}
if (databaseType === "sqlserver") {
return `SELECT TOP ${limit} * FROM ${table}${where}${order}`;
}
const offset = options.offset ? ` OFFSET ${options.offset}` : "";
return `SELECT * FROM ${table}${where}${order} LIMIT ${limit}${offset};`;
}

View File

@ -0,0 +1,40 @@
import { strict as assert } from "node:assert";
import test from "node:test";
import { buildTableSelectSql } from "../src/lib/tableSelectSql.ts";
test("builds a MySQL table WHERE query from search input", () => {
const sql = buildTableSelectSql({
databaseType: "mysql",
tableName: "users",
primaryKeys: ["id"],
whereInput: "where status = 'active'",
limit: 100,
});
assert.equal(sql, "SELECT * FROM `users` WHERE (status = 'active') ORDER BY `id` ASC LIMIT 100;");
});
test("builds a schema-qualified PostgreSQL table WHERE query", () => {
const sql = buildTableSelectSql({
databaseType: "postgres",
schema: "public",
tableName: "orders",
whereInput: "WHERE amount > 10",
limit: 50,
offset: 100,
});
assert.equal(sql, 'SELECT * FROM "public"."orders" WHERE (amount > 10) LIMIT 50 OFFSET 100;');
});
test("builds SQL Server WHERE query with TOP", () => {
const sql = buildTableSelectSql({
databaseType: "sqlserver",
schema: "dbo",
tableName: "accounts",
whereInput: "where id = 1",
limit: 25,
});
assert.equal(sql, 'SELECT TOP 25 * FROM "dbo"."accounts" WHERE (id = 1)');
});