Skip to content

Commit b6e4cf9

Browse files
committed
Add WOQL collect predicate
1 parent 82f0319 commit b6e4cf9

2 files changed

Lines changed: 170 additions & 0 deletions

File tree

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
"""
2+
Integration tests for WOQL Collect predicate.
3+
4+
Collect gathers all solutions from a sub-query into a list,
5+
completing the list/binding symmetry alongside Member:
6+
- Member: List -> Bindings (destructure)
7+
- Collect: Bindings -> List (gather)
8+
"""
9+
10+
import pytest
11+
12+
from terminusdb_client import Client
13+
from terminusdb_client.woqlquery.woql_query import WOQLQuery
14+
15+
test_user_agent = "terminusdb-client-python-tests"
16+
17+
18+
def extract_values(result_list):
19+
"""Extract raw values from a list of typed literals."""
20+
if not result_list:
21+
return []
22+
return [
23+
item["@value"] if isinstance(item, dict) and "@value" in item else item
24+
for item in result_list
25+
]
26+
27+
28+
class TestWOQLCollect:
29+
"""Tests for the WOQL Collect predicate."""
30+
31+
@pytest.fixture(autouse=True)
32+
def setup_teardown(self, docker_url):
33+
"""Setup and teardown for each test."""
34+
self.client = Client(docker_url, user_agent=test_user_agent)
35+
self.client.connect()
36+
self.db_name = "test_woql_collect"
37+
38+
# Create database for tests
39+
if self.db_name in self.client.list_databases():
40+
self.client.delete_database(self.db_name)
41+
self.client.create_database(self.db_name)
42+
43+
# Add schema
44+
self.client.insert_document(
45+
[
46+
{
47+
"@type": "@context",
48+
"@base": "terminusdb:///data/",
49+
"@schema": "terminusdb:///schema#",
50+
},
51+
{
52+
"@id": "NamedThing",
53+
"@type": "Class",
54+
"@key": {"@type": "Lexical", "@fields": ["name"]},
55+
"name": "xsd:string",
56+
},
57+
],
58+
graph_type="schema",
59+
full_replace=True,
60+
)
61+
62+
# Insert test documents
63+
self.client.insert_document(
64+
[
65+
{"@type": "NamedThing", "name": "Alice"},
66+
{"@type": "NamedThing", "name": "Bob"},
67+
{"@type": "NamedThing", "name": "Carol"},
68+
]
69+
)
70+
71+
yield
72+
73+
# Cleanup
74+
self.client.delete_database(self.db_name)
75+
76+
def test_collect_triple_objects_into_list(self):
77+
"""Collect gathers all matching triple objects into a single list."""
78+
query = WOQLQuery().collect(
79+
"v:name",
80+
"v:names",
81+
WOQLQuery().triple("v:doc", "name", "v:name"),
82+
)
83+
84+
result = self.client.query(query)
85+
assert len(result["bindings"]) == 1
86+
names = sorted(extract_values(result["bindings"][0]["names"]))
87+
assert names == ["Alice", "Bob", "Carol"]
88+
89+
def test_collect_empty_result(self):
90+
"""Collect produces empty list when sub-query has no solutions."""
91+
query = WOQLQuery().collect(
92+
"v:x",
93+
"v:collected",
94+
WOQLQuery().triple("v:doc", "nonexistent_property", "v:x"),
95+
)
96+
97+
result = self.client.query(query)
98+
assert len(result["bindings"]) == 1
99+
assert result["bindings"][0]["collected"] == []
100+
101+
def test_collect_composes_with_length(self):
102+
"""Collect result can be used with length to count solutions."""
103+
query = WOQLQuery().woql_and(
104+
WOQLQuery().collect(
105+
"v:name",
106+
"v:names",
107+
WOQLQuery().triple("v:doc", "name", "v:name"),
108+
),
109+
WOQLQuery().length("v:names", "v:count"),
110+
)
111+
112+
result = self.client.query(query)
113+
assert len(result["bindings"]) == 1
114+
assert result["bindings"][0]["count"]["@value"] == 3
115+
116+
def test_collect_with_limit_in_subquery(self):
117+
"""Collect respects limit inside the sub-query."""
118+
query = WOQLQuery().collect(
119+
"v:name",
120+
"v:names",
121+
WOQLQuery().limit(2, WOQLQuery().triple("v:doc", "name", "v:name")),
122+
)
123+
124+
result = self.client.query(query)
125+
assert len(result["bindings"]) == 1
126+
assert len(result["bindings"][0]["names"]) == 2
127+
128+
def test_collect_with_list_template(self):
129+
"""Collect with multi-element list template produces nested lists."""
130+
query = WOQLQuery().collect(
131+
["v:doc", "v:name"],
132+
"v:pairs",
133+
WOQLQuery().triple("v:doc", "name", "v:name"),
134+
)
135+
136+
result = self.client.query(query)
137+
assert len(result["bindings"]) == 1
138+
pairs = result["bindings"][0]["pairs"]
139+
assert len(pairs) == 3
140+
for pair in pairs:
141+
assert isinstance(pair, list)
142+
assert len(pair) == 2

terminusdb_client/woqlquery/woql_query.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3105,6 +3105,34 @@ def group_by(self, group_vars, template, output, groupquery=None):
31053105
self._cursor["grouped"] = self._clean_object(output)
31063106
return self._add_sub_query(groupquery)
31073107

3108+
def collect(self, template, into, query=None):
3109+
"""Collects all solutions of a sub-query into a list.
3110+
3111+
Completes the list/binding symmetry alongside member:
3112+
- Member: List -> Bindings (destructure)
3113+
- Collect: Bindings -> List (gather)
3114+
3115+
Parameters
3116+
----------
3117+
template : str or list
3118+
A variable or list of variables specifying what to collect from each solution
3119+
into : str
3120+
Variable that will be bound to the collected list
3121+
query : WOQLQuery, optional
3122+
The query whose solutions will be collected
3123+
3124+
Returns
3125+
-------
3126+
WOQLQuery object
3127+
query object that can be chained and/or execute
3128+
"""
3129+
if self._cursor.get("@type"):
3130+
self._wrap_cursor_with_and()
3131+
self._cursor["@type"] = "Collect"
3132+
self._cursor["template"] = self._clean_object(template)
3133+
self._cursor["into"] = self._clean_object(into)
3134+
return self._add_sub_query(query)
3135+
31083136
def true(self):
31093137
"""Sets true for cursor type.
31103138

0 commit comments

Comments
 (0)