From 7b40a6ffcac03cf919e083f5af17af6457f46c82 Mon Sep 17 00:00:00 2001 From: Hand Sonic <8078023+HandSonic@users.noreply.github.com> Date: Sun, 18 Oct 2026 12:20:51 -0700 Subject: [PATCH] feat(editor): add native Neo4j Cypher completion --- .../graph/profiles/neo4jQueryProfile.spec.ts | 31 +++++ .../lib/graph/profiles/neo4jQueryProfile.ts | 108 ++++++++++++++++++ docs/screenshots/neo4j-query-completion.png | Bin 0 -> 16061 bytes 3 files changed, 139 insertions(+) create mode 100644 apps/desktop/src/lib/graph/profiles/neo4jQueryProfile.spec.ts create mode 100644 apps/desktop/src/lib/graph/profiles/neo4jQueryProfile.ts create mode 100644 docs/screenshots/neo4j-query-completion.png diff --git a/apps/desktop/src/lib/graph/profiles/neo4jQueryProfile.spec.ts b/apps/desktop/src/lib/graph/profiles/neo4jQueryProfile.spec.ts new file mode 100644 index 0000000000..bbf6e5e6cb --- /dev/null +++ b/apps/desktop/src/lib/graph/profiles/neo4jQueryProfile.spec.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { buildGraphCompletionItems, getGraphCompletionContext } from "../graphCompletion"; +import { getGraphFunctionSignatureHelp } from "../graphSignatureHelp"; +import profile from "./neo4jQueryProfile"; + +describe("Neo4j Cypher completion profile", () => { + it("covers the common Neo4j 5 function families without nGQL-only functions", () => { + const names = profile.functions.map((fn) => fn.name); + for (const name of ["rand", "randomUUID", "percentileCont", "stDev", "isEmpty", "point", "point.distance", "duration.between", "toStringOrNull", "elementId"]) expect(names).toContain(name); + for (const name of ["src", "dst", "rank", "now", "uuid"]) expect(names).not.toContain(name); + for (const word of ["DROP", "ALTER", "INDEXES", "FUNCTIONS", "PROCEDURES", "TRANSACTIONS"]) expect(profile.keywords).toContain(word); + }); + + it("completes qualified functions with leaf-only replacement and signature help", () => { + const source = "RETURN point.di"; + const items = buildGraphCompletionItems(getGraphCompletionContext(source, source.length, "neo4j"), "neo4j"); + expect(items).toEqual([expect.objectContaining({ label: "distance", apply: "distance(${from}, ${to})" })]); + const call = "RETURN duration.between(a, "; + expect(getGraphFunctionSignatureHelp(call, call.length, "neo4j")).toMatchObject({ name: "duration.between", activeParameter: 1 }); + }); + + it("prefers bound variables over function namespaces", () => { + const source = "MATCH (point:Person) RETURN point.na"; + expect(getGraphCompletionContext(source, source.length, "neo4j")).toMatchObject({ mode: "property", objects: ["Person"] }); + }); + + it("does not misrepresent node labels as relationship types", () => { + const source = "MATCH ()-[r:"; + expect(buildGraphCompletionItems(getGraphCompletionContext(source, source.length, "neo4j"), "neo4j", { tables: [{ name: "Person", type: "table" }] })).toEqual([]); + }); +}); diff --git a/apps/desktop/src/lib/graph/profiles/neo4jQueryProfile.ts b/apps/desktop/src/lib/graph/profiles/neo4jQueryProfile.ts new file mode 100644 index 0000000000..2809fc6f24 --- /dev/null +++ b/apps/desktop/src/lib/graph/profiles/neo4jQueryProfile.ts @@ -0,0 +1,108 @@ +import { commonGraphFunctions, functionDefinitions, graphListPredicates, graphReduce, temporalFunctions, type GraphQueryProfile } from "../graphQueryProfile"; + +// Neo4j 5 built-ins: https://neo4j.com/docs/cypher-manual/5/functions/ +const reservedWords = + "ALL ALTER AND AS ASC ASCENDING ASSERT BY CALL CASE COMMIT COMPOSITE CONSTRAINT CONSTRAINTS CONTAINS CREATE CSV CURRENT DATABASE DATABASES DELETE DESC DESCENDING DETACH DISTINCT DROP ELSE END ENDS EXCEPT EXECUTABLE EXISTS EXPLAIN FALSE FIELDTERMINATOR FOR FOREACH FROM FUNCTIONS HEADERS IF IN INDEX INDEXES IS JOIN LIMIT LOAD MATCH MERGE NODE NODETACH NOT NULL OF ON OPTIONAL OR ORDER PERIODIC PRIVILEGES PROCEDURE PROCEDURES PROFILE REMOVE RENAME REQUIRE RETURN ROLE ROLES SCAN SERVER SERVERS SET SETTINGS SHOW SKIP START STARTS THEN TO TRANSACTION TRANSACTIONS TRUE UNION UNIQUE UNWIND USE USER USERS USING WHEN WHERE WITH XOR YIELD".split( + " ", + ); +// Cypher 5.19+ path selectors/restrictors (SHORTEST/WALK/TRAIL/ACYCLIC/SIMPLE) and index +// DDL words (LOOKUP/FULLTEXT) complete and highlight as keywords but stay out of +// reservedWords; POINT/RANGE/TEXT/GROUP are intentionally omitted because they are common +// variable/property names and keyword-ness breaks bound-variable resolution. +const keywords = [...new Set([...reservedWords, ..."ACYCLIC FULLTEXT LOOKUP SHORTEST SIMPLE TRAIL WALK".split(" ")])]; + +const profile: GraphQueryProfile = { + databaseType: "neo4j", + language: "Cypher", + lineComment: "//", + keywords, + reservedWords, + functions: [ + ...commonGraphFunctions, + ...functionDefinitions({ + length: ["path"], + timestamp: [], + elementId: ["entity"], + endNode: ["relationship"], + startNode: ["relationship"], + toLower: ["string"], + toUpper: ["string"], + lower: ["string"], + upper: ["string"], + exists: ["property"], + isEmpty: ["value"], + shortestPath: ["pattern"], + allShortestPaths: ["pattern"], + percentileCont: ["expression", "percentile"], + percentileDisc: ["expression", "percentile"], + stDev: ["expression"], + stDevP: ["expression"], + rand: [], + randomUUID: [], + e: [], + pi: [], + exp: ["number"], + log: ["number"], + log10: ["number"], + isNaN: ["number"], + acos: ["number"], + asin: ["number"], + atan: ["number"], + atan2: ["y", "x"], + cos: ["number"], + cot: ["number"], + degrees: ["radians"], + haversin: ["number"], + radians: ["degrees"], + sin: ["number"], + tan: ["number"], + point: ["map"], + "point.distance": ["from", "to"], + "point.withinBBox": ["point", "lowerLeft", "upperRight"], + "duration.between": ["from", "to"], + "duration.inDays": ["from", "to"], + "duration.inMonths": ["from", "to"], + "duration.inSeconds": ["from", "to"], + "datetime.fromEpoch": ["seconds", "nanoseconds"], + "datetime.fromEpochMillis": ["milliseconds"], + "db.nameFromElementId": ["elementId"], + "graph.byElementId": ["elementId"], + "graph.byName": ["name"], + "graph.names": [], + "graph.propertiesByName": ["name"], + "vector.similarity.cosine": ["a", "b"], + "vector.similarity.euclidean": ["a", "b"], + nullIf: ["value1", "value2"], + valueType: ["value"], + char_length: ["string"], + character_length: ["string"], + btrim: ["string", "characters?"], + ltrim: ["string", "characters?"], + rtrim: ["string", "characters?"], + normalize: ["string", "normalForm?"], + toBooleanOrNull: ["value"], + toFloatOrNull: ["value"], + toIntegerOrNull: ["value"], + toStringOrNull: ["value"], + toBooleanList: ["list"], + toFloatList: ["list"], + toIntegerList: ["list"], + toStringList: ["list"], + file: [], + linenumber: [], + }), + ...temporalFunctions(["date", "datetime", "localdatetime", "localtime", "time"]), + ...["date", "datetime", "localdatetime", "localtime", "time"].flatMap((type) => + functionDefinitions({ + [`${type}.realtime`]: ["timezone?"], + [`${type}.statement`]: ["timezone?"], + [`${type}.transaction`]: ["timezone?"], + [`${type}.truncate`]: ["unit", "input?", "fields?"], + }), + ), + ...graphListPredicates, + graphReduce, + ], +}; + +export default profile; diff --git a/docs/screenshots/neo4j-query-completion.png b/docs/screenshots/neo4j-query-completion.png new file mode 100644 index 0000000000000000000000000000000000000000..290ab913a28c8b05679e545dcfd352f670e544df GIT binary patch literal 16061 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 L-;IG?s^NbDm8O?E literal 0 HcmV?d00001