nedb-engine-client 3.2.2__tar.gz → 3.3.1__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nedb-engine-client
3
- Version: 3.2.2
3
+ Version: 3.3.1
4
4
  Summary: Async Python client for nedbd — the nedb-engine server daemon
5
5
  Author: Eth-Interchained
6
6
  License: MIT
@@ -12,5 +12,5 @@ Usage:
12
12
 
13
13
  from .client import NedbClient, NedbError
14
14
 
15
- __version__ = "3.2.2"
15
+ __version__ = "3.3.1"
16
16
  __all__ = ["NedbClient", "NedbError"]
@@ -14,6 +14,7 @@ All /v1/databases/* routes are covered. The client handles:
14
14
  from __future__ import annotations
15
15
 
16
16
  import asyncio
17
+ import urllib.parse
17
18
  from typing import Any, Dict, List, Optional, Union
18
19
 
19
20
  try:
@@ -120,6 +121,33 @@ class NedbClient:
120
121
  raise RuntimeError("NedbClient not open — use 'async with NedbClient(...) as c'")
121
122
  return self._write_client
122
123
 
124
+ # ── Id handling ───────────────────────────────────────────────────────────
125
+
126
+ @staticmethod
127
+ def _seg(value: str) -> str:
128
+ """Percent-encode one URL path segment.
129
+
130
+ `delete("t", "a/slash")` used to interpolate the id straight into the
131
+ path, so the `/` split it and the route matched a different id — the
132
+ call returned False ("no such document") for a document that existed.
133
+ `safe=""` is deliberate: `/` must be encoded, not passed through.
134
+ """
135
+ return urllib.parse.quote(str(value), safe="")
136
+
137
+ @staticmethod
138
+ def _nql_str(value: str) -> str:
139
+ """Escape a value for use inside a double-quoted NQL string literal.
140
+
141
+ The engine's lexer collapses `\\"` to a literal quote and leaves every
142
+ OTHER backslash alone, so only the quote needs escaping. One case is
143
+ genuinely unrepresentable: a value ENDING in a backslash would produce
144
+ `...\\"`, which the lexer reads as an escaped quote and the string
145
+ never terminates. Callers that might see such an id should use the
146
+ `rows/:coll/:id` route, which takes the id from the URL path and has no
147
+ quoting to get wrong.
148
+ """
149
+ return str(value).replace('"', '\\"')
150
+
123
151
  # ── Internal HTTP helpers ─────────────────────────────────────────────────
124
152
 
125
153
  async def _raise(self, resp: httpx.Response) -> None:
@@ -196,11 +224,45 @@ class NedbClient:
196
224
  if client_id is not None: payload["client"] = client_id
197
225
  return await self._put_raw(payload)
198
226
 
199
- async def get(self, coll: str, id: str) -> Optional[Dict[str, Any]]:
227
+ async def get(self, coll: str, id: str,
228
+ as_of: Optional[int] = None) -> Optional[Dict[str, Any]]:
200
229
  """
201
230
  Fetch the current version of a document. Returns the doc dict or None.
231
+
232
+ With ``as_of``, returns the version at or before that sequence number —
233
+ the single-document form of time travel.
234
+
235
+ Uses ``GET /v1/databases/<db>/rows/<coll>/<id>``, which takes the id
236
+ from the URL path. This used to build ``FROM coll WHERE _id = "..."``
237
+ and interpolate the id into it, which made every id containing a double
238
+ quote unreachable: the call returned None — meaning "no such document"
239
+ — for a document ``put()`` had stored and ``FROM coll`` returned. An id
240
+ ending in a backslash could not be escaped at all.
241
+
242
+ A missing document is ``200 {"row": null}`` on this route, not 404 —
243
+ deliberately, so that a 404/405 unambiguously means "the server does
244
+ not have this route" and the client can fall back to the query path.
245
+ Requires nedb-engine >= 3.3.0; older servers take the fallback.
202
246
  """
203
- result = await self._query_raw(f'FROM {coll} WHERE _id = "{id}" LIMIT 1')
247
+ path = f"/v1/databases/{self._db}/rows/{self._seg(coll)}/{self._seg(id)}"
248
+ params = {"as_of": as_of} if as_of is not None else None
249
+ resp = await self._rc().get(path, params=params)
250
+
251
+ # The route answers 200 with `row: null` for a missing document,
252
+ # precisely so this cannot be confused with "no such route". A
253
+ # 404/405 therefore means the server predates the route.
254
+ if resp.status_code in (404, 405):
255
+ return await self._get_via_query(coll, id, as_of)
256
+ if not resp.is_success:
257
+ await self._raise(resp)
258
+ return resp.json().get("row")
259
+
260
+ async def _get_via_query(self, coll: str, id: str,
261
+ as_of: Optional[int] = None) -> Optional[Dict[str, Any]]:
262
+ """Pre-3.3.0 fallback for :meth:`get` — a point lookup built as NQL."""
263
+ as_of_clause = f" AS OF {int(as_of)}" if as_of is not None else ""
264
+ nql = f'FROM {coll}{as_of_clause} WHERE _id = "{self._nql_str(id)}" LIMIT 1'
265
+ result = await self._query_raw(nql)
204
266
  rows = result.get("rows", [])
205
267
  return rows[0] if rows else None
206
268
 
@@ -210,7 +272,8 @@ class NedbClient:
210
272
  The object history is preserved in the DAG; the live id pointer is removed.
211
273
  Returns True if the document existed.
212
274
  """
213
- resp = await self._wc().delete(f"/v1/databases/{self._db}/rows/{coll}/{id}")
275
+ resp = await self._wc().delete(
276
+ f"/v1/databases/{self._db}/rows/{self._seg(coll)}/{self._seg(id)}")
214
277
  if resp.status_code == 404:
215
278
  return False
216
279
  if not resp.is_success:
@@ -226,12 +289,40 @@ class NedbClient:
226
289
  FROM <coll>
227
290
  [AS OF <seq>]
228
291
  [VALID AS OF "<date>"]
229
- [WHERE field = value [AND ...]]
230
- [ORDER BY field [DESC]]
231
- [LIMIT n]
232
- [GROUP BY field COUNT|SUM|AVG|MIN|MAX]
233
- [TRACE caused_by [REVERSE]]
292
+ [WHERE <predicate>]
234
293
  [SEARCH "text"]
294
+ [TRAVERSE <relation>]
295
+ [TRACE caused_by [REVERSE]]
296
+ [GROUP BY field [COUNT|SUM f|AVG f|MIN f|MAX f]]
297
+ [COUNT | SUM f | AVG f | MIN f | MAX f]
298
+ [HAVING <predicate>]
299
+ [ORDER BY field [ASC|DESC] (, field [ASC|DESC])*]
300
+ [LIMIT n] [OFFSET n]
301
+
302
+ Clauses are evaluated in SQL's order regardless of how they are
303
+ written: FROM -> WHERE -> GROUP BY -> HAVING -> ORDER BY -> OFFSET ->
304
+ LIMIT.
305
+
306
+ ``<predicate>`` is a full boolean expression (AND binds tighter than
307
+ OR; parentheses nest to any depth)::
308
+
309
+ field = != < <= > >= value
310
+ field [NOT] IN (v1, v2, ...)
311
+ field [NOT] BETWEEN low AND high -- inclusive, as in SQL
312
+ field [NOT] LIKE|ILIKE "pat" -- % any run, _ any one char
313
+ field IS [NOT] NULL -- absent OR explicitly null
314
+ NOT (...) / (... OR ...) / ...
315
+
316
+ An ordering comparison against a missing or null field is never true,
317
+ so ``WHERE fee < 5`` will not return a row that has no ``fee``. Use
318
+ ``IS NULL`` to select those rows.
319
+
320
+ A clause the server does not implement is REJECTED rather than
321
+ silently ignored, so a typo raises instead of quietly answering a
322
+ different question.
323
+
324
+ Requires nedb-engine >= 3.3.0 for IN / BETWEEN / LIKE / IS NULL / OR /
325
+ NOT / OFFSET / HAVING / multi-key ORDER BY and bare aggregates.
235
326
  """
236
327
  result = await self._query_raw(nql)
237
328
  return result.get("rows", [])
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nedb-engine-client
3
- Version: 3.2.2
3
+ Version: 3.3.1
4
4
  Summary: Async Python client for nedbd — the nedb-engine server daemon
5
5
  Author: Eth-Interchained
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "nedb-engine-client"
7
- version = "3.2.2"
7
+ version = "3.3.1"
8
8
  description = "Async Python client for nedbd — the nedb-engine server daemon"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }