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.
@@ -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/
@@ -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
+ ```
@@ -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