Skip to content

Commit 4f6f8b2

Browse files
committed
Add PathElement class to support per-element reverse and type_filter on multi-element query paths
1 parent 2e7e202 commit 4f6f8b2

3 files changed

Lines changed: 201 additions & 27 deletions

File tree

fairgraph/kgobject.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -412,7 +412,7 @@ def list(
412412
release_status = handle_scope_keyword(scope, release_status)
413413

414414
if api == "auto":
415-
if filters:
415+
if filters or follow_links:
416416
api = "query"
417417
else:
418418
api = "core"

fairgraph/queries.py

Lines changed: 61 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,39 @@
3030
logger = logging.getLogger("fairgraph")
3131

3232

33+
class PathElement:
34+
"""
35+
A single element in a multi-element query path, carrying optional
36+
``reverse`` and ``type_filter`` settings for that step.
37+
38+
Args:
39+
uri (str): The URI for this path step.
40+
reverse (bool): Whether to follow this link in reverse. Defaults to False.
41+
type_filter (str or list of str, optional): Type URI(s) to filter on at this step.
42+
"""
43+
44+
def __init__(self, uri: str, reverse: bool = False, type_filter: Optional[Union[str, List[str]]] = None):
45+
self.uri = uri
46+
self.reverse = reverse
47+
self.type_filter = type_filter
48+
49+
def __repr__(self):
50+
return f"PathElement('{self.uri}', reverse={self.reverse}, type_filter={self.type_filter!r})"
51+
52+
def serialize(self) -> Union[str, Dict[str, Any]]:
53+
if not self.reverse and not self.type_filter:
54+
return self.uri
55+
d: Dict[str, Any] = {"@id": self.uri}
56+
if self.type_filter:
57+
if isinstance(self.type_filter, (list, tuple)):
58+
d["typeFilter"] = [{"@id": t} for t in self.type_filter]
59+
else:
60+
d["typeFilter"] = {"@id": self.type_filter}
61+
if self.reverse:
62+
d["reverse"] = True
63+
return d
64+
65+
3366
class Filter:
3467
"""
3568
A filter for querying Knowledge Graph nodes.
@@ -99,14 +132,14 @@ class QueryProperty:
99132

100133
def __init__(
101134
self,
102-
path: str,
135+
path: Union[str, List[Union[str, PathElement]]],
103136
name: Optional[str] = None,
104137
filter: Optional[Filter] = None,
105138
sorted: bool = False,
106139
required: bool = False,
107140
ensure_order: bool = False,
108141
properties: Optional[List[QueryProperty]] = None,
109-
type_filter: Optional[str] = None,
142+
type_filter: Optional[Union[str, List[str]]] = None,
110143
reverse: bool = False,
111144
expect_single: bool = False,
112145
):
@@ -121,6 +154,27 @@ def __init__(
121154
self.reverse = reverse
122155
self.expect_single = expect_single
123156

157+
# Normalize path to a list of PathElement for clean serialization
158+
if isinstance(path, str):
159+
self._path_elements = [PathElement(path, reverse=reverse, type_filter=type_filter)]
160+
else:
161+
has_path_elements = any(isinstance(p, PathElement) for p in path)
162+
if has_path_elements and (reverse or type_filter is not None):
163+
raise ValueError(
164+
"Cannot use top-level 'reverse' or 'type_filter' when path contains PathElement objects; "
165+
"set those on the PathElement directly."
166+
)
167+
normalized = []
168+
for i, p in enumerate(path):
169+
if isinstance(p, PathElement):
170+
normalized.append(p)
171+
elif i == 0 and not has_path_elements:
172+
# backwards compat: top-level reverse/type_filter apply to first element
173+
normalized.append(PathElement(p, reverse=reverse, type_filter=type_filter))
174+
else:
175+
normalized.append(PathElement(p))
176+
self._path_elements = normalized
177+
124178
for prop in self.properties:
125179
if prop.sorted:
126180
raise ValueError("Sorting is only allowed on the root level of a query.")
@@ -135,9 +189,11 @@ def add_property(self, prop: QueryProperty):
135189
self.properties.append(prop)
136190

137191
def serialize(self) -> Dict[str, Any]:
138-
data: Dict[str, Any] = {
139-
"path": self.path,
140-
}
192+
serialized_elements = [pe.serialize() for pe in self._path_elements]
193+
if isinstance(self.path, str):
194+
data: Dict[str, Any] = {"path": serialized_elements[0]}
195+
else:
196+
data: Dict[str, Any] = {"path": serialized_elements}
141197
if self.name:
142198
data["propertyName"] = self.name
143199
if self.filter:
@@ -150,26 +206,6 @@ def serialize(self) -> Dict[str, Any]:
150206
data["ensureOrder"] = True
151207
if self.properties:
152208
data["structure"] = [prop.serialize() for prop in self.properties]
153-
if self.type_filter or self.reverse:
154-
if isinstance(self.path, str):
155-
first_path_element = {"@id": self.path}
156-
else:
157-
# for now we only support specifying type filters/reverse
158-
# for the first element in a multi-element path
159-
assert isinstance(self.path, (list, tuple))
160-
first_path_element = {"@id": self.path[0]}
161-
if self.type_filter:
162-
if isinstance(self.type_filter, (list, tuple)):
163-
first_path_element["typeFilter"] = [{"@id": type_iri} for type_iri in self.type_filter]
164-
else:
165-
assert isinstance(self.type_filter, str)
166-
first_path_element["typeFilter"] = {"@id": self.type_filter}
167-
if self.reverse:
168-
first_path_element["reverse"] = True
169-
if isinstance(self.path, str):
170-
data["path"] = first_path_element
171-
else:
172-
data["path"] = [first_path_element, *self.path[1:]]
173209
if self.expect_single:
174210
data["singleValue"] = "FIRST"
175211
return data

test/test_queries.py

Lines changed: 139 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import json
33
import pytest
44
from kg_core.request import Stage, Pagination
5-
from fairgraph.queries import Query, QueryProperty, Filter
5+
from fairgraph.queries import Query, QueryProperty, Filter, PathElement
66
from fairgraph.utility import adapt_namespaces_for_query
77
import fairgraph.openminds.core as omcore
88
from .utils import kg_client, mock_client, skip_if_no_connection
@@ -580,3 +580,141 @@ def test_generate_query_type_filter_flattened():
580580
],
581581
}
582582
assert query.serialize() == expected
583+
584+
585+
def test_multi_element_path_with_path_elements():
586+
query = Query(
587+
node_type="https://openminds.om-i.org/types/File",
588+
properties=[
589+
QueryProperty(
590+
[
591+
"https://openminds.om-i.org/props/fileRepository",
592+
PathElement(
593+
"https://openminds.om-i.org/props/repository",
594+
reverse=True,
595+
type_filter="https://openminds.om-i.org/types/DatasetVersion",
596+
),
597+
],
598+
name="dataset",
599+
)
600+
],
601+
)
602+
expected = {
603+
"@context": {
604+
"@vocab": "https://core.kg.ebrains.eu/vocab/query/",
605+
"merge": {"@id": "merge", "@type": "@id"},
606+
"query": "https://schema.hbp.eu/myQuery/",
607+
"propertyName": {"@id": "propertyName", "@type": "@id"},
608+
"path": {"@id": "path", "@type": "@id"},
609+
},
610+
"meta": {
611+
"type": "https://openminds.om-i.org/types/File",
612+
"description": "Automatically generated by fairgraph",
613+
},
614+
"structure": [
615+
{"path": "@id", "filter": {"op": "EQUALS", "parameter": "id"}},
616+
{
617+
"propertyName": "dataset",
618+
"path": [
619+
"https://openminds.om-i.org/props/fileRepository",
620+
{
621+
"@id": "https://openminds.om-i.org/props/repository",
622+
"reverse": True,
623+
"typeFilter": {"@id": "https://openminds.om-i.org/types/DatasetVersion"},
624+
},
625+
],
626+
},
627+
],
628+
}
629+
assert query.serialize() == expected
630+
631+
632+
def test_path_element_conflicts_with_top_level_reverse():
633+
with pytest.raises(ValueError, match="Cannot use top-level"):
634+
QueryProperty(
635+
[
636+
"https://openminds.om-i.org/props/fileRepository",
637+
PathElement("https://openminds.om-i.org/props/repository", reverse=True),
638+
],
639+
reverse=True,
640+
)
641+
642+
643+
@skip_if_no_connection
644+
def test_execute_query_with_multi_element_path_with_path_elements(kg_client):
645+
# This query should return only Files belonging to the specified dataset.
646+
# This is possibly a bad choice for a test, since queries involving
647+
# Files are often very slow, as there are so many Files in the KG.
648+
DATASET_ID = "bd5f91ff-e829-4b85-92eb-fc56991541f1"
649+
query = Query(
650+
node_type="https://openminds.om-i.org/types/File",
651+
properties=[
652+
QueryProperty(
653+
[
654+
"https://openminds.om-i.org/props/fileRepository",
655+
PathElement(
656+
"https://openminds.om-i.org/props/repository",
657+
reverse=True,
658+
type_filter="https://openminds.om-i.org/types/DatasetVersion",
659+
),
660+
"@id",
661+
],
662+
name="dataset",
663+
expect_single=True,
664+
filter=Filter("CONTAINS", value=DATASET_ID)
665+
),
666+
QueryProperty("https://openminds.om-i.org/props/name", name="name"),
667+
]
668+
)
669+
expected = {
670+
"@context": {
671+
"@vocab": "https://core.kg.ebrains.eu/vocab/query/",
672+
"merge": {
673+
"@id": "merge",
674+
"@type": "@id",
675+
},
676+
"query": "https://schema.hbp.eu/myQuery/",
677+
"propertyName": {"@id": "propertyName", "@type": "@id"},
678+
"path": {"@id": "path", "@type": "@id"},
679+
},
680+
"meta": {
681+
"type": "https://openminds.om-i.org/types/File",
682+
"description": "Automatically generated by fairgraph",
683+
},
684+
"structure": [
685+
{
686+
"filter": {
687+
"op": "EQUALS",
688+
"parameter": "id",
689+
},
690+
"path": "@id",
691+
},
692+
{
693+
"propertyName": "dataset",
694+
"singleValue": "FIRST",
695+
"filter": {"op": "CONTAINS", "value": "bd5f91ff-e829-4b85-92eb-fc56991541f1"},
696+
"path": [
697+
"https://openminds.om-i.org/props/fileRepository",
698+
{
699+
"@id": "https://openminds.om-i.org/props/repository",
700+
"reverse": True,
701+
"typeFilter": {"@id": "https://openminds.om-i.org/types/DatasetVersion"},
702+
},
703+
"@id",
704+
],
705+
},
706+
{
707+
"propertyName": "name",
708+
"path": "https://openminds.om-i.org/props/name"
709+
},
710+
],
711+
}
712+
assert query.serialize() == expected
713+
response = kg_client._kg_client.queries.test_query(
714+
payload=query.serialize(),
715+
stage=Stage.RELEASED,
716+
pagination=Pagination(start=0, size=5),
717+
)
718+
assert len(response.data) == 5
719+
assert all(item["dataset"] == f"https://kg.ebrains.eu/api/instances/{DATASET_ID}"
720+
for item in response.data)

0 commit comments

Comments
 (0)