mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-02 02:34:42 +08:00
fix(neo4j): correct label grids typed saves and menus
This commit is contained in:
@@ -267,6 +267,26 @@ func TestDecodeQueryOptions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPropertyColumnsUseCypherTypesAndMergeLabelCombinations(t *testing.T) {
|
||||
record := func(name string, types []any, mandatory bool) *neo4j.Record {
|
||||
return &neo4j.Record{Keys: []string{"propertyName", "propertyTypes", "mandatory"}, Values: []any{name, types, mandatory}}
|
||||
}
|
||||
got := propertyColumns([]*neo4j.Record{
|
||||
record("score", []any{"Long"}, true),
|
||||
record("ratio", []any{"Double"}, true),
|
||||
record("score", []any{"String", "Long"}, false),
|
||||
record("values", []any{"LongArray", "DoubleArray"}, false),
|
||||
})
|
||||
want := []columnInfo{
|
||||
{Name: "ratio", DataType: "Float", IsNullable: false},
|
||||
{Name: "score", DataType: "Integer | String", IsNullable: true},
|
||||
{Name: "values", DataType: "FloatArray | IntegerArray", IsNullable: true},
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("propertyColumns() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoundedQueryUsesFetchAll(t *testing.T) {
|
||||
options := queryOptions{SQL: "MATCH (n) RETURN n LIMIT 10000", MaxRows: 10000}
|
||||
if got := effectiveFetchSize(options); got != neo4j.FetchAll {
|
||||
|
||||
@@ -185,30 +185,57 @@ func (s *server) getColumns(label string) ([]columnInfo, error) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
result := make([]columnInfo, 0, len(records))
|
||||
seen := map[string]struct{}{}
|
||||
return propertyColumns(records), nil
|
||||
}
|
||||
|
||||
func propertyColumns(records []*neo4j.Record) []columnInfo {
|
||||
columns := map[string]*columnInfo{}
|
||||
typesByName := map[string]map[string]struct{}{}
|
||||
for _, record := range records {
|
||||
name := recordString(record, "propertyName")
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[name]; exists {
|
||||
continue
|
||||
column := columns[name]
|
||||
if column == nil {
|
||||
column = &columnInfo{Name: name}
|
||||
columns[name] = column
|
||||
typesByName[name] = map[string]struct{}{}
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
types := recordStringSlice(record, "propertyTypes")
|
||||
dataType := "Unknown"
|
||||
column.IsNullable = column.IsNullable || !recordBool(record, "mandatory")
|
||||
for _, kind := range recordStringSlice(record, "propertyTypes") {
|
||||
typesByName[name][propertyTypeName(kind)] = struct{}{}
|
||||
}
|
||||
}
|
||||
result := make([]columnInfo, 0, len(columns))
|
||||
for name, column := range columns {
|
||||
types := make([]string, 0, len(typesByName[name]))
|
||||
for kind := range typesByName[name] {
|
||||
types = append(types, kind)
|
||||
}
|
||||
sort.Strings(types)
|
||||
column.DataType = "Unknown"
|
||||
if len(types) > 0 {
|
||||
dataType = strings.Join(types, " | ")
|
||||
column.DataType = strings.Join(types, " | ")
|
||||
}
|
||||
result = append(result, columnInfo{
|
||||
Name: name,
|
||||
DataType: dataType,
|
||||
IsNullable: !recordBool(record, "mandatory"),
|
||||
})
|
||||
result = append(result, *column)
|
||||
}
|
||||
sort.Slice(result, func(left, right int) bool { return result[left].Name < result[right].Name })
|
||||
return result, nil
|
||||
return result
|
||||
}
|
||||
|
||||
func propertyTypeName(kind string) string {
|
||||
if element, ok := strings.CutSuffix(kind, "Array"); ok {
|
||||
return propertyTypeName(element) + "Array"
|
||||
}
|
||||
switch kind {
|
||||
case "Long":
|
||||
return "Integer"
|
||||
case "Double":
|
||||
return "Float"
|
||||
default:
|
||||
return kind
|
||||
}
|
||||
}
|
||||
|
||||
func (s *server) listIndexes(label string) ([]indexInfo, error) {
|
||||
|
||||
@@ -2792,7 +2792,7 @@ function requestDropTableChildObject() {
|
||||
}
|
||||
|
||||
function canDropTreeNode(node: TreeNode): boolean {
|
||||
if (databaseTypeForNode(node) === "nebula") return false;
|
||||
if (["neo4j", "nebula"].includes(databaseTypeForNode(node) || "")) return false;
|
||||
if (isSqlServerLinkedNode(node)) return false;
|
||||
if (node.type === "table") return !!node.connectionId && !!node.database;
|
||||
if (node.type === "view" || node.type === "materialized_view" || node.type === "procedure" || node.type === "function" || node.type === "event") {
|
||||
@@ -3135,7 +3135,7 @@ function requestDropSelectedNodes(): boolean {
|
||||
}
|
||||
|
||||
function requestDropSelectedNode(): boolean {
|
||||
if (currentDatabaseType() === "nebula") return false;
|
||||
if (["neo4j", "nebula"].includes(currentDatabaseType() || "")) return false;
|
||||
if (activeNode.value.type === "table") {
|
||||
dropTable();
|
||||
return true;
|
||||
@@ -4760,7 +4760,7 @@ const canOpenSqlFileExecution = computed(() => {
|
||||
const canExportAllDatabases = computed(() => {
|
||||
if (activeNode.value.type !== "connection" || !activeNode.value.connectionId) return false;
|
||||
const dbType = connectionStore.getConfig(activeNode.value.connectionId)?.db_type;
|
||||
return !["redis", "mongodb", "dynamodb", "elasticsearch", "easysearch", "meilisearch", "solr", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "nacos", "plugin", "salesforce", "nebula"].includes(dbType || "");
|
||||
return !["redis", "mongodb", "dynamodb", "elasticsearch", "easysearch", "meilisearch", "solr", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "nacos", "plugin", "salesforce", "neo4j", "nebula"].includes(dbType || "");
|
||||
});
|
||||
|
||||
const canOpenScheduledBackups = computed(() => {
|
||||
@@ -5867,15 +5867,17 @@ function buildDatabaseSidebarMenu(context: SidebarMenuFactoryContext): boolean {
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (currentDatabaseType() === "nebula" && node.type === "database") {
|
||||
if (["neo4j", "nebula"].includes(currentDatabaseType() || "") && node.type === "database") {
|
||||
if (canCloseDatabaseConnection.value) items.push({ label: t("contextMenu.closeDatabaseConnection"), action: closeDatabaseConnection, icon: Unplug });
|
||||
items.push(copyNameMenuItem());
|
||||
items.push({ label: "", separator: true });
|
||||
if (canOpenObjectBrowser.value) items.push({ label: t("contextMenu.openObjectBrowser"), action: openObjectBrowser, icon: TableProperties });
|
||||
items.push({ label: t("contextMenu.newQuery"), action: newQuery, icon: TerminalSquare });
|
||||
if (supportsAiAssistantContext(currentDatabaseType())) items.push(addToAiMenuItem(node));
|
||||
const sqlHistoryMenu = savedSqlHistorySubmenu();
|
||||
if (sqlHistoryMenu) items.push(sqlHistoryMenu);
|
||||
items.push({ label: isNodeDefaultDatabase.value ? t("contextMenu.clearDefaultDatabase") : t("contextMenu.setDefaultDatabase"), action: isNodeDefaultDatabase.value ? clearNodeDefaultDatabase : setNodeAsDefaultDatabase, icon: Database });
|
||||
if (canOpenSqlFileExecution.value) items.push({ label: t("sqlFile.title"), action: openSqlFileExecution, icon: FileCode });
|
||||
items.push({ label: "", separator: true });
|
||||
items.push({ label: t("contextMenu.refreshChildren"), action: refresh, icon: RefreshCw, shortcut: shortcutRefresh });
|
||||
return true;
|
||||
@@ -6286,13 +6288,15 @@ function buildObjectSidebarMenu(context: SidebarMenuFactoryContext): boolean {
|
||||
appendPluginTableMenuItems(items, node);
|
||||
return true;
|
||||
}
|
||||
if (currentDatabaseType() === "nebula") {
|
||||
if (["neo4j", "nebula"].includes(currentDatabaseType() || "")) {
|
||||
items.push(copyNameMenuItem());
|
||||
items.push({ label: t("contextMenu.newQuery"), action: newQuery, icon: TerminalSquare });
|
||||
if (supportsAiAssistantContext(currentDatabaseType())) items.push(addToAiMenuItem(node));
|
||||
items.push({ label: "", separator: true });
|
||||
items.push({ label: t("contextMenu.viewData"), action: openDataImmediately, icon: TableProperties });
|
||||
items.push({ label: t("contextMenu.openInNewDataTab"), action: openDataInNewTabImmediately, icon: CopyPlus, shortcut: shortcutOpenDataInNewTab.value });
|
||||
items.push({ label: t("contextMenu.viewDdl"), action: openDdl, icon: FileCode });
|
||||
if (currentDatabaseType() === "nebula") items.push({ label: t("contextMenu.viewDdl"), action: openDdl, icon: FileCode });
|
||||
if (currentDatabaseType() === "neo4j") items.push(exportDataSubmenu(false));
|
||||
const sqlHistoryMenu = savedSqlHistorySubmenu();
|
||||
if (sqlHistoryMenu) items.push(sqlHistoryMenu);
|
||||
items.push({ label: "", separator: true });
|
||||
@@ -6652,7 +6656,8 @@ function buildObjectGroupSidebarMenu(context: SidebarMenuFactoryContext): boolea
|
||||
const mysqlObjectTemplate = node.connectionId ? mysqlObjectTemplateForGroup(connectionStore.getConfig(node.connectionId), node) : null;
|
||||
const hasMongoCreateIndexAction = node.type === "group-indexes" && canCreateMongoIndex.value;
|
||||
const hasMongoDropAllIndexesAction = node.type === "group-indexes" && canDropAllMongoIndexes.value;
|
||||
const hasGroupAction = (node.type === "group-tables" && canCreateTable.value) || (node.type === "group-views" && !!node.connectionId && !!node.database && currentDatabaseType() !== "nebula") || !!mysqlObjectTemplate || hasMongoCreateIndexAction || hasMongoDropAllIndexesAction;
|
||||
const canCreateGroupView = node.type === "group-views" && !!node.connectionId && !!node.database && !["neo4j", "nebula"].includes(currentDatabaseType() || "");
|
||||
const hasGroupAction = (node.type === "group-tables" && canCreateTable.value) || canCreateGroupView || !!mysqlObjectTemplate || hasMongoCreateIndexAction || hasMongoDropAllIndexesAction;
|
||||
const canLoadAllObjectGroup = node.type === "group-tables" || node.type === "group-dolt-system-tables" || node.type === "group-views" || node.type === "group-materialized-views";
|
||||
if (node.type === "group-tables" && canCreateTable.value) {
|
||||
items.push({ label: t("contextMenu.createTable"), action: createTable, icon: Plus });
|
||||
@@ -6663,7 +6668,7 @@ function buildObjectGroupSidebarMenu(context: SidebarMenuFactoryContext): boolea
|
||||
items.push({ label: t("contextMenu.pasteTable"), action: openPasteTableDialog, icon: Clipboard });
|
||||
}
|
||||
}
|
||||
if (node.type === "group-views" && node.connectionId && node.database && currentDatabaseType() !== "nebula") {
|
||||
if (canCreateGroupView) {
|
||||
items.push({ label: t("contextMenu.createView"), action: createView, icon: Plus });
|
||||
}
|
||||
if (node.type === "group-events" && node.connectionId && node.database) {
|
||||
|
||||
Vendored
+77
@@ -0,0 +1,77 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { createApp, defineComponent, h, nextTick, ref, type App } from "vue";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "@/i18n";
|
||||
import type { ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
|
||||
import type { DatabaseType, TreeNode } from "@/types/database";
|
||||
import { supportsTableTruncate } from "@/lib/database/databaseFeatureSupport";
|
||||
|
||||
vi.mock("@/lib/backend/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/backend/api")>();
|
||||
return { ...actual, listPlugins: vi.fn().mockResolvedValue([]) };
|
||||
});
|
||||
|
||||
import SidebarTreeRuntimeHost from "@/components/sidebar/SidebarTreeRuntimeHost.vue";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
|
||||
const node = (type: TreeNode["type"], label: string): TreeNode => ({ id: `sample:${type}:${label}`, type, label, connectionId: "sample", database: "sample", tableName: label });
|
||||
const mountedApps: App<Element>[] = [];
|
||||
const labels = (items: ContextMenuItem[]): string[] => items.flatMap((item) => [...(item.label ? [item.label] : []), ...(item.children ? labels(item.children) : [])]);
|
||||
const tr = (key: string) => i18n.global.t(key);
|
||||
|
||||
async function mountHost(dbType: DatabaseType = "neo4j") {
|
||||
const pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
useConnectionStore().connections = [{ id: "sample", name: "Sample", db_type: dbType, driver_profile: dbType, host: "localhost", port: 7687, username: "sample", password: "" }];
|
||||
const host = ref<InstanceType<typeof SidebarTreeRuntimeHost> | null>(null);
|
||||
const root = defineComponent({ setup: () => () => h(SidebarTreeRuntimeHost, { ref: host, node: node("connection", "Sample"), depth: 0 }) });
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const app = createApp(root);
|
||||
app.use(pinia);
|
||||
app.use(i18n);
|
||||
app.mount(container);
|
||||
mountedApps.push(app);
|
||||
await nextTick();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await nextTick();
|
||||
return host.value as { buildContextMenu(node: TreeNode): ContextMenuItem[] };
|
||||
}
|
||||
|
||||
describe("Neo4j sidebar capabilities", () => {
|
||||
afterEach(() => {
|
||||
for (const app of mountedApps.splice(0)) app.unmount();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("keeps database browsing and queries without relational comparisons or transfers", async () => {
|
||||
const host = await mountHost();
|
||||
const items = labels(host.buildContextMenu(node("database", "sample")));
|
||||
expect(items).toEqual(expect.arrayContaining([tr("contextMenu.newQuery"), tr("contextMenu.openObjectBrowser"), tr("contextMenu.refreshChildren"), tr("sqlFile.title")]));
|
||||
for (const key of ["transfer.dataTransfer", "diff.title", "dataCompare.title", "contextMenu.exportDatabase", "dataDictionary.title"]) expect(items).not.toContain(tr(key));
|
||||
});
|
||||
|
||||
it.each(["table", "view"] as const)("keeps %s data browsing without SQL structure mutations", async (type) => {
|
||||
const host = await mountHost();
|
||||
const items = labels(host.buildContextMenu(node(type, "Person")));
|
||||
expect(items).toEqual(expect.arrayContaining([tr("contextMenu.viewData"), tr("contextMenu.newQuery"), tr("contextMenu.exportData")]));
|
||||
for (const key of ["contextMenu.viewDdl", "contextMenu.generateSql", "contextMenu.duplicateStructure", "contextMenu.dropTable", "contextMenu.dropView", "contextMenu.truncateTable", "contextMenu.emptyTable", "contextMenu.exportDatabase", "dataCompare.title"]) expect(items).not.toContain(tr(key));
|
||||
});
|
||||
|
||||
it("does not offer SQL view creation or relational database backups", async () => {
|
||||
const host = await mountHost();
|
||||
expect(labels(host.buildContextMenu(node("group-views", "Views")))).not.toContain(tr("contextMenu.createView"));
|
||||
const connectionItems = labels(host.buildContextMenu(node("connection", "Sample")));
|
||||
expect(connectionItems).toContain(tr("contextMenu.newQuery"));
|
||||
expect(connectionItems).not.toContain(tr("contextMenu.exportAllDatabases"));
|
||||
expect(supportsTableTruncate("neo4j")).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves MySQL relational menus", async () => {
|
||||
const host = await mountHost("mysql");
|
||||
expect(labels(host.buildContextMenu(node("database", "sample")))).toContain(tr("transfer.dataTransfer"));
|
||||
expect(labels(host.buildContextMenu(node("table", "Person")))).toContain(tr("contextMenu.generateSql"));
|
||||
expect(supportsTableTruncate("mysql")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -425,8 +425,8 @@ describe("useSidebarDataOpenRuntime", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("waits for NebulaGraph properties before building the first table query", async () => {
|
||||
mocks.databaseType = "nebula";
|
||||
it.each(["nebula", "neo4j"])("waits for %s properties before building the first table query", async (databaseType) => {
|
||||
mocks.databaseType = databaseType;
|
||||
let releaseMetadata: () => void = () => {};
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
releaseMetadata = resolve;
|
||||
@@ -455,7 +455,7 @@ describe("useSidebarDataOpenRuntime", () => {
|
||||
releaseMetadata();
|
||||
await opening;
|
||||
|
||||
expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ databaseType: "nebula", columns: ["name"] }));
|
||||
expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ databaseType, columns: ["name"] }));
|
||||
expect(mocks.callOrder).toEqual(["query"]);
|
||||
});
|
||||
|
||||
|
||||
@@ -64,6 +64,13 @@ describe("dataGridCellDisplayText", () => {
|
||||
});
|
||||
|
||||
describe("coerceDataGridCellValue", () => {
|
||||
it.each(["Integer", "Long", "int"])("keeps Neo4j %s edits as exact text for the Cypher builder", (data_type) => {
|
||||
for (const value of ["1001", "9007199254740999", "9223372036854775807", "-9223372036854775808"]) {
|
||||
expect(coerceDataGridCellValue({ value, oldValue: "1", databaseType: "neo4j", columnInfo: { data_type } })).toBe(value);
|
||||
expect(coerceDataGridCellValue({ value: `${value}\n`, oldValue: "1", databaseType: "neo4j", columnInfo: { data_type } })).toBe(value);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["null", "NULL", "Null", "nUlL"])("preserves literal %s input as text", (value) => {
|
||||
expect(
|
||||
coerceDataGridCellValue({
|
||||
|
||||
@@ -323,6 +323,7 @@ describe("quoteTableIdentifier", () => {
|
||||
expect(requiresEagerTableMetadataForDataOpen("mysql")).toBe(true);
|
||||
expect(requiresEagerTableMetadataForDataOpen("postgres")).toBe(true);
|
||||
expect(requiresEagerTableMetadataForDataOpen("nebula")).toBe(true);
|
||||
expect(requiresEagerTableMetadataForDataOpen("neo4j")).toBe(true);
|
||||
// Drivers whose preview works from `SELECT *` keep loading metadata lazily.
|
||||
expect(requiresEagerTableMetadataForDataOpen("sqlite")).toBe(false);
|
||||
expect(requiresEagerTableMetadataForDataOpen(undefined)).toBe(false);
|
||||
|
||||
@@ -37,6 +37,9 @@ export function coerceDataGridCellValue(options: CoerceDataGridCellValueOptions)
|
||||
const useSampledValueType = normalizeDataType(options.columnInfo?.data_type) === "";
|
||||
const numericInput = isNumericColumnType(options.columnInfo?.data_type) || (useSampledValueType && typeof oldValue === "number");
|
||||
const numericText = normalizeGroupedNumberText(value, options.columnInfo, oldValue, options.numberFormat ?? runtimeNumberFormat());
|
||||
// Neo4j INTEGER is always signed 64-bit. Keep its input as text until the
|
||||
// Cypher builder validates it, including values outside JavaScript's range.
|
||||
if (options.databaseType === "neo4j" && ["integer", "long", "int"].includes(normalizeDataType(options.columnInfo?.data_type))) return numericText.trim();
|
||||
if (isBooleanInputColumn(options) || (useSampledValueType && typeof oldValue === "boolean")) {
|
||||
// MySQL exposes TINYINT(1) as an integer in the grid. Keep its numeric
|
||||
// 0/1 edits numeric while still accepting explicit TRUE/FALSE aliases.
|
||||
|
||||
@@ -268,6 +268,7 @@ export function supportsTableTruncate(dbType?: DatabaseType): boolean {
|
||||
dbType !== "victoriametrics" &&
|
||||
dbType !== "manticoresearch" &&
|
||||
dbType !== "salesforce" &&
|
||||
dbType !== "neo4j" &&
|
||||
dbType !== "nebula"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -447,11 +447,11 @@ export function normalizeWhereInput(whereInput?: string): string {
|
||||
* * Salesforce: SOQL has no `SELECT *`. With no known fields the backend builder
|
||||
* falls back to the `FIELDS(ALL)` selector, which the org only accepts with
|
||||
* `LIMIT 200` or less — awaiting the describe keeps every page size working.
|
||||
* * NebulaGraph: without tag/edge properties, the grid can only show a single
|
||||
* * Neo4j/NebulaGraph: without node/tag/edge properties, the grid can only show a single
|
||||
* vertex/edge value instead of separate property columns.
|
||||
*/
|
||||
export function requiresEagerTableMetadataForDataOpen(databaseType: DatabaseType | undefined): boolean {
|
||||
return databaseType === "mysql" || databaseType === "postgres" || databaseType === "salesforce" || databaseType === "nebula";
|
||||
return databaseType === "mysql" || databaseType === "postgres" || databaseType === "salesforce" || databaseType === "neo4j" || databaseType === "nebula";
|
||||
}
|
||||
|
||||
export async function buildTableSelectSql(options: BuildTableSelectSqlOptions): Promise<string> {
|
||||
|
||||
@@ -4,7 +4,9 @@ use super::*;
|
||||
use crate::models::connection::DatabaseType;
|
||||
use crate::sql_dialect::{neo4j_element_id_function, NEO4J_LEGACY_ELEMENT_ID_FUNCTION};
|
||||
|
||||
pub(super) fn build_neo4j_data_grid_save_statements(options: &DataGridSaveStatementOptions) -> Vec<String> {
|
||||
pub(super) fn build_neo4j_data_grid_save_statements(
|
||||
options: &DataGridSaveStatementOptions,
|
||||
) -> Result<Vec<String>, String> {
|
||||
let label = quote_ident(Some(DatabaseType::Neo4j), &options.table_meta.table_name);
|
||||
let element_id_function = neo4j_element_id_function(options.server_version.as_deref());
|
||||
let mut statements = Vec::new();
|
||||
@@ -20,13 +22,12 @@ pub(super) fn build_neo4j_data_grid_save_statements(options: &DataGridSaveStatem
|
||||
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)
|
||||
))
|
||||
Some(
|
||||
property_literal(options, column, value)
|
||||
.map(|literal| format!("n.{} = {}", quote_ident(Some(DatabaseType::Neo4j), column), literal)),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.collect::<Result<Vec<_>, _>>()?
|
||||
.join(", ");
|
||||
if sets.is_empty() {
|
||||
continue;
|
||||
@@ -58,13 +59,12 @@ pub(super) fn build_neo4j_data_grid_save_statements(options: &DataGridSaveStatem
|
||||
if value.is_null() {
|
||||
return None;
|
||||
}
|
||||
Some(format!(
|
||||
"{}: {}",
|
||||
quote_ident(Some(DatabaseType::Neo4j), column),
|
||||
format_grid_sql_literal(value, Some(DatabaseType::Neo4j), None)
|
||||
))
|
||||
Some(
|
||||
property_literal(options, column, value)
|
||||
.map(|literal| format!("{}: {}", quote_ident(Some(DatabaseType::Neo4j), column), literal)),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.collect::<Result<Vec<_>, _>>()?
|
||||
.join(", ");
|
||||
statements.push(if props.is_empty() {
|
||||
format!("CREATE (n:{label});")
|
||||
@@ -73,52 +73,21 @@ pub(super) fn build_neo4j_data_grid_save_statements(options: &DataGridSaveStatem
|
||||
});
|
||||
}
|
||||
|
||||
statements
|
||||
Ok(statements)
|
||||
}
|
||||
|
||||
pub(super) fn build_neo4j_data_grid_rollback_statements(options: &DataGridSaveStatementOptions) -> Vec<String> {
|
||||
pub(super) fn build_neo4j_data_grid_rollback_statements(
|
||||
options: &DataGridSaveStatementOptions,
|
||||
) -> Result<Vec<String>, String> {
|
||||
// Matching inserted properties can delete other nodes, and recreating a
|
||||
// detached node cannot restore its identity or relationships.
|
||||
if !options.new_rows.is_empty() || !options.deleted_rows.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let label = quote_ident(Some(DatabaseType::Neo4j), &options.table_meta.table_name);
|
||||
let element_id_function = neo4j_element_id_function(options.server_version.as_deref());
|
||||
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::<Vec<_>>()
|
||||
.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;
|
||||
@@ -130,17 +99,12 @@ pub(super) fn build_neo4j_data_grid_rollback_statements(options: &DataGridSaveSt
|
||||
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
|
||||
)
|
||||
))
|
||||
Some(
|
||||
property_literal(options, column, row.get(*column_index).unwrap_or(&Value::Null))
|
||||
.map(|literal| format!("n.{} = {}", quote_ident(Some(DatabaseType::Neo4j), column), literal)),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.collect::<Result<Vec<_>, _>>()?
|
||||
.join(", ");
|
||||
if sets.is_empty() {
|
||||
continue;
|
||||
@@ -151,7 +115,7 @@ pub(super) fn build_neo4j_data_grid_rollback_statements(options: &DataGridSaveSt
|
||||
));
|
||||
}
|
||||
|
||||
statements
|
||||
Ok(statements)
|
||||
}
|
||||
|
||||
fn neo4j_element_id_predicate(columns: &[String], row: &[Value], element_id_function: &str) -> String {
|
||||
@@ -182,22 +146,76 @@ fn neo4j_legacy_element_id_literal(value: &Value) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
fn neo4j_row_property_where(columns: &[String], row: &[Value]) -> String {
|
||||
columns
|
||||
fn property_literal(options: &DataGridSaveStatementOptions, column: &str, value: &Value) -> Result<String, String> {
|
||||
// Cypher property names are case-sensitive, unlike relational metadata lookups.
|
||||
let data_type = options
|
||||
.table_meta
|
||||
.columns
|
||||
.as_deref()
|
||||
.unwrap_or(&[])
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, column)| {
|
||||
if is_neo4j_element_id(Some(DatabaseType::Neo4j), Some(column)) {
|
||||
return None;
|
||||
.find(|info| info.name == column)
|
||||
.map(|info| info.data_type.as_str())
|
||||
.unwrap_or("");
|
||||
typed_property_literal(value, data_type)
|
||||
.map_err(|error| format!("Cannot save Neo4j property `{column}` ({data_type}): {error}"))
|
||||
}
|
||||
|
||||
fn typed_property_literal(value: &Value, data_type: &str) -> Result<String, &'static str> {
|
||||
if value.is_null() {
|
||||
return Ok("NULL".to_string());
|
||||
}
|
||||
let kind = data_type.trim().to_ascii_lowercase();
|
||||
if let Some(element_type) = kind.strip_suffix("array").or_else(|| kind.strip_suffix("[]")) {
|
||||
let parsed;
|
||||
let items = if let Some(items) = value.as_array() {
|
||||
items
|
||||
} else {
|
||||
parsed = serde_json::from_str::<Value>(value.as_str().ok_or("expected a JSON array")?)
|
||||
.map_err(|_| "expected a JSON array")?;
|
||||
parsed.as_array().ok_or("expected a JSON array")?
|
||||
};
|
||||
let values = items
|
||||
.iter()
|
||||
.map(|item| {
|
||||
if item.is_null() {
|
||||
return Err("property arrays cannot contain null");
|
||||
}
|
||||
typed_property_literal(item, element_type)
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
return Ok(format!("[{}]", values.join(", ")));
|
||||
}
|
||||
let text = value.as_str().map(str::to_string).unwrap_or_else(|| value.to_string());
|
||||
let quoted = || format_grid_sql_literal(&Value::String(text.clone()), Some(DatabaseType::Neo4j), None);
|
||||
match kind.as_str() {
|
||||
"long" | "integer" | "int" => {
|
||||
let number = text.trim().parse::<i64>().map_err(|_| "expected a signed 64-bit integer")?;
|
||||
// Keep exact integers out of JavaScript floating-point conversions and
|
||||
// support the full signed range, including the minimum integer literal.
|
||||
Ok(format!("toInteger('{number}')"))
|
||||
}
|
||||
"double" | "float" => {
|
||||
let number = text.trim().parse::<f64>().map_err(|_| "expected a finite float")?;
|
||||
if !number.is_finite() {
|
||||
return Err("expected a finite float");
|
||||
}
|
||||
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)))
|
||||
Ok(format!("toFloat({})", quoted()))
|
||||
}
|
||||
"boolean" | "bool" => match text.trim().to_ascii_lowercase().as_str() {
|
||||
"true" => Ok("true".to_string()),
|
||||
"false" => Ok("false".to_string()),
|
||||
_ => Err("expected true or false"),
|
||||
},
|
||||
"string" => {
|
||||
if !value.is_string() {
|
||||
return Err("expected a string");
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" AND ")
|
||||
Ok(quoted())
|
||||
}
|
||||
"date" => Ok(format!("date({})", quoted())),
|
||||
"duration" => Ok(format!("duration({})", quoted())),
|
||||
// A mixed or unsupported property type must not silently become a string.
|
||||
_ => Err("this property type requires an explicit Cypher query"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -403,6 +403,22 @@ pub fn prepare_data_grid_save_for_driver_profile(
|
||||
}
|
||||
|
||||
let mut keyless_guards = Vec::new();
|
||||
if options.database_type == Some(DatabaseType::Neo4j) {
|
||||
let generated = build_neo4j_data_grid_save_statements(&options).and_then(|statements| {
|
||||
build_neo4j_data_grid_rollback_statements(&options).map(|rollback| (statements, rollback))
|
||||
});
|
||||
let (validation_error, statements, rollback_statements) = match generated {
|
||||
Ok((statements, rollback)) => (None, statements, rollback),
|
||||
Err(error) => (Some(error), Vec::new(), Vec::new()),
|
||||
};
|
||||
return DataGridSavePreparation {
|
||||
validation_error,
|
||||
statements,
|
||||
rollback_statements,
|
||||
execution_schema: None,
|
||||
keyless_guards,
|
||||
};
|
||||
}
|
||||
let statements = build_data_grid_save_statements(&options, driver_profile, &mut keyless_guards);
|
||||
DataGridSavePreparation {
|
||||
validation_error: None,
|
||||
@@ -1093,6 +1109,12 @@ fn is_sqlserver_legacy_profile(driver_profile: Option<&str>) -> bool {
|
||||
}
|
||||
|
||||
pub fn build_data_grid_count_sql(options: DataGridCountSqlOptions) -> String {
|
||||
if options.database_type == Some(DatabaseType::Neo4j) {
|
||||
let label = quote_ident(Some(DatabaseType::Neo4j), &options.table_name);
|
||||
let predicate = crate::sql_dialect::normalize_where_input(options.where_input.as_deref());
|
||||
let where_clause = if predicate.is_empty() { String::new() } else { format!(" WHERE ({predicate})") };
|
||||
return format!("MATCH (n:{label}){where_clause} RETURN count(n) AS cnt");
|
||||
}
|
||||
// Keep the reference identical to the one the grid's SELECT uses: Caché/IRIS
|
||||
// reject quoted ordinary names when delimited identifiers are disabled, so
|
||||
// the count must not be the only statement that quotes them (#8929).
|
||||
@@ -1585,9 +1607,6 @@ fn build_data_grid_save_statements(
|
||||
driver_profile: Option<&str>,
|
||||
keyless_guards: &mut Vec<DataGridSaveGuard>,
|
||||
) -> Vec<String> {
|
||||
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);
|
||||
}
|
||||
@@ -1787,9 +1806,6 @@ fn build_data_grid_rollback_statements(
|
||||
options: &DataGridSaveStatementOptions,
|
||||
driver_profile: Option<&str>,
|
||||
) -> Vec<String> {
|
||||
if options.database_type == Some(DatabaseType::Neo4j) {
|
||||
return build_neo4j_data_grid_rollback_statements(options);
|
||||
}
|
||||
if options.database_type == Some(DatabaseType::Tdengine) {
|
||||
return build_tdengine_data_grid_rollback_statements(options);
|
||||
}
|
||||
@@ -4274,6 +4290,113 @@ mod tests {
|
||||
assert_eq!(without_version.statements, on_neo4j_5.statements);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neo4j_property_writes_preserve_types_and_exact_integers() {
|
||||
for (kind, value, expected) in [
|
||||
("Long", json!("9223372036854775807"), "toInteger('9223372036854775807')"),
|
||||
("Integer", json!("-9223372036854775808"), "toInteger('-9223372036854775808')"),
|
||||
("Double", json!(2), "toFloat('2')"),
|
||||
("Boolean", json!("false"), "false"),
|
||||
("Date", json!("2026-09-30"), "date('2026-09-30')"),
|
||||
("Duration", json!("P1D"), "duration('P1D')"),
|
||||
("StringArray", json!("[\"A\",\"B\"]"), "['A', 'B']"),
|
||||
("LongArray", json!("[9007199254740997]"), "[toInteger('9007199254740997')]"),
|
||||
("BooleanArray", json!([true, false]), "[true, false]"),
|
||||
("String", json!("a'b\\c"), "'a\\'b\\\\c'"),
|
||||
] {
|
||||
let prepared = prepare_data_grid_save(neo4j_property_save_options(kind, value));
|
||||
assert_eq!(prepared.validation_error, None, "{kind}");
|
||||
assert_eq!(
|
||||
prepared.statements,
|
||||
vec![format!("MATCH (n:`Person`) WHERE elementId(n) = 'sample-id' SET n.`value` = {expected};")]
|
||||
);
|
||||
assert_eq!(prepared.rollback_statements, prepared.statements);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neo4j_property_writes_reject_invalid_and_ambiguous_types_before_execution() {
|
||||
for (kind, value) in [
|
||||
("Long", json!("9223372036854775808")),
|
||||
("Long", json!("1.5")),
|
||||
("Long", json!("1); MATCH (n) DELETE n")),
|
||||
("Double", json!("NaN")),
|
||||
("Double", json!("Infinity")),
|
||||
("Boolean", json!("not-a-boolean")),
|
||||
("LongArray", json!("[null]")),
|
||||
("StringArray", json!("[1]")),
|
||||
("LongArray", json!("[1.5]")),
|
||||
("StringArray", json!("invalid JSON")),
|
||||
("Long | String", json!("12")),
|
||||
("Unknown", json!("12")),
|
||||
("Point", json!("{x:1,y:2}")),
|
||||
] {
|
||||
let prepared = prepare_data_grid_save(neo4j_property_save_options(kind, value));
|
||||
assert!(prepared.validation_error.is_some(), "{kind}");
|
||||
assert!(prepared.statements.is_empty(), "{kind}");
|
||||
assert!(prepared.rollback_statements.is_empty(), "{kind}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neo4j_insert_and_delete_do_not_generate_unsafe_history_reversals() {
|
||||
let mut options = neo4j_property_save_options("Long", json!("9007199254740997"));
|
||||
options.dirty_rows.clear();
|
||||
options.new_rows = vec![vec![Value::Null, json!("9007199254740997")]];
|
||||
let prepared = prepare_data_grid_save(options.clone());
|
||||
assert_eq!(prepared.validation_error, None);
|
||||
assert_eq!(prepared.statements, vec!["CREATE (n:`Person` {`value`: toInteger('9007199254740997')});"]);
|
||||
assert!(prepared.rollback_statements.is_empty());
|
||||
options.new_rows.clear();
|
||||
options.deleted_rows = vec![0];
|
||||
let prepared = prepare_data_grid_save(options);
|
||||
assert_eq!(prepared.statements, vec!["MATCH (n:`Person`) WHERE elementId(n) = 'sample-id' DETACH DELETE n;"]);
|
||||
assert!(prepared.rollback_statements.is_empty());
|
||||
}
|
||||
|
||||
fn neo4j_property_save_options(kind: &str, value: Value) -> DataGridSaveStatementOptions {
|
||||
DataGridSaveStatementOptions {
|
||||
database_type: Some(DatabaseType::Neo4j),
|
||||
identifier_quote: None,
|
||||
server_version: Some("Neo4j/5.26.0".to_string()),
|
||||
table_meta: DataGridTableMeta {
|
||||
catalog: None,
|
||||
database: None,
|
||||
schema: None,
|
||||
table_name: "Person".to_string(),
|
||||
primary_keys: vec![DBX_NEO4J_ELEMENT_ID_COLUMN.to_string()],
|
||||
columns: Some(vec![column("value", kind, true, None)]),
|
||||
},
|
||||
columns: vec![DBX_NEO4J_ELEMENT_ID_COLUMN.to_string(), "value".to_string()],
|
||||
source_columns: None,
|
||||
rows: vec![vec![json!("sample-id"), value.clone()]],
|
||||
dirty_rows: vec![(0, vec![(1, value)])],
|
||||
deleted_rows: vec![],
|
||||
new_rows: vec![],
|
||||
include_database_name: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neo4j_counts_use_cypher_and_keep_the_grid_predicate() {
|
||||
for (where_input, where_clause) in [(None, ""), (Some("WHERE n.`age` > 10"), " WHERE (n.`age` > 10)")] {
|
||||
let options = DataGridCountSqlOptions {
|
||||
database_type: Some(DatabaseType::Neo4j),
|
||||
identifier_quote: None,
|
||||
catalog: None,
|
||||
database: Some("neo4j".to_string()),
|
||||
schema: Some("neo4j".to_string()),
|
||||
table_name: "P`erson".to_string(),
|
||||
where_input: where_input.map(str::to_string),
|
||||
count_hint: None,
|
||||
};
|
||||
assert_eq!(
|
||||
build_data_grid_count_sql(options),
|
||||
format!("MATCH (n:`P``erson`){where_clause} RETURN count(n) AS cnt")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iris_cache_data_grid_save_uses_unquoted_ordinary_identifiers() {
|
||||
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 181 KiB |
Reference in New Issue
Block a user