feat: sidebar table context menu — smart SQL templates (SELECT/INSERT/UPDATE)

This commit is contained in:
t8y2
2026-06-14 23:33:37 +08:00
parent d04fc13af4
commit abc7136ed2
7 changed files with 235 additions and 3 deletions
@@ -49,6 +49,8 @@ import {
UsersRound,
Lock,
HardDriveDownload,
FilePlus,
SquarePen,
} from "@lucide/vue";
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
import { useConnectionStore } from "@/stores/connectionStore";
@@ -62,7 +64,7 @@ import * as api from "@/lib/api";
import { uuid } from "@/lib/utils";
import { resolveDefaultDatabase } from "@/lib/defaultDatabase";
import { canTreeNodeShowExpander, treeItemPaddingLeft, usesFullWidthTreeLabel } from "@/lib/sidebarTreeItemLayout";
import { buildTableSelectSql } from "@/lib/tableSelectSql";
import { buildTableSelectSql, quoteTableIdentifier, qualifiedTableName } from "@/lib/tableSelectSql";
import { connectionFilePath, defaultSqliteBackupFileName, isMemorySqlitePath, sqliteBackupSourcePath } from "@/lib/connectionFile";
import { revealPathInFileManager } from "@/lib/tauri";
import { clearActiveTableReferencePayload, createTableReferencePayload, createTableReferenceDropEvent, setActiveTableReferencePayload, type QueryEditorTableReferencePayload } from "@/lib/queryEditorTableDrop";
@@ -817,7 +819,15 @@ async function newQuery() {
await connectionStore.ensureConnected(node.connectionId);
connectionStore.activeConnectionId = node.connectionId;
if (hasTreeNodeDatabaseContext(node)) {
queryStore.createTab(node.connectionId, node.database, undefined, "query", node.schema);
const tabId = queryStore.createTab(node.connectionId, node.database, undefined, "query", node.schema);
// For table/view nodes, generate SELECT * FROM table_name SQL
if (node.type === "table" || node.type === "view") {
const config = connectionStore.getConfig(node.connectionId);
const dbType = config ? effectiveDatabaseTypeForConnection(config) : undefined;
const tableSchema = node.schema || node.database;
const tableName = qualifiedTableName({ databaseType: dbType, schema: tableSchema, tableName: node.label });
queryStore.updateSql(tabId, `SELECT * FROM ${tableName};`);
}
return;
}
const connection = connectionStore.getConfig(node.connectionId);
@@ -832,6 +842,198 @@ async function newQuery() {
}
}
// ---- SQL template helpers ----
/**
* Detect whether a column value is auto-generated by the database
* (auto-increment, identity, serial, computed/virtual, etc.).
* Such columns should be omitted from INSERT templates.
*/
function isAutoGeneratedColumn(column: ColumnInfo, _dbType?: DatabaseType): boolean {
const extra = (column.extra ?? "").toLowerCase().trim();
const colDefault = (column.column_default ?? "").toLowerCase().trim();
// MySQL / MariaDB: extra contains "auto_increment"
if (extra.includes("auto_increment")) return true;
// SQLite: extra contains "autoincrement"
if (extra.includes("autoincrement")) return true;
// SQL Server: extra contains "identity"
if (extra.includes("identity")) return true;
// PostgreSQL 10+ / GaussDB / Kingbase / etc.: GENERATED … AS IDENTITY
if (extra.includes("generated") && (extra.includes("as identity") || extra.includes("always as"))) return true;
// PostgreSQL legacy SERIAL / Oracle sequences: column_default contains nextval
if (colDefault.includes("nextval(")) return true;
if (colDefault.includes(".nextval")) return true;
// Virtual / computed columns (MySQL, MariaDB, etc.)
if ((extra.includes("virtual") || extra.includes("computed")) && extra.includes("generated")) return true;
return false;
}
function typeLooksNumeric(dataType: string): boolean {
return /^(tinyint|smallint|mediumint|int\d*|integer\d*|bigint|smallserial|serial\d*|bigserial|number|numeric|decimal|dec|float\d*|double|real|double\s+precision|money|smallmoney|fixed)/i.test(dataType.trim());
}
function typeLooksBoolean(dataType: string): boolean {
return /^(bool|boolean|bit\s*\(\s*1\s*\))$/i.test(dataType.trim());
}
function typeLooksDateOnly(dataType: string): boolean {
return /^date$/i.test(dataType.trim());
}
function typeLooksTimeOnly(dataType: string): boolean {
return /^time\b(?!stamp|stamptz|with|without)/i.test(dataType.trim());
}
function typeLooksTimestamp(dataType: string): boolean {
return /^(datetime|timestamp|timestamptz|timestamp\s+with\s+time\s+zone|timestamp\s+without\s+time\s+zone|smalldatetime|datetime2|datetimeoffset)/i.test(dataType.trim());
}
function typeLooksJson(dataType: string): boolean {
return /^(json|jsonb)$/i.test(dataType.trim());
}
function typeLooksBinary(dataType: string): boolean {
return /^(bytea|blob|binary|varbinary|image|raw|longblob|mediumblob|tinyblob|long\s+raw|bfile)$/i.test(dataType.trim());
}
function typeLooksUuid(dataType: string): boolean {
return /^uuid$/i.test(dataType.trim());
}
function typeLooksXml(dataType: string): boolean {
return /^xml$/i.test(dataType.trim());
}
function typeLooksSpatial(dataType: string): boolean {
return /^(geometry|geography|point|polygon|linestring|box|circle|path|line|lseg)/i.test(dataType.trim());
}
function typeLooksArray(dataType: string): boolean {
return /^(array|vector|_float\d*|_int\d*|_text|_varchar|_bool|_uuid)/i.test(dataType.trim());
}
/**
* Return a type-appropriate placeholder value for a template column.
* Inspects column.data_type and database type to produce the right
* literal so the user can execute the template immediately after
* replacing placeholders.
*/
function columnPlaceholderValue(column: ColumnInfo, dbType?: DatabaseType): string {
const dataType = (column.data_type ?? "").trim();
const colName = column.name;
if (typeLooksNumeric(dataType)) return "0";
if (typeLooksBoolean(dataType)) return dbType === "mysql" || dbType === "sqlite" || dbType === "rqlite" ? "1" : "TRUE";
if (typeLooksDateOnly(dataType)) return "'2024-01-01'";
if (typeLooksTimeOnly(dataType)) return "'12:00:00'";
if (typeLooksTimestamp(dataType)) return "CURRENT_TIMESTAMP";
if (typeLooksJson(dataType)) return dbType === "postgres" || dbType === "redshift" ? "'{}'::jsonb" : "'{}'";
if (typeLooksBinary(dataType)) return "''";
if (typeLooksUuid(dataType)) return dbType === "postgres" || dbType === "redshift" ? "'00000000-0000-0000-0000-000000000000'::uuid" : "'00000000-0000-0000-0000-000000000000'";
if (typeLooksXml(dataType)) return "'<root/>'";
if (typeLooksSpatial(dataType)) return "NULL";
if (typeLooksArray(dataType)) return "'{}'";
// Fallback: quoted column-name placeholder so user knows what to fill
return `'${colName}_value'`;
}
// ---- Template actions ----
async function newInsertTemplate() {
const node = props.node;
if (!node.connectionId || !hasTreeNodeDatabaseContext(node)) return;
if (node.type !== "table" && node.type !== "view") return;
try {
await connectionStore.ensureConnected(node.connectionId);
connectionStore.activeConnectionId = node.connectionId;
const config = connectionStore.getConfig(node.connectionId);
const dbType = config ? effectiveDatabaseTypeForConnection(config) : undefined;
const tableSchema = node.schema || node.database;
let columns: ColumnInfo[] = [];
try {
const querySchema = connectionObjectTreeQuerySchema(config, node.database, tableSchema);
columns = await api.getColumns(node.connectionId, node.database, querySchema, node.label);
} catch (e) {
console.warn("[DBX][newInsertTemplate:getColumns:error]", e);
}
const tableName = qualifiedTableName({ databaseType: dbType, schema: tableSchema, tableName: node.label });
// Omit auto-generated columns (auto_increment, identity, serial, computed, etc.)
const insertColumns = columns.filter((c) => !isAutoGeneratedColumn(c, dbType));
let sql: string;
if (insertColumns.length === 0) {
sql = `INSERT INTO ${tableName}\n/* TODO: add column values */\nVALUES ();`;
} else {
const colNames = insertColumns.map((c) => quoteTableIdentifier(dbType, c.name));
const placeholders = insertColumns.map((c) => columnPlaceholderValue(c, dbType));
sql = `INSERT INTO ${tableName} (${colNames.join(", ")})\nVALUES (${placeholders.join(", ")});`;
}
const tabId = queryStore.createTab(node.connectionId, node.database, undefined, "query", node.schema);
queryStore.updateSql(tabId, sql);
} catch (e: any) {
toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000);
}
}
async function newUpdateTemplate() {
const node = props.node;
if (!node.connectionId || !hasTreeNodeDatabaseContext(node)) return;
if (node.type !== "table" && node.type !== "view") return;
try {
await connectionStore.ensureConnected(node.connectionId);
connectionStore.activeConnectionId = node.connectionId;
const config = connectionStore.getConfig(node.connectionId);
const dbType = config ? effectiveDatabaseTypeForConnection(config) : undefined;
const tableSchema = node.schema || node.database;
let columns: ColumnInfo[] = [];
let primaryKeys: string[] = [];
try {
const querySchema = connectionObjectTreeQuerySchema(config, node.database, tableSchema);
columns = await api.getColumns(node.connectionId, node.database, querySchema, node.label);
primaryKeys = columns.filter((c) => c.is_primary_key).map((c) => c.name);
} catch (e) {
console.warn("[DBX][newUpdateTemplate:getColumns:error]", e);
}
const tableName = qualifiedTableName({ databaseType: dbType, schema: tableSchema, tableName: node.label });
// SET clause: non-PK columns only (updating PKs is unusual and dangerous)
const pkNameSet = new Set(primaryKeys);
const updateColumns = columns.filter((c) => !pkNameSet.has(c.name));
const setClauses = updateColumns.map((c) => `${quoteTableIdentifier(dbType, c.name)} = ${columnPlaceholderValue(c, dbType)}`);
// WHERE clause: use primary keys to target a single row
let whereClause: string;
if (primaryKeys.length > 0) {
const pkColumns = columns.filter((c) => pkNameSet.has(c.name));
const pkConditions = pkColumns.map((c) => `${quoteTableIdentifier(dbType, c.name)} = ${columnPlaceholderValue(c, dbType)}`);
whereClause = `WHERE ${pkConditions.join(" AND ")}`;
} else {
whereClause = `WHERE /* TODO: add WHERE clause */`;
}
const sql = `UPDATE ${tableName}\nSET ${setClauses.join(",\n ")}\n${whereClause};`;
const tabId = queryStore.createTab(node.connectionId, node.database, undefined, "query", node.schema);
queryStore.updateSql(tabId, sql);
} catch (e: any) {
toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000);
}
}
async function setNodeAsDefaultDatabase() {
const node = props.node;
if (!node.connectionId || !node.database) return;
@@ -2988,7 +3190,19 @@ function treeItemMenuItems(): ContextMenuItem[] {
variant: "destructive" as const,
});
}
items.push({ label: t("contextMenu.newQuery"), action: newQuery, icon: TerminalSquare });
if (isTableNotView.value) {
items.push({
label: t("contextMenu.newSql"),
icon: FilePlus,
children: [
{ label: t("contextMenu.newQuery"), action: newQuery, icon: TerminalSquare },
{ label: t("contextMenu.newInsert"), action: newInsertTemplate, icon: FilePlus },
{ label: t("contextMenu.newUpdate"), action: newUpdateTemplate, icon: SquarePen },
],
});
} else {
items.push({ label: t("contextMenu.newQuery"), action: newQuery, icon: TerminalSquare });
}
const sqlHistoryMenu = savedSqlHistorySubmenu();
if (sqlHistoryMenu) items.push(sqlHistoryMenu);
if (canOpenDiagram.value) {
+3
View File
@@ -1030,6 +1030,9 @@
openUserAdmin: "Open Users & Privileges",
duplicateConnection: "Duplicate Connection",
newQuery: "New Query",
newSql: "New SQL",
newInsert: "New Insert",
newUpdate: "New Update",
sqlHistory: "Query History",
noSqlHistory: "No related SQL",
openObjectBrowser: "Browse Objects",
+3
View File
@@ -871,6 +871,9 @@
selectVisibleDatabases: "Seleccionar bases visibles",
duplicateConnection: "Duplicar conexión",
newQuery: "Nueva consulta",
newSql: "Nuevo SQL",
newInsert: "Nueva Inserción",
newUpdate: "Nueva Actualización",
sqlHistory: "Historial de consultas",
noSqlHistory: "Sin SQL relacionado",
openObjectBrowser: "Explorar objetos",
+3
View File
@@ -972,6 +972,9 @@
selectVisibleDatabases: "Seleziona Database Visibili",
duplicateConnection: "Duplica Connessione",
newQuery: "Nuova Query",
newSql: "Nuovo SQL",
newInsert: "Nuovo Insert",
newUpdate: "Nuovo Update",
sqlHistory: "Cronologia Query",
noSqlHistory: "Nessun SQL correlato",
openObjectBrowser: "Esplora Oggetti",
+3
View File
@@ -972,6 +972,9 @@
selectVisibleDatabases: "Selecionar Bancos de Dados Visíveis",
duplicateConnection: "Duplicar Conexão",
newQuery: "Nova Consulta",
newSql: "Novo SQL",
newInsert: "Novo Insert",
newUpdate: "Novo Update",
sqlHistory: "Histórico de Consultas",
noSqlHistory: "Nenhum SQL relacionado",
openObjectBrowser: "Explorar Objetos",
+3
View File
@@ -1029,6 +1029,9 @@
userAdmin: "用户与权限",
openUserAdmin: "打开用户与权限",
newQuery: "新建查询",
newInsert: "新建新增",
newSql: "新建 SQL",
newUpdate: "新建更新",
sqlHistory: "历史查询",
noSqlHistory: "暂无关联 SQL",
openObjectBrowser: "浏览对象",
+3
View File
@@ -951,6 +951,9 @@
selectVisibleDatabases: "選擇顯示資料庫",
duplicateConnection: "複製連線",
newQuery: "建立查詢",
newSql: "新增 SQL",
newInsert: "建立新增",
newUpdate: "建立更新",
sqlHistory: "歷史查詢",
noSqlHistory: "暫無關聯 SQL",
openObjectBrowser: "瀏覽物件",