import type { DatabaseType } from "@/types/database"; import { DBX_NEO4J_ELEMENT_ID_COLUMN, DBX_ROWID_COLUMN } from "./tableEditing.ts"; import { qualifiedTableName, quoteTableIdentifier } from "./tableSelectSql.ts"; export type GridCellValue = string | number | boolean | null; export interface DataGridTableMeta { schema?: string; tableName: string; primaryKeys: string[]; } export interface DataGridColumnInfo { name: string; is_nullable: boolean; column_default?: string | null; extra?: string | null; } export interface DataGridSaveStatementOptions { databaseType?: DatabaseType; tableMeta: DataGridTableMeta; columns: string[]; rows: GridCellValue[][]; dirtyRows: Array<[number, Array<[number, GridCellValue]>]>; deletedRows: number[]; newRows: GridCellValue[][]; } export interface DataGridSaveValidationOptions { databaseType?: DatabaseType; columns: string[]; columnInfo?: DataGridColumnInfo[]; dirtyRows: Array<[number, Array<[number, GridCellValue]>]>; newRows: GridCellValue[][]; } export function validateDataGridSave(options: DataGridSaveValidationOptions): string | undefined { 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 column = options.columns[columnIndex]; if (isNullWriteToNotNullColumn(options.databaseType, notNullColumns, column, value)) { return nullWriteError(column); } } } for (const row of options.newRows) { for (const [columnIndex, column] of options.columns.entries()) { if (isNullWriteToNotNullColumn(options.databaseType, notNullColumns, column, row[columnIndex])) { return nullWriteError(column); } } } return undefined; } export function buildDataGridSaveStatements(options: DataGridSaveStatementOptions): string[] { if (options.databaseType === "neo4j") return buildNeo4jDataGridSaveStatements(options); 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]) => !isOracleRowId(options.databaseType, options.columns[columnIndex])) .map( ([columnIndex, value]) => `${quoteIdent(options.databaseType, options.columns[columnIndex])} = ${formatGridSqlLiteral(value, options.databaseType)}`, ) .join(", "); if (!sets) continue; const where = buildPrimaryKeyWhere(options.databaseType, options.tableMeta.primaryKeys, options.columns, row); 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, options.columns, row); statements.push(`DELETE FROM ${table} WHERE ${where};`); } for (const row of options.newRows) { const insertPairs = options.columns .map((column, index) => ({ column, value: row[index] })) .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)).join(", "); statements.push(`INSERT INTO ${table} (${columns}) VALUES (${values});`); } return statements; } export function buildDataGridRollbackStatements(options: DataGridSaveStatementOptions): string[] { if (options.databaseType === "neo4j") return buildNeo4jDataGridRollbackStatements(options); 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, options.columns, row); 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 = options.columns .map((column, index) => ({ column, value: row[index] })) .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)).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]) => !isOracleRowId(options.databaseType, options.columns[columnIndex]), ); const sets = writableChanges .map( ([columnIndex]) => `${quoteIdent(options.databaseType, options.columns[columnIndex])} = ${formatGridSqlLiteral(row[columnIndex], options.databaseType)}`, ) .join(", "); if (!sets) continue; const where = [ buildPrimaryKeyWhere(options.databaseType, options.tableMeta.primaryKeys, options.columns, afterRow), ...writableChanges.map(([columnIndex, value]) => buildColumnPredicate(options.databaseType, options.columns[columnIndex], value), ), ] .filter(Boolean) .join(" AND "); statements.push(`UPDATE ${table} SET ${sets} WHERE ${where};`); } return statements; } 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)) { 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."; } return message; } export function formatGridSqlLiteral(value: GridCellValue, databaseType?: DatabaseType): string { if (value === null || value === undefined) return "NULL"; if (typeof value === "boolean") return value ? "TRUE" : "FALSE"; if (typeof value === "number" && Number.isFinite(value)) return String(value); const text = String(value); if (text === "") return databaseType === "sqlserver" ? "N''" : "''"; const escaped = `'${text.replace(/\\/g, "\\\\").replace(/'/g, "''")}'`; return databaseType === "sqlserver" ? `N${escaped}` : escaped; } function buildPrimaryKeyWhere( databaseType: DatabaseType | undefined, primaryKeys: string[], columns: string[], row: GridCellValue[], ): string { if (databaseType === "hive" && primaryKeys.length === 0) return buildRowWhere(databaseType, columns, row); return primaryKeys .map((primaryKey) => { const value = row[columns.indexOf(primaryKey)]; return `${predicateIdent(databaseType, primaryKey)} = ${formatGridSqlLiteral(value, databaseType)}`; }) .join(" AND "); } function buildRowWhere(databaseType: DatabaseType | undefined, columns: string[], row: GridCellValue[]): string { return columns .map((column, index) => isOracleRowId(databaseType, column) ? "" : buildColumnPredicate(databaseType, column, row[index]), ) .filter(Boolean) .join(" AND "); } function buildColumnPredicate(databaseType: DatabaseType | undefined, column: string, value: GridCellValue): string { const ident = predicateIdent(databaseType, column); if (value === null || value === undefined) return `${ident} IS NULL`; return `${ident} = ${formatGridSqlLiteral(value, databaseType)}`; } function isOracleRowId(databaseType: DatabaseType | undefined, name: string | undefined): boolean { 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 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); } 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 "); }