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 c24bf9c1a..4f052e341 100644 --- a/packages/graph-explorer/src/connector/gremlin/fetchNeighbors/oneHopTemplate.test.ts +++ b/packages/graph-explorer/src/connector/gremlin/fetchNeighbors/oneHopTemplate.test.ts @@ -192,4 +192,49 @@ describe("Gremlin > oneHopTemplate", () => { `), ); }); + + it("should filter neighbors by a single id", () => { + const template = oneHopTemplate({ + vertexId: createVertexId("12"), + filterByIds: [createVertexId("42")], + }); + + expect(normalize(template)).toBe( + normalize(` + g.V("12").as("start") + .both().hasId("42") + .dedup().as("neighbor") + .project("vertex", "edges") + .by() + .by( + __.select("start").bothE() + .where(otherV().where(eq("neighbor"))) + .dedup().fold() + ) + `), + ); + }); + + it("should filter neighbors by multiple ids combined with a type", () => { + const template = oneHopTemplate({ + vertexId: createVertexId("12"), + filterByVertexTypes: ["airport"], + filterByIds: [createVertexId("42"), createVertexId(7)], + }); + + expect(normalize(template)).toBe( + normalize(` + g.V("12").as("start") + .both().hasLabel("airport").hasId("42", 7L) + .dedup().as("neighbor") + .project("vertex", "edges") + .by() + .by( + __.select("start").bothE() + .where(otherV().where(eq("neighbor"))) + .dedup().fold() + ) + `), + ); + }); }); diff --git a/packages/graph-explorer/src/connector/gremlin/fetchNeighbors/oneHopTemplate.ts b/packages/graph-explorer/src/connector/gremlin/fetchNeighbors/oneHopTemplate.ts index 78a7f8cbf..05a6cd119 100644 --- a/packages/graph-explorer/src/connector/gremlin/fetchNeighbors/oneHopTemplate.ts +++ b/packages/graph-explorer/src/connector/gremlin/fetchNeighbors/oneHopTemplate.ts @@ -121,6 +121,7 @@ export default function oneHopTemplate({ vertexId, excludedVertices = new Set(), filterByVertexTypes = [], + filterByIds = [], filterCriteria = [], limit = 0, }: Omit): string { @@ -133,14 +134,21 @@ export default function oneHopTemplate({ ? `hasLabel(${vertexTypes.map(type => `"${type}"`).join(", ")})` : ``; + const idsTemplate = + filterByIds.length > 0 + ? `hasId(${filterByIds.map(idParam).join(", ")})` + : ``; + const filterCriteriaTemplate = filterCriteria.length > 0 ? `and(${filterCriteria.map(criterionTemplate).join(", ")})` : ``; - const nodeFilters = [vertexTypesTemplate, filterCriteriaTemplate].filter( - Boolean, - ); + const nodeFilters = [ + vertexTypesTemplate, + idsTemplate, + filterCriteriaTemplate, + ].filter(Boolean); const nodeFiltersTemplate = nodeFilters.length > 0 ? `.${nodeFilters.join(".")}` : ``; 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 37c25e573..e2472ea84 100644 --- a/packages/graph-explorer/src/connector/openCypher/fetchNeighbors/oneHopTemplate.test.ts +++ b/packages/graph-explorer/src/connector/openCypher/fetchNeighbors/oneHopTemplate.test.ts @@ -144,4 +144,39 @@ describe("OpenCypher > oneHopTemplate", () => { `, ); }); + + it("should filter neighbors by a single id", () => { + const template = oneHopTemplate({ + vertexId: createVertexId("12"), + filterByIds: [createVertexId("42")], + }); + + expect(template).toEqual( + query` + MATCH (v)-[e]-(tgt) + WHERE ID(v) = "12" AND ID(tgt) IN ["42"] + RETURN + collect(DISTINCT tgt) AS vObjects, + collect(e) AS eObjects + `, + ); + }); + + it("should filter neighbors by multiple ids combined with a type", () => { + const template = oneHopTemplate({ + vertexId: createVertexId("12"), + filterByVertexTypes: ["country"], + filterByIds: [createVertexId("42"), createVertexId("7")], + }); + + expect(template).toEqual( + query` + MATCH (v)-[e]-(tgt:country) + WHERE ID(v) = "12" AND ID(tgt) IN ["42", "7"] + RETURN + collect(DISTINCT tgt) AS vObjects, + collect(e) AS eObjects + `, + ); + }); }); diff --git a/packages/graph-explorer/src/connector/openCypher/fetchNeighbors/oneHopTemplate.ts b/packages/graph-explorer/src/connector/openCypher/fetchNeighbors/oneHopTemplate.ts index 03f1b1376..81b5adc05 100644 --- a/packages/graph-explorer/src/connector/openCypher/fetchNeighbors/oneHopTemplate.ts +++ b/packages/graph-explorer/src/connector/openCypher/fetchNeighbors/oneHopTemplate.ts @@ -110,6 +110,7 @@ const criterionTemplate = (criterion: Criterion): string => { const oneHopTemplate = ({ vertexId, filterByVertexTypes = [], + filterByIds = [], filterCriteria = [], excludedVertices = new Set(), limit = 0, @@ -119,6 +120,11 @@ const oneHopTemplate = ({ ? `NOT ID(tgt) IN [${excludedVertices.values().map(idParam).toArray().join(", ")}]` : ""; + const formattedIds = + filterByIds.length > 0 + ? `ID(tgt) IN [${filterByIds.map(idParam).join(", ")}]` + : ""; + // List of possible vertex labels when there are multiple (single label is handled elsewhere) const formattedVertexTypes = filterByVertexTypes.length > 1 @@ -137,6 +143,7 @@ const oneHopTemplate = ({ `ID(v) = ${idParam(vertexId)}`, formattedExcludedVertices, formattedVertexTypes, + formattedIds, ...(filterCriteria?.map(criterionTemplate) ?? []), ] .filter(Boolean) 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 3a99cf022..aa3e67c48 100644 --- a/packages/graph-explorer/src/connector/sparql/fetchNeighbors/oneHopNeighborsTemplate.test.ts +++ b/packages/graph-explorer/src/connector/sparql/fetchNeighbors/oneHopNeighborsTemplate.test.ts @@ -247,6 +247,43 @@ describe("oneHopNeighborsTemplate", () => { `), ); }); + + it("should produce query filtering neighbors by id", () => { + const template = oneHopNeighborsTemplate({ + resourceURI: createVertexId("http://www.example.com/soccer/resource#EPL"), + filterByIds: [ + createVertexId("http://www.example.com/soccer/resource#Arsenal"), + createVertexId("http://www.example.com/soccer/resource#Chelsea"), + ], + }); + + expect(normalize(template)).toEqual( + normalize(query` + SELECT DISTINCT ?subject ?predicate ?object + WHERE { + { + SELECT DISTINCT ?neighbor + WHERE { + BIND( AS ?resource) + { + ?neighbor ?predicate ?resource . + OPTIONAL { ?neighbor a ?class } . + FILTER(!isLiteral(?neighbor) && ?predicate != ) + } + UNION + { + ?resource ?predicate ?neighbor . + OPTIONAL { ?neighbor a ?class } . + FILTER(!isLiteral(?neighbor) && ?predicate != ) + } + FILTER(?neighbor IN (, )) + } + } + ${commonPartOfQuery("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 8e205aeca..665744e19 100644 --- a/packages/graph-explorer/src/connector/sparql/fetchNeighbors/oneHopNeighborsTemplate.ts +++ b/packages/graph-explorer/src/connector/sparql/fetchNeighbors/oneHopNeighborsTemplate.ts @@ -1,3 +1,5 @@ +import type { VertexId } from "@/core"; + import { query } from "@/utils"; import { @@ -172,6 +174,7 @@ export function oneHopNeighborsTemplate( export function findNeighborsUsingFilters({ resourceURI, subjectClasses = [], + filterByIds = [], filterCriteria = [], excludedVertices = new Set(), limit = 0, @@ -185,7 +188,7 @@ export function findNeighborsUsingFilters({ BIND(${resourceTemplate} AS ?resource) { # Incoming neighbors - ?neighbor ?predicate ?resource . + ?neighbor ?predicate ?resource . OPTIONAL { ?neighbor a ?class } . ${getNeighborsFilter(excludedVertices)} ${getSubjectClasses(subjectClasses)} @@ -193,17 +196,30 @@ export function findNeighborsUsingFilters({ UNION { # Outgoing neighbors - ?resource ?predicate ?neighbor . + ?resource ?predicate ?neighbor . OPTIONAL { ?neighbor a ?class } . ${getNeighborsFilter(excludedVertices)} ${getSubjectClasses(subjectClasses)} } + ${getIdsFilter(filterByIds)} ${getFilterTemplate(filterCriteria)} } ${getLimit(limit)} `; } +/** + * Creates a filter template that narrows neighbors to the given resource URIs. + */ +function getIdsFilter(filterByIds: VertexId[]) { + if (filterByIds.length === 0) { + return ""; + } + + const idList = filterByIds.map(id => idParam(id)).join(", "); + return query`FILTER(?neighbor IN (${idList}))`; +} + /** * Creates a filter template for the given filter criteria. * diff --git a/packages/graph-explorer/src/connector/sparql/sparqlExplorer.ts b/packages/graph-explorer/src/connector/sparql/sparqlExplorer.ts index 94b9a64dc..75f9bac65 100644 --- a/packages/graph-explorer/src/connector/sparql/sparqlExplorer.ts +++ b/packages/graph-explorer/src/connector/sparql/sparqlExplorer.ts @@ -118,6 +118,7 @@ export function createSparqlExplorer( const request: SPARQLNeighborsRequest = { resourceURI: req.vertexId, subjectClasses: req.filterByVertexTypes, + filterByIds: req.filterByIds, filterCriteria: req.filterCriteria?.map((c: Criterion) => ({ predicate: c.name, object: c.value, diff --git a/packages/graph-explorer/src/connector/sparql/types.ts b/packages/graph-explorer/src/connector/sparql/types.ts index a3062709d..769428daf 100644 --- a/packages/graph-explorer/src/connector/sparql/types.ts +++ b/packages/graph-explorer/src/connector/sparql/types.ts @@ -28,6 +28,10 @@ export type SPARQLNeighborsRequest = { * Filter by subject classes */ subjectClasses?: Array; + /** + * Filter to neighbors matching one of these resource URIs. + */ + filterByIds?: Array; /** * Filter by predicates and objects matching values. */ diff --git a/packages/graph-explorer/src/connector/useGEFetchTypes.ts b/packages/graph-explorer/src/connector/useGEFetchTypes.ts index 7e2874ffe..d3c1a9f53 100644 --- a/packages/graph-explorer/src/connector/useGEFetchTypes.ts +++ b/packages/graph-explorer/src/connector/useGEFetchTypes.ts @@ -102,6 +102,10 @@ export type NeighborsRequest = { * Filter by vertex types. */ filterByVertexTypes?: Array; + /** + * Filter to neighbors matching one of these vertex IDs. + */ + filterByIds?: Array; /** * Filter by vertex attributes. */ diff --git a/packages/graph-explorer/src/modules/NodeExpand/NodeExpandContent.tsx b/packages/graph-explorer/src/modules/NodeExpand/NodeExpandContent.tsx index 22ada5ad6..624fbdc41 100644 --- a/packages/graph-explorer/src/modules/NodeExpand/NodeExpandContent.tsx +++ b/packages/graph-explorer/src/modules/NodeExpand/NodeExpandContent.tsx @@ -1,8 +1,6 @@ import { useSuspenseQuery } from "@tanstack/react-query"; import { Suspense, useState } from "react"; -import type { VertexId } from "@/core"; - import { Button, DefaultQueryErrorBoundary, @@ -14,7 +12,12 @@ import { VertexRow, } from "@/components"; import { neighborsCountQuery } from "@/connector"; -import { type DisplayVertex, useNeighbors } from "@/core"; +import { + createVertexId, + type DisplayVertex, + useNeighbors, + type VertexId, +} from "@/core"; import { useExpandNode } from "@/hooks"; import { type ExpandNodeFilters, @@ -26,7 +29,10 @@ import useNeighborsOptions, { import useTranslations from "@/hooks/useTranslations"; import NeighborsList from "@/modules/common/NeighborsList/NeighborsList"; -import NodeExpandFilters, { type NodeExpandFilter } from "./NodeExpandFilters"; +import NodeExpandFilters, { + ID_FILTER_NAME, + type NodeExpandFilter, +} from "./NodeExpandFilters"; export type NodeExpandContentProps = { vertex: DisplayVertex; @@ -133,11 +139,16 @@ function ExpansionOptions({ vertexId={vertexId} filters={{ filterByVertexTypes: [selectedType], - filterCriteria: filters.map(filter => ({ - name: filter.name, - operator: "LIKE", - value: filter.value, - })), + filterByIds: filters + .filter(filter => filter.name === ID_FILTER_NAME && filter.value) + .map(filter => createVertexId(filter.value)), + filterCriteria: filters + .filter(filter => filter.name !== ID_FILTER_NAME) + .map(filter => ({ + name: filter.name, + operator: "LIKE", + value: filter.value, + })), limit: limitEnabled && limit ? limit : undefined, }} /> diff --git a/packages/graph-explorer/src/modules/NodeExpand/NodeExpandFilters.tsx b/packages/graph-explorer/src/modules/NodeExpand/NodeExpandFilters.tsx index 2f186d690..f5c3e1a09 100644 --- a/packages/graph-explorer/src/modules/NodeExpand/NodeExpandFilters.tsx +++ b/packages/graph-explorer/src/modules/NodeExpand/NodeExpandFilters.tsx @@ -25,6 +25,15 @@ import { useSearchableAttributes } from "@/core"; import useTranslations from "@/hooks/useTranslations"; let nextFilterId = 1; + +/** + * Reserved filter name used to represent filtering by a neighbor's vertex ID + * rather than by one of its properties. The ID is not a real attribute, so this + * sentinel is partitioned out of the property filter criteria before the + * neighbor request is built. + */ +export const ID_FILTER_NAME = "__ge_neighbor_id__"; + export type NodeExpandFilter = { id: number; name: string; @@ -43,12 +52,18 @@ export type NodeExpandFiltersProps = { onLimitEnabledToggle(enabled: boolean): void; }; -function useAttributeOptions(selectedType: string) { +function useAttributeOptions(selectedType: string): SelectOption[] { const allSearchableAttributes = useSearchableAttributes(selectedType); - return allSearchableAttributes.map(a => ({ - label: a.displayLabel, - value: a.name, - })); + + // ID is always available as a filter, even for types with no searchable + // attributes, so a specific neighbor can be found by its ID. + return [ + { label: "ID", value: ID_FILTER_NAME }, + ...allSearchableAttributes.map(a => ({ + label: a.displayLabel, + value: a.name, + })), + ]; } const NodeExpandFilters = ({ @@ -65,7 +80,7 @@ const NodeExpandFilters = ({ const t = useTranslations(); const attributeSelectOptions = useAttributeOptions(selectedType); - const hasSearchableAttributes = attributeSelectOptions.length > 0; + const hasFilterOptions = attributeSelectOptions.length > 0; const onFilterAdd = () => { onFiltersChange([ @@ -103,7 +118,7 @@ const NodeExpandFilters = ({ options={neighborsOptions} /> - {hasSearchableAttributes ? ( + {hasFilterOptions ? (
Filter to narrow results