mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-31 00:22:30 +00:00
fix(neo4j): support table querying and editing
This commit is contained in:
@@ -826,7 +826,7 @@ function currentWhereInput(): string | undefined {
|
||||
function currentOrderBy(): string | undefined {
|
||||
return (
|
||||
orderByInput.value.trim() ||
|
||||
(sortCol.value ? `${quoteIdent(sortCol.value)} ${sortDir.value.toUpperCase()}` : undefined)
|
||||
(sortCol.value ? `${queryColumnRef(sortCol.value)} ${sortDir.value.toUpperCase()}` : undefined)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1313,7 +1313,7 @@ type FilterMode =
|
||||
|
||||
function contextFilterCondition(mode: FilterMode): string | null {
|
||||
if (!contextColumn.value) return null;
|
||||
const column = quoteIdent(contextColumn.value);
|
||||
const column = queryColumnRef(contextColumn.value);
|
||||
const value = contextCellValue.value;
|
||||
|
||||
if (mode === "is-null") return `${column} IS NULL`;
|
||||
@@ -1355,6 +1355,7 @@ async function applyOrderBySearch() {
|
||||
databaseType: props.databaseType,
|
||||
schema: props.tableMeta.schema,
|
||||
tableName: props.tableMeta.tableName,
|
||||
columns: props.tableMeta.columns.map((column) => column.name),
|
||||
primaryKeys: props.tableMeta.primaryKeys,
|
||||
orderBy: orderByClause,
|
||||
limit: pageSize.value,
|
||||
@@ -1379,10 +1380,11 @@ async function applyWhereFilter() {
|
||||
databaseType: props.databaseType,
|
||||
schema: props.tableMeta.schema,
|
||||
tableName: props.tableMeta.tableName,
|
||||
columns: props.tableMeta.columns.map((column) => column.name),
|
||||
primaryKeys: props.tableMeta.primaryKeys,
|
||||
orderBy:
|
||||
orderByInput.value.trim() ||
|
||||
(sortCol.value ? `${quoteIdent(sortCol.value)} ${sortDir.value.toUpperCase()}` : undefined),
|
||||
(sortCol.value ? `${queryColumnRef(sortCol.value)} ${sortDir.value.toUpperCase()}` : undefined),
|
||||
limit: pageSize.value,
|
||||
whereInput: whereFilterInput.value.trim() || undefined,
|
||||
includeRowId: tableUsesSyntheticRowId.value,
|
||||
@@ -1408,6 +1410,11 @@ function quoteIdent(name: string): string {
|
||||
return quoteTableIdentifier(props.databaseType, name);
|
||||
}
|
||||
|
||||
function queryColumnRef(name: string): string {
|
||||
const quoted = quoteIdent(name);
|
||||
return props.databaseType === "neo4j" ? `n.${quoted}` : quoted;
|
||||
}
|
||||
|
||||
function escapeVal(value: CellValue): string {
|
||||
return formatGridSqlLiteral(value);
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||||
import * as api from "@/lib/api";
|
||||
import type { ConnectionConfig, ObjectInfo, ObjectSourceKind } from "@/types/database";
|
||||
import { isSchemaAware } from "@/lib/databaseCapabilities";
|
||||
import { qualifiedTableName } from "@/lib/tableSelectSql";
|
||||
import { buildTableSelectSql, qualifiedTableName } from "@/lib/tableSelectSql";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { buildExecutableObjectSourceSql, objectSourceSaveExecutionMode } from "@/lib/objectSourceEditor";
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
@@ -215,7 +215,15 @@ async function openSource(row: ObjectRow) {
|
||||
|
||||
function openNewQuery(row: ObjectRow) {
|
||||
const tabId = queryStore.createTab(props.connection.id, props.database, row.name);
|
||||
queryStore.updateSql(tabId, `SELECT * FROM ${qualifiedName(row)} LIMIT 100;`);
|
||||
queryStore.updateSql(
|
||||
tabId,
|
||||
buildTableSelectSql({
|
||||
databaseType: props.connection.db_type,
|
||||
schema: row.schema || selectedSchema.value,
|
||||
tableName: row.name,
|
||||
limit: 100,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function qualifiedName(row: ObjectRow): string {
|
||||
|
||||
@@ -415,6 +415,7 @@ async function openData() {
|
||||
databaseType: config.db_type,
|
||||
schema: node.schema,
|
||||
tableName: node.label,
|
||||
columns: columns.map((column) => column.name),
|
||||
primaryKeys: pks,
|
||||
includeRowId: usesSyntheticRowIdKey(config.db_type, pks),
|
||||
});
|
||||
@@ -896,7 +897,20 @@ async function exportData(format: "csv" | "json" | "sql") {
|
||||
isSchemaAware(config.db_type) && node.schema
|
||||
? `${quoteIdent(node.schema)}.${quoteIdent(node.label)}`
|
||||
: quoteIdent(node.label);
|
||||
const result = await api.executeQuery(node.connectionId, node.database, `SELECT * FROM ${qualifiedName}`);
|
||||
const queryColumns =
|
||||
config.db_type === "neo4j"
|
||||
? (await api.getColumns(node.connectionId, node.database, node.schema || node.database, node.label)).map(
|
||||
(column) => column.name,
|
||||
)
|
||||
: undefined;
|
||||
const dataSql = buildTableSelectSql({
|
||||
databaseType: config.db_type,
|
||||
schema: node.schema,
|
||||
tableName: node.label,
|
||||
columns: queryColumns,
|
||||
limit: 10000,
|
||||
});
|
||||
const result = await api.executeQuery(node.connectionId, node.database, dataSql);
|
||||
|
||||
let content: string;
|
||||
let ext: string;
|
||||
@@ -927,11 +941,20 @@ async function exportDataXlsx() {
|
||||
|
||||
try {
|
||||
await connectionStore.ensureConnected(node.connectionId);
|
||||
const qualifiedName =
|
||||
isSchemaAware(config.db_type) && node.schema
|
||||
? `${quoteIdent(node.schema)}.${quoteIdent(node.label)}`
|
||||
: quoteIdent(node.label);
|
||||
const result = await api.executeQuery(node.connectionId, node.database, `SELECT * FROM ${qualifiedName}`);
|
||||
const queryColumns =
|
||||
config.db_type === "neo4j"
|
||||
? (await api.getColumns(node.connectionId, node.database, node.schema || node.database, node.label)).map(
|
||||
(column) => column.name,
|
||||
)
|
||||
: undefined;
|
||||
const dataSql = buildTableSelectSql({
|
||||
databaseType: config.db_type,
|
||||
schema: node.schema,
|
||||
tableName: node.label,
|
||||
columns: queryColumns,
|
||||
limit: 10000,
|
||||
});
|
||||
const result = await api.executeQuery(node.connectionId, node.database, dataSql);
|
||||
|
||||
const { buildXlsxWorkbook } = await import("@/lib/xlsxExport");
|
||||
const workbook = buildXlsxWorkbook({
|
||||
|
||||
@@ -37,6 +37,7 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
|
||||
databaseType: config?.db_type,
|
||||
schema: tab.tableMeta?.schema,
|
||||
tableName: tab.tableMeta?.tableName ?? "",
|
||||
columns: tab.tableMeta?.columns.map((column) => column.name),
|
||||
primaryKeys,
|
||||
fallbackOrderColumns,
|
||||
includeRowId: useRowId,
|
||||
@@ -100,7 +101,11 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
|
||||
if (tab.mode === "data") {
|
||||
if (!tab.tableMeta) return;
|
||||
tab.whereInput = whereInput ?? "";
|
||||
const orderBy = direction ? `${quoteIdent(tab, column)} ${direction.toUpperCase()}` : undefined;
|
||||
const config = connectionStore.getConfig(tab.connectionId);
|
||||
const quotedColumn = quoteIdent(tab, column);
|
||||
const orderBy = direction
|
||||
? `${config?.db_type === "neo4j" ? `n.${quotedColumn}` : quotedColumn} ${direction.toUpperCase()}`
|
||||
: undefined;
|
||||
const sql = buildTableSql(tab, { orderBy, whereInput });
|
||||
queryStore.updateSql(tab.id, sql);
|
||||
await queryStore.executeCurrentTab();
|
||||
|
||||
@@ -27,6 +27,27 @@ async function openTableTarget(target: NavigationTarget) {
|
||||
await connectionStore.ensureConnected(target.connectionId);
|
||||
if (!config) throw new Error("Connection config not found");
|
||||
const querySchema = target.schema || target.database;
|
||||
if (config.db_type === "neo4j") {
|
||||
const columns = await api.getColumns(target.connectionId, target.database, querySchema, target.tableName);
|
||||
const primaryKeys = editablePrimaryKeys(config.db_type, columns);
|
||||
const sql = buildTableSelectSql({
|
||||
databaseType: config.db_type,
|
||||
schema: target.schema,
|
||||
tableName: target.tableName,
|
||||
columns: columns.map((column) => column.name),
|
||||
primaryKeys,
|
||||
whereInput: target.whereInput,
|
||||
});
|
||||
queryStore.updateSql(tabId, sql);
|
||||
queryStore.setTableMeta(tabId, {
|
||||
schema: target.schema,
|
||||
tableName: target.tableName,
|
||||
columns,
|
||||
primaryKeys,
|
||||
});
|
||||
await queryStore.executeTabSql(tabId, sql);
|
||||
return;
|
||||
}
|
||||
const sql = buildTableSelectSql({
|
||||
databaseType: config.db_type,
|
||||
schema: target.schema,
|
||||
|
||||
+106
-1
@@ -1,5 +1,5 @@
|
||||
import type { DatabaseType } from "@/types/database";
|
||||
import { DBX_ROWID_COLUMN } from "./tableEditing.ts";
|
||||
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;
|
||||
@@ -70,6 +70,8 @@ export function validateDataGridSave(options: DataGridSaveValidationOptions): st
|
||||
}
|
||||
|
||||
export function buildDataGridSaveStatements(options: DataGridSaveStatementOptions): string[] {
|
||||
if (options.databaseType === "neo4j") return buildNeo4jDataGridSaveStatements(options);
|
||||
|
||||
const table = qualifiedTableName({
|
||||
databaseType: options.databaseType,
|
||||
schema: options.tableMeta.schema,
|
||||
@@ -114,6 +116,8 @@ export function buildDataGridSaveStatements(options: DataGridSaveStatementOption
|
||||
}
|
||||
|
||||
export function buildDataGridRollbackStatements(options: DataGridSaveStatementOptions): string[] {
|
||||
if (options.databaseType === "neo4j") return buildNeo4jDataGridRollbackStatements(options);
|
||||
|
||||
const table = qualifiedTableName({
|
||||
databaseType: options.databaseType,
|
||||
schema: options.tableMeta.schema,
|
||||
@@ -172,6 +176,7 @@ export function dataGridSaveExecutionSchema(
|
||||
databaseType: DatabaseType | undefined,
|
||||
tableMeta: DataGridTableMeta | undefined,
|
||||
): string | undefined {
|
||||
if (databaseType === "neo4j") return undefined;
|
||||
if (databaseType === "oracle") return undefined;
|
||||
return tableMeta?.schema;
|
||||
}
|
||||
@@ -219,6 +224,10 @@ 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 ?? "");
|
||||
}
|
||||
@@ -230,6 +239,7 @@ function isNullWriteToNotNullColumn(
|
||||
value: GridCellValue,
|
||||
): boolean {
|
||||
if (!column || isOracleRowId(databaseType, column)) return false;
|
||||
if (isNeo4jElementId(databaseType, column)) return false;
|
||||
return value === null && notNullColumns.has(normalizeColumnName(column));
|
||||
}
|
||||
|
||||
@@ -248,3 +258,98 @@ 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 ");
|
||||
}
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import type { ColumnInfo, DatabaseType } from "@/types/database";
|
||||
|
||||
export const DBX_ROWID_COLUMN = "__DBX_ROWID";
|
||||
export const DBX_NEO4J_ELEMENT_ID_COLUMN = "__DBX_ELEMENT_ID";
|
||||
|
||||
export function editablePrimaryKeys(databaseType: DatabaseType | undefined, columns: ColumnInfo[]): string[] {
|
||||
const primaryKeys = columns.filter((column) => column.is_primary_key).map((column) => column.name);
|
||||
if (databaseType === "oracle" && primaryKeys.length === 0) return [DBX_ROWID_COLUMN];
|
||||
if (databaseType === "neo4j" && primaryKeys.length === 0) return [DBX_NEO4J_ELEMENT_ID_COLUMN];
|
||||
return primaryKeys;
|
||||
}
|
||||
|
||||
export function usesSyntheticRowIdKey(databaseType: DatabaseType | undefined, primaryKeys: string[]): boolean {
|
||||
return databaseType === "oracle" && primaryKeys.length === 1 && primaryKeys[0].toUpperCase() === DBX_ROWID_COLUMN;
|
||||
return (
|
||||
primaryKeys.length === 1 &&
|
||||
((databaseType === "oracle" && primaryKeys[0].toUpperCase() === DBX_ROWID_COLUMN) ||
|
||||
(databaseType === "neo4j" && primaryKeys[0] === DBX_NEO4J_ELEMENT_ID_COLUMN))
|
||||
);
|
||||
}
|
||||
|
||||
export function isHiddenGridColumn(
|
||||
@@ -17,5 +23,6 @@ export function isHiddenGridColumn(
|
||||
column: string,
|
||||
primaryKeys: string[],
|
||||
): boolean {
|
||||
if (databaseType === "neo4j" && column === DBX_NEO4J_ELEMENT_ID_COLUMN) return true;
|
||||
return usesSyntheticRowIdKey(databaseType, primaryKeys) && column.toUpperCase() === DBX_ROWID_COLUMN;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import type { DatabaseType } from "../types/database.ts";
|
||||
import { isSchemaAware, usesFetchFirst } from "./databaseCapabilities.ts";
|
||||
import { DBX_ROWID_COLUMN } from "./tableEditing.ts";
|
||||
import { DBX_NEO4J_ELEMENT_ID_COLUMN, DBX_ROWID_COLUMN } from "./tableEditing.ts";
|
||||
|
||||
export interface BuildTableSelectSqlOptions {
|
||||
databaseType?: DatabaseType;
|
||||
schema?: string;
|
||||
tableName: string;
|
||||
primaryKeys?: string[];
|
||||
columns?: string[];
|
||||
fallbackOrderColumns?: string[];
|
||||
orderBy?: string;
|
||||
limit?: number;
|
||||
@@ -17,10 +18,15 @@ export interface BuildTableSelectSqlOptions {
|
||||
|
||||
export function quoteTableIdentifier(databaseType: DatabaseType | undefined, name: string): string {
|
||||
if (databaseType === "mysql") return `\`${name.replace(/`/g, "``")}\``;
|
||||
if (databaseType === "neo4j") return quoteCypherIdentifier(name);
|
||||
if (databaseType === "sqlserver") return `[${name.replace(/\]/g, "]]")}]`;
|
||||
return `"${name.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
function quoteCypherIdentifier(name: string): string {
|
||||
return `\`${name.replace(/`/g, "``")}\``;
|
||||
}
|
||||
|
||||
function isOracleRowId(databaseType: DatabaseType | undefined, name: string): boolean {
|
||||
return databaseType === "oracle" && name.toUpperCase() === DBX_ROWID_COLUMN;
|
||||
}
|
||||
@@ -48,6 +54,8 @@ export function normalizeWhereInput(whereInput?: string): string {
|
||||
export function buildTableSelectSql(options: BuildTableSelectSqlOptions): string {
|
||||
const databaseType = options.databaseType;
|
||||
const limit = options.limit ?? 100;
|
||||
if (databaseType === "neo4j") return buildNeo4jTableSelectSql(options, limit);
|
||||
|
||||
const table = qualifiedTableName(options);
|
||||
const predicate = normalizeWhereInput(options.whereInput);
|
||||
const where = predicate ? ` WHERE (${predicate})` : "";
|
||||
@@ -77,3 +85,22 @@ export function buildTableSelectSql(options: BuildTableSelectSqlOptions): string
|
||||
const offset = options.offset ? ` OFFSET ${options.offset}` : "";
|
||||
return `SELECT * FROM ${table}${where}${order} LIMIT ${limit}${offset};`;
|
||||
}
|
||||
|
||||
function buildNeo4jTableSelectSql(options: BuildTableSelectSqlOptions, limit: number): string {
|
||||
const label = quoteCypherIdentifier(options.tableName);
|
||||
const predicate = normalizeWhereInput(options.whereInput);
|
||||
const where = predicate ? ` WHERE ${predicate}` : "";
|
||||
const returnedColumns = options.columns?.length
|
||||
? options.columns
|
||||
.map((column) => `n.${quoteCypherIdentifier(column)} AS ${quoteCypherIdentifier(column)}`)
|
||||
.join(", ")
|
||||
: "n";
|
||||
const returns = `elementId(n) AS ${quoteCypherIdentifier(DBX_NEO4J_ELEMENT_ID_COLUMN)}, ${returnedColumns}`;
|
||||
const defaultOrderBy = options.primaryKeys?.length
|
||||
? options.primaryKeys.map((pk) => `n.${quoteCypherIdentifier(pk)} ASC`).join(", ")
|
||||
: undefined;
|
||||
const orderBy = options.orderBy ?? defaultOrderBy;
|
||||
const order = orderBy ? ` ORDER BY ${orderBy}` : "";
|
||||
const skip = options.offset ? ` SKIP ${options.offset}` : "";
|
||||
return `MATCH (n:${label})${where} RETURN ${returns}${order}${skip} LIMIT ${limit};`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user