feat(query): move grid save prep to Rust
This commit is contained in:
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);
|
||||||
|
|||||||
@@ -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,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 ");
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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/);
|
||||||
|
});
|
||||||
@@ -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.',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
Reference in new issue
Block a user