Skip to content

Commit 1d3aafa

Browse files
authored
Merge pull request #38 from nitrictech/feature/collection-subcollections
Feature/collection subcollections
2 parents b1fb342 + b59f825 commit 1d3aafa

4 files changed

Lines changed: 159 additions & 11 deletions

File tree

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
1-
# # [START import]
2-
# from nitric.api import Documents
3-
# # [END import]
4-
# async def documents_sub_col_query():
5-
# # [START snippet]
6-
# docs = Documents()
1+
# [START import]
2+
from nitric.api import Documents
3+
# [END import]
4+
async def documents_sub_col_query():
5+
# [START snippet]
6+
docs = Documents()
77

8-
# query = (docs.collection("Customers")
9-
# .collection("Orders")
10-
# .query())
8+
query = (docs.collection("Customers")
9+
.collection("Orders")
10+
.query())
1111

12-
# results = await query.fetch()
13-
# # [END snippet]
12+
results = await query.fetch()
13+
# [END snippet]

nitric/api/documents.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@
3636
)
3737
from nitric.utils import new_default_channel, _dict_from_struct, _struct_from_dict
3838

39+
NIL_DOC_ID = ""
40+
3941

4042
class CollectionDepthException(Exception):
4143
"""The max depth of document sub-collections has been exceeded."""
@@ -147,6 +149,23 @@ def doc(self, doc_id: str) -> DocumentRef:
147149
"""Return a reference to a document in the collection."""
148150
return DocumentRef(_documents=self._documents, parent=self, id=doc_id)
149151

152+
def collection(self, name: str) -> CollectionGroupRef:
153+
"""
154+
Return a reference to a sub-collection of this document.
155+
156+
This is currently only supported to one level of depth.
157+
e.g. Documents().collection('a').collection('b').doc('c') is valid,
158+
Documents().collection('a').doc('b').collection('c').collection('d') is invalid (1 level too deep).
159+
"""
160+
current_depth = self.sub_collection_depth()
161+
if current_depth >= MAX_SUB_COLLECTION_DEPTH:
162+
# Collection nesting is only supported to a maximum depth.
163+
raise CollectionDepthException(
164+
f"sub-collections supported to a depth of {MAX_SUB_COLLECTION_DEPTH}, "
165+
f"attempted to create new collection with depth {current_depth + 1}"
166+
)
167+
return CollectionGroupRef(_documents=self._documents, name=name, parent=self)
168+
150169
def query(
151170
self,
152171
paging_token: Any = None,
@@ -174,6 +193,66 @@ def is_sub_collection(self):
174193
return self.parent is not None
175194

176195

196+
@dataclass(frozen=True, order=True)
197+
class CollectionGroupRef:
198+
"""A reference to a collection group."""
199+
200+
_documents: Documents
201+
name: str
202+
parent: Union[CollectionRef, None] = field(default_factory=lambda: None)
203+
204+
def query(
205+
self,
206+
paging_token: Any = None,
207+
limit: int = 0,
208+
expressions: Union[Expression, List[Expression]] = None,
209+
) -> QueryBuilder:
210+
"""Return a query builder scoped to this collection."""
211+
return QueryBuilder(
212+
documents=self._documents,
213+
collection=self.to_collection_ref(),
214+
paging_token=paging_token,
215+
limit=limit,
216+
expressions=[expressions] if isinstance(expressions, Expression) else expressions,
217+
)
218+
219+
def sub_collection_depth(self) -> int:
220+
"""Return the depth of this collection group, which is a count of the parents above this collection."""
221+
if not self.is_sub_collection():
222+
return 0
223+
else:
224+
return self.parent.sub_collection_depth() + 1
225+
226+
def is_sub_collection(self):
227+
"""Return True if this collection is a sub-collection of a document in another collection."""
228+
return self.parent is not None
229+
230+
def to_collection_ref(self):
231+
"""Return this collection group as a collection ref"""
232+
return CollectionRef(
233+
self._documents,
234+
self.name,
235+
DocumentRef(
236+
self._documents,
237+
self.parent,
238+
NIL_DOC_ID,
239+
),
240+
)
241+
242+
@staticmethod
243+
def from_collection_ref(collectionRef: CollectionRef, documents: Documents) -> CollectionGroupRef:
244+
"""Return a collection ref as a collection group"""
245+
if collectionRef.parent is not None:
246+
return CollectionGroupRef(
247+
documents,
248+
collectionRef.name,
249+
CollectionGroupRef.from_collection_ref(
250+
collectionRef.parent,
251+
documents,
252+
),
253+
)
254+
255+
177256
class Operator(Enum):
178257
"""Valid query expression operators."""
179258

tests/api/test_documents.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,24 @@ async def test_nested_subcollections_fail(self):
200200
"sub-collections supported to a depth of 1, attempted to create new collection with depth 2", str(e.value)
201201
)
202202

203+
def test_build_collection_subcollection(self):
204+
col_ref = Documents().collection("test-collection").collection("test-subcollection")
205+
206+
assert col_ref == col_ref
207+
208+
def test_nested_subcollections_depth(self):
209+
col_ref = Documents().collection("test-collection").collection("test-subcollection")
210+
211+
assert col_ref.sub_collection_depth() == 1
212+
213+
async def test_nested_subcollectiongroup_fail(self):
214+
with pytest.raises(Exception) as e:
215+
Documents().collection("a").doc("b").collection("c").collection("should-fail")
216+
217+
self.assertIn(
218+
"sub-collections supported to a depth of 1, attempted to create new collection with depth 2", str(e.value)
219+
)
220+
203221
async def test_collection_query_fetch(self):
204222
mock_query = AsyncMock()
205223
mock_query.return_value = DocumentQueryResponse(
@@ -241,6 +259,48 @@ async def test_collection_query_fetch(self):
241259
self.assertEqual({"b": "c"}, results.paging_token)
242260
self.assertEqual([{"a": i} for i in range(3)], [doc.content for doc in results.documents])
243261

262+
async def test_subcollection_query_fetch(self):
263+
mock_query = AsyncMock()
264+
mock_query.return_value = DocumentQueryResponse(
265+
documents=[
266+
Document(
267+
content=Struct(fields={"a": Value(number_value=i)}),
268+
key=Key(id="test-doc", collection=Collection(name="a")),
269+
)
270+
for i in range(3)
271+
],
272+
paging_token={"b": "c"},
273+
)
274+
275+
with patch("nitric.proto.nitric.document.v1.DocumentServiceStub.query", mock_query):
276+
results = (
277+
await Documents()
278+
.collection("a")
279+
.collection("b")
280+
.query()
281+
.where("name", "startsWith", "test")
282+
.where("age", ">", 3)
283+
.where("dollar", "<", 2.0)
284+
.where("true", "==", True)
285+
.limit(3)
286+
.fetch()
287+
)
288+
289+
mock_query.assert_called_once_with(
290+
collection=Collection(name="b", parent=Key(Collection(name="a"), id="")),
291+
expressions=[
292+
Expression(operand="name", operator="startsWith", value=ExpressionValue(string_value="test")),
293+
Expression(operand="age", operator=">", value=ExpressionValue(int_value=3)),
294+
Expression(operand="dollar", operator="<", value=ExpressionValue(double_value=2.0)),
295+
Expression(operand="true", operator="==", value=ExpressionValue(bool_value=True)),
296+
],
297+
limit=3,
298+
paging_token=None,
299+
)
300+
301+
self.assertEqual({"b": "c"}, results.paging_token)
302+
self.assertEqual([{"a": i} for i in range(3)], [doc.content for doc in results.documents])
303+
244304
async def test_has_more_pages(self):
245305
page = QueryResultsPage(paging_token="anything", documents=[])
246306
assert page.has_more_pages()

tests/examples/test_documents_example.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from examples.documents.sub_col_query import documents_sub_col_query
12
from nitric.proto.nitric.document.v1 import Collection, DocumentGetResponse, DocumentQueryStreamResponse, Document, Key
23
from examples.documents.set import documents_set
34
from examples.documents.get import documents_get
@@ -96,6 +97,14 @@ async def test_sub_doc_query_document(self):
9697

9798
mock_query.assert_called_once()
9899

100+
async def test_sub_col_query_document(self):
101+
mock_query = AsyncMock()
102+
103+
with patch("nitric.proto.nitric.document.v1.DocumentServiceStub.query", mock_query):
104+
await documents_sub_col_query()
105+
106+
mock_query.assert_called_once()
107+
99108
async def test_streamed_document(self):
100109
stream_calls = 0
101110
call_args = {}

0 commit comments

Comments
 (0)