Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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()
)
`),
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ export default function oneHopTemplate({
vertexId,
excludedVertices = new Set(),
filterByVertexTypes = [],
filterByIds = [],
filterCriteria = [],
limit = 0,
}: Omit<NeighborsRequest, "vertexTypes">): string {
Expand All @@ -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(".")}` : ``;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
`,
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ const criterionTemplate = (criterion: Criterion): string => {
const oneHopTemplate = ({
vertexId,
filterByVertexTypes = [],
filterByIds = [],
filterCriteria = [],
excludedVertices = new Set(),
limit = 0,
Expand All @@ -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
Expand All @@ -137,6 +143,7 @@ const oneHopTemplate = ({
`ID(v) = ${idParam(vertexId)}`,
formattedExcludedVertices,
formattedVertexTypes,
formattedIds,
...(filterCriteria?.map(criterionTemplate) ?? []),
]
.filter(Boolean)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(<http://www.example.com/soccer/resource#EPL> AS ?resource)
{
?neighbor ?predicate ?resource .
OPTIONAL { ?neighbor a ?class } .
FILTER(!isLiteral(?neighbor) && ?predicate != <http://www.w3.org/1999/02/22-rdf-syntax-ns#type>)
}
UNION
{
?resource ?predicate ?neighbor .
OPTIONAL { ?neighbor a ?class } .
FILTER(!isLiteral(?neighbor) && ?predicate != <http://www.w3.org/1999/02/22-rdf-syntax-ns#type>)
}
FILTER(?neighbor IN (<http://www.example.com/soccer/resource#Arsenal>, <http://www.example.com/soccer/resource#Chelsea>))
}
}
${commonPartOfQuery("http://www.example.com/soccer/resource#EPL")}
}
`),
);
});
});

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { VertexId } from "@/core";

import { query } from "@/utils";

import {
Expand Down Expand Up @@ -172,6 +174,7 @@ export function oneHopNeighborsTemplate(
export function findNeighborsUsingFilters({
resourceURI,
subjectClasses = [],
filterByIds = [],
filterCriteria = [],
excludedVertices = new Set(),
limit = 0,
Expand All @@ -185,25 +188,38 @@ export function findNeighborsUsingFilters({
BIND(${resourceTemplate} AS ?resource)
{
# Incoming neighbors
?neighbor ?predicate ?resource .
?neighbor ?predicate ?resource .
OPTIONAL { ?neighbor a ?class } .
${getNeighborsFilter(excludedVertices)}
${getSubjectClasses(subjectClasses)}
}
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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions packages/graph-explorer/src/connector/sparql/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ export type SPARQLNeighborsRequest = {
* Filter by subject classes
*/
subjectClasses?: Array<string>;
/**
* Filter to neighbors matching one of these resource URIs.
*/
filterByIds?: Array<VertexId>;
/**
* Filter by predicates and objects matching values.
*/
Expand Down
4 changes: 4 additions & 0 deletions packages/graph-explorer/src/connector/useGEFetchTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,10 @@ export type NeighborsRequest = {
* Filter by vertex types.
*/
filterByVertexTypes?: Array<string>;
/**
* Filter to neighbors matching one of these vertex IDs.
*/
filterByIds?: Array<VertexId>;
/**
* Filter by vertex attributes.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { useSuspenseQuery } from "@tanstack/react-query";
import { Suspense, useState } from "react";

import type { VertexId } from "@/core";

import {
Button,
DefaultQueryErrorBoundary,
Expand All @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -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,
}}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 = ({
Expand All @@ -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([
Expand Down Expand Up @@ -103,7 +118,7 @@ const NodeExpandFilters = ({
options={neighborsOptions}
/>
</Section>
{hasSearchableAttributes ? (
{hasFilterOptions ? (
<Section>
<SectionTitle>Filter to narrow results</SectionTitle>
<div className="space-y-4">
Expand Down