@@ -117,7 +117,13 @@ def get_triplets(
117117 properties : dict [str , Any ] | None = None ,
118118 ids : list [str ] | None = None ,
119119 ) -> list [list [LabelledNode ]]:
120- """Retrieve triplets (subject, predicate, object) as node triples."""
120+ """Retrieve triplets (subject, predicate, object) as node triples.
121+
122+ Note:
123+ ``relation_names`` is **required**. CoordiNode does not support
124+ untyped wildcard ``[r]`` relationship patterns — they silently return
125+ no rows. Omitting ``relation_names`` raises ``NotImplementedError``.
126+ """
121127 conditions : list [str ] = []
122128 params : dict [str , Any ] = {}
123129
@@ -131,20 +137,26 @@ def get_triplets(
131137 rel_filter = "|" .join (_cypher_ident (t ) for t in relation_names )
132138 rel_pattern = f"[r:{ rel_filter } ]"
133139 else :
134- rel_pattern = "[r]"
140+ # CoordiNode: wildcard [r] pattern returns no results.
141+ # Callers must supply relation_names for the query to work.
142+ raise NotImplementedError (
143+ "CoordinodePropertyGraphStore.get_triplets() requires relation_names — "
144+ "CoordiNode does not support untyped wildcard [r] patterns"
145+ )
135146
136147 where = f"WHERE { ' AND ' .join (conditions )} " if conditions else ""
148+ # CoordiNode: use r.__type__ instead of type(r) — type() returns null.
137149 cypher = (
138150 f"MATCH (n)-{ rel_pattern } ->(m) { where } "
139- "RETURN n, type(r) AS rel_type, m, n.id AS _src_id, m.id AS _dst_id "
151+ "RETURN n, r.__type__ AS rel_type, m, n.id AS _src_id, m.id AS _dst_id "
140152 "LIMIT 1000"
141153 )
142154 result = self ._client .cypher (cypher , params = params )
143155
144156 triplets : list [list [LabelledNode ]] = []
145157 for row in result :
146158 src_data = row .get ("n" , {})
147- rel_type = row .get ("rel_type" , "RELATED" )
159+ rel_type = row .get ("rel_type" ) or "RELATED"
148160 dst_data = row .get ("m" , {})
149161 src_id = str (row .get ("_src_id" , "" ))
150162 dst_id = str (row .get ("_dst_id" , "" ))
@@ -158,30 +170,47 @@ def get_triplets(
158170 def get_rel_map (
159171 self ,
160172 graph_nodes : list [LabelledNode ],
161- depth : int = 2 ,
173+ depth : int = 1 ,
162174 limit : int = 30 ,
163175 ignore_rels : list [str ] | None = None ,
164176 ) -> list [list [LabelledNode ]]:
165- """Get relationship map for a set of nodes up to ``depth`` hops."""
177+ """Get relationship map for a set of nodes up to ``depth`` hops.
178+
179+ Note: only ``depth=1`` (single hop) is supported. ``depth > 1`` raises
180+ ``NotImplementedError`` because CoordiNode does not yet serialise
181+ variable-length path results.
182+ """
183+ if depth != 1 :
184+ raise NotImplementedError (
185+ "CoordinodePropertyGraphStore.get_rel_map() currently supports depth=1 only; "
186+ "variable-length path queries are not yet available in CoordiNode"
187+ )
188+
166189 if not graph_nodes :
167190 return []
168191
169- node_ids = [n .id for n in graph_nodes ]
170- ignored = list (ignore_rels ) if ignore_rels else []
192+ # CoordiNode: wildcard [r] pattern returns no results. Fetch all
193+ # known edge types from the schema and build a typed pattern instead,
194+ # e.g. [r:TYPE_A|TYPE_B|...].
195+ schema_text = self ._client .get_schema_text ()
196+ edge_types = _parse_edge_types_from_schema (schema_text )
171197
172- # Push ignore_rels filter into Cypher so LIMIT applies after filtering;
173- # a Python-side filter after LIMIT would silently truncate valid results.
198+ ignored = set (ignore_rels ) if ignore_rels else set ()
199+ active_types = [t for t in edge_types if t not in ignored ]
200+
201+ if not active_types :
202+ return []
203+
204+ rel_filter = "|" .join (_cypher_ident (t ) for t in active_types )
205+ node_ids = [n .id for n in graph_nodes ]
206+ safe_limit = int (limit ) # coerce to int to prevent Cypher injection via non-integer input
174207 params : dict [str , object ] = {"ids" : node_ids }
175- ignore_clause = ""
176- if ignored :
177- ignore_clause = " AND NONE(rel IN r WHERE type(rel) IN $ignored_rels)"
178- params ["ignored_rels" ] = ignored
179208
180209 cypher = (
181- f"MATCH (n)-[r*1.. { depth } ]->(m) "
182- f"WHERE n.id IN $ids{ ignore_clause } "
183- f"RETURN n, r, m, n.id AS _src_id, m.id AS _dst_id "
184- f"LIMIT { limit } "
210+ f"MATCH (n)-[r: { rel_filter } ]->(m) "
211+ f"WHERE n.id IN $ids "
212+ f"RETURN n, r.__type__ AS _rel_type , m, n.id AS _src_id, m.id AS _dst_id "
213+ f"LIMIT { safe_limit } "
185214 )
186215 result = self ._client .cypher (cypher , params = params )
187216
@@ -191,13 +220,7 @@ def get_rel_map(
191220 dst_data = row .get ("m" , {})
192221 src_id = str (row .get ("_src_id" , "" ))
193222 dst_id = str (row .get ("_dst_id" , "" ))
194- # Variable-length path [r*1..N] returns a list of relationship dicts.
195- rels = row .get ("r" , [])
196- if isinstance (rels , list ) and rels :
197- first_rel = rels [0 ]
198- rel_label = first_rel .get ("type" , "RELATED" ) if isinstance (first_rel , dict ) else str (first_rel )
199- else :
200- rel_label = "RELATED"
223+ rel_label = str (row .get ("_rel_type" ) or "RELATED" )
201224 src = _node_result_to_labelled (src_id , src_data )
202225 dst = _node_result_to_labelled (dst_id , dst_data )
203226 rel = Relation (label = rel_label , source_id = src_id , target_id = dst_id )
@@ -217,18 +240,29 @@ def upsert_relations(self, relations: list[Relation]) -> None:
217240 """Upsert relationships into the graph."""
218241 for rel in relations :
219242 props = rel .properties or {}
220- cypher = (
221- f"MATCH (src {{id: $src_id}}), (dst {{id: $dst_id}}) "
222- f"MERGE (src)-[r:{ _cypher_ident (rel .label )} ]->(dst) SET r += $props"
223- )
224- self ._client .cypher (
225- cypher ,
226- params = {
227- "src_id" : rel .source_id ,
228- "dst_id" : rel .target_id ,
229- "props" : props ,
230- },
231- )
243+ label = _cypher_ident (rel .label )
244+ # CoordiNode does not yet support MERGE for edge patterns; use CREATE.
245+ # A WHERE NOT (src)-[:TYPE]->(dst) guard was tested but returns 0
246+ # rows silently in CoordiNode, making all CREATE statements no-ops.
247+ # Until server-side MERGE or pattern predicates are supported,
248+ # repeated calls will create duplicate edges.
249+ # SET r += $props is skipped when props is empty — SET r += {} is
250+ # not supported by all server versions.
251+ if props :
252+ cypher = (
253+ f"MATCH (src {{id: $src_id}}) MATCH (dst {{id: $dst_id}}) "
254+ f"CREATE (src)-[r:{ label } ]->(dst) SET r += $props"
255+ )
256+ self ._client .cypher (
257+ cypher ,
258+ params = {"src_id" : rel .source_id , "dst_id" : rel .target_id , "props" : props },
259+ )
260+ else :
261+ cypher = f"MATCH (src {{id: $src_id}}) MATCH (dst {{id: $dst_id}}) CREATE (src)-[r:{ label } ]->(dst)"
262+ self ._client .cypher (
263+ cypher ,
264+ params = {"src_id" : rel .source_id , "dst_id" : rel .target_id },
265+ )
232266
233267 def delete (
234268 self ,
@@ -342,3 +376,29 @@ def _node_label(node: LabelledNode) -> str:
342376 if isinstance (node , EntityNode ):
343377 return node .label or "Entity"
344378 return "Node"
379+
380+
381+ def _parse_edge_types_from_schema (schema_text : str ) -> list [str ]:
382+ """Extract edge type names from CoordiNode schema text.
383+
384+ Parses the "Edge types:" section produced by ``get_schema_text()``.
385+ """
386+ edge_types : list [str ] = []
387+ lines = iter (schema_text .splitlines ())
388+
389+ # Advance to the "Edge types:" header.
390+ for line in lines :
391+ if line .strip ().lower ().startswith ("edge types" ):
392+ break
393+
394+ # Collect bullet items until the first blank line.
395+ for line in lines :
396+ stripped = line .strip ()
397+ if not stripped :
398+ break
399+ if stripped .startswith (("-" , "*" )):
400+ name = stripped .lstrip ("-* " ).split ("(" )[0 ].strip ()
401+ if name :
402+ edge_types .append (name )
403+
404+ return edge_types
0 commit comments