diff --git a/apps/desktop/src/composables/__tests__/useDataGenerateSession.spec.ts b/apps/desktop/src/composables/__tests__/useDataGenerateSession.spec.ts index a30c475553..9dd4a14cc6 100644 --- a/apps/desktop/src/composables/__tests__/useDataGenerateSession.spec.ts +++ b/apps/desktop/src/composables/__tests__/useDataGenerateSession.spec.ts @@ -154,6 +154,42 @@ describe("data generation background sessions", () => { expect(session.results[0]?.cancelled).toBe(true); }); + it("counts only the first 100 OceanBase rows when the second INSERT ALL statement fails", async () => { + mocks.executeMultiWithProgress.mockResolvedValueOnce([{ columns: [], rows: [], affected_rows: 100, execution_time_ms: 1 } as QueryResult, { columns: ["Error"], rows: [["duplicate key"]], affected_rows: 0, execution_time_ms: 1, execution_error: true } as QueryResult]); + const session = startDataGenerateSession( + { + connectionId: "ob-connection", + database: "oracletest", + prefillSchema: "APP", + databaseType: "oceanbase-oracle", + label: "APP.Items", + targets: [ + { + tableName: "Items", + schema: "APP", + database: "oracletest", + targetRowCount: 101, + isSample: false, + columns: ["ID"], + rows: [], + sql: "", + statements: [], + resolvedColumns: [{ columnName: "ID", dataType: "NUMBER", rowCount: 101, generatorKey: "sequence", generatorParams: { startValue: 1, increment: 1 } }], + }, + ], + options: { continueOnError: false, truncate: false, useTransaction: false, extendedInsert: true, timeoutSecs: 0, batchRows: 101 }, + }, + { invalidateMetadataCache: vi.fn() }, + ); + await vi.waitFor(() => expect(session.status).toBe("completed")); + expect(mocks.executeMultiWithProgress).toHaveBeenCalledOnce(); + expect(mocks.executeMultiWithProgress.mock.calls[0]![2].match(/INSERT ALL/g)).toHaveLength(2); + expect(session.results[0]).toMatchObject({ table: "Items", total: 101, ok: 100, err: 1, error: "duplicate key" }); + const task = useExportTracker().tasks.value.find((candidate) => candidate.exportId === session.id)!; + expect(task.status).toBe("Error"); + expect(task.rowsExported).toBe(100); + }); + it("retains per-table failures in the resumable result", async () => { mocks.executeMultiWithProgress.mockResolvedValueOnce([ { diff --git a/apps/desktop/src/lib/dataGrid/dataGenerate.ts b/apps/desktop/src/lib/dataGrid/dataGenerate.ts index c8ded34001..c039a16808 100644 --- a/apps/desktop/src/lib/dataGrid/dataGenerate.ts +++ b/apps/desktop/src/lib/dataGrid/dataGenerate.ts @@ -2016,7 +2016,7 @@ export function formatGeneratedRowValues(config: TableGenerateConfig, databaseTy * on backends that report failures inside the result array instead of throwing. */ export function generateInsertBatches(databaseType: DatabaseType | undefined, state: TableGenerateChunkState, valueRows: string[], forceSingleRow = false): { statements: string[]; rowsPerStatement: number[] } { - if (databaseType === "oracle") { + if (databaseType === "oracle" || databaseType === "oceanbase-oracle") { const statements = buildOracleInsertStatements(state.targetTable, state.columnList, valueRows); return { statements, rowsPerStatement: oracleRowsPerStatement(valueRows, statements) }; } diff --git a/packages/app-tests/dataGenerateInsertAccounting.test.ts b/packages/app-tests/dataGenerateInsertAccounting.test.ts index 81f6b8f812..20667feeb4 100644 --- a/packages/app-tests/dataGenerateInsertAccounting.test.ts +++ b/packages/app-tests/dataGenerateInsertAccounting.test.ts @@ -1,7 +1,7 @@ import { strict as assert } from "node:assert"; import { test } from "vitest"; import { BackendErrorException } from "../../apps/desktop/src/lib/backend/errorUtils.ts"; -import { createTableGenerateState, generateInsertBatches, type TableGenerateConfig } from "../../apps/desktop/src/lib/dataGrid/dataGenerate.ts"; +import { createTableGenerateState, formatGeneratedRowValues, generateInsertBatches, generateTableData, supportsGeneratedMultiRowValues, type TableGenerateConfig } from "../../apps/desktop/src/lib/dataGrid/dataGenerate.ts"; import { errorMessage, isQueryCanceledError, summarizeBatchResults } from "../../apps/desktop/src/lib/dataGrid/generateInsertAccounting.ts"; import type { QueryResult } from "../../apps/desktop/src/types/database.ts"; @@ -62,6 +62,61 @@ test("generateInsertBatches mirrors Oracle INSERT ALL chunking", () => { assert.deepEqual(single.rowsPerStatement, [1]); }); +test("OceanBase Oracle generation batches 0/1/2/100/101 rows with matching statement accounting", () => { + for (const [count, expectedCounts] of [ + [0, []], + [1, [1]], + [2, [2]], + [100, [100]], + [101, [100, 1]], + ] as const) { + const config = makeConfig(count); + const generated = generateTableData(config, "oceanbase-oracle"); + const generationState = createTableGenerateState(config, "oceanbase-oracle"); + const formattedRows = generated.rows.map((row) => formatGeneratedRowValues(config, "oceanbase-oracle", generationState, row)); + const batches = generateInsertBatches("oceanbase-oracle", generationState, formattedRows); + assert.equal(generated.rows.length, count); + assert.deepEqual(generated.statements, batches.statements); + assert.deepEqual(batches.rowsPerStatement, expectedCounts); + assert.equal(generated.statements.length, expectedCounts.length); + for (const [index, sql] of generated.statements.entries()) { + if (count === 1) assert.match(sql, /^INSERT INTO /); + else { + assert.match(sql, /^INSERT ALL\n/); + assert.match(sql, /SELECT 1 FROM DUAL;$/); + assert.equal(sql.match(/\n INTO /g)?.length, expectedCounts[index]); + } + assert.doesNotMatch(sql, /VALUES\s*\([^;]+\),\s*\(/s); + } + } + assert.equal(supportsGeneratedMultiRowValues("oceanbase-oracle"), false); +}); + +test("OceanBase Oracle generation preserves quoted names and typed temporal and null values", () => { + const generated = generateTableData( + { + tableName: "Items", + schema: "APP", + database: "oracletest", + rowCount: 2, + columns: [ + { columnName: "ID", dataType: "NUMBER", rowCount: 2, generatorKey: "sequence", generatorParams: { startValue: 1, increment: 1 } }, + { columnName: "Name", dataType: "VARCHAR2(30)", rowCount: 2, generatorKey: "enum", generatorParams: { values: "O'Brien" } }, + { columnName: "CREATED", dataType: "DATE", rowCount: 2, generatorKey: "enum", generatorParams: { values: "2026-10-09" } }, + { columnName: "STAMP", dataType: "TIMESTAMP(6)", rowCount: 2, generatorKey: "enum", generatorParams: { values: "2026-10-09 12:34:56" } }, + { columnName: "EMPTY", dataType: "VARCHAR2(30)", rowCount: 2, generatorKey: "enum", generatorParams: { values: "unused", includeNull: true, nullPercent: 100 } }, + ], + }, + "oceanbase-oracle", + ); + assert.equal(generated.statements.length, 1); + assert.match(generated.sql, /INTO "APP"\."Items" \("ID", "Name", "CREATED", "STAMP", "EMPTY"\)/); + assert.match(generated.sql, /'O''Brien'/); + assert.match(generated.sql, /TO_DATE\('2026-10-09', 'YYYY-MM-DD'\)/); + assert.match(generated.sql, /TO_TIMESTAMP\('2026-10-09 12:34:56', 'YYYY-MM-DD HH24:MI:SS'\)/); + assert.match(generated.sql, /, NULL\)/); +}); + test("generateInsertBatches returns nothing for an empty batch", () => { const batches = generateInsertBatches("mysql", state("mysql", 0), []); assert.deepEqual(batches.statements, []); @@ -78,10 +133,7 @@ test("summarizeBatchResults counts every row when all statements succeed", () => test("summarizeBatchResults ignores failed statements that do not throw", () => { // Sequential executor and the MySQL batch route resolve with the failure // recorded in the result array instead of rejecting. - const outcome = summarizeBatchResults( - [result({ affected: 2 }), result({ executionError: true, error: { detail: "Duplicate entry" } }), result({ affected: 1 })], - [2, 5, 1], - ); + const outcome = summarizeBatchResults([result({ affected: 2 }), result({ executionError: true, error: { detail: "Duplicate entry" } }), result({ affected: 1 })], [2, 5, 1]); assert.equal(outcome.insertedRows, 3); assert.equal(outcome.failed, true); assert.equal(outcome.error, "Duplicate entry"); @@ -127,7 +179,7 @@ test("isQueryCanceledError recognizes the backend cancellation message", () => { }); test("isQueryCanceledError does not treat SQL failures as cancellations", () => { - assert.equal(isQueryCanceledError(new Error("relation \"users\" does not exist")), false); + assert.equal(isQueryCanceledError(new Error('relation "users" does not exist')), false); assert.equal(isQueryCanceledError(new Error("Deadlock found when trying to get lock")), false); assert.equal(isQueryCanceledError(new BackendErrorException({ message: "Syntax error" })), false); });