hexdb 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hexdb-1.0.0/PKG-INFO +16 -0
- hexdb-1.0.0/README.md +3 -0
- hexdb-1.0.0/hexdb/__init__.py +295 -0
- hexdb-1.0.0/hexdb.egg-info/PKG-INFO +16 -0
- hexdb-1.0.0/hexdb.egg-info/SOURCES.txt +8 -0
- hexdb-1.0.0/hexdb.egg-info/dependency_links.txt +1 -0
- hexdb-1.0.0/hexdb.egg-info/top_level.txt +1 -0
- hexdb-1.0.0/pyproject.toml +22 -0
- hexdb-1.0.0/setup.cfg +4 -0
- hexdb-1.0.0/tests/test_client.py +102 -0
hexdb-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hexdb
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Client for HexDB, the hexagonal document database.
|
|
5
|
+
Author: Dream In Hex
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://hexdb-website.vercel.app
|
|
8
|
+
Project-URL: Source, https://github.com/dreaminhex/hexdb
|
|
9
|
+
Project-URL: Issues, https://github.com/dreaminhex/hexdb/issues
|
|
10
|
+
Keywords: hexdb,database,document,nosql,client
|
|
11
|
+
Requires-Python: >=3.9
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# hexdb (Python)
|
|
15
|
+
|
|
16
|
+
Client for HexDB. Python 3.9+, standard library only. See ../README.md and the HexDB manual.
|
hexdb-1.0.0/README.md
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""HexDB client for Python 3.9+. Standard library only.
|
|
2
|
+
|
|
3
|
+
from hexdb import HexDB
|
|
4
|
+
|
|
5
|
+
db = HexDB("http://127.0.0.1:7700", api_key=os.environ["HEXDB_API_KEY"])
|
|
6
|
+
orders = db.tessellation("orders")
|
|
7
|
+
doc = orders.insert({"customer": "ada", "total": 12})
|
|
8
|
+
page = orders.query(filter={"total": {"$gt": 10}}, sort="-total")
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import ssl
|
|
15
|
+
import time
|
|
16
|
+
import urllib.error
|
|
17
|
+
import urllib.parse
|
|
18
|
+
import urllib.request
|
|
19
|
+
from typing import Any, Callable, Dict, Iterator, List, Optional, Sequence, Union
|
|
20
|
+
|
|
21
|
+
__all__ = ["HexDB", "HexDBError", "Tessellation", "Stream"]
|
|
22
|
+
__version__ = "1.0.0"
|
|
23
|
+
|
|
24
|
+
Json = Any
|
|
25
|
+
Document = Dict[str, Any]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class HexDBError(Exception):
|
|
29
|
+
"""An error answer from HexDB: `status` is the HTTP status, `code` HexDB's error code."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, status: int, code: str, message: str, retry_after: Optional[float] = None):
|
|
32
|
+
super().__init__(message)
|
|
33
|
+
self.status = status
|
|
34
|
+
self.code = code
|
|
35
|
+
self.message = message
|
|
36
|
+
self.retry_after = retry_after
|
|
37
|
+
|
|
38
|
+
def __repr__(self) -> str:
|
|
39
|
+
return f"HexDBError({self.status}, {self.code!r}, {self.message!r})"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _quote(text: str) -> str:
|
|
43
|
+
return urllib.parse.quote(text, safe="")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class HexDB:
|
|
47
|
+
"""A connection to a HexDB server (any hex; replicas forward writes)."""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
url: str,
|
|
52
|
+
api_key: Optional[str] = None,
|
|
53
|
+
*,
|
|
54
|
+
retries: int = 3,
|
|
55
|
+
timeout: float = 30.0,
|
|
56
|
+
ca_file: Optional[str] = None,
|
|
57
|
+
):
|
|
58
|
+
self.base = url.rstrip("/")
|
|
59
|
+
self.token = api_key
|
|
60
|
+
self.retries = retries
|
|
61
|
+
self.timeout = timeout
|
|
62
|
+
self._context = ssl.create_default_context(cafile=ca_file) if ca_file else None
|
|
63
|
+
|
|
64
|
+
# -- transport ---------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
def request(self, method: str, path: str, body: Json = None, headers: Optional[Dict[str, str]] = None, timeout: Optional[float] = None) -> Json:
|
|
67
|
+
"""Send a request to the REST API; returns the parsed JSON (None for 204)."""
|
|
68
|
+
headers = dict(headers or {})
|
|
69
|
+
data = None
|
|
70
|
+
if body is not None:
|
|
71
|
+
data = json.dumps(body).encode("utf-8")
|
|
72
|
+
headers["Content-Type"] = "application/json"
|
|
73
|
+
headers["Accept"] = "application/json"
|
|
74
|
+
if self.token:
|
|
75
|
+
headers["Authorization"] = f"Bearer {self.token}"
|
|
76
|
+
attempt = 0
|
|
77
|
+
while True:
|
|
78
|
+
req = urllib.request.Request(self.base + path, data=data, method=method, headers=headers)
|
|
79
|
+
try:
|
|
80
|
+
with urllib.request.urlopen(req, timeout=timeout or self.timeout, context=self._context) as response:
|
|
81
|
+
if response.status == 204:
|
|
82
|
+
return None
|
|
83
|
+
text = response.read().decode("utf-8")
|
|
84
|
+
return json.loads(text) if text else None
|
|
85
|
+
except urllib.error.HTTPError as e:
|
|
86
|
+
text = e.read().decode("utf-8", "replace")
|
|
87
|
+
e.close()
|
|
88
|
+
retry_after = float(e.headers.get("Retry-After") or 0) or None
|
|
89
|
+
retryable = e.code in (429, 503) and (method == "GET" or "Idempotency-Key" in headers)
|
|
90
|
+
if retryable and attempt < self.retries:
|
|
91
|
+
time.sleep(retry_after or 2**attempt)
|
|
92
|
+
attempt += 1
|
|
93
|
+
continue
|
|
94
|
+
try:
|
|
95
|
+
error = json.loads(text).get("error", {})
|
|
96
|
+
except ValueError:
|
|
97
|
+
error = {}
|
|
98
|
+
raise HexDBError(e.code, error.get("code", "error"), error.get("message", text or e.reason), retry_after) from None
|
|
99
|
+
except urllib.error.URLError as e:
|
|
100
|
+
raise HexDBError(0, "unreachable", f"Couldn't reach HexDB at {self.base}: {e.reason}") from None
|
|
101
|
+
|
|
102
|
+
# -- session -----------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
def login(self, login: str, password: str, code: Optional[str] = None) -> Dict[str, Any]:
|
|
105
|
+
"""Sign in (with a one-time code if MFA is on); later requests use the session."""
|
|
106
|
+
body: Dict[str, Any] = {"login": login, "password": password, "return_token": True}
|
|
107
|
+
if code:
|
|
108
|
+
body["code"] = code
|
|
109
|
+
result = self.request("POST", "/auth/login", body)
|
|
110
|
+
self.token = result["token"]
|
|
111
|
+
return {"user": result["user"], "expires_at": result["expires_at"]}
|
|
112
|
+
|
|
113
|
+
def logout(self) -> None:
|
|
114
|
+
self.request("POST", "/auth/logout")
|
|
115
|
+
self.token = None
|
|
116
|
+
|
|
117
|
+
# -- server ------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
def health(self) -> Dict[str, Any]:
|
|
120
|
+
return self.request("GET", "/health")
|
|
121
|
+
|
|
122
|
+
def status(self) -> Dict[str, Any]:
|
|
123
|
+
return self.request("GET", "/status")
|
|
124
|
+
|
|
125
|
+
def tessellation(self, name: str) -> "Tessellation":
|
|
126
|
+
return Tessellation(self, name)
|
|
127
|
+
|
|
128
|
+
def tessellations(self) -> List[Dict[str, Any]]:
|
|
129
|
+
return self.request("GET", "/tessellations")["tessellations"]
|
|
130
|
+
|
|
131
|
+
def transaction(self, operations: List[Dict[str, Any]], idempotency_key: Optional[str] = None) -> Dict[str, Any]:
|
|
132
|
+
"""Operations across tessellations, all or nothing."""
|
|
133
|
+
return self.request("POST", "/transactions", {"operations": operations}, _idem(idempotency_key))
|
|
134
|
+
|
|
135
|
+
def graphql(self, query: str, variables: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
136
|
+
"""Run a GraphQL query or mutation; raises HexDBError on GraphQL errors."""
|
|
137
|
+
result = self.request("POST", "/graphql", {"query": query, "variables": variables or {}})
|
|
138
|
+
if result.get("errors"):
|
|
139
|
+
first = result["errors"][0]
|
|
140
|
+
raise HexDBError(200, first.get("extensions", {}).get("code", "GRAPHQL"), first["message"])
|
|
141
|
+
return result["data"]
|
|
142
|
+
|
|
143
|
+
def changes(self, after: Optional[int] = None, tessellation: Optional[str] = None, stop: Optional[Callable[[], bool]] = None) -> Iterator[Dict[str, Any]]:
|
|
144
|
+
"""Committed changes after `after` (default: from now), as they happen (long polling)."""
|
|
145
|
+
cursor = after
|
|
146
|
+
while not (stop and stop()):
|
|
147
|
+
params = {"wait": "30", "limit": "500"}
|
|
148
|
+
if cursor is not None:
|
|
149
|
+
params["after"] = str(cursor)
|
|
150
|
+
if tessellation:
|
|
151
|
+
params["tessellation"] = tessellation
|
|
152
|
+
page = self.request("GET", "/changes?" + urllib.parse.urlencode(params), timeout=60)
|
|
153
|
+
cursor = page["last_seq"]
|
|
154
|
+
yield from page["changes"]
|
|
155
|
+
|
|
156
|
+
def stream(self, name: str) -> "Stream":
|
|
157
|
+
return Stream(self, name)
|
|
158
|
+
|
|
159
|
+
def run_function(self, name: str, params: Optional[Dict[str, Any]] = None) -> Json:
|
|
160
|
+
"""Run a saved function; returns its result."""
|
|
161
|
+
return self.request("POST", f"/functions/{_quote(name)}/run", {"params": params or {}})["result"]
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _idem(key: Optional[str]) -> Dict[str, str]:
|
|
165
|
+
return {"Idempotency-Key": key} if key else {}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _ttl(ttl_seconds: Optional[int]) -> str:
|
|
169
|
+
return f"?ttl={int(ttl_seconds)}" if ttl_seconds else ""
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class Tessellation:
|
|
173
|
+
"""A collection of documents."""
|
|
174
|
+
|
|
175
|
+
def __init__(self, db: HexDB, name: str):
|
|
176
|
+
self.db = db
|
|
177
|
+
self.name = name
|
|
178
|
+
self.path = "/" + _quote(name)
|
|
179
|
+
|
|
180
|
+
def insert(self, doc: Document, *, ttl_seconds: Optional[int] = None, idempotency_key: Optional[str] = None) -> Document:
|
|
181
|
+
return self.db.request("POST", self.path + _ttl(ttl_seconds), doc, _idem(idempotency_key))
|
|
182
|
+
|
|
183
|
+
def insert_many(self, docs: Sequence[Document], *, ttl_seconds: Optional[int] = None, idempotency_key: Optional[str] = None) -> List[str]:
|
|
184
|
+
"""Insert up to 1000 documents atomically; returns their IDs in order."""
|
|
185
|
+
return self.db.request("POST", f"{self.path}/_bulk{_ttl(ttl_seconds)}", list(docs), _idem(idempotency_key))["ids"]
|
|
186
|
+
|
|
187
|
+
def get(self, id: str, fields: Optional[Sequence[str]] = None) -> Optional[Document]:
|
|
188
|
+
query = "?fields=" + _quote(",".join(fields)) if fields else ""
|
|
189
|
+
try:
|
|
190
|
+
return self.db.request("GET", f"{self.path}/{_quote(id)}{query}")
|
|
191
|
+
except HexDBError as e:
|
|
192
|
+
if e.status == 404:
|
|
193
|
+
return None
|
|
194
|
+
raise
|
|
195
|
+
|
|
196
|
+
def replace(self, id: str, doc: Document, *, ttl_seconds: Optional[int] = None, idempotency_key: Optional[str] = None) -> Document:
|
|
197
|
+
return self.db.request("PUT", f"{self.path}/{_quote(id)}{_ttl(ttl_seconds)}", doc, _idem(idempotency_key))
|
|
198
|
+
|
|
199
|
+
def patch(self, id: str, changes: Document, *, ttl_seconds: Optional[int] = None, idempotency_key: Optional[str] = None) -> Document:
|
|
200
|
+
"""Merge fields into a document (None removes a field)."""
|
|
201
|
+
return self.db.request("PATCH", f"{self.path}/{_quote(id)}{_ttl(ttl_seconds)}", changes, _idem(idempotency_key))
|
|
202
|
+
|
|
203
|
+
def delete(self, id: str, *, idempotency_key: Optional[str] = None) -> bool:
|
|
204
|
+
try:
|
|
205
|
+
self.db.request("DELETE", f"{self.path}/{_quote(id)}", headers=_idem(idempotency_key))
|
|
206
|
+
return True
|
|
207
|
+
except HexDBError as e:
|
|
208
|
+
if e.status == 404:
|
|
209
|
+
return False
|
|
210
|
+
raise
|
|
211
|
+
|
|
212
|
+
def query(
|
|
213
|
+
self,
|
|
214
|
+
filter: Optional[Dict[str, Any]] = None,
|
|
215
|
+
sort: Union[str, List[Dict[str, Any]], None] = None,
|
|
216
|
+
limit: int = 100,
|
|
217
|
+
offset: int = 0,
|
|
218
|
+
after: Optional[str] = None,
|
|
219
|
+
fields: Optional[Sequence[str]] = None,
|
|
220
|
+
) -> Dict[str, Any]:
|
|
221
|
+
"""One page: {"documents", "total", "next", "plan"}."""
|
|
222
|
+
body: Dict[str, Any] = {"limit": limit, "offset": offset}
|
|
223
|
+
for key, value in (("filter", filter), ("sort", sort), ("after", after), ("fields", list(fields) if fields else None)):
|
|
224
|
+
if value is not None:
|
|
225
|
+
body[key] = value
|
|
226
|
+
return self.db.request("POST", f"{self.path}/_query", body)
|
|
227
|
+
|
|
228
|
+
def iterate(self, filter: Optional[Dict[str, Any]] = None, page_size: int = 500, fields: Optional[Sequence[str]] = None) -> Iterator[Document]:
|
|
229
|
+
"""Every matching document, page by page, in ID order."""
|
|
230
|
+
after = None
|
|
231
|
+
while True:
|
|
232
|
+
page = self.query(filter=filter, limit=page_size, after=after, fields=fields)
|
|
233
|
+
yield from page["documents"]
|
|
234
|
+
if not page.get("next"):
|
|
235
|
+
return
|
|
236
|
+
after = page["next"]
|
|
237
|
+
|
|
238
|
+
def count(self, filter: Optional[Dict[str, Any]] = None) -> int:
|
|
239
|
+
query = "?filter=" + _quote(json.dumps(filter)) if filter else ""
|
|
240
|
+
return self.db.request("GET", f"{self.path}/count{query}")["count"]
|
|
241
|
+
|
|
242
|
+
def aggregate(self, group_by: Optional[List[str]] = None, aggregates: Optional[Dict[str, Any]] = None, **options: Any) -> Dict[str, Any]:
|
|
243
|
+
"""Group and summarize: aggregate(["status"], {"total": {"$sum": "amount"}}, filter=...)."""
|
|
244
|
+
body = dict(options)
|
|
245
|
+
if group_by is not None:
|
|
246
|
+
body["group_by"] = group_by
|
|
247
|
+
if aggregates is not None:
|
|
248
|
+
body["aggregates"] = aggregates
|
|
249
|
+
return self.db.request("POST", f"{self.path}/_aggregate", body)
|
|
250
|
+
|
|
251
|
+
def upsert(self, key: Union[str, Sequence[str]], docs: Sequence[Document], *, idempotency_key: Optional[str] = None) -> Dict[str, Any]:
|
|
252
|
+
"""Insert or replace documents matched by key fields."""
|
|
253
|
+
return self.db.request("POST", f"{self.path}/_upsert", {"key": key if isinstance(key, str) else list(key), "documents": list(docs)}, _idem(idempotency_key))
|
|
254
|
+
|
|
255
|
+
def update_where(self, filter: Dict[str, Any], update: Dict[str, Any]) -> Dict[str, int]:
|
|
256
|
+
return self.db.request("POST", f"{self.path}/_update", {"filter": filter, "update": update})
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class Stream:
|
|
260
|
+
"""A publish/subscribe stream."""
|
|
261
|
+
|
|
262
|
+
def __init__(self, db: HexDB, name: str):
|
|
263
|
+
self.db = db
|
|
264
|
+
self.name = name
|
|
265
|
+
self.path = "/streams/" + _quote(name)
|
|
266
|
+
|
|
267
|
+
def publish(self, *messages: Dict[str, Any]) -> List[str]:
|
|
268
|
+
"""Publish messages ({"payload", "key"?, "headers"?}); returns their offsets."""
|
|
269
|
+
return self.db.request("POST", f"{self.path}/messages", list(messages))["offsets"]
|
|
270
|
+
|
|
271
|
+
def read(self, after: Optional[str] = None, group: Optional[str] = None, limit: int = 100, wait: int = 0) -> Dict[str, Any]:
|
|
272
|
+
params = {"limit": str(limit)}
|
|
273
|
+
if after:
|
|
274
|
+
params["after"] = after
|
|
275
|
+
if group:
|
|
276
|
+
params["group"] = group
|
|
277
|
+
if wait:
|
|
278
|
+
params["wait"] = str(wait)
|
|
279
|
+
return self.db.request("GET", f"{self.path}/messages?" + urllib.parse.urlencode(params), timeout=wait + 30)
|
|
280
|
+
|
|
281
|
+
def commit(self, group: str, offset: str) -> None:
|
|
282
|
+
self.db.request("POST", f"{self.path}/groups/{_quote(group)}/commit", {"offset": offset})
|
|
283
|
+
|
|
284
|
+
def consume(self, group: str, handle: Callable[[Dict[str, Any]], None], stop: Optional[Callable[[], bool]] = None, batch: int = 100) -> None:
|
|
285
|
+
"""Call `handle` for each message in order as a consumer group, committing after each batch."""
|
|
286
|
+
while not (stop and stop()):
|
|
287
|
+
page = self.read(group=group, limit=batch, wait=30)
|
|
288
|
+
handled = None
|
|
289
|
+
for message in page["messages"]:
|
|
290
|
+
handle(message)
|
|
291
|
+
handled = message["offset"]
|
|
292
|
+
if stop and stop():
|
|
293
|
+
break
|
|
294
|
+
if handled:
|
|
295
|
+
self.commit(group, handled)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hexdb
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Client for HexDB, the hexagonal document database.
|
|
5
|
+
Author: Dream In Hex
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://hexdb-website.vercel.app
|
|
8
|
+
Project-URL: Source, https://github.com/dreaminhex/hexdb
|
|
9
|
+
Project-URL: Issues, https://github.com/dreaminhex/hexdb/issues
|
|
10
|
+
Keywords: hexdb,database,document,nosql,client
|
|
11
|
+
Requires-Python: >=3.9
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# hexdb (Python)
|
|
15
|
+
|
|
16
|
+
Client for HexDB. Python 3.9+, standard library only. See ../README.md and the HexDB manual.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
hexdb
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "hexdb"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Client for HexDB, the hexagonal document database."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "Apache-2.0"
|
|
12
|
+
dependencies = []
|
|
13
|
+
authors = [{ name = "Dream In Hex" }]
|
|
14
|
+
keywords = ["hexdb", "database", "document", "nosql", "client"]
|
|
15
|
+
|
|
16
|
+
[project.urls]
|
|
17
|
+
Homepage = "https://hexdb-website.vercel.app"
|
|
18
|
+
Source = "https://github.com/dreaminhex/hexdb"
|
|
19
|
+
Issues = "https://github.com/dreaminhex/hexdb/issues"
|
|
20
|
+
|
|
21
|
+
[tool.setuptools]
|
|
22
|
+
packages = ["hexdb"]
|
hexdb-1.0.0/setup.cfg
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Runs the Python driver against a real server.
|
|
2
|
+
|
|
3
|
+
node drivers/testing/server.mjs python -m unittest discover -s drivers/python/tests -t drivers/python
|
|
4
|
+
|
|
5
|
+
HEXDB_URL and HEXDB_API_KEY name the server (set by server.mjs).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
import unittest
|
|
12
|
+
|
|
13
|
+
from hexdb import HexDB, HexDBError
|
|
14
|
+
|
|
15
|
+
URL = os.environ.get("HEXDB_URL")
|
|
16
|
+
KEY = os.environ.get("HEXDB_API_KEY")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@unittest.skipUnless(URL and KEY, "set HEXDB_URL and HEXDB_API_KEY (or run through drivers/testing/server.mjs)")
|
|
20
|
+
class ClientTest(unittest.TestCase):
|
|
21
|
+
def setUp(self):
|
|
22
|
+
self.db = HexDB(URL, KEY)
|
|
23
|
+
self.prefix = f"py{int(time.time() * 1000) % 10_000_000}"
|
|
24
|
+
|
|
25
|
+
def tess(self, name):
|
|
26
|
+
return self.db.tessellation(f"{self.prefix}_{name}")
|
|
27
|
+
|
|
28
|
+
def test_documents(self):
|
|
29
|
+
notes = self.tess("notes")
|
|
30
|
+
doc = notes.insert({"title": "hello"})
|
|
31
|
+
self.assertEqual(len(doc["id"]), 26)
|
|
32
|
+
self.assertEqual(notes.get(doc["id"])["title"], "hello")
|
|
33
|
+
self.assertEqual(notes.patch(doc["id"], {"title": "hi"})["title"], "hi")
|
|
34
|
+
self.assertEqual(notes.replace(doc["id"], {"body": "x"})["body"], "x")
|
|
35
|
+
self.assertTrue(notes.delete(doc["id"]))
|
|
36
|
+
self.assertIsNone(notes.get(doc["id"]))
|
|
37
|
+
self.assertFalse(notes.delete(doc["id"]))
|
|
38
|
+
|
|
39
|
+
def test_queries_and_upserts(self):
|
|
40
|
+
orders = self.tess("orders")
|
|
41
|
+
ids = orders.insert_many([{"n": i, "total": i * 10, "status": "paid" if i % 2 else "new"} for i in range(20)])
|
|
42
|
+
self.assertEqual(len(ids), 20)
|
|
43
|
+
page = orders.query(filter={"status": "paid"}, sort="-total", limit=2, fields=["total"])
|
|
44
|
+
self.assertEqual(page["total"], 10)
|
|
45
|
+
self.assertEqual([d["total"] for d in page["documents"]], [190, 170])
|
|
46
|
+
self.assertEqual(orders.count({"total": {"$lt": 50}}), 5)
|
|
47
|
+
self.assertEqual(sum(1 for _ in orders.iterate(page_size=6)), 20)
|
|
48
|
+
agg = orders.aggregate(["status"], {"sum": {"$sum": "total"}})
|
|
49
|
+
self.assertEqual(len(agg["rows"]), 2)
|
|
50
|
+
result = orders.upsert("n", [{"n": 0, "status": "void"}, {"n": 100}])
|
|
51
|
+
self.assertEqual((result["inserted"], result["replaced"]), (1, 1))
|
|
52
|
+
self.assertEqual(result["ids"][0], ids[0])
|
|
53
|
+
self.assertEqual(orders.update_where({"status": "void"}, {"refunded": True})["modified"], 1)
|
|
54
|
+
|
|
55
|
+
def test_transactions_and_errors(self):
|
|
56
|
+
ledger = f"{self.prefix}_ledger"
|
|
57
|
+
op = [{"op": "insert", "tessellation": ledger, "data": {"amount": 5}}]
|
|
58
|
+
self.db.transaction(op, idempotency_key=f"{self.prefix}-tx")
|
|
59
|
+
self.db.transaction(op, idempotency_key=f"{self.prefix}-tx")
|
|
60
|
+
self.assertEqual(self.db.tessellation(ledger).count(), 1)
|
|
61
|
+
with self.assertRaises(HexDBError) as caught:
|
|
62
|
+
self.db.tessellation(ledger).query(filter={"$bogus": 1})
|
|
63
|
+
self.assertEqual(caught.exception.status, 400)
|
|
64
|
+
with self.assertRaises(HexDBError) as caught:
|
|
65
|
+
HexDB(URL).tessellation(ledger).count()
|
|
66
|
+
self.assertEqual((caught.exception.status, caught.exception.code), (401, "unauthorized"))
|
|
67
|
+
|
|
68
|
+
def test_graphql_and_changes(self):
|
|
69
|
+
feed = f"{self.prefix}_feed"
|
|
70
|
+
self.tess("feed").insert({"seed": True})
|
|
71
|
+
data = self.db.graphql("query($t: String!) { count(tessellation: $t) }", {"t": feed})
|
|
72
|
+
self.assertEqual(data["count"], 1)
|
|
73
|
+
received = []
|
|
74
|
+
done = threading.Event()
|
|
75
|
+
|
|
76
|
+
def follow():
|
|
77
|
+
for change in self.db.changes(tessellation=feed, stop=done.is_set):
|
|
78
|
+
received.append(change)
|
|
79
|
+
done.set()
|
|
80
|
+
|
|
81
|
+
thread = threading.Thread(target=follow, daemon=True)
|
|
82
|
+
thread.start()
|
|
83
|
+
time.sleep(0.5)
|
|
84
|
+
doc = self.tess("feed").insert({"x": 1})
|
|
85
|
+
thread.join(timeout=35)
|
|
86
|
+
self.assertEqual(received[0]["id"], doc["id"])
|
|
87
|
+
|
|
88
|
+
def test_streams(self):
|
|
89
|
+
name = f"{self.prefix}-events"
|
|
90
|
+
self.db.request("POST", "/streams", {"name": name})
|
|
91
|
+
events = self.db.stream(name)
|
|
92
|
+
offsets = events.publish({"payload": {"n": 1}}, {"payload": {"n": 2}, "key": "k"})
|
|
93
|
+
self.assertEqual(len(offsets), 2)
|
|
94
|
+
self.assertEqual([m["payload"]["n"] for m in events.read()["messages"]], [1, 2])
|
|
95
|
+
got = []
|
|
96
|
+
events.consume("workers", lambda m: got.append(m["payload"]["n"]), stop=lambda: len(got) >= 2)
|
|
97
|
+
self.assertEqual(got, [1, 2])
|
|
98
|
+
self.assertEqual(events.read(group="workers")["messages"], [])
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
if __name__ == "__main__":
|
|
102
|
+
unittest.main()
|