fix(neo4j): support table querying and editing

This commit is contained in:
t8y2
2026-05-13 19:30:00 +08:00
parent 2f4c50dc1e
commit 0172ee50be
11 changed files with 303 additions and 15 deletions
+10 -3
View File
@@ -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);
}
+10 -2
View File
@@ -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 {
+29 -6
View File
@@ -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({
+6 -1
View File
@@ -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();
+21
View File
@@ -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
View File
@@ -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 ");
}
+8 -1
View File
@@ -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;
}
+28 -1
View File
@@ -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};`;
}