fix(oracle): generate compatible insert statements

This commit is contained in:
zipg
2026-07-14 18:03:43 +08:00
committed by GitHub
parent 0bbaf33d92
commit 9105df9a04
3 changed files with 222 additions and 46 deletions
@@ -3,9 +3,9 @@ import { reactive, ref, computed, onMounted, watch, type ComponentPublicInstance
import { useI18n } from "vue-i18n";
import { useConnectionStore } from "@/stores/connectionStore";
import * as api from "@/lib/backend/api";
import type { TableGenerateConfig } from "@/lib/dataGrid/dataGenerate";
import { displayGeneratedValue, findGeneratorKey, formatGeneratedValue, generateTableData, defaultGeneratorParams } from "@/lib/dataGrid/dataGenerate";
import { quoteTableIdentifier } from "@/lib/table/tableSelectSql";
import type { GenerateResult, TableGenerateConfig } from "@/lib/dataGrid/dataGenerate";
import { displayGeneratedValue, findGeneratorKey, formatGeneratedValue, generateTableData, defaultGeneratorParams, supportsGeneratedMultiRowValues } from "@/lib/dataGrid/dataGenerate";
import { qualifiedTableName, quoteTableIdentifier } from "@/lib/table/tableSelectSql";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
@@ -56,7 +56,11 @@ const panelColumnName = ref<string | null>(null);
// Step state: config -> preview
const currentStep = ref<"config" | "preview" | "result">("config");
const generatedResults = ref<{ tableName: string; columns: string[]; rows: unknown[][]; sql: string }[]>([]);
interface GeneratedTableResult extends GenerateResult {
tableName: string;
schema: string;
}
const generatedResults = ref<GeneratedTableResult[]>([]);
const connectionName = computed(() => (props.prefillConnectionId ? store.getConfig(props.prefillConnectionId)?.name : ""));
@@ -142,6 +146,9 @@ async function loadSchemas() {
for (const c of cols) {
checkedColumns[colKey(targetSchema, props.prefillTable, c.name)] = true;
}
panelTableKey.value = key;
panelMode.value = "table";
panelColumnName.value = null;
break;
}
} catch {
@@ -263,6 +270,26 @@ async function selectTable(schema: string, table: string, checked: boolean) {
}
}
async function activateTable(schema: string, table: string) {
const key = tableKey(schema, table);
if (!checkedTables[key]) {
await selectTable(schema, table, true);
}
await showTable(schema, table);
}
async function updateTableSelection(schema: string, table: string, checked: boolean) {
const key = tableKey(schema, table);
await selectTable(schema, table, checked);
if (checked) {
await showTable(schema, table);
} else if (panelTableKey.value === key) {
panelTableKey.value = null;
panelMode.value = null;
panelColumnName.value = null;
}
}
function toggleColumn(schema: string, table: string, column: string) {
const ck = colKey(schema, table, column);
const key = tableKey(schema, table);
@@ -325,7 +352,7 @@ function onPreviewColResizeStart(ci: number, event: MouseEvent) {
document.addEventListener("pointerup", onUp);
document.body.classList.add("select-none", "cursor-col-resize");
}
const currentPreview = computed(() => generatedResults.value[previewTableIndex.value] ?? { tableName: "", columns: [], rows: [], sql: "" });
const currentPreview = computed<GeneratedTableResult>(() => generatedResults.value[previewTableIndex.value] ?? { tableName: "", schema: "", columns: [], rows: [], sql: "", statements: [] });
function displayPreviewCell(cell: unknown): string {
return displayGeneratedValue(cell);
@@ -355,7 +382,8 @@ async function fetchMaxValues(cfg: TableGenerateConfig): Promise<Record<string,
}
async function doGenerate() {
const results: { tableName: string; columns: string[]; rows: unknown[][]; sql: string }[] = [];
if (!Object.values(checkedTables).some(Boolean)) return;
const results: GeneratedTableResult[] = [];
const order = tableOrder.value.length > 0 ? tableOrder.value : Object.keys(configs);
for (const key of order) {
if (!checkedTables[key]) continue;
@@ -373,7 +401,7 @@ async function doGenerate() {
});
}
const result = generateTableData({ ...cfg, columns }, dbType.value);
results.push({ tableName: cfg.tableName, columns: result.columns, rows: result.rows, sql: result.sql });
results.push({ tableName: cfg.tableName, schema: cfg.schema, ...result });
}
generatedResults.value = results;
previewTableIndex.value = 0;
@@ -382,7 +410,8 @@ async function doGenerate() {
async function regenerate() {
if (generatedResults.value.length === 0) return;
const key = Object.keys(configs).find((k) => configs[k].tableName === generatedResults.value[previewTableIndex.value]?.tableName);
const preview = generatedResults.value[previewTableIndex.value];
const key = Object.keys(configs).find((k) => configs[k].tableName === preview?.tableName && configs[k].schema === preview?.schema);
if (!key) return;
const cfg = configs[key];
let columns = cfg.columns.filter((col) => checkedColumns[colKey(cfg.schema, cfg.tableName, col.columnName)] !== false);
@@ -397,7 +426,7 @@ async function regenerate() {
});
}
const result = generateTableData({ ...cfg, columns }, dbType.value);
generatedResults.value[previewTableIndex.value] = { tableName: cfg.tableName, columns: result.columns, rows: result.rows, sql: result.sql };
generatedResults.value[previewTableIndex.value] = { tableName: cfg.tableName, schema: cfg.schema, ...result };
}
function copyAllSql() {
@@ -422,21 +451,30 @@ const generateOptions = reactive({
useTransaction: true,
extendedInsert: true,
});
const supportsExtendedInsert = computed(() => supportsGeneratedMultiRowValues(dbType.value));
watch(
supportsExtendedInsert,
(supported) => {
if (!supported) generateOptions.extendedInsert = false;
},
{ immediate: true },
);
const optionsDialogOpen = ref(false);
function sqlStatementsForTable(r: { tableName: string; columns: string[]; rows: unknown[][]; sql: string }): string[] {
function sqlStatementsForTable(r: GeneratedTableResult): string[] {
const stmts: string[] = [];
const targetTable = qualifiedTableName({ databaseType: dbType.value, schema: r.schema, tableName: r.tableName, database: props.prefillDatabase });
if (generateOptions.truncate) {
stmts.push(`TRUNCATE TABLE ${quoteTableIdentifier(dbType.value, r.tableName)};`);
stmts.push(`TRUNCATE TABLE ${targetTable};`);
}
if (generateOptions.extendedInsert) {
stmts.push(r.sql);
if (generateOptions.extendedInsert || !supportsGeneratedMultiRowValues(dbType.value)) {
stmts.push(...r.statements);
} else {
const colList = r.columns.map((c) => quoteTableIdentifier(dbType.value, c)).join(", ");
for (const row of r.rows) {
const vals = row.map(formatGeneratedValue).join(", ");
stmts.push(`INSERT INTO ${quoteTableIdentifier(dbType.value, r.tableName)} (${colList}) VALUES (${vals});`);
const vals = row.map((value) => formatGeneratedValue(value)).join(", ");
stmts.push(`INSERT INTO ${targetTable} (${colList}) VALUES (${vals});`);
}
}
return stmts;
@@ -462,37 +500,55 @@ async function startInsert() {
execute: async () => {
executing.value = true;
const perTable: TableResult[] = [];
let stopAfterTable = false;
for (const r of generatedResults.value) {
const stmts = sqlStatementsForTable(r);
const rowCount = r.rows.length;
let ok = 0;
let lastError = "";
for (let si = 0; si < stmts.length; si++) {
const executeAsStatementGroup = generateOptions.useTransaction && !supportsGeneratedMultiRowValues(dbType.value);
if (executeAsStatementGroup) {
try {
if (generateOptions.useTransaction) {
await api.executeInTransaction(cid, db, [stmts[si]], props.prefillSchema);
} else {
await api.executeQuery(cid, db, stmts[si], props.prefillSchema);
}
if (generateOptions.extendedInsert) {
ok = rowCount;
} else if (!(generateOptions.truncate && si === 0)) {
ok++;
}
await api.executeInTransaction(cid, db, stmts, r.schema || props.prefillSchema);
ok = rowCount;
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
console.error("[startInsert] SQL error:", msg);
if (!lastError) lastError = msg;
if (generateOptions.extendedInsert) {
ok = 0;
lastError = msg;
stopAfterTable = !generateOptions.continueOnError;
}
} else {
const usesMultiRowStatement = generateOptions.extendedInsert && supportsGeneratedMultiRowValues(dbType.value);
for (let si = 0; si < stmts.length; si++) {
try {
if (generateOptions.useTransaction) {
await api.executeInTransaction(cid, db, [stmts[si]], r.schema || props.prefillSchema);
} else {
await api.executeQuery(cid, db, stmts[si], r.schema || props.prefillSchema);
}
if (usesMultiRowStatement) {
ok = rowCount;
} else if (!(generateOptions.truncate && si === 0)) {
ok++;
}
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
console.error("[startInsert] SQL error:", msg);
if (!lastError) lastError = msg;
if (usesMultiRowStatement) ok = 0;
if (!generateOptions.continueOnError) {
stopAfterTable = true;
break;
}
}
if (!generateOptions.continueOnError) break;
}
}
perTable.push({ table: r.tableName, total: rowCount, ok, err: rowCount - ok, error: lastError || undefined });
if (ok > 0) {
store.invalidateMetadataCache(cid, db, props.prefillSchema || undefined, r.tableName);
store.invalidateMetadataCache(cid, db, r.schema || props.prefillSchema || undefined, r.tableName);
}
if (stopAfterTable) break;
}
executeResults.value = perTable;
currentStep.value = "result";
@@ -510,6 +566,7 @@ const tableOrder = ref<string[]>([]);
const orderableTables = computed(() => {
return Object.keys(checkedTables).filter((k) => checkedTables[k]);
});
const hasSelectedTables = computed(() => orderableTables.value.length > 0);
function openOrderDialog() {
tableOrder.value = [...orderableTables.value];
@@ -763,14 +820,14 @@ async function onFileSelected(event: Event) {
<div v-if="expandedSchemas[sc] && schemaTables[sc]" class="ml-[18px]">
<div v-for="tbl in schemaTables[sc]" :key="tbl.name">
<!-- table row -->
<div class="group flex items-center gap-1.5 py-1 px-2 cursor-pointer hover:bg-accent">
<button type="button" class="-m-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:bg-muted hover:text-foreground" @click="toggleTable(sc, tbl.name)">
<div class="group flex items-center gap-1.5 py-1 px-2 cursor-pointer hover:bg-accent" @click="activateTable(sc, tbl.name)">
<button type="button" class="-m-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:bg-muted hover:text-foreground" @click.stop="toggleTable(sc, tbl.name)">
<Loader2 v-if="tableColumnsLoading[tableKey(sc, tbl.name)]" class="h-3.5 w-3.5 animate-spin" />
<ChevronRight v-else class="h-3.5 w-3.5 transition-transform" :class="{ 'rotate-90': expandedTables[tableKey(sc, tbl.name)] }" />
</button>
<input type="checkbox" class="h-3.5 w-3.5 accent-primary shrink-0" :checked="!!checkedTables[tableKey(sc, tbl.name)]" :ref="(el) => setTableRef(el, tableKey(sc, tbl.name))" @change="selectTable(sc, tbl.name, ($event.target as HTMLInputElement).checked)" />
<input type="checkbox" class="h-3.5 w-3.5 accent-primary shrink-0" :checked="!!checkedTables[tableKey(sc, tbl.name)]" :ref="(el) => setTableRef(el, tableKey(sc, tbl.name))" @click.stop @change="updateTableSelection(sc, tbl.name, ($event.target as HTMLInputElement).checked)" />
<Table class="h-3.5 w-3.5 shrink-0 text-green-500" />
<span class="text-sm truncate" :class="{ 'text-foreground font-medium': panelTableKey === tableKey(sc, tbl.name), 'text-muted-foreground': panelTableKey !== tableKey(sc, tbl.name) }" @click="showTable(sc, tbl.name)">
<span class="text-sm truncate" :class="{ 'text-foreground font-medium': panelTableKey === tableKey(sc, tbl.name), 'text-muted-foreground': panelTableKey !== tableKey(sc, tbl.name) }">
{{ tbl.name }}
</span>
</div>
@@ -948,7 +1005,7 @@ async function onFileSelected(event: Event) {
{{ t("dangerDialog.cancel") }}
</Button>
<template v-if="currentStep === 'config'">
<Button variant="default" size="sm" class="h-7 text-xs" @click="doGenerate">
<Button variant="default" size="sm" class="h-7 text-xs" :disabled="!hasSelectedTables" @click="doGenerate">
{{ t("dataGenerate.nextStep") }}
</Button>
</template>
@@ -986,8 +1043,8 @@ async function onFileSelected(event: Event) {
<input type="checkbox" v-model="generateOptions.useTransaction" class="h-4 w-4 accent-primary" />
<span class="text-xs">{{ t("dataGenerate.useTransaction") }}</span>
</label>
<label class="flex items-center gap-3 cursor-pointer">
<input type="checkbox" v-model="generateOptions.extendedInsert" class="h-4 w-4 accent-primary" />
<label class="flex items-center gap-3 cursor-pointer" :class="{ 'cursor-not-allowed opacity-50': !supportsExtendedInsert }">
<input type="checkbox" v-model="generateOptions.extendedInsert" class="h-4 w-4 accent-primary" :disabled="!supportsExtendedInsert" />
<span class="text-xs">{{ t("dataGenerate.extendedInsert") }}</span>
</label>
</div>
+59 -7
View File
@@ -1,5 +1,5 @@
import type { DatabaseType } from "@/types/database";
import { quoteTableIdentifier } from "@/lib/table/tableSelectSql";
import { qualifiedTableName, quoteTableIdentifier } from "@/lib/table/tableSelectSql";
export interface GeneratorNode {
key: string;
@@ -1722,12 +1722,41 @@ function generateByType(type: string, rowIndex: number): unknown {
return `value_${rowIndex + 1}`;
}
export function formatGeneratedValue(value: unknown): string {
function quoteGeneratedString(value: string): string {
return `'${value.replace(/'/g, "''")}'`;
}
function formatOracleTemporalValue(value: string, dataType: string): string | null {
const type = dataType.toLowerCase();
const dateTimeMatch = /^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2})(\.\d+)?$/.exec(value);
const dateMatch = /^\d{4}-\d{2}-\d{2}$/.test(value);
if (type.includes("timestamp") && dateTimeMatch) {
const mask = dateTimeMatch[3] ? "YYYY-MM-DD HH24:MI:SS.FF" : "YYYY-MM-DD HH24:MI:SS";
return `TO_TIMESTAMP(${quoteGeneratedString(value.replace("T", " "))}, '${mask}')`;
}
if (/^date(?:\b|\()/i.test(type)) {
if (dateTimeMatch) {
return `TO_DATE(${quoteGeneratedString(value.replace("T", " "))}, 'YYYY-MM-DD HH24:MI:SS')`;
}
if (dateMatch) {
return `TO_DATE(${quoteGeneratedString(value)}, 'YYYY-MM-DD')`;
}
}
return null;
}
export function formatGeneratedValue(value: unknown, databaseType?: DatabaseType, dataType?: string): string {
if (isGeneratedSqlExpression(value)) return value.sql;
if (value === null || value === undefined) return "NULL";
if (typeof value === "number") return String(value);
if (typeof value === "boolean") return value ? "1" : "0";
return `'${String(value).replace(/'/g, "''")}'`;
const stringValue = String(value);
if ((databaseType === "oracle" || databaseType === "oceanbase-oracle") && dataType) {
const temporalValue = formatOracleTemporalValue(stringValue, dataType);
if (temporalValue) return temporalValue;
}
return quoteGeneratedString(stringValue);
}
export function displayGeneratedValue(value: unknown): string {
@@ -1740,6 +1769,26 @@ export interface GenerateResult {
columns: string[];
rows: unknown[][];
sql: string;
statements: string[];
}
export function supportsGeneratedMultiRowValues(databaseType?: DatabaseType): boolean {
return databaseType !== "oracle" && databaseType !== "oceanbase-oracle" && databaseType !== "iris";
}
const ORACLE_INSERT_ALL_BATCH_SIZE = 100;
function buildOracleInsertStatements(targetTable: string, columnList: string, valueRows: string[]): string[] {
if (valueRows.length === 1) {
return [`INSERT INTO ${targetTable} (${columnList}) VALUES ${valueRows[0]};`];
}
const statements: string[] = [];
for (let start = 0; start < valueRows.length; start += ORACLE_INSERT_ALL_BATCH_SIZE) {
const rows = valueRows.slice(start, start + ORACLE_INSERT_ALL_BATCH_SIZE);
statements.push(["INSERT ALL", ...rows.map((values) => ` INTO ${targetTable} (${columnList}) VALUES ${values}`), "SELECT 1 FROM DUAL;"].join("\n"));
}
return statements;
}
export function generateTableData(config: TableGenerateConfig, databaseType?: DatabaseType): GenerateResult {
@@ -1751,10 +1800,13 @@ export function generateTableData(config: TableGenerateConfig, databaseType?: Da
rows.push(row);
}
const valuesSql = rows.map((row) => `(${row.map(formatGeneratedValue).join(", ")})`).join(",\n");
const quotedCols = config.columns.map((c) => quoteTableIdentifier(databaseType, c.columnName));
const sql = `INSERT INTO ${quoteTableIdentifier(databaseType, config.tableName)} (${quotedCols.join(", ")}) VALUES\n${valuesSql};`;
const targetTable = qualifiedTableName({ databaseType, schema: config.schema, tableName: config.tableName, database: config.database });
const columnList = quotedCols.join(", ");
const insertPrefix = `INSERT INTO ${targetTable} (${columnList}) VALUES`;
const valueRows = rows.map((row) => `(${row.map((value, index) => formatGeneratedValue(value, databaseType, config.columns[index]?.dataType)).join(", ")})`);
const statements = databaseType === "oracle" ? buildOracleInsertStatements(targetTable, columnList, valueRows) : supportsGeneratedMultiRowValues(databaseType) ? [`${insertPrefix}\n${valueRows.join(",\n")};`] : valueRows.map((values) => `${insertPrefix} ${values};`);
const sql = statements.join("\n");
return { columns: colNames, rows, sql };
return { columns: colNames, rows, sql, statements };
}
+68 -1
View File
@@ -1,6 +1,6 @@
import { strict as assert } from "node:assert";
import { test } from "vitest";
import { defaultGeneratorParams, displayGeneratedValue, generateTableData, generateValue } from "../../apps/desktop/src/lib/dataGrid/dataGenerate.ts";
import { defaultGeneratorParams, displayGeneratedValue, generateTableData, generateValue, supportsGeneratedMultiRowValues } from "../../apps/desktop/src/lib/dataGrid/dataGenerate.ts";
test("enables default values for columns with schema defaults", () => {
const params = defaultGeneratorParams(
@@ -98,3 +98,70 @@ test("includeDefault without a schema default no longer emits NULL", () => {
assert.notEqual(value, null);
assert.notEqual(value, undefined);
});
test("generates Oracle-compatible single-row inserts with explicit temporal literals", () => {
const result = generateTableData(
{
tableName: "DBX_GENERATE_TEST",
schema: "APP",
database: "XE",
rowCount: 2,
columns: [
{
columnName: "ID",
dataType: "NUMBER",
rowCount: 2,
generatorKey: "sequence",
generatorParams: { startValue: 1, increment: 1 },
},
{
columnName: "CREATED_ON",
dataType: "DATE",
rowCount: 2,
generatorKey: "date",
},
{
columnName: "CREATED_AT",
dataType: "TIMESTAMP(6)",
rowCount: 2,
generatorKey: "datetime",
},
],
},
"oracle",
);
assert.equal(supportsGeneratedMultiRowValues("oracle"), false);
assert.equal(result.statements.length, 1);
assert.match(result.statements[0], /^INSERT ALL\n/);
assert.equal(result.statements[0].match(/\n INTO /g)?.length, 2);
assert.match(result.statements[0], /SELECT 1 FROM DUAL;$/);
assert.ok(result.statements.every((sql) => /TO_DATE\('[^']+', 'YYYY-MM-DD'\)/.test(sql)));
assert.ok(result.statements.every((sql) => /TO_TIMESTAMP\('[^']+', 'YYYY-MM-DD HH24:MI:SS'\)/.test(sql)));
assert.doesNotMatch(result.sql, /VALUES\s*\([^;]+\),\s*\(/s);
});
test("batches large Oracle data generation statements", () => {
const result = generateTableData(
{
tableName: "DBX_GENERATE_TEST",
schema: "APP",
database: "XE",
rowCount: 101,
columns: [
{
columnName: "ID",
dataType: "NUMBER",
rowCount: 101,
generatorKey: "sequence",
generatorParams: { startValue: 1, increment: 1 },
},
],
},
"oracle",
);
assert.equal(result.statements.length, 2);
assert.equal(result.statements[0].match(/\n INTO /g)?.length, 100);
assert.equal(result.statements[1].match(/\n INTO /g)?.length, 1);
});