mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
fix(export): keep LOB snapshot formatting and Oracle SQL raw values
This commit is contained in:
@@ -190,7 +190,7 @@ describe("original LOB result exports", () => {
|
||||
setActivePinia(createPinia());
|
||||
vi.mocked(exportSnapshotResult).mockResolvedValue(undefined);
|
||||
});
|
||||
function snapshotState() {
|
||||
function snapshotState(overrides: Partial<UseDataGridExportOptions> = {}) {
|
||||
const original = {
|
||||
columns: ["id", "name"],
|
||||
rows: [[1, "preview"]],
|
||||
@@ -204,6 +204,8 @@ describe("original LOB result exports", () => {
|
||||
snapshotResult: computed(() => original),
|
||||
hasCompleteLocalResult: computed(() => true),
|
||||
completeLocalResult: computed(() => original),
|
||||
displayItems: computed(() => original.rows.map((data, sourceIndex) => ({ ...row(data), id: sourceIndex + 1, sourceIndex }))),
|
||||
...overrides,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -223,6 +225,127 @@ describe("original LOB result exports", () => {
|
||||
expect(exportQueryResultCsv).not.toHaveBeenCalled();
|
||||
expect(exportQueryResultJson).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each(["csv", "json"] as const)("formats the complete LOB before exporting %s", async (format) => {
|
||||
const displayValue = vi.fn((value: CellValue) => `formatted:${value}`);
|
||||
const { state } = snapshotState({
|
||||
displayValue,
|
||||
hasColumnFormatter: (index) => index === 1,
|
||||
applyColumnFormattersOnExport: computed(() => true),
|
||||
});
|
||||
vi.mocked(readLargeValueChunk).mockResolvedValue({ status: "ok", data: "complete original text", next_offset: 22, eof: true, value_kind: "text" });
|
||||
await (format === "csv" ? state.exportCsv() : state.exportJson());
|
||||
expect(displayValue).toHaveBeenCalledWith("complete original text", 1);
|
||||
expect(exportSnapshotResult).toHaveBeenCalledWith(expect.objectContaining({ format, rows: [[1, "formatted:complete original text"]], cells: [] }), expect.any(String), expect.any(AbortSignal));
|
||||
});
|
||||
it.each([
|
||||
["csv", "all"],
|
||||
["csv", "selected"],
|
||||
["csv", "page"],
|
||||
["json", "all"],
|
||||
["json", "selected"],
|
||||
["json", "page"],
|
||||
] as const)("preserves mixed formatted and streaming LOBs in %s %s export", async (format, scope) => {
|
||||
const { original, state } = snapshotState({
|
||||
columns: computed(() => ["id", "name", "binary"]),
|
||||
columnTypes: computed(() => ["NUMBER", "CLOB", "BLOB"]),
|
||||
displayValue: (value) => `formatted:${value}`,
|
||||
hasColumnFormatter: (index) => index === 1,
|
||||
applyColumnFormattersOnExport: computed(() => true),
|
||||
});
|
||||
original.columns.push("binary");
|
||||
original.rows = [
|
||||
[1, "preview", "0x00"],
|
||||
[2, null, null],
|
||||
[3, "", "0x"],
|
||||
];
|
||||
original.large_value_cells!.push({ row_index: 0, column_index: 2, original_bytes: 1000, value_ref: "binary-locator" });
|
||||
vi.mocked(readLargeValueChunk).mockResolvedValue({ status: "ok", data: "complete original text", next_offset: 22, eof: true, value_kind: "text" });
|
||||
if (scope === "page") await (format === "csv" ? state.exportCurrentPageCsv() : state.exportCurrentPageJson());
|
||||
else await (format === "csv" ? state.exportCsv(scope === "selected" ? [1, 2, 3] : undefined) : state.exportJson(scope === "selected" ? [1, 2, 3] : undefined));
|
||||
expect(readLargeValueChunk).toHaveBeenCalledOnce();
|
||||
expect(readLargeValueChunk).toHaveBeenCalledWith(expect.objectContaining({ valueRef: "original-locator" }));
|
||||
expect(exportSnapshotResult).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
rows: [
|
||||
[1, "formatted:complete original text", "0x00"],
|
||||
[2, null, null],
|
||||
[3, "formatted:", "0x"],
|
||||
],
|
||||
cells: [{ rowIndex: 0, columnIndex: 2, valueRef: "binary-locator" }],
|
||||
}),
|
||||
expect.any(String),
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
});
|
||||
it.each([false, true])("keeps unformatted snapshot LOBs streaming when formatter enabled=%s", async (enabled) => {
|
||||
const { state } = snapshotState({
|
||||
displayValue: (value) => `formatted:${value}`,
|
||||
hasColumnFormatter: (index) => index === (enabled ? 9 : 1),
|
||||
applyColumnFormattersOnExport: computed(() => enabled),
|
||||
});
|
||||
await state.exportJson();
|
||||
expect(readLargeValueChunk).not.toHaveBeenCalled();
|
||||
expect(exportSnapshotResult).toHaveBeenCalledWith(expect.objectContaining({ rows: [[1, "preview"]], cells: [{ rowIndex: 0, columnIndex: 1, valueRef: "original-locator" }] }), expect.any(String), expect.any(AbortSignal));
|
||||
});
|
||||
it("formats complete binary values using source indexes after column projection", async () => {
|
||||
const displayValue = vi.fn((value: CellValue, index: number) => `${index}:${value}`);
|
||||
const { original, state } = snapshotState({
|
||||
columns: computed(() => ["binary", "id"]),
|
||||
columnTypes: computed(() => ["BLOB", "NUMBER"]),
|
||||
visibleColumnIndexes: computed(() => [2, 0]),
|
||||
displayValue,
|
||||
hasColumnFormatter: (index) => index === 2,
|
||||
applyColumnFormattersOnExport: computed(() => true),
|
||||
});
|
||||
original.columns = ["id", "__DBX_PK_0", "binary"];
|
||||
original.rows = [[1, "hidden", "0x00"]];
|
||||
original.hidden_column_indexes = [1];
|
||||
original.large_value_cells = [{ row_index: 0, column_index: 2, original_bytes: 4, value_ref: "binary-locator" }];
|
||||
vi.mocked(readLargeValueChunk).mockResolvedValue({ status: "ok", data: "00ff0102", next_offset: 4, eof: true, value_kind: "binary" });
|
||||
await state.exportCsv({ columnIndexes: [0] });
|
||||
expect(displayValue).toHaveBeenCalledWith("0x00ff0102", 2);
|
||||
expect(exportSnapshotResult).toHaveBeenCalledWith(expect.objectContaining({ columns: ["binary"], rows: [["2:0x00ff0102"]], cells: [] }), expect.any(String), expect.any(AbortSignal));
|
||||
});
|
||||
it.each(["cancel", "replacement", "disposal"] as const)("blocks formatted snapshot output after %s while a chunk is pending", async (change) => {
|
||||
const scope = effectScope();
|
||||
const cancelHandler = ref<(() => Promise<void>) | null>(null);
|
||||
const current = ref<QueryResult>();
|
||||
const { original, state } = scope.run(() =>
|
||||
snapshotState({
|
||||
snapshotResult: computed(() => current.value!),
|
||||
displayValue: (value) => `formatted:${value}`,
|
||||
hasColumnFormatter: (index) => index === 1,
|
||||
applyColumnFormattersOnExport: computed(() => true),
|
||||
exportCancelHandler: cancelHandler,
|
||||
}),
|
||||
)!;
|
||||
current.value = original;
|
||||
let deliver!: (chunk: Awaited<ReturnType<typeof readLargeValueChunk>>) => void;
|
||||
vi.mocked(readLargeValueChunk).mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
deliver = resolve;
|
||||
}),
|
||||
);
|
||||
vi.mocked(cancelQuery).mockRejectedValue(new Error("cancel unavailable"));
|
||||
let settled = false;
|
||||
const pending = state.exportJson().finally(() => {
|
||||
settled = true;
|
||||
});
|
||||
try {
|
||||
await vi.waitFor(() => expect(readLargeValueChunk).toHaveBeenCalledOnce());
|
||||
if (change === "cancel") await cancelHandler.value!().catch(() => undefined);
|
||||
else if (change === "replacement") current.value = { ...original, rows: [[2, "new result"]] };
|
||||
else scope.stop();
|
||||
await vi.waitFor(() => expect(settled).toBe(true));
|
||||
deliver({ status: "ok", data: "late", next_offset: 4, eof: true, value_kind: "text" });
|
||||
await pending;
|
||||
expect(exportSnapshotResult).not.toHaveBeenCalled();
|
||||
expect(original.rows).toEqual([[1, "preview"]]);
|
||||
} finally {
|
||||
scope.stop();
|
||||
}
|
||||
});
|
||||
it.each(["csv", "json"] as const)("preserves hidden-key filtering and the row limit for snapshot %s", async (format) => {
|
||||
const { state, original } = snapshotState();
|
||||
original.columns.push("__DBX_PK_0");
|
||||
|
||||
@@ -786,7 +786,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
||||
const resolvedValue = externalValue === undefined ? value : externalValue;
|
||||
if (resolvedValue !== null && resolvedValue !== undefined && options.displayValue) {
|
||||
const resolvedSourceIndex = sourceIndex ?? visibleColumnIndexes.value[columnIndex] ?? columnIndex;
|
||||
if (isOracleTemporalColumn(columnIndex, resolvedSourceIndex)) {
|
||||
if (applyFormatters && isOracleTemporalColumn(columnIndex, resolvedSourceIndex)) {
|
||||
return options.displayValue(resolvedValue, resolvedSourceIndex);
|
||||
}
|
||||
if (applyFormatters && shouldApplyColumnFormatter(resolvedSourceIndex)) {
|
||||
@@ -1064,17 +1064,16 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
||||
const editorSettings = useSettingsStore().editorSettings;
|
||||
const originalRows = editorSettings.exportRowLimitEnabled ? original.rows.slice(0, editorSettings.exportRowLimit) : original.rows;
|
||||
const items = exportAll
|
||||
? originalRows.map((row, sourceIndex) => ({ sourceIndex, data: sourceIndexes.map((index) => externalCellValue(row[index], index, index, true, true)) }))
|
||||
? originalRows.map((row, sourceIndex) => ({ sourceIndex, data: sourceIndexes.map((index) => row[index]) }))
|
||||
: rowsToExport(rowIds).map((item) => ({
|
||||
sourceIndex: item.sourceIndex,
|
||||
isNew: item.isNew,
|
||||
data: externalizeRows([item.data], indexes, true, sourceIndexes)[0]!,
|
||||
data: (indexes ?? columns.value.map((_, index) => index)).map((index) => item.data[index]),
|
||||
isDirtyCol: (indexes ?? columns.value.map((_, index) => index)).map((index) => item.isDirtyCol[index] ?? false),
|
||||
}));
|
||||
const selected = snapshotExportSelection(original, names, sourceIndexes, items);
|
||||
if (!selected) return false;
|
||||
const types = sourceIndexes.map((index) => original.column_types?.[index] ?? "");
|
||||
selected.rows = applyGlobalDateTimeExportFormat({ columns: names, columnTypes: types, rows: selected.rows as CellValue[][] }, true, sourceIndexes).rows;
|
||||
let outputPath = exportFileName(full ? "export" : "export-page", format, { page: !full });
|
||||
if (isTauriRuntime()) {
|
||||
const path = await promptExportSavePath({ defaultFileName: outputPath, filters: [{ name: FORMAT_META[format]!.label, extensions: [format] }], preferredPath: useSettingsStore().editorSettings.preferredExportPath });
|
||||
@@ -1116,6 +1115,24 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
||||
},
|
||||
);
|
||||
try {
|
||||
const formattedCells = selected.cells.filter((cell) => shouldApplyColumnFormatter(sourceIndexes[cell.columnIndex]!));
|
||||
if (formattedCells.length) {
|
||||
selected.rows = await materializeSnapshotResultRows(
|
||||
{
|
||||
...original,
|
||||
rows: selected.rows as CellValue[][],
|
||||
large_value_cells: formattedCells.map((cell) => ({ ...original.large_value_cells!.find((source) => source.value_ref === cell.valueRef)!, row_index: cell.rowIndex, column_index: cell.columnIndex })),
|
||||
},
|
||||
isCurrent,
|
||||
controller.signal,
|
||||
executionId,
|
||||
);
|
||||
selected.cells = selected.cells.filter((cell) => !formattedCells.includes(cell));
|
||||
}
|
||||
selected.rows = selected.rows.map((row) => row.map((value, columnIndex) => externalCellValue(value as CellValue, exportAll ? sourceIndexes[columnIndex]! : (indexes?.[columnIndex] ?? columnIndex), sourceIndexes[columnIndex], true, !!exportAll)));
|
||||
selected.rows = applyGlobalDateTimeExportFormat({ columns: names, columnTypes: types, rows: selected.rows as CellValue[][] }, true, sourceIndexes).rows;
|
||||
controller.signal.throwIfAborted();
|
||||
if (!isCurrent()) throw new Error("LOB result context changed");
|
||||
await waitForLargeValueSnapshot(
|
||||
api.exportSnapshotResult({ ...selected, context: { ...selected.context, executionId }, format, quoteMode: useSettingsStore().editorSettings.csvQuoteMode, nullLiteral: csvNullLiteralForMode(useSettingsStore().editorSettings.csvNullMode) }, outputPath, controller.signal),
|
||||
controller.signal,
|
||||
|
||||
@@ -982,7 +982,17 @@ fn format_oracle_export_temporal_literal(
|
||||
"timestamptz_dty" => ExportTemporalKind::DateTimeWithTimeZone,
|
||||
_ => export_temporal_column_kind(database_type, column_type)?,
|
||||
};
|
||||
let parts = parse_export_date_parts(value.as_str()?)?;
|
||||
let text = value.as_str()?;
|
||||
let Some(parts) = parse_export_date_parts(text) else {
|
||||
let (datetime, _) = text.rsplit_once(' ')?;
|
||||
parse_export_local_temporal_parts(datetime)?;
|
||||
let normalized_type = match base {
|
||||
"timestampdty" => "TIMESTAMP",
|
||||
"timestamptz_dty" => "TIMESTAMP WITH TIME ZONE",
|
||||
_ => column_type,
|
||||
};
|
||||
return crate::data_grid_sql::format_oracle_temporal_literal(text, Some(normalized_type));
|
||||
};
|
||||
if base == "date" {
|
||||
return Some(format_oracle_export_date_parts_literal(&parts));
|
||||
}
|
||||
@@ -1605,6 +1615,11 @@ pub(crate) fn build_export_insert_statements_excluding_with_dialect(
|
||||
insert_sql.push(if *row_count > 1 { '\n' } else { ' ' });
|
||||
insert_sql.push_str(values);
|
||||
insert_sql.push(';');
|
||||
if output_database_type == Some(DatabaseType::OceanbaseOracle) {
|
||||
if let Some(statement) = crate::data_grid_sql::oceanbase_blob_statement(&insert_sql, false) {
|
||||
insert_sql = statement;
|
||||
}
|
||||
}
|
||||
if needs_identity_insert_wrapper {
|
||||
statements.push(wrap_dameng_identity_insert_sql_for_table(&insert_sql, &table));
|
||||
} else {
|
||||
@@ -1645,6 +1660,10 @@ pub(crate) fn build_export_insert_statements_excluding_with_dialect(
|
||||
};
|
||||
let literal = if insert_dialect == SqlInsertDialect::Standard {
|
||||
format_standard_sql_literal(value)
|
||||
} else if let Some(literal) =
|
||||
crate::data_grid_sql::format_oracle_lob_export_literal(value, options.database_type, column_type)?
|
||||
{
|
||||
literal
|
||||
} else {
|
||||
format_export_sql_literal_typed_with_spatial(
|
||||
value,
|
||||
@@ -5892,7 +5911,9 @@ mod tests {
|
||||
|
||||
assert_eq!(
|
||||
statements,
|
||||
vec!["INSERT INTO `places` (`location`, `shape`) VALUES (ST_GeomFromWKB(0x0101000000AE47E17A14AE5C4052B81E85EBF34240, 4326), ST_GeomFromWKB(0x0101000000000000000000F03F0000000000000040));"]
|
||||
vec![
|
||||
"INSERT INTO `places` (`location`, `shape`) VALUES (ST_GeomFromWKB(0x0101000000AE47E17A14AE5C4052B81E85EBF34240, 4326), ST_GeomFromWKB(0x0101000000000000000000F03F0000000000000040));"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7005,6 +7026,108 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oracle_character_lob_exports_distinguish_empty_from_null() {
|
||||
for database_type in ["oracle", "oceanbase-oracle"] {
|
||||
let options: BuildExportSqlInsertOptions = serde_json::from_value(json!({
|
||||
"databaseType": database_type, "tableName": "LOB_VALUES",
|
||||
"columns": ["C", "N", "TXT"], "columnTypes": ["CLOB", "NCLOB", "VARCHAR2"],
|
||||
"rows": [["", "", ""], [null, null, null], [" ", " ", " "]]
|
||||
}))
|
||||
.unwrap();
|
||||
let sql = build_export_sql_insert(options.clone()).unwrap();
|
||||
assert!(sql.contains("VALUES (EMPTY_CLOB(), EMPTY_CLOB(), '')"), "{database_type}: {sql}");
|
||||
assert!(sql.contains("VALUES (NULL, NULL, NULL)"), "{database_type}: {sql}");
|
||||
assert!(sql.contains("VALUES (' ', ' ', ' ')"), "{database_type}: {sql}");
|
||||
let mut standard = options;
|
||||
standard.insert_dialect = SqlInsertDialect::Standard;
|
||||
let sql = build_export_sql_insert(standard).unwrap();
|
||||
assert!(sql.contains("('', '', '')"), "{database_type}: {sql}");
|
||||
assert!(!sql.contains("EMPTY_CLOB"), "{database_type}: {sql}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oceanbase_result_export_preserves_lobs_and_driver_timezone() {
|
||||
let statements = build_export_insert_statements(BuildExportInsertStatementsOptions {
|
||||
database_type: Some(DatabaseType::OceanbaseOracle),
|
||||
identifier_quote: None,
|
||||
schema: None,
|
||||
table_name: Some("LOB_VALUES".to_string()),
|
||||
qualified_table_name: None,
|
||||
columns: vec!["C".into(), "B".into(), "TZ".into()],
|
||||
column_types: vec![Some("CLOB".into()), Some("BLOB".into()), Some("TIMESTAMP(9) WITH TIME ZONE".into())],
|
||||
column_extras: Vec::new(),
|
||||
spatial_columns: Vec::new(),
|
||||
spatial_values: Vec::new(),
|
||||
rows: vec![
|
||||
vec![json!("text'完整"), json!("0x00ff0102"), json!("2026-10-13 12:34:56.123456789 +8:00")],
|
||||
vec![json!(""), json!("0x"), json!("2026-10-13 00:00:00.000000001 -5:30")],
|
||||
vec![Value::Null, Value::Null, Value::Null],
|
||||
],
|
||||
batch_size: Some(1),
|
||||
preserve_original_language: false,
|
||||
})
|
||||
.unwrap();
|
||||
assert!(statements[0].contains("TO_BLOB(HEXTORAW('00ff0102'))"), "{}", statements[0]);
|
||||
assert!(statements[0].contains("TO_TIMESTAMP_TZ('2026-10-13 12:34:56.123456789 +08:00'"), "{}", statements[0]);
|
||||
assert!(statements[1].contains("EMPTY_CLOB()"), "{}", statements[1]);
|
||||
assert!(statements[1].contains("EMPTY_BLOB()"), "{}", statements[1]);
|
||||
assert!(statements[1].contains("TO_TIMESTAMP_TZ('2026-10-13 00:00:00.000000001 -05:30'"), "{}", statements[1]);
|
||||
assert!(statements[2].contains("VALUES (NULL, NULL, NULL)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oceanbase_large_lob_export_uses_existing_chunked_statements() {
|
||||
let text = format!("{}尾部", "中文'".repeat(2000));
|
||||
let hex = "00ff0102".repeat(1024);
|
||||
let options: BuildExportSqlInsertOptions = serde_json::from_value(json!({
|
||||
"databaseType": "oceanbase-oracle", "tableName": "LOB_VALUES",
|
||||
"columns": ["C", "B", "TXT"], "columnTypes": ["CLOB", "BLOB", "VARCHAR2"],
|
||||
"rows": [[text, format!("0x{hex}"), "TO_BLOB(HEXTORAW('fake'))"],
|
||||
["", format!("0x{hex}"), "0x00ff"], [null, null, null]]
|
||||
}))
|
||||
.unwrap();
|
||||
let sql = build_export_sql_insert(options).unwrap();
|
||||
let statements = crate::sql::split_sql_statements_for_database(&sql, DatabaseType::OceanbaseOracle);
|
||||
assert_eq!(statements.len(), 3);
|
||||
for statement in &statements[..2] {
|
||||
assert!(statement.starts_with("DECLARE"));
|
||||
assert!(statement.contains("EXECUTE IMMEDIATE dbx_sql USING dbx_lob_0"));
|
||||
assert_eq!(statement.matches("FREETEMPORARY(dbx_lob_0)").count(), 2);
|
||||
let chunks = statement
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
line.strip_prefix("DBMS_LOB.WRITEAPPEND(dbx_lob_0, ")?
|
||||
.split_once("HEXTORAW('")
|
||||
.map(|(_, hex)| hex.trim_end_matches("'));").to_string())
|
||||
})
|
||||
.collect::<String>();
|
||||
assert_eq!(chunks, hex);
|
||||
}
|
||||
assert!(statements[0].contains("尾部"));
|
||||
assert!(statements[0].contains("TO_BLOB(HEXTORAW(''''fake''''))"));
|
||||
assert!(statements[1].contains("EMPTY_CLOB()"));
|
||||
assert!(statements[2].contains("VALUES (NULL, NULL, NULL)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oceanbase_lob_export_validates_only_source_exported_columns() {
|
||||
let options: BuildExportSqlInsertOptions = serde_json::from_value(json!({
|
||||
"databaseType": "oceanbase-oracle", "tableName": "LOB_VALUES",
|
||||
"columns": ["B", "TXT"], "columnTypes": ["BLOB", "VARCHAR2"],
|
||||
"rows": [["0xnot-hex", "0xnot-hex"]]
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(build_export_sql_insert(options.clone()).unwrap_err().contains("hexadecimal byte pairs"));
|
||||
let mut excluded = options.clone();
|
||||
excluded.exclude_columns = vec!["B".into()];
|
||||
assert!(build_export_sql_insert(excluded).unwrap().contains("(\"TXT\") VALUES ('0xnot-hex')"));
|
||||
let mut standard = options;
|
||||
standard.insert_dialect = SqlInsertDialect::Standard;
|
||||
assert!(build_export_sql_insert(standard).unwrap().contains("'0xnot-hex', '0xnot-hex'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_oracle_timestamp_exports_keep_existing_literals() {
|
||||
let statements = build_export_insert_statements(BuildExportInsertStatementsOptions {
|
||||
@@ -8263,7 +8386,9 @@ mod tests {
|
||||
assert!(!ddl.contains("OWNED BY"));
|
||||
assert_eq!(
|
||||
generate_postgres_sequence_setval_sql(&sequence, "public").as_deref(),
|
||||
Some("SELECT setval(pg_get_serial_sequence('\"public\".\"permissions\"', 'id'), GREATEST(COALESCE(MAX(\"id\"), 42), 42), true) FROM \"public\".\"permissions\"")
|
||||
Some(
|
||||
"SELECT setval(pg_get_serial_sequence('\"public\".\"permissions\"', 'id'), GREATEST(COALESCE(MAX(\"id\"), 42), 42), true) FROM \"public\".\"permissions\""
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2925,7 +2925,7 @@ fn is_oracle_temporal_literal_database(database_type: Option<DatabaseType>) -> b
|
||||
matches!(database_type, Some(DatabaseType::Oracle | DatabaseType::OceanbaseOracle))
|
||||
}
|
||||
|
||||
fn format_oracle_temporal_literal(text: &str, data_type: Option<&str>) -> Option<String> {
|
||||
pub fn format_oracle_temporal_literal(text: &str, data_type: Option<&str>) -> Option<String> {
|
||||
let kind = oracle_temporal_column_kind(data_type?)?;
|
||||
let parts = regex_like_oracle_temporal(text)?;
|
||||
let fraction = parts.fraction.as_deref().unwrap_or_default();
|
||||
@@ -3726,6 +3726,39 @@ fn oceanbase_blob_hex(value: &Value) -> Option<&str> {
|
||||
(hex.len() % 2 == 0 && hex.bytes().all(|byte| byte.is_ascii_hexdigit())).then_some(hex)
|
||||
}
|
||||
|
||||
/// Reuse grid LOB constructors without applying save-time transaction or row guards.
|
||||
pub fn format_oracle_lob_export_literal(
|
||||
value: &Value,
|
||||
database_type: Option<DatabaseType>,
|
||||
data_type: Option<&str>,
|
||||
) -> Result<Option<String>, String> {
|
||||
if !is_oracle_temporal_literal_database(database_type) {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(data_type) = data_type else { return Ok(None) };
|
||||
let blob = database_type == Some(DatabaseType::OceanbaseOracle) && data_type.trim().eq_ignore_ascii_case("BLOB");
|
||||
if !blob && oracle_character_lob_constructor(data_type).is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
if !blob && value.as_str() == Some("") {
|
||||
return Ok(Some("EMPTY_CLOB()".to_string()));
|
||||
}
|
||||
if blob && !value.is_null() && oceanbase_blob_hex(value).is_none() {
|
||||
return Err(
|
||||
"OceanBase BLOB export requires NULL or 0x followed by complete hexadecimal byte pairs.".to_string()
|
||||
);
|
||||
}
|
||||
let column = DataGridColumnInfo {
|
||||
name: String::new(),
|
||||
data_type: data_type.to_string(),
|
||||
is_nullable: true,
|
||||
is_primary_key: false,
|
||||
column_default: None,
|
||||
extra: None,
|
||||
};
|
||||
Ok(Some(format_grid_assignment_sql_literal(value, database_type, Some(&column), None)))
|
||||
}
|
||||
|
||||
fn validate_oceanbase_blob_save(options: &DataGridSaveStatementOptions) -> Option<String> {
|
||||
if options.database_type != Some(DatabaseType::OceanbaseOracle) {
|
||||
return None;
|
||||
@@ -3761,7 +3794,7 @@ fn validate_oceanbase_blob_save(options: &DataGridSaveStatementOptions) -> Optio
|
||||
|
||||
// Only rewrite our generated BLOB literal outside quoted strings/identifiers. The SQL text
|
||||
// uses binds so temporary PL variables cannot resolve to an identically named table column.
|
||||
fn oceanbase_blob_statement(sql: &str, guard_row_count: bool) -> Option<String> {
|
||||
pub fn oceanbase_blob_statement(sql: &str, guard_row_count: bool) -> Option<String> {
|
||||
let (rewritten, blobs) = oceanbase_blob_literals(sql, false);
|
||||
if blobs.is_empty() {
|
||||
return None;
|
||||
|
||||
@@ -811,7 +811,7 @@ pub(super) fn build_saphana_existing_column_sql(table: &str, column: &EditableSt
|
||||
if type_changed || nullable_changed || default_changed {
|
||||
let mut parts = vec![
|
||||
quote_ident(StructureDialect::SapHana, ¤t_name),
|
||||
column_data_type(StructureDialect::SapHana, column),
|
||||
column_data_type(StructureDialect::SapHana, column, None),
|
||||
];
|
||||
let default_value = normalize_default(Some(&column.default_value));
|
||||
if !default_value.is_empty() {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 85 KiB |
@@ -1,6 +1,6 @@
|
||||
# OceanBase Oracle LOB regression acceptance
|
||||
|
||||
This batch covers result-snapshot CLOB/BLOB reading, writing, copying and exporting. Target database acceptance uses OceanBase Oracle 4.2.5.7 and Connector/J 2.4.18. Automated tests with JDBC and transport fixtures do not establish support on other database versions.
|
||||
This batch covers result-snapshot CLOB/BLOB reading, writing, copying and exporting. Combination acceptance uses OceanBase Oracle 4.2.5.7 and Connector/J 2.4.19; the earlier locator acceptance used Connector/J 2.4.18. Automated tests with JDBC and transport fixtures do not establish support on other database versions.
|
||||
|
||||
## Reproduction and expected behavior
|
||||
|
||||
@@ -13,16 +13,23 @@ Use an authorized isolated table with a primary key, CLOB, BLOB and nullable tem
|
||||
5. Close the result tab or disconnect one connection while a query is pending. Its result and client sessions must be released; other connections must remain usable. A late result must not restore the closed tab or its runtime state.
|
||||
6. Hold the original session's registry, transaction or Agent lock, then cancel a LOB request. Core must finish local waiting and remove its task registration. Missing original pools, wrong client sessions and metadata-only pools must not create replacement locator sessions.
|
||||
7. Render and export NULL date/time cells and explicit timezone values. NULL stays NULL; SQL extraction preserves the explicit timezone information.
|
||||
8. Apply a JSON-path formatter whose value appears after the CLOB preview. Export the current page, selected cells and all loaded rows to CSV/JSON/XLSX with formatters enabled and disabled. Formatting must receive the full selected value; unformatted BLOBs must still stream from their original references. Keep NULL and empty LOBs distinct. An XLSX cell exceeding 32,767 characters must fail explicitly instead of truncating.
|
||||
9. Export source-dialect SQL with formatting enabled in the grid, then replay the downloaded file into a separate isolated table. Compare complete CLOB/BLOB content, NULL versus empty LOBs, nine fractional timestamp digits and explicit offsets, including the driver's space-separated `+8:00` and `-5:30` forms. Include a BLOB larger than 2,000 bytes and a CLOB containing Unicode and quotes beyond one SQL literal chunk. SQL export must use original values rather than display formatting.
|
||||
|
||||
## Automated coverage
|
||||
|
||||
- Frontend tests call the real editor, exporter, large-value composable, snapshot helpers and query/connection stores, with external backend calls controlled by fixtures.
|
||||
- Agent tests cover chunk boundaries, retained locator lifetime, resource limits, typed BLOB bindings and RPC dispatch. Opt-in database tests must be reported as skipped when their environment is absent.
|
||||
- Core tests cover snapshot export, original-session lookup and cancellation while waiting for locks. Transport tests cover late browser-download responses.
|
||||
- SQL export tests use the production builders and script splitter. They cover Oracle-compatible temporal aliases, OceanBase LOB constructors and large-BLOB blocks, malformed hex, excluded columns, ordinary text and Standard-dialect compatibility. Large-BLOB blocks reuse the grid's existing temporary-LOB cleanup; they do not add grid-save row guards or transaction handling to export.
|
||||
- Tests, type checking, compilation, database acceptance and GUI acceptance are separate evidence layers. Record the exact candidate and results in the PR.
|
||||
|
||||
## Limits and remaining acceptance
|
||||
|
||||
Local cancellation settles client waiting. It does not prove database-side statement termination or physical server cursor release. Native delivery may already have persisted its destination before cancellation reaches the caller; browser late-response checks do not establish atomic native-file cancellation.
|
||||
|
||||
The independent candidate still requires database and GUI readback against its own verified build. Historical integration-worktree results are not current-candidate acceptance. Exercise success, error, cancellation, close/reopen and disconnect with the loaded Agent JAR hash recorded. Verify exact fixture cleanup separately, and keep native delivery, clipboard content and untested database versions explicitly unverified until checked.
|
||||
Formatting a LOB requires materializing its full value within the existing 16 MiB per-value and 64 MiB combined limits. Unformatted snapshot cells retain the streaming path. The OceanBase BLOB SQL constructors and block wrapper do not establish native Oracle BLOB support. Native Oracle database acceptance for the 2026-10-13 combination batch was explicitly deferred; retain that gap separately from OceanBase results.
|
||||
|
||||
The 2026-10-13 combination candidate was tested on upstream base `a86520234e6f5b25bd9de6f9adbff6bf73e46140` with its own Web build and Agent JAR. GUI-downloaded source SQL replayed through the production script endpoint preserved four rows, including a 36,028-character CLOB, a 4,000-byte BLOB, NULL/empty LOBs, nine fractional timestamp digits and positive/negative offsets. GUI CLOB/BLOB edits were read back independently; both isolated fixture tables were removed and absence was verified. The two opt-in snapshot integration tests passed without skips. Clipboard, CSV/JSON and formatted XLSX content were checked; oversized unformatted XLSX failed explicitly. Cancellation and late-result races have automated coverage, not a claim of database-side termination or native-file atomicity.
|
||||
|
||||

|
||||
|
||||
+34
@@ -592,6 +592,40 @@ test("local SQL export maps the selected mode to the shared formatter", async ()
|
||||
}
|
||||
});
|
||||
|
||||
test.each([
|
||||
["oracle", "all"],
|
||||
["oracle", "page"],
|
||||
["oceanbase-oracle", "all"],
|
||||
["oceanbase-oracle", "page"],
|
||||
] as const)("local %s %s SQL export preserves temporal precision and timezone", async (databaseType, scope) => {
|
||||
const download = installTextDownloadCapture();
|
||||
apiMock.buildExportSqlInsert.mockResolvedValueOnce("INSERT INTO fixture VALUES (1);");
|
||||
const timestamp = "2026-10-13T12:34:56.123456789+08:00";
|
||||
try {
|
||||
const completeLocalResult: QueryResult = {
|
||||
columns: ["id", "created_at"],
|
||||
column_types: ["NUMBER", "TIMESTAMP WITH TIME ZONE"],
|
||||
rows: [[1, timestamp]],
|
||||
affected_rows: 0,
|
||||
execution_time_ms: 1,
|
||||
truncated: false,
|
||||
has_more: false,
|
||||
};
|
||||
const { composable } = buildExportHarness({
|
||||
databaseType,
|
||||
columns: completeLocalResult.columns,
|
||||
columnTypes: completeLocalResult.column_types,
|
||||
rows: completeLocalResult.rows,
|
||||
completeLocalResult,
|
||||
displayValue: () => "2026-10-13 12:34:56",
|
||||
});
|
||||
await (scope === "all" ? composable.exportSql() : composable.exportCurrentPageSql());
|
||||
assert.deepEqual(apiMock.buildExportSqlInsert.mock.calls[0][0].rows, [[1, timestamp]]);
|
||||
} finally {
|
||||
download.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("local SQL export defaults to the shared batch formatter", async () => {
|
||||
const download = installTextDownloadCapture();
|
||||
apiMock.buildExportSqlInsert.mockResolvedValueOnce("INSERT INTO `users` (`id`, `name`) VALUES (1, 'Ada'), (2, 'Lin');");
|
||||
|
||||
Reference in New Issue
Block a user