|
|
|
@@ -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) {
|
|
|
|
|