Skip to content

Commit 01daa72

Browse files
authored
Fix RDF neighbor expansion (#870)
* Move parseEdgeId logic * Fix sparql neighbor expansion * Fix limit * Update changelog * Update comments * Performance and readability * Remove drafts * Remove need for indentLinesBeyondFirst * Add unit tests for more scenarios * Fix trailing space in queries * Update helpers * Move getLimit up to common area * Use getLimit in more places
1 parent 91bd960 commit 01daa72

25 files changed

+1078
-290
lines changed

Changelog.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@
2727
([#828](https://github.com/aws/graph-explorer/pull/828))
2828
- **Updated** namespaces sidebar to use tabs instead of dropdown
2929
([#830](https://github.com/aws/graph-explorer/pull/830))
30+
- **Fixed** issues with filtered neighbor expansion and neighbor counts in RDF
31+
databases ([#870](https://github.com/aws/graph-explorer/pull/870))
3032
- **Fixed** issue with table exports
3133
([#860](https://github.com/aws/graph-explorer/pull/860))
3234
- **Fixed** issue with long node titles or descriptions pushing the "add to

packages/graph-explorer/src/connector/sparql/edgeDetails.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { createEdge } from "@/core";
22
import { EdgeDetailsRequest, EdgeDetailsResponse } from "../useGEFetchTypes";
33
import {
4-
parseEdgeId,
54
SparqlFetch,
65
sparqlResponseSchema,
76
sparqlUriValueSchema,
@@ -10,6 +9,7 @@ import { logger, query } from "@/utils";
109
import { z } from "zod";
1110
import isErrorResponse from "../utils/isErrorResponse";
1211
import { idParam } from "./idParam";
12+
import { parseEdgeId } from "./parseEdgeId";
1313

1414
const responseSchema = sparqlResponseSchema(
1515
z.object({
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { query } from "@/utils";
2+
import { getFilters, getSubjectClasses } from "./helpers";
3+
import { normalizeWithNewlines as normalize } from "@/utils/testing";
4+
5+
describe("getSubjectClasses", () => {
6+
it("should return empty string if no subject classes", () => {
7+
const result = getSubjectClasses([]);
8+
expect(result).toEqual("");
9+
});
10+
11+
it("should create values with one class", () => {
12+
const result = getSubjectClasses(["http://example.org/class"]);
13+
expect(result).toEqual(
14+
`VALUES ?subjectClass { <http://example.org/class> }`
15+
);
16+
});
17+
18+
it("should create values with multiple classes", () => {
19+
const result = getSubjectClasses([
20+
"http://example.org/class1",
21+
"http://example.org/class2",
22+
]);
23+
expect(result).toEqual(
24+
`VALUES ?subjectClass { <http://example.org/class1> <http://example.org/class2> }`
25+
);
26+
});
27+
});
28+
29+
describe("getFilters", () => {
30+
it("should return empty string if no filters", () => {
31+
const result = getFilters([]);
32+
expect(normalize(result)).toEqual("");
33+
});
34+
35+
it("should create filter with one criteria", () => {
36+
const result = getFilters([
37+
{ predicate: "http://example.org/predicate", object: "value" },
38+
]);
39+
expect(normalize(result)).toEqual(
40+
normalize(query`
41+
FILTER (
42+
(?sPred=<http://example.org/predicate> && regex(str(?sValue), "value", "i"))
43+
)
44+
`)
45+
);
46+
});
47+
48+
it("should create filter with multiple criteria", () => {
49+
const result = getFilters([
50+
{ predicate: "http://example.org/predicate", object: "value" },
51+
{ predicate: "http://example.org/predicate2", object: "value2" },
52+
]);
53+
expect(normalize(result)).toEqual(
54+
normalize(query`
55+
FILTER (
56+
(?sPred=<http://example.org/predicate> && regex(str(?sValue), "value", "i")) ||
57+
(?sPred=<http://example.org/predicate2> && regex(str(?sValue), "value2", "i"))
58+
)
59+
`)
60+
);
61+
});
62+
});
Lines changed: 16 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { SPARQLCriterion } from "../types";
2+
import { query } from "@/utils";
23

3-
export const getSubjectClasses = (subjectClasses: string[]) => {
4+
export function getSubjectClasses(subjectClasses: string[]) {
45
if (!subjectClasses?.length) {
56
return "";
67
}
@@ -11,28 +12,23 @@ export const getSubjectClasses = (subjectClasses: string[]) => {
1112
});
1213
classesValues += " }";
1314
return classesValues;
14-
};
15+
}
1516

16-
export const getFilters = (filterCriteria: SPARQLCriterion[]) => {
17+
export function getFilters(filterCriteria: SPARQLCriterion[]) {
1718
if (!filterCriteria?.length) {
1819
return "";
1920
}
2021

21-
let filter = "FILTER(";
22-
filterCriteria.forEach((criterion, cI) => {
23-
filter += `(?sPred=<${criterion.predicate}> && regex(str(?sValue), "${criterion.object}", "i"))`;
22+
const filtersTemplate = filterCriteria
23+
.map(
24+
c =>
25+
`(?sPred=<${c.predicate}> && regex(str(?sValue), "${c.object}", "i"))`
26+
)
27+
.join(" ||\n");
2428

25-
if (cI < filterCriteria.length - 1) {
26-
filter += " || ";
27-
}
28-
});
29-
filter += ")";
30-
return filter;
31-
};
32-
33-
export const getLimit = (limit?: number, offset?: number) => {
34-
if (limit === 0) {
35-
return "";
36-
}
37-
return `LIMIT ${limit} OFFSET ${offset}`;
38-
};
29+
return query`
30+
FILTER (
31+
${filtersTemplate}
32+
)
33+
`;
34+
}
Lines changed: 16 additions & 144 deletions
Original file line numberDiff line numberDiff line change
@@ -1,134 +1,12 @@
1-
import groupBy from "lodash/groupBy";
2-
import { Edge, Vertex, VertexId } from "@/core";
31
import {
42
toMappedQueryResults,
53
type NeighborsResponse,
64
} from "@/connector/useGEFetchTypes";
7-
import mapIncomingToEdge, {
8-
IncomingPredicate,
9-
isIncomingPredicate,
10-
} from "../mappers/mapIncomingToEdge";
11-
import mapOutgoingToEdge, {
12-
OutgoingPredicate,
13-
} from "../mappers/mapOutgoingToEdge";
14-
import mapRawResultToVertex from "../mappers/mapRawResultToVertex";
15-
import oneHopNeighborsTemplate from "./oneHopNeighborsTemplate";
16-
import subjectPredicatesTemplate from "./subjectPredicatesTemplate";
17-
import {
18-
RawResult,
19-
RawValue,
20-
SparqlFetch,
21-
SPARQLNeighborsRequest,
22-
} from "../types";
23-
import { logger } from "@/utils";
24-
25-
type RawOneHopNeighborsResponse = {
26-
results: {
27-
bindings: Array<{
28-
subject: RawValue;
29-
pred: RawValue;
30-
value: RawValue;
31-
subjectClass: RawValue;
32-
pToSubject?: RawValue;
33-
pFromSubject?: RawValue;
34-
}>;
35-
};
36-
};
37-
38-
type RawNeighborsPredicatesResponse = {
39-
results: {
40-
bindings: Array<OutgoingPredicate | IncomingPredicate>;
41-
};
42-
};
43-
44-
const isBlank = (result: RawValue) => {
45-
return result.type === "bnode";
46-
};
5+
import { oneHopNeighborsTemplate } from "./oneHopNeighborsTemplate";
476

48-
const fetchOneHopNeighbors = async (
49-
sparqlFetch: SparqlFetch,
50-
req: SPARQLNeighborsRequest
51-
) => {
52-
const oneHopTemplate = oneHopNeighborsTemplate(req);
53-
logger.log("[SPARQL Explorer] Fetching oneHopNeighbors...", req);
54-
const data = await sparqlFetch<RawOneHopNeighborsResponse>(oneHopTemplate);
55-
56-
const groupBySubject = groupBy(
57-
data.results.bindings,
58-
result => result.subject.value
59-
);
60-
61-
const mappedResults: Record<string, RawResult> = {};
62-
const bNodesEdges: Edge[] = [];
63-
64-
Object.entries(groupBySubject).forEach(([uri, result]) => {
65-
// Create outgoing predicates to blank nodes
66-
if (isBlank(result[0].subject) && result[0].pToSubject) {
67-
const edge = mapOutgoingToEdge(req.resourceURI, req.resourceClasses, {
68-
subject: result[0].subject,
69-
subjectClass: result[0].subjectClass,
70-
predToSubject: result[0].pToSubject,
71-
});
72-
bNodesEdges.push(edge);
73-
}
74-
75-
// Create incoming predicates from blank nodes
76-
if (isBlank(result[0].subject) && result[0].pFromSubject) {
77-
const edge = mapIncomingToEdge(req.resourceURI, req.resourceClasses, {
78-
subject: result[0].subject,
79-
subjectClass: result[0].subjectClass,
80-
predFromSubject: result[0].pFromSubject,
81-
});
82-
bNodesEdges.push(edge);
83-
}
84-
85-
mappedResults[uri] = {
86-
uri: uri,
87-
class: result[0].subjectClass.value,
88-
isBlank: isBlank(result[0].subject),
89-
attributes: {},
90-
};
91-
92-
result.forEach(attr => {
93-
mappedResults[uri].attributes[attr.pred.value] = attr.value.value;
94-
});
95-
});
96-
97-
const vertices = Object.values(mappedResults).map(result => {
98-
return mapRawResultToVertex(result);
99-
});
100-
101-
return {
102-
vertices,
103-
bNodesEdges,
104-
};
105-
};
106-
107-
export const fetchNeighborsPredicates = async (
108-
sparqlFetch: SparqlFetch,
109-
resourceURI: VertexId,
110-
resourceClasses: Vertex["types"],
111-
subjectURIs: VertexId[]
112-
) => {
113-
const template = subjectPredicatesTemplate({
114-
resourceURI,
115-
subjectURIs,
116-
});
117-
118-
logger.log("[SPARQL Explorer] Fetching neighbor predicates...", {
119-
resourceURI,
120-
resourceClasses,
121-
subjectURIs,
122-
});
123-
const response = await sparqlFetch<RawNeighborsPredicatesResponse>(template);
124-
return response.results.bindings.map(result => {
125-
if (isIncomingPredicate(result)) {
126-
return mapIncomingToEdge(resourceURI, resourceClasses, result);
127-
}
128-
129-
return mapOutgoingToEdge(resourceURI, resourceClasses, result);
130-
});
131-
};
7+
import { SparqlFetch, SPARQLNeighborsRequest } from "../types";
8+
import { logger } from "@/utils";
9+
import { mapToResults, RawOneHopNeighborsResponse } from "./mapToResults";
13210

13311
/**
13412
* Given a subject URI, it returns a set of subjects (with their properties)
@@ -144,26 +22,20 @@ export const fetchNeighborsPredicates = async (
14422
*
14523
* It does not return neighbors counts.
14624
*/
147-
const fetchNeighbors = async (
25+
export default async function fetchNeighbors(
14826
sparqlFetch: SparqlFetch,
14927
req: SPARQLNeighborsRequest
150-
): Promise<NeighborsResponse> => {
151-
const { vertices, bNodesEdges } = await fetchOneHopNeighbors(
152-
sparqlFetch,
153-
req
154-
);
155-
const subjectsURIs = vertices.map(v => v.id);
156-
const edges = await fetchNeighborsPredicates(
157-
sparqlFetch,
158-
req.resourceURI,
159-
req.resourceClasses,
160-
subjectsURIs
161-
);
28+
): Promise<NeighborsResponse> {
29+
const oneHopTemplate = oneHopNeighborsTemplate(req);
30+
logger.log("[SPARQL Explorer] Fetching oneHopNeighbors...", req);
31+
const data = await sparqlFetch<RawOneHopNeighborsResponse>(oneHopTemplate);
32+
logger.log("[SPARQL Explorer] Fetched oneHopNeighbors", data);
33+
34+
const results = mapToResults(data.results.bindings);
16235

16336
return toMappedQueryResults({
164-
vertices,
165-
edges: [...edges, ...bNodesEdges],
37+
// Filter out the source vertex since it is already in the graph and this one is missing the attributes
38+
vertices: results.vertices.filter(v => v.id !== req.resourceURI),
39+
edges: results.edges,
16640
});
167-
};
168-
169-
export default fetchNeighbors;
41+
}

0 commit comments

Comments
 (0)