From 3b3795476f4ddc713a867f1edcc9ca7ec158ce4d Mon Sep 17 00:00:00 2001 From: Salih Date: Sat, 12 Sep 2026 16:43:15 +0300 Subject: [PATCH] Add ID filter for neighbor expansion --- .../fetchNeighbors/oneHopTemplate.test.ts | 7 +++++++ .../gremlin/fetchNeighbors/oneHopTemplate.ts | 6 +++++- .../fetchNeighbors/oneHopTemplate.test.ts | 8 +++++++- .../fetchNeighbors/oneHopTemplate.ts | 10 +++++++--- .../oneHopNeighborsTemplate.test.ts | 18 +++++++++++++++++- .../fetchNeighbors/oneHopNeighborsTemplate.ts | 11 ++++++++--- .../storedBlankNodeNeighborsRequest.test.ts | 18 ++++++++++++++++++ .../storedBlankNodeNeighborsRequest.ts | 9 +++++++++ .../src/connector/useGEFetchTypes.ts | 3 ++- .../modules/NodeExpand/NodeExpandFilters.tsx | 12 ++++++++---- 10 files changed, 88 insertions(+), 14 deletions(-) diff --git a/packages/graph-explorer/src/connector/gremlin/fetchNeighbors/oneHopTemplate.test.ts b/packages/graph-explorer/src/connector/gremlin/fetchNeighbors/oneHopTemplate.test.ts index cf663fd99..ff782bbc5 100644 --- a/packages/graph-explorer/src/connector/gremlin/fetchNeighbors/oneHopTemplate.test.ts +++ b/packages/graph-explorer/src/connector/gremlin/fetchNeighbors/oneHopTemplate.test.ts @@ -1,6 +1,7 @@ import type { AttributeFilter } from "@/connector/useGEFetchTypes"; import { createVertexId } from "@/core"; +import { SEARCH_TOKENS } from "@/utils"; import { normalizeWithNoSpace as normalize } from "@/utils/testing"; import { UnrepresentableNumberError } from "../../queryValueError"; @@ -242,6 +243,12 @@ describe("Gremlin > oneHopTemplate", () => { ); }); + it("matches a neighbor ID exactly", () => { + expect( + fragmentFor([{ name: SEARCH_TOKENS.NODE_ID, value: "airport'1" }]), + ).toContain(normalize("and(hasId('airport\\'1'))")); + }); + it("requires every filter to match", () => { expect( fragmentFor([ diff --git a/packages/graph-explorer/src/connector/gremlin/fetchNeighbors/oneHopTemplate.ts b/packages/graph-explorer/src/connector/gremlin/fetchNeighbors/oneHopTemplate.ts index 922f0a005..8d7ccd9c5 100644 --- a/packages/graph-explorer/src/connector/gremlin/fetchNeighbors/oneHopTemplate.ts +++ b/packages/graph-explorer/src/connector/gremlin/fetchNeighbors/oneHopTemplate.ts @@ -3,11 +3,15 @@ import type { NeighborsRequest, } from "@/connector/useGEFetchTypes"; -import { query } from "@/utils"; +import { createVertexId } from "@/core"; +import { query, SEARCH_TOKENS } from "@/utils"; import { fragment } from "../fragments"; function attributeFilterTemplate({ name, value }: AttributeFilter): string { + if (name === SEARCH_TOKENS.NODE_ID) { + return `hasId(${fragment.id(createVertexId(value))})`; + } return `has(${fragment.identifier(name)},containing(${fragment.string(value)}))`; } diff --git a/packages/graph-explorer/src/connector/openCypher/fetchNeighbors/oneHopTemplate.test.ts b/packages/graph-explorer/src/connector/openCypher/fetchNeighbors/oneHopTemplate.test.ts index 78d5cf77f..762652dc0 100644 --- a/packages/graph-explorer/src/connector/openCypher/fetchNeighbors/oneHopTemplate.test.ts +++ b/packages/graph-explorer/src/connector/openCypher/fetchNeighbors/oneHopTemplate.test.ts @@ -1,7 +1,7 @@ import type { AttributeFilter } from "@/connector/useGEFetchTypes"; import { createVertexId } from "@/core"; -import { query } from "@/utils"; +import { query, SEARCH_TOKENS } from "@/utils"; import oneHopTemplate from "./oneHopTemplate"; @@ -172,6 +172,12 @@ describe("OpenCypher > oneHopTemplate", () => { ); }); + it("matches a neighbor ID exactly", () => { + expect( + templateFor([{ name: SEARCH_TOKENS.NODE_ID, value: 'airport"1' }]), + ).toContain('ID(tgt) = "airport\\"1"'); + }); + it("requires every filter to match", () => { expect( templateFor([ diff --git a/packages/graph-explorer/src/connector/openCypher/fetchNeighbors/oneHopTemplate.ts b/packages/graph-explorer/src/connector/openCypher/fetchNeighbors/oneHopTemplate.ts index b9a46e718..97de708f0 100644 --- a/packages/graph-explorer/src/connector/openCypher/fetchNeighbors/oneHopTemplate.ts +++ b/packages/graph-explorer/src/connector/openCypher/fetchNeighbors/oneHopTemplate.ts @@ -3,12 +3,16 @@ import type { NeighborsRequest, } from "@/connector/useGEFetchTypes"; -import { query } from "@/utils"; +import { createVertexId } from "@/core"; +import { query, SEARCH_TOKENS } from "@/utils"; import { fragment } from "../fragments"; -const attributeFilterTemplate = ({ name, value }: AttributeFilter): string => - `tgt.${fragment.identifier(name)} CONTAINS ${fragment.string(value)}`; +function attributeFilterTemplate({ name, value }: AttributeFilter): string { + return name === SEARCH_TOKENS.NODE_ID + ? `ID(tgt) = ${fragment.id(createVertexId(value))}` + : `tgt.${fragment.identifier(name)} CONTAINS ${fragment.string(value)}`; +} /** * @example diff --git a/packages/graph-explorer/src/connector/sparql/fetchNeighbors/oneHopNeighborsTemplate.test.ts b/packages/graph-explorer/src/connector/sparql/fetchNeighbors/oneHopNeighborsTemplate.test.ts index 379848008..f87798f52 100644 --- a/packages/graph-explorer/src/connector/sparql/fetchNeighbors/oneHopNeighborsTemplate.test.ts +++ b/packages/graph-explorer/src/connector/sparql/fetchNeighbors/oneHopNeighborsTemplate.test.ts @@ -1,5 +1,5 @@ import { createVertexId } from "@/core"; -import { LABELS, query } from "@/utils"; +import { LABELS, query, SEARCH_TOKENS } from "@/utils"; import { normalize as normalizeCollapsed, normalizeWithNewlines as normalize, @@ -340,6 +340,22 @@ describe("oneHopNeighborsTemplate", () => { ); }); + it("should match a neighbor ID exactly", () => { + const template = oneHopNeighborsTemplate({ + resourceURI: createVertexId("http://www.example.com/soccer/resource#EPL"), + attributeFilters: [ + { + name: SEARCH_TOKENS.NODE_ID, + value: "http://www.example.com/soccer/resource#Arsenal", + }, + ], + }); + + expect(template).toContain( + "FILTER(?neighbor = )", + ); + }); + it("should produce one existential FILTER EXISTS per filter on the same attribute", () => { const template = oneHopNeighborsTemplate({ resourceURI: createVertexId("http://www.example.com/soccer/resource#EPL"), diff --git a/packages/graph-explorer/src/connector/sparql/fetchNeighbors/oneHopNeighborsTemplate.ts b/packages/graph-explorer/src/connector/sparql/fetchNeighbors/oneHopNeighborsTemplate.ts index cf5c41108..e098dc6a0 100644 --- a/packages/graph-explorer/src/connector/sparql/fetchNeighbors/oneHopNeighborsTemplate.ts +++ b/packages/graph-explorer/src/connector/sparql/fetchNeighbors/oneHopNeighborsTemplate.ts @@ -1,6 +1,6 @@ import type { AttributeFilter } from "@/connector/useGEFetchTypes"; -import { query } from "@/utils"; +import { query, SEARCH_TOKENS } from "@/utils"; import { getLimit, @@ -213,13 +213,18 @@ export function findNeighborsUsingFilters({ * makes `&&` across filters unsatisfiable and dominates query cost. */ function getFilterTemplate(attributeFilters: AttributeFilter[]) { - const createFilterTemplate = (filter: AttributeFilter) => - query` + function createFilterTemplate(filter: AttributeFilter) { + if (filter.name === SEARCH_TOKENS.NODE_ID) { + return `FILTER(?neighbor = ${fragment.iri(filter.value)})`; + } + + return query` FILTER EXISTS { ?neighbor ${fragment.iri(filter.name)} ?filterValue . FILTER(isLiteral(?filterValue) && CONTAINS(LCASE(STR(?filterValue)), LCASE(${fragment.string(filter.value)}))) } `; + } return attributeFilters.map(createFilterTemplate).join("\n"); } diff --git a/packages/graph-explorer/src/connector/sparql/fetchNeighbors/storedBlankNodeNeighborsRequest.test.ts b/packages/graph-explorer/src/connector/sparql/fetchNeighbors/storedBlankNodeNeighborsRequest.test.ts index 617d47565..eeb43690f 100644 --- a/packages/graph-explorer/src/connector/sparql/fetchNeighbors/storedBlankNodeNeighborsRequest.test.ts +++ b/packages/graph-explorer/src/connector/sparql/fetchNeighbors/storedBlankNodeNeighborsRequest.test.ts @@ -1,4 +1,5 @@ import { createVertexId, type Vertex } from "@/core"; +import { SEARCH_TOKENS } from "@/utils"; import { createTestableVertex } from "@/utils/testing"; import type { BlankNodeItem, BlankNodesMap } from "../types"; @@ -63,6 +64,23 @@ describe("SPARQL > storedBlankNodeNeighborsRequest", () => { expect(response.vertices).toStrictEqual([seattle]); }); + it("should match a stored neighbor ID exactly", async () => { + const seattle = airportWith({ city: "Seattle" }); + const portland = airportWith({ city: "Portland" }); + + const response = await storedBlankNodeNeighborsRequest( + blankNodesWithNeighbors([seattle, portland]), + { + resourceURI: blankNodeId, + attributeFilters: [ + { name: SEARCH_TOKENS.NODE_ID, value: String(seattle.id) }, + ], + }, + ); + + expect(response.vertices).toStrictEqual([seattle]); + }); + it("should match the attribute regardless of case", async () => { const seattle = airportWith({ city: "Seattle" }); diff --git a/packages/graph-explorer/src/connector/sparql/fetchNeighbors/storedBlankNodeNeighborsRequest.ts b/packages/graph-explorer/src/connector/sparql/fetchNeighbors/storedBlankNodeNeighborsRequest.ts index dde20f07d..5bcc0dd5e 100644 --- a/packages/graph-explorer/src/connector/sparql/fetchNeighbors/storedBlankNodeNeighborsRequest.ts +++ b/packages/graph-explorer/src/connector/sparql/fetchNeighbors/storedBlankNodeNeighborsRequest.ts @@ -1,5 +1,7 @@ import type { NeighborsResponse } from "@/connector/useGEFetchTypes"; +import { SEARCH_TOKENS } from "@/utils"; + import type { BlankNodesMap, SPARQLNeighborsRequest } from "../types"; /** @@ -26,6 +28,13 @@ export const storedBlankNodeNeighborsRequest = ( } for (const filter of req.attributeFilters ?? []) { + if (filter.name === SEARCH_TOKENS.NODE_ID) { + if (String(vertex.id) !== filter.value) { + return false; + } + continue; + } + const attrVal = vertex.attributes[filter.name]; if (attrVal == null) { return false; diff --git a/packages/graph-explorer/src/connector/useGEFetchTypes.ts b/packages/graph-explorer/src/connector/useGEFetchTypes.ts index 659c1d48f..8c32992c2 100644 --- a/packages/graph-explorer/src/connector/useGEFetchTypes.ts +++ b/packages/graph-explorer/src/connector/useGEFetchTypes.ts @@ -64,7 +64,8 @@ export type SchemaResponse = { /** * Narrows neighbors to those whose attribute contains the given value. * - * Only string attributes are filterable, so the value needs no type of its own. + * The node ID search token matches the ID exactly. Only string attributes are + * otherwise filterable, so the value needs no type of its own. */ export type AttributeFilter = { /** Attribute name. */ diff --git a/packages/graph-explorer/src/modules/NodeExpand/NodeExpandFilters.tsx b/packages/graph-explorer/src/modules/NodeExpand/NodeExpandFilters.tsx index 0e240b22d..c2e822307 100644 --- a/packages/graph-explorer/src/modules/NodeExpand/NodeExpandFilters.tsx +++ b/packages/graph-explorer/src/modules/NodeExpand/NodeExpandFilters.tsx @@ -23,6 +23,7 @@ import { } from "@/components"; import { useSearchableAttributes } from "@/core"; import useTranslations from "@/hooks/useTranslations"; +import { SEARCH_TOKENS } from "@/utils"; let nextFilterId = 1; export type NodeExpandFilter = { @@ -45,10 +46,13 @@ export type NodeExpandFiltersProps = { function useAttributeOptions(selectedType: string) { const allSearchableAttributes = useSearchableAttributes(selectedType); - return allSearchableAttributes.map(a => ({ - label: a.displayLabel, - value: a.name, - })); + return [ + { label: "ID", value: SEARCH_TOKENS.NODE_ID }, + ...allSearchableAttributes.map(a => ({ + label: a.displayLabel, + value: a.name, + })), + ]; } const NodeExpandFilters = ({