feat: add SQL library history query entry points

This commit is contained in:
SuLea-IT 2026-06-11 10:53:54 +08:00 committed by GitHub
parent 634e26e8e7
commit 806d7c758b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 345 additions and 8 deletions

View File

@ -30,6 +30,7 @@ import { useVisibilityChange } from "@/composables/useVisibilityChange";
import "@/i18n";
import { translateBackendError } from "@/i18n/backend-errors";
import * as api from "@/lib/api";
import { connectionRedactedNameLabel } from "@/lib/connectionPresentation";
import { resolveDefaultDatabase } from "@/lib/defaultDatabase";
import { findTreeNodeById, resolveNewQueryTarget } from "@/lib/newQueryContext";
import { buildExecutableObjectSourceStatements, objectSourceSaveExecutionMode } from "@/lib/objectSourceEditor";
@ -61,6 +62,7 @@ import { classifyAiSqlExecution } from "@/lib/aiSqlExecutionPolicy";
import { buildHistoryAiAnalysisPrompt } from "@/lib/historyAiAnalysis";
import { countAvailableAgentDriverUpdates, type AgentDriverUpdateBadgeState } from "@/lib/agentDriverUpdateBadge";
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/safeStorage";
import { rankSavedSqlHistory } from "@/lib/savedSqlHistory";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@ -215,6 +217,20 @@ const connectionStats = computed(() => ({
types: new Set(connectionStore.connections.map((c) => c.driver_profile || c.db_type)).size,
}));
const recentConnections = computed(() => connectionStore.connections.slice(0, 5));
const savedSqlHistoryItems = computed(() => {
const folderById = new Map(savedSqlStore.allFolders.map((folder) => [folder.id, folder.name]));
return rankSavedSqlHistory(savedSqlStore.allFiles, { limit: 6 }).map((file) => {
const connection = connectionStore.getConfig(file.connectionId);
return {
id: file.id,
name: file.name,
connectionName: connection ? connectionRedactedNameLabel(connection) : t("welcome.unknownConnection"),
database: file.database,
folderName: file.folderId ? folderById.get(file.folderId) : undefined,
openCount: file.openCount ?? 0,
};
});
});
const saveSqlFolders = computed(() => {
return savedSqlStore.allFolders;
});
@ -619,6 +635,15 @@ async function openConnectionQuery(connectionId: string) {
}
}
function openSavedSqlFromWelcome(fileId: string) {
const file = savedSqlStore.getFile(fileId);
if (!file) return;
queryStore.openSavedSql(file);
connectionStore.activeConnectionId = file.connectionId;
void savedSqlStore.recordFileUsage(file.id);
toast(t("welcome.fileOpened", { name: file.name }), 2000);
}
async function onClickTable(tableName: string) {
const tab = activeTab.value;
if (!tab) return;
@ -1088,9 +1113,11 @@ onUnmounted(() => {
v-else
:connection-stats="connectionStats"
:recent-connections="recentConnections"
:saved-sql-history-items="savedSqlHistoryItems"
:app-version="appVersion"
:has-connections="connectionStore.connections.length > 0"
@open-connection-query="openConnectionQuery"
@open-saved-sql="openSavedSqlFromWelcome"
@new-connection="showConnectionDialog = true"
@new-query="newQuery"
@show-history="showHistory = true"

View File

@ -439,6 +439,7 @@ function openFile(file: SavedSqlFile) {
if (suppressNextRowClick.value) return;
queryStore.openSavedSql(file);
connectionStore.activeConnectionId = file.connectionId;
void savedSqlStore.recordFileUsage(file.id);
}
const contextTarget = ref<SavedSqlFolder | SavedSqlFile | "panel" | null>(null);

View File

@ -5,15 +5,26 @@ import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import { connectionDriverLabel, connectionIconType, connectionRedactedNameLabel, connectionRedactedOptionSubtitle } from "@/lib/connectionPresentation";
import type { ConnectionConfig } from "@/types/database";
export interface WelcomeSavedSqlHistoryItem {
id: string;
name: string;
connectionName: string;
database?: string;
folderName?: string;
openCount?: number;
}
defineProps<{
connectionStats: { total: number; connected: number; types: number };
recentConnections: ConnectionConfig[];
savedSqlHistoryItems: WelcomeSavedSqlHistoryItem[];
appVersion: string;
hasConnections: boolean;
}>();
const emit = defineEmits<{
"open-connection-query": [connectionId: string];
"open-saved-sql": [fileId: string];
"new-connection": [];
"new-query": [];
"show-history": [];
@ -83,6 +94,30 @@ const { t } = useI18n();
</div>
</div>
<div class="rounded-lg border">
<div class="flex items-center justify-between border-b px-4 py-3">
<div class="flex items-center gap-2 text-sm font-medium"><History class="h-4 w-4" /> {{ t("welcome.sqlHistory") }}</div>
</div>
<div class="divide-y">
<button v-for="item in savedSqlHistoryItems" :key="item.id" class="flex w-full items-center gap-3 px-4 py-3 text-left hover:bg-muted/40" @click="emit('open-saved-sql', item.id)">
<History class="h-4 w-4 text-muted-foreground" />
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium">{{ item.name }}</div>
<div class="truncate text-xs text-muted-foreground">
<span>{{ item.connectionName }}</span>
<span v-if="item.database"> · {{ item.database }}</span>
<span v-if="item.folderName"> · {{ item.folderName }}</span>
<span v-if="item.openCount"> · {{ t("welcome.sqlHistoryOpenCount", { count: item.openCount }) }}</span>
</div>
</div>
<FilePlus2 class="h-4 w-4 text-muted-foreground" />
</button>
<div v-if="savedSqlHistoryItems.length === 0" class="px-4 py-8 text-sm text-muted-foreground">
{{ t("welcome.sqlHistoryEmpty") }}
</div>
</div>
</div>
<!-- MCP Integration Hint -->
<div class="rounded-lg border bg-muted/10 px-5 py-4">
<div class="flex items-start gap-3">

View File

@ -53,6 +53,7 @@ import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomC
import { useConnectionStore } from "@/stores/connectionStore";
import { useQueryStore } from "@/stores/queryStore";
import { useSettingsStore } from "@/stores/settingsStore";
import { useSavedSqlStore } from "@/stores/savedSqlStore";
import { useToast } from "@/composables/useToast";
import { useDatabaseOptions } from "@/composables/useDatabaseOptions";
import type { ColumnInfo, DatabaseType, TreeNode, TreeNodeType } from "@/types/database";
@ -103,6 +104,7 @@ import { isTauriRuntime } from "@/lib/tauriRuntime";
import { copyToClipboard } from "@/lib/clipboard";
import { hasEnabledTransportLayers } from "@/lib/connectionTransport";
import { formatShortcut } from "@/lib/shortcutRegistry";
import { rankSavedSqlHistory, type SavedSqlHistoryScope } from "@/lib/savedSqlHistory";
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import ConnectionErrorIndicator from "@/components/connection/ConnectionErrorIndicator.vue";
import VisibleDatabasesDialog from "@/components/sidebar/VisibleDatabasesDialog.vue";
@ -126,6 +128,7 @@ function isLabelTruncated(): boolean {
const connectionStore = useConnectionStore();
const queryStore = useQueryStore();
const settingsStore = useSettingsStore();
const savedSqlStore = useSavedSqlStore();
const { toast } = useToast();
const { highlight } = useSqlHighlighter();
@ -2529,6 +2532,60 @@ function copyStructureAsSubmenu(): ContextMenuItem {
};
}
function savedSqlHistoryScopeForNode(node: TreeNode): SavedSqlHistoryScope | null {
if (!node.connectionId) return null;
if (node.type === "connection") {
return { connectionId: node.connectionId };
}
if ((node.type === "database" || node.type === "schema") && hasTreeNodeDatabaseContext(node)) {
return {
connectionId: node.connectionId,
database: node.database,
schema: node.type === "schema" ? node.schema : undefined,
};
}
if ((node.type === "table" || node.type === "view") && hasTreeNodeDatabaseContext(node)) {
return {
connectionId: node.connectionId,
database: node.database,
schema: node.schema,
tableName: node.label,
};
}
return null;
}
function openSavedSqlHistoryFile(fileId: string) {
const file = savedSqlStore.getFile(fileId);
if (!file) return;
queryStore.openSavedSql(file);
connectionStore.activeConnectionId = file.connectionId;
void savedSqlStore.recordFileUsage(file.id);
}
function savedSqlHistorySubmenu(): ContextMenuItem | null {
const scope = savedSqlHistoryScopeForNode(props.node);
if (!scope) return null;
const files = rankSavedSqlHistory(savedSqlStore.allFiles, { ...scope, limit: 10 });
return {
label: t("contextMenu.sqlHistory"),
icon: ScrollText,
children:
files.length > 0
? files.map((file) => ({
label: file.name,
action: () => openSavedSqlHistoryFile(file.id),
icon: FileCode,
}))
: [
{
label: t("contextMenu.noSqlHistory"),
disabled: true,
},
],
};
}
function treeItemMenuItems(): ContextMenuItem[] {
const node = props.node;
const items: ContextMenuItem[] = [];
@ -2554,6 +2611,8 @@ function treeItemMenuItems(): ContextMenuItem[] {
items.push({ label: t("contextMenu.closeConnection"), action: disconnectConnection, icon: Unplug });
}
items.push({ label: t("contextMenu.newQuery"), action: newQuery, icon: TerminalSquare });
const sqlHistoryMenu = savedSqlHistorySubmenu();
if (sqlHistoryMenu) items.push(sqlHistoryMenu);
if (supportsDatabaseUserAdmin(currentDatabaseType())) {
items.push({ label: t("contextMenu.userAdmin"), action: openUserAdmin, icon: UsersRound });
}
@ -2641,6 +2700,8 @@ function treeItemMenuItems(): ContextMenuItem[] {
items.push({ label: t("contextMenu.openObjectBrowser"), action: openObjectBrowser, icon: TableProperties });
}
items.push({ label: t("contextMenu.newQuery"), action: newQuery, icon: TerminalSquare });
const sqlHistoryMenu = savedSqlHistorySubmenu();
if (sqlHistoryMenu) items.push(sqlHistoryMenu);
if (node.type === "database") {
if (!isNodeDefaultDatabase.value) {
items.push({ label: t("contextMenu.setDefaultDatabase"), action: setNodeAsDefaultDatabase, icon: Database });
@ -2758,6 +2819,8 @@ function treeItemMenuItems(): ContextMenuItem[] {
});
}
items.push({ label: t("contextMenu.newQuery"), action: newQuery, icon: TerminalSquare });
const sqlHistoryMenu = savedSqlHistorySubmenu();
if (sqlHistoryMenu) items.push(sqlHistoryMenu);
if (canOpenDiagram.value) {
items.push({ label: t("diagram.open"), action: openDiagram, icon: Network });
}

View File

@ -761,6 +761,10 @@
quickConnectionsHint: "Click a connection to open a query tab.",
shortcuts: "Shortcuts",
shortcutsHint: "Start the next action from here.",
sqlHistory: "Query History",
sqlHistoryEmpty: "No SQL Library records yet",
sqlHistoryOpenCount: "Opened {count} times",
unknownConnection: "Unknown connection",
tip: "You can also expand connections on the left and right-click databases or tables for more actions.",
tipSidebar: "Click a table on the left to view data",
tipExecute: "to execute query",
@ -1002,6 +1006,8 @@
openUserAdmin: "Open Users & Privileges",
duplicateConnection: "Duplicate Connection",
newQuery: "New Query",
sqlHistory: "Query History",
noSqlHistory: "No related SQL",
openObjectBrowser: "Browse Objects",
viewData: "View Data",
editStructure: "Edit Structure",

View File

@ -669,6 +669,10 @@
quickConnectionsHint: "Haz clic en una conexión para abrir una pestaña de consulta.",
shortcuts: "Atajos",
shortcutsHint: "Inicia la siguiente acción desde aquí.",
sqlHistory: "Historial de consultas",
sqlHistoryEmpty: "Aún no hay registros de la biblioteca SQL",
sqlHistoryOpenCount: "Abierto {count} veces",
unknownConnection: "Conexión desconocida",
tip: "También puedes expandir las conexiones a la izquierda y hacer clic derecho en bases de datos o tablas para más opciones.",
tipSidebar: "Haz clic en una tabla a la izquierda para ver los datos",
tipExecute: "para ejecutar la consulta",
@ -865,6 +869,8 @@
selectVisibleDatabases: "Seleccionar bases visibles",
duplicateConnection: "Duplicar conexión",
newQuery: "Nueva consulta",
sqlHistory: "Historial de consultas",
noSqlHistory: "Sin SQL relacionado",
openObjectBrowser: "Explorar objetos",
viewData: "Ver datos",
editStructure: "Editar estructura",

View File

@ -729,6 +729,10 @@
quickConnectionsHint: "Fai clic su una connessione per aprire una scheda query.",
shortcuts: "Scorciatoie",
shortcutsHint: "Avvia la prossima azione da qui.",
sqlHistory: "Cronologia Query",
sqlHistoryEmpty: "Nessun record nella Libreria SQL",
sqlHistoryOpenCount: "Aperto {count} volte",
unknownConnection: "Connessione sconosciuta",
tip: "Puoi anche espandere le connessioni a sinistra e fare clic con il tasto destro su database o tabelle per altre azioni.",
tipSidebar: "Fai clic su una tabella a sinistra per visualizzarne i dati",
tipExecute: "per eseguire la query",
@ -966,6 +970,8 @@
selectVisibleDatabases: "Seleziona Database Visibili",
duplicateConnection: "Duplica Connessione",
newQuery: "Nuova Query",
sqlHistory: "Cronologia Query",
noSqlHistory: "Nessun SQL correlato",
openObjectBrowser: "Esplora Oggetti",
viewData: "Visualizza Dati",
editStructure: "Modifica Struttura",

View File

@ -729,6 +729,10 @@
quickConnectionsHint: "Clique em uma conexão para abrir uma aba de consulta.",
shortcuts: "Atalhos",
shortcutsHint: "Inicie a próxima ação a partir daqui.",
sqlHistory: "Histórico de Consultas",
sqlHistoryEmpty: "Nenhum registro da Biblioteca SQL ainda",
sqlHistoryOpenCount: "Aberto {count} vezes",
unknownConnection: "Conexão desconhecida",
tip: "Você também pode expandir as conexões à esquerda e clicar com o botão direito em bancos de dados ou tabelas para mais ações.",
tipSidebar: "Clique em uma tabela à esquerda para visualizar os dados",
tipExecute: "para executar a consulta",
@ -966,6 +970,8 @@
selectVisibleDatabases: "Selecionar Bancos de Dados Visíveis",
duplicateConnection: "Duplicar Conexão",
newQuery: "Nova Consulta",
sqlHistory: "Histórico de Consultas",
noSqlHistory: "Nenhum SQL relacionado",
openObjectBrowser: "Explorar Objetos",
viewData: "Ver Dados",
editStructure: "Editar Estrutura",

View File

@ -762,6 +762,10 @@
quickConnectionsHint: "点击连接即可新建查询标签页。",
shortcuts: "常用操作",
shortcutsHint: "从这里开始你的下一步。",
sqlHistory: "历史查询",
sqlHistoryEmpty: "暂无 SQL 库记录",
sqlHistoryOpenCount: "打开 {count} 次",
unknownConnection: "未知连接",
tip: "也可以在左侧展开连接,右键数据库或表查看更多操作。",
fileOpened: "已打开 {name}",
mcpTitle: "AI 编程助手集成",
@ -1001,6 +1005,8 @@
userAdmin: "用户与权限",
openUserAdmin: "打开用户与权限",
newQuery: "新建查询",
sqlHistory: "历史查询",
noSqlHistory: "暂无关联 SQL",
openObjectBrowser: "浏览对象",
viewData: "查看数据",
editStructure: "编辑表结构",

View File

@ -708,6 +708,10 @@
quickConnectionsHint: "點選連線以開啟查詢分頁。",
shortcuts: "常用操作",
shortcutsHint: "從這裡開始你的下一步。",
sqlHistory: "歷史查詢",
sqlHistoryEmpty: "暫無 SQL 庫記錄",
sqlHistoryOpenCount: "開啟 {count} 次",
unknownConnection: "未知連線",
tip: "也可以在左側展開連線,並在資料庫或資料表上按右鍵查看更多操作。",
tipSidebar: "點選左側資料表以檢視資料",
tipExecute: "執行查詢",
@ -945,6 +949,8 @@
selectVisibleDatabases: "選擇顯示資料庫",
duplicateConnection: "複製連線",
newQuery: "建立查詢",
sqlHistory: "歷史查詢",
noSqlHistory: "暫無關聯 SQL",
openObjectBrowser: "瀏覽物件",
viewData: "檢視資料",
editStructure: "編輯資料表結構",

View File

@ -0,0 +1,81 @@
import type { SavedSqlFile } from "@/types/database";
const DAY_MS = 24 * 60 * 60 * 1000;
const RECENCY_WINDOW_MS = 30 * DAY_MS;
export interface SavedSqlHistoryScope {
connectionId?: string;
database?: string;
schema?: string;
tableName?: string;
limit?: number;
now?: number;
}
function timestamp(value?: string) {
if (!value) return 0;
const time = Date.parse(value);
return Number.isFinite(time) ? time : 0;
}
function normalize(value?: string) {
return (value || "").trim().toLowerCase();
}
function containsToken(text: string | undefined, token: string) {
const normalizedText = normalize(text);
const normalizedToken = normalize(token);
if (!normalizedText || !normalizedToken) return false;
return normalizedText.includes(normalizedToken);
}
function scopeRelevance(file: SavedSqlFile, scope: SavedSqlHistoryScope) {
let score = 0;
if (scope.database != null && file.database === scope.database) score += 100;
if (scope.schema && file.schema === scope.schema) score += 30;
if (scope.tableName) {
const schemaTable = scope.schema ? `${scope.schema}.${scope.tableName}` : "";
if (containsToken(file.name, scope.tableName) || containsToken(file.sql, scope.tableName)) score += 80;
if (schemaTable && (containsToken(file.name, schemaTable) || containsToken(file.sql, schemaTable))) score += 40;
}
return score;
}
export function savedSqlHistoryScore(file: SavedSqlFile, now = Date.now()) {
const openCount = Math.max(0, file.openCount ?? 0);
const openedAt = timestamp(file.openedAt);
const age = openedAt > 0 ? Math.max(0, now - openedAt) : RECENCY_WINDOW_MS;
const recency = openedAt > 0 ? Math.max(0, 1 - age / RECENCY_WINDOW_MS) : 0;
return openCount * 1000 + recency * 999;
}
export function savedSqlMatchesHistoryScope(file: SavedSqlFile, scope: SavedSqlHistoryScope) {
if (scope.connectionId && file.connectionId !== scope.connectionId) return false;
if (scope.database != null && file.database !== scope.database) return false;
if (scope.schema && file.schema && file.schema !== scope.schema) return false;
return true;
}
export function rankSavedSqlHistory(files: SavedSqlFile[], scope: SavedSqlHistoryScope = {}) {
const now = scope.now ?? Date.now();
const ranked = files
.filter((file) => savedSqlMatchesHistoryScope(file, scope))
.map((file) => ({
file,
score: scopeRelevance(file, scope) + savedSqlHistoryScore(file, now),
openedAt: timestamp(file.openedAt),
updatedAt: timestamp(file.updatedAt),
}))
.sort((a, b) => {
const scoreDiff = b.score - a.score;
if (scoreDiff !== 0) return scoreDiff;
const openedDiff = b.openedAt - a.openedAt;
if (openedDiff !== 0) return openedDiff;
const updatedDiff = b.updatedAt - a.updatedAt;
if (updatedDiff !== 0) return updatedDiff;
return a.file.name.localeCompare(b.file.name, undefined, { numeric: true, sensitivity: "base" });
})
.map((item) => item.file);
return typeof scope.limit === "number" ? ranked.slice(0, Math.max(0, scope.limit)) : ranked;
}

View File

@ -187,6 +187,24 @@ export const useSavedSqlStore = defineStore("savedSql", () => {
await syncToLocalDirectory();
}
async function recordFileUsage(id: string) {
const existing = getFile(id);
if (!existing) return;
try {
const saved = await api.saveSavedSqlFile({
...existing,
openCount: (existing.openCount ?? 0) + 1,
openedAt: nowIso(),
});
files.value = files.value.map((file) => (file.id === id ? saved : file));
bumpVersion();
return saved;
} catch (error) {
console.warn("[DBX][saved-sql:usage:error]", error);
return existing;
}
}
async function deleteFile(id: string) {
await api.deleteSavedSqlFile(id);
files.value = files.value.filter((file) => file.id !== id);
@ -340,6 +358,7 @@ export const useSavedSqlStore = defineStore("savedSql", () => {
deleteFolder,
saveFile,
renameFile,
recordFileUsage,
deleteFile,
reorderFolders,
reorderFiles,

View File

@ -466,6 +466,8 @@ export interface SavedSqlFile {
schema?: string;
sql: string;
orderIndex?: number;
openCount?: number;
openedAt?: string;
createdAt: string;
updatedAt: string;
}

View File

@ -24,6 +24,9 @@ pub struct SavedSqlFile {
pub sql: String,
#[serde(default)]
pub order_index: i64,
#[serde(default)]
pub open_count: i64,
pub opened_at: Option<String>,
pub created_at: String,
pub updated_at: String,
}

View File

@ -144,6 +144,8 @@ const SCHEMA_STATEMENTS: &[&str] = &[
schema_name TEXT,
sql_text TEXT NOT NULL DEFAULT '',
order_index INTEGER NOT NULL DEFAULT 0,
open_count INTEGER NOT NULL DEFAULT 0,
opened_at TEXT,
created_at TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL DEFAULT ''
)",
@ -208,7 +210,11 @@ fn ensure_history_columns_sync(conn: &Connection) -> Result<(), String> {
fn ensure_saved_sql_columns_sync(conn: &Connection) -> Result<(), String> {
const FOLDER_COLUMNS: &[(&str, &str)] = &[("order_index", "INTEGER NOT NULL DEFAULT 0")];
const FILE_COLUMNS: &[(&str, &str)] = &[("order_index", "INTEGER NOT NULL DEFAULT 0")];
const FILE_COLUMNS: &[(&str, &str)] = &[
("order_index", "INTEGER NOT NULL DEFAULT 0"),
("open_count", "INTEGER NOT NULL DEFAULT 0"),
("opened_at", "TEXT"),
];
ensure_table_columns(conn, "saved_sql_folders", FOLDER_COLUMNS)?;
ensure_table_columns(conn, "saved_sql_files", FILE_COLUMNS)?;
@ -871,8 +877,8 @@ impl Storage {
for file in &library.files {
tx.execute(
"INSERT INTO saved_sql_files \
(id, connection_id, folder_id, name, database_name, schema_name, sql_text, order_index, created_at, updated_at) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(id, connection_id, folder_id, name, database_name, schema_name, sql_text, order_index, open_count, opened_at, created_at, updated_at) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
params![
file.id,
file.connection_id,
@ -882,6 +888,8 @@ impl Storage {
file.schema,
file.sql,
file.order_index,
file.open_count,
file.opened_at,
file.created_at,
file.updated_at
],
@ -919,7 +927,7 @@ impl Storage {
let mut file_stmt = conn
.prepare(
"SELECT id, connection_id, folder_id, name, database_name, schema_name, sql_text, order_index, created_at, updated_at \
"SELECT id, connection_id, folder_id, name, database_name, schema_name, sql_text, order_index, open_count, opened_at, created_at, updated_at \
FROM saved_sql_files ORDER BY COALESCE(folder_id, ''), order_index, connection_id, name COLLATE NOCASE",
)
.map_err(|e| e.to_string())?;
@ -934,8 +942,10 @@ impl Storage {
schema: row.get(5)?,
sql: row.get(6)?,
order_index: row.get(7)?,
created_at: row.get(8)?,
updated_at: row.get(9)?,
open_count: row.get(8)?,
opened_at: row.get(9)?,
created_at: row.get(10)?,
updated_at: row.get(11)?,
})
})
.map_err(|e| e.to_string())?
@ -989,8 +999,8 @@ impl Storage {
self.with_conn(move |conn| {
conn.execute(
"INSERT INTO saved_sql_files \
(id, connection_id, folder_id, name, database_name, schema_name, sql_text, order_index, created_at, updated_at) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) \
(id, connection_id, folder_id, name, database_name, schema_name, sql_text, order_index, open_count, opened_at, created_at, updated_at) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) \
ON CONFLICT(id) DO UPDATE SET \
connection_id = excluded.connection_id, \
folder_id = excluded.folder_id, \
@ -999,6 +1009,8 @@ impl Storage {
schema_name = excluded.schema_name, \
sql_text = excluded.sql_text, \
order_index = excluded.order_index, \
open_count = excluded.open_count, \
opened_at = excluded.opened_at, \
updated_at = excluded.updated_at",
params![
file.id,
@ -1009,6 +1021,8 @@ impl Storage {
file.schema,
file.sql,
file.order_index,
file.open_count,
file.opened_at,
file.created_at,
file.updated_at
],

View File

@ -0,0 +1,56 @@
import assert from "node:assert/strict";
import { test } from "vitest";
import { rankSavedSqlHistory, savedSqlHistoryScore, savedSqlMatchesHistoryScope } from "../../apps/desktop/src/lib/savedSqlHistory.ts";
import type { SavedSqlFile } from "../../apps/desktop/src/types/database.ts";
function file(input: Partial<SavedSqlFile> & Pick<SavedSqlFile, "id" | "name" | "connectionId" | "database">): SavedSqlFile {
return {
folderId: undefined,
schema: undefined,
sql: "select 1",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
...input,
};
}
test("SQL 库历史按连接和数据库过滤", () => {
const files = [file({ id: "a", name: "a.sql", connectionId: "conn-1", database: "app" }), file({ id: "b", name: "b.sql", connectionId: "conn-1", database: "analytics" }), file({ id: "c", name: "c.sql", connectionId: "conn-2", database: "app" })];
assert.deepEqual(
rankSavedSqlHistory(files, {
connectionId: "conn-1",
database: "app",
}).map((item) => item.id),
["a"],
);
assert.equal(savedSqlMatchesHistoryScope(files[2], { connectionId: "conn-1" }), false);
});
test("SQL 库历史综合打开次数和最近打开时间排序", () => {
const now = Date.parse("2026-06-11T12:00:00.000Z");
const files = [
file({ id: "recent", name: "recent.sql", connectionId: "conn", database: "app", openCount: 2, openedAt: "2026-06-11T11:00:00.000Z" }),
file({ id: "frequent", name: "frequent.sql", connectionId: "conn", database: "app", openCount: 7, openedAt: "2026-04-01T00:00:00.000Z" }),
file({ id: "unused", name: "unused.sql", connectionId: "conn", database: "app", openCount: 0 }),
];
assert.deepEqual(
rankSavedSqlHistory(files, { now }).map((item) => item.id),
["frequent", "recent", "unused"],
);
assert.ok(savedSqlHistoryScore(files[0], now) > savedSqlHistoryScore(files[2], now));
});
test("表级入口会优先展示命中表名的 SQL", () => {
const now = Date.parse("2026-06-11T12:00:00.000Z");
const files = [
file({ id: "generic", name: "daily.sql", connectionId: "conn", database: "app", openCount: 1, openedAt: "2026-06-11T11:00:00.000Z" }),
file({ id: "orders", name: "orders-report.sql", connectionId: "conn", database: "app", sql: "select * from public.orders", openCount: 1, openedAt: "2026-06-11T10:00:00.000Z" }),
];
assert.deepEqual(
rankSavedSqlHistory(files, { connectionId: "conn", database: "app", schema: "public", tableName: "orders", now }).map((item) => item.id),
["orders", "generic"],
);
});