mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-30 16:21:39 +00:00
365 lines
14 KiB
TypeScript
365 lines
14 KiB
TypeScript
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<string>,
|
|
column: string | undefined,
|
|
value: GridCellValue,
|
|
): boolean {
|
|
if (!column || isOracleRowId(databaseType, column)) return false;
|
|
if (isNeo4jElementId(databaseType, column)) return false;
|
|
return value === null && notNullColumns.has(normalizeColumnName(column));
|
|
}
|
|
|
|
function 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 ");
|
|
}
|