Skip to content

Commit 885d91e

Browse files
authored
feat: Add Cypher queries (#48)
1 parent 214528a commit 885d91e

File tree

9 files changed

+447
-134
lines changed

9 files changed

+447
-134
lines changed

motifstudio-web/src/app/MotifVisualizer.tsx

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,17 @@ import useSWR from "swr";
66
import { BASE_URL, bodiedFetcher } from "./api";
77
import { useRef } from "react";
88
import ColorHash from "color-hash";
9+
import { getQueryParams } from "./queryparams";
910

1011
Cytoscape.use(COSEBilkent);
1112

1213
export const MotifVisualizer = ({ motifSource }: { motifSource: string }) => {
13-
// Construct the motif graph:
14+
// All React hooks must be called before any conditional returns
1415
const debouncedQuery = useThrottle(motifSource, 1000);
15-
let elements = useRef([]);
16+
let elements = useRef<any[]>([]);
17+
18+
// Get query type from URL parameters
19+
const { query_type } = typeof window !== "undefined" ? getQueryParams() : { query_type: "dotmotif" };
1620

1721
const colorhash = new ColorHash({
1822
lightness: 0.5,
@@ -22,7 +26,7 @@ export const MotifVisualizer = ({ motifSource }: { motifSource: string }) => {
2226
const nodeWithoutID = { ...item, __seed: opts.seed };
2327
delete nodeWithoutID.id;
2428
opts.without.forEach((key) => {
25-
delete nodeWithoutID[key];
29+
delete (nodeWithoutID as any)[key];
2630
});
2731
return colorhash.hex(JSON.stringify(nodeWithoutID));
2832
}
@@ -33,13 +37,13 @@ export const MotifVisualizer = ({ motifSource }: { motifSource: string }) => {
3337
isLoading: queryIsLoading,
3438
} = useSWR(
3539
[`${BASE_URL}/queries/motifs/_parse`, "", debouncedQuery],
36-
() => bodiedFetcher(`${BASE_URL}/queries/motifs/_parse`, { host_id: "", query: debouncedQuery }),
40+
() => bodiedFetcher(`${BASE_URL}/queries/motifs/_parse`, { host_id: "", query: debouncedQuery, query_type }),
3741
{
3842
onSuccess: (data) => {
3943
// Construct the motif graph:
4044
const motifGraph = JSON.parse(data?.motif_nodelink_json || "{}");
4145
elements.current = [
42-
...(motifGraph?.nodes || []).map((node) => {
46+
...(motifGraph?.nodes || []).map((node: any) => {
4347
return {
4448
data: {
4549
...node,
@@ -48,7 +52,7 @@ export const MotifVisualizer = ({ motifSource }: { motifSource: string }) => {
4852
},
4953
};
5054
}),
51-
...(motifGraph?.links || []).map((link) => {
55+
...(motifGraph?.links || []).map((link: any) => {
5256
return {
5357
data: {
5458
...link,
@@ -61,6 +65,25 @@ export const MotifVisualizer = ({ motifSource }: { motifSource: string }) => {
6165
},
6266
}
6367
);
68+
69+
// If it's a Cypher query, show a message that visualization is not supported
70+
if (query_type === "cypher") {
71+
return (
72+
<div className="flex flex-col items-center justify-center h-64 bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
73+
<div className="text-center">
74+
<div className="text-lg font-semibold text-gray-700 dark:text-gray-300 mb-2">
75+
Cypher Query Visualization
76+
</div>
77+
<div className="text-sm text-gray-500 dark:text-gray-400">
78+
Visualization is not available for Cypher queries.
79+
<br />
80+
Cypher queries can return arbitrary data structures that cannot be displayed as traditional motif graphs.
81+
</div>
82+
</div>
83+
</div>
84+
);
85+
}
86+
6487
if (queryError) {
6588
return <div>Error loading motif</div>;
6689
}

motifstudio-web/src/app/ResultsFetcher.tsx

Lines changed: 82 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,27 @@ import { HostListing, bodiedFetcher, BASE_URL, neuroglancerUrlFromHostVolumetric
44
import { useDebounce } from "./useDebounce";
55
import { LoadingSpinner } from "./LoadingSpinner";
66

7-
export function ResultsFetcher({ graph, query }: { graph: HostListing | null; query: string }) {
7+
export function ResultsFetcher({
8+
graph,
9+
query,
10+
queryType,
11+
}: {
12+
graph: HostListing | null;
13+
query: string;
14+
queryType: "dotmotif" | "cypher";
15+
}) {
816
const debouncedQuery = useDebounce(query, 500);
917

1018
const {
1119
data: queryData,
1220
error: queryError,
1321
isLoading: queryIsLoading,
14-
} = useSWR([`${BASE_URL}/queries/motifs`, graph?.id, debouncedQuery], () =>
15-
bodiedFetcher(`${BASE_URL}/queries/motifs`, { host_id: graph?.id, query: debouncedQuery })
22+
} = useSWR([`${BASE_URL}/queries/motifs`, graph?.id, debouncedQuery, queryType], () =>
23+
bodiedFetcher(`${BASE_URL}/queries/motifs`, {
24+
host_id: graph?.id,
25+
query: debouncedQuery,
26+
query_type: queryType,
27+
})
1628
);
1729

1830
if (queryIsLoading) return <LoadingSpinner />;
@@ -63,7 +75,28 @@ export function ResultsFetcher({ graph, query }: { graph: HostListing | null; qu
6375
a.click();
6476
} else if (format === "csv") {
6577
const csv = queryData.motif_results.map((result: any) => {
66-
return queryData.motif_entities.map((entity: string) => result[entity].id).join(",");
78+
return queryData.motif_entities
79+
.map((entity: string) => {
80+
let value = result[entity].id;
81+
// For JSON-serialized values, try to parse them for CSV export
82+
if (typeof value === "string") {
83+
try {
84+
const parsed = JSON.parse(value);
85+
// Use the parsed value if it's simple, otherwise keep the JSON string
86+
if (typeof parsed === "string" || typeof parsed === "number") {
87+
value = parsed.toString();
88+
}
89+
} catch (e) {
90+
// If parsing fails, use as-is
91+
}
92+
}
93+
// Escape commas and quotes for CSV
94+
if (typeof value === "string" && (value.includes(",") || value.includes('"'))) {
95+
value = `"${value.replace(/"/g, '""')}"`;
96+
}
97+
return value;
98+
})
99+
.join(",");
67100
});
68101
const blob = new Blob([csv.join("\n")], { type: "text/csv" });
69102
const url = URL.createObjectURL(blob);
@@ -158,19 +191,58 @@ export function ResultsFetcher({ graph, query }: { graph: HostListing | null; qu
158191
href={neuroglancerUrlFromHostVolumetricData(
159192
queryData?.host_volumetric_data?.uri,
160193
queryData?.host_volumetric_data?.other_channels || [],
161-
Object.values(result).map((v: any) => v?.__segmentation_id__ || v.id)
194+
Object.values(result).map((v: any) => {
195+
let id = v?.__segmentation_id__ || v.id;
196+
// For JSON-serialized values, try to parse them
197+
if (typeof id === "string") {
198+
try {
199+
const parsed = JSON.parse(id);
200+
return parsed;
201+
} catch (e) {
202+
return id;
203+
}
204+
}
205+
return id;
206+
})
162207
)}
163208
target="_blank"
164209
rel="noreferrer"
165210
>
166211
<b>View</b>
167212
</a>
168213
{queryData?.motif_entities ? (
169-
queryData.motif_entities.map((entity: string, j: number) => (
170-
<td key={j} className="truncate max-w-xs" title={result[entity].id}>
171-
{result[entity].id}
172-
</td>
173-
))
214+
queryData.motif_entities.map((entity: string, j: number) => {
215+
let displayValue = result[entity].id;
216+
let titleValue = result[entity].id;
217+
218+
// For Cypher queries, the id field contains JSON-serialized data
219+
// Try to parse and display it nicely
220+
if (typeof displayValue === "string") {
221+
try {
222+
const parsed = JSON.parse(displayValue);
223+
// If it's a simple value, display it directly
224+
if (typeof parsed === "string" || typeof parsed === "number") {
225+
displayValue = parsed.toString();
226+
} else {
227+
// For complex objects, show a truncated JSON representation
228+
displayValue = JSON.stringify(parsed);
229+
if (displayValue.length > 50) {
230+
displayValue = displayValue.substring(0, 47) + "...";
231+
}
232+
}
233+
titleValue = JSON.stringify(parsed, null, 2);
234+
} catch (e) {
235+
// If parsing fails, display as-is
236+
displayValue = displayValue;
237+
}
238+
}
239+
240+
return (
241+
<td key={j} className="truncate max-w-xs" title={titleValue}>
242+
{displayValue}
243+
</td>
244+
);
245+
})
174246
) : (
175247
<div></div>
176248
)}

motifstudio-web/src/app/ResultsWrapper.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,21 @@ import { useEffect } from "react";
44
import { HostListing } from "./api";
55
import { ResultsFetcher } from "./ResultsFetcher";
66

7-
export function ResultsWrapper({ graph, query }: { graph: HostListing | null; query: string }) {
7+
export function ResultsWrapper({
8+
graph,
9+
query,
10+
queryType,
11+
}: {
12+
graph: HostListing | null;
13+
query: string;
14+
queryType: "dotmotif" | "cypher";
15+
}) {
816
// Trigger results fetch on button click
917
const [trigger, setTrigger] = useState(false);
1018
// When graph or query changes, reset trigger
1119
useEffect(() => {
1220
setTrigger(false);
13-
}, [graph, query]);
21+
}, [graph, query, queryType]);
1422

1523
return (
1624
<div className="flex flex-col gap-2 w-full h-full p-4 bg-white rounded-lg shadow-lg dark:bg-gray-800">
@@ -22,7 +30,7 @@ export function ResultsWrapper({ graph, query }: { graph: HostListing | null; qu
2230
Run Query
2331
</button>
2432
) : null}
25-
{trigger ? <ResultsFetcher graph={graph} query={query} /> : null}
33+
{trigger ? <ResultsFetcher graph={graph} query={query} queryType={queryType} /> : null}
2634
</div>
2735
);
2836
}

motifstudio-web/src/app/WrappedEditor.tsx

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,25 @@ B -> A
1111
1212
`;
1313

14+
export const _DEFAULT_CYPHER_CONTENTS = `// This is a Cypher query editor.
15+
// For more information about Cypher, visit
16+
// https://neo4j.com/docs/cypher-manual/current/
17+
18+
// Find paths of length 2:
19+
MATCH (a)-[r1]->(b)-[r2]->(c)
20+
RETURN a, b, c
21+
LIMIT 100
22+
23+
`;
24+
1425
export function WrappedEditor({
1526
startValue,
27+
queryType = "dotmotif",
1628
entityNames,
1729
onChange,
1830
}: {
1931
startValue?: string;
32+
queryType?: "dotmotif" | "cypher";
2033
entityNames?: string[];
2134
onChange?: (value?: string) => void;
2235
}) {
@@ -40,6 +53,33 @@ export function WrappedEditor({
4053
},
4154
});
4255

56+
// Register Cypher language support
57+
monaco.languages.register({ id: "cypher" });
58+
monaco.languages.setMonarchTokensProvider("cypher", {
59+
tokenizer: {
60+
root: [
61+
[/\/\/.*$/, "comment"],
62+
[/\/\*[\s\S]*?\*\//, "comment"],
63+
[
64+
/\b(MATCH|RETURN|WHERE|WITH|UNWIND|CREATE|DELETE|SET|REMOVE|MERGE|FOREACH|CALL|YIELD|UNION|ORDER BY|SKIP|LIMIT|AS|DISTINCT|OPTIONAL|NOT|AND|OR|XOR|IN|STARTS WITH|ENDS WITH|CONTAINS|IS NULL|IS NOT NULL|TRUE|FALSE|NULL)\b/i,
65+
"keyword",
66+
],
67+
[
68+
/\b(count|sum|avg|min|max|collect|size|length|type|id|properties|keys|labels|nodes|relationships|range|reduce|filter|extract|all|any|none|single|exists|head|last|tail|reverse|sort)\b/i,
69+
"function",
70+
],
71+
[/\([^)]*\)/, "variable"],
72+
[/\[[^\]]*\]/, "relationship"],
73+
[/\{[^}]*\}/, "property"],
74+
[/[<>-]+/, "edge"],
75+
[/"([^"\\]|\\.)*"/, "string"],
76+
[/'([^'\\]|\\.)*'/, "string"],
77+
[/\d+/, "number"],
78+
[/\w+/, "identifier"],
79+
],
80+
},
81+
});
82+
4383
// Define a new theme that contains only rules that match this language
4484
monaco.editor.defineTheme("motiftheme", {
4585
base: "vs",
@@ -57,6 +97,15 @@ export function WrappedEditor({
5797
},
5898
{ token: "entity", foreground: "008800" },
5999
{ token: "macro", foreground: "888800" },
100+
// Cypher tokens
101+
{ token: "keyword", foreground: "0000ff", fontStyle: "bold" },
102+
{ token: "function", foreground: "800080" },
103+
{ token: "variable", foreground: "008800" },
104+
{ token: "relationship", foreground: "0066dd" },
105+
{ token: "property", foreground: "888800" },
106+
{ token: "string", foreground: "dd0000" },
107+
{ token: "number", foreground: "ff6600" },
108+
{ token: "identifier", foreground: "000000" },
60109
],
61110
colors: {
62111
"editor.foreground": "#888888",
@@ -78,6 +127,15 @@ export function WrappedEditor({
78127
},
79128
{ token: "entity", foreground: "008800" },
80129
{ token: "macro", foreground: "888800" },
130+
// Cypher tokens
131+
{ token: "keyword", foreground: "4fc3f7", fontStyle: "bold" },
132+
{ token: "function", foreground: "ce93d8" },
133+
{ token: "variable", foreground: "81c784" },
134+
{ token: "relationship", foreground: "64b5f6" },
135+
{ token: "property", foreground: "fff176" },
136+
{ token: "string", foreground: "f48fb1" },
137+
{ token: "number", foreground: "ffab40" },
138+
{ token: "identifier", foreground: "ffffff" },
81139
],
82140
colors: {
83141
"editor.foreground": "#888888",
@@ -102,6 +160,20 @@ export function WrappedEditor({
102160

103161
const prefersDark = typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: dark)").matches;
104162

163+
const getDefaultContent = () => {
164+
if (queryType === "cypher") {
165+
return _DEFAULT_CYPHER_CONTENTS;
166+
}
167+
return _DEFAULT_EDITOR_CONTENTS;
168+
};
169+
170+
const getLanguage = () => {
171+
if (queryType === "cypher") {
172+
return "cypher";
173+
}
174+
return "motiflang";
175+
};
176+
105177
return (
106178
<Editor
107179
height="40vh"
@@ -115,8 +187,9 @@ export function WrappedEditor({
115187
onMount={(editor, monaco) => {
116188
onChange ? onChange(editor.getValue()) : null;
117189
}}
118-
defaultLanguage="motiflang"
119-
value={startValue || _DEFAULT_EDITOR_CONTENTS}
190+
defaultLanguage={getLanguage()}
191+
language={getLanguage()}
192+
value={startValue || getDefaultContent()}
120193
options={{
121194
fontSize: 16,
122195
fontLigatures: true,

0 commit comments

Comments
 (0)