diff --git a/apps/desktop/src/composables/useDataGridEditor.ts b/apps/desktop/src/composables/useDataGridEditor.ts index fece519a47..7f142867b7 100644 --- a/apps/desktop/src/composables/useDataGridEditor.ts +++ b/apps/desktop/src/composables/useDataGridEditor.ts @@ -1,12 +1,6 @@ import { ref, computed, nextTick, watch, onBeforeUnmount, type ComputedRef, type Ref } from "vue"; import * as api from "@/lib/api"; -import { - buildDataGridRollbackStatements, - buildDataGridSaveStatements, - dataGridSaveExecutionSchema, - normalizeDataGridSaveError, - validateDataGridSave, -} from "@/lib/dataGridSql"; +import { normalizeDataGridSaveError } from "@/lib/dataGridSql"; import { rowStatusFilterAfterAddingRow, type RowStatusFilter } from "@/lib/gridRowStatus"; import { supportsDataGridTransaction } from "@/lib/tableEditing"; import { useConnectionStore } from "@/stores/connectionStore"; @@ -636,30 +630,28 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { } const stmtOptions = saveStatementOptions(); - const validationError = stmtOptions - ? validateDataGridSave({ - databaseType: databaseType.value, - tableMeta: tableMeta.value, - columns: stmtOptions.columns, - sourceColumns: stmtOptions.sourceColumns, - rows: stmtOptions.rows, - columnInfo: tableMeta.value?.columns, - dirtyRows: stmtOptions.dirtyRows, - newRows: stmtOptions.newRows, - }) - : undefined; - if (validationError) { - saveError.value = validationError; + let preparedSave: Awaited> | undefined; + if (stmtOptions) { + try { + preparedSave = await api.prepareDataGridSave(stmtOptions); + } catch (e: any) { + saveError.value = normalizeDataGridSaveError(databaseType.value, e); + isSaving.value = false; + return; + } + } + if (preparedSave?.validationError) { + saveError.value = preparedSave.validationError; isSaving.value = false; return; } - const stmts = stmtOptions ? buildDataGridSaveStatements(stmtOptions) : []; + const stmts = preparedSave?.statements ?? []; if (stmts.length === 0) { isSaving.value = false; return; } - const rollbackStmts = stmtOptions ? buildDataGridRollbackStatements(stmtOptions) : []; + const rollbackStmts = preparedSave?.rollbackStatements ?? []; const start = Date.now(); let apiResult: { affected_rows?: number } | undefined; console.info("[DBX][dataGrid:save-statements]", { @@ -677,7 +669,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { connectionId.value, database.value, stmts, - dataGridSaveExecutionSchema(databaseType.value, tableMeta.value), + preparedSave?.executionSchema, ); } catch (e: any) { saveError.value = normalizeDataGridSaveError(databaseType.value, e); diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts index e470124985..62e47a0d00 100644 --- a/apps/desktop/src/lib/api.ts +++ b/apps/desktop/src/lib/api.ts @@ -88,6 +88,7 @@ export const cancelQuery = forward("cancelQuery"); export const closeQuerySession = forward("closeQuerySession"); export const analyzeSqlReferences = forward("analyzeSqlReferences"); export const analyzeEditableQueryEditability = forward("analyzeEditableQueryEditability"); +export const prepareDataGridSave = forward("prepareDataGridSave"); // AI export const aiComplete = forward("aiComplete"); diff --git a/apps/desktop/src/lib/dataGridSql.ts b/apps/desktop/src/lib/dataGridSql.ts index ab8a22e9a3..14a560fda8 100644 --- a/apps/desktop/src/lib/dataGridSql.ts +++ b/apps/desktop/src/lib/dataGridSql.ts @@ -1,10 +1,5 @@ import type { DatabaseType } from "@/types/database"; -import { - DBX_NEO4J_ELEMENT_ID_COLUMN, - DBX_ROWID_COLUMN, - DBX_TDENGINE_TBNAME_COLUMN, - usesKeylessRowPredicate, -} from "./tableEditing.ts"; +import { DBX_ROWID_COLUMN } from "./tableEditing.ts"; import { qualifiedTableName, quoteTableIdentifier } from "./tableSelectSql.ts"; export type GridCellValue = string | number | boolean | null; @@ -54,155 +49,6 @@ export interface DataGridCopyInsertStatementOptions { excludePrimaryKeys?: boolean; } -export interface DataGridSaveValidationOptions { - databaseType?: DatabaseType; - tableMeta?: DataGridTableMeta; - columns: string[]; - sourceColumns?: Array; - 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(); - for (const row of options.rows ?? []) { - const key = primaryKeyValueKey(primaryKeyIndexes, row); - if (key) existingKeys.add(key); - } - - const newKeys = new Set(); - 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[] { if (options.databaseType === "neo4j" || options.databaseType === "tdengine") return []; 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")};`; } -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, - 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, 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, -): 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 { - 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( sourceColumns: Array | undefined, columns: string[], @@ -451,27 +149,10 @@ function effectiveColumns( return sourceColumns; } -function effectiveColumn( - sourceColumns: Array | undefined, - columns: string[], - index: number, -): string | undefined { - return effectiveColumns(sourceColumns, columns)[index]; -} - function columnInfoByName(columns: DataGridColumnInfo[] | undefined): Map { 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 { const message = error instanceof Error ? error.message : String(error); 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}`; } -function buildPrimaryKeyWhere( - databaseType: DatabaseType | undefined, - primaryKeys: string[], - columns: Array, - row: GridCellValue[], - columnInfo: Map = 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, - row: GridCellValue[], - columnInfo: Map = 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( databaseType: DatabaseType | undefined, column: string, @@ -612,63 +256,15 @@ function isOracleRowId(databaseType: DatabaseType | undefined, name: string | un 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, - 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, target: string): number { const normalizedTarget = normalizeColumnName(target); 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 { return name.toUpperCase(); } -function nullWriteError(column: string): string { - return `Column "${column}" does not allow NULL.`; -} - function predicateIdent(databaseType: DatabaseType | undefined, name: string): string { 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 { 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 "); -} diff --git a/apps/desktop/src/lib/http.ts b/apps/desktop/src/lib/http.ts index da615b7ca7..aba854d2f0 100644 --- a/apps/desktop/src/lib/http.ts +++ b/apps/desktop/src/lib/http.ts @@ -50,6 +50,8 @@ import type { XlsxCellValue, } from "./tauri"; import type { QueryEditability } from "@/lib/sqlAnalysis"; +import type { DataGridSaveStatementOptions } from "@/lib/dataGridSql"; +import type { DataGridSavePreparation } from "./tauri"; // --------------------------------------------------------------------------- // Helpers @@ -409,6 +411,10 @@ export async function analyzeEditableQueryEditability(sql: string): Promise { + return post("/api/query/prepare-data-grid-save", { options }); +} + // --------------------------------------------------------------------------- // AI // --------------------------------------------------------------------------- diff --git a/apps/desktop/src/lib/tauri.ts b/apps/desktop/src/lib/tauri.ts index 040d9e17e4..dba02ed8fd 100644 --- a/apps/desktop/src/lib/tauri.ts +++ b/apps/desktop/src/lib/tauri.ts @@ -22,6 +22,7 @@ import type { } from "@/types/database"; import type { AiConfig } from "@/stores/settingsStore"; import type { QueryEditability } from "@/lib/sqlAnalysis"; +import type { DataGridSaveStatementOptions } from "@/lib/dataGridSql"; export interface AgentDriverInfo { db_type: string; @@ -317,6 +318,17 @@ export async function analyzeEditableQueryEditability(sql: string): Promise { + return invoke("prepare_data_grid_save", { options }); +} + export async function listIndexes( connectionId: string, database: string, diff --git a/crates/dbx-core/src/data_grid_sql.rs b/crates/dbx-core/src/data_grid_sql.rs new file mode 100644 index 0000000000..a2237a8e11 --- /dev/null +++ b/crates/dbx-core/src/data_grid_sql.rs @@ -0,0 +1,1299 @@ +use chrono::{Local, NaiveDateTime}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::models::connection::DatabaseType; + +const DBX_ROWID_COLUMN: &str = "__DBX_ROWID"; +const DBX_NEO4J_ELEMENT_ID_COLUMN: &str = "__DBX_ELEMENT_ID"; +const DBX_TDENGINE_TBNAME_COLUMN: &str = "tbname"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DataGridTableMeta { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub schema: Option, + pub table_name: String, + #[serde(default)] + pub primary_keys: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub columns: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DataGridColumnInfo { + pub name: String, + #[serde(default)] + pub data_type: String, + #[serde(default)] + pub is_nullable: bool, + #[serde(default)] + pub is_primary_key: bool, + #[serde(default)] + pub column_default: Option, + #[serde(default)] + pub extra: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DataGridSaveStatementOptions { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub database_type: Option, + pub table_meta: DataGridTableMeta, + pub columns: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_columns: Option>>, + #[serde(default)] + pub rows: Vec>, + #[serde(default)] + pub dirty_rows: Vec<(usize, Vec<(usize, Value)>)>, + #[serde(default)] + pub deleted_rows: Vec, + #[serde(default)] + pub new_rows: Vec>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DataGridSavePreparation { + #[serde(skip_serializing_if = "Option::is_none")] + pub validation_error: Option, + pub statements: Vec, + pub rollback_statements: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub execution_schema: Option, +} + +pub fn prepare_data_grid_save(options: DataGridSaveStatementOptions) -> DataGridSavePreparation { + let validation_error = validate_data_grid_save(&options); + if validation_error.is_some() { + return DataGridSavePreparation { + validation_error, + statements: Vec::new(), + rollback_statements: Vec::new(), + execution_schema: data_grid_save_execution_schema(options.database_type, &options.table_meta), + }; + } + + DataGridSavePreparation { + validation_error: None, + statements: build_data_grid_save_statements(&options), + rollback_statements: build_data_grid_rollback_statements(&options), + execution_schema: data_grid_save_execution_schema(options.database_type, &options.table_meta), + } +} + +fn validate_data_grid_save(options: &DataGridSaveStatementOptions) -> Option { + if let Some(error) = validate_inserted_primary_keys(options) { + return Some(error); + } + + let not_null_columns: Vec = options + .table_meta + .columns + .as_deref() + .unwrap_or(&[]) + .iter() + .filter(|column| { + !column.is_nullable + && column.column_default.is_none() + && !is_auto_generated_column(column) + && !is_oracle_row_id(options.database_type, Some(&column.name)) + }) + .map(|column| normalize_column_name(&column.name)) + .collect(); + if not_null_columns.is_empty() { + return None; + } + + for (_, changes) in &options.dirty_rows { + for (column_index, value) in changes { + let source_column = effective_column(options, *column_index); + if is_null_write_to_not_null_column(options.database_type, ¬_null_columns, source_column, value) { + return Some(null_write_error(source_column.unwrap_or_default())); + } + } + } + + for row in &options.new_rows { + for column_index in 0..options.columns.len() { + let source_column = effective_column(options, column_index); + if is_null_write_to_not_null_column( + options.database_type, + ¬_null_columns, + source_column, + row.get(column_index).unwrap_or(&Value::Null), + ) { + return Some(null_write_error(source_column.unwrap_or_default())); + } + } + } + + None +} + +fn validate_inserted_primary_keys(options: &DataGridSaveStatementOptions) -> Option { + let primary_keys = &options.table_meta.primary_keys; + if primary_keys.is_empty() || options.new_rows.is_empty() { + return None; + } + + let save_columns = effective_columns(options); + let primary_key_indexes: Vec> = + primary_keys.iter().map(|primary_key| find_column_index(&save_columns, primary_key)).collect(); + if primary_key_indexes.iter().any(Option::is_none) { + return None; + } + let primary_key_indexes: Vec = primary_key_indexes.into_iter().flatten().collect(); + + let mut existing_keys: Vec = Vec::new(); + for row in &options.rows { + if let Some(key) = primary_key_value_key(&primary_key_indexes, row) { + existing_keys.push(key); + } + } + + let mut new_keys: Vec = Vec::new(); + for row in &options.new_rows { + let Some(key) = primary_key_value_key(&primary_key_indexes, row) else { + continue; + }; + if existing_keys.contains(&key) || new_keys.contains(&key) { + return Some(duplicate_primary_key_error( + primary_keys, + &primary_key_indexes, + row, + existing_keys.contains(&key), + )); + } + new_keys.push(key); + } + + None +} + +fn build_data_grid_save_statements(options: &DataGridSaveStatementOptions) -> Vec { + if options.database_type == Some(DatabaseType::Neo4j) { + return build_neo4j_data_grid_save_statements(options); + } + if options.database_type == Some(DatabaseType::Tdengine) { + return build_tdengine_data_grid_save_statements(options); + } + + let save_columns = effective_columns(options); + let column_info = options.table_meta.columns.as_deref().unwrap_or(&[]); + let table = qualified_table_name( + options.database_type, + options.table_meta.schema.as_deref(), + &options.table_meta.table_name, + ); + let mut statements = Vec::new(); + + for (row_index, changes) in &options.dirty_rows { + let Some(row) = options.rows.get(*row_index) else { + continue; + }; + let sets = changes + .iter() + .filter_map(|(column_index, value)| { + let column = save_columns.get(*column_index)?.as_deref()?; + if is_oracle_row_id(options.database_type, Some(column)) { + return None; + } + Some(format!( + "{} = {}", + quote_ident(options.database_type, column), + format_grid_sql_literal(value, options.database_type, column_info_for(column_info, column)) + )) + }) + .collect::>() + .join(", "); + if sets.is_empty() { + continue; + } + let where_clause = build_primary_key_where( + options.database_type, + &options.table_meta.primary_keys, + &save_columns, + row, + column_info, + ); + statements.push(format!("UPDATE {table} SET {sets} WHERE {where_clause};")); + } + + for row_index in &options.deleted_rows { + let Some(row) = options.rows.get(*row_index) else { + continue; + }; + let where_clause = build_primary_key_where( + options.database_type, + &options.table_meta.primary_keys, + &save_columns, + row, + column_info, + ); + statements.push(format!("DELETE FROM {table} WHERE {where_clause};")); + } + + for row in &options.new_rows { + let insert_pairs: Vec<(&str, &Value)> = save_columns + .iter() + .enumerate() + .filter_map(|(index, column)| Some((column.as_deref()?, row.get(index).unwrap_or(&Value::Null)))) + .filter(|(column, _)| !is_oracle_row_id(options.database_type, Some(column))) + .filter(|(_, value)| !value.is_null()) + .collect(); + if insert_pairs.is_empty() { + continue; + } + let columns = insert_pairs + .iter() + .map(|(column, _)| quote_ident(options.database_type, column)) + .collect::>() + .join(", "); + let values = insert_pairs + .iter() + .map(|(column, value)| { + format_grid_sql_literal(value, options.database_type, column_info_for(column_info, column)) + }) + .collect::>() + .join(", "); + statements.push(format!("INSERT INTO {table} ({columns}) VALUES ({values});")); + } + + statements +} + +fn build_tdengine_data_grid_save_statements(options: &DataGridSaveStatementOptions) -> Vec { + let save_columns = effective_columns(options); + let mut statements = Vec::new(); + let can_overwrite_existing_rows = save_columns + .iter() + .any(|column| column.as_deref().is_some_and(|column| column.eq_ignore_ascii_case(DBX_TDENGINE_TBNAME_COLUMN))); + + if can_overwrite_existing_rows { + for (row_index, changes) in &options.dirty_rows { + let Some(row) = options.rows.get(*row_index) else { + continue; + }; + let mut after_row = row.clone(); + for (column_index, value) in changes { + if *column_index < after_row.len() { + after_row[*column_index] = value.clone(); + } + } + if let Some(statement) = build_tdengine_insert_statement(options, &save_columns, &after_row) { + statements.push(statement); + } + } + } + + for row in &options.new_rows { + if let Some(statement) = build_tdengine_insert_statement(options, &save_columns, row) { + statements.push(statement); + } + } + + statements +} + +fn build_tdengine_insert_statement( + options: &DataGridSaveStatementOptions, + save_columns: &[Option], + row: &[Value], +) -> Option { + let tbname = tdengine_tbname_value(save_columns, row); + let table = qualified_table_name( + Some(DatabaseType::Tdengine), + options.table_meta.schema.as_deref(), + &options.table_meta.table_name, + ); + let tag_columns = tdengine_tag_columns(options.table_meta.columns.as_deref()); + let insert_pairs: Vec<(&str, &Value)> = save_columns + .iter() + .enumerate() + .filter_map(|(index, column)| Some((column.as_deref()?, row.get(index).unwrap_or(&Value::Null)))) + .filter(|(_, value)| !value.is_null()) + .filter(|(column, _)| { + tdengine_can_insert_column(column, &options.table_meta.table_name, tbname.as_deref(), &tag_columns) + }) + .collect(); + if insert_pairs.is_empty() { + return None; + } + let columns = insert_pairs + .iter() + .map(|(column, _)| quote_ident(Some(DatabaseType::Tdengine), column)) + .collect::>() + .join(", "); + let values = insert_pairs + .iter() + .map(|(_, value)| format_grid_sql_literal(value, Some(DatabaseType::Tdengine), None)) + .collect::>() + .join(", "); + Some(format!("INSERT INTO {table} ({columns}) VALUES ({values});")) +} + +fn build_data_grid_rollback_statements(options: &DataGridSaveStatementOptions) -> Vec { + if options.database_type == Some(DatabaseType::Neo4j) { + return build_neo4j_data_grid_rollback_statements(options); + } + + let save_columns = effective_columns(options); + let column_info = options.table_meta.columns.as_deref().unwrap_or(&[]); + let table = qualified_table_name( + options.database_type, + options.table_meta.schema.as_deref(), + &options.table_meta.table_name, + ); + let mut statements = Vec::new(); + + for row in &options.new_rows { + let where_clause = build_row_where(options.database_type, &save_columns, row, column_info); + if !where_clause.is_empty() { + statements.push(format!("DELETE FROM {table} WHERE {where_clause};")); + } + } + + for row_index in &options.deleted_rows { + let Some(row) = options.rows.get(*row_index) else { + continue; + }; + let insert_pairs: Vec<(&str, &Value)> = save_columns + .iter() + .enumerate() + .filter_map(|(index, column)| Some((column.as_deref()?, row.get(index).unwrap_or(&Value::Null)))) + .filter(|(column, _)| !is_oracle_row_id(options.database_type, Some(column))) + .collect(); + let columns = insert_pairs + .iter() + .map(|(column, _)| quote_ident(options.database_type, column)) + .collect::>() + .join(", "); + let values = insert_pairs + .iter() + .map(|(column, value)| { + format_grid_sql_literal(value, options.database_type, column_info_for(column_info, column)) + }) + .collect::>() + .join(", "); + statements.push(format!("INSERT INTO {table} ({columns}) VALUES ({values});")); + } + + for (row_index, changes) in &options.dirty_rows { + let Some(row) = options.rows.get(*row_index) else { + continue; + }; + let mut after_row = row.clone(); + for (column_index, value) in changes { + if *column_index < after_row.len() { + after_row[*column_index] = value.clone(); + } + } + let writable_changes: Vec<(&(usize, Value), &str)> = changes + .iter() + .filter_map(|change @ (column_index, _)| { + let column = save_columns.get(*column_index)?.as_deref()?; + if is_oracle_row_id(options.database_type, Some(column)) { + return None; + } + Some((change, column)) + }) + .collect(); + let sets = writable_changes + .iter() + .map(|((column_index, _), column)| { + format!( + "{} = {}", + quote_ident(options.database_type, column), + format_grid_sql_literal( + row.get(*column_index).unwrap_or(&Value::Null), + options.database_type, + column_info_for(column_info, column) + ) + ) + }) + .collect::>() + .join(", "); + if sets.is_empty() { + continue; + } + let mut predicates = vec![build_primary_key_where( + options.database_type, + &options.table_meta.primary_keys, + &save_columns, + &after_row, + column_info, + )]; + predicates.extend(writable_changes.iter().map(|((_, value), column)| { + build_column_predicate(options.database_type, column, value, column_info_for(column_info, column)) + })); + statements.push(format!( + "UPDATE {table} SET {sets} WHERE {};", + predicates.into_iter().filter(|part| !part.is_empty()).collect::>().join(" AND ") + )); + } + + statements +} + +fn build_neo4j_data_grid_save_statements(options: &DataGridSaveStatementOptions) -> Vec { + let label = quote_ident(Some(DatabaseType::Neo4j), &options.table_meta.table_name); + let mut statements = Vec::new(); + + for (row_index, changes) in &options.dirty_rows { + let Some(row) = options.rows.get(*row_index) else { + continue; + }; + let sets = changes + .iter() + .filter_map(|(column_index, value)| { + let column = options.columns.get(*column_index)?; + if is_neo4j_element_id(Some(DatabaseType::Neo4j), Some(column)) { + return None; + } + Some(format!( + "n.{} = {}", + quote_ident(Some(DatabaseType::Neo4j), column), + format_grid_sql_literal(value, Some(DatabaseType::Neo4j), None) + )) + }) + .collect::>() + .join(", "); + if sets.is_empty() { + continue; + } + statements + .push(format!("MATCH (n:{label}) WHERE {} SET {sets};", neo4j_element_id_predicate(&options.columns, row))); + } + + for row_index in &options.deleted_rows { + let Some(row) = options.rows.get(*row_index) else { + continue; + }; + statements.push(format!( + "MATCH (n:{label}) WHERE {} DETACH DELETE n;", + neo4j_element_id_predicate(&options.columns, row) + )); + } + + for row in &options.new_rows { + let props = options + .columns + .iter() + .enumerate() + .filter(|(_, column)| !is_neo4j_element_id(Some(DatabaseType::Neo4j), Some(column))) + .filter_map(|(index, column)| { + let value = row.get(index).unwrap_or(&Value::Null); + if value.is_null() { + return None; + } + Some(format!( + "{}: {}", + quote_ident(Some(DatabaseType::Neo4j), column), + format_grid_sql_literal(value, Some(DatabaseType::Neo4j), None) + )) + }) + .collect::>() + .join(", "); + statements.push(if props.is_empty() { + format!("CREATE (n:{label});") + } else { + format!("CREATE (n:{label} {{{props}}});") + }); + } + + statements +} + +fn build_neo4j_data_grid_rollback_statements(options: &DataGridSaveStatementOptions) -> Vec { + let label = quote_ident(Some(DatabaseType::Neo4j), &options.table_meta.table_name); + let mut statements = Vec::new(); + + for row in &options.new_rows { + let where_clause = neo4j_row_property_where(&options.columns, row); + statements.push(if where_clause.is_empty() { + format!("MATCH (n:{label}) DETACH DELETE n;") + } else { + format!("MATCH (n:{label}) WHERE {where_clause} DETACH DELETE n;") + }); + } + + for row_index in &options.deleted_rows { + let Some(row) = options.rows.get(*row_index) else { + continue; + }; + let props = options + .columns + .iter() + .enumerate() + .filter(|(_, column)| !is_neo4j_element_id(Some(DatabaseType::Neo4j), Some(column))) + .filter_map(|(index, column)| { + let value = row.get(index).unwrap_or(&Value::Null); + if value.is_null() { + return None; + } + Some(format!( + "{}: {}", + quote_ident(Some(DatabaseType::Neo4j), column), + format_grid_sql_literal(value, Some(DatabaseType::Neo4j), None) + )) + }) + .collect::>() + .join(", "); + statements.push(if props.is_empty() { + format!("CREATE (n:{label});") + } else { + format!("CREATE (n:{label} {{{props}}});") + }); + } + + for (row_index, changes) in &options.dirty_rows { + let Some(row) = options.rows.get(*row_index) else { + continue; + }; + let sets = changes + .iter() + .filter_map(|(column_index, _)| { + let column = options.columns.get(*column_index)?; + if is_neo4j_element_id(Some(DatabaseType::Neo4j), Some(column)) { + return None; + } + Some(format!( + "n.{} = {}", + quote_ident(Some(DatabaseType::Neo4j), column), + format_grid_sql_literal( + row.get(*column_index).unwrap_or(&Value::Null), + Some(DatabaseType::Neo4j), + None + ) + )) + }) + .collect::>() + .join(", "); + if sets.is_empty() { + continue; + } + statements + .push(format!("MATCH (n:{label}) WHERE {} SET {sets};", neo4j_element_id_predicate(&options.columns, row))); + } + + statements +} + +fn effective_columns(options: &DataGridSaveStatementOptions) -> Vec> { + match &options.source_columns { + Some(source_columns) if source_columns.len() == options.columns.len() => source_columns.clone(), + _ => options.columns.iter().map(|column| Some(column.clone())).collect(), + } +} + +fn effective_column(options: &DataGridSaveStatementOptions, index: usize) -> Option<&str> { + match &options.source_columns { + Some(source_columns) if source_columns.len() == options.columns.len() => source_columns.get(index)?.as_deref(), + _ => options.columns.get(index).map(String::as_str), + } +} + +fn data_grid_save_execution_schema( + database_type: Option, + table_meta: &DataGridTableMeta, +) -> Option { + if matches!(database_type, Some(DatabaseType::Neo4j | DatabaseType::Oracle)) { + return None; + } + table_meta.schema.clone() +} + +pub fn normalize_data_grid_save_error(database_type: Option, error: &str) -> String { + if database_type == Some(DatabaseType::Hive) + && (error.contains("Attempt to do update or delete") || error.contains("Error 10294")) + { + return "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.".to_string(); + } + error.to_string() +} + +pub fn format_grid_sql_literal( + value: &Value, + database_type: Option, + column_info: Option<&DataGridColumnInfo>, +) -> String { + if value.is_null() { + return "NULL".to_string(); + } + if let Some(value) = value.as_bool() { + return if value { "TRUE" } else { "FALSE" }.to_string(); + } + if let Some(number) = value.as_number() { + return number.to_string(); + } + let text = value.as_str().map_or_else(|| value.to_string(), ToString::to_string); + if text.is_empty() { + return if database_type == Some(DatabaseType::SqlServer) { "N''" } else { "''" }.to_string(); + } + let literal_text = if database_type == Some(DatabaseType::Tdengine) { + format_tdengine_timestamp_literal_text(&text) + } else if is_mysql_datetime_literal_database(database_type) + && column_info.map(|column| is_temporal_column_type(&column.data_type)).unwrap_or(true) + { + format_mysql_temporal_literal_text(&text, column_info.map(|column| column.data_type.as_str())) + } else { + text + }; + let escaped = format!("'{}'", literal_text.replace('\\', "\\\\").replace('\'', "''")); + if database_type == Some(DatabaseType::SqlServer) { + format!("N{escaped}") + } else { + escaped + } +} + +fn is_mysql_datetime_literal_database(database_type: Option) -> bool { + matches!( + database_type, + Some( + DatabaseType::Mysql + | DatabaseType::Doris + | DatabaseType::StarRocks + | DatabaseType::Goldendb + | DatabaseType::Sundb + ) + ) +} + +fn format_mysql_temporal_literal_text(text: &str, data_type: Option<&str>) -> String { + let Some(captures) = regex_like_rfc3339(text) else { + return text.to_string(); + }; + match temporal_column_kind(data_type) { + Some("date") => captures.date, + Some("time") => { + format!("{}{}", captures.time, normalize_mysql_fractional_seconds(captures.fraction.as_deref())) + } + _ => format!( + "{} {}{}", + captures.date, + captures.time, + normalize_mysql_fractional_seconds(captures.fraction.as_deref()) + ), + } +} + +struct Rfc3339Parts { + date: String, + time: String, + fraction: Option, +} + +fn regex_like_rfc3339(text: &str) -> Option { + let bytes = text.as_bytes(); + if bytes.len() < 20 || bytes.get(4) != Some(&b'-') || bytes.get(7) != Some(&b'-') { + return None; + } + let separator = *bytes.get(10)?; + if separator != b'T' && separator != b' ' { + return None; + } + if bytes.get(13) != Some(&b':') || bytes.get(16) != Some(&b':') { + return None; + } + let date = &text[0..10]; + let time = &text[11..19]; + let rest = &text[19..]; + let (fraction, zone) = if let Some(rest) = rest.strip_prefix('.') { + let digit_count = rest.chars().take_while(|ch| ch.is_ascii_digit()).count(); + if digit_count == 0 || digit_count > 9 { + return None; + } + (Some(format!(".{}", &rest[..digit_count])), &rest[digit_count..]) + } else { + (None, rest) + }; + if zone == "Z" || zone == "z" || is_timezone_offset(zone) { + Some(Rfc3339Parts { date: date.to_string(), time: time.to_string(), fraction }) + } else { + None + } +} + +fn is_timezone_offset(value: &str) -> bool { + let bytes = value.as_bytes(); + bytes.len() == 6 + && matches!(bytes[0], b'+' | b'-') + && bytes[3] == b':' + && bytes[1].is_ascii_digit() + && bytes[2].is_ascii_digit() + && bytes[4].is_ascii_digit() + && bytes[5].is_ascii_digit() +} + +fn normalize_mysql_fractional_seconds(fraction: Option<&str>) -> String { + match fraction { + Some(fraction) if fraction.len() > 7 => fraction[..7].to_string(), + Some(fraction) => fraction.to_string(), + None => String::new(), + } +} + +fn is_temporal_column_type(data_type: &str) -> bool { + temporal_column_kind(Some(data_type)).is_some() +} + +fn temporal_column_kind(data_type: Option<&str>) -> Option<&'static str> { + let base = + data_type.unwrap_or("").trim().to_ascii_lowercase().split(['(', ':', ' ']).next().unwrap_or("").to_string(); + match base.as_str() { + "date" => Some("date"), + "time" => Some("time"), + "datetime" | "timestamp" => Some("datetime"), + _ => None, + } +} + +fn format_tdengine_timestamp_literal_text(text: &str) -> String { + let Some((date, time, fraction)) = parse_tdengine_timestamp(text) else { + return text.to_string(); + }; + format!( + "{date}T{time}{}{suffix}", + normalize_fractional_seconds(fraction.as_deref()), + suffix = local_timezone_offset_suffix(text) + ) +} + +fn parse_tdengine_timestamp(text: &str) -> Option<(String, String, Option)> { + if text.len() < 19 { + return None; + } + let bytes = text.as_bytes(); + if bytes.get(4) != Some(&b'-') || bytes.get(7) != Some(&b'-') || bytes.get(10) != Some(&b' ') { + return None; + } + if bytes.get(13) != Some(&b':') || bytes.get(16) != Some(&b':') { + return None; + } + let date = text[0..10].to_string(); + let time = text[11..19].to_string(); + let rest = &text[19..]; + if rest.is_empty() { + return Some((date, time, None)); + } + let fraction = rest.strip_prefix('.')?; + if fraction.is_empty() || fraction.len() > 9 || !fraction.chars().all(|ch| ch.is_ascii_digit()) { + return None; + } + Some((date, time, Some(format!(".{fraction}")))) +} + +fn normalize_fractional_seconds(fraction: Option<&str>) -> String { + match fraction { + Some(fraction) if fraction.len() >= 4 => fraction[..4].to_string(), + Some(fraction) => format!("{fraction:0<4}"), + None => ".000".to_string(), + } +} + +fn local_timezone_offset_suffix(text: &str) -> String { + let naive = NaiveDateTime::parse_from_str(&text.replace(' ', "T"), "%Y-%m-%dT%H:%M:%S%.f").ok(); + let offset_minutes = naive + .and_then(|dt| { + let local = dt.and_local_timezone(Local).earliest()?; + Some(local.offset().local_minus_utc() / -60) + }) + .unwrap_or_else(|| Local::now().offset().local_minus_utc() / -60); + let sign = if offset_minutes <= 0 { "+" } else { "-" }; + let abs = offset_minutes.abs(); + format!("{sign}{:02}:{:02}", abs / 60, abs % 60) +} + +fn build_primary_key_where( + database_type: Option, + primary_keys: &[String], + columns: &[Option], + row: &[Value], + column_info: &[DataGridColumnInfo], +) -> String { + if primary_keys.is_empty() && uses_keyless_row_predicate(database_type) { + return build_row_where(database_type, columns, row, column_info); + } + primary_keys + .iter() + .map(|primary_key| { + let value = row + .get( + columns + .iter() + .position(|column| column.as_deref() == Some(primary_key.as_str())) + .unwrap_or(usize::MAX), + ) + .unwrap_or(&Value::Null); + format!( + "{} = {}", + predicate_ident(database_type, primary_key), + format_grid_sql_literal(value, database_type, column_info_for(column_info, primary_key)) + ) + }) + .collect::>() + .join(" AND ") +} + +fn build_row_where( + database_type: Option, + columns: &[Option], + row: &[Value], + column_info: &[DataGridColumnInfo], +) -> String { + columns + .iter() + .enumerate() + .filter_map(|(index, column)| { + let column = column.as_deref()?; + if is_oracle_row_id(database_type, Some(column)) { + return None; + } + Some(build_column_predicate( + database_type, + column, + row.get(index).unwrap_or(&Value::Null), + column_info_for(column_info, column), + )) + }) + .collect::>() + .join(" AND ") +} + +fn build_column_predicate( + database_type: Option, + column: &str, + value: &Value, + column_info: Option<&DataGridColumnInfo>, +) -> String { + let ident = predicate_ident(database_type, column); + if value.is_null() { + format!("{ident} IS NULL") + } else { + format!("{ident} = {}", format_grid_sql_literal(value, database_type, column_info)) + } +} + +fn is_oracle_row_id(database_type: Option, name: Option<&str>) -> bool { + database_type == Some(DatabaseType::Oracle) && name.is_some_and(|name| name.eq_ignore_ascii_case(DBX_ROWID_COLUMN)) +} + +fn is_neo4j_element_id(database_type: Option, name: Option<&str>) -> bool { + database_type == Some(DatabaseType::Neo4j) && name == Some(DBX_NEO4J_ELEMENT_ID_COLUMN) +} + +fn is_auto_generated_column(column: &DataGridColumnInfo) -> bool { + column + .extra + .as_deref() + .unwrap_or("") + .to_ascii_lowercase() + .split(|ch: char| !ch.is_ascii_alphanumeric() && ch != '_') + .any(|part| matches!(part, "auto_increment" | "autoincrement" | "identity")) +} + +fn is_null_write_to_not_null_column( + database_type: Option, + not_null_columns: &[String], + column: Option<&str>, + value: &Value, +) -> bool { + let Some(column) = column else { + return false; + }; + if is_oracle_row_id(database_type, Some(column)) || is_neo4j_element_id(database_type, Some(column)) { + return false; + } + value.is_null() && not_null_columns.iter().any(|not_null| not_null == &normalize_column_name(column)) +} + +fn find_column_index(columns: &[Option], target: &str) -> Option { + let normalized_target = normalize_column_name(target); + columns + .iter() + .position(|column| column.as_deref().map(normalize_column_name).unwrap_or_default() == normalized_target) +} + +fn primary_key_value_key(primary_key_indexes: &[usize], row: &[Value]) -> Option { + let values: Vec = + primary_key_indexes.iter().map(|index| row.get(*index).cloned().unwrap_or(Value::Null)).collect(); + if values.iter().any(Value::is_null) { + return None; + } + serde_json::to_string(&values).ok() +} + +fn duplicate_primary_key_error( + primary_keys: &[String], + primary_key_indexes: &[usize], + row: &[Value], + matches_existing_row: bool, +) -> String { + let key_summary = primary_keys + .iter() + .enumerate() + .map(|(index, primary_key)| { + format!( + "{} = {}", + primary_key, + format_key_value_for_message(row.get(primary_key_indexes[index]).unwrap_or(&Value::Null)) + ) + }) + .collect::>() + .join(", "); + let source = if matches_existing_row { "the existing primary key" } else { "another new row's primary key" }; + format!("New row duplicates {source} ({key_summary}). Change the key before saving.") +} + +fn format_key_value_for_message(value: &Value) -> String { + if value.is_null() { + return "NULL".to_string(); + } + if let Some(value) = value.as_str() { + return format!("\"{}\"", value.replace('"', "\\\"")); + } + value.to_string() +} + +fn normalize_column_name(name: &str) -> String { + name.to_ascii_uppercase() +} + +fn null_write_error(column: &str) -> String { + format!("Column \"{column}\" does not allow NULL.") +} + +fn predicate_ident(database_type: Option, name: &str) -> String { + if is_oracle_row_id(database_type, Some(name)) { + "ROWIDTOCHAR(ROWID)".to_string() + } else { + quote_ident(database_type, name) + } +} + +fn quote_ident(database_type: Option, name: &str) -> String { + match database_type { + Some(DatabaseType::Mysql | DatabaseType::Hive | DatabaseType::Tdengine | DatabaseType::Access) => { + format!("`{}`", name.replace('`', "``")) + } + Some(DatabaseType::Informix) if is_plain_identifier(name) => name.to_string(), + Some(DatabaseType::Neo4j) => format!("`{}`", name.replace('`', "``")), + Some(DatabaseType::SqlServer) => format!("[{}]", name.replace(']', "]]")), + _ => format!("\"{}\"", name.replace('"', "\"\"")), + } +} + +fn qualified_table_name(database_type: Option, schema: Option<&str>, table_name: &str) -> String { + if is_schema_aware(database_type) && schema.is_some_and(|schema| !schema.is_empty()) { + format!("{}.{}", quote_ident(database_type, schema.unwrap()), quote_ident(database_type, table_name)) + } else { + quote_ident(database_type, table_name) + } +} + +fn is_schema_aware(database_type: Option) -> bool { + matches!( + database_type, + Some( + DatabaseType::Postgres + | DatabaseType::SqlServer + | DatabaseType::Oracle + | DatabaseType::Redshift + | DatabaseType::Dameng + | DatabaseType::Gaussdb + | DatabaseType::Kingbase + | DatabaseType::Highgo + | DatabaseType::Vastbase + | DatabaseType::Yashandb + | DatabaseType::Databricks + | DatabaseType::SapHana + | DatabaseType::Teradata + | DatabaseType::Vertica + | DatabaseType::Exasol + | DatabaseType::OpenGauss + | DatabaseType::OceanbaseOracle + | DatabaseType::Gbase + | DatabaseType::Jdbc + | DatabaseType::H2 + | DatabaseType::Snowflake + | DatabaseType::Trino + | DatabaseType::Db2 + | DatabaseType::Tdengine + ) + ) +} + +fn uses_keyless_row_predicate(database_type: Option) -> bool { + matches!( + database_type, + Some( + DatabaseType::Mysql + | DatabaseType::Postgres + | DatabaseType::Sqlite + | DatabaseType::DuckDb + | DatabaseType::SqlServer + | DatabaseType::Oracle + | DatabaseType::Doris + | DatabaseType::StarRocks + | DatabaseType::Redshift + | DatabaseType::Dameng + | DatabaseType::Gaussdb + | DatabaseType::Kingbase + | DatabaseType::Highgo + | DatabaseType::Vastbase + | DatabaseType::Goldendb + | DatabaseType::Yashandb + | DatabaseType::Databricks + | DatabaseType::SapHana + | DatabaseType::Teradata + | DatabaseType::Vertica + | DatabaseType::Firebird + | DatabaseType::Exasol + | DatabaseType::OpenGauss + | DatabaseType::OceanbaseOracle + | DatabaseType::Gbase + | DatabaseType::Access + | DatabaseType::H2 + | DatabaseType::Snowflake + | DatabaseType::Db2 + | DatabaseType::Informix + | DatabaseType::Bigquery + | DatabaseType::Sundb + | DatabaseType::Hive + ) + ) +} + +fn is_plain_identifier(name: &str) -> bool { + let mut chars = name.chars(); + let Some(first) = chars.next() else { + return false; + }; + (first.is_ascii_alphabetic() || first == '_') + && chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '$') +} + +fn column_info_for<'a>(columns: &'a [DataGridColumnInfo], name: &str) -> Option<&'a DataGridColumnInfo> { + let normalized = normalize_column_name(name); + columns.iter().find(|column| normalize_column_name(&column.name) == normalized) +} + +fn tdengine_tbname_value(save_columns: &[Option], row: &[Value]) -> Option { + let index = save_columns.iter().position(|column| { + column.as_deref().is_some_and(|column| column.eq_ignore_ascii_case(DBX_TDENGINE_TBNAME_COLUMN)) + })?; + let value = row.get(index)?; + if value.is_null() { + None + } else if let Some(value) = value.as_str() { + Some(value.to_string()) + } else { + Some(value.to_string()) + } +} + +fn tdengine_can_insert_column(column: &str, table_name: &str, tbname: Option<&str>, tag_columns: &[String]) -> bool { + let normalized = column.to_ascii_lowercase(); + let target_is_child_table = tbname.is_none_or(|tbname| tbname == table_name); + if !target_is_child_table { + return true; + } + if normalized == DBX_TDENGINE_TBNAME_COLUMN { + return false; + } + !tag_columns.iter().any(|tag| tag == &normalized) +} + +fn tdengine_tag_columns(columns: Option<&[DataGridColumnInfo]>) -> Vec { + columns + .unwrap_or(&[]) + .iter() + .filter(|column| column.extra.as_deref().unwrap_or("").to_ascii_lowercase().contains("tag")) + .map(|column| column.name.to_ascii_lowercase()) + .collect() +} + +fn neo4j_element_id_predicate(columns: &[String], row: &[Value]) -> String { + let index = columns.iter().position(|column| column == DBX_NEO4J_ELEMENT_ID_COLUMN).unwrap_or(usize::MAX); + format!( + "elementId(n) = {}", + format_grid_sql_literal(row.get(index).unwrap_or(&Value::Null), Some(DatabaseType::Neo4j), None) + ) +} + +fn neo4j_row_property_where(columns: &[String], row: &[Value]) -> String { + columns + .iter() + .enumerate() + .filter_map(|(index, column)| { + if is_neo4j_element_id(Some(DatabaseType::Neo4j), Some(column)) { + return None; + } + let value = row.get(index).unwrap_or(&Value::Null); + let ident = format!("n.{}", quote_ident(Some(DatabaseType::Neo4j), column)); + if value.is_null() { + Some(format!("{ident} IS NULL")) + } else { + Some(format!("{ident} = {}", format_grid_sql_literal(value, Some(DatabaseType::Neo4j), None))) + } + }) + .collect::>() + .join(" AND ") +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + fn column(name: &str, data_type: &str, nullable: bool, extra: Option<&str>) -> DataGridColumnInfo { + DataGridColumnInfo { + name: name.to_string(), + data_type: data_type.to_string(), + is_nullable: nullable, + is_primary_key: false, + column_default: None, + extra: extra.map(ToString::to_string), + } + } + + #[test] + fn prepares_sqlserver_save_statements() { + let result = prepare_data_grid_save(DataGridSaveStatementOptions { + database_type: Some(DatabaseType::SqlServer), + table_meta: DataGridTableMeta { + schema: Some("game".to_string()), + table_name: "player states".to_string(), + primary_keys: vec!["role id".to_string()], + columns: None, + }, + columns: vec!["role id".to_string(), "state".to_string(), "updated at".to_string()], + source_columns: None, + rows: vec![vec![json!(42), json!("old"), json!("2026-05-03")]], + dirty_rows: vec![(0, vec![(1, json!("ready")), (2, json!("2026-05-04"))])], + deleted_rows: vec![0], + new_rows: vec![vec![json!(43), json!("new"), json!("2026-05-05")]], + }); + + assert_eq!( + result.statements, + vec![ + "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] + fn skips_expression_only_source_columns() { + let result = prepare_data_grid_save(DataGridSaveStatementOptions { + database_type: Some(DatabaseType::Postgres), + table_meta: DataGridTableMeta { + schema: Some("public".to_string()), + table_name: "ihli_data".to_string(), + primary_keys: vec!["iso3".to_string(), "year".to_string()], + columns: None, + }, + columns: vec!["iso3".to_string(), "year".to_string(), "country_name".to_string(), "score".to_string()], + source_columns: Some(vec![ + Some("iso3".to_string()), + Some("year".to_string()), + Some("country_name".to_string()), + None, + ]), + rows: vec![vec![json!("LUX"), json!(2007), json!("Luxembourg"), json!(50242.1)]], + dirty_rows: vec![(0, vec![(2, json!("Luxembourg City")), (3, json!(999))])], + deleted_rows: vec![], + new_rows: vec![vec![json!("USA"), json!(2008), json!("United States"), json!(43000)]], + }); + + assert_eq!( + result.statements, + vec![ + r#"UPDATE "public"."ihli_data" SET "country_name" = 'Luxembourg City' WHERE "iso3" = 'LUX' AND "year" = 2007;"#, + r#"INSERT INTO "public"."ihli_data" ("iso3", "year", "country_name") VALUES ('USA', 2008, 'United States');"#, + ] + ); + } + + #[test] + fn formats_mysql_temporal_columns_by_target_type() { + let result = prepare_data_grid_save(DataGridSaveStatementOptions { + database_type: Some(DatabaseType::Mysql), + table_meta: DataGridTableMeta { + schema: None, + table_name: "policies".to_string(), + primary_keys: vec!["id".to_string()], + columns: Some(vec![ + column("id", "int", false, None), + column("insurance_start_time", "datetime", true, None), + column("raw_text", "varchar(64)", true, None), + column("coverage_day", "date", true, None), + column("start_clock", "time", true, None), + ]), + }, + columns: vec![ + "id".to_string(), + "insurance_start_time".to_string(), + "raw_text".to_string(), + "coverage_day".to_string(), + "start_clock".to_string(), + ], + source_columns: None, + rows: vec![vec![ + json!(1), + json!("2026-05-12T00:00:00+00:00"), + json!("old"), + json!("2026-05-12T00:00:00+00:00"), + json!("2026-05-12T09:30:45+00:00"), + ]], + dirty_rows: vec![( + 0, + vec![ + (1, json!("2026-05-12T00:00:00+00:00")), + (2, json!("2026-05-12T00:00:00+00:00")), + (3, json!("2026-05-12T00:00:00+00:00")), + (4, json!("2026-05-12T09:30:45+00:00")), + ], + )], + deleted_rows: vec![], + new_rows: vec![], + }); + + assert_eq!( + result.statements, + vec!["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;"] + ); + } + + #[test] + fn validates_duplicate_inserted_primary_keys() { + let result = prepare_data_grid_save(DataGridSaveStatementOptions { + database_type: Some(DatabaseType::Postgres), + table_meta: DataGridTableMeta { + schema: None, + table_name: "education_data".to_string(), + primary_keys: vec!["country_code".to_string(), "year".to_string()], + columns: None, + }, + columns: vec!["country_code".to_string(), "year".to_string(), "value".to_string()], + source_columns: None, + rows: vec![vec![json!("ALB"), json!(2021), json!(0.812)]], + dirty_rows: vec![], + deleted_rows: vec![], + new_rows: vec![vec![json!("ALB"), json!(2021), json!(0.913)]], + }); + + assert_eq!( + result.validation_error, + Some(r#"New row duplicates the existing primary key (country_code = "ALB", year = 2021). Change the key before saving."#.to_string()) + ); + assert!(result.statements.is_empty()); + } +} diff --git a/crates/dbx-core/src/lib.rs b/crates/dbx-core/src/lib.rs index 4dfe84ff8d..92dd2817b3 100644 --- a/crates/dbx-core/src/lib.rs +++ b/crates/dbx-core/src/lib.rs @@ -4,6 +4,7 @@ pub mod ai; pub mod connection; pub mod connection_secrets; pub mod csv_export; +pub mod data_grid_sql; pub mod database_capabilities; pub mod database_export; pub mod db; diff --git a/crates/dbx-web/src/main.rs b/crates/dbx-web/src/main.rs index c6e5fa610a..40147e101f 100644 --- a/crates/dbx-web/src/main.rs +++ b/crates/dbx-web/src/main.rs @@ -125,6 +125,7 @@ async fn main() { .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-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/close-session", post(routes::query::close_query_session)) // Redis diff --git a/crates/dbx-web/src/routes/query.rs b/crates/dbx-web/src/routes/query.rs index 34d8fcb871..31dfa01164 100644 --- a/crates/dbx-web/src/routes/query.rs +++ b/crates/dbx-web/src/routes/query.rs @@ -57,6 +57,12 @@ pub struct AnalyzeEditableQueryRequest { 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( State(state): State>, Json(req): Json, @@ -199,3 +205,9 @@ pub async fn analyze_editable_query_editability( ) -> Json { Json(dbx_core::sql_editability::analyze_editable_query_editability(&req.sql)) } + +pub async fn prepare_data_grid_save( + Json(req): Json, +) -> Json { + Json(dbx_core::data_grid_sql::prepare_data_grid_save(req.options)) +} diff --git a/packages/app-tests/dataGridEditor.test.ts b/packages/app-tests/dataGridEditor.test.ts index 5926793a1c..366cd76bb5 100644 --- a/packages/app-tests/dataGridEditor.test.ts +++ b/packages/app-tests/dataGridEditor.test.ts @@ -3,6 +3,7 @@ import test from "node:test"; import { computed, nextTick, ref } from "vue"; import { createPinia, setActivePinia } from "pinia"; 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"; type CellValue = string | number | boolean | null; @@ -17,6 +18,64 @@ function installBrowserTestGlobals() { key: () => null, 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 { diff --git a/packages/app-tests/dataGridSaveBackend.test.ts b/packages/app-tests/dataGridSaveBackend.test.ts new file mode 100644 index 0000000000..6abeb3fb03 --- /dev/null +++ b/packages/app-tests/dataGridSaveBackend.test.ts @@ -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/); +}); diff --git a/packages/app-tests/dataGridSql.test.ts b/packages/app-tests/dataGridSql.test.ts index 37a140e4b0..c683eb7e87 100644 --- a/packages/app-tests/dataGridSql.test.ts +++ b/packages/app-tests/dataGridSql.test.ts @@ -3,44 +3,9 @@ import test from "node:test"; import { buildDataGridCopyInsertStatement, buildDataGridCopyUpdateStatements, - buildDataGridRollbackStatements, - buildDataGridSaveStatements, - dataGridSaveExecutionSchema, normalizeDataGridSaveError, formatGridSqlLiteral, - validateDataGridSave, } 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", () => { const statements = buildDataGridCopyUpdateStatements({ @@ -123,286 +88,6 @@ test("skips copy-as-update statements when primary keys are unavailable", () => 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", () => { assert.equal( 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'"); }); -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", () => { const statements = buildDataGridCopyUpdateStatements({ databaseType: "mysql", @@ -494,128 +134,6 @@ function tdengineTimestampLiteral(text: string): string { 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", () => { const error = normalizeDataGridSaveError( "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.", ); }); - -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.', - ); -}); diff --git a/src-tauri/src/commands/query.rs b/src-tauri/src/commands/query.rs index 6c3962e6db..856d4b8d50 100644 --- a/src-tauri/src/commands/query.rs +++ b/src-tauri/src/commands/query.rs @@ -159,3 +159,10 @@ pub async fn analyze_sql_references( pub fn analyze_editable_query_editability(sql: String) -> Result { 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 { + Ok(dbx_core::data_grid_sql::prepare_data_grid_save(options)) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 03a1cf5d76..7b3dcbaaa2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -289,6 +289,7 @@ pub fn run() { commands::query::execute_in_transaction, commands::query::analyze_sql_references, commands::query::analyze_editable_query_editability, + commands::query::prepare_data_grid_save, commands::sql_file::preview_sql_file, commands::sql_file::execute_sql_file, commands::sql_file::cancel_sql_file_execution,