diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index dc7433e8a..427378e22 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -52,6 +52,7 @@ import { HardDriveDownload, FilePlus, SquarePen, + ListX, } from "@lucide/vue"; import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue"; import { useConnectionStore } from "@/stores/connectionStore"; @@ -65,7 +66,8 @@ 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, quoteTableIdentifier, qualifiedTableName } from "@/lib/tableSelectSql"; +import { buildTableSelectSql } from "@/lib/tableSelectSql"; +import { buildTableDeleteTemplate, buildTableInsertTemplate, buildTableSelectTemplate, buildTableUpdateTemplate } from "@/lib/tableSqlTemplates"; import { connectionFilePath, defaultSqliteBackupFileName, isMemorySqlitePath, sqliteBackupSourcePath } from "@/lib/connectionFile"; import { revealPathInFileManager } from "@/lib/tauri"; import { clearActiveTableReferencePayload, createTableReferencePayload, createTableReferenceDropEvent, setActiveTableReferencePayload, type QueryEditorTableReferencePayload } from "@/lib/queryEditorTableDrop"; @@ -946,15 +948,11 @@ async function newQuery() { await connectionStore.ensureConnected(node.connectionId); connectionStore.activeConnectionId = node.connectionId; if (hasTreeNodeDatabaseContext(node)) { - 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" || node.type === "materialized_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};`); + await newSelectTemplate(); + return; } + queryStore.createTab(node.connectionId, node.database, undefined, "query", node.schema); return; } const connection = connectionStore.getConfig(node.connectionId); @@ -969,206 +967,124 @@ 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(); - const dataType = (column.data_type ?? "").trim(); - - if (typeLooksPostgresTextSearchVector(dataType)) return true; - - // 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()); -} - -function typeLooksPostgresTextSearchVector(dataType: string): boolean { - const normalized = dataType - .trim() - .replace(/^"+|"+$/g, "") - .toLowerCase(); - return normalized === "tsvector" || normalized.endsWith(".tsvector"); -} - -/** - * 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() { +async function loadTemplateContext(allowView = false) { const node = props.node; - if (!node.connectionId || !hasTreeNodeDatabaseContext(node)) return; - if (node.type !== "table" && node.type !== "view") return; + if (!node.connectionId || !hasTreeNodeDatabaseContext(node)) return null; + const isTableNode = node.type === "table"; + const isReadableObject = isTableNode || (allowView && (node.type === "view" || node.type === "materialized_view")); + if (!isReadableObject) return null; + + 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 { - 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; + const querySchema = connectionObjectTreeQuerySchema(config, node.database, tableSchema); + columns = await api.getColumns(node.connectionId, node.database, querySchema, node.label); + } catch (e) { + console.warn("[DBX][tableSqlTemplate:getColumns:error]", e); + } - 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); - } + return { node, dbType, tableSchema, columns }; +} - const tableName = qualifiedTableName({ databaseType: dbType, schema: tableSchema, tableName: node.label }); +function openSqlTemplateTab(connectionId: string, database: string, schema: string | undefined, sql: string, title?: string) { + const tabId = queryStore.createTab(connectionId, database, title, "query", schema); + queryStore.updateSql(tabId, sql); +} - // Omit auto-generated columns (auto_increment, identity, serial, computed, etc.) - const insertColumns = columns.filter((c) => !isAutoGeneratedColumn(c, dbType)); +async function newSelectTemplate() { + try { + const context = await loadTemplateContext(true); + if (!context) return; + const sql = buildTableSelectTemplate({ + databaseType: context.dbType, + schema: context.tableSchema, + tableName: context.node.label, + columns: context.columns, + }); + openSqlTemplateTab(context.node.connectionId!, context.node.database!, context.node.schema, sql); + } catch (e: any) { + toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000); + } +} - 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); +async function newInsertTemplate() { + try { + const context = await loadTemplateContext(false); + if (!context) return; + const sql = buildTableInsertTemplate({ + databaseType: context.dbType, + schema: context.tableSchema, + tableName: context.node.label, + columns: context.columns, + }); + openSqlTemplateTab(context.node.connectionId!, context.node.database!, context.node.schema, sql); } catch (e: any) { toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000); } } async function newUpdateTemplate() { + try { + const context = await loadTemplateContext(false); + if (!context) return; + const sql = buildTableUpdateTemplate({ + databaseType: context.dbType, + schema: context.tableSchema, + tableName: context.node.label, + columns: context.columns, + }); + openSqlTemplateTab(context.node.connectionId!, context.node.database!, context.node.schema, sql); + } catch (e: any) { + toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000); + } +} + +async function newDeleteTemplate() { + try { + const context = await loadTemplateContext(false); + if (!context) return; + const sql = buildTableDeleteTemplate({ + databaseType: context.dbType, + schema: context.tableSchema, + tableName: context.node.label, + columns: context.columns, + }); + openSqlTemplateTab(context.node.connectionId!, context.node.database!, context.node.schema, sql); + } catch (e: any) { + toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000); + } +} + +async function generateDdlTemplate() { const node = props.node; if (!node.connectionId || !hasTreeNodeDatabaseContext(node)) return; - if (node.type !== "table" && node.type !== "view") return; + if (node.type !== "table" && node.type !== "view" && node.type !== "materialized_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 ")}`; + const schema = node.schema || node.database; + let ddl: string; + if (node.type === "table") { + ddl = await api.getTableDdl(node.connectionId, node.database, schema, node.label); } else { - whereClause = `WHERE /* TODO: add WHERE clause */`; + const objectType = node.type === "materialized_view" ? "MATERIALIZED_VIEW" : "VIEW"; + const result = await api.getObjectSource(node.connectionId, node.database, schema, node.label, objectType); + ddl = await buildViewDdl({ + databaseType: currentDatabaseType(), + schema, + name: node.label, + source: result.source, + }); } - - 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); + openSqlTemplateTab(node.connectionId, node.database, node.schema, ddl, `DDL - ${node.label}`); } catch (e: any) { - toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000); + toast(e?.message || String(e), 5000); } } @@ -3384,19 +3300,22 @@ function treeItemMenuItems(): ContextMenuItem[] { variant: "destructive" as const, }); } - 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 }); - } + items.push({ + label: t("contextMenu.generateSql"), + icon: FilePlus, + children: isTableNotView.value + ? [ + { label: "SELECT", action: newSelectTemplate, icon: TerminalSquare }, + { label: "INSERT", action: newInsertTemplate, icon: FilePlus }, + { label: "UPDATE", action: newUpdateTemplate, icon: SquarePen }, + { label: "DELETE", action: newDeleteTemplate, icon: ListX }, + { label: "DDL", action: generateDdlTemplate, icon: FileCode }, + ] + : [ + { label: "SELECT", action: newSelectTemplate, icon: TerminalSquare }, + { label: "DDL", action: generateDdlTemplate, icon: FileCode }, + ], + }); 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 16948a187..3d5f799bf 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -1066,6 +1066,7 @@ export default { duplicateConnection: "Duplicate Connection", newQuery: "New Query", newSql: "New SQL", + generateSql: "Generate SQL", newInsert: "New Insert", newUpdate: "New Update", sqlHistory: "Query History", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 853b6dab8..034994266 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -894,6 +894,7 @@ export default { duplicateConnection: "Duplicar conexión", newQuery: "Nueva consulta", newSql: "Nuevo SQL", + generateSql: "Generar SQL", newInsert: "Nueva Inserción", newUpdate: "Nueva Actualización", sqlHistory: "Historial de consultas", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 3eddf3531..0f33d12b2 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -1001,6 +1001,7 @@ export default { duplicateConnection: "Duplica Connessione", newQuery: "Nuova Query", newSql: "Nuovo SQL", + generateSql: "Genera SQL", newInsert: "Nuovo Insert", newUpdate: "Nuovo Update", sqlHistory: "Cronologia Query", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 058ff76e7..889679bc5 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -1055,6 +1055,7 @@ export default { duplicateConnection: "接続を複製", newQuery: "新しいクエリ", newSql: "新しいSQL", + generateSql: "SQLを生成", newInsert: "新しいINSERT", newUpdate: "新しいUPDATE", sqlHistory: "クエリ履歴", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 21f867329..c3fb4b432 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -1001,6 +1001,7 @@ export default { duplicateConnection: "Duplicar Conexão", newQuery: "Nova Consulta", newSql: "Novo SQL", + generateSql: "Gerar SQL", newInsert: "Novo Insert", newUpdate: "Novo Update", sqlHistory: "Histórico de Consultas", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index b768a380c..86d10f071 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -1066,6 +1066,7 @@ export default { newQuery: "新建查询", newInsert: "新建新增", newSql: "新建 SQL", + generateSql: "生成 SQL", newUpdate: "新建更新", sqlHistory: "历史查询", noSqlHistory: "暂无关联 SQL", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 5c76c6e08..3271d7bba 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -981,6 +981,7 @@ export default { duplicateConnection: "複製連線", newQuery: "建立查詢", newSql: "新增 SQL", + generateSql: "產生 SQL", newInsert: "建立新增", newUpdate: "建立更新", sqlHistory: "歷史查詢", diff --git a/apps/desktop/src/lib/tableSqlTemplates.ts b/apps/desktop/src/lib/tableSqlTemplates.ts new file mode 100644 index 000000000..a5d79054a --- /dev/null +++ b/apps/desktop/src/lib/tableSqlTemplates.ts @@ -0,0 +1,151 @@ +import type { ColumnInfo, DatabaseType } from "@/types/database"; +import { qualifiedTableName, quoteTableIdentifier } from "@/lib/tableSelectSql"; + +export interface TableSqlTemplateOptions { + databaseType?: DatabaseType; + schema?: string; + tableName: string; + columns?: ColumnInfo[]; +} + +export function buildTableSelectTemplate(options: TableSqlTemplateOptions): string { + const tableName = templateTableName(options); + const columns = options.columns ?? []; + if (!columns.length) { + return `SELECT *\nFROM ${tableName};`; + } + const selectColumns = columns.map((column) => quoteTableIdentifier(options.databaseType, column.name)); + return `SELECT ${selectColumns.join(", ")}\nFROM ${tableName};`; +} + +export function buildTableInsertTemplate(options: TableSqlTemplateOptions): string { + const tableName = templateTableName(options); + const columns = (options.columns ?? []).filter((column) => !isAutoGeneratedColumn(column)); + + if (!columns.length) { + return `INSERT INTO ${tableName}\n/* TODO: add column values */\nVALUES ();`; + } + + const columnNames = columns.map((column) => quoteTableIdentifier(options.databaseType, column.name)); + const values = columns.map((column) => columnPlaceholderValue(column, options.databaseType)); + return `INSERT INTO ${tableName} (${columnNames.join(", ")})\nVALUES (${values.join(", ")});`; +} + +export function buildTableUpdateTemplate(options: TableSqlTemplateOptions): string { + const tableName = templateTableName(options); + const columns = options.columns ?? []; + const primaryKeys = columns.filter((column) => column.is_primary_key); + const primaryKeyNames = new Set(primaryKeys.map((column) => column.name)); + const updateColumns = columns.filter((column) => !primaryKeyNames.has(column.name)); + const setClause = updateColumns.length ? updateColumns.map((column) => `${quoteTableIdentifier(options.databaseType, column.name)} = ${columnPlaceholderValue(column, options.databaseType)}`).join(",\n ") : "/* TODO: set column = value */"; + + return `UPDATE ${tableName}\nSET ${setClause}\n${buildWhereClause(options.databaseType, primaryKeys)};`; +} + +export function buildTableDeleteTemplate(options: TableSqlTemplateOptions): string { + const tableName = templateTableName(options); + const primaryKeys = (options.columns ?? []).filter((column) => column.is_primary_key); + return `DELETE FROM ${tableName}\n${buildWhereClause(options.databaseType, primaryKeys)};`; +} + +function templateTableName(options: TableSqlTemplateOptions): string { + return qualifiedTableName({ + databaseType: options.databaseType, + schema: options.schema, + tableName: options.tableName, + }); +} + +function buildWhereClause(databaseType: DatabaseType | undefined, primaryKeys: ColumnInfo[]): string { + if (!primaryKeys.length) return "WHERE /* TODO: add WHERE clause */"; + const conditions = primaryKeys.map((column) => `${quoteTableIdentifier(databaseType, column.name)} = ${columnPlaceholderValue(column, databaseType)}`); + return `WHERE ${conditions.join(" AND ")}`; +} + +function isAutoGeneratedColumn(column: ColumnInfo): boolean { + const extra = (column.extra ?? "").toLowerCase().trim(); + const colDefault = (column.column_default ?? "").toLowerCase().trim(); + const dataType = (column.data_type ?? "").trim(); + + if (typeLooksPostgresTextSearchVector(dataType)) return true; + if (extra.includes("auto_increment")) return true; + if (extra.includes("autoincrement")) return true; + if (extra.includes("identity")) return true; + if (extra.includes("generated") && (extra.includes("as identity") || extra.includes("always as"))) return true; + if (colDefault.includes("nextval(")) return true; + if (colDefault.includes(".nextval")) return true; + if ((extra.includes("virtual") || extra.includes("computed")) && extra.includes("generated")) return true; + + return false; +} + +function columnPlaceholderValue(column: ColumnInfo, databaseType?: DatabaseType): string { + const dataType = (column.data_type ?? "").trim(); + const colName = column.name; + + if (typeLooksNumeric(dataType)) return "0"; + if (typeLooksBoolean(dataType)) return databaseType === "mysql" || databaseType === "sqlite" || databaseType === "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 databaseType === "postgres" || databaseType === "redshift" ? "'{}'::jsonb" : "'{}'"; + if (typeLooksBinary(dataType)) return "''"; + if (typeLooksUuid(dataType)) return databaseType === "postgres" || databaseType === "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 "'{}'"; + + return `'${colName}_value'`; +} + +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()); +} + +function typeLooksPostgresTextSearchVector(dataType: string): boolean { + const normalized = dataType + .trim() + .replace(/^"+|"+$/g, "") + .toLowerCase(); + return normalized === "tsvector" || normalized.endsWith(".tsvector"); +} diff --git a/packages/app-tests/tableSqlTemplates.test.ts b/packages/app-tests/tableSqlTemplates.test.ts new file mode 100644 index 000000000..1073878a2 --- /dev/null +++ b/packages/app-tests/tableSqlTemplates.test.ts @@ -0,0 +1,78 @@ +import { strict as assert } from "node:assert"; +import { test } from "vitest"; +import type { ColumnInfo } from "../../apps/desktop/src/types/database.ts"; +import { buildTableDeleteTemplate, buildTableInsertTemplate, buildTableSelectTemplate, buildTableUpdateTemplate } from "../../apps/desktop/src/lib/tableSqlTemplates.ts"; + +function col(overrides: Partial & { name: string; data_type: string }): ColumnInfo { + return { + is_nullable: true, + column_default: null, + is_primary_key: false, + extra: null, + ...overrides, + }; +} + +const columns: ColumnInfo[] = [ + col({ name: "id", data_type: "integer", is_primary_key: true, extra: "auto_increment" }), + col({ name: "name", data_type: "varchar" }), + col({ name: "created_at", data_type: "timestamp" }), +]; + +test("builds SELECT template with explicit table columns", () => { + assert.equal( + buildTableSelectTemplate({ + databaseType: "postgres", + schema: "public", + tableName: "users", + columns, + }), + 'SELECT "id", "name", "created_at"\nFROM "public"."users";', + ); +}); + +test("builds INSERT template without auto generated columns", () => { + assert.equal( + buildTableInsertTemplate({ + databaseType: "mysql", + tableName: "users", + columns, + }), + "INSERT INTO `users` (`name`, `created_at`)\nVALUES ('name_value', CURRENT_TIMESTAMP);", + ); +}); + +test("builds UPDATE template with primary key WHERE clause", () => { + assert.equal( + buildTableUpdateTemplate({ + databaseType: "postgres", + schema: "public", + tableName: "users", + columns, + }), + 'UPDATE "public"."users"\nSET "name" = \'name_value\',\n "created_at" = CURRENT_TIMESTAMP\nWHERE "id" = 0;', + ); +}); + +test("builds DELETE template with primary key WHERE clause", () => { + assert.equal( + buildTableDeleteTemplate({ + databaseType: "postgres", + schema: "public", + tableName: "users", + columns, + }), + 'DELETE FROM "public"."users"\nWHERE "id" = 0;', + ); +}); + +test("builds DELETE template with TODO WHERE clause when no primary key exists", () => { + assert.equal( + buildTableDeleteTemplate({ + databaseType: "sqlite", + tableName: "audit", + columns: [col({ name: "message", data_type: "text" })], + }), + 'DELETE FROM "audit"\nWHERE /* TODO: add WHERE clause */;', + ); +});