feat(query): move grid save prep to Rust

This commit is contained in:
t8y2 committed 2026-05-22 15:31:50 +08:00
1 parent 56aeed79fc
commit 209eff528d
14 files changed
+1448 -1092

No files matched your search

@@ -1,12 +1,6 @@
import { ref, computed, nextTick, watch, onBeforeUnmount, type ComputedRef, type Ref } from "vue"; import { ref, computed, nextTick, watch, onBeforeUnmount, type ComputedRef, type Ref } from "vue";
import * as api from "@/lib/api"; import * as api from "@/lib/api";
import { import { normalizeDataGridSaveError } from "@/lib/dataGridSql";
buildDataGridRollbackStatements,
buildDataGridSaveStatements,
dataGridSaveExecutionSchema,
normalizeDataGridSaveError,
validateDataGridSave,
} from "@/lib/dataGridSql";
import { rowStatusFilterAfterAddingRow, type RowStatusFilter } from "@/lib/gridRowStatus"; import { rowStatusFilterAfterAddingRow, type RowStatusFilter } from "@/lib/gridRowStatus";
import { supportsDataGridTransaction } from "@/lib/tableEditing"; import { supportsDataGridTransaction } from "@/lib/tableEditing";
import { useConnectionStore } from "@/stores/connectionStore"; import { useConnectionStore } from "@/stores/connectionStore";
@@ -636,30 +630,28 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
} }
const stmtOptions = saveStatementOptions(); const stmtOptions = saveStatementOptions();
const validationError = stmtOptions let preparedSave: Awaited<ReturnType<typeof api.prepareDataGridSave>> | undefined;
? validateDataGridSave({ if (stmtOptions) {
databaseType: databaseType.value, try {
tableMeta: tableMeta.value, preparedSave = await api.prepareDataGridSave(stmtOptions);
columns: stmtOptions.columns, } catch (e: any) {
sourceColumns: stmtOptions.sourceColumns, saveError.value = normalizeDataGridSaveError(databaseType.value, e);
rows: stmtOptions.rows, isSaving.value = false;
columnInfo: tableMeta.value?.columns, return;
dirtyRows: stmtOptions.dirtyRows, }
newRows: stmtOptions.newRows, }
}) if (preparedSave?.validationError) {
: undefined; saveError.value = preparedSave.validationError;
if (validationError) {
saveError.value = validationError;
isSaving.value = false; isSaving.value = false;
return; return;
} }
const stmts = stmtOptions ? buildDataGridSaveStatements(stmtOptions) : []; const stmts = preparedSave?.statements ?? [];
if (stmts.length === 0) { if (stmts.length === 0) {
isSaving.value = false; isSaving.value = false;
return; return;
} }
const rollbackStmts = stmtOptions ? buildDataGridRollbackStatements(stmtOptions) : []; const rollbackStmts = preparedSave?.rollbackStatements ?? [];
const start = Date.now(); const start = Date.now();
let apiResult: { affected_rows?: number } | undefined; let apiResult: { affected_rows?: number } | undefined;
console.info("[DBX][dataGrid:save-statements]", { console.info("[DBX][dataGrid:save-statements]", {
@@ -677,7 +669,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
connectionId.value, connectionId.value,
database.value, database.value,
stmts, stmts,
dataGridSaveExecutionSchema(databaseType.value, tableMeta.value), preparedSave?.executionSchema,
); );
} catch (e: any) { } catch (e: any) {
saveError.value = normalizeDataGridSaveError(databaseType.value, e); saveError.value = normalizeDataGridSaveError(databaseType.value, e);
+1
View File
@@ -88,6 +88,7 @@ export const cancelQuery = forward("cancelQuery");
export const closeQuerySession = forward("closeQuerySession"); export const closeQuerySession = forward("closeQuerySession");
export const analyzeSqlReferences = forward("analyzeSqlReferences"); export const analyzeSqlReferences = forward("analyzeSqlReferences");
export const analyzeEditableQueryEditability = forward("analyzeEditableQueryEditability"); export const analyzeEditableQueryEditability = forward("analyzeEditableQueryEditability");
export const prepareDataGridSave = forward("prepareDataGridSave");
// AI // AI
export const aiComplete = forward("aiComplete"); export const aiComplete = forward("aiComplete");
+1 -500
View File
@@ -1,10 +1,5 @@
import type { DatabaseType } from "@/types/database"; import type { DatabaseType } from "@/types/database";
import { import { DBX_ROWID_COLUMN } from "./tableEditing.ts";
DBX_NEO4J_ELEMENT_ID_COLUMN,
DBX_ROWID_COLUMN,
DBX_TDENGINE_TBNAME_COLUMN,
usesKeylessRowPredicate,
} from "./tableEditing.ts";
import { qualifiedTableName, quoteTableIdentifier } from "./tableSelectSql.ts"; import { qualifiedTableName, quoteTableIdentifier } from "./tableSelectSql.ts";
export type GridCellValue = string | number | boolean | null; export type GridCellValue = string | number | boolean | null;
@@ -54,155 +49,6 @@ export interface DataGridCopyInsertStatementOptions {
excludePrimaryKeys?: boolean; excludePrimaryKeys?: boolean;
} }
export interface DataGridSaveValidationOptions {
databaseType?: DatabaseType;
tableMeta?: DataGridTableMeta;
columns: string[];
sourceColumns?: Array<string | undefined>;
rows?: GridCellValue[][];
columnInfo?: DataGridColumnInfo[];
dirtyRows: Array<[number, Array<[number, GridCellValue]>]>;
newRows: GridCellValue[][];
}
export function validateDataGridSave(options: DataGridSaveValidationOptions): string | undefined {
const duplicateKeyError = validateInsertedPrimaryKeys(options);
if (duplicateKeyError) return duplicateKeyError;
const notNullColumns = new Set(
(options.columnInfo ?? [])
.filter(
(column) =>
!column.is_nullable &&
!column.column_default &&
!isAutoGeneratedColumn(column) &&
!isOracleRowId(options.databaseType, column.name),
)
.map((column) => normalizeColumnName(column.name)),
);
if (notNullColumns.size === 0) return undefined;
for (const [, changes] of options.dirtyRows) {
for (const [columnIndex, value] of changes) {
const sourceColumn = effectiveColumn(options.sourceColumns, options.columns, columnIndex);
if (!sourceColumn) continue;
if (isNullWriteToNotNullColumn(options.databaseType, notNullColumns, sourceColumn, value)) {
return nullWriteError(sourceColumn);
}
}
}
for (const row of options.newRows) {
for (const columnIndex of options.columns.keys()) {
const sourceColumn = effectiveColumn(options.sourceColumns, options.columns, columnIndex);
if (!sourceColumn) continue;
if (isNullWriteToNotNullColumn(options.databaseType, notNullColumns, sourceColumn, row[columnIndex])) {
return nullWriteError(sourceColumn);
}
}
}
return undefined;
}
function validateInsertedPrimaryKeys(options: DataGridSaveValidationOptions): string | undefined {
const primaryKeys = options.tableMeta?.primaryKeys ?? [];
if (primaryKeys.length === 0 || options.newRows.length === 0) return undefined;
const saveColumns = effectiveColumns(options.sourceColumns, options.columns);
const primaryKeyIndexes = primaryKeys.map((primaryKey) => findColumnIndex(saveColumns, primaryKey));
if (primaryKeyIndexes.some((index) => index === -1)) return undefined;
const existingKeys = new Set<string>();
for (const row of options.rows ?? []) {
const key = primaryKeyValueKey(primaryKeyIndexes, row);
if (key) existingKeys.add(key);
}
const newKeys = new Set<string>();
for (const row of options.newRows) {
const key = primaryKeyValueKey(primaryKeyIndexes, row);
if (!key) continue;
if (existingKeys.has(key) || newKeys.has(key)) {
return duplicatePrimaryKeyError(primaryKeys, primaryKeyIndexes, row, existingKeys.has(key));
}
newKeys.add(key);
}
return undefined;
}
export function buildDataGridSaveStatements(options: DataGridSaveStatementOptions): string[] {
if (options.databaseType === "neo4j") return buildNeo4jDataGridSaveStatements(options);
if (options.databaseType === "tdengine") return buildTdengineDataGridSaveStatements(options);
const saveColumns = effectiveColumns(options.sourceColumns, options.columns);
const columnInfo = columnInfoByName(options.tableMeta.columns);
const table = qualifiedTableName({
databaseType: options.databaseType,
schema: options.tableMeta.schema,
tableName: options.tableMeta.tableName,
});
const statements: string[] = [];
for (const [rowIndex, changes] of options.dirtyRows) {
const row = options.rows[rowIndex];
if (!row) continue;
const sets = changes
.filter(([columnIndex]) => !!saveColumns[columnIndex])
.filter(([columnIndex]) => !isOracleRowId(options.databaseType, saveColumns[columnIndex]))
.map(
([columnIndex, value]) =>
`${quoteIdent(options.databaseType, saveColumns[columnIndex]!)} = ${formatGridSqlLiteral(
value,
options.databaseType,
columnInfo.get(normalizeColumnName(saveColumns[columnIndex]!)),
)}`,
)
.join(", ");
if (!sets) continue;
const where = buildPrimaryKeyWhere(
options.databaseType,
options.tableMeta.primaryKeys,
saveColumns,
row,
columnInfo,
);
statements.push(`UPDATE ${table} SET ${sets} WHERE ${where};`);
}
for (const rowIndex of options.deletedRows) {
const row = options.rows[rowIndex];
if (!row) continue;
const where = buildPrimaryKeyWhere(
options.databaseType,
options.tableMeta.primaryKeys,
saveColumns,
row,
columnInfo,
);
statements.push(`DELETE FROM ${table} WHERE ${where};`);
}
for (const row of options.newRows) {
const insertPairs = saveColumns
.map((column, index) => ({ column, value: row[index] }))
.filter((pair): pair is { column: string; value: GridCellValue } => !!pair.column)
.filter((pair) => !isOracleRowId(options.databaseType, pair.column))
.filter((pair) => pair.value !== null && pair.value !== undefined);
if (!insertPairs.length) continue;
const columns = insertPairs.map((pair) => quoteIdent(options.databaseType, pair.column)).join(", ");
const values = insertPairs
.map((pair) =>
formatGridSqlLiteral(pair.value, options.databaseType, columnInfo.get(normalizeColumnName(pair.column))),
)
.join(", ");
statements.push(`INSERT INTO ${table} (${columns}) VALUES (${values});`);
}
return statements;
}
export function buildDataGridCopyUpdateStatements(options: DataGridCopyUpdateStatementOptions): string[] { export function buildDataGridCopyUpdateStatements(options: DataGridCopyUpdateStatementOptions): string[] {
if (options.databaseType === "neo4j" || options.databaseType === "tdengine") return []; if (options.databaseType === "neo4j" || options.databaseType === "tdengine") return [];
const primaryKeys = options.tableMeta.primaryKeys; const primaryKeys = options.tableMeta.primaryKeys;
@@ -295,154 +141,6 @@ export function buildDataGridCopyInsertStatement(options: DataGridCopyInsertStat
return `INSERT INTO ${table} (${columns}) VALUES${valueRows.length === 1 ? " " : "\n"}${valueRows.join(",\n")};`; return `INSERT INTO ${table} (${columns}) VALUES${valueRows.length === 1 ? " " : "\n"}${valueRows.join(",\n")};`;
} }
function buildTdengineDataGridSaveStatements(options: DataGridSaveStatementOptions): string[] {
const saveColumns = effectiveColumns(options.sourceColumns, options.columns);
const statements: string[] = [];
const canOverwriteExistingRows = saveColumns.some((column) => column?.toLowerCase() === DBX_TDENGINE_TBNAME_COLUMN);
if (canOverwriteExistingRows) {
for (const [rowIndex, changes] of options.dirtyRows) {
const row = options.rows[rowIndex];
if (!row) continue;
const afterRow = [...row];
for (const [columnIndex, value] of changes) {
afterRow[columnIndex] = value;
}
const statement = buildTdengineInsertStatement(options, saveColumns, afterRow);
if (statement) statements.push(statement);
}
}
for (const row of options.newRows) {
const statement = buildTdengineInsertStatement(options, saveColumns, row);
if (statement) statements.push(statement);
}
return statements;
}
function buildTdengineInsertStatement(
options: DataGridSaveStatementOptions,
saveColumns: Array<string | undefined>,
row: GridCellValue[],
): string | undefined {
const tbname = tdengineTbnameValue(saveColumns, row);
const table = qualifiedTableName({
databaseType: "tdengine",
schema: options.tableMeta.schema,
tableName: options.tableMeta.tableName,
});
const tagColumns = tdengineTagColumns(options.tableMeta.columns);
const insertPairs = saveColumns
.map((column, index) => ({ column, value: row[index] }))
.filter((pair): pair is { column: string; value: GridCellValue } => !!pair.column)
.filter((pair) => pair.value !== null && pair.value !== undefined)
.filter((pair) => tdengineCanInsertColumn(pair.column, options.tableMeta.tableName, tbname, tagColumns));
if (!insertPairs.length) return undefined;
const columns = insertPairs.map((pair) => quoteIdent("tdengine", pair.column)).join(", ");
const values = insertPairs.map((pair) => formatGridSqlLiteral(pair.value, "tdengine")).join(", ");
return `INSERT INTO ${table} (${columns}) VALUES (${values});`;
}
function tdengineTbnameValue(saveColumns: Array<string | undefined>, row: GridCellValue[]): string | undefined {
const index = saveColumns.findIndex((column) => column?.toLowerCase() === DBX_TDENGINE_TBNAME_COLUMN);
if (index < 0) return undefined;
const value = row[index];
return value === null || value === undefined ? undefined : String(value);
}
function tdengineCanInsertColumn(
column: string,
tableName: string,
tbname: string | undefined,
tagColumns: Set<string>,
): boolean {
const normalized = column.toLowerCase();
const targetIsChildTable = !tbname || tbname === tableName;
if (!targetIsChildTable) return true;
if (normalized === DBX_TDENGINE_TBNAME_COLUMN) return false;
return !tagColumns.has(normalized);
}
function tdengineTagColumns(columns: DataGridColumnInfo[] | undefined): Set<string> {
return new Set(
(columns ?? []).filter((column) => /\btag\b/i.test(column.extra ?? "")).map((column) => column.name.toLowerCase()),
);
}
export function buildDataGridRollbackStatements(options: DataGridSaveStatementOptions): string[] {
if (options.databaseType === "neo4j") return buildNeo4jDataGridRollbackStatements(options);
const saveColumns = effectiveColumns(options.sourceColumns, options.columns);
const columnInfo = columnInfoByName(options.tableMeta.columns);
const table = qualifiedTableName({
databaseType: options.databaseType,
schema: options.tableMeta.schema,
tableName: options.tableMeta.tableName,
});
const statements: string[] = [];
for (const row of options.newRows) {
const where = buildRowWhere(options.databaseType, saveColumns, row, columnInfo);
if (where) statements.push(`DELETE FROM ${table} WHERE ${where};`);
}
for (const rowIndex of options.deletedRows) {
const row = options.rows[rowIndex];
if (!row) continue;
const insertPairs = saveColumns
.map((column, index) => ({ column, value: row[index] }))
.filter((pair): pair is { column: string; value: GridCellValue } => !!pair.column)
.filter((pair) => !isOracleRowId(options.databaseType, pair.column));
const columns = insertPairs.map((pair) => quoteIdent(options.databaseType, pair.column)).join(", ");
const values = insertPairs
.map((pair) =>
formatGridSqlLiteral(pair.value, options.databaseType, columnInfo.get(normalizeColumnName(pair.column))),
)
.join(", ");
statements.push(`INSERT INTO ${table} (${columns}) VALUES (${values});`);
}
for (const [rowIndex, changes] of options.dirtyRows) {
const row = options.rows[rowIndex];
if (!row) continue;
const afterRow = [...row];
for (const [columnIndex, value] of changes) {
afterRow[columnIndex] = value;
}
const writableChanges = changes.filter(
([columnIndex]) => !!saveColumns[columnIndex] && !isOracleRowId(options.databaseType, saveColumns[columnIndex]),
);
const sets = writableChanges
.map(
([columnIndex]) =>
`${quoteIdent(options.databaseType, saveColumns[columnIndex]!)} = ${formatGridSqlLiteral(
row[columnIndex],
options.databaseType,
columnInfo.get(normalizeColumnName(saveColumns[columnIndex]!)),
)}`,
)
.join(", ");
if (!sets) continue;
const where = [
buildPrimaryKeyWhere(options.databaseType, options.tableMeta.primaryKeys, saveColumns, afterRow, columnInfo),
...writableChanges.map(([columnIndex, value]) =>
buildColumnPredicate(
options.databaseType,
saveColumns[columnIndex]!,
value,
columnInfo.get(normalizeColumnName(saveColumns[columnIndex]!)),
),
),
]
.filter(Boolean)
.join(" AND ");
statements.push(`UPDATE ${table} SET ${sets} WHERE ${where};`);
}
return statements;
}
function effectiveColumns( function effectiveColumns(
sourceColumns: Array<string | undefined> | undefined, sourceColumns: Array<string | undefined> | undefined,
columns: string[], columns: string[],
@@ -451,27 +149,10 @@ function effectiveColumns(
return sourceColumns; return sourceColumns;
} }
function effectiveColumn(
sourceColumns: Array<string | undefined> | undefined,
columns: string[],
index: number,
): string | undefined {
return effectiveColumns(sourceColumns, columns)[index];
}
function columnInfoByName(columns: DataGridColumnInfo[] | undefined): Map<string, DataGridColumnInfo> { function columnInfoByName(columns: DataGridColumnInfo[] | undefined): Map<string, DataGridColumnInfo> {
return new Map((columns ?? []).map((column) => [normalizeColumnName(column.name), column])); return new Map((columns ?? []).map((column) => [normalizeColumnName(column.name), column]));
} }
export function dataGridSaveExecutionSchema(
databaseType: DatabaseType | undefined,
tableMeta: DataGridTableMeta | undefined,
): string | undefined {
if (databaseType === "neo4j") return undefined;
if (databaseType === "oracle") return undefined;
return tableMeta?.schema;
}
export function normalizeDataGridSaveError(databaseType: DatabaseType | undefined, error: unknown): string { export function normalizeDataGridSaveError(databaseType: DatabaseType | undefined, error: unknown): string {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
if (databaseType === "hive" && /Attempt to do update or delete|Error 10294/i.test(message)) { if (databaseType === "hive" && /Attempt to do update or delete|Error 10294/i.test(message)) {
@@ -560,43 +241,6 @@ function localTimezoneOffsetSuffix(text: string): string {
return `${sign}${hours}:${minutes}`; return `${sign}${hours}:${minutes}`;
} }
function buildPrimaryKeyWhere(
databaseType: DatabaseType | undefined,
primaryKeys: string[],
columns: Array<string | undefined>,
row: GridCellValue[],
columnInfo: Map<string, DataGridColumnInfo> = new Map(),
): string {
if (primaryKeys.length === 0 && usesKeylessRowPredicate(databaseType))
return buildRowWhere(databaseType, columns, row, columnInfo);
return primaryKeys
.map((primaryKey) => {
const value = row[columns.indexOf(primaryKey)];
return `${predicateIdent(databaseType, primaryKey)} = ${formatGridSqlLiteral(
value,
databaseType,
columnInfo.get(normalizeColumnName(primaryKey)),
)}`;
})
.join(" AND ");
}
function buildRowWhere(
databaseType: DatabaseType | undefined,
columns: Array<string | undefined>,
row: GridCellValue[],
columnInfo: Map<string, DataGridColumnInfo> = new Map(),
): string {
return columns
.map((column, index) =>
!column || isOracleRowId(databaseType, column)
? ""
: buildColumnPredicate(databaseType, column, row[index], columnInfo.get(normalizeColumnName(column))),
)
.filter(Boolean)
.join(" AND ");
}
function buildColumnPredicate( function buildColumnPredicate(
databaseType: DatabaseType | undefined, databaseType: DatabaseType | undefined,
column: string, column: string,
@@ -612,63 +256,15 @@ function isOracleRowId(databaseType: DatabaseType | undefined, name: string | un
return databaseType === "oracle" && name?.toUpperCase() === DBX_ROWID_COLUMN; return databaseType === "oracle" && name?.toUpperCase() === DBX_ROWID_COLUMN;
} }
function isNeo4jElementId(databaseType: DatabaseType | undefined, name: string | undefined): boolean {
return databaseType === "neo4j" && name === DBX_NEO4J_ELEMENT_ID_COLUMN;
}
function isAutoGeneratedColumn(column: DataGridColumnInfo): boolean {
return /\b(auto_increment|autoincrement|identity)\b/i.test(column.extra ?? "");
}
function isNullWriteToNotNullColumn(
databaseType: DatabaseType | undefined,
notNullColumns: Set<string>,
column: string | undefined,
value: GridCellValue,
): boolean {
if (!column || isOracleRowId(databaseType, column)) return false;
if (isNeo4jElementId(databaseType, column)) return false;
return value === null && notNullColumns.has(normalizeColumnName(column));
}
function findColumnIndex(columns: Array<string | undefined>, target: string): number { function findColumnIndex(columns: Array<string | undefined>, target: string): number {
const normalizedTarget = normalizeColumnName(target); const normalizedTarget = normalizeColumnName(target);
return columns.findIndex((column) => (column ? normalizeColumnName(column) : "") === normalizedTarget); return columns.findIndex((column) => (column ? normalizeColumnName(column) : "") === normalizedTarget);
} }
function primaryKeyValueKey(primaryKeyIndexes: number[], row: GridCellValue[]): string | undefined {
const values = primaryKeyIndexes.map((index) => row[index]);
if (values.some((value) => value === null || value === undefined)) return undefined;
return JSON.stringify(values);
}
function duplicatePrimaryKeyError(
primaryKeys: string[],
primaryKeyIndexes: number[],
row: GridCellValue[],
matchesExistingRow: boolean,
): string {
const keySummary = primaryKeys
.map((primaryKey, index) => `${primaryKey} = ${formatKeyValueForMessage(row[primaryKeyIndexes[index]])}`)
.join(", ");
const source = matchesExistingRow ? "the existing primary key" : "another new row's primary key";
return `New row duplicates ${source} (${keySummary}). Change the key before saving.`;
}
function formatKeyValueForMessage(value: GridCellValue): string {
if (value === null || value === undefined) return "NULL";
if (typeof value === "string") return `"${value.replace(/"/g, '\\"')}"`;
return String(value);
}
function normalizeColumnName(name: string): string { function normalizeColumnName(name: string): string {
return name.toUpperCase(); return name.toUpperCase();
} }
function nullWriteError(column: string): string {
return `Column "${column}" does not allow NULL.`;
}
function predicateIdent(databaseType: DatabaseType | undefined, name: string): string { function predicateIdent(databaseType: DatabaseType | undefined, name: string): string {
return isOracleRowId(databaseType, name) ? "ROWIDTOCHAR(ROWID)" : quoteIdent(databaseType, name); return isOracleRowId(databaseType, name) ? "ROWIDTOCHAR(ROWID)" : quoteIdent(databaseType, name);
} }
@@ -676,98 +272,3 @@ function predicateIdent(databaseType: DatabaseType | undefined, name: string): s
function quoteIdent(databaseType: DatabaseType | undefined, name: string): string { function quoteIdent(databaseType: DatabaseType | undefined, name: string): string {
return quoteTableIdentifier(databaseType, name); return quoteTableIdentifier(databaseType, name);
} }
function buildNeo4jDataGridSaveStatements(options: DataGridSaveStatementOptions): string[] {
const label = quoteIdent("neo4j", options.tableMeta.tableName);
const statements: string[] = [];
for (const [rowIndex, changes] of options.dirtyRows) {
const row = options.rows[rowIndex];
if (!row) continue;
const sets = changes
.filter(([columnIndex]) => !isNeo4jElementId("neo4j", options.columns[columnIndex]))
.map(
([columnIndex, value]) =>
`n.${quoteIdent("neo4j", options.columns[columnIndex])} = ${formatGridSqlLiteral(value, "neo4j")}`,
)
.join(", ");
if (!sets) continue;
statements.push(`MATCH (n:${label}) WHERE ${neo4jElementIdPredicate(options.columns, row)} SET ${sets};`);
}
for (const rowIndex of options.deletedRows) {
const row = options.rows[rowIndex];
if (!row) continue;
statements.push(`MATCH (n:${label}) WHERE ${neo4jElementIdPredicate(options.columns, row)} DETACH DELETE n;`);
}
for (const row of options.newRows) {
const props = options.columns
.map((column, index) => ({ column, value: row[index] }))
.filter((pair) => !isNeo4jElementId("neo4j", pair.column))
.filter((pair) => pair.value !== null && pair.value !== undefined)
.map((pair) => `${quoteIdent("neo4j", pair.column)}: ${formatGridSqlLiteral(pair.value, "neo4j")}`)
.join(", ");
statements.push(props ? `CREATE (n:${label} {${props}});` : `CREATE (n:${label});`);
}
return statements;
}
function buildNeo4jDataGridRollbackStatements(options: DataGridSaveStatementOptions): string[] {
const label = quoteIdent("neo4j", options.tableMeta.tableName);
const statements: string[] = [];
for (const row of options.newRows) {
const where = neo4jRowPropertyWhere(options.columns, row);
statements.push(
where ? `MATCH (n:${label}) WHERE ${where} DETACH DELETE n;` : `MATCH (n:${label}) DETACH DELETE n;`,
);
}
for (const rowIndex of options.deletedRows) {
const row = options.rows[rowIndex];
if (!row) continue;
const props = options.columns
.map((column, index) => ({ column, value: row[index] }))
.filter((pair) => !isNeo4jElementId("neo4j", pair.column))
.filter((pair) => pair.value !== null && pair.value !== undefined)
.map((pair) => `${quoteIdent("neo4j", pair.column)}: ${formatGridSqlLiteral(pair.value, "neo4j")}`)
.join(", ");
statements.push(props ? `CREATE (n:${label} {${props}});` : `CREATE (n:${label});`);
}
for (const [rowIndex, changes] of options.dirtyRows) {
const row = options.rows[rowIndex];
if (!row) continue;
const sets = changes
.filter(([columnIndex]) => !isNeo4jElementId("neo4j", options.columns[columnIndex]))
.map(
([columnIndex]) =>
`n.${quoteIdent("neo4j", options.columns[columnIndex])} = ${formatGridSqlLiteral(row[columnIndex], "neo4j")}`,
)
.join(", ");
if (!sets) continue;
statements.push(`MATCH (n:${label}) WHERE ${neo4jElementIdPredicate(options.columns, row)} SET ${sets};`);
}
return statements;
}
function neo4jElementIdPredicate(columns: string[], row: GridCellValue[]): string {
const value = row[columns.indexOf(DBX_NEO4J_ELEMENT_ID_COLUMN)];
return `elementId(n) = ${formatGridSqlLiteral(value, "neo4j")}`;
}
function neo4jRowPropertyWhere(columns: string[], row: GridCellValue[]): string {
return columns
.map((column, index) => {
if (isNeo4jElementId("neo4j", column)) return "";
const value = row[index];
const ident = `n.${quoteIdent("neo4j", column)}`;
if (value === null || value === undefined) return `${ident} IS NULL`;
return `${ident} = ${formatGridSqlLiteral(value, "neo4j")}`;
})
.filter(Boolean)
.join(" AND ");
}
+6
View File
@@ -50,6 +50,8 @@ import type {
XlsxCellValue, XlsxCellValue,
} from "./tauri"; } from "./tauri";
import type { QueryEditability } from "@/lib/sqlAnalysis"; import type { QueryEditability } from "@/lib/sqlAnalysis";
import type { DataGridSaveStatementOptions } from "@/lib/dataGridSql";
import type { DataGridSavePreparation } from "./tauri";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Helpers // Helpers
@@ -409,6 +411,10 @@ export async function analyzeEditableQueryEditability(sql: string): Promise<Quer
return post("/api/query/analyze-editability", { sql }); return post("/api/query/analyze-editability", { sql });
} }
export async function prepareDataGridSave(options: DataGridSaveStatementOptions): Promise<DataGridSavePreparation> {
return post("/api/query/prepare-data-grid-save", { options });
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// AI // AI
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+12
View File
@@ -22,6 +22,7 @@ import type {
} from "@/types/database"; } from "@/types/database";
import type { AiConfig } from "@/stores/settingsStore"; import type { AiConfig } from "@/stores/settingsStore";
import type { QueryEditability } from "@/lib/sqlAnalysis"; import type { QueryEditability } from "@/lib/sqlAnalysis";
import type { DataGridSaveStatementOptions } from "@/lib/dataGridSql";
export interface AgentDriverInfo { export interface AgentDriverInfo {
db_type: string; db_type: string;
@@ -317,6 +318,17 @@ export async function analyzeEditableQueryEditability(sql: string): Promise<Quer
return invoke("analyze_editable_query_editability", { sql }); return invoke("analyze_editable_query_editability", { sql });
} }
export interface DataGridSavePreparation {
validationError?: string;
statements: string[];
rollbackStatements: string[];
executionSchema?: string;
}
export async function prepareDataGridSave(options: DataGridSaveStatementOptions): Promise<DataGridSavePreparation> {
return invoke("prepare_data_grid_save", { options });
}
export async function listIndexes( export async function listIndexes(
connectionId: string, connectionId: string,
database: string, database: string,
File diff suppressed because it is too large. Load diff
+1
View File
@@ -4,6 +4,7 @@ pub mod ai;
pub mod connection; pub mod connection;
pub mod connection_secrets; pub mod connection_secrets;
pub mod csv_export; pub mod csv_export;
pub mod data_grid_sql;
pub mod database_capabilities; pub mod database_capabilities;
pub mod database_export; pub mod database_export;
pub mod db; pub mod db;
+1
View File
@@ -125,6 +125,7 @@ async fn main() {
.route("/query/execute-in-transaction", post(routes::query::execute_in_transaction)) .route("/query/execute-in-transaction", post(routes::query::execute_in_transaction))
.route("/query/analyze-sql-references", post(routes::query::analyze_sql_references)) .route("/query/analyze-sql-references", post(routes::query::analyze_sql_references))
.route("/query/analyze-editability", post(routes::query::analyze_editable_query_editability)) .route("/query/analyze-editability", post(routes::query::analyze_editable_query_editability))
.route("/query/prepare-data-grid-save", post(routes::query::prepare_data_grid_save))
.route("/query/cancel", post(routes::query::cancel_query)) .route("/query/cancel", post(routes::query::cancel_query))
.route("/query/close-session", post(routes::query::close_query_session)) .route("/query/close-session", post(routes::query::close_query_session))
// Redis // Redis
+12
View File
@@ -57,6 +57,12 @@ pub struct AnalyzeEditableQueryRequest {
pub sql: String, pub sql: String,
} }
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PrepareDataGridSaveRequest {
pub options: dbx_core::data_grid_sql::DataGridSaveStatementOptions,
}
pub async fn execute_query( pub async fn execute_query(
State(state): State<Arc<WebState>>, State(state): State<Arc<WebState>>,
Json(req): Json<ExecuteQueryRequest>, Json(req): Json<ExecuteQueryRequest>,
@@ -199,3 +205,9 @@ pub async fn analyze_editable_query_editability(
) -> Json<dbx_core::sql_editability::QueryEditability> { ) -> Json<dbx_core::sql_editability::QueryEditability> {
Json(dbx_core::sql_editability::analyze_editable_query_editability(&req.sql)) Json(dbx_core::sql_editability::analyze_editable_query_editability(&req.sql))
} }
pub async fn prepare_data_grid_save(
Json(req): Json<PrepareDataGridSaveRequest>,
) -> Json<dbx_core::data_grid_sql::DataGridSavePreparation> {
Json(dbx_core::data_grid_sql::prepare_data_grid_save(req.options))
}
+59
View File
@@ -3,6 +3,7 @@ import test from "node:test";
import { computed, nextTick, ref } from "vue"; import { computed, nextTick, ref } from "vue";
import { createPinia, setActivePinia } from "pinia"; import { createPinia, setActivePinia } from "pinia";
import { useDataGridEditor } from "../../apps/desktop/src/composables/useDataGridEditor.ts"; import { useDataGridEditor } from "../../apps/desktop/src/composables/useDataGridEditor.ts";
import { formatGridSqlLiteral, type DataGridSaveStatementOptions } from "../../apps/desktop/src/lib/dataGridSql.ts";
import type { ColumnInfo } from "../../apps/desktop/src/types/database.ts"; import type { ColumnInfo } from "../../apps/desktop/src/types/database.ts";
type CellValue = string | number | boolean | null; type CellValue = string | number | boolean | null;
@@ -17,6 +18,64 @@ function installBrowserTestGlobals() {
key: () => null, key: () => null,
length: 0, length: 0,
}; };
globalThis.fetch = (async (input, init) => {
if (String(input) !== "/api/query/prepare-data-grid-save") {
return new Response("unexpected request", { status: 500 });
}
const body = JSON.parse(String(init?.body ?? "{}"));
const options = body.options as DataGridSaveStatementOptions;
return new Response(
JSON.stringify({
statements: mockPreparedSaveStatements(options),
rollbackStatements: [],
executionSchema:
options.databaseType === "oracle" || options.databaseType === "neo4j" ? undefined : options.tableMeta.schema,
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}) as typeof fetch;
}
function mockPreparedSaveStatements(options: DataGridSaveStatementOptions): string[] {
const table = options.tableMeta.schema
? `${quotePgIdentifier(options.tableMeta.schema)}.${quotePgIdentifier(options.tableMeta.tableName)}`
: quotePgIdentifier(options.tableMeta.tableName);
const statements: string[] = [];
for (const rowIndex of options.deletedRows) {
const row = options.rows[rowIndex];
if (!row) continue;
statements.push(`DELETE FROM ${table} WHERE ${primaryKeyWhere(options, row)};`);
}
for (const [rowIndex, changes] of options.dirtyRows) {
const row = options.rows[rowIndex];
if (!row) continue;
const sets = changes
.map(
([columnIndex, value]) =>
`${quotePgIdentifier(options.columns[columnIndex])} = ${formatGridSqlLiteral(value, options.databaseType)}`,
)
.join(", ");
statements.push(`UPDATE ${table} SET ${sets} WHERE ${primaryKeyWhere(options, row)};`);
}
for (const row of options.newRows) {
const columns = options.columns.map(quotePgIdentifier).join(", ");
const values = row.map((value) => formatGridSqlLiteral(value, options.databaseType)).join(", ");
statements.push(`INSERT INTO ${table} (${columns}) VALUES (${values});`);
}
return statements;
}
function primaryKeyWhere(options: DataGridSaveStatementOptions, row: CellValue[]): string {
return options.tableMeta.primaryKeys
.map((key) => {
const index = options.columns.indexOf(key);
return `${quotePgIdentifier(key)} = ${formatGridSqlLiteral(row[index], options.databaseType)}`;
})
.join(" AND ");
}
function quotePgIdentifier(name: string): string {
return `"${name.replace(/"/g, '""')}"`;
} }
function column(name: string, isPrimaryKey = false, extra: string | null = null): ColumnInfo { function column(name: string, isPrimaryKey = false, extra: string | null = null): ColumnInfo {
@@ -0,0 +1,32 @@
import { readFileSync } from "node:fs";
import { strict as assert } from "node:assert";
import test from "node:test";
const apiSource = readFileSync("apps/desktop/src/lib/api.ts", "utf8");
const tauriSource = readFileSync("apps/desktop/src/lib/tauri.ts", "utf8");
const httpSource = readFileSync("apps/desktop/src/lib/http.ts", "utf8");
const editorSource = readFileSync("apps/desktop/src/composables/useDataGridEditor.ts", "utf8");
const tauriLibSource = readFileSync("src-tauri/src/lib.rs", "utf8");
const webMainSource = readFileSync("crates/dbx-web/src/main.rs", "utf8");
const rustCoreLibSource = readFileSync("crates/dbx-core/src/lib.rs", "utf8");
test("shared API exposes backend data grid save preparation", () => {
assert.match(apiSource, /export const prepareDataGridSave = forward\("prepareDataGridSave"\)/);
assert.match(tauriSource, /export async function prepareDataGridSave\(/);
assert.match(tauriSource, /invoke\("prepare_data_grid_save"/);
assert.match(httpSource, /export async function prepareDataGridSave\(/);
assert.match(httpSource, /\/api\/query\/prepare-data-grid-save/);
});
test("data grid save flow uses backend save preparation", () => {
assert.match(editorSource, /await api\.prepareDataGridSave\(stmtOptions\)/);
assert.doesNotMatch(editorSource, /buildDataGridSaveStatements\(/);
assert.doesNotMatch(editorSource, /buildDataGridRollbackStatements\(/);
assert.doesNotMatch(editorSource, /validateDataGridSave\(/);
});
test("Rust backends register data grid save preparation", () => {
assert.match(rustCoreLibSource, /pub mod data_grid_sql/);
assert.match(tauriLibSource, /commands::query::prepare_data_grid_save/);
assert.match(webMainSource, /\/query\/prepare-data-grid-save/);
});
-568
View File
@@ -3,44 +3,9 @@ import test from "node:test";
import { import {
buildDataGridCopyInsertStatement, buildDataGridCopyInsertStatement,
buildDataGridCopyUpdateStatements, buildDataGridCopyUpdateStatements,
buildDataGridRollbackStatements,
buildDataGridSaveStatements,
dataGridSaveExecutionSchema,
normalizeDataGridSaveError, normalizeDataGridSaveError,
formatGridSqlLiteral, formatGridSqlLiteral,
validateDataGridSave,
} from "../../apps/desktop/src/lib/dataGridSql.ts"; } from "../../apps/desktop/src/lib/dataGridSql.ts";
import { DBX_NEO4J_ELEMENT_ID_COLUMN } from "../../apps/desktop/src/lib/tableEditing.ts";
test("builds SQL Server grid save statements with schema and bracket quoting", () => {
const statements = buildDataGridSaveStatements({
databaseType: "sqlserver",
tableMeta: {
schema: "game",
tableName: "player states",
primaryKeys: ["role id"],
},
columns: ["role id", "state", "updated at"],
rows: [[42, "old", "2026-05-03"]],
dirtyRows: [
[
0,
[
[1, "ready"],
[2, "2026-05-04"],
],
],
],
deletedRows: [0],
newRows: [[43, "new", "2026-05-05"]],
});
assert.deepEqual(statements, [
"UPDATE [game].[player states] SET [state] = N'ready', [updated at] = N'2026-05-04' WHERE [role id] = 42;",
"DELETE FROM [game].[player states] WHERE [role id] = 42;",
"INSERT INTO [game].[player states] ([role id], [state], [updated at]) VALUES (43, N'new', N'2026-05-05');",
]);
});
test("builds copy-as-update statements using primary keys and non-primary-key columns", () => { test("builds copy-as-update statements using primary keys and non-primary-key columns", () => {
const statements = buildDataGridCopyUpdateStatements({ const statements = buildDataGridCopyUpdateStatements({
@@ -123,286 +88,6 @@ test("skips copy-as-update statements when primary keys are unavailable", () =>
assert.deepEqual(statements, []); assert.deepEqual(statements, []);
}); });
test("builds Access grid save statements with backtick identifiers", () => {
const statements = buildDataGridSaveStatements({
databaseType: "access",
tableMeta: {
tableName: "Order Details",
primaryKeys: ["Order ID"],
},
columns: ["Order ID", "Product Name", "Active"],
rows: [[42, "Old", true]],
dirtyRows: [[0, [[1, "Ready"]]]],
deletedRows: [0],
newRows: [[43, "New", false]],
});
assert.deepEqual(statements, [
"UPDATE `Order Details` SET `Product Name` = 'Ready' WHERE `Order ID` = 42;",
"DELETE FROM `Order Details` WHERE `Order ID` = 42;",
"INSERT INTO `Order Details` (`Order ID`, `Product Name`, `Active`) VALUES (43, 'New', FALSE);",
]);
});
test("builds Access grid save statements with row predicates when primary keys are unavailable", () => {
const statements = buildDataGridSaveStatements({
databaseType: "access",
tableMeta: {
tableName: "orders",
primaryKeys: [],
},
columns: ["id", "quantity", "status", "shipped_at"],
rows: [[1, 3, "pending", null]],
dirtyRows: [[0, [[1, 4]]]],
deletedRows: [0],
newRows: [],
});
assert.deepEqual(statements, [
"UPDATE `orders` SET `quantity` = 4 WHERE `id` = 1 AND `quantity` = 3 AND `status` = 'pending' AND `shipped_at` IS NULL;",
"DELETE FROM `orders` WHERE `id` = 1 AND `quantity` = 3 AND `status` = 'pending' AND `shipped_at` IS NULL;",
]);
});
test("builds grid save statements through source columns for aliased query results", () => {
const statements = buildDataGridSaveStatements({
databaseType: "postgres",
tableMeta: {
schema: "public",
tableName: "ihli_data",
primaryKeys: ["iso3", "year"],
},
columns: ["country_code", "report_year", "country_name", "ihli_rank"],
sourceColumns: ["iso3", "year", "country_name", "rank"],
rows: [["LUX", 2007, "Luxembourg", 1]],
dirtyRows: [[0, [[2, "Luxembourg City"]]]],
deletedRows: [0],
newRows: [["USA", 2008, "United States", 2]],
});
assert.deepEqual(statements, [
`UPDATE "public"."ihli_data" SET "country_name" = 'Luxembourg City' WHERE "iso3" = 'LUX' AND "year" = 2007;`,
`DELETE FROM "public"."ihli_data" WHERE "iso3" = 'LUX' AND "year" = 2007;`,
`INSERT INTO "public"."ihli_data" ("iso3", "year", "country_name", "rank") VALUES ('USA', 2008, 'United States', 2);`,
]);
});
test("skips expression-only result columns when saving aliased query results", () => {
const statements = buildDataGridSaveStatements({
databaseType: "postgres",
tableMeta: {
schema: "public",
tableName: "ihli_data",
primaryKeys: ["iso3", "year"],
},
columns: ["iso3", "year", "country_name", "score"],
sourceColumns: ["iso3", "year", "country_name", undefined],
rows: [["LUX", 2007, "Luxembourg", 50242.1]],
dirtyRows: [
[
0,
[
[2, "Luxembourg City"],
[3, 999],
],
],
],
deletedRows: [],
newRows: [["USA", 2008, "United States", 43000]],
});
assert.deepEqual(statements, [
`UPDATE "public"."ihli_data" SET "country_name" = 'Luxembourg City' WHERE "iso3" = 'LUX' AND "year" = 2007;`,
`INSERT INTO "public"."ihli_data" ("iso3", "year", "country_name") VALUES ('USA', 2008, 'United States');`,
]);
});
test("builds Hive grid save statements with backtick identifiers", () => {
const statements = buildDataGridSaveStatements({
databaseType: "hive",
tableMeta: {
tableName: "department states",
primaryKeys: ["dept id"],
},
columns: ["dept id", "display name"],
rows: [[10, "Sales"]],
dirtyRows: [[0, [[1, "Marketing"]]]],
deletedRows: [0],
newRows: [[20, "Engineering"]],
});
assert.deepEqual(statements, [
"UPDATE `department states` SET `display name` = 'Marketing' WHERE `dept id` = 10;",
"DELETE FROM `department states` WHERE `dept id` = 10;",
"INSERT INTO `department states` (`dept id`, `display name`) VALUES (20, 'Engineering');",
]);
});
test("builds Hive grid save statements without primary keys using row predicates", () => {
const statements = buildDataGridSaveStatements({
databaseType: "hive",
tableMeta: {
tableName: "departments",
primaryKeys: [],
},
columns: ["id", "name", "location"],
rows: [[10, "Sales", null]],
dirtyRows: [[0, [[1, "Marketing"]]]],
deletedRows: [0],
newRows: [],
});
assert.deepEqual(statements, [
"UPDATE `departments` SET `name` = 'Marketing' WHERE `id` = 10 AND `name` = 'Sales' AND `location` IS NULL;",
"DELETE FROM `departments` WHERE `id` = 10 AND `name` = 'Sales' AND `location` IS NULL;",
]);
});
test("builds PostgreSQL grid save statements without primary keys using row predicates", () => {
const statements = buildDataGridSaveStatements({
databaseType: "postgres",
tableMeta: {
schema: "public",
tableName: "visits",
primaryKeys: [],
},
columns: ["id", "title", "published_at"],
rows: [[63, "old title", null]],
dirtyRows: [[0, [[1, "new title"]]]],
deletedRows: [0],
newRows: [[64, "fresh", "2026-05-20 12:00:00"]],
});
assert.deepEqual(statements, [
`UPDATE "public"."visits" SET "title" = 'new title' WHERE "id" = 63 AND "title" = 'old title' AND "published_at" IS NULL;`,
`DELETE FROM "public"."visits" WHERE "id" = 63 AND "title" = 'old title' AND "published_at" IS NULL;`,
`INSERT INTO "public"."visits" ("id", "title", "published_at") VALUES (64, 'fresh', '2026-05-20 12:00:00');`,
]);
});
test("builds Dameng grid save statements without primary keys using row predicates", () => {
const statements = buildDataGridSaveStatements({
databaseType: "dameng",
tableMeta: {
schema: "APP",
tableName: "VISITS",
primaryKeys: [],
},
columns: ["ID", "TITLE", "PUBLISHED_AT"],
rows: [[63, "old title", null]],
dirtyRows: [[0, [[1, "new title"]]]],
deletedRows: [0],
newRows: [[64, "fresh", "2026-05-20 12:00:00"]],
});
assert.deepEqual(statements, [
`UPDATE "APP"."VISITS" SET "TITLE" = 'new title' WHERE "ID" = 63 AND "TITLE" = 'old title' AND "PUBLISHED_AT" IS NULL;`,
`DELETE FROM "APP"."VISITS" WHERE "ID" = 63 AND "TITLE" = 'old title' AND "PUBLISHED_AT" IS NULL;`,
`INSERT INTO "APP"."VISITS" ("ID", "TITLE", "PUBLISHED_AT") VALUES (64, 'fresh', '2026-05-20 12:00:00');`,
]);
});
test("builds non-empty YashanDB grid update statements for primary-key edits", () => {
const statements = buildDataGridSaveStatements({
databaseType: "yashandb",
tableMeta: {
schema: "DBX_DEMO",
tableName: "METRICS_DAILY",
primaryKeys: ["DAY_DATE"],
},
columns: ["DAY_DATE", "ACTIVE_USERS", "QUERY_COUNT", "REVENUE"],
rows: [["2026-05-17", 3, 391, 88.8]],
dirtyRows: [[0, [[1, 123123]]]],
deletedRows: [],
newRows: [],
});
assert.deepEqual(statements, [
`UPDATE "DBX_DEMO"."METRICS_DAILY" SET "ACTIVE_USERS" = 123123 WHERE "DAY_DATE" = '2026-05-17';`,
]);
});
test("builds TDengine append-only grid save statements", () => {
const newTs = tdengineTimestampLiteral("2026-05-16 09:40:00.000");
const statements = buildDataGridSaveStatements({
databaseType: "tdengine",
tableMeta: {
schema: "test_db",
tableName: "meters",
primaryKeys: ["ts"],
},
columns: ["ts", "current", "voltage", "location", "groupid"],
rows: [["2026-05-16 09:35:57.000", 10.3, 219, "Beijing", 1]],
dirtyRows: [[0, [[1, 11.1]]]],
deletedRows: [0],
newRows: [["2026-05-16 09:40:00.000", 12.1, 220, "Shenzhen", 3]],
});
assert.deepEqual(statements, [
`INSERT INTO \`test_db\`.\`meters\` (\`ts\`, \`current\`, \`voltage\`, \`location\`, \`groupid\`) VALUES (${newTs}, 12.1, 220, 'Shenzhen', 3);`,
]);
});
test("builds TDengine supertable overwrites as full-row inserts by tbname and timestamp", () => {
const existingTs = tdengineTimestampLiteral("2026-05-16 09:35:57.000");
const newTs = tdengineTimestampLiteral("2026-05-16 09:40:00.000");
const statements = buildDataGridSaveStatements({
databaseType: "tdengine",
tableMeta: {
schema: "test_db",
tableName: "meters",
primaryKeys: ["tbname", "ts"],
columns: [
{ name: "ts", data_type: "TIMESTAMP", is_nullable: false, is_primary_key: true },
{ name: "current", data_type: "FLOAT", is_nullable: true, is_primary_key: false },
{ name: "voltage", data_type: "INT", is_nullable: true, is_primary_key: false },
{ name: "location", data_type: "NCHAR", is_nullable: true, is_primary_key: false, extra: "TAG" },
{ name: "groupid", data_type: "INT", is_nullable: true, is_primary_key: false, extra: "TAG" },
],
},
columns: ["tbname", "ts", "current", "voltage", "location", "groupid"],
rows: [["d1001", "2026-05-16 09:35:57.000", 10.3, 219, "Beijing", 1]],
dirtyRows: [[0, [[2, 11.1]]]],
deletedRows: [0],
newRows: [["d1003", "2026-05-16 09:40:00.000", 12.1, 220, "Shenzhen", 3]],
});
assert.deepEqual(statements, [
`INSERT INTO \`test_db\`.\`meters\` (\`tbname\`, \`ts\`, \`current\`, \`voltage\`, \`location\`, \`groupid\`) VALUES ('d1001', ${existingTs}, 11.1, 219, 'Beijing', 1);`,
`INSERT INTO \`test_db\`.\`meters\` (\`tbname\`, \`ts\`, \`current\`, \`voltage\`, \`location\`, \`groupid\`) VALUES ('d1003', ${newTs}, 12.1, 220, 'Shenzhen', 3);`,
]);
});
test("builds TDengine child table overwrites without pseudo or tag columns", () => {
const existingTs = tdengineTimestampLiteral("2026-05-16 09:35:57.000");
const newTs = tdengineTimestampLiteral("2026-05-16 09:40:00.000");
const statements = buildDataGridSaveStatements({
databaseType: "tdengine",
tableMeta: {
schema: "test_db",
tableName: "d1001",
primaryKeys: ["tbname", "ts"],
columns: [
{ name: "ts", data_type: "TIMESTAMP", is_nullable: false, is_primary_key: true },
{ name: "current", data_type: "FLOAT", is_nullable: true, is_primary_key: false },
{ name: "voltage", data_type: "INT", is_nullable: true, is_primary_key: false },
{ name: "location", data_type: "NCHAR", is_nullable: true, is_primary_key: false, extra: "TAG" },
{ name: "groupid", data_type: "INT", is_nullable: true, is_primary_key: false, extra: "TAG" },
],
},
columns: ["tbname", "ts", "current", "voltage", "location", "groupid"],
rows: [["d1001", "2026-05-16 09:35:57.000", 10.3, 219, "Beijing", 1]],
dirtyRows: [[0, [[2, 11.1]]]],
deletedRows: [],
newRows: [["d1001", "2026-05-16 09:40:00.000", 12.1, 220, "Beijing", 1]],
});
assert.deepEqual(statements, [
`INSERT INTO \`test_db\`.\`d1001\` (\`ts\`, \`current\`, \`voltage\`) VALUES (${existingTs}, 11.1, 219);`,
`INSERT INTO \`test_db\`.\`d1001\` (\`ts\`, \`current\`, \`voltage\`) VALUES (${newTs}, 12.1, 220);`,
]);
});
test("formats TDengine timestamp literals with the local timezone offset", () => { test("formats TDengine timestamp literals with the local timezone offset", () => {
assert.equal( assert.equal(
formatGridSqlLiteral("2026-05-16 09:35:57.975", "tdengine"), formatGridSqlLiteral("2026-05-16 09:35:57.975", "tdengine"),
@@ -415,51 +100,6 @@ test("formats MySQL RFC3339 datetime strings as DATETIME-compatible literals", (
assert.equal(formatGridSqlLiteral("2026-05-12T00:00:00.123456Z", "mysql"), "'2026-05-12 00:00:00.123456'"); assert.equal(formatGridSqlLiteral("2026-05-12T00:00:00.123456Z", "mysql"), "'2026-05-12 00:00:00.123456'");
}); });
test("formats MySQL grid saves using target column temporal types", () => {
const statements = buildDataGridSaveStatements({
databaseType: "mysql",
tableMeta: {
tableName: "policies",
primaryKeys: ["id"],
columns: [
{ name: "id", data_type: "int", is_nullable: false, is_primary_key: true },
{ name: "insurance_start_time", data_type: "datetime", is_nullable: true, is_primary_key: false },
{ name: "raw_text", data_type: "varchar(64)", is_nullable: true, is_primary_key: false },
{ name: "coverage_day", data_type: "date", is_nullable: true, is_primary_key: false },
{ name: "start_clock", data_type: "time", is_nullable: true, is_primary_key: false },
],
},
columns: ["id", "insurance_start_time", "raw_text", "coverage_day", "start_clock"],
rows: [[1, "2026-05-12T00:00:00+00:00", "old", "2026-05-12T00:00:00+00:00", "2026-05-12T09:30:45+00:00"]],
dirtyRows: [
[
0,
[
[1, "2026-05-12T00:00:00+00:00"],
[2, "2026-05-12T00:00:00+00:00"],
[3, "2026-05-12T00:00:00+00:00"],
[4, "2026-05-12T09:30:45+00:00"],
],
],
],
deletedRows: [],
newRows: [
[
2,
"2026-05-12T00:00:00+00:00",
"2026-05-12T00:00:00+00:00",
"2026-05-12T00:00:00+00:00",
"2026-05-12T09:30:45+00:00",
],
],
});
assert.deepEqual(statements, [
"UPDATE `policies` SET `insurance_start_time` = '2026-05-12 00:00:00', `raw_text` = '2026-05-12T00:00:00+00:00', `coverage_day` = '2026-05-12', `start_clock` = '09:30:45' WHERE `id` = 1;",
"INSERT INTO `policies` (`id`, `insurance_start_time`, `raw_text`, `coverage_day`, `start_clock`) VALUES (2, '2026-05-12 00:00:00', '2026-05-12T00:00:00+00:00', '2026-05-12', '09:30:45');",
]);
});
test("formats MySQL copy-as-update statements using target column temporal types", () => { test("formats MySQL copy-as-update statements using target column temporal types", () => {
const statements = buildDataGridCopyUpdateStatements({ const statements = buildDataGridCopyUpdateStatements({
databaseType: "mysql", databaseType: "mysql",
@@ -494,128 +134,6 @@ function tdengineTimestampLiteral(text: string): string {
return `'${datePart}T${time}${fraction}${sign}${hours}:${minutes}'`; return `'${datePart}T${time}${fraction}${sign}${hours}:${minutes}'`;
} }
test("builds Trino insert statements with schema-qualified identifiers", () => {
const statements = buildDataGridSaveStatements({
databaseType: "trino",
tableMeta: {
schema: "tiny",
tableName: "nation",
primaryKeys: [],
},
columns: ["nationkey", "name"],
rows: [],
dirtyRows: [],
deletedRows: [],
newRows: [[100, "Atlantis"]],
});
assert.deepEqual(statements, ['INSERT INTO "tiny"."nation" ("nationkey", "name") VALUES (100, \'Atlantis\');']);
});
test("builds Informix grid save statements without delimited identifiers", () => {
const statements = buildDataGridSaveStatements({
databaseType: "informix",
tableMeta: {
schema: "testdb",
tableName: "dbx_grid_edit_probe",
primaryKeys: ["id"],
},
columns: ["id", "name"],
rows: [[1, "before"]],
dirtyRows: [[0, [[1, "after"]]]],
deletedRows: [0],
newRows: [[2, "new"]],
});
assert.deepEqual(statements, [
"UPDATE dbx_grid_edit_probe SET name = 'after' WHERE id = 1;",
"DELETE FROM dbx_grid_edit_probe WHERE id = 1;",
"INSERT INTO dbx_grid_edit_probe (id, name) VALUES (2, 'new');",
]);
});
test("uses Oracle ROWID as a synthetic key without writing it as a normal column", () => {
const statements = buildDataGridSaveStatements({
databaseType: "oracle",
tableMeta: {
schema: "DBXTEST",
tableName: "DBX_LOAD_TABLE_006",
primaryKeys: ["__DBX_ROWID"],
},
columns: ["__DBX_ROWID", "ID", "CITY", "NOTE"],
rows: [["AAATiBAABAAABrXAAA", 1, "上海", "old"]],
dirtyRows: [[0, [[2, "北京"]]]],
deletedRows: [0],
newRows: [[null, 2, "广州", "new"]],
});
assert.deepEqual(statements, [
`UPDATE "DBXTEST"."DBX_LOAD_TABLE_006" SET "CITY" = '北京' WHERE ROWIDTOCHAR(ROWID) = 'AAATiBAABAAABrXAAA';`,
`DELETE FROM "DBXTEST"."DBX_LOAD_TABLE_006" WHERE ROWIDTOCHAR(ROWID) = 'AAATiBAABAAABrXAAA';`,
`INSERT INTO "DBXTEST"."DBX_LOAD_TABLE_006" ("ID", "CITY", "NOTE") VALUES (2, '广州', 'new');`,
]);
});
test("builds Neo4j grid save statements with elementId keys", () => {
const statements = buildDataGridSaveStatements({
databaseType: "neo4j",
tableMeta: {
tableName: "Employee",
primaryKeys: [DBX_NEO4J_ELEMENT_ID_COLUMN],
},
columns: [DBX_NEO4J_ELEMENT_ID_COLUMN, "name", "active"],
rows: [["4:abc:7", "Ada", true]],
dirtyRows: [[0, [[1, "Grace"]]]],
deletedRows: [0],
newRows: [[null, "Linus", false]],
});
assert.deepEqual(statements, [
"MATCH (n:`Employee`) WHERE elementId(n) = '4:abc:7' SET n.`name` = 'Grace';",
"MATCH (n:`Employee`) WHERE elementId(n) = '4:abc:7' DETACH DELETE n;",
"CREATE (n:`Employee` {`name`: 'Linus', `active`: FALSE});",
]);
});
test("builds best-effort Neo4j rollback statements", () => {
const statements = buildDataGridRollbackStatements({
databaseType: "neo4j",
tableMeta: {
tableName: "Employee",
primaryKeys: [DBX_NEO4J_ELEMENT_ID_COLUMN],
},
columns: [DBX_NEO4J_ELEMENT_ID_COLUMN, "name", "active"],
rows: [["4:abc:7", "Ada", true]],
dirtyRows: [[0, [[1, "Grace"]]]],
deletedRows: [0],
newRows: [[null, "Linus", false]],
});
assert.deepEqual(statements, [
"MATCH (n:`Employee`) WHERE n.`name` = 'Linus' AND n.`active` = FALSE DETACH DELETE n;",
"CREATE (n:`Employee` {`name`: 'Ada', `active`: TRUE});",
"MATCH (n:`Employee`) WHERE elementId(n) = '4:abc:7' SET n.`name` = 'Ada';",
]);
});
test("skips schema setup for Neo4j data grid saves", () => {
assert.equal(
dataGridSaveExecutionSchema("neo4j", { schema: "ignored", tableName: "Employee", primaryKeys: [] }),
undefined,
);
});
test("skips current_schema setup for Oracle data grid saves", () => {
assert.equal(
dataGridSaveExecutionSchema("oracle", { schema: "DBXTEST", tableName: "T", primaryKeys: [] }),
undefined,
);
assert.equal(
dataGridSaveExecutionSchema("postgres", { schema: "public", tableName: "T", primaryKeys: [] }),
"public",
);
});
test("normalizes Hive ACID update and delete errors", () => { test("normalizes Hive ACID update and delete errors", () => {
const error = normalizeDataGridSaveError( const error = normalizeDataGridSaveError(
"hive", "hive",
@@ -627,89 +145,3 @@ test("normalizes Hive ACID update and delete errors", () => {
"Hive UPDATE/DELETE are not enabled for this table or server. Add rows with INSERT, or enable ACID transactional tables in Hive before editing/deleting existing rows.", "Hive UPDATE/DELETE are not enabled for this table or server. Add rows with INSERT, or enable ACID transactional tables in Hive before editing/deleting existing rows.",
); );
}); });
test("rejects NULL writes to non-null table columns", () => {
const error = validateDataGridSave({
columns: ["ID", "CREATED_AT", "CITY"],
columnInfo: [
{ name: "ID", is_nullable: false, is_primary_key: true },
{ name: "CREATED_AT", is_nullable: false, is_primary_key: false },
{ name: "CITY", is_nullable: true, is_primary_key: false },
],
dirtyRows: [[0, [[1, null]]]],
newRows: [[2, null, "上海"]],
});
assert.equal(error, 'Column "CREATED_AT" does not allow NULL.');
});
test("allows NULL for MySQL auto increment columns when inserting rows", () => {
const error = validateDataGridSave({
databaseType: "mysql",
tableMeta: {
tableName: "people",
primaryKeys: ["id"],
},
columns: ["id", "name"],
rows: [],
columnInfo: [
{ name: "id", is_nullable: false, column_default: null, is_primary_key: true, extra: "auto_increment" },
{ name: "name", is_nullable: false, column_default: null, is_primary_key: false, extra: null },
],
dirtyRows: [],
newRows: [[null, "Ada"]],
});
assert.equal(error, undefined);
});
test("rejects inserted rows that duplicate existing primary key values", () => {
const error = validateDataGridSave({
databaseType: "postgres",
tableMeta: {
tableName: "education_data",
primaryKeys: ["country_code", "year"],
},
columns: ["country_code", "year", "value"],
rows: [["ALB", 2021, 0.812]],
columnInfo: [
{ name: "country_code", is_nullable: false, column_default: null, is_primary_key: true, extra: null },
{ name: "year", is_nullable: false, column_default: null, is_primary_key: true, extra: null },
{ name: "value", is_nullable: true, column_default: null, is_primary_key: false, extra: null },
],
dirtyRows: [],
newRows: [["ALB", 2021, 0.812]],
});
assert.equal(
error,
'New row duplicates the existing primary key (country_code = "ALB", year = 2021). Change the key before saving.',
);
});
test("rejects inserted rows that duplicate another new row primary key", () => {
const error = validateDataGridSave({
databaseType: "postgres",
tableMeta: {
tableName: "education_data",
primaryKeys: ["country_code", "year"],
},
columns: ["country_code", "year", "value"],
rows: [],
columnInfo: [
{ name: "country_code", is_nullable: false, column_default: null, is_primary_key: true, extra: null },
{ name: "year", is_nullable: false, column_default: null, is_primary_key: true, extra: null },
{ name: "value", is_nullable: true, column_default: null, is_primary_key: false, extra: null },
],
dirtyRows: [],
newRows: [
["ALB", 2021, 0.812],
["ALB", 2021, 0.913],
],
});
assert.equal(
error,
'New row duplicates another new row\'s primary key (country_code = "ALB", year = 2021). Change the key before saving.',
);
});
+7
View File
@@ -159,3 +159,10 @@ pub async fn analyze_sql_references(
pub fn analyze_editable_query_editability(sql: String) -> Result<dbx_core::sql_editability::QueryEditability, String> { pub fn analyze_editable_query_editability(sql: String) -> Result<dbx_core::sql_editability::QueryEditability, String> {
Ok(dbx_core::sql_editability::analyze_editable_query_editability(&sql)) Ok(dbx_core::sql_editability::analyze_editable_query_editability(&sql))
} }
#[tauri::command]
pub fn prepare_data_grid_save(
options: dbx_core::data_grid_sql::DataGridSaveStatementOptions,
) -> Result<dbx_core::data_grid_sql::DataGridSavePreparation, String> {
Ok(dbx_core::data_grid_sql::prepare_data_grid_save(options))
}
+1
View File
@@ -289,6 +289,7 @@ pub fn run() {
commands::query::execute_in_transaction, commands::query::execute_in_transaction,
commands::query::analyze_sql_references, commands::query::analyze_sql_references,
commands::query::analyze_editable_query_editability, commands::query::analyze_editable_query_editability,
commands::query::prepare_data_grid_save,
commands::sql_file::preview_sql_file, commands::sql_file::preview_sql_file,
commands::sql_file::execute_sql_file, commands::sql_file::execute_sql_file,
commands::sql_file::cancel_sql_file_execution, commands::sql_file::cancel_sql_file_execution,