3030logger = 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+
3366class 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
0 commit comments