diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index 4ace484adb..4eb25c6a7d 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -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 "''"; + 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) { diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index fdc771ce81..eaea75b192 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index c1bca4a06b..a9fe82009e 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 5766f10f0f..ccc075af67 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 756c448af2..66f35e016a 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 5d77df0408..54b1617bb0 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -1029,6 +1029,9 @@ userAdmin: "用户与权限", openUserAdmin: "打开用户与权限", newQuery: "新建查询", + newInsert: "新建新增", + newSql: "新建 SQL", + newUpdate: "新建更新", sqlHistory: "历史查询", noSqlHistory: "暂无关联 SQL", openObjectBrowser: "浏览对象", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 63c01247ac..3a945e199a 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -951,6 +951,9 @@ selectVisibleDatabases: "選擇顯示資料庫", duplicateConnection: "複製連線", newQuery: "建立查詢", + newSql: "新增 SQL", + newInsert: "建立新增", + newUpdate: "建立更新", sqlHistory: "歷史查詢", noSqlHistory: "暫無關聯 SQL", openObjectBrowser: "瀏覽物件",