Skip to content

Commit 19a3b34

Browse files
committed
fix(adapters): raise NotImplementedError for unsupported wildcard patterns
- get_rel_map: query schema edge types, build typed [r:T1|T2|...] pattern instead of wildcard [r] which returns no results in CoordiNode - get_rel_map: raise NotImplementedError when depth != 1 - get_triplets: raise NotImplementedError when relation_names is None - add_graph_documents docstring: clarify edges use unconditional CREATE - _cypher_ident: use \w with re.ASCII instead of [A-Za-z0-9_] - tests: count(*) instead of count(r) — CoordiNode returns 0 for rel vars - tests: add relationship to idempotency test, assert edge count >= 1 - tests: assert depth=2 raises NotImplementedError - tests: fix example port 17080 -> 7080 in docstrings
1 parent f045c77 commit 19a3b34

4 files changed

Lines changed: 104 additions & 39 deletions

File tree

langchain-coordinode/langchain_coordinode/graph.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -91,14 +91,18 @@ def add_graph_documents(
9191
) -> None:
9292
"""Store nodes and relationships extracted from ``GraphDocument`` objects.
9393
94-
Nodes are upserted by ``id`` (used as the ``name`` property).
95-
Relationships are created between existing nodes; if a relationship
96-
between the same source and target already exists it is skipped.
94+
Nodes are upserted by ``id`` (used as the ``name`` property) via
95+
``MERGE``, so repeated calls are safe for nodes.
96+
97+
Relationships are created with unconditional ``CREATE`` because
98+
CoordiNode does not yet support ``MERGE`` for edge patterns. Re-ingesting
99+
the same ``GraphDocument`` will therefore produce duplicate edges.
97100
98101
Args:
99102
graph_documents: List of ``langchain_community.graphs.graph_document.GraphDocument``.
100103
include_source: If ``True``, also store the source ``Document`` as a
101-
``__Document__`` node linked to every extracted entity.
104+
``__Document__`` node linked to every extracted entity via
105+
``MENTIONS`` edges (also unconditional ``CREATE``).
102106
"""
103107
for doc in graph_documents:
104108
# ── Upsert nodes ──────────────────────────────────────────────
@@ -206,8 +210,8 @@ def _stable_document_id(source: Any) -> str:
206210

207211
def _cypher_ident(name: str) -> str:
208212
"""Escape a label/type name for use as a Cypher identifier."""
209-
# If already safe (alphanumeric + underscore, not starting with digit) keep as-is
210-
if re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", name):
213+
# ASCII-only word characters: letter/digit/underscore, not starting with digit.
214+
if re.match(r"^[A-Za-z_]\w*$", name, re.ASCII):
211215
return name
212216
return f"`{name.replace('`', '``')}`"
213217

llama-index-coordinode/llama_index/graph_stores/coordinode/base.py

Lines changed: 58 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -131,12 +131,15 @@ def get_triplets(
131131
rel_filter = "|".join(_cypher_ident(t) for t in relation_names)
132132
rel_pattern = f"[r:{rel_filter}]"
133133
else:
134-
rel_pattern = "[r]"
134+
# CoordiNode: wildcard [r] pattern returns no results.
135+
# Callers must supply relation_names for the query to work.
136+
raise NotImplementedError(
137+
"CoordinodePropertyGraphStore.get_triplets() requires relation_names — "
138+
"CoordiNode does not support untyped wildcard [r] patterns"
139+
)
135140

136141
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
137142
# CoordiNode: use r.__type__ instead of type(r) — type() returns null.
138-
# Wildcard [r] pattern also returns no results; caller must supply
139-
# relation_names for wildcard queries to work.
140143
cypher = (
141144
f"MATCH (n)-{rel_pattern}->(m) {where} "
142145
"RETURN n, r.__type__ AS rel_type, m, n.id AS _src_id, m.id AS _dst_id "
@@ -165,28 +168,41 @@ def get_rel_map(
165168
limit: int = 30,
166169
ignore_rels: list[str] | None = None,
167170
) -> list[list[LabelledNode]]:
168-
"""Get relationship map for a set of nodes up to ``depth`` hops."""
171+
"""Get relationship map for a set of nodes up to ``depth`` hops.
172+
173+
Note: only ``depth=1`` (single hop) is supported. ``depth > 1`` raises
174+
``NotImplementedError`` because CoordiNode does not yet serialise
175+
variable-length path results.
176+
"""
177+
if depth != 1:
178+
raise NotImplementedError(
179+
"CoordinodePropertyGraphStore.get_rel_map() currently supports depth=1 only; "
180+
"variable-length path queries are not yet available in CoordiNode"
181+
)
182+
169183
if not graph_nodes:
170184
return []
171185

172-
node_ids = [n.id for n in graph_nodes]
173-
ignored = list(ignore_rels) if ignore_rels else []
186+
# CoordiNode: wildcard [r] pattern returns no results. Fetch all
187+
# known edge types from the schema and build a typed pattern instead,
188+
# e.g. [r:TYPE_A|TYPE_B|...].
189+
schema_text = self._client.get_schema_text()
190+
edge_types = _parse_edge_types_from_schema(schema_text)
191+
192+
ignored = set(ignore_rels) if ignore_rels else set()
193+
active_types = [t for t in edge_types if t not in ignored]
194+
195+
if not active_types:
196+
return []
174197

198+
rel_filter = "|".join(_cypher_ident(t) for t in active_types)
199+
node_ids = [n.id for n in graph_nodes]
175200
params: dict[str, object] = {"ids": node_ids}
176-
ignore_clause = ""
177-
if ignored:
178-
# Single-hop [r]: filter with r.__type__ NOT IN $ignored_rels.
179-
ignore_clause = " AND NOT r.__type__ IN $ignored_rels"
180-
params["ignored_rels"] = ignored
181-
182-
# CoordiNode does not support variable-length path [r*1..N] in RETURN
183-
# position (result serialisation is undefined for path lists). Use a
184-
# single-hop pattern; multi-hop traversal is a future enhancement.
185-
_ = depth # depth parameter reserved; currently single-hop only
201+
186202
cypher = (
187-
f"MATCH (n)-[r]->(m) "
188-
f"WHERE n.id IN $ids{ignore_clause} "
189-
f"RETURN n, r, m, n.id AS _src_id, m.id AS _dst_id "
203+
f"MATCH (n)-[r:{rel_filter}]->(m) "
204+
f"WHERE n.id IN $ids "
205+
f"RETURN n, r.__type__ AS _rel_type, m, n.id AS _src_id, m.id AS _dst_id "
190206
f"LIMIT {limit}"
191207
)
192208
result = self._client.cypher(cypher, params=params)
@@ -197,13 +213,7 @@ def get_rel_map(
197213
dst_data = row.get("m", {})
198214
src_id = str(row.get("_src_id", ""))
199215
dst_id = str(row.get("_dst_id", ""))
200-
# Single-hop [r] returns the relationship as a dict.
201-
# CoordiNode: use __type__ key — type() returns null.
202-
r_val = row.get("r", {})
203-
if isinstance(r_val, dict):
204-
rel_label = r_val.get("__type__") or r_val.get("type") or "RELATED"
205-
else:
206-
rel_label = "RELATED"
216+
rel_label = str(row.get("_rel_type") or "RELATED")
207217
src = _node_result_to_labelled(src_id, src_data)
208218
dst = _node_result_to_labelled(dst_id, dst_data)
209219
rel = Relation(label=rel_label, source_id=src_id, target_id=dst_id)
@@ -357,3 +367,25 @@ def _node_label(node: LabelledNode) -> str:
357367
if isinstance(node, EntityNode):
358368
return node.label or "Entity"
359369
return "Node"
370+
371+
372+
def _parse_edge_types_from_schema(schema_text: str) -> list[str]:
373+
"""Extract edge type names from CoordiNode schema text.
374+
375+
Parses the "Edge types:" section produced by ``get_schema_text()``.
376+
"""
377+
edge_types: list[str] = []
378+
in_edges = False
379+
for line in schema_text.splitlines():
380+
stripped = line.strip()
381+
if stripped.lower().startswith("edge types"):
382+
in_edges = True
383+
continue
384+
if in_edges:
385+
if not stripped:
386+
break
387+
if stripped.startswith("-") or stripped.startswith("*"):
388+
name = stripped.lstrip("-* ").split("(")[0].strip()
389+
if name:
390+
edge_types.append(name)
391+
return edge_types

tests/integration/adapters/test_langchain.py

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
(default: localhost:7080).
55
66
Run via:
7-
COORDINODE_ADDR=localhost:17080 pytest tests/integration/adapters/test_langchain.py -v
7+
COORDINODE_ADDR=localhost:7080 pytest tests/integration/adapters/test_langchain.py -v
88
"""
99

1010
import os
@@ -97,27 +97,47 @@ def test_add_graph_documents_creates_relationship(graph, unique_tag):
9797
graph.add_graph_documents([doc])
9898

9999
# Verify the relationship was created, not just the source node.
100+
# count(*) instead of count(r): CoordiNode returns 0 for relationship-variable counts
100101
result = graph.query(
101-
"MATCH (a:LCPerson2 {name: $src})-[r:LC_RESEARCHES]->(b:LCConcept {name: $dst}) RETURN count(r) AS cnt",
102+
"MATCH (a:LCPerson2 {name: $src})-[r:LC_RESEARCHES]->(b:LCConcept {name: $dst}) RETURN count(*) AS cnt",
102103
params={"src": f"Charlie-{unique_tag}", "dst": f"GraphRAG-{unique_tag}"},
103104
)
104105
assert result[0]["cnt"] >= 1, f"relationship not found: {result}"
105106

106107

107108
def test_add_graph_documents_idempotent(graph, unique_tag):
108-
"""Calling add_graph_documents twice must not raise."""
109-
node = Node(id=f"Idempotent-{unique_tag}", type="LCIdempotent")
110-
doc = GraphDocument(nodes=[node], relationships=[], source=Document(page_content="test"))
109+
"""Calling add_graph_documents twice must not raise.
110+
111+
Nodes are idempotent (MERGE). Edges are NOT — CoordiNode does not yet
112+
support MERGE for edges, so unconditional CREATE is used and duplicate
113+
edges are expected after two ingests.
114+
"""
115+
node_a = Node(id=f"Idempotent-{unique_tag}", type="LCIdempotent")
116+
node_b = Node(id=f"IdempTarget-{unique_tag}", type="LCIdempotent")
117+
rel = Relationship(source=node_a, target=node_b, type="LC_IDEMP_REL")
118+
doc = GraphDocument(
119+
nodes=[node_a, node_b],
120+
relationships=[rel],
121+
source=Document(page_content="test"),
122+
)
111123

112124
graph.add_graph_documents([doc])
113125
graph.add_graph_documents([doc]) # second call must not raise
114126

127+
# Nodes: MERGE keeps count at 1
115128
result = graph.query(
116-
"MATCH (n:LCIdempotent {name: $name}) RETURN count(n) AS cnt",
129+
"MATCH (n:LCIdempotent {name: $name}) RETURN count(*) AS cnt",
117130
params={"name": f"Idempotent-{unique_tag}"},
118131
)
119132
assert result[0]["cnt"] == 1
120133

134+
# Edges: unconditional CREATE → count >= 1 (may be > 1 due to CoordiNode limitation)
135+
result = graph.query(
136+
"MATCH (a:LCIdempotent {name: $src})-[r:LC_IDEMP_REL]->(b:LCIdempotent {name: $dst}) RETURN count(*) AS cnt",
137+
params={"src": f"Idempotent-{unique_tag}", "dst": f"IdempTarget-{unique_tag}"},
138+
)
139+
assert result[0]["cnt"] >= 1
140+
121141

122142
def test_schema_refreshes_after_add(graph, unique_tag):
123143
"""structured_schema is invalidated and re-fetched after add_graph_documents."""

tests/integration/adapters/test_llama_index.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
(default: localhost:7080).
55
66
Run via:
7-
COORDINODE_ADDR=localhost:17080 pytest tests/integration/adapters/test_llama_index.py -v
7+
COORDINODE_ADDR=localhost:7080 pytest tests/integration/adapters/test_llama_index.py -v
88
"""
99

1010
import os
@@ -122,6 +122,15 @@ def test_get_rel_map(store, tag):
122122
assert len(result) >= 1
123123

124124

125+
def test_get_rel_map_depth_gt1_raises(store, tag):
126+
"""depth > 1 must raise NotImplementedError until multi-hop is supported."""
127+
node = EntityNode(label="LIRelMapDepth", name=f"DepthNode-{tag}")
128+
store.upsert_nodes([node])
129+
130+
with pytest.raises(NotImplementedError):
131+
store.get_rel_map([node], depth=2, limit=10)
132+
133+
125134
# ── Delete ────────────────────────────────────────────────────────────────────
126135

127136

0 commit comments

Comments
 (0)