From 063801e1af1b7724974c6ecb939eb03048865041 Mon Sep 17 00:00:00 2001 From: Hand Sonic <8078023+HandSonic@users.noreply.github.com> Date: Sun, 18 Oct 2026 12:13:32 -0700 Subject: [PATCH] feat(editor): add native NebulaGraph nGQL completion --- .../QueryEditor.graph.integration.spec.ts | 211 ++++++++++++++++++ .../useQueryEditorDiagnostics.spec.ts | 9 + .../editor/queryEditorSqlExtensions.ts | 21 +- .../editor/useQueryEditorCompletion.ts | 64 +++++- .../editor/useQueryEditorDiagnostics.ts | 3 +- .../src/lib/editor/queryEditorLineComment.ts | 2 + apps/desktop/src/lib/graph/graphCompletion.ts | 190 ++++++++++++++++ .../src/lib/graph/graphCompletionScope.ts | 108 +++++++++ .../lib/graph/graphQueryConformance.spec.ts | 141 ++++++++++++ .../src/lib/graph/graphQueryFunctions.ts | 11 + .../src/lib/graph/graphQueryLanguage.ts | 111 +++++++++ .../src/lib/graph/graphQueryProfile.ts | 79 +++++++ .../src/lib/graph/graphQueryProfiles.ts | 18 ++ .../src/lib/graph/graphSignatureHelp.ts | 62 +++++ .../graph/profiles/nebulaQueryProfile.spec.ts | 46 ++++ .../lib/graph/profiles/nebulaQueryProfile.ts | 97 ++++++++ docs/screenshots/nebula-query-completion.png | Bin 0 -> 21454 bytes 17 files changed, 1162 insertions(+), 11 deletions(-) create mode 100644 apps/desktop/src/components/editor/__tests__/QueryEditor.graph.integration.spec.ts create mode 100644 apps/desktop/src/lib/graph/graphCompletion.ts create mode 100644 apps/desktop/src/lib/graph/graphCompletionScope.ts create mode 100644 apps/desktop/src/lib/graph/graphQueryConformance.spec.ts create mode 100644 apps/desktop/src/lib/graph/graphQueryFunctions.ts create mode 100644 apps/desktop/src/lib/graph/graphQueryLanguage.ts create mode 100644 apps/desktop/src/lib/graph/graphQueryProfile.ts create mode 100644 apps/desktop/src/lib/graph/graphQueryProfiles.ts create mode 100644 apps/desktop/src/lib/graph/graphSignatureHelp.ts create mode 100644 apps/desktop/src/lib/graph/profiles/nebulaQueryProfile.spec.ts create mode 100644 apps/desktop/src/lib/graph/profiles/nebulaQueryProfile.ts create mode 100644 docs/screenshots/nebula-query-completion.png diff --git a/apps/desktop/src/components/editor/__tests__/QueryEditor.graph.integration.spec.ts b/apps/desktop/src/components/editor/__tests__/QueryEditor.graph.integration.spec.ts new file mode 100644 index 0000000000..06b9c67b36 --- /dev/null +++ b/apps/desktop/src/components/editor/__tests__/QueryEditor.graph.integration.spec.ts @@ -0,0 +1,211 @@ +// @vitest-environment happy-dom + +import { createApp, h, nextTick, reactive, shallowRef } from "vue"; +import { createPinia, setActivePinia } from "pinia"; +import { createI18n } from "vue-i18n"; +import { language } from "@codemirror/language"; +import { EditorView, showTooltip } from "@codemirror/view"; +import { toggleLineComment } from "@codemirror/commands"; +import { acceptCompletion, completionStatus, currentCompletions, startCompletion } from "@codemirror/autocomplete"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import QueryEditor from "../QueryEditor.vue"; +import type { QueryEditorProps } from "../queryEditorTypes"; +import { useSettingsStore } from "@/stores/settingsStore"; +import { useConnectionStore } from "@/stores/connectionStore"; + +const cleanups: Array<() => void> = []; + +afterEach(() => { + for (const cleanup of cleanups.splice(0).reverse()) cleanup(); + vi.restoreAllMocks(); +}); + +async function mountEditor(overrides: Partial = {}) { + const pinia = createPinia(); + setActivePinia(pinia); + const settings = useSettingsStore(); + settings.editorSettings.showExecutionTargetPicker = false; + settings.editorSettings.completionTriggerMode = "manual"; + const props = reactive({ modelValue: "S", connectionId: "language-test", databaseType: "qdrant", dialect: "mysql", autoFocus: false, ...overrides }); + const editor = shallowRef>(); + const onExecute = vi.fn(); + const host = document.createElement("div"); + document.body.append(host); + const app = createApp({ + render: () => + h(QueryEditor, { + ...props, + ref: editor, + onExecute, + "onUpdate:modelValue": (value: string) => { + props.modelValue = value; + }, + }), + }); + app.use(pinia); + app.use(createI18n({ legacy: false, locale: "en", messages: { en: {} }, missingWarn: false, fallbackWarn: false })); + app.mount(host); + cleanups.push(() => { + app.unmount(); + host.remove(); + }); + await vi.waitFor(() => expect(host.querySelector(".cm-editor")).not.toBeNull(), { timeout: 5000 }); + const view = EditorView.findFromDOM(host.querySelector(".cm-editor") as HTMLElement)!; + return { editor: editor.value!, view, props, host, onExecute, settings }; +} + +import { graphQueryDatabases, graphQueryProfile, isGraphQueryDatabase } from "@/lib/graph/graphQueryProfiles"; + +describe.each(graphQueryDatabases)("QueryEditor %s native graph completion", (databaseType) => { + it("automatically opens native keywords and inserts the accepted candidate", async () => { + const { view, settings } = await mountEditor({ databaseType, modelValue: "" }); + settings.editorSettings.completionTriggerMode = "require-prefix"; + await nextTick(); + view.dispatch({ changes: { from: 0, insert: "mat" }, selection: { anchor: 3 }, userEvent: "input.type" }); + await vi.waitFor(() => expect(currentCompletions(view.state).map((item) => item.label)).toContain("MATCH")); + expect(currentCompletions(view.state).map((item) => item.label)).not.toContain("SELECT"); + await vi.waitFor(() => expect(acceptCompletion(view)).toBe(true)); + expect(view.state.doc.toString().trim()).toBe("MATCH"); + }); + + it("keeps automatic completion disabled in manual mode", async () => { + const { view } = await mountEditor({ databaseType, modelValue: "" }); + view.dispatch({ changes: { from: 0, insert: "mat" }, selection: { anchor: 3 }, userEvent: "input.type" }); + expect(currentCompletions(view.state)).toEqual([]); + startCompletion(view); + await vi.waitFor(() => expect(currentCompletions(view.state).map((item) => item.label)).toContain("MATCH")); + }); + + it("inserts multiple native parameters and reuses existing argument lists", async () => { + const { view, props } = await mountEditor({ databaseType, modelValue: "RETURN rep" }); + view.dispatch({ selection: { anchor: view.state.doc.length } }); + startCompletion(view); + await vi.waitFor(() => expect(currentCompletions(view.state)[0]?.label).toBe("replace")); + await vi.waitFor(() => expect(acceptCompletion(view)).toBe(true)); + expect(view.state.doc.toString()).toBe("RETURN replace(string, search, replacement)"); + expect(view.state.sliceDoc(view.state.selection.main.from, view.state.selection.main.to)).toBe("string"); + props.modelValue = "RETURN rep_old ('a', 'b', 'c')"; + await nextTick(); + view.dispatch({ selection: { anchor: "RETURN rep".length } }); + startCompletion(view); + await vi.waitFor(() => expect(currentCompletions(view.state)[0]?.label).toBe("replace")); + await vi.waitFor(() => expect(acceptCompletion(view)).toBe(true)); + expect(view.state.doc.toString()).toBe("RETURN replace ('a', 'b', 'c')"); + }); + + it("uses configured function casing, empty placeholders and parameter highlighting", async () => { + const { view, settings } = await mountEditor({ databaseType, modelValue: "RETURN rep" }); + settings.editorSettings.sqlFormatter.functionCase = "upper"; + settings.editorSettings.functionCompletionIncludeParams = false; + await nextTick(); + view.dispatch({ selection: { anchor: view.state.doc.length } }); + startCompletion(view); + await vi.waitFor(() => expect(currentCompletions(view.state)[0]?.label).toBe("REPLACE")); + await vi.waitFor(() => expect(acceptCompletion(view)).toBe(true)); + expect(view.state.doc.toString()).toBe("RETURN REPLACE()"); + expect(view.state.selection.main.head).toBe("RETURN REPLACE(".length); + const tooltip = view.state.facet(showTooltip).filter(Boolean)[0]!; + const dom = tooltip.create(view).dom; + expect(dom.textContent).toContain("REPLACE(string, search, replacement)"); + expect(dom.querySelector('[data-active-parameter="true"]')?.textContent).toBe("string"); + }); + + it("resolves renamed node metadata and replaces the remaining word without a trailing space", async () => { + const source = databaseType === "nebula" ? "MATCH (n:Person) WITH n AS p RETURN p.Person.name" : "MATCH (n:Person) WITH n AS p RETURN p.name"; + const { view, settings } = await mountEditor({ databaseType, database: "synthetic", modelValue: source }); + settings.editorSettings.insertSpaceAfterCompletion = true; + const columns = vi.spyOn(useConnectionStore(), "listCompletionColumns").mockResolvedValue([{ name: "name", table: "Person", dataType: "string" }]); + view.dispatch({ selection: { anchor: source.length - 2 } }); + startCompletion(view); + await vi.waitFor(() => expect(currentCompletions(view.state)[0]?.label).toBe("name")); + expect(columns).toHaveBeenCalledWith("language-test", "synthetic", "Person"); + await vi.waitFor(() => expect(acceptCompletion(view)).toBe(true)); + expect(view.state.doc.toString()).toBe(source); + }); + + it("completes quoted graph labels without duplicating delimiters", async () => { + const source = "MATCH (n:`Per-old`)"; + const { view } = await mountEditor({ databaseType, database: "synthetic", modelValue: source }); + vi.spyOn(useConnectionStore(), "listCompletionTables").mockResolvedValue([{ name: "Person", type: "table" }]); + view.dispatch({ selection: { anchor: source.indexOf("Per") + 3 } }); + startCompletion(view); + await vi.waitFor(() => expect(currentCompletions(view.state)[0]?.label).toBe("Person")); + await vi.waitFor(() => expect(acceptCompletion(view)).toBe(true)); + expect(view.state.doc.toString()).toBe("MATCH (n:`Person`)"); + }); + + it("discards stale graph metadata when the connection changes", async () => { + const { view, props } = await mountEditor({ databaseType, database: "synthetic", modelValue: "MATCH (n:" }); + let resolve!: (tables: Array<{ name: string }>) => void; + const pending = new Promise>((done) => { + resolve = done; + }); + const tables = vi.spyOn(useConnectionStore(), "listCompletionTables").mockImplementation((connectionId) => (connectionId === "language-test" ? pending : Promise.resolve([]))); + view.dispatch({ selection: { anchor: view.state.doc.length } }); + startCompletion(view); + await vi.waitFor(() => expect(tables).toHaveBeenCalled()); + props.connectionId = "another-connection"; + await nextTick(); + resolve([{ name: "OldConnectionLabel" }]); + await vi.waitFor(() => expect(completionStatus(view.state)).not.toBe("pending")); + expect(currentCompletions(view.state).map((item) => item.label)).not.toContain("OldConnectionLabel"); + }); + + it("uses native comments and hides function hints in comments", async () => { + const source = "RETURN sum("; + const { view } = await mountEditor({ databaseType, modelValue: source, initialSelection: { anchor: source.length, head: source.length } }); + expect(view.state.facet(showTooltip).filter(Boolean)).not.toEqual([]); + expect(toggleLineComment(view)).toBe(true); + expect(view.state.doc.toString()).toBe(graphQueryProfile(databaseType).lineComment + " " + source); + expect(view.state.facet(showTooltip).filter(Boolean)).toEqual([]); + }); + + it("honors custom snippets and excluded completion items", async () => { + const { view, props, settings } = await mountEditor({ databaseType, modelValue: "gq" }); + settings.editorSettings.snippets = [{ id: "custom-graph", prefix: "gq", label: "Find Person", body: "MATCH (p:Person) RETURN p" }]; + await nextTick(); + view.dispatch({ selection: { anchor: 2 } }); + startCompletion(view); + await vi.waitFor(() => expect(currentCompletions(view.state)[0]?.label).toBe("gq Find Person")); + await vi.waitFor(() => expect(acceptCompletion(view)).toBe(true)); + expect(view.state.doc.toString().trim()).toBe("MATCH (p:Person) RETURN p"); + settings.editorSettings.excludedCompletionItems = ["replace"]; + props.modelValue = "RETURN rep"; + await nextTick(); + view.dispatch({ selection: { anchor: view.state.doc.length } }); + startCompletion(view); + await vi.waitFor(() => expect(completionStatus(view.state)).not.toBe("pending")); + expect(currentCompletions(view.state).map((item) => item.label)).not.toContain("replace"); + }); + + it("restores SQL completion and signatures after changing database type", async () => { + const { view, props } = await mountEditor({ databaseType, modelValue: "mat" }); + view.dispatch({ selection: { anchor: 3 } }); + startCompletion(view); + await vi.waitFor(() => expect(currentCompletions(view.state).map((item) => item.label)).toContain("MATCH")); + props.databaseType = "mysql"; + props.modelValue = "S"; + await nextTick(); + view.dispatch({ selection: { anchor: 1 } }); + startCompletion(view); + await vi.waitFor(() => expect(currentCompletions(view.state).map((item) => item.label)).toContain("SELECT")); + props.modelValue = "SELECT GROUP_CONCAT("; + await nextTick(); + view.dispatch({ selection: { anchor: view.state.doc.length } }); + expect(view.state.facet(showTooltip).filter(Boolean)).not.toEqual([]); + props.databaseType = databaseType; + await nextTick(); + expect(view.state.facet(showTooltip).filter(Boolean)).toEqual([]); + }); +}); + +it("does not enable a graph dialect whose profile is absent", async () => { + for (const databaseType of ["neo4j", "nebula"] as const) { + if (isGraphQueryDatabase(databaseType)) continue; + const { view } = await mountEditor({ databaseType, modelValue: "S" }); + expect(view.state.facet(language)).not.toBeNull(); + view.dispatch({ selection: { anchor: 1 } }); + startCompletion(view); + await vi.waitFor(() => expect(currentCompletions(view.state).map((item) => item.label)).toContain("SELECT")); + } +}); diff --git a/apps/desktop/src/components/editor/__tests__/useQueryEditorDiagnostics.spec.ts b/apps/desktop/src/components/editor/__tests__/useQueryEditorDiagnostics.spec.ts index b414bd87ff..54a7bf3fe1 100644 --- a/apps/desktop/src/components/editor/__tests__/useQueryEditorDiagnostics.spec.ts +++ b/apps/desktop/src/components/editor/__tests__/useQueryEditorDiagnostics.spec.ts @@ -8,6 +8,7 @@ import { useQueryEditorDiagnostics } from "../useQueryEditorDiagnostics"; import type { SqlSemanticDiagnostic } from "@/lib/sql/semantic/diagnostics"; import type { QueryEditorProps } from "../queryEditorTypes"; import type { QueryResult, SqlReferenceAnalysis } from "@/types/database"; +import { graphQueryDatabases } from "@/lib/graph/graphQueryProfiles"; const { analyze, buildDiagnostics } = vi.hoisted(() => ({ analyze: vi.fn(), buildDiagnostics: vi.fn() })); vi.mock("@/lib/backend/api", () => ({ analyzeSqlReferences: analyze })); @@ -88,6 +89,14 @@ function createHarness(overrides: Partial = {}) { } describe("QueryEditor diagnostic scheduling ownership", () => { + it.each(graphQueryDatabases)("does not send %s editor text to SQL semantic diagnostics", async (databaseType) => { + const { diagnostics, metadata } = createHarness({ databaseType }); + diagnostics.scheduleSemanticDiagnostics(0); + await vi.advanceTimersByTimeAsync(0); + expect(analyze).not.toHaveBeenCalled(); + expect(buildDiagnostics).not.toHaveBeenCalled(); + expect(metadata.ensureColumnsForTable).not.toHaveBeenCalled(); + }); it("preserves the unqualified quoted OceanBase reference and selected schema through metadata loading", async () => { const sql = 'SELECT MISSING FROM "Alias"'; const { diagnostics, metadata } = createHarness({ databaseType: "oceanbase-oracle", dialect: "oracle", schema: "APP", modelValue: sql }); diff --git a/apps/desktop/src/components/editor/queryEditorSqlExtensions.ts b/apps/desktop/src/components/editor/queryEditorSqlExtensions.ts index 03cf3d3690..95d4ec9121 100644 --- a/apps/desktop/src/components/editor/queryEditorSqlExtensions.ts +++ b/apps/desktop/src/components/editor/queryEditorSqlExtensions.ts @@ -14,6 +14,8 @@ import { compareSqlCompletions } from "@/lib/editor/sqlCompletionPresentation"; import { currentStatementFrameLayer } from "@/lib/editor/codemirrorCurrentStatementFrameLayer"; import { createSqlAliasHighlights } from "@/lib/editor/codemirrorSqlAliasHighlights"; import { createDbxCodeMirrorSqlDialect, supportsQueryEditorSqlLanguage } from "@/lib/editor/codemirrorSqlDialect"; +import { graphQueryLanguage, isGraphQueryDatabase } from "@/lib/graph/graphQueryLanguage"; +import { getGraphFunctionSignatureHelp } from "@/lib/graph/graphSignatureHelp"; import { sqlSemanticTableNameSpansForSyntaxTree } from "@/lib/editor/codemirrorSqlSemanticHighlight"; import { queryEditorCommentTokens, queryEditorLineCommentToken, queryEditorWordLanguageData } from "@/lib/editor/queryEditorLineComment"; import { createShellLineCommentHighlight } from "@/lib/editor/codemirrorShellLineCommentHighlight"; @@ -300,7 +302,10 @@ export function configureQueryEditorSqlExtensions(options: QueryEditorSqlExtensi // text instead of materializing the whole document prefix on every // keystroke — doc.toString() was O(document) per key on large scripts. const windowFrom = Math.max(0, cursor - SQL_SIGNATURE_HELP_WINDOW_CHARS); - const signature = getSqlFunctionSignatureHelp(currentState.doc.sliceString(windowFrom, cursor), cursor - windowFrom, props.databaseType, sqlDriverProfile.value, { truncatedPrefix: windowFrom > 0 }); + const source = currentState.doc.sliceString(windowFrom, cursor); + const signature = isGraphQueryDatabase(props.databaseType) + ? getGraphFunctionSignatureHelp(source, cursor - windowFrom, props.databaseType, { truncatedPrefix: windowFrom > 0 }) + : getSqlFunctionSignatureHelp(source, cursor - windowFrom, props.databaseType, sqlDriverProfile.value, { truncatedPrefix: windowFrom > 0 }); if (!signature) return null; return { pos: cursor, @@ -332,11 +337,13 @@ export function configureQueryEditorSqlExtensions(options: QueryEditorSqlExtensi const shellLineCommentHighlightPlugin = createShellLineCommentHighlight({ ViewPlugin, Decoration, highlightingFor, syntaxTree }); codeMirrorRuntime.buildSqlLanguageExtension = () => [ - supportsQueryEditorSqlLanguage(props.databaseType) - ? langSql.sql({ - dialect: createDbxCodeMirrorSqlDialect(langSql, props.syntaxDialect ?? props.dialect, props.databaseType, sqlDriverProfile.value), - }) - : [], + isGraphQueryDatabase(props.databaseType) + ? graphQueryLanguage(props.databaseType) + : supportsQueryEditorSqlLanguage(props.databaseType) + ? langSql.sql({ + dialect: createDbxCodeMirrorSqlDialect(langSql, props.syntaxDialect ?? props.dialect, props.databaseType, sqlDriverProfile.value), + }) + : [], // Non-SQL editors (MongoDB shell) keep the SQL grammar for highlighting, so override the // comment marker that toggleLineComment reads from language data. Prec.highest(EditorState.languageData.of(() => [{ commentTokens: queryEditorCommentTokens(props.databaseType) }])), @@ -354,7 +361,7 @@ export function configureQueryEditorSqlExtensions(options: QueryEditorSqlExtensi const refreshSqlSemanticHighlightEffect = StateEffect.define(); codeMirrorRuntime.buildSqlSemanticHighlightExtension = () => { - if (!supportsQueryEditorSqlLanguage(props.databaseType)) return []; + if (!supportsQueryEditorSqlLanguage(props.databaseType) || isGraphQueryDatabase(props.databaseType)) return []; return [ createSqlAliasHighlights({ databaseType: props.databaseType, dialect: sqlBehaviorDialect(), enabled: queryEditorSelectionLanguage() === "sql" }), ViewPlugin.fromClass( diff --git a/apps/desktop/src/components/editor/useQueryEditorCompletion.ts b/apps/desktop/src/components/editor/useQueryEditorCompletion.ts index 4069064be9..1994c8394d 100644 --- a/apps/desktop/src/components/editor/useQueryEditorCompletion.ts +++ b/apps/desktop/src/components/editor/useQueryEditorCompletion.ts @@ -52,7 +52,9 @@ import { sqlSnippetDatabaseTypeForConnection } from "@/lib/database/jdbcDialect" import { oracleDatabaseLinkCompletionItems } from "@/lib/sql/oracleDatabaseLinkCompletion"; import { isOracleCompletionDatabase, isSequenceCompletionDatabase } from "@/lib/sql/oracleCompletionSession"; import { buildRedisCompletionItemsFromContext, getRedisCompletionContext, getRedisCompletionResultValidFor, shouldAutoOpenRedisCompletion, takesKeyArgument } from "@/lib/redis/redisCompletion"; -import type { SqlCompletionColumn, SqlCompletionContext, SqlCompletionForeignKey, SqlCompletionItem, SqlCompletionObject } from "@/lib/sql/sqlCompletion"; +import { buildGraphCompletionItems, getGraphCompletionContext, graphCompletionValidFor, shouldAutoOpenGraphCompletion } from "@/lib/graph/graphCompletion"; +import { isGraphQueryDatabase, type GraphQueryDatabase } from "@/lib/graph/graphQueryLanguage"; +import type { SqlCompletionColumn, SqlCompletionContext, SqlCompletionForeignKey, SqlCompletionItem, SqlCompletionObject, SqlCompletionTable } from "@/lib/sql/sqlCompletion"; import type { CompletionAssistantObjectKind, SqlServerCompletionContext } from "@/types/database"; import type { ShallowRef, ComputedRef } from "vue"; import type { Composer } from "vue-i18n"; @@ -371,7 +373,8 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions) return mergeSqlCompletionQualifierNames(currentSchema ? [currentSchema] : [], connectionStore.lookupLocalCompletionSchemas(props.connectionId, currentDatabase, completionContext.qualifier, MAX_COMPLETION_TABLES)); } - function shouldInsertSqlCompletionSpace(): boolean { + function shouldInsertSqlCompletionSpace(item: QueryCompletionItem): boolean { + if (isGraphQueryDatabase(props.databaseType)) return item.type === "keyword"; return ( props.databaseType !== "mongodb" && props.databaseType !== "redis" && @@ -481,7 +484,7 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions) replaceSelectWildcard: "replaceSelectWildcard" in item ? item.replaceSelectWildcard : undefined, }); let insert = appendSqlCompletionSpace(item.apply ?? item.label, { - enabled: ("appendSpace" in item && item.appendSpace === true) || (shouldInsertSqlCompletionSpace() && settingsStore.editorSettings.insertSpaceAfterCompletion), + enabled: ("appendSpace" in item && item.appendSpace === true) || (shouldInsertSqlCompletionSpace(item) && settingsStore.editorSettings.insertSpaceAfterCompletion), itemType: item.type, nextCharacter: view.state.sliceDoc(replaceTo, replaceTo + 1), }); @@ -691,6 +694,56 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions) }; } + async function provideGraphCompletions(context: CompletionContext, databaseType: GraphQueryDatabase) { + const { connectionId, database } = props; + if (!connectionId) return null; + const fullDoc = context.state.doc.toString(); + const completionContext = getGraphCompletionContext(fullDoc, context.pos, databaseType); + if (completionContext.mode === "none") return null; + if (!context.explicit) { + const mode = settingsStore.editorSettings.completionTriggerMode; + if (mode === "manual" || (mode === "require-prefix" && !completionContext.prefix) || !shouldAutoOpenGraphCompletion(fullDoc, context.pos, databaseType)) return null; + } + const epoch = ++completionEpoch; + const isCurrent = () => !context.aborted && epoch === completionEpoch && props.connectionId === connectionId && props.database === database && props.databaseType === databaseType; + let tables: SqlCompletionTable[] = []; + let columns: SqlCompletionColumn[] = []; + let databases: string[] = []; + try { + if (completionContext.mode === "database") databases = await connectionStore.listCompletionDatabases(connectionId); + else if (database != null && ["label", "relationship", "object"].includes(completionContext.mode) && !(databaseType === "neo4j" && completionContext.mode === "relationship")) { + tables = await connectionStore.listCompletionTables(connectionId, database, completionContext.prefix, MAX_COMPLETION_TABLES, undefined, false, undefined, undefined, { activateConnection: false }); + } else if (database != null && completionContext.mode === "property") { + for (const object of completionContext.objects) { + if (!isCurrent()) return null; + columns.push(...(await connectionStore.listCompletionColumns(connectionId, database, object))); + } + } + } catch { + // Native syntax remains available when a connection cannot supply metadata. + } + if (!isCurrent()) return null; + const result = buildCompletionResult( + buildGraphCompletionItems( + completionContext, + databaseType, + { tables, columns, databases }, + { + keywordCase: settingsStore.editorSettings.sqlFormatter.keywordCase, + functionCase: settingsStore.editorSettings.sqlFormatter.functionCase, + functionCompletionIncludeParams: settingsStore.editorSettings.functionCompletionIncludeParams, + snippets: settingsStore.editorSettings.snippets, + }, + ), + completionContext.from, + graphCompletionValidFor(completionContext), + completionContext.prefix, + ); + // Candidates already match the typed prefix; the replacement can also cover an old suffix. + if (result && completionContext.to > context.pos) return { ...result, to: completionContext.to, filter: false as const, validFor: undefined }; + return result ? { ...result, to: completionContext.to } : null; + } + async function provideSqlCompletions(context: CompletionContext) { const currentState = context.state; const position = context.pos; @@ -698,6 +751,7 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions) const typedActivation = isTypedCompletionActivation(explicit); if (runtime.imeCompositionActive || view.value?.compositionStarted || view.value?.composing) return null; if (!props.connectionId) return null; + if (isGraphQueryDatabase(props.databaseType)) return provideGraphCompletions(context, props.databaseType); const fullDoc = currentState.doc.toString(); if (props.databaseType === "mongodb") { return provideMongoCompletions(currentState, position, explicit); @@ -1023,6 +1077,10 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions) async function shouldTriggerSqlCompletionForPosition(fullDoc: string, position: number): Promise { const mode = settingsStore.editorSettings.completionTriggerMode; if (mode === "manual") return false; + if (isGraphQueryDatabase(props.databaseType)) { + const context = getGraphCompletionContext(fullDoc, position, props.databaseType); + return (mode !== "require-prefix" || !!context.prefix) && shouldAutoOpenGraphCompletion(fullDoc, position, props.databaseType); + } const currentView = view.value; const expectedDoc = currentView?.state.doc; const analysis = await getEditorSqlCompletionAnalysis(fullDoc, position, currentView?.state); diff --git a/apps/desktop/src/components/editor/useQueryEditorDiagnostics.ts b/apps/desktop/src/components/editor/useQueryEditorDiagnostics.ts index acc52c9d89..03ca05abe6 100644 --- a/apps/desktop/src/components/editor/useQueryEditorDiagnostics.ts +++ b/apps/desktop/src/components/editor/useQueryEditorDiagnostics.ts @@ -5,6 +5,7 @@ import type { QueryEditorProps, CompletionMetadataScope } from "./queryEditorTyp import * as api from "@/lib/backend/api"; import type { SqlParameterOptions } from "@/lib/sql/sqlParameters"; import { createSqlDiagnosticAnalysisWorker } from "@/lib/sql/sqlDiagnosticAnalysisWorker"; +import { isGraphQueryDatabase } from "@/lib/graph/graphQueryProfiles"; import type { EditorView as EditorViewType } from "@codemirror/view"; import { type SqlTextRange } from "@/lib/sql/sqlStatementRanges"; @@ -209,7 +210,7 @@ export function useQueryEditorDiagnostics(options: QueryEditorDiagnosticsOptions } function shouldSkipSqlSemanticDiagnostics() { - return props.databaseType === "victoriametrics" || props.databaseType === "salesforce" || (props.databaseType !== "redis" && props.databaseType !== "mongodb" && !settingsStore.editorSettings.sqlSemanticDiagnosticsEnabled); + return isGraphQueryDatabase(props.databaseType) || props.databaseType === "victoriametrics" || props.databaseType === "salesforce" || (props.databaseType !== "redis" && props.databaseType !== "mongodb" && !settingsStore.editorSettings.sqlSemanticDiagnosticsEnabled); } function rangesOverlap(left: { from: number; to: number }, right: { from: number; to: number }): boolean { diff --git a/apps/desktop/src/lib/editor/queryEditorLineComment.ts b/apps/desktop/src/lib/editor/queryEditorLineComment.ts index faa786fbe1..8aadb06f94 100644 --- a/apps/desktop/src/lib/editor/queryEditorLineComment.ts +++ b/apps/desktop/src/lib/editor/queryEditorLineComment.ts @@ -1,4 +1,5 @@ import type { DatabaseType } from "@/types/database"; +import { graphQueryProfile, isGraphQueryDatabase } from "@/lib/graph/graphQueryProfiles"; const SQL_LINE_COMMENT_TOKEN = "--"; @@ -9,6 +10,7 @@ const LINE_COMMENT_TOKENS: Partial> = { export function queryEditorLineCommentToken(dbType?: DatabaseType): string { if (!dbType) return SQL_LINE_COMMENT_TOKEN; + if (isGraphQueryDatabase(dbType)) return graphQueryProfile(dbType).lineComment; return LINE_COMMENT_TOKENS[dbType] ?? SQL_LINE_COMMENT_TOKEN; } diff --git a/apps/desktop/src/lib/graph/graphCompletion.ts b/apps/desktop/src/lib/graph/graphCompletion.ts new file mode 100644 index 0000000000..5b3873ec07 --- /dev/null +++ b/apps/desktop/src/lib/graph/graphCompletion.ts @@ -0,0 +1,190 @@ +import type { SqlCompletionColumn, SqlCompletionItem, SqlCompletionTable, SqlKeywordCase } from "@/lib/sql/sqlCompletion"; +import type { SqlSnippet } from "@/types/database"; +import { graphFunctionDefinitions } from "./graphQueryFunctions"; +import { graphQueryKeywords, graphQueryTokens, type GraphQueryDatabase } from "./graphQueryLanguage"; +import { graphQueryProfile } from "./graphQueryProfiles"; +import { graphCompletionScope, graphIdentifier as identifier } from "./graphCompletionScope"; + +export interface GraphCompletionContext { + mode: "none" | "expression" | "label" | "relationship" | "object" | "property" | "database" | "function"; + from: number; + to: number; + prefix: string; + quoted: boolean; + openingParenAfterCursor?: boolean; + replaceClosingQuote?: "`"; + objects: string[]; + variables: string[]; + functionNamespace?: string; + localProperties?: string[]; + propertyMap?: boolean; +} + +export function getGraphCompletionContext(text: string, cursor: number, databaseType: GraphQueryDatabase): GraphCompletionContext { + const position = Math.max(0, Math.min(cursor, text.length)); + const source = text.slice(0, position); + const tokens = graphQueryTokens(source, databaseType); + const last = tokens[tokens.length - 1]; + const result: GraphCompletionContext = { mode: "expression", from: position, to: position, prefix: "", quoted: false, objects: [], variables: [] }; + if (last && ((last.kind === "string" && last.open) || (last.kind === "comment" && (last.open || (!last.text.startsWith("/*") && !/[\r\n]/.test(source.slice(last.to))))))) return { ...result, mode: "none" }; + const atIdentifier = last?.to === position && identifier(last) !== undefined; + if (last && atIdentifier) { + result.from = last.from; + result.prefix = identifier(last)!; + result.quoted = last.text.startsWith("`"); + if (result.quoted) { + result.from++; + if (last.open) result.to += /^(?:[^`]|``)*/.exec(text.slice(position))?.[0].length ?? 0; + if (text[result.to] === "`") result.replaceClosingQuote = "`"; + } + if (!result.quoted) result.to += /^[\p{L}\p{N}_$]*/u.exec(text.slice(position))?.[0].length ?? 0; + tokens.pop(); + } + let statementStart = tokens.length - 1; + while (statementStart >= 0 && tokens[statementStart]?.text !== ";") statementStart--; + const before = tokens.slice(statementStart + 1).filter((token) => token.kind !== "comment"); + const { bindings, pipelineFields, propertyMapObjects } = graphCompletionScope(before); + result.variables = [...bindings.keys()]; + const tail = before[before.length - 1]; + const previous = before[before.length - 2]; + if (tail?.text === ".") { + const qualifier = identifier(previous); + result.mode = "property"; + result.objects = qualifier ? (bindings.get(qualifier)?.objects ?? (databaseType === "nebula" ? [qualifier] : [])) : []; + const parts: string[] = []; + for (let index = before.length - 2; index >= 0; index -= 2) { + const name = identifier(before[index]); + if (!name) break; + parts.unshift(name); + if (before[index - 1]?.text !== ".") break; + } + const namespace = parts.join("."); + if (!bindings.has(parts[0] ?? "") && graphFunctionDefinitions(databaseType).some((definition) => definition.name.toLowerCase().startsWith(`${namespace.toLowerCase()}.`))) { + result.mode = "function"; + result.functionNamespace = namespace; + result.objects = []; + } + // nGQL qualifies node properties with a tag: v.player.name. + if (databaseType === "nebula") { + if (before[before.length - 3]?.text === "." && qualifier) result.objects = [qualifier]; + else if ((qualifier && bindings.has(qualifier) && !bindings.get(qualifier)?.relationship) || qualifier === "$$" || previous?.text === "^") result.mode = "label"; + if (previous?.text === "-" && before[before.length - 3]?.text === "$") { + result.mode = "property"; + result.objects = []; + result.localProperties = pipelineFields; + } + } + } else if (tail?.text === ":") { + const stack: string[] = []; + for (const token of before) { + if (["(", "[", "{"].includes(token.text)) stack.push(token.text); + else if ([")", "]", "}"].includes(token.text)) stack.pop(); + } + result.mode = stack[stack.length - 1] === "[" ? "relationship" : stack[stack.length - 1] === "(" ? "label" : "none"; + } else if (databaseType === "nebula") { + const words = before.map((token) => token.text.toUpperCase()); + if (tail?.text.toUpperCase() === "USE") result.mode = "database"; + else if (tail?.text.toUpperCase() === "OVER" || ["EDGE", "EDGES"].includes(words[words.length - 1] ?? "")) result.mode = "relationship"; + else if (["TAG", "TAGS", "VERTEX"].includes(words[words.length - 1] ?? "")) result.mode = "label"; + else if (tail?.text.toUpperCase() === "ON" && ["LOOKUP", "FETCH"].some((word) => words.includes(word))) result.mode = "object"; + else if (tail?.text === ",") { + for (let index = before.length - 2; index >= 0; index--) { + const word = before[index]!.text.toUpperCase(); + if (["YIELD", "WHERE", "RETURN", "|", ";"].includes(word)) break; + if (word === "OVER") { + result.mode = "relationship"; + break; + } + } + } + } + if (propertyMapObjects !== undefined) { + result.mode = "property"; + result.objects = propertyMapObjects; + result.propertyMap = true; + } + let callIndex = -1; + for (let index = before.length - 1; index >= 0; index--) { + if (before[index]?.kind !== "keyword") continue; + const word = before[index]!.text.toUpperCase(); + if (word === "CALL") { + callIndex = index; + break; + } + if (["RETURN", "WITH", "YIELD", "MATCH", "WHERE"].includes(word)) break; + } + if (callIndex >= 0 && before.slice(callIndex + 1).every((token) => identifier(token) !== undefined || token.text === ".")) result.mode = "none"; + if (result.quoted && result.mode === "expression") result.mode = "none"; + result.openingParenAfterCursor = /^\s*\(/.test(text.slice(result.to)); + return result; +} + +export function shouldAutoOpenGraphCompletion(text: string, cursor: number, databaseType: GraphQueryDatabase): boolean { + const context = getGraphCompletionContext(text, cursor, databaseType); + return context.mode !== "none" && (!!context.prefix || context.mode !== "expression"); +} + +export function graphCompletionValidFor(context: GraphCompletionContext): RegExp { + return context.quoted ? /^(?:[^`]|``)*$/ : /^[\p{L}\p{N}_$]*$/u; +} + +export interface GraphCompletionOptions { + keywordCase?: SqlKeywordCase; + functionCase?: SqlKeywordCase; + functionCompletionIncludeParams?: boolean; + snippets?: SqlSnippet[]; +} + +function applyCase(value: string, letterCase?: SqlKeywordCase): string { + return letterCase === "lower" ? value.toLowerCase() : letterCase === "upper" ? value.toUpperCase() : value; +} + +export function buildGraphCompletionItems(context: GraphCompletionContext, databaseType: GraphQueryDatabase, metadata: { tables?: SqlCompletionTable[]; columns?: SqlCompletionColumn[]; databases?: string[] } = {}, options: GraphCompletionOptions = {}): SqlCompletionItem[] { + if (context.mode === "none") return []; + const profile = graphQueryProfile(databaseType); + const language = profile.language; + const reserved = new Set(profile.reservedWords); + const quote = (name: string) => (context.quoted ? `${name.replaceAll("`", "``")}\`` : !/^[\p{L}_][\p{L}\p{N}_]*$/u.test(name) || reserved.has(name.toUpperCase()) ? `\`${name.replaceAll("`", "``")}\`` : name); + const nameItem = (name: string, type: "table" | "column" | "schema" | "variable", detail: string): SqlCompletionItem => ({ label: name, type, detail, apply: quote(name), boost: 100, ...(context.replaceClosingQuote ? { replaceClosingQuote: context.replaceClosingQuote } : {}) }); + let items: SqlCompletionItem[]; + if (context.mode === "database") items = (metadata.databases ?? []).map((name) => nameItem(name, "schema", "nGQL space")); + else if (context.mode === "property") items = [...(metadata.columns ?? []).map((column) => nameItem(column.name, "column", [column.table, column.dataType].filter(Boolean).join(" - "))), ...(context.localProperties ?? []).map((name) => nameItem(name, "column", "nGQL pipeline column"))]; + else if (["label", "relationship", "object"].includes(context.mode)) { + items = (metadata.tables ?? []) + .filter((table) => { + // Neo4j's existing table metadata contains node labels, not relationship types. + if (databaseType === "neo4j") return context.mode !== "relationship"; + return context.mode === "object" || (context.mode === "relationship" ? table.type === "view" : table.type !== "view"); + }) + .map((table) => nameItem(table.name, "table", databaseType === "neo4j" ? "Cypher label" : table.type === "view" ? "nGQL edge" : "nGQL tag")); + } else { + items = [ + ...(context.mode === "function" ? [] : graphQueryKeywords(databaseType).map((word): SqlCompletionItem => ({ label: applyCase(word, options.keywordCase), type: "keyword", detail: language, boost: 40 }))), + ...graphFunctionDefinitions(databaseType) + .filter((definition) => !context.functionNamespace || definition.name.toLowerCase().startsWith(`${context.functionNamespace.toLowerCase()}.`)) + .map((definition): SqlCompletionItem => { + const label = applyCase(context.functionNamespace ? definition.name.slice(context.functionNamespace.length + 1) : definition.name, options.functionCase); + const required = definition.parameters.filter((parameter) => !parameter.endsWith("?") && !parameter.startsWith("...")); + const template = definition.template?.replace(/\b(IN|WHERE)\b/g, (word) => applyCase(word, options.keywordCase)); + const parameters = options.functionCompletionIncludeParams === false ? (definition.parameters.length ? "${}" : "") : (template ?? required.map((parameter) => `\${${parameter}}`).join(", ")); + return { label, type: "function", detail: `${language} - ${label}(${definition.parameters.join(", ")})`, apply: context.openingParenAfterCursor ? label : `${label}(${parameters})`, boost: 30 }; + }), + ...(context.mode === "function" ? [] : context.variables.map((name) => nameItem(name, "variable", `${language} variable`))), + ]; + if (context.prefix) { + for (const snippet of options.snippets ?? []) { + // SQL built-ins are not valid graph query templates; user snippets retain their exact text. + if (snippet.enabled === false || snippet.id.startsWith("builtin-")) continue; + if (!snippet.prefix.toLowerCase().startsWith(context.prefix.toLowerCase())) continue; + items.push({ label: snippet.label, filterText: snippet.prefix, type: "snippet", apply: snippet.body, detail: snippet.body, exactMatch: snippet.prefix.toLowerCase() === context.prefix.toLowerCase() || undefined, boost: 4000 }); + } + } + } + const seen = new Set(); + return items.filter((item) => { + const key = `${item.type}:${item.label}`; + if (seen.has(key) || !(item.filterText ?? item.label).toLowerCase().startsWith(context.prefix.toLowerCase())) return false; + seen.add(key); + return true; + }); +} diff --git a/apps/desktop/src/lib/graph/graphCompletionScope.ts b/apps/desktop/src/lib/graph/graphCompletionScope.ts new file mode 100644 index 0000000000..e9a6671d18 --- /dev/null +++ b/apps/desktop/src/lib/graph/graphCompletionScope.ts @@ -0,0 +1,108 @@ +import type { GraphQueryToken } from "./graphQueryLanguage"; + +export interface GraphCompletionBinding { + objects: string[]; + relationship: boolean; +} + +export function graphIdentifier(token: GraphQueryToken | undefined): string | undefined { + if (!token || !["variable", "identifier", "builtin", "keyword"].includes(token.kind ?? "")) return undefined; + if (token.text.startsWith("`")) return token.text.slice(1, token.open ? undefined : -1).replaceAll("``", "`"); + return /^[\p{L}_$][\p{L}\p{N}_$]*$/u.test(token.text) ? token.text : undefined; +} + +const CLAUSES = new Set(["MATCH", "CREATE", "MERGE", "WHERE", "WITH", "RETURN", "UNWIND", "CALL", "YIELD", "ORDER", "SKIP", "LIMIT", "SET", "REMOVE", "DELETE", "UNION", "GO", "LOOKUP", "FETCH"]); +const emptyBinding = (): GraphCompletionBinding => ({ objects: [], relationship: false }); + +function projections(tokens: GraphQueryToken[], bindings: Map) { + const output = new Map(); + let depth = 0; + let start = 0; + const add = (part: GraphQueryToken[]) => { + if (part[0]?.text.toUpperCase() === "DISTINCT") part = part.slice(1); + if (part.length === 1 && part[0]?.text === "*") for (const [name, binding] of bindings) output.set(name, binding); + const alias = part.findIndex((token) => token.kind === "keyword" && token.text.toUpperCase() === "AS"); + const name = graphIdentifier(alias >= 0 ? part[alias + 1] : part.length === 1 ? part[0] : undefined); + if (!name) return; + const source = alias === 1 || part.length === 1 ? graphIdentifier(part[0]) : undefined; + output.set(name, source ? (bindings.get(source) ?? emptyBinding()) : emptyBinding()); + }; + for (let index = 0; index <= tokens.length; index++) { + const text = tokens[index]?.text; + if (index === tokens.length || (text === "," && depth === 0)) { + add(tokens.slice(start, index)); + start = index + 1; + } else if (["(", "[", "{"].includes(text ?? "")) depth++; + else if ([")", "]", "}"].includes(text ?? "")) depth--; + } + return output; +} + +export function graphCompletionScope(tokens: GraphQueryToken[]) { + let bindings = new Map(); + let clause = ""; + let projection: { kind: string; start: number } | undefined; + let pipelineFields: string[] = []; + const stack: Array<{ delimiter: string; kind?: "node" | "relationship" | "properties"; objects: string[] }> = []; + + // Use the native lexer's complete tokens; punctuation inside strings/comments never changes scope. + for (let index = 0; index < tokens.length; index++) { + const token = tokens[index]!; + if (token.kind === "string" || token.kind === "comment") continue; + const word = token.text.toUpperCase(); + const previous = tokens[index - 1]; + if (stack.length === 0 && previous?.text !== "." && token.kind === "keyword" && CLAUSES.has(word)) { + if (projection) { + const projected = projections(tokens.slice(projection.start, index), bindings); + if (projection.kind === "WITH") bindings = projected; + projection = undefined; + } + if (word === "UNION") bindings.clear(); + clause = word; + if (["WITH", "RETURN", "YIELD"].includes(word)) projection = { kind: word, start: index + 1 }; + } + if (stack.length === 0 && clause === "UNWIND" && word === "AS") { + const name = graphIdentifier(tokens[index + 1]); + if (name) bindings.set(name, emptyBinding()); + } + if (stack.length === 0 && token.text === "|") { + const projected = projection ? projections(tokens.slice(projection.start, index), bindings) : new Map(); + pipelineFields = [...projected.keys()]; + bindings = projected; + projection = undefined; + clause = ""; + } + if (["(", "[", "{"].includes(token.text)) { + let kind: "node" | "relationship" | "properties" | undefined; + let objects: string[] = []; + const pattern = ["MATCH", "CREATE", "MERGE"].includes(clause); + const boundary = !previous || previous.kind === "keyword" || ["=", ",", "("].includes(previous.text) || /^[<>-]+$/.test(previous.text); + if (pattern && boundary && (token.text === "(" || (token.text === "[" && /-/.test(previous?.text ?? "")))) { + kind = token.text === "(" ? "node" : "relationship"; + const variable = graphIdentifier(tokens[index + 1]); + let next = variable ? index + 2 : index + 1; + while ([":", "|", "&"].includes(tokens[next]?.text ?? "")) { + if (tokens[next + 1]?.text === ":") next++; + const label = graphIdentifier(tokens[next + 1]); + if (label) objects.push(label); + next += 2; + } + if (variable && (tokens[index + 1]?.kind !== "keyword" || tokens[index + 1]?.text.startsWith("`"))) { + const known = bindings.get(variable); + bindings.set(variable, { objects: objects.length ? objects : (known?.objects ?? []), relationship: kind === "relationship" }); + } + if (token.text === "(" && previous?.text === "=") { + const path = graphIdentifier(tokens[index - 2]); + if (path) bindings.set(path, emptyBinding()); + } + } else if (token.text === "{" && ["node", "relationship"].includes(stack[stack.length - 1]?.kind ?? "")) { + kind = "properties"; + objects = stack[stack.length - 1]!.objects; + } + stack.push({ delimiter: token.text, kind, objects }); + } else if ([")", "]", "}"].includes(token.text)) stack.pop(); + } + const tail = tokens[tokens.length - 1]?.text; + const frame = stack[stack.length - 1]; + return { bindings, pipelineFields, propertyMapObjects: frame?.kind === "properties" && ["{", ","].includes(tail ?? "") ? frame.objects : undefined }; +} diff --git a/apps/desktop/src/lib/graph/graphQueryConformance.spec.ts b/apps/desktop/src/lib/graph/graphQueryConformance.spec.ts new file mode 100644 index 0000000000..d3a7d78d53 --- /dev/null +++ b/apps/desktop/src/lib/graph/graphQueryConformance.spec.ts @@ -0,0 +1,141 @@ +import { EditorState } from "@codemirror/state"; +import { syntaxTree } from "@codemirror/language"; +import { describe, expect, it } from "vitest"; +import { buildGraphCompletionItems, getGraphCompletionContext, shouldAutoOpenGraphCompletion } from "./graphCompletion"; +import { graphFunctionDefinitions } from "./graphQueryFunctions"; +import { graphQueryLanguage, graphQueryTokens, type GraphQueryDatabase } from "./graphQueryLanguage"; +import { graphQueryDatabases, graphQueryProfile, isGraphQueryDatabase } from "./graphQueryProfiles"; +import { getGraphFunctionSignatureHelp } from "./graphSignatureHelp"; + +const tables = [ + { name: "Person", type: "table" as const }, + { name: "Odd label", type: "table" as const }, +]; +function context(source: string, type: GraphQueryDatabase) { + return getGraphCompletionContext(source, source.length, type); +} +function items(source: string, type: GraphQueryDatabase) { + return buildGraphCompletionItems(context(source, type), type, { tables, columns: [{ table: "Person", name: "name" }] }); +} + +describe.each(graphQueryDatabases)("%s graph completion conformance", (type) => { + it("registers the native lexer without registering SQL databases", () => { + expect(isGraphQueryDatabase(type)).toBe(true); + for (const sqlType of ["mysql", "postgres", "oracle", "sqlserver", "jdbc", "salesforce", "mongodb", "redis"] as const) expect(isGraphQueryDatabase(sqlType)).toBe(false); + const state = EditorState.create({ doc: "MATCH (n:Person) RETURN n", extensions: [graphQueryLanguage(type)] }); + expect(syntaxTree(state).resolveInner(2).name).toBe("keyword"); + expect(graphQueryTokens("MATCH (n:Person)", type).map((token) => token.text)).toEqual(["MATCH", "(", "n", ":", "Person", ")"]); + }); + + it("uses native keywords and catalog signatures rather than SQL suggestions", () => { + expect(items("mat", type).map((item) => item.label)).toContain("MATCH"); + const names = items("", type).map((item) => item.label); + for (const name of ["SELECT", "GROUP_CONCAT", "GROUP BY"]) expect(names).not.toContain(name); + const functions = graphFunctionDefinitions(type); + expect(new Set(functions.map((fn) => fn.name.toLowerCase())).size).toBe(functions.length); + for (const fn of functions) { + const source = "RETURN " + fn.name + "("; + expect(getGraphFunctionSignatureHelp(source, source.length, type)?.parameters).toEqual(fn.parameters); + } + }); + + it.each(["RETURN 'mat", 'RETURN "mat', "RETURN 'escaped\\' mat", "/* ignored\nmat"])("suppresses incomplete literals/comments: %s", (source) => { + expect(items(source, type)).toEqual([]); + expect(shouldAutoOpenGraphCompletion(source, source.length, type)).toBe(false); + }); + + it("resumes native completion after a line comment or a closed literal", () => { + const marker = graphQueryProfile(type).lineComment; + expect(items(marker + " ignored\nmat", type).map((item) => item.label)).toContain("MATCH"); + expect(items('RETURN "ignored"; mat', type).map((item) => item.label)).toContain("MATCH"); + expect(items(marker + " mat", type)).toEqual([]); + }); + + it("tracks aliases, WITH visibility, WITH *, path variables and UNWIND", () => { + const alias = context("MATCH (n:Person) WITH n AS p RETURN p.", type); + expect(alias.objects).toEqual(["Person"]); + expect(alias.variables).toEqual(["p"]); + const dropped = context("MATCH (n:Person) WITH 1 AS x RETURN n.", type); + expect(dropped.objects).not.toContain("Person"); + expect(dropped.variables).toEqual(["x"]); + expect(context("MATCH (n:Person) WITH * RETURN n.", type).objects).toEqual(["Person"]); + expect(items("UNWIND [1,2] AS item RETURN it", type).map((item) => item.label)).toContain("item"); + expect(context("MATCH p=(n:Person)-->() RETURN p", type).variables).toContain("p"); + }); + + it("does not infer pattern variables from function calls or previous statements", () => { + expect(context("RETURN coalesce(value, other), va", type).variables).toEqual([]); + expect(context('MATCH (old:Person) RETURN old; RETURN "(fake:Fake)", old.', type).variables).toEqual([]); + expect(context("MATCH (n:Person) RETURN n UNION RETURN n.", type).variables).toEqual([]); + }); + + it("resolves labels, arrows and property-map keys", () => { + expect(context("MATCH (n:", type).mode).toBe("label"); + expect(context("MATCH ()-[r:", type).mode).toBe("relationship"); + expect(context("MATCH (n:Person)-->(m:Person) RETURN m.", type).objects).toEqual(["Person"]); + expect(items("MATCH (n:Person {na", type).map((item) => item.label)).toEqual(["name"]); + expect(items("MATCH (n:Person {age: 1, na", type).map((item) => item.label)).toEqual(["name"]); + }); + + it("quotes reserved/escaped metadata and replaces identifier suffixes", () => { + const source = "MATCH (n:`Per-old`)"; + const ctx = getGraphCompletionContext(source, source.indexOf("Per") + 3, type); + expect(ctx).toMatchObject({ quoted: true, replaceClosingQuote: "`" }); + expect(buildGraphCompletionItems(ctx, type, { tables })[0]?.apply).toBe("Person`"); + expect(items("MATCH (n:Odd", type)[0]?.apply).toBe("`Odd label`"); + const field = type === "nebula" ? "MATCH (n:Person) RETURN n.Person.re" : "MATCH (n:Person) RETURN n.re"; + expect(buildGraphCompletionItems(context(field, type), type, { columns: [{ name: "RETURN", table: "Person" }] })[0]?.apply).toBe("`RETURN`"); + }); + + it("honors case/parameter settings without changing identifier case", () => { + const result = buildGraphCompletionItems(context("MATCH (Camel:Person) RETURN ", type), type, {}, { keywordCase: "lower", functionCase: "upper", functionCompletionIncludeParams: false }); + expect(result).toEqual(expect.arrayContaining([expect.objectContaining({ label: "return" }), expect.objectContaining({ label: "REPLACE", apply: "REPLACE(${})" }), expect.objectContaining({ label: "Camel", apply: "Camel" })])); + expect(items("RETURN rep", type).find((item) => item.label === "replace")?.apply).toBe("replace(${string}, ${search}, ${replacement})"); + const source = "RETURN rep_old ('a', 'b', 'c')"; + const ctx = getGraphCompletionContext(source, "RETURN rep".length, type); + expect(ctx.openingParenAfterCursor).toBe(true); + expect(buildGraphCompletionItems(ctx, type).find((item) => item.label === "replace")?.apply).toBe("replace"); + }); + + it("keeps user snippets unchanged and excludes SQL built-ins", () => { + const result = buildGraphCompletionItems( + context("gq", type), + type, + {}, + { + snippets: [ + { id: "custom", prefix: "gq", label: "Find Person", body: "MATCH (Camel:Person) RETURN Camel" }, + { id: "builtin-select", prefix: "gq", label: "Select", body: "SELECT * FROM table" }, + { id: "disabled", prefix: "gq", label: "Disabled", body: "RETURN 1", enabled: false }, + ], + }, + ); + expect(result).toEqual([expect.objectContaining({ label: "Find Person", filterText: "gq", apply: "MATCH (Camel:Person) RETURN Camel", exactMatch: true })]); + }); + + it("tracks nested argument groups and temporal overloads", () => { + for (const [source, name, activeParameter] of [ + ["RETURN replace(substring(n.name, 1), ", "replace", 1], + ["RETURN substring(n.name, 0, ", "substring", 2], + ["RETURN coalesce({a: 1, b: [1,2]}, ", "coalesce", 1], + ["RETURN reduce(acc = 0, ", "reduce", 1], + ] as const) { + expect(getGraphFunctionSignatureHelp(source, source.length, type)).toMatchObject({ name, activeParameter }); + } + const source = "RETURN datetime({year: 2026, "; + expect(getGraphFunctionSignatureHelp(source, source.length, type)).toMatchObject({ activeOverload: 1, parameters: ["value"] }); + }); + + it.each(["RETURN 'replace(", "RETURN replace('open", "/* replace(", "RETURN `replace(`", "RETURN n.replace(", "RETURN apoc.replace(", "RETURN GROUP_CONCAT(", "RETURN replace('a','b','c'); RETURN 1", "RETURN replace('a','b','c')"])("suppresses false signatures: %s", (source) => { + expect(getGraphFunctionSignatureHelp(source, source.length, type)).toBeNull(); + }); + + it("rejects a function whose name is cut by the analysis window", () => { + expect(getGraphFunctionSignatureHelp("replace(", 8, type, { truncatedPrefix: true })).toBeNull(); + }); + it("does not offer ordinary functions as CALL procedures", () => { + expect(items("CALL db.na", type).filter((item) => item.type === "function")).toEqual([]); + const source = "CALL db.nameFromElementId("; + expect(getGraphFunctionSignatureHelp(source, source.length, type)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/lib/graph/graphQueryFunctions.ts b/apps/desktop/src/lib/graph/graphQueryFunctions.ts new file mode 100644 index 0000000000..ff74c4c0cc --- /dev/null +++ b/apps/desktop/src/lib/graph/graphQueryFunctions.ts @@ -0,0 +1,11 @@ +import type { GraphQueryDatabase } from "./graphQueryProfile"; +import { graphQueryProfile } from "./graphQueryProfiles"; +export type { GraphFunctionDefinition } from "./graphQueryProfile"; + +export function graphFunctionDefinitions(databaseType: GraphQueryDatabase) { + return graphQueryProfile(databaseType).functions; +} + +export function graphFunctionDefinition(name: string, databaseType: GraphQueryDatabase) { + return graphFunctionDefinitions(databaseType).find((definition) => definition.name.toLowerCase() === name.toLowerCase()); +} diff --git a/apps/desktop/src/lib/graph/graphQueryLanguage.ts b/apps/desktop/src/lib/graph/graphQueryLanguage.ts new file mode 100644 index 0000000000..5a7cf9328f --- /dev/null +++ b/apps/desktop/src/lib/graph/graphQueryLanguage.ts @@ -0,0 +1,111 @@ +import { StreamLanguage, StringStream, type StreamParser } from "@codemirror/language"; +import { cypher } from "@codemirror/legacy-modes/mode/cypher"; +import { graphFunctionDefinitions } from "./graphQueryFunctions"; +import { graphQueryDatabases, graphQueryProfile } from "./graphQueryProfiles"; +import type { GraphQueryDatabase } from "./graphQueryProfile"; +export type { GraphQueryDatabase } from "./graphQueryProfile"; +export { isGraphQueryDatabase } from "./graphQueryProfiles"; + +export function graphQueryKeywords(databaseType: GraphQueryDatabase): string[] { + return [...graphQueryProfile(databaseType).keywords]; +} + +export function graphQueryFunctions(databaseType: GraphQueryDatabase): string[] { + return graphFunctionDefinitions(databaseType).map((definition) => definition.name); +} + +interface GraphStreamState { + base: unknown; + quote: string | null; + commentDepth: number; +} + +function graphQueryParser(databaseType: GraphQueryDatabase): StreamParser { + const keywords = new Set(graphQueryKeywords(databaseType)); + const functions = new Set(graphQueryFunctions(databaseType).map((name) => name.toLowerCase())); + const readQuote = (stream: StringStream, state: GraphStreamState) => { + const quote = state.quote; + while (!stream.eol()) { + const char = stream.next(); + if (char === "\\" && quote !== "`") stream.next(); + else if (char === quote && !stream.eat(quote)) { + state.quote = null; + break; + } + } + return quote === "`" ? "variable" : "string"; + }; + const readComment = (stream: StringStream, state: GraphStreamState) => { + while (!stream.eol() && state.commentDepth > 0) { + if (stream.match("/*")) state.commentDepth++; + else if (stream.match("*/")) state.commentDepth--; + else stream.next(); + } + return "comment"; + }; + return { + name: databaseType === "neo4j" ? "cypher" : "ngql", + startState: (indentUnit) => ({ base: cypher.startState?.(indentUnit), quote: null, commentDepth: 0 }), + copyState: (state) => ({ ...state, base: cypher.copyState ? cypher.copyState(state.base) : { ...(state.base as object) } }), + // Reuse CodeMirror's Cypher mode, adding nGQL vocabulary and complete literal/comment spans. + token(stream, state) { + if (state.commentDepth) return readComment(stream, state); + if (state.quote) return readQuote(stream, state); + if (stream.eatSpace()) return null; + if (stream.match("//") || (databaseType === "nebula" && stream.eat("#"))) { + stream.skipToEnd(); + return "comment"; + } + if (stream.match("/*")) { + state.commentDepth = 1; + return readComment(stream, state); + } + if (stream.match(/^["'`]/)) { + state.quote = stream.current(); + return readQuote(stream, state); + } + if (stream.match(/^[\p{L}_$][\p{L}\p{N}_$]*/u)) { + const word = stream.current(); + if (keywords.has(word.toUpperCase())) return "keyword"; + return functions.has(word.toLowerCase()) ? "builtin" : "variable"; + } + if (stream.eat(":")) return "punctuation"; + return cypher.token(stream, state.base); + }, + languageData: { commentTokens: { line: graphQueryProfile(databaseType).lineComment, block: { open: "/*", close: "*/" } } }, + }; +} + +const graphParsers = new Map(graphQueryDatabases.map((type) => [type, graphQueryParser(type)])); +const graphLanguages = new Map(graphQueryDatabases.map((type) => [type, StreamLanguage.define(graphParsers.get(type)!)])); + +export function graphQueryLanguage(databaseType: GraphQueryDatabase) { + return graphLanguages.get(databaseType)!; +} + +export interface GraphQueryToken { + text: string; + from: number; + to: number; + kind: string | null; + open: boolean; +} + +export function graphQueryTokens(text: string, databaseType: GraphQueryDatabase): GraphQueryToken[] { + const parser = graphParsers.get(databaseType)!; + const state = parser.startState!(2); + const tokens: GraphQueryToken[] = []; + let offset = 0; + for (const line of text.split("\n")) { + const stream = new StringStream(line, 2, 2); + while (!stream.eol()) { + stream.start = stream.pos; + const quote = state.quote ?? (/["'`]/.test(stream.peek() ?? "") ? stream.peek() : null); + const kind = parser.token(stream, state); + const value = stream.current(); + if (value.trim()) tokens.push({ text: value, from: offset + stream.start, to: offset + stream.pos, kind: quote === "`" ? "identifier" : kind, open: !!state.quote || state.commentDepth > 0 }); + } + offset += line.length + 1; + } + return tokens; +} diff --git a/apps/desktop/src/lib/graph/graphQueryProfile.ts b/apps/desktop/src/lib/graph/graphQueryProfile.ts new file mode 100644 index 0000000000..cad799479b --- /dev/null +++ b/apps/desktop/src/lib/graph/graphQueryProfile.ts @@ -0,0 +1,79 @@ +import type { DatabaseType } from "@/types/database"; + +export type GraphQueryDatabase = Extract; + +export interface GraphFunctionDefinition { + name: string; + parameters: readonly string[]; + overloads?: readonly (readonly string[])[]; + template?: string; +} + +export interface GraphQueryProfile { + databaseType: GraphQueryDatabase; + language: string; + lineComment: string; + keywords: readonly string[]; + reservedWords: readonly string[]; + functions: readonly GraphFunctionDefinition[]; +} + +export function functionDefinitions(parameters: Record): GraphFunctionDefinition[] { + return Object.entries(parameters).map(([name, parameters]) => ({ name, parameters })); +} + +export const graphListPredicates: GraphFunctionDefinition[] = ["all", "any", "none", "single"].map((name) => ({ + name, + parameters: ["variable IN list WHERE predicate"], + template: "${variable} IN ${list} WHERE ${predicate}", +})); + +export const graphReduce: GraphFunctionDefinition = { + name: "reduce", + parameters: ["accumulator = initial", "variable IN list | expression"], + template: "${accumulator} = ${initial}, ${variable} IN ${list} | ${expression}", +}; + +export function temporalFunctions(names: string[]): GraphFunctionDefinition[] { + return names.map((name) => ({ name, parameters: [], overloads: [[], ["value"]] })); +} + +export const commonGraphFunctions = functionDefinitions({ + abs: ["number"], + avg: ["expression"], + ceil: ["number"], + coalesce: ["value", "...values"], + collect: ["expression"], + count: ["expression"], + floor: ["number"], + head: ["list"], + id: ["entity"], + keys: ["entity"], + labels: ["node"], + last: ["list"], + left: ["string", "length"], + max: ["expression"], + min: ["expression"], + nodes: ["path"], + range: ["start", "end", "step?"], + relationships: ["path"], + replace: ["string", "search", "replacement"], + reverse: ["value"], + right: ["string", "length"], + round: ["number", "precision?", "mode?"], + sign: ["number"], + size: ["value"], + split: ["string", "delimiter"], + sqrt: ["number"], + substring: ["string", "start", "length?"], + sum: ["expression"], + tail: ["list"], + trim: ["string"], + properties: ["entity"], + type: ["relationship"], + toBoolean: ["value"], + toFloat: ["value"], + toInteger: ["value"], + toString: ["value"], + duration: ["value"], +}); diff --git a/apps/desktop/src/lib/graph/graphQueryProfiles.ts b/apps/desktop/src/lib/graph/graphQueryProfiles.ts new file mode 100644 index 0000000000..8f655bc9cc --- /dev/null +++ b/apps/desktop/src/lib/graph/graphQueryProfiles.ts @@ -0,0 +1,18 @@ +import type { DatabaseType } from "@/types/database"; +import type { GraphQueryDatabase, GraphQueryProfile } from "./graphQueryProfile"; + +// Separate registrations let either dialect ship without enabling the other. +const modules = import.meta.glob("./profiles/*QueryProfile.ts", { eager: true, import: "default" }); +const profiles = new Map(Object.values(modules).map((profile) => [profile.databaseType, profile])); + +export const graphQueryDatabases = [...profiles.keys()]; + +export function isGraphQueryDatabase(databaseType?: DatabaseType): databaseType is GraphQueryDatabase { + return !!databaseType && profiles.has(databaseType as GraphQueryDatabase); +} + +export function graphQueryProfile(databaseType: GraphQueryDatabase): GraphQueryProfile { + const profile = profiles.get(databaseType); + if (!profile) throw new Error(`No graph query profile registered for ${databaseType}`); + return profile; +} diff --git a/apps/desktop/src/lib/graph/graphSignatureHelp.ts b/apps/desktop/src/lib/graph/graphSignatureHelp.ts new file mode 100644 index 0000000000..e96fff945a --- /dev/null +++ b/apps/desktop/src/lib/graph/graphSignatureHelp.ts @@ -0,0 +1,62 @@ +import type { SqlFunctionSignatureHelp } from "@/lib/sql/sqlCompletion"; +import { graphFunctionDefinition, type GraphFunctionDefinition } from "./graphQueryFunctions"; +import { graphQueryTokens, type GraphQueryDatabase } from "./graphQueryLanguage"; +import { graphIdentifier } from "./graphCompletionScope"; + +export function getGraphFunctionSignatureHelp(text: string, cursor: number, databaseType: GraphQueryDatabase, options?: { truncatedPrefix?: boolean }): SqlFunctionSignatureHelp | null { + const source = text.slice(0, Math.max(0, Math.min(cursor, text.length))); + const tokens = graphQueryTokens(source, databaseType); + const last = tokens[tokens.length - 1]; + if (last && ((last.kind === "string" && last.open) || (last.kind === "comment" && (last.open || (!last.text.startsWith("/*") && !/[\r\n]/.test(source.slice(last.to))))))) return null; + + const stack: Array<{ delimiter: string; definition?: GraphFunctionDefinition; argument: number; name?: string; openTo: number }> = []; + const significant = tokens.filter((token) => token.kind !== "comment"); + for (let index = 0; index < significant.length; index++) { + const token = significant[index]!; + if (token.kind === "string" || token.kind === "identifier") continue; + if (token.text === ";") stack.length = 0; + else if (["(", "[", "{"].includes(token.text)) { + const parts: string[] = []; + let nameFrom = token.from; + let start = index - 1; + while (start >= 0) { + const name = graphIdentifier(significant[start]); + if (!name) break; + nameFrom = significant[start]!.from; + parts.unshift(name); + if (significant[start - 1]?.text !== ".") break; + start -= 2; + } + const name = parts.join("."); + const callTarget = significant[start - 1]?.text.toUpperCase() === "CALL"; + const definition = token.text === "(" && !callTarget && significant[start - 1]?.text !== ":" && (!options?.truncatedPrefix || nameFrom > 0) ? graphFunctionDefinition(name, databaseType) : undefined; + stack.push({ delimiter: token.text, definition, name, argument: 0, openTo: token.to }); + } else if ([")", "]", "}"].includes(token.text)) { + const expected = { ")": "(", "]": "[", "}": "{" }[token.text]; + if (stack[stack.length - 1]?.delimiter !== expected) stack.length = 0; + else stack.pop(); + } else if (token.text === "," && stack.length) stack[stack.length - 1]!.argument++; + } + const frame = [...stack].reverse().find((frame) => frame.definition); + if (!frame?.definition || !frame.name) return null; + const definition = frame.definition; + const groups = definition.overloads ?? [definition.parameters]; + const hasArgument = (significant[significant.length - 1]?.to ?? 0) > frame.openTo; + const matchingOverload = hasArgument ? groups.findIndex((group) => group.length > frame.argument) : 0; + const activeOverload = Math.max(0, matchingOverload); + const parameters = [...groups[activeOverload]!]; + const activeParameter = parameters.length ? Math.min(frame.argument, parameters.length - 1) : -1; + return { + name: frame.name, + parameters, + activeParameter, + signature: `${frame.name}(${parameters.join(", ")})`, + activeOverload, + overloads: groups.map((group) => ({ + signature: `${frame.name}(${group.join(", ")})`, + parameterGroups: [[...group]], + activeGroup: 0, + activeParameter: group.length ? Math.min(frame.argument, group.length - 1) : -1, + })), + }; +} diff --git a/apps/desktop/src/lib/graph/profiles/nebulaQueryProfile.spec.ts b/apps/desktop/src/lib/graph/profiles/nebulaQueryProfile.spec.ts new file mode 100644 index 0000000000..90ae026a87 --- /dev/null +++ b/apps/desktop/src/lib/graph/profiles/nebulaQueryProfile.spec.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { buildGraphCompletionItems, getGraphCompletionContext } from "../graphCompletion"; +import { getGraphFunctionSignatureHelp } from "../graphSignatureHelp"; +import profile from "./nebulaQueryProfile"; + +const tables = [ + { name: "player", type: "table" as const }, + { name: "follow", type: "view" as const }, + { name: "serve", type: "view" as const }, + { name: "TAG", type: "table" as const }, +]; +function items(source: string) { + return buildGraphCompletionItems(getGraphCompletionContext(source, source.length, "nebula"), "nebula", { tables, databases: ["basketball"] }); +} + +describe("NebulaGraph nGQL completion profile", () => { + it("covers native function families, including reduce, without Cypher-only functions", () => { + const names = profile.functions.map((fn) => fn.name); + for (const name of ["concat", "concat_ws", "substr", "std", "reduce", "all", "any", "isEmpty", "src", "dst", "rank", "now", "st_distance"]) expect(names).toContain(name); + for (const name of ["vertex", "edge", "elementId", "point.distance", "uuid"]) expect(names).not.toContain(name); + for (const word of ["ALTER", "DROP", "REBUILD", "INDEX", "GROUP", "KILL", "SUBMIT", "JOB"]) expect(profile.keywords).toContain(word); + }); + + it("keeps tags, edges, comma-separated traversals and spaces separate", () => { + expect(items("MATCH (n:").map((item) => item.label)).toEqual(["player", "TAG"]); + expect(items('GO FROM "1" OVER ').map((item) => item.label)).toEqual(["follow", "serve"]); + expect(items('GO FROM "1" OVER follow, se').map((item) => item.label)).toEqual(["serve"]); + expect(items("USE ba").map((item) => item.label)).toEqual(["basketball"]); + expect(items("MATCH (n:TA")[0]?.apply).toBe("`TAG`"); + }); + + it("resolves tag-qualified fields and pipe outputs", () => { + const source = "MATCH (n:player)-[r:follow]->() RETURN n.player.na"; + expect(getGraphCompletionContext(source, source.length, "nebula")).toMatchObject({ mode: "property", objects: ["player"] }); + const edge = "MATCH ()-[r:follow]->() RETURN r."; + expect(getGraphCompletionContext(edge, edge.length, "nebula")).toMatchObject({ mode: "property", objects: ["follow"] }); + expect(items('GO FROM "1" OVER follow YIELD dst(edge) AS next | YIELD $-.ne').map((item) => item.label)).toEqual(["next"]); + }); + + it("offers native multi-parameter and reduce templates", () => { + expect(items("RETURN concat_w")[0]?.apply).toBe("concat_ws(${separator}, ${string1}, ${string2})"); + expect(items("RETURN red")[0]?.apply).toBe("reduce(${accumulator} = ${initial}, ${variable} IN ${list} | ${expression})"); + const source = "RETURN reduce(acc = 0, "; + expect(getGraphFunctionSignatureHelp(source, source.length, "nebula")).toMatchObject({ name: "reduce", activeParameter: 1 }); + }); +}); diff --git a/apps/desktop/src/lib/graph/profiles/nebulaQueryProfile.ts b/apps/desktop/src/lib/graph/profiles/nebulaQueryProfile.ts new file mode 100644 index 0000000000..02f7b0d9ca --- /dev/null +++ b/apps/desktop/src/lib/graph/profiles/nebulaQueryProfile.ts @@ -0,0 +1,97 @@ +import { commonGraphFunctions, functionDefinitions, graphListPredicates, graphReduce, temporalFunctions, type GraphQueryProfile } from "../graphQueryProfile"; + +// NebulaGraph 3.8: https://docs.nebula-graph.io/3.8.0/2.quick-start/6.cheatsheet-for-ngql/ +const reservedWords = + "ACROSS ADD ALTER AND AS ASC ASCENDING BALANCE BOOL BY CASE CHANGE COMPACT CREATE DATE DATETIME DELETE DESC DESCENDING DESCRIBE DISTINCT DOUBLE DOWNLOAD DROP DURATION EDGE EDGES EXISTS EXPLAIN FALSE FETCH FIND FIXED_STRING FLOAT FLUSH FROM GEOGRAPHY GET GO GRANT IF IGNORE_EXISTED_INDEX IN INDEX INDEXES INGEST INSERT INT INT16 INT32 INT64 INT8 INTERSECT IS JOIN LEFT LIST LOOKUP MAP MATCH MINUS NO NOT NULL OF ON OR ORDER OVER OVERWRITE PATH PROP REBUILD RECOVER REMOVE RESTART RETURN REVERSELY REVOKE SET SHOW STEP STEPS STOP STRING SUBMIT TAG TAGS TIME TIMESTAMP TO TRUE UNION UNWIND UPDATE UPSERT UPTO USE VERTEX VERTICES WHEN WHERE WITH XOR YIELD".split( + " ", + ); +const keywords = [ + ...new Set([ + ...reservedWords, + ..."ALL ANY ATOMIC_EDGE BIDIRECT BOTH CLEAR COLLATION CONFIGS DATA DEFAULT ELSE END FULLTEXT GROUP HOST HOSTS JOB JOBS KILL LIMIT NOLOOP OPTIONAL OUT PART PARTS PROFILE QUERIES QUERY SAMPLE SESSION SESSIONS SHORTEST SKIP SPACE SPACES STATS STATUS SUBGRAPH THEN TTL_COL TTL_DURATION USER USERS VALUES ZONE ZONES".split( + " ", + ), + ]), +]; + +const profile: GraphQueryProfile = { + databaseType: "nebula", + language: "nGQL", + lineComment: "#", + keywords, + reservedWords, + functions: [ + ...commonGraphFunctions, + ...functionDefinitions({ + length: ["string_or_path"], + timestamp: ["value?"], + src: ["edge"], + dst: ["edge"], + rank: ["edge"], + typeid: ["edge"], + tags: ["vertex"], + hash: ["value"], + lower: ["string"], + upper: ["string"], + toLower: ["string"], + toUpper: ["string"], + now: [], + startNode: ["edge_or_path"], + endNode: ["edge_or_path"], + cbrt: ["number"], + hypot: ["x", "y"], + pow: ["x", "y"], + exp: ["number"], + exp2: ["number"], + log: ["number"], + log2: ["number"], + log10: ["number"], + sin: ["number"], + asin: ["number"], + cos: ["number"], + acos: ["number"], + tan: ["number"], + atan: ["number"], + e: [], + pi: [], + radians: ["degrees"], + rand: [], + rand32: ["min?", "max?"], + rand64: ["min?", "max?"], + bit_and: ["a", "b"], + bit_or: ["a", "b"], + bit_xor: ["a", "b"], + std: ["expression"], + strcasecmp: ["string_a", "string_b"], + ltrim: ["string"], + rtrim: ["string"], + lpad: ["string", "length", "padding"], + rpad: ["string", "length", "padding"], + substr: ["string", "start", "length?"], + concat: ["string1", "string2", "...strings"], + concat_ws: ["separator", "string1", "string2", "...strings"], + extract: ["string", "pattern"], + json_extract: ["string"], + exists: ["property"], + isEmpty: ["value"], + toSet: ["list"], + st_point: ["longitude", "latitude"], + st_geogfromtext: ["wkt"], + st_astext: ["geography"], + st_centroid: ["geography"], + st_isvalid: ["geography"], + st_distance: ["a", "b"], + st_dwithin: ["a", "b", "distance", "exclusive?"], + st_intersects: ["a", "b"], + st_covers: ["a", "b"], + st_coveredby: ["a", "b"], + s2_cellidfrompoint: ["point", "level?"], + s2_coveringcellids: ["geography", "minLevel?", "maxLevel?", "maxCells?", "buffer?"], + }), + ...temporalFunctions(["date", "datetime", "time"]), + ...graphListPredicates, + graphReduce, + ], +}; + +export default profile; diff --git a/docs/screenshots/nebula-query-completion.png b/docs/screenshots/nebula-query-completion.png new file mode 100644 index 0000000000000000000000000000000000000000..581e87813e376f243d8fb7d84c272c069a3de060 GIT binary patch literal 21454 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 D@K;Da literal 0 HcmV?d00001