zydecodb 0.9.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.
- zydecodb-0.9.0/.gitignore +77 -0
- zydecodb-0.9.0/PKG-INFO +122 -0
- zydecodb-0.9.0/README.md +104 -0
- zydecodb-0.9.0/pyproject.toml +34 -0
- zydecodb-0.9.0/tests/test_conformance.py +184 -0
- zydecodb-0.9.0/tests/test_live.py +140 -0
- zydecodb-0.9.0/tests/test_protocol.py +88 -0
- zydecodb-0.9.0/zydecodb/__init__.py +47 -0
- zydecodb-0.9.0/zydecodb/_connection.py +120 -0
- zydecodb-0.9.0/zydecodb/_protocol.py +354 -0
- zydecodb-0.9.0/zydecodb/client.py +432 -0
- zydecodb-0.9.0/zydecodb/collection.py +218 -0
- zydecodb-0.9.0/zydecodb/errors.py +83 -0
- zydecodb-0.9.0/zydecodb/pool.py +119 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Node.js
|
|
2
|
+
node_modules/
|
|
3
|
+
dist/
|
|
4
|
+
experiments/**/*.js
|
|
5
|
+
|
|
6
|
+
# Compiled binaries
|
|
7
|
+
experiments/write_heavy/write_heavy
|
|
8
|
+
clients/go/user_backend
|
|
9
|
+
|
|
10
|
+
# Rust
|
|
11
|
+
/target
|
|
12
|
+
**/*.rs.bk
|
|
13
|
+
|
|
14
|
+
# Runtime data (dev)
|
|
15
|
+
*.sock
|
|
16
|
+
/.zydecodb-data/
|
|
17
|
+
/.zydecodb-run/
|
|
18
|
+
|
|
19
|
+
# Soak run artifacts (baselines in docs/soak-baselines/ are committed)
|
|
20
|
+
/soak-runs/**/data/
|
|
21
|
+
/soak-runs/**/wal/
|
|
22
|
+
/soak-runs/**/*.log
|
|
23
|
+
/soak-runs/**/*.jsonl
|
|
24
|
+
|
|
25
|
+
# Python
|
|
26
|
+
__pycache__/
|
|
27
|
+
*.py[cod]
|
|
28
|
+
.pytest_cache/
|
|
29
|
+
*.egg-info/
|
|
30
|
+
.venv/
|
|
31
|
+
|
|
32
|
+
# Fuzzing
|
|
33
|
+
/fuzz/corpus/
|
|
34
|
+
/fuzz/artifacts/
|
|
35
|
+
/fuzz/target/
|
|
36
|
+
|
|
37
|
+
# Coverage
|
|
38
|
+
/coverage/
|
|
39
|
+
*.profraw
|
|
40
|
+
lcov.info
|
|
41
|
+
|
|
42
|
+
# Secrets / credentials (never commit)
|
|
43
|
+
.env
|
|
44
|
+
.env.*
|
|
45
|
+
!.env.example
|
|
46
|
+
keys.toml
|
|
47
|
+
**/keys.toml
|
|
48
|
+
!config/zydecodb.keys.example.toml
|
|
49
|
+
*.pem
|
|
50
|
+
*.key
|
|
51
|
+
*.p12
|
|
52
|
+
*.pfx
|
|
53
|
+
tls.crt
|
|
54
|
+
tls.key
|
|
55
|
+
|
|
56
|
+
# IDE
|
|
57
|
+
/.idea/
|
|
58
|
+
/.vscode/
|
|
59
|
+
*.swp
|
|
60
|
+
.DS_Store
|
|
61
|
+
|
|
62
|
+
# Empty stub left over from an earlier packaging experiment
|
|
63
|
+
/.github/workflows/publish-python.yml
|
|
64
|
+
|
|
65
|
+
# Website / Marketing (deployed from this tree via scripts/deploy-website.sh;
|
|
66
|
+
# keep the marketing site and ops scripts local unless explicitly force-added)
|
|
67
|
+
/website/
|
|
68
|
+
/scripts/*
|
|
69
|
+
!/scripts/install.sh
|
|
70
|
+
!/scripts/deploy-website.sh
|
|
71
|
+
!/scripts/tenant-isolation-soak.sh
|
|
72
|
+
|
|
73
|
+
# Internal / Archived Docs
|
|
74
|
+
/ROADMAP.md
|
|
75
|
+
/docs/archive/
|
|
76
|
+
/docs/soak-baselines/
|
|
77
|
+
.cargo-home/
|
zydecodb-0.9.0/PKG-INFO
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: zydecodb
|
|
3
|
+
Version: 0.9.0
|
|
4
|
+
Summary: Official Python driver for ZydecoDB.
|
|
5
|
+
Project-URL: Homepage, https://github.com/dataparade/zydecodb
|
|
6
|
+
Project-URL: Source, https://github.com/dataparade/zydecodb
|
|
7
|
+
Author: Dataparade
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: database,document-store,nosql,zydecodb
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Database :: Front-Ends
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# ZydecoDB Python driver
|
|
20
|
+
|
|
21
|
+
Official Python client for [ZydecoDB](../../README.md). Pure standard library,
|
|
22
|
+
no runtime dependencies.
|
|
23
|
+
|
|
24
|
+
**Wire reference:** this client is the hand-maintained reference codec. Go and
|
|
25
|
+
TypeScript track the same bytes via [`../conformance/vectors.json`](../conformance/vectors.json)
|
|
26
|
+
(CI job `wire-conformance`).
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install zydecodb
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Requires Python 3.9+. (Working from a checkout of this repo:
|
|
35
|
+
`pip install -e clients/python`.)
|
|
36
|
+
|
|
37
|
+
## Quick start
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from zydecodb import Client
|
|
41
|
+
|
|
42
|
+
# Plain TCP (localhost). For TLS: Client(..., api_key="YOUR_KEY", tls=True)
|
|
43
|
+
with Client("127.0.0.1", 9470, api_key="YOUR_KEY") as db:
|
|
44
|
+
users = db.collection("users")
|
|
45
|
+
users.create_index(["email"], unique=True)
|
|
46
|
+
|
|
47
|
+
uid = users.insert_one({"email": "ada@example.com", "name": "Ada", "age": 30})
|
|
48
|
+
|
|
49
|
+
for u in users.find({"age": {"$gte": 18}}, sort=[("age", True)]):
|
|
50
|
+
print(u["name"], u["age"])
|
|
51
|
+
|
|
52
|
+
users.update_one({"_id": uid}, {"$inc": {"age": 1}})
|
|
53
|
+
print(users.count_documents())
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## What you get
|
|
57
|
+
|
|
58
|
+
- **Connection pooling.** `Client` owns a thread-safe pool (`pool_size`,
|
|
59
|
+
default 8) and is safe to share across threads.
|
|
60
|
+
- **Automatic retries with backoff.** Transient transport failures and server
|
|
61
|
+
`EngineBusy` responses are retried (full-jitter exponential backoff) for
|
|
62
|
+
operations that are safe to repeat. Operator updates and deletes are never
|
|
63
|
+
retried automatically.
|
|
64
|
+
- **Keepalive.** Idle pooled connections are validated with a `Ping` on
|
|
65
|
+
checkout and transparently replaced if dead.
|
|
66
|
+
- **Typed error taxonomy.** Non-OK responses raise a specific subclass:
|
|
67
|
+
`ConflictError` (unique-index violation), `AuthError`, `ServerBusyError`,
|
|
68
|
+
`InvalidRequestError`, or the base `ServerError` — each carrying the wire
|
|
69
|
+
`status` byte. Transport problems raise `ConnectionError`.
|
|
70
|
+
- **`Collection` API.** `insert_one/many`, `find`/`find_one`,
|
|
71
|
+
`update_one/many`, `delete_one/many`, `count_documents`, `distinct`,
|
|
72
|
+
`create_index`, with `$`-operators, sort, projection, and skip/limit.
|
|
73
|
+
Pagination is repeatable-read across pages.
|
|
74
|
+
- **Raw KV with TTL.** Side-channel `put` (with `expires_at`), `get`, and `delete` methods on `Client` for session data that needs a time-to-live.
|
|
75
|
+
- **TLS.** Pass `tls=True` for system CA defaults, or an `ssl.SSLContext` for custom roots / verification.
|
|
76
|
+
|
|
77
|
+
## Optimistic concurrency
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
got = users.get_with_revision(uid)
|
|
81
|
+
doc, rev = got
|
|
82
|
+
doc["age"] += 1
|
|
83
|
+
try:
|
|
84
|
+
users.replace_one_if_match(uid, doc, if_match=rev)
|
|
85
|
+
except ConflictError:
|
|
86
|
+
pass # re-read and retry, or merge
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Also: `find_with_revision`, `update_by_id_if_match`. Revisions are opaque
|
|
90
|
+
integers. Stale/missing documents raise `ConflictError`. Against an older
|
|
91
|
+
server these methods fail with a protocol error instead of silently becoming
|
|
92
|
+
unconditional writes.
|
|
93
|
+
|
|
94
|
+
## Durability
|
|
95
|
+
|
|
96
|
+
Writes are durable (fsync-on-commit) by default. For latency-sensitive,
|
|
97
|
+
loss-tolerant writes, pass `relaxed=True` to acknowledge before the fsync.
|
|
98
|
+
It is available on every write: `insert_one`, `replace_one`, `update_one`,
|
|
99
|
+
`update_many`, `delete_one`, and `delete_many`.
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
users.insert_one(doc, relaxed=True)
|
|
103
|
+
users.update_one({"_id": "ada"}, {"$inc": {"hits": 1}}, relaxed=True)
|
|
104
|
+
users.delete_many({"stale": True}, relaxed=True)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Running the tests
|
|
108
|
+
|
|
109
|
+
Unit + wire conformance (no server):
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
cd clients/python
|
|
113
|
+
pip install -e ".[dev]"
|
|
114
|
+
pytest tests/test_protocol.py tests/test_conformance.py
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Integration tests run against a live server selected by environment variables
|
|
118
|
+
(skipped automatically if it is unreachable):
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
ZYDECODB_TEST_HOST=127.0.0.1 ZYDECODB_TEST_PORT=9470 pytest
|
|
122
|
+
```
|
zydecodb-0.9.0/README.md
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# ZydecoDB Python driver
|
|
2
|
+
|
|
3
|
+
Official Python client for [ZydecoDB](../../README.md). Pure standard library,
|
|
4
|
+
no runtime dependencies.
|
|
5
|
+
|
|
6
|
+
**Wire reference:** this client is the hand-maintained reference codec. Go and
|
|
7
|
+
TypeScript track the same bytes via [`../conformance/vectors.json`](../conformance/vectors.json)
|
|
8
|
+
(CI job `wire-conformance`).
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install zydecodb
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Requires Python 3.9+. (Working from a checkout of this repo:
|
|
17
|
+
`pip install -e clients/python`.)
|
|
18
|
+
|
|
19
|
+
## Quick start
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from zydecodb import Client
|
|
23
|
+
|
|
24
|
+
# Plain TCP (localhost). For TLS: Client(..., api_key="YOUR_KEY", tls=True)
|
|
25
|
+
with Client("127.0.0.1", 9470, api_key="YOUR_KEY") as db:
|
|
26
|
+
users = db.collection("users")
|
|
27
|
+
users.create_index(["email"], unique=True)
|
|
28
|
+
|
|
29
|
+
uid = users.insert_one({"email": "ada@example.com", "name": "Ada", "age": 30})
|
|
30
|
+
|
|
31
|
+
for u in users.find({"age": {"$gte": 18}}, sort=[("age", True)]):
|
|
32
|
+
print(u["name"], u["age"])
|
|
33
|
+
|
|
34
|
+
users.update_one({"_id": uid}, {"$inc": {"age": 1}})
|
|
35
|
+
print(users.count_documents())
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## What you get
|
|
39
|
+
|
|
40
|
+
- **Connection pooling.** `Client` owns a thread-safe pool (`pool_size`,
|
|
41
|
+
default 8) and is safe to share across threads.
|
|
42
|
+
- **Automatic retries with backoff.** Transient transport failures and server
|
|
43
|
+
`EngineBusy` responses are retried (full-jitter exponential backoff) for
|
|
44
|
+
operations that are safe to repeat. Operator updates and deletes are never
|
|
45
|
+
retried automatically.
|
|
46
|
+
- **Keepalive.** Idle pooled connections are validated with a `Ping` on
|
|
47
|
+
checkout and transparently replaced if dead.
|
|
48
|
+
- **Typed error taxonomy.** Non-OK responses raise a specific subclass:
|
|
49
|
+
`ConflictError` (unique-index violation), `AuthError`, `ServerBusyError`,
|
|
50
|
+
`InvalidRequestError`, or the base `ServerError` — each carrying the wire
|
|
51
|
+
`status` byte. Transport problems raise `ConnectionError`.
|
|
52
|
+
- **`Collection` API.** `insert_one/many`, `find`/`find_one`,
|
|
53
|
+
`update_one/many`, `delete_one/many`, `count_documents`, `distinct`,
|
|
54
|
+
`create_index`, with `$`-operators, sort, projection, and skip/limit.
|
|
55
|
+
Pagination is repeatable-read across pages.
|
|
56
|
+
- **Raw KV with TTL.** Side-channel `put` (with `expires_at`), `get`, and `delete` methods on `Client` for session data that needs a time-to-live.
|
|
57
|
+
- **TLS.** Pass `tls=True` for system CA defaults, or an `ssl.SSLContext` for custom roots / verification.
|
|
58
|
+
|
|
59
|
+
## Optimistic concurrency
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
got = users.get_with_revision(uid)
|
|
63
|
+
doc, rev = got
|
|
64
|
+
doc["age"] += 1
|
|
65
|
+
try:
|
|
66
|
+
users.replace_one_if_match(uid, doc, if_match=rev)
|
|
67
|
+
except ConflictError:
|
|
68
|
+
pass # re-read and retry, or merge
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Also: `find_with_revision`, `update_by_id_if_match`. Revisions are opaque
|
|
72
|
+
integers. Stale/missing documents raise `ConflictError`. Against an older
|
|
73
|
+
server these methods fail with a protocol error instead of silently becoming
|
|
74
|
+
unconditional writes.
|
|
75
|
+
|
|
76
|
+
## Durability
|
|
77
|
+
|
|
78
|
+
Writes are durable (fsync-on-commit) by default. For latency-sensitive,
|
|
79
|
+
loss-tolerant writes, pass `relaxed=True` to acknowledge before the fsync.
|
|
80
|
+
It is available on every write: `insert_one`, `replace_one`, `update_one`,
|
|
81
|
+
`update_many`, `delete_one`, and `delete_many`.
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
users.insert_one(doc, relaxed=True)
|
|
85
|
+
users.update_one({"_id": "ada"}, {"$inc": {"hits": 1}}, relaxed=True)
|
|
86
|
+
users.delete_many({"stale": True}, relaxed=True)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Running the tests
|
|
90
|
+
|
|
91
|
+
Unit + wire conformance (no server):
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
cd clients/python
|
|
95
|
+
pip install -e ".[dev]"
|
|
96
|
+
pytest tests/test_protocol.py tests/test_conformance.py
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Integration tests run against a live server selected by environment variables
|
|
100
|
+
(skipped automatically if it is unreachable):
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
ZYDECODB_TEST_HOST=127.0.0.1 ZYDECODB_TEST_PORT=9470 pytest
|
|
104
|
+
```
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "zydecodb"
|
|
7
|
+
version = "0.9.0"
|
|
8
|
+
description = "Official Python driver for ZydecoDB."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Dataparade" }]
|
|
13
|
+
keywords = ["zydecodb", "database", "document-store", "nosql"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Topic :: Database :: Front-Ends",
|
|
19
|
+
]
|
|
20
|
+
# Pure stdlib: no runtime dependencies.
|
|
21
|
+
dependencies = []
|
|
22
|
+
|
|
23
|
+
[project.optional-dependencies]
|
|
24
|
+
dev = ["pytest>=7"]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://github.com/dataparade/zydecodb"
|
|
28
|
+
Source = "https://github.com/dataparade/zydecodb"
|
|
29
|
+
|
|
30
|
+
[tool.hatch.build.targets.wheel]
|
|
31
|
+
packages = ["zydecodb"]
|
|
32
|
+
|
|
33
|
+
[tool.pytest.ini_options]
|
|
34
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Conformance: the Python codec must match the shared wire vectors byte-for-byte.
|
|
2
|
+
|
|
3
|
+
The vectors in `clients/conformance/vectors.json` are generated from the Rust
|
|
4
|
+
server encoders (the protocol authority). Running the Python codec against them
|
|
5
|
+
proves it cannot silently drift from the server. See `clients/conformance/README.md`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
from zydecodb import _protocol as proto
|
|
16
|
+
|
|
17
|
+
VECTORS_PATH = Path(__file__).resolve().parents[2] / "conformance" / "vectors.json"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _load():
|
|
21
|
+
with VECTORS_PATH.open(encoding="utf-8") as fh:
|
|
22
|
+
return json.load(fh)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
VECTORS = _load()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _json_field(s: str):
|
|
29
|
+
"""An opaque pre-serialized JSON field -> the object Python re-serializes to
|
|
30
|
+
the same bytes (empty string means "absent")."""
|
|
31
|
+
return None if s == "" else json.loads(s)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _encode_request(kind: str, inp: dict) -> bytes:
|
|
35
|
+
if kind == "Put":
|
|
36
|
+
return proto.encode_put(
|
|
37
|
+
bytes.fromhex(inp["key_hex"]),
|
|
38
|
+
bytes.fromhex(inp["value_hex"]),
|
|
39
|
+
expires_at=inp["expires_at"],
|
|
40
|
+
)
|
|
41
|
+
if kind == "Get":
|
|
42
|
+
return proto.encode_key(bytes.fromhex(inp["key_hex"]))
|
|
43
|
+
if kind == "Del":
|
|
44
|
+
return proto.encode_key(bytes.fromhex(inp["key_hex"]))
|
|
45
|
+
if kind == "DocPut":
|
|
46
|
+
return proto.encode_doc_put(
|
|
47
|
+
inp["collection"], inp["doc_id"], _json_field(inp["body_json"]),
|
|
48
|
+
relaxed=inp["relaxed"],
|
|
49
|
+
expires_at=inp.get("expires_at", 0),
|
|
50
|
+
)
|
|
51
|
+
if kind == "DocDel":
|
|
52
|
+
return proto.encode_doc_del(inp["collection"], inp["doc_id"])
|
|
53
|
+
if kind == "IndexDef":
|
|
54
|
+
return proto.encode_index_def(
|
|
55
|
+
inp["collection"], inp["index_name"], inp["fields"], unique=inp["unique"],
|
|
56
|
+
expire_after_seconds=inp.get("expire_after_seconds", 0),
|
|
57
|
+
)
|
|
58
|
+
if kind == "QueryById":
|
|
59
|
+
return proto.encode_query_by_id(inp["collection"], inp["doc_id"])
|
|
60
|
+
if kind == "QueryIndexRange":
|
|
61
|
+
return proto.encode_query_index_range(
|
|
62
|
+
inp["collection"], inp["index_name"],
|
|
63
|
+
lo=_json_field(inp["lo_json"]), hi=_json_field(inp["hi_json"]),
|
|
64
|
+
page_size=inp["limit"], cursor=bytes.fromhex(inp["cursor_hex"]),
|
|
65
|
+
)
|
|
66
|
+
if kind in ("Find", "FindRev"):
|
|
67
|
+
proj = inp["projection"]
|
|
68
|
+
mode = {"none": None, "include": proto.PROJ_INCLUDE, "exclude": proto.PROJ_EXCLUDE}[proj["mode"]]
|
|
69
|
+
projection = None if mode is None else (mode, proj["fields"])
|
|
70
|
+
return proto.encode_find(
|
|
71
|
+
inp["collection"], _json_field(inp["filter_json"]),
|
|
72
|
+
[tuple(s) for s in inp["sort"]], projection,
|
|
73
|
+
inp["skip"], inp["limit"], bytes.fromhex(inp["cursor_hex"]),
|
|
74
|
+
)
|
|
75
|
+
if kind == "DocGetRev":
|
|
76
|
+
return proto.encode_query_by_id(inp["collection"], inp["doc_id"])
|
|
77
|
+
if kind == "DocPutIfMatch":
|
|
78
|
+
return proto.encode_doc_put_if_match(
|
|
79
|
+
inp["collection"],
|
|
80
|
+
inp["doc_id"],
|
|
81
|
+
_json_field(inp["body_json"]),
|
|
82
|
+
relaxed=inp["relaxed"],
|
|
83
|
+
if_match=inp["if_match"],
|
|
84
|
+
expires_at=inp.get("expires_at", 0),
|
|
85
|
+
)
|
|
86
|
+
if kind == "DocUpdateIfMatch":
|
|
87
|
+
return proto.encode_doc_update_if_match(
|
|
88
|
+
inp["collection"],
|
|
89
|
+
inp["doc_id"],
|
|
90
|
+
_json_field(inp["update_json"]),
|
|
91
|
+
relaxed=inp["relaxed"],
|
|
92
|
+
if_match=inp["if_match"],
|
|
93
|
+
)
|
|
94
|
+
if kind == "Update":
|
|
95
|
+
return proto.encode_update(
|
|
96
|
+
inp["collection"], _json_field(inp["filter_json"]),
|
|
97
|
+
_json_field(inp["update_json"]), multi=inp["multi"], relaxed=inp["relaxed"],
|
|
98
|
+
upsert=inp.get("upsert", False),
|
|
99
|
+
)
|
|
100
|
+
if kind == "Delete":
|
|
101
|
+
return proto.encode_delete(
|
|
102
|
+
inp["collection"], _json_field(inp["filter_json"]),
|
|
103
|
+
multi=inp["multi"], relaxed=inp["relaxed"],
|
|
104
|
+
)
|
|
105
|
+
if kind == "Count":
|
|
106
|
+
return proto.encode_count(inp["collection"], _json_field(inp["filter_json"]))
|
|
107
|
+
if kind == "Distinct":
|
|
108
|
+
return proto.encode_distinct(
|
|
109
|
+
inp["collection"], inp["field"], _json_field(inp["filter_json"])
|
|
110
|
+
)
|
|
111
|
+
if kind == "SessionInit":
|
|
112
|
+
return inp["api_key"].encode("utf-8")
|
|
113
|
+
if kind == "Ping":
|
|
114
|
+
return b""
|
|
115
|
+
raise AssertionError(f"unhandled request kind: {kind}")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@pytest.mark.parametrize("vec", VECTORS["requests"], ids=lambda v: v["name"])
|
|
119
|
+
def test_request_payload_matches(vec):
|
|
120
|
+
payload = _encode_request(vec["kind"], vec["input"])
|
|
121
|
+
assert payload.hex() == vec["payload_hex"], vec["name"]
|
|
122
|
+
envelope = proto.encode_header(vec["command"], len(payload)) + payload
|
|
123
|
+
assert envelope.hex() == vec["envelope_hex"], vec["name"]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@pytest.mark.parametrize("vec", VECTORS["responses"], ids=lambda v: v["name"])
|
|
127
|
+
def test_response_decode_matches(vec):
|
|
128
|
+
kind = vec["kind"]
|
|
129
|
+
if kind == "QueryPage":
|
|
130
|
+
rows, cursor = proto.decode_page(bytes.fromhex(vec["bytes_hex"]))
|
|
131
|
+
expected_rows = vec["decoded"]["rows"]
|
|
132
|
+
assert len(rows) == len(expected_rows), vec["name"]
|
|
133
|
+
for (doc_id, body), exp in zip(rows, expected_rows):
|
|
134
|
+
assert doc_id.decode("utf-8") == exp["doc_id"]
|
|
135
|
+
assert body.decode("utf-8") == exp["body_json"]
|
|
136
|
+
expected_cursor = vec["decoded"]["next_cursor_hex"]
|
|
137
|
+
if expected_cursor is None:
|
|
138
|
+
assert cursor == b""
|
|
139
|
+
else:
|
|
140
|
+
assert cursor.hex() == expected_cursor
|
|
141
|
+
return
|
|
142
|
+
if kind == "QueryPageRev":
|
|
143
|
+
rows, cursor = proto.decode_page_with_revision(
|
|
144
|
+
bytes.fromhex(vec["bytes_hex"]), with_revision=True
|
|
145
|
+
)
|
|
146
|
+
expected_rows = vec["decoded"]["rows"]
|
|
147
|
+
assert len(rows) == len(expected_rows), vec["name"]
|
|
148
|
+
for (doc_id, body, rev), exp in zip(rows, expected_rows):
|
|
149
|
+
assert doc_id.decode("utf-8") == exp["doc_id"]
|
|
150
|
+
assert body.decode("utf-8") == exp["body_json"]
|
|
151
|
+
assert rev == exp["revision"]
|
|
152
|
+
assert cursor == b""
|
|
153
|
+
return
|
|
154
|
+
if kind == "DocGetRevResponse":
|
|
155
|
+
body, rev = proto.decode_doc_get_rev_response(bytes.fromhex(vec["bytes_hex"]))
|
|
156
|
+
assert body.decode("utf-8") == vec["decoded"]["body_json"]
|
|
157
|
+
assert rev == vec["decoded"]["revision"]
|
|
158
|
+
return
|
|
159
|
+
raise AssertionError(f"unhandled response kind: {kind}")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_command_codes_match_vectors():
|
|
163
|
+
cmds = VECTORS["commands"]
|
|
164
|
+
assert proto.CMD_DOC_PUT == cmds["DocPut"]
|
|
165
|
+
assert proto.CMD_FIND == cmds["Find"]
|
|
166
|
+
assert proto.CMD_UPDATE == cmds["Update"]
|
|
167
|
+
assert proto.CMD_DELETE == cmds["Delete"]
|
|
168
|
+
assert proto.CMD_COUNT == cmds["Count"]
|
|
169
|
+
assert proto.CMD_DOC_GET_REV == cmds["DocGetRev"]
|
|
170
|
+
assert proto.CMD_FIND_REV == cmds["FindRev"]
|
|
171
|
+
assert proto.CMD_DOC_PUT_IF_MATCH == cmds["DocPutIfMatch"]
|
|
172
|
+
assert proto.CMD_DOC_UPDATE_IF_MATCH == cmds["DocUpdateIfMatch"]
|
|
173
|
+
assert proto.CMD_INDEX_DEF == cmds["IndexDef"]
|
|
174
|
+
assert proto.CMD_SESSION_INIT == cmds["SessionInit"]
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_status_codes_match_vectors():
|
|
178
|
+
st = VECTORS["statuses"]
|
|
179
|
+
assert proto.STATUS_OK == st["Ok"]
|
|
180
|
+
assert proto.STATUS_ENGINE_BUSY == st["EngineBusy"]
|
|
181
|
+
assert proto.STATUS_POLICY_REJECTED == st["PolicyRejected"]
|
|
182
|
+
assert proto.STATUS_UNSUPPORTED_FORMAT == st["UnsupportedFormat"]
|
|
183
|
+
assert proto.STATUS_UNAUTHORIZED == st["Unauthorized"]
|
|
184
|
+
assert proto.STATUS_FORBIDDEN == st["Forbidden"]
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Integration tests against a live ZydecoDB server.
|
|
2
|
+
|
|
3
|
+
Set ZYDECODB_TEST_HOST / ZYDECODB_TEST_PORT (and optionally ZYDECODB_TEST_API_KEY)
|
|
4
|
+
to point at a running server. The whole module is skipped when the server is not
|
|
5
|
+
reachable, so a plain `pytest` run stays green offline; CI starts a server first.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import socket
|
|
10
|
+
import uuid
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
from zydecodb import Client, ConflictError
|
|
15
|
+
|
|
16
|
+
HOST = os.environ.get("ZYDECODB_TEST_HOST", "127.0.0.1")
|
|
17
|
+
PORT = int(os.environ.get("ZYDECODB_TEST_PORT", "9470"))
|
|
18
|
+
API_KEY = os.environ.get("ZYDECODB_TEST_API_KEY") or None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _server_up() -> bool:
|
|
22
|
+
try:
|
|
23
|
+
with socket.create_connection((HOST, PORT), timeout=1.0):
|
|
24
|
+
return True
|
|
25
|
+
except OSError:
|
|
26
|
+
return False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
pytestmark = pytest.mark.skipif(
|
|
30
|
+
not _server_up(), reason=f"no ZydecoDB server at {HOST}:{PORT}"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@pytest.fixture()
|
|
35
|
+
def db():
|
|
36
|
+
client = Client(HOST, PORT, api_key=API_KEY)
|
|
37
|
+
yield client
|
|
38
|
+
client.close()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@pytest.fixture()
|
|
42
|
+
def coll(db):
|
|
43
|
+
# A unique collection per test keeps runs isolated.
|
|
44
|
+
return db.collection(f"pytest_{uuid.uuid4().hex[:12]}")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_ping(db):
|
|
48
|
+
db.ping()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_insert_find_update_delete(coll):
|
|
52
|
+
coll.create_index(["age"])
|
|
53
|
+
ids = coll.insert_many(
|
|
54
|
+
[
|
|
55
|
+
{"name": "Ada", "age": 30, "city": "London"},
|
|
56
|
+
{"name": "Bo", "age": 25, "city": "NOLA"},
|
|
57
|
+
{"name": "Cy", "age": 40, "city": "NOLA"},
|
|
58
|
+
]
|
|
59
|
+
)
|
|
60
|
+
assert len(ids) == 3
|
|
61
|
+
|
|
62
|
+
got = list(coll.find({"age": {"$gte": 30}}, sort=[("age", True)]))
|
|
63
|
+
assert [d["name"] for d in got] == ["Ada", "Cy"]
|
|
64
|
+
|
|
65
|
+
res = coll.update_one({"name": "Bo"}, {"$inc": {"age": 10}})
|
|
66
|
+
assert res["matched"] == 1 and res["modified"] == 1
|
|
67
|
+
assert coll.find_one({"name": "Bo"})["age"] == 35
|
|
68
|
+
|
|
69
|
+
assert coll.count_documents() == 3
|
|
70
|
+
assert sorted(coll.distinct("city")) == ["London", "NOLA"]
|
|
71
|
+
|
|
72
|
+
assert coll.delete_many({"city": "NOLA"}) == 2
|
|
73
|
+
assert coll.count_documents() == 1
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_unique_index_conflict(coll):
|
|
77
|
+
coll.create_index(["email"], unique=True)
|
|
78
|
+
coll.insert_one({"email": "a@b.com"})
|
|
79
|
+
with pytest.raises(ConflictError):
|
|
80
|
+
coll.insert_one({"email": "a@b.com"})
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_pagination_is_stable(coll):
|
|
84
|
+
coll.create_index(["n"])
|
|
85
|
+
coll.insert_many([{"n": i} for i in range(25)])
|
|
86
|
+
seen = [d["n"] for d in coll.find({"n": {"$gte": 0}}, page_size=10)]
|
|
87
|
+
assert sorted(seen) == list(range(25))
|
|
88
|
+
assert len(seen) == 25 # no duplicates across pages
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_get_by_id(coll):
|
|
92
|
+
doc_id = coll.insert_one({"name": "Zee"})
|
|
93
|
+
fetched = coll.get(doc_id)
|
|
94
|
+
assert fetched["name"] == "Zee"
|
|
95
|
+
assert coll.get("does-not-exist") is None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_upsert_set_on_insert(coll):
|
|
99
|
+
miss = coll.update_one(
|
|
100
|
+
{"email": "soi@example.com"},
|
|
101
|
+
{"$set": {"email": "soi@example.com", "n": 1}, "$setOnInsert": {"created": True}},
|
|
102
|
+
upsert=True,
|
|
103
|
+
)
|
|
104
|
+
assert miss["matched"] == 0 and miss["modified"] == 0
|
|
105
|
+
assert miss.get("upserted_id")
|
|
106
|
+
doc = coll.find_one({"email": "soi@example.com"})
|
|
107
|
+
assert doc["created"] is True
|
|
108
|
+
assert doc["n"] == 1
|
|
109
|
+
|
|
110
|
+
hit = coll.update_one(
|
|
111
|
+
{"email": "soi@example.com"},
|
|
112
|
+
{"$set": {"n": 2}, "$setOnInsert": {"created": False, "extra": 1}},
|
|
113
|
+
upsert=True,
|
|
114
|
+
)
|
|
115
|
+
assert hit["matched"] == 1 and hit["modified"] == 1
|
|
116
|
+
assert "upserted_id" not in hit
|
|
117
|
+
doc = coll.find_one({"email": "soi@example.com"})
|
|
118
|
+
assert doc["n"] == 2
|
|
119
|
+
assert doc["created"] is True
|
|
120
|
+
assert "extra" not in doc
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_optimistic_concurrency(coll):
|
|
124
|
+
from zydecodb.errors import ConflictError
|
|
125
|
+
|
|
126
|
+
doc_id = coll.insert_one({"n": 1})
|
|
127
|
+
got = coll.get_with_revision(doc_id)
|
|
128
|
+
assert got is not None
|
|
129
|
+
doc, rev = got
|
|
130
|
+
assert doc["n"] == 1
|
|
131
|
+
assert rev > 0
|
|
132
|
+
new_rev = coll.replace_one_if_match(doc_id, {"n": 2}, if_match=rev)
|
|
133
|
+
assert new_rev > rev
|
|
134
|
+
try:
|
|
135
|
+
coll.replace_one_if_match(doc_id, {"n": 3}, if_match=rev)
|
|
136
|
+
assert False, "expected ConflictError"
|
|
137
|
+
except ConflictError:
|
|
138
|
+
pass
|
|
139
|
+
after = coll.update_by_id_if_match(doc_id, {"$inc": {"n": 1}}, if_match=new_rev)
|
|
140
|
+
assert after > new_rev
|