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 */;',
+ );
+});