feat(sql): add multi-row INSERT template generation for tables

This commit is contained in:
xiaohuowa
2026-10-11 08:23:19 +08:00
committed by GitHub
parent a21880ea13
commit b799cf38df
13 changed files with 443 additions and 70 deletions
@@ -53,6 +53,7 @@ import {
Code2,
Wrench,
ListFilter,
ListPlus,
Clipboard,
UsersRound,
ShieldCheck,
@@ -115,7 +116,8 @@ import { buildXuguSchedulerJobSql, type XuguSchedulerJobAction } from "@/lib/dat
import { canViewDatabaseObjectDependencies, databaseDependencyProviderFor } from "@/lib/database/databaseObjectDependencies";
import { elasticsearchClearIndexPreview, isElasticsearchClearConfirmed, isElasticsearchIndexPattern } from "@/lib/sidebar/elasticsearchIndexActions";
import { mysqlObjectTemplateForGroup } from "@/lib/sidebar/mysqlObjectTemplates";
import { buildTableDeleteTemplate, buildTableInsertTemplate, buildTableSelectTemplate, buildTableUpdateTemplate } from "@/lib/table/tableSqlTemplates";
import { buildTableDeleteTemplate, buildTableInsertTemplate, buildTableSelectTemplate, buildTableUpdateTemplate, insertTemplatePrimaryKeyConflicts } from "@/lib/table/tableSqlTemplates";
import SqlTemplateInsertRowsDialog from "@/components/sidebar/SqlTemplateInsertRowsDialog.vue";
import { joinExportedDdls } from "@/lib/export/ddlExport";
import { qualifiedTableName } from "@/lib/table/tableSelectSql";
import { driverStoreFocusForInstallError } from "@/lib/connection/agentDriverInstallHint";
@@ -2174,17 +2176,15 @@ async function loadTemplateContext(allowView = false, node: TreeNode = activeNod
return { node, dbType, driverProfile: config?.driver_profile, identifierQuote, tableSchema, columns, tableType };
}
async function openMultiTableSqlTemplate(targets: Array<TreeNode & { connectionId: string; database: string }>, buildSql: (context: NonNullable<Awaited<ReturnType<typeof loadTemplateContext>>>) => string, allowView: boolean, titlePrefix: string) {
const parts: string[] = [];
const tabTarget = targets.find((target) => target.id === activeNode.value.id) ?? targets[0]!;
for (const target of targets) {
const context = await loadTemplateContext(allowView, target);
if (!context) continue;
parts.push(buildSql(context));
}
type TableTemplateContext = NonNullable<Awaited<ReturnType<typeof loadTemplateContext>>>;
async function openMultiTableSqlTemplate(targets: Array<TreeNode & { connectionId: string; database: string }>, buildSql: (context: TableTemplateContext) => string, allowView: boolean, titlePrefix: string) {
const contexts = (await Promise.all(targets.map((target) => loadTemplateContext(allowView, target)))).filter((context): context is TableTemplateContext => context !== null);
const parts = contexts.map((context) => buildSql(context));
if (!parts.length) return;
const sql = parts.length === 1 ? parts[0]! : joinExportedDdls(parts);
const title = targets.length === 1 ? undefined : `${titlePrefix} - ${targets.map((target) => target.label).join(", ")}`;
const tabTarget = targets.find((target) => target.id === activeNode.value.id) ?? targets[0]!;
openSqlTemplateTab(tabTarget.connectionId, tabTarget.database, tabTarget.schema, tabTarget.catalog, sql, title);
}
@@ -2238,46 +2238,72 @@ async function newSelectTemplate() {
}
async function newInsertTemplate() {
await runInsertTemplate(1);
}
const insertTemplateRowsOpen = ref(false);
let insertTemplateRowsPrefetch: Promise<TableTemplateContext[] | null> | null = null;
async function loadInsertTemplateContexts(): Promise<TableTemplateContext[]> {
const targets = selectedDdlTargets().filter((target) => target.type === "table");
if (targets.length > 1) {
const contexts = await Promise.all(targets.map((target) => loadTemplateContext(false, target)));
return contexts.filter((context): context is TableTemplateContext => context !== null);
}
const context = await loadTemplateContext(false);
return context ? [context] : [];
}
function openInsertTemplateRowsDialog() {
insertTemplateRowsOpen.value = true;
// Run the health check + getColumns roundtrips while the user picks a row
// count, so confirming the dialog does not wait on them again.
insertTemplateRowsPrefetch = loadInsertTemplateContexts().catch((e) => {
console.warn("[DBX][tableSqlTemplate:prefetch:error]", e);
return null;
});
}
async function confirmInsertTemplateRows(count: number) {
const prefetched = insertTemplateRowsPrefetch;
insertTemplateRowsPrefetch = null;
await runInsertTemplate(count, prefetched ? await prefetched : null);
}
function insertTemplateOptions(context: TableTemplateContext, rowCount: number) {
return {
databaseType: context.dbType,
driverProfile: context.driverProfile,
identifierQuote: context.identifierQuote,
catalog: context.node.catalog,
database: context.node.database,
schema: context.tableSchema,
includeDatabaseName: settingsStore.editorSettings.generateSqlIncludeDatabaseName,
quoteIdentifiers: settingsStore.editorSettings.generateSqlQuoteIdentifiers,
tableName: context.node.label,
columns: context.columns,
tableType: context.tableType,
rowCount,
};
}
function warnInsertTemplatePkConflicts(contexts: TableTemplateContext[], rowCount: number) {
const conflicts = contexts.flatMap((context) => insertTemplatePrimaryKeyConflicts(context.columns, rowCount));
if (conflicts.length) {
toast(t("contextMenu.insertRowsPkConflictWarning", { columns: conflicts.map((conflict) => conflict.column).join(", "), rows: rowCount }), 6000);
}
}
async function runInsertTemplate(rowCount: number, prefetchedContexts?: TableTemplateContext[] | null) {
try {
const targets = selectedDdlTargets().filter((target) => target.type === "table");
if (targets.length > 1) {
await openMultiTableSqlTemplate(
targets,
(context) =>
buildTableInsertTemplate({
databaseType: context.dbType,
driverProfile: context.driverProfile,
identifierQuote: context.identifierQuote,
catalog: context.node.catalog,
database: context.node.database,
schema: context.tableSchema,
includeDatabaseName: settingsStore.editorSettings.generateSqlIncludeDatabaseName,
quoteIdentifiers: settingsStore.editorSettings.generateSqlQuoteIdentifiers,
tableName: context.node.label,
columns: context.columns,
tableType: context.tableType,
}),
false,
"INSERT",
);
return;
}
const context = await loadTemplateContext(false);
if (!context) return;
const sql = buildTableInsertTemplate({
databaseType: context.dbType,
driverProfile: context.driverProfile,
identifierQuote: context.identifierQuote,
catalog: context.node.catalog,
database: context.node.database,
schema: context.tableSchema,
includeDatabaseName: settingsStore.editorSettings.generateSqlIncludeDatabaseName,
quoteIdentifiers: settingsStore.editorSettings.generateSqlQuoteIdentifiers,
tableName: context.node.label,
columns: context.columns,
tableType: context.tableType,
});
openSqlTemplateTab(context.node.connectionId!, context.node.database!, context.node.schema, context.node.catalog, sql);
const contexts = prefetchedContexts ?? (await loadInsertTemplateContexts());
if (!contexts.length) return;
warnInsertTemplatePkConflicts(contexts, rowCount);
const parts = contexts.map((context) => buildTableInsertTemplate(insertTemplateOptions(context, rowCount)));
const sql = parts.length === 1 ? parts[0]! : joinExportedDdls(parts);
const tabTarget = contexts.find((context) => context.node.id === activeNode.value.id)?.node ?? contexts[0]!.node;
const title = contexts.length === 1 ? undefined : `INSERT - ${contexts.map((context) => context.node.label).join(", ")}`;
openSqlTemplateTab(tabTarget.connectionId!, tabTarget.database!, tabTarget.schema, tabTarget.catalog, sql, title);
} catch (e: any) {
toast(t("connection.connectFailed", { message: translateBackendError(t, e) }), 5000);
}
@@ -6638,6 +6664,7 @@ function buildObjectSidebarMenu(context: SidebarMenuFactoryContext): boolean {
? [
{ label: "SELECT", action: newSelectTemplate, icon: TerminalSquare },
{ label: "INSERT", action: newInsertTemplate, icon: FilePlus },
{ label: "INSERT (N)…", action: openInsertTemplateRowsDialog, icon: ListPlus },
{ label: "UPDATE", action: newUpdateTemplate, icon: SquarePen },
{ label: "DELETE", action: newDeleteTemplate, icon: ListX },
{ label: "DDL", action: generateDdlTemplate, icon: FileCode },
@@ -7406,6 +7433,7 @@ defineExpose({
<template>
<ModelGenerateDialog v-if="modelGenerationTarget" :key="modelGenerationTarget.id" :target="modelGenerationTarget" @close="modelGenerationTarget = null" />
<SqlTemplateInsertRowsDialog v-model:open="insertTemplateRowsOpen" @confirm="confirmInsertTemplateRows" />
<Dialog
:open="!!pluginDialog"
@update:open="
@@ -0,0 +1,72 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { INSERT_TEMPLATE_MAX_ROW_COUNT } from "@/lib/table/tableSqlTemplates";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
const MAX_ROWS = INSERT_TEMPLATE_MAX_ROW_COUNT;
const { t } = useI18n();
const open = defineModel<boolean>("open", { default: false });
const emit = defineEmits<{ confirm: [count: number] }>();
const rowCount = ref<string | number>("1");
watch(
open,
(isOpen) => {
if (isOpen) rowCount.value = "1";
},
{ immediate: true },
);
function parseIntegerOrNull(raw: string | number): number | null {
const trimmed = String(raw).trim();
if (!/^\d+$/.test(trimmed)) return null;
const value = Number(trimmed);
return Number.isInteger(value) && value >= 1 ? value : null;
}
const parsedCount = computed<number | null>(() => {
const parsed = parseIntegerOrNull(rowCount.value);
if (parsed === null) return null;
return Math.min(parsed, MAX_ROWS);
});
const inputInvalid = computed(() => {
const raw = String(rowCount.value).trim();
if (raw === "") return false;
return parseIntegerOrNull(raw) === null;
});
function confirmRows() {
const count = parsedCount.value;
if (count === null) return;
emit("confirm", count);
open.value = false;
}
</script>
<template>
<Dialog v-model:open="open">
<DialogContent class="sm:max-w-[420px]">
<DialogHeader>
<DialogTitle>{{ t("contextMenu.insertRowsDialogTitle") }}</DialogTitle>
<DialogDescription>{{ t("contextMenu.insertRowsDialogDescription") }}</DialogDescription>
</DialogHeader>
<div class="space-y-2">
<Label for="insert-template-rows-count">{{ t("contextMenu.insertRowsDialogCountLabel") }}</Label>
<Input id="insert-template-rows-count" v-model="rowCount" type="number" min="1" :max="MAX_ROWS" :aria-invalid="inputInvalid" class="w-40" @keydown.enter.prevent="confirmRows" />
<p v-if="inputInvalid" class="text-sm text-destructive">{{ t("contextMenu.insertRowsDialogCountInvalid", { max: MAX_ROWS }) }}</p>
<p class="text-xs text-muted-foreground">{{ t("contextMenu.insertRowsDialogMaxHint", { max: MAX_ROWS }) }}</p>
</div>
<DialogFooter>
<Button variant="outline" @click="open = false">{{ t("dangerDialog.cancel") }}</Button>
<Button :disabled="parsedCount === null" @click="confirmRows">{{ t("contextMenu.insertRowsDialogConfirm") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
+7
View File
@@ -3959,6 +3959,13 @@ export default {
instanceInfo: "Instance Info",
newSql: "New SQL",
generateSql: "Generate SQL",
insertRowsDialogTitle: "Generate INSERT Rows",
insertRowsDialogDescription: "Generates the given number of INSERT template rows for each selected table; placeholder values are numbered per row when more than one row is generated.",
insertRowsDialogCountLabel: "Row count",
insertRowsDialogCountInvalid: "Enter an integer between 1 and {max}",
insertRowsDialogMaxHint: "Up to {max} rows",
insertRowsDialogConfirm: "Generate",
insertRowsPkConflictWarning: "Heads up: primary key column(s) {columns} cannot hold {rows} distinct values; some INSERT statements will conflict on execution. Adjust the values or the row count.",
newInsert: "New Insert",
newUpdate: "New Update",
sqlHistory: "Query History",
+7
View File
@@ -3861,6 +3861,13 @@ export default withEnglishFallback({
addToAiMultiple: "Agregar a la IA ({count} tablas)",
newSql: "Nuevo SQL",
generateSql: "Generar SQL",
insertRowsDialogTitle: "Generar filas INSERT",
insertRowsDialogDescription: "Genera el número indicado de filas de plantilla INSERT para cada tabla seleccionada; cuando se genera más de una fila, los valores de marcador se numeran por fila.",
insertRowsDialogCountLabel: "Número de filas",
insertRowsDialogCountInvalid: "Introduce un entero entre 1 y {max}",
insertRowsDialogMaxHint: "Hasta {max} filas",
insertRowsDialogConfirm: "Generar",
insertRowsPkConflictWarning: "Aviso: la(s) columna(s) de clave primaria {columns} no pueden contener {rows} valores distintos; algunos INSERT fallarán por conflicto de clave al ejecutarse. Ajusta los valores o el número de filas.",
newInsert: "Nueva Inserción",
newUpdate: "Nueva Actualización",
sqlHistory: "Historial de consultas",
+7
View File
@@ -3705,6 +3705,13 @@ export default withEnglishFallback({
instanceInfo: "Informazioni Istanza",
newSql: "Nuovo SQL",
generateSql: "Genera SQL",
insertRowsDialogTitle: "Genera righe INSERT",
insertRowsDialogDescription: "Genera il numero indicato di righe di modello INSERT per ogni tabella selezionata; con più di una riga, i valori segnaposto vengono numerati per riga.",
insertRowsDialogCountLabel: "Numero di righe",
insertRowsDialogCountInvalid: "Inserisci un numero intero tra 1 e {max}",
insertRowsDialogMaxHint: "Fino a {max} righe",
insertRowsDialogConfirm: "Genera",
insertRowsPkConflictWarning: "Attenzione: le colonne di chiave primaria {columns} non possono contenere {rows} valori distinti; alcune INSERT andranno in conflitto in esecuzione. Modifica i valori o il numero di righe.",
newInsert: "Nuovo Insert",
newUpdate: "Nuovo Update",
sqlHistory: "Cronologia Query",
+7
View File
@@ -3726,6 +3726,13 @@ export default withEnglishFallback({
addToAiMultiple: "AI に追加({count} テーブル)",
newSql: "新しいSQL",
generateSql: "SQLを生成",
insertRowsDialogTitle: "INSERT 行を生成",
insertRowsDialogDescription: "選択した各テーブルについて指定した行数の INSERT テンプレートを生成します。複数行を生成する場合、プレースホルダーの値は行番号ごとに区別されます。",
insertRowsDialogCountLabel: "行数",
insertRowsDialogCountInvalid: "1~{max} の整数を入力してください",
insertRowsDialogMaxHint: "最大 {max} 行",
insertRowsDialogConfirm: "生成",
insertRowsPkConflictWarning: "注意: 主キー列 {columns} は {rows} 行の異なる値を保持できません。実行時に一部の INSERT で主キー競合が発生します。値または行数を調整してください。",
newInsert: "新しいINSERT",
newUpdate: "新しいUPDATE",
sqlHistory: "クエリ履歴",
+7
View File
@@ -3654,6 +3654,13 @@ export default withEnglishFallback({
instanceInfo: "인스턴스 정보",
newSql: "새 SQL",
generateSql: "SQL 생성",
insertRowsDialogTitle: "INSERT 행 생성",
insertRowsDialogDescription: "선택한 각 테이블에 대해 지정한 행 수의 INSERT 템플릿을 생성합니다. 여러 행을 생성하면 자리표시자 값이 행 번호별로 구분됩니다.",
insertRowsDialogCountLabel: "행 수",
insertRowsDialogCountInvalid: "1~{max} 사이의 정수를 입력하세요",
insertRowsDialogMaxHint: "최대 {max}행",
insertRowsDialogConfirm: "생성",
insertRowsPkConflictWarning: "주의: 기본 키 열 {columns}은(는) {rows}개의 서로 다른 값을 담을 수 없습니다. 실행 시 일부 INSERT에서 기본 키 충돌이 발생합니다. 값이나 행 수를 조정하세요.",
newInsert: "새 INSERT",
newUpdate: "새 UPDATE",
sqlHistory: "쿼리 기록",
+7
View File
@@ -3705,6 +3705,13 @@ export default withEnglishFallback({
addToAiMultiple: "Adicionar à IA ({count} tabelas)",
newSql: "Novo SQL",
generateSql: "Gerar SQL",
insertRowsDialogTitle: "Gerar linhas INSERT",
insertRowsDialogDescription: "Gera o número indicado de linhas de modelo INSERT para cada tabela selecionada; ao gerar mais de uma linha, os valores de espaço reservado são numerados por linha.",
insertRowsDialogCountLabel: "Número de linhas",
insertRowsDialogCountInvalid: "Informe um número inteiro entre 1 e {max}",
insertRowsDialogMaxHint: "Até {max} linhas",
insertRowsDialogConfirm: "Gerar",
insertRowsPkConflictWarning: "Atenção: as colunas de chave primária {columns} não podem conter {rows} valores distintos; alguns INSERT causarão conflito de chave ao serem executados. Ajuste os valores ou o número de linhas.",
newInsert: "Novo Insert",
newUpdate: "Novo Update",
sqlHistory: "Histórico de Consultas",
+7
View File
@@ -3815,6 +3815,13 @@ export default withEnglishFallback({
instanceInfo: "Сведения об экземпляре",
newSql: "Новый SQL",
generateSql: "Сгенерировать SQL",
insertRowsDialogTitle: "Сгенерировать строки INSERT",
insertRowsDialogDescription: "Создаёт указанное количество строк шаблона INSERT для каждой выбранной таблицы; при генерации нескольких строк значения-заполнители нумеруются по строкам.",
insertRowsDialogCountLabel: "Количество строк",
insertRowsDialogCountInvalid: "Введите целое число от 1 до {max}",
insertRowsDialogMaxHint: "Не более {max} строк",
insertRowsDialogConfirm: "Сгенерировать",
insertRowsPkConflictWarning: "Внимание: столбцы первичного ключа {columns} не могут вместить {rows} различных значений; при выполнении часть INSERT-запросов вызовет конфликт первичного ключа. Измените значения или количество строк.",
newInsert: "Новый Insert",
newUpdate: "Новый Update",
sqlHistory: "История запросов",
+7
View File
@@ -3893,6 +3893,13 @@ export default withEnglishFallback({
newInsert: "新建新增",
newSql: "新建 SQL",
generateSql: "生成 SQL",
insertRowsDialogTitle: "生成多行 INSERT",
insertRowsDialogDescription: "为选中的每张表生成指定行数的 INSERT 模板,多行时占位值将按行号区分。",
insertRowsDialogCountLabel: "行数",
insertRowsDialogCountInvalid: "请输入 1~{max} 之间的整数",
insertRowsDialogMaxHint: "最多 {max} 行",
insertRowsDialogConfirm: "生成",
insertRowsPkConflictWarning: "注意:主键列 {columns} 无法容纳 {rows} 行的不同值,执行时部分 INSERT 会主键冲突,请先调整值或行数",
newUpdate: "新建更新",
sqlHistory: "历史查询",
noSqlHistory: "暂无关联 SQL",
+7
View File
@@ -3709,6 +3709,13 @@ export default withEnglishFallback({
instanceInfo: "實例資訊",
newSql: "新增 SQL",
generateSql: "產生 SQL",
insertRowsDialogTitle: "產生多行 INSERT",
insertRowsDialogDescription: "為選中的每張資料表產生指定行數的 INSERT 範本,多行時佔位值將按行號區分。",
insertRowsDialogCountLabel: "行數",
insertRowsDialogCountInvalid: "請輸入 1~{max} 之間的整數",
insertRowsDialogMaxHint: "最多 {max} 行",
insertRowsDialogConfirm: "產生",
insertRowsPkConflictWarning: "注意:主鍵欄位 {columns} 無法容納 {rows} 行的不同值,執行時部分 INSERT 會發生主鍵衝突,請先調整值或行數",
newInsert: "建立新增",
newUpdate: "建立更新",
sqlHistory: "歷史查詢",
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { buildTableDeleteTemplate, buildTableInsertTemplate, buildTableSelectTemplate, buildTableUpdateTemplate } from "@/lib/table/tableSqlTemplates";
import { buildTableDeleteTemplate, buildTableInsertTemplate, buildTableSelectTemplate, buildTableUpdateTemplate, insertTemplatePrimaryKeyConflicts } from "@/lib/table/tableSqlTemplates";
import type { ColumnInfo } from "@/types/database";
describe("buildTableSelectTemplate", () => {
@@ -103,3 +103,134 @@ describe("buildTableInsertTemplate string placeholder length", () => {
expect(sql).toBe('INSERT INTO "t" ("部门")\nVALUES (\'部_value\');');
});
});
describe("buildTableInsertTemplate rowCount", () => {
function column(partial: Partial<ColumnInfo> & Pick<ColumnInfo, "name" | "data_type">): ColumnInfo {
return { is_nullable: true, column_default: null, is_primary_key: false, extra: null, ...partial };
}
const mysqlColumns: ColumnInfo[] = [column({ name: "id", data_type: "int", is_primary_key: true }), column({ name: "name", data_type: "varchar" }), column({ name: "age", data_type: "int" }), column({ name: "created_at", data_type: "datetime" })];
it("keeps the single-row output unchanged without rowCount", () => {
const sql = buildTableInsertTemplate({ databaseType: "mysql", tableName: "users", columns: mysqlColumns });
expect(sql).toBe("INSERT INTO `users` (`id`, `name`, `age`, `created_at`)\nVALUES (0, 'name_value', 0, CURRENT_TIMESTAMP);");
});
it("treats rowCount=1 the same as the default", () => {
const single = buildTableInsertTemplate({ databaseType: "mysql", tableName: "users", columns: mysqlColumns });
const explicit = buildTableInsertTemplate({ databaseType: "mysql", tableName: "users", columns: mysqlColumns, rowCount: 1 });
expect(explicit).toBe(single);
});
it("generates numbered values per row when rowCount > 1", () => {
const sql = buildTableInsertTemplate({ databaseType: "mysql", tableName: "users", columns: mysqlColumns, rowCount: 3 });
expect(sql).toBe(
[
"INSERT INTO `users` (`id`, `name`, `age`, `created_at`)",
"VALUES (1, 'name_value_1', 1, CURRENT_TIMESTAMP);",
"",
"INSERT INTO `users` (`id`, `name`, `age`, `created_at`)",
"VALUES (2, 'name_value_2', 2, CURRENT_TIMESTAMP);",
"",
"INSERT INTO `users` (`id`, `name`, `age`, `created_at`)",
"VALUES (3, 'name_value_3', 3, CURRENT_TIMESTAMP);",
].join("\n"),
);
});
it("generates a distinct uuid per row for postgres uuid columns", () => {
const sql = buildTableInsertTemplate({ databaseType: "postgres", tableName: "users", columns: [column({ name: "id", data_type: "uuid" })], rowCount: 3 });
const values = [...sql.matchAll(/VALUES \('([0-9a-f-]{36})'::uuid\);/g)].map((match) => match[1]!);
expect(values).toHaveLength(3);
expect(new Set(values).size).toBe(3);
});
it("still returns the single TODO template when no columns are insertable", () => {
const columns = [column({ name: "id", data_type: "int", extra: "auto_increment" })];
const sql = buildTableInsertTemplate({ databaseType: "mysql", tableName: "t", columns, rowCount: 3 });
expect(sql).toBe("INSERT INTO `t`\n/* TODO: add column values */\nVALUES ();");
});
it("generates one statement per row for tdengine stable tables with static tag placeholders", () => {
const columns = [column({ name: "ts", data_type: "timestamp" }), column({ name: "current", data_type: "float" }), column({ name: "location", data_type: "varchar", comment: "TAG" })];
const sql = buildTableInsertTemplate({ databaseType: "tdengine", tableName: "meters", columns, tableType: "STABLE", rowCount: 2 });
expect(sql).toBe(["INSERT INTO /* child_table_name */ USING meters", "TAGS ('location_value')", "VALUES (NOW, 1);", "", "INSERT INTO /* child_table_name */ USING meters", "TAGS ('location_value')", "VALUES (NOW, 2);"].join("\n"));
});
it("clamps oversized rowCount to the max", () => {
const columns = [column({ name: "id", data_type: "int" })];
const sql = buildTableInsertTemplate({ databaseType: "mysql", tableName: "t", columns, rowCount: 5000 });
expect(sql.split("\n\n")).toHaveLength(1000);
});
it("clamps row numbers to narrow numeric column ranges", () => {
const columns = [column({ name: "id", data_type: "tinyint", numeric_precision: 3 }), column({ name: "code", data_type: "number", numeric_precision: 2 }), column({ name: "wide", data_type: "int", numeric_precision: 10 })];
const sql = buildTableInsertTemplate({ databaseType: "mysql", tableName: "t", columns, rowCount: 200 });
const values = [...sql.matchAll(/VALUES \((\d+), (\d+), (\d+)\);/g)];
expect(values).toHaveLength(200);
expect(values[126]![1]).toBe("127");
expect(values[199]![1]).toBe("127");
expect(values[98]![2]).toBe("99");
expect(values[199]![2]).toBe("99");
expect(values[199]![3]).toBe("200");
});
it("does not clamp row numbers when precision metadata is missing", () => {
const columns = [column({ name: "id", data_type: "int" })];
const sql = buildTableInsertTemplate({ databaseType: "mysql", tableName: "t", columns, rowCount: 200 });
const values = [...sql.matchAll(/VALUES \((\d+)\);/g)];
expect(values[199]![1]).toBe("200");
});
it("truncates string placeholders to the column character limit while keeping the row suffix", () => {
const columns = [column({ name: "CRYPTO_MODE", data_type: "varchar2", character_maximum_length: 16 }), column({ name: "note", data_type: "varchar2", character_maximum_length: 64 })];
const sql = buildTableInsertTemplate({ databaseType: "oracle", tableName: "t", columns, rowCount: 12 });
const values = [...sql.matchAll(/VALUES \('([^']*)', '([^']*)'\);/g)];
expect(values).toHaveLength(12);
expect(values[0]![1]).toBe("CRYPTO_M_value_1");
expect(values[1]![1]).toBe("CRYPTO_M_value_2");
expect(values[9]![1]).toBe("CRYPTO__value_10");
expect(values[0]![2]).toBe("note_value_1");
expect(values.every((match) => match[1]!.length <= 16 && match[2]!.length <= 64)).toBe(true);
});
it("falls back to the bare row number when the column cannot hold the placeholder suffix", () => {
const columns = [column({ name: "status", data_type: "varchar2", character_maximum_length: 4 })];
const sql = buildTableInsertTemplate({ databaseType: "oracle", tableName: "t", columns, rowCount: 2 });
const values = [...sql.matchAll(/VALUES \('([^']*)'\);/g)];
expect(values[0]![1]).toBe("1");
expect(values[1]![1]).toBe("2");
});
});
describe("insertTemplatePrimaryKeyConflicts", () => {
function column(partial: Partial<ColumnInfo> & Pick<ColumnInfo, "name" | "data_type">): ColumnInfo {
return { is_nullable: true, column_default: null, is_primary_key: false, extra: null, ...partial };
}
it("flags numeric primary keys whose cap is below the row count", () => {
const columns = [column({ name: "id", data_type: "tinyint", numeric_precision: 3, is_primary_key: true })];
expect(insertTemplatePrimaryKeyConflicts(columns, 200)).toEqual([{ column: "id", reason: "numeric-cap", cap: 127 }]);
});
it("ignores non-key, auto-generated and wide-enough columns", () => {
const columns = [
column({ name: "status", data_type: "tinyint", numeric_precision: 3 }),
column({ name: "rowid", data_type: "int", is_primary_key: true, extra: "auto_increment" }),
column({ name: "code", data_type: "int", numeric_precision: 10, is_primary_key: true }),
column({ name: "ref", data_type: "uuid", is_primary_key: true }),
column({ name: "slug", data_type: "varchar", is_primary_key: true }),
];
expect(insertTemplatePrimaryKeyConflicts(columns, 200)).toEqual([]);
});
it("flags date/time primary keys as static duplicates", () => {
const columns = [column({ name: "day", data_type: "date", is_primary_key: true })];
expect(insertTemplatePrimaryKeyConflicts(columns, 3)).toEqual([{ column: "day", reason: "static-value" }]);
});
it("returns nothing for single-row generation", () => {
const columns = [column({ name: "id", data_type: "tinyint", numeric_precision: 3, is_primary_key: true })];
expect(insertTemplatePrimaryKeyConflicts(columns, 1)).toEqual([]);
});
});
+100 -21
View File
@@ -14,6 +14,23 @@ export interface TableSqlTemplateOptions {
tableName: string;
columns?: ColumnInfo[];
tableType?: string;
/** Number of INSERT statements to generate; values are numbered per row when greater than 1. */
rowCount?: number;
}
/** Upper bound for INSERT template row generation, shared with the row-count dialog. */
export const INSERT_TEMPLATE_MAX_ROW_COUNT = 1000;
function normalizeInsertTemplateRowCount(rowCount: number | undefined): number {
if (!rowCount || !Number.isFinite(rowCount) || rowCount <= 1) return 1;
return Math.min(Math.floor(rowCount), INSERT_TEMPLATE_MAX_ROW_COUNT);
}
function insertTemplateStatements(rowCount: number, buildStatement: (rowIndex?: number) => string): string {
if (rowCount <= 1) return buildStatement();
const statements: string[] = [];
for (let rowIndex = 0; rowIndex < rowCount; rowIndex += 1) statements.push(buildStatement(rowIndex));
return statements.join("\n\n");
}
export function buildTableSelectTemplate(options: TableSqlTemplateOptions): string {
@@ -37,14 +54,14 @@ type InsertTemplateStrategy = (options: TableSqlTemplateOptions) => string;
function buildDefaultInsertTemplate(options: TableSqlTemplateOptions): string {
const tableName = templateTableName(options);
const columns = (options.columns ?? []).filter((column) => !isAutoGeneratedColumn(column));
const rowCount = normalizeInsertTemplateRowCount(options.rowCount);
// TDengine does not support INSERT INTO t(col1, col2) VALUES(...) syntax
if (options.databaseType === "tdengine") {
const values = columns.map((column) => columnPlaceholderValue(column, options.databaseType));
if (!values.length) {
if (!columns.length) {
return `INSERT INTO ${tableName}\nVALUES (/* TODO: add values */);`;
}
return `INSERT INTO ${tableName}\nVALUES (${values.join(", ")});`;
return insertTemplateStatements(rowCount, (rowIndex) => `INSERT INTO ${tableName}\nVALUES (${columns.map((column) => columnPlaceholderValue(column, options.databaseType, rowIndex)).join(", ")});`);
}
if (!columns.length) {
@@ -52,11 +69,11 @@ function buildDefaultInsertTemplate(options: TableSqlTemplateOptions): string {
}
const columnNames = columns.map((column) => templateIdentifier(options, column.name));
const values = columns.map((column) => columnPlaceholderValue(column, options.databaseType));
return `INSERT INTO ${tableName} (${columnNames.join(", ")})\nVALUES (${values.join(", ")});`;
return insertTemplateStatements(rowCount, (rowIndex) => `INSERT INTO ${tableName} (${columnNames.join(", ")})\nVALUES (${columns.map((column) => columnPlaceholderValue(column, options.databaseType, rowIndex)).join(", ")});`);
}
function buildTdengineInsertTemplate(options: TableSqlTemplateOptions): string {
const rowCount = normalizeInsertTemplateRowCount(options.rowCount);
const columns = options.columns ?? [];
const isTagColumn = (column: ColumnInfo) => (column.comment ?? "").toUpperCase() === "TAG" || (column.extra ?? "").toUpperCase().includes("TAG");
const tagColumns = columns.filter((column) => isTagColumn(column));
@@ -71,13 +88,12 @@ function buildTdengineInsertTemplate(options: TableSqlTemplateOptions): string {
const tagPlaceholders = tagColumns.map((column) => columnPlaceholderValue(column, options.databaseType));
const tagsSql = tagPlaceholders.length > 0 ? `TAGS (${tagPlaceholders.join(", ")})` : "TAGS (/* TODO: add tag values */)";
const stableNameForUsing = templateTableName(options).replace(/^`|`$/g, "");
const valuePlaceholders = valueColumns.map((column) => columnPlaceholderValue(column, options.databaseType));
if (!valueColumns.length) {
return `INSERT INTO /* child_table_name */ USING ${stableNameForUsing}\n${tagsSql}\nVALUES (/* TODO: add values */);`;
}
return `INSERT INTO /* child_table_name */ USING ${stableNameForUsing}\n${tagsSql}\nVALUES (${valuePlaceholders.join(", ")});`;
return insertTemplateStatements(rowCount, (rowIndex) => `INSERT INTO /* child_table_name */ USING ${stableNameForUsing}\n${tagsSql}\nVALUES (${valueColumns.map((column) => columnPlaceholderValue(column, options.databaseType, rowIndex)).join(", ")});`);
}
const insertTemplateStrategies: Partial<Record<DatabaseType, InsertTemplateStrategy>> = {
@@ -101,6 +117,34 @@ export function buildTableDeleteTemplate(options: TableSqlTemplateOptions): stri
return `DELETE FROM ${tableName}\n${buildWhereClause(options, primaryKeys)};`;
}
export interface InsertTemplatePkConflict {
column: string;
reason: "numeric-cap" | "static-value";
cap?: number;
}
/**
* Primary-key columns that cannot hold `rowCount` distinct placeholder values:
* narrow numeric keys capped below the row count, and date/time keys whose
* placeholder stays static across rows. Auto-generated keys are skipped
* because they are omitted from the generated INSERT column list.
*/
export function insertTemplatePrimaryKeyConflicts(columns: ColumnInfo[], rowCount: number): InsertTemplatePkConflict[] {
if (rowCount <= 1) return [];
const conflicts: InsertTemplatePkConflict[] = [];
for (const column of columns) {
if (!column.is_primary_key || isAutoGeneratedColumn(column)) continue;
const dataType = (column.data_type ?? "").trim();
if (typeLooksNumeric(dataType)) {
const cap = numericRowValueCap(column);
if (cap !== null && cap < rowCount) conflicts.push({ column: column.name, reason: "numeric-cap", cap });
} else if (typeLooksDateOnly(dataType) || typeLooksTimeOnly(dataType) || typeLooksTimestamp(dataType)) {
conflicts.push({ column: column.name, reason: "static-value" });
}
}
return conflicts;
}
function templateTableName(options: TableSqlTemplateOptions): string {
return qualifiedTableName({
databaseType: options.databaseType,
@@ -142,23 +186,31 @@ function isAutoGeneratedColumn(column: ColumnInfo): boolean {
return false;
}
function columnPlaceholderValue(column: ColumnInfo, databaseType?: DatabaseType): string {
function columnPlaceholderValue(column: ColumnInfo, databaseType?: DatabaseType, rowIndex?: number): string {
const dataType = (column.data_type ?? "").trim();
const colName = column.name;
const rowNumber = rowIndex === undefined ? undefined : rowIndex + 1;
if (typeLooksNumeric(dataType)) return "0";
if (typeLooksNumeric(dataType)) {
if (rowNumber === undefined) return "0";
const cap = numericRowValueCap(column);
return String(cap === null ? rowNumber : Math.min(rowNumber, cap));
}
if (typeLooksBoolean(dataType)) return databaseType === "mysql" || databaseType === "sqlite" || databaseType === "rqlite" || databaseType === "cloudflare-d1" ? "1" : "TRUE";
if (typeLooksDateOnly(dataType)) return "'2024-01-01'";
if (typeLooksTimeOnly(dataType)) return "'12:00:00'";
if (typeLooksTimestamp(dataType)) return databaseType === "tdengine" ? "NOW" : "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 (typeLooksUuid(dataType)) {
const uuid = rowNumber === undefined ? "00000000-0000-0000-0000-000000000000" : randomUuidValue();
return databaseType === "postgres" || databaseType === "redshift" ? `'${uuid}'::uuid` : `'${uuid}'`;
}
if (typeLooksXml(dataType)) return "'<root/>'";
if (typeLooksSpatial(dataType)) return "NULL";
if (typeLooksArray(dataType)) return "'{}'";
return stringPlaceholderValue(colName, column.character_maximum_length);
return stringPlaceholderValue(colName, rowNumber, column.character_maximum_length);
}
// Byte-aware budget so byte-length columns (Oracle VARCHAR2 N BYTE) stay safe
@@ -173,17 +225,20 @@ function utf8Length(value: string): number {
}
/**
* `col_value` placeholder clipped to the column's declared length: the name
* part shrinks so the recognizable `_value` suffix survives; when even the
* suffix cannot fit, it degrades to a clipped suffix so the statement stays
* executable.
* `col_value[_row]` placeholder clipped to the column's declared length: the
* name part shrinks so the row-suffix (the per-row distinct part) survives.
* When even the suffix cannot fit, degrade to the bare row number.
*/
function stringPlaceholderValue(colName: string, maxLength: number | null | undefined): string {
const value = `${colName}_value`;
function stringPlaceholderValue(colName: string, rowNumber: number | undefined, maxLength: number | null | undefined): string {
const suffix = `_value${rowNumber === undefined ? "" : `_${rowNumber}`}`;
const limit = typeof maxLength === "number" && Number.isFinite(maxLength) && maxLength > 0 ? maxLength : null;
if (limit === null || utf8Length(value) <= limit) return `'${value}'`;
const suffixLength = utf8Length("_value");
if (suffixLength > limit) return `'${"_value".slice(0, limit)}'`;
if (limit === null) return `'${colName}${suffix}'`;
const suffixLength = utf8Length(suffix);
if (suffixLength > limit) {
if (rowNumber === undefined) return `'${"_value".slice(0, limit)}'`;
const fallback = String(rowNumber);
return `'${utf8Length(fallback) <= limit ? fallback : [...fallback].slice(0, Math.max(1, limit)).join("")}'`;
}
const nameBudget = limit - suffixLength;
let name = "";
let used = 0;
@@ -193,7 +248,31 @@ function stringPlaceholderValue(colName: string, maxLength: number | null | unde
name += char;
used += charLength;
}
return `'${name}_value'`;
return `'${name}${suffix}'`;
}
function randomUuidValue(): string {
const uuid = globalThis.crypto?.randomUUID?.();
if (uuid) return uuid;
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (char) => {
const random = (Math.random() * 16) | 0;
const value = char === "x" ? random : (random & 0x3) | 0x8;
return value.toString(16);
});
}
// Unsigned metadata is not exposed per column, so narrow integer types use the
// conservative signed upper bound when capping per-row placeholder numbers.
function numericRowValueCap(column: ColumnInfo): number | null {
const dataType = (column.data_type ?? "").trim().toLowerCase();
if (dataType === "tinyint" || dataType === "int8") return 127;
const precision = column.numeric_precision;
if (typeof precision !== "number" || !Number.isFinite(precision) || precision <= 0) return null;
const scale = typeof column.numeric_scale === "number" && Number.isFinite(column.numeric_scale) && column.numeric_scale > 0 ? column.numeric_scale : 0;
const integerDigits = precision - scale;
// A pure-fraction column (scale >= precision) cannot hold any nonzero integer; keep 0 so statements stay valid.
if (integerDigits <= 0) return 0;
return 10 ** integerDigits - 1;
}
function typeLooksNumeric(dataType: string): boolean {