Skip to content

Commit 4aa90b4

Browse files
committed
feat(nanodb): Implement update_one method for DocumentCollection
resolves #68
1 parent 3c0b4a7 commit 4aa90b4

File tree

5 files changed

+280
-11
lines changed

5 files changed

+280
-11
lines changed

packages/nanodb/src/flux0_nanodb/api.py

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
11
from __future__ import annotations
22

33
from abc import ABC, abstractmethod
4-
from typing import Generic, Mapping, Optional, Sequence, Tuple, Type
4+
from typing import Generic, List, Mapping, Optional, Sequence, Tuple, Type
55

66
from flux0_nanodb.projection import Projection
77
from flux0_nanodb.query import QueryFilter
8-
from flux0_nanodb.types import DeleteResult, InsertOneResult, SortingOrder, TDocument
8+
from flux0_nanodb.types import (
9+
DeleteResult,
10+
InsertOneResult,
11+
JSONPatchOperation,
12+
SortingOrder,
13+
TDocument,
14+
UpdateOneResult,
15+
)
916

1017

1118
class DocumentDatabase(ABC):
@@ -64,6 +71,24 @@ async def insert_one(self, document: TDocument) -> InsertOneResult:
6471
"""
6572
pass
6673

74+
@abstractmethod
75+
async def update_one(
76+
self, filters: QueryFilter, patch: List[JSONPatchOperation], upsert: bool = False
77+
) -> UpdateOneResult:
78+
"""
79+
Apply a JSON Patch (RFC 6902) to a single document that matches the provided filters.
80+
If upsert is True and no document matches, insert a new document.
81+
82+
Parameters:
83+
filters (QueryFilter): Query to match a document.
84+
patch (List[JSONPatchOperation]): JSON Patch operations in a type-safe structured format.
85+
upsert (bool): If True, insert a new document if no match is found.
86+
87+
Returns:
88+
UpdateOneResult: Metadata about the update operation.
89+
"""
90+
pass
91+
6792
@abstractmethod
6893
async def delete_one(self, filters: QueryFilter) -> DeleteResult[TDocument]:
6994
"""
Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,28 @@
1-
from typing import Any, Mapping, Type, get_type_hints
1+
from typing import Any, Dict, List, Mapping, Type, get_type_hints
2+
3+
from flux0_nanodb.types import JSONPatchOperation
24

35

46
def validate_is_total(document: Mapping[str, Any], schema: Type[Mapping[str, Any]]) -> None:
5-
required_keys = get_type_hints(schema).keys()
7+
# Use the __required_keys__ attribute if it exists, otherwise fall back to all type hints.
8+
required_keys = getattr(schema, "__required_keys__", get_type_hints(schema).keys())
69
missing_keys = [key for key in required_keys if key not in document]
710

811
if missing_keys:
912
raise TypeError(
1013
f"TypedDict '{schema.__qualname__}' is missing required keys: {missing_keys}. "
1114
f"Expected at least the keys: {list(required_keys)}."
1215
)
16+
17+
18+
def convert_patch(patch: List[JSONPatchOperation]) -> List[Dict[str, Any]]:
19+
converted = []
20+
for op in patch:
21+
# Create a shallow copy to avoid mutating the original.
22+
op_copy = dict(op)
23+
# For move and copy operations, rename "from_" to "from" as expected by jsonpatch.
24+
if op_copy.get("op") in ("move", "copy"):
25+
if "from_" in op_copy:
26+
op_copy["from"] = op_copy.pop("from_")
27+
converted.append(op_copy)
28+
return converted

packages/nanodb/src/flux0_nanodb/memory.py

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
1-
from typing import Any, Mapping, Optional, Protocol, Sequence, Tuple, Type, cast
1+
from typing import Any, List, Mapping, Optional, Protocol, Sequence, Tuple, Type, cast
2+
3+
import jsonpatch
24

35
from flux0_nanodb.api import DocumentCollection, DocumentDatabase
4-
from flux0_nanodb.common import validate_is_total
6+
from flux0_nanodb.common import convert_patch, validate_is_total
57
from flux0_nanodb.projection import Projection, apply_projection
68
from flux0_nanodb.query import QueryFilter, matches_query
79
from flux0_nanodb.types import (
810
DeleteResult,
911
DocumentID,
1012
InsertOneResult,
13+
JSONPatchOperation,
1114
SortingOrder,
1215
TDocument,
16+
UpdateOneResult,
1317
)
1418

1519

@@ -73,6 +77,39 @@ async def insert_one(self, document: TDocument) -> InsertOneResult:
7377
raise ValueError("Document is missing an 'id' field")
7478
return InsertOneResult(acknowledged=True, inserted_id=inserted_id)
7579

80+
async def update_one(
81+
self, filters: QueryFilter, patch: List[JSONPatchOperation], upsert: bool = False
82+
) -> UpdateOneResult:
83+
standard_patch = convert_patch(patch)
84+
# Look for an existing document matching the filters.
85+
for i, doc in enumerate(self._documents):
86+
if matches_query(filters, doc):
87+
try:
88+
updated_doc = jsonpatch.apply_patch(doc, standard_patch, in_place=False)
89+
except jsonpatch.JsonPatchException as e:
90+
raise ValueError("Invalid JSON patch") from e
91+
# validate_is_total(updated_doc, self._schema)
92+
self._documents[i] = cast(TDocument, updated_doc)
93+
return UpdateOneResult(
94+
acknowledged=True, matched_count=1, modified_count=1, upserted_id=None
95+
)
96+
# No matching document found.
97+
if upsert:
98+
try:
99+
new_doc = jsonpatch.apply_patch({}, standard_patch, in_place=False)
100+
except jsonpatch.JsonPatchException as e:
101+
raise ValueError("Invalid JSON patch for upsert") from e
102+
if "id" not in new_doc:
103+
raise ValueError("Upserted document is missing an 'id' field")
104+
validate_is_total(new_doc, self._schema)
105+
self._documents.append(cast(TDocument, new_doc))
106+
return UpdateOneResult(
107+
acknowledged=True, matched_count=0, modified_count=0, upserted_id=new_doc["id"]
108+
)
109+
return UpdateOneResult(
110+
acknowledged=True, matched_count=0, modified_count=0, upserted_id=None
111+
)
112+
76113
async def delete_one(self, filters: QueryFilter) -> DeleteResult[TDocument]:
77114
for i, doc in enumerate(self._documents):
78115
if matches_query(filters, doc):

packages/nanodb/src/flux0_nanodb/types.py

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from dataclasses import dataclass
22
from enum import Enum
3-
from typing import Generic, NewType, Optional, TypedDict, TypeVar
3+
from typing import Any, Generic, Literal, NewType, Optional, TypedDict, TypeVar, Union
44

55
DocumentID = NewType("DocumentID", str)
66
DocumentVersion = NewType("DocumentVersion", str)
@@ -26,9 +26,57 @@ class InsertOneResult:
2626
inserted_id: DocumentID # Mimicking MongoDB’s inserted_id field.
2727

2828

29+
@dataclass(frozen=True)
30+
class UpdateOneResult:
31+
acknowledged: bool
32+
matched_count: int
33+
modified_count: int
34+
upserted_id: Optional[DocumentID]
35+
36+
2937
# MongoDB-like result structure for delete operations.
3038
@dataclass(frozen=True)
3139
class DeleteResult(Generic[TDocument]):
3240
acknowledged: bool
3341
deleted_count: int
3442
deleted_document: Optional[TDocument]
43+
44+
45+
# Define a type-safe JSON Patch operation
46+
class AddOp(TypedDict):
47+
op: Literal["add"]
48+
path: str
49+
value: Any
50+
51+
52+
class RemoveOp(TypedDict):
53+
op: Literal["remove"]
54+
path: str
55+
56+
57+
class ReplaceOp(TypedDict):
58+
op: Literal["replace"]
59+
path: str
60+
value: Any
61+
62+
63+
class MoveOp(TypedDict):
64+
op: Literal["move"]
65+
from_: str # note: using from_ instead of 'from' because it's a keyword
66+
path: str
67+
68+
69+
class CopyOp(TypedDict):
70+
op: Literal["copy"]
71+
from_: str
72+
path: str
73+
74+
75+
class TestOp(TypedDict):
76+
op: Literal["test"]
77+
path: str
78+
value: Any
79+
80+
81+
# Union type for JSON patch operations.
82+
JSONPatchOperation = Union[AddOp, RemoveOp, ReplaceOp, MoveOp, CopyOp, TestOp]

0 commit comments

Comments
 (0)