alt-python-pynosqlc-core 1.0.4__py3-none-any.whl
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.
- alt_python_pynosqlc_core-1.0.4.dist-info/METADATA +28 -0
- alt_python_pynosqlc_core-1.0.4.dist-info/RECORD +14 -0
- alt_python_pynosqlc_core-1.0.4.dist-info/WHEEL +4 -0
- pynosqlc/core/__init__.py +39 -0
- pynosqlc/core/client.py +112 -0
- pynosqlc/core/collection.py +109 -0
- pynosqlc/core/cursor.py +88 -0
- pynosqlc/core/driver.py +41 -0
- pynosqlc/core/driver_manager.py +66 -0
- pynosqlc/core/errors.py +12 -0
- pynosqlc/core/filter.py +176 -0
- pynosqlc/core/py.typed +0 -0
- pynosqlc/core/testing/__init__.py +10 -0
- pynosqlc/core/testing/compliance.py +280 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: alt-python-pynosqlc-core
|
|
3
|
+
Version: 1.0.4
|
|
4
|
+
Summary: Core abstraction hierarchy for pynosqlc — Driver, DriverManager, Client, Collection, Cursor, Filter, FieldCondition, UnsupportedOperationError, compliance suite
|
|
5
|
+
Project-URL: Homepage, https://github.com/alt-python/pynosqlc
|
|
6
|
+
Project-URL: Repository, https://github.com/alt-python/pynosqlc
|
|
7
|
+
Project-URL: Documentation, https://github.com/alt-python/pynosqlc#getting-started
|
|
8
|
+
Project-URL: Bug Tracker, https://github.com/alt-python/pynosqlc/issues
|
|
9
|
+
Author: Craig Parravicini, Claude (Anthropic)
|
|
10
|
+
License: MIT
|
|
11
|
+
Keywords: async,database,driver,nosql
|
|
12
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
13
|
+
Classifier: Framework :: AsyncIO
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Database
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.12
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# pynosqlc-core
|
|
25
|
+
|
|
26
|
+
Core abstraction hierarchy for pynosqlc — a JDBC-inspired unified async NoSQL access layer for Python.
|
|
27
|
+
|
|
28
|
+
Provides `DriverManager`, `Driver`, `Client`, `Collection`, `Cursor`, `Filter`, `FieldCondition`, `UnsupportedOperationError`, and a portable driver compliance suite.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
pynosqlc/core/__init__.py,sha256=lvHNYFoztmC1jmZo2CEeVTsoAYlW3LXISjj-cPwGQBg,1375
|
|
2
|
+
pynosqlc/core/client.py,sha256=_lvuY4r8pHPCtY27rbLPQbhtMEODxc_XoBp5aO869bw,3873
|
|
3
|
+
pynosqlc/core/collection.py,sha256=zj1YncYG1no3YEKAgG_oxVSP5Vb1F3Bf19W77u7mJCE,3915
|
|
4
|
+
pynosqlc/core/cursor.py,sha256=GFLHnheZq-SHZFaDQW5pvfWzfEGz3VfagoTJjQoEhdY,2979
|
|
5
|
+
pynosqlc/core/driver.py,sha256=VqpgbbREuMyr4TI2o4KHgPXhirjBu2Yw0qzs6pHwPT0,1174
|
|
6
|
+
pynosqlc/core/driver_manager.py,sha256=tLYd67kqjSIFH294Suhoh4YeONjEqgMfEhod31GsWR0,2013
|
|
7
|
+
pynosqlc/core/errors.py,sha256=yhgJACWHfhoTgQXXT6V0IIZKkkjw-xPPVV5ElpE8upU,297
|
|
8
|
+
pynosqlc/core/filter.py,sha256=I3ZL0KgpSki20L5eP7xZAocEL5Y7a6FA_k5gl1zMzyo,6152
|
|
9
|
+
pynosqlc/core/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
pynosqlc/core/testing/__init__.py,sha256=SVOhsktVPobQUTAmLEwslT_sQ2kk6KrALFMhZhop-5M,249
|
|
11
|
+
pynosqlc/core/testing/compliance.py,sha256=WDrcLEstdnG3Tot2aaoI_8-luWEDEncfXLEkPK9foVM,11894
|
|
12
|
+
alt_python_pynosqlc_core-1.0.4.dist-info/METADATA,sha256=3YX6BctKi9VZkoqXNXiJIJ7YJEWJSQD4ehtFxmI8jkU,1387
|
|
13
|
+
alt_python_pynosqlc_core-1.0.4.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
14
|
+
alt_python_pynosqlc_core-1.0.4.dist-info/RECORD,,
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""
|
|
2
|
+
pynosqlc.core — Core abstraction hierarchy for pynosqlc.
|
|
3
|
+
|
|
4
|
+
Provides:
|
|
5
|
+
Driver — ABC for NoSQL drivers
|
|
6
|
+
DriverManager — Registry and URL-based connection dispatcher
|
|
7
|
+
Client — ABC for database clients (async context manager)
|
|
8
|
+
ClientDataSource — Connection factory wrapping DriverManager
|
|
9
|
+
Collection — ABC for collections / tables / buckets
|
|
10
|
+
Cursor — Cursor-based and bulk document access (async iterable)
|
|
11
|
+
Filter — Chainable query filter builder
|
|
12
|
+
FieldCondition — Field-level condition within a Filter
|
|
13
|
+
UnsupportedOperationError — Raised when a driver does not implement an operation
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from pynosqlc.core.errors import UnsupportedOperationError
|
|
19
|
+
from pynosqlc.core.driver import Driver
|
|
20
|
+
from pynosqlc.core.driver_manager import DriverManager
|
|
21
|
+
from pynosqlc.core.cursor import Cursor
|
|
22
|
+
from pynosqlc.core.collection import Collection
|
|
23
|
+
from pynosqlc.core.client import Client, ClientDataSource
|
|
24
|
+
from pynosqlc.core.filter import Filter, FieldCondition
|
|
25
|
+
|
|
26
|
+
__author__ = "Craig Parravicini"
|
|
27
|
+
__collaborators__ = ["Claude (Anthropic)"]
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"Driver",
|
|
31
|
+
"DriverManager",
|
|
32
|
+
"Client",
|
|
33
|
+
"ClientDataSource",
|
|
34
|
+
"Collection",
|
|
35
|
+
"Cursor",
|
|
36
|
+
"Filter",
|
|
37
|
+
"FieldCondition",
|
|
38
|
+
"UnsupportedOperationError",
|
|
39
|
+
]
|
pynosqlc/core/client.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""
|
|
2
|
+
client.py — Abstract base class for a pynosqlc client session.
|
|
3
|
+
|
|
4
|
+
Drivers override ``_get_collection()`` and ``_close()``.
|
|
5
|
+
Manages a cache of Collection instances keyed by name.
|
|
6
|
+
Implements the async context manager protocol (``async with``).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from abc import ABC, abstractmethod
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
from pynosqlc.core.driver_manager import DriverManager
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from pynosqlc.core.collection import Collection
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Client(ABC):
|
|
21
|
+
"""A session to a NoSQL database.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
config: optional dict; recognises key ``'url'``.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, config: dict | None = None) -> None:
|
|
28
|
+
cfg = config or {}
|
|
29
|
+
self._url: str | None = cfg.get("url")
|
|
30
|
+
self._closed: bool = False
|
|
31
|
+
self._collections: dict[str, "Collection"] = {}
|
|
32
|
+
|
|
33
|
+
# ── Async context manager ──────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
async def __aenter__(self) -> "Client":
|
|
36
|
+
return self
|
|
37
|
+
|
|
38
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
39
|
+
await self.close()
|
|
40
|
+
|
|
41
|
+
# ── Public API ─────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
def get_collection(self, name: str) -> "Collection":
|
|
44
|
+
"""Return a (cached) Collection by name.
|
|
45
|
+
|
|
46
|
+
Raises:
|
|
47
|
+
RuntimeError: if the client is closed
|
|
48
|
+
"""
|
|
49
|
+
self._check_closed()
|
|
50
|
+
if name not in self._collections:
|
|
51
|
+
self._collections[name] = self._get_collection(name)
|
|
52
|
+
return self._collections[name]
|
|
53
|
+
|
|
54
|
+
async def close(self) -> None:
|
|
55
|
+
"""Close the client and release all resources."""
|
|
56
|
+
self._closed = True
|
|
57
|
+
self._collections.clear()
|
|
58
|
+
await self._close()
|
|
59
|
+
|
|
60
|
+
def is_closed(self) -> bool:
|
|
61
|
+
"""Return ``True`` if the client has been closed."""
|
|
62
|
+
return self._closed
|
|
63
|
+
|
|
64
|
+
def get_url(self) -> str | None:
|
|
65
|
+
"""Return the pynosqlc URL this client was opened with."""
|
|
66
|
+
return self._url
|
|
67
|
+
|
|
68
|
+
# ── Abstract implementation hooks ──────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
@abstractmethod
|
|
71
|
+
def _get_collection(self, name: str) -> "Collection":
|
|
72
|
+
"""Create and return a new Collection instance for *name*."""
|
|
73
|
+
|
|
74
|
+
async def _close(self) -> None:
|
|
75
|
+
"""Override to release driver-specific resources on close."""
|
|
76
|
+
|
|
77
|
+
# ── Internal helpers ───────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
def _check_closed(self) -> None:
|
|
80
|
+
if self._closed:
|
|
81
|
+
raise RuntimeError("Client is closed")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class ClientDataSource:
|
|
85
|
+
"""Convenience factory that wraps :meth:`DriverManager.get_client`.
|
|
86
|
+
|
|
87
|
+
Mirrors pydbc's DataSource pattern.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
config: dict with keys:
|
|
91
|
+
- ``url`` (required) — pynosqlc URL
|
|
92
|
+
- ``username`` (optional)
|
|
93
|
+
- ``password`` (optional)
|
|
94
|
+
- ``properties`` (optional) — additional driver-specific options
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
def __init__(self, config: dict | None = None) -> None:
|
|
98
|
+
cfg = config or {}
|
|
99
|
+
self._url: str = cfg["url"]
|
|
100
|
+
self._properties: dict = {
|
|
101
|
+
"username": cfg.get("username"),
|
|
102
|
+
"password": cfg.get("password"),
|
|
103
|
+
**(cfg.get("properties") or {}),
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async def get_client(self) -> Client:
|
|
107
|
+
"""Return a client from the configured data source."""
|
|
108
|
+
return await DriverManager.get_client(self._url, self._properties)
|
|
109
|
+
|
|
110
|
+
def get_url(self) -> str:
|
|
111
|
+
"""Return the configured pynosqlc URL."""
|
|
112
|
+
return self._url
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""
|
|
2
|
+
collection.py — Abstract base class for a named collection (table/bucket).
|
|
3
|
+
|
|
4
|
+
Driver implementations override the ``_`` methods. All base ``_`` methods
|
|
5
|
+
raise ``UnsupportedOperationError`` — drivers implement only what their
|
|
6
|
+
backend supports.
|
|
7
|
+
|
|
8
|
+
Operations
|
|
9
|
+
----------
|
|
10
|
+
Key-value : get(key), store(key, doc), delete(key)
|
|
11
|
+
Document : insert(doc), update(key, patch)
|
|
12
|
+
Query : find(ast)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from abc import ABC, abstractmethod
|
|
18
|
+
from typing import TYPE_CHECKING
|
|
19
|
+
|
|
20
|
+
from pynosqlc.core.errors import UnsupportedOperationError
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from pynosqlc.core.client import Client
|
|
24
|
+
from pynosqlc.core.cursor import Cursor
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Collection(ABC):
|
|
28
|
+
"""Represents a named collection within a :class:`Client`."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, client: "Client", name: str) -> None:
|
|
31
|
+
self._client = client
|
|
32
|
+
self._name = name
|
|
33
|
+
self._closed: bool = False
|
|
34
|
+
|
|
35
|
+
def get_name(self) -> str:
|
|
36
|
+
"""Return the collection name."""
|
|
37
|
+
return self._name
|
|
38
|
+
|
|
39
|
+
# ── Public API ─────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
async def get(self, key: str) -> dict | None:
|
|
42
|
+
"""Retrieve a document by its primary key.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
The document dict, or ``None`` if the key does not exist.
|
|
46
|
+
"""
|
|
47
|
+
self._check_closed()
|
|
48
|
+
return await self._get(key)
|
|
49
|
+
|
|
50
|
+
async def store(self, key: str, doc: dict) -> None:
|
|
51
|
+
"""Store (upsert) a document under *key*."""
|
|
52
|
+
self._check_closed()
|
|
53
|
+
await self._store(key, doc)
|
|
54
|
+
|
|
55
|
+
async def delete(self, key: str) -> None:
|
|
56
|
+
"""Delete the document at *key*. No-op if the key does not exist."""
|
|
57
|
+
self._check_closed()
|
|
58
|
+
await self._delete(key)
|
|
59
|
+
|
|
60
|
+
async def insert(self, doc: dict) -> str:
|
|
61
|
+
"""Insert a document and return the backend-assigned key / ``_id``."""
|
|
62
|
+
self._check_closed()
|
|
63
|
+
return await self._insert(doc)
|
|
64
|
+
|
|
65
|
+
async def update(self, key: str, patch: dict) -> None:
|
|
66
|
+
"""Patch the document at *key*.
|
|
67
|
+
|
|
68
|
+
Only provided fields are updated; others are preserved (shallow merge).
|
|
69
|
+
"""
|
|
70
|
+
self._check_closed()
|
|
71
|
+
await self._update(key, patch)
|
|
72
|
+
|
|
73
|
+
async def find(self, ast: dict) -> "Cursor":
|
|
74
|
+
"""Find documents matching the given filter AST.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
ast: a built filter AST from ``Filter.build()``
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
A :class:`~pynosqlc.core.Cursor` over matching documents.
|
|
81
|
+
"""
|
|
82
|
+
self._check_closed()
|
|
83
|
+
return await self._find(ast)
|
|
84
|
+
|
|
85
|
+
# ── Abstract implementation hooks ──────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
async def _get(self, key: str) -> dict | None:
|
|
88
|
+
raise UnsupportedOperationError("get() is not supported by this driver")
|
|
89
|
+
|
|
90
|
+
async def _store(self, key: str, doc: dict) -> None:
|
|
91
|
+
raise UnsupportedOperationError("store() is not supported by this driver")
|
|
92
|
+
|
|
93
|
+
async def _delete(self, key: str) -> None:
|
|
94
|
+
raise UnsupportedOperationError("delete() is not supported by this driver")
|
|
95
|
+
|
|
96
|
+
async def _insert(self, doc: dict) -> str:
|
|
97
|
+
raise UnsupportedOperationError("insert() is not supported by this driver")
|
|
98
|
+
|
|
99
|
+
async def _update(self, key: str, patch: dict) -> None:
|
|
100
|
+
raise UnsupportedOperationError("update() is not supported by this driver")
|
|
101
|
+
|
|
102
|
+
async def _find(self, ast: dict) -> "Cursor":
|
|
103
|
+
raise UnsupportedOperationError("find() is not supported by this driver")
|
|
104
|
+
|
|
105
|
+
# ── Internal helpers ───────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
def _check_closed(self) -> None:
|
|
108
|
+
if self._closed:
|
|
109
|
+
raise RuntimeError("Collection is closed")
|
pynosqlc/core/cursor.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cursor.py — Cursor over a find() result set.
|
|
3
|
+
|
|
4
|
+
Provides cursor-based iteration (next/get_document) plus bulk access
|
|
5
|
+
(get_documents) and implements the async iterator protocol for use with
|
|
6
|
+
``async for``.
|
|
7
|
+
|
|
8
|
+
The base class buffers all results in a list. Driver implementations may
|
|
9
|
+
subclass Cursor to support streaming from the database.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Cursor:
|
|
16
|
+
"""Async-iterable cursor over a collection of documents.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
documents: pre-buffered result list (pass ``[]`` for an empty cursor)
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, documents: list[dict] | None = None) -> None:
|
|
23
|
+
self._documents: list[dict] = documents if documents is not None else []
|
|
24
|
+
self._cursor: int = -1
|
|
25
|
+
self._closed: bool = False
|
|
26
|
+
|
|
27
|
+
async def next(self) -> bool:
|
|
28
|
+
"""Advance to the next document.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
``True`` if there is a current document; ``False`` when exhausted.
|
|
32
|
+
"""
|
|
33
|
+
self._check_closed()
|
|
34
|
+
self._cursor += 1
|
|
35
|
+
return self._cursor < len(self._documents)
|
|
36
|
+
|
|
37
|
+
def get_document(self) -> dict:
|
|
38
|
+
"""Return a shallow copy of the document at the current cursor position.
|
|
39
|
+
|
|
40
|
+
Raises:
|
|
41
|
+
RuntimeError: if the cursor is not on a valid document (call
|
|
42
|
+
``next()`` first)
|
|
43
|
+
RuntimeError: if the cursor is closed
|
|
44
|
+
"""
|
|
45
|
+
self._check_closed()
|
|
46
|
+
self._check_cursor()
|
|
47
|
+
return dict(self._documents[self._cursor])
|
|
48
|
+
|
|
49
|
+
def get_documents(self) -> list[dict]:
|
|
50
|
+
"""Return all documents as a list of shallow copies.
|
|
51
|
+
|
|
52
|
+
Does not require ``next()`` to have been called — returns the full
|
|
53
|
+
buffered result set.
|
|
54
|
+
"""
|
|
55
|
+
self._check_closed()
|
|
56
|
+
return [dict(d) for d in self._documents]
|
|
57
|
+
|
|
58
|
+
async def close(self) -> None:
|
|
59
|
+
"""Close the cursor and release resources."""
|
|
60
|
+
self._closed = True
|
|
61
|
+
|
|
62
|
+
def is_closed(self) -> bool:
|
|
63
|
+
"""Return ``True`` if the cursor has been closed."""
|
|
64
|
+
return self._closed
|
|
65
|
+
|
|
66
|
+
# ── Async iterator protocol ────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
def __aiter__(self) -> "Cursor":
|
|
69
|
+
return self
|
|
70
|
+
|
|
71
|
+
async def __anext__(self) -> dict:
|
|
72
|
+
has_more = await self.next()
|
|
73
|
+
if has_more:
|
|
74
|
+
return self.get_document()
|
|
75
|
+
await self.close()
|
|
76
|
+
raise StopAsyncIteration
|
|
77
|
+
|
|
78
|
+
# ── Internal helpers ───────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
def _check_closed(self) -> None:
|
|
81
|
+
if self._closed:
|
|
82
|
+
raise RuntimeError("Cursor is closed")
|
|
83
|
+
|
|
84
|
+
def _check_cursor(self) -> None:
|
|
85
|
+
if self._cursor < 0 or self._cursor >= len(self._documents):
|
|
86
|
+
raise RuntimeError(
|
|
87
|
+
"Cursor is not on a valid document — call next() first"
|
|
88
|
+
)
|
pynosqlc/core/driver.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""
|
|
2
|
+
driver.py — Abstract base class for pynosqlc drivers.
|
|
3
|
+
|
|
4
|
+
Each driver implementation registers itself with DriverManager on import
|
|
5
|
+
and declares which URL schemes it handles.
|
|
6
|
+
|
|
7
|
+
URL scheme: pynosqlc:<subprotocol>:<connection-details>
|
|
8
|
+
e.g. pynosqlc:mongodb://localhost:27017/mydb
|
|
9
|
+
pynosqlc:memory:
|
|
10
|
+
pynosqlc:dynamodb:us-east-1
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from abc import ABC, abstractmethod
|
|
16
|
+
from typing import TYPE_CHECKING
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from pynosqlc.core.client import Client
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Driver(ABC):
|
|
23
|
+
"""Creates client connections to a specific NoSQL database type."""
|
|
24
|
+
|
|
25
|
+
@abstractmethod
|
|
26
|
+
def accepts_url(self, url: str) -> bool:
|
|
27
|
+
"""Return True if this driver handles the given pynosqlc URL.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
url: e.g. ``'pynosqlc:mongodb://localhost:27017/mydb'``
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
@abstractmethod
|
|
34
|
+
async def connect(self, url: str, properties: dict | None = None) -> "Client":
|
|
35
|
+
"""Create a client connection to the database.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
url: pynosqlc URL
|
|
39
|
+
properties: optional dict with driver-specific options
|
|
40
|
+
(e.g. username, password, endpoint)
|
|
41
|
+
"""
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""
|
|
2
|
+
driver_manager.py — Registry for pynosqlc drivers.
|
|
3
|
+
|
|
4
|
+
Drivers register themselves on import. When get_client() is called,
|
|
5
|
+
DriverManager iterates registered drivers to find one that accepts the URL.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from pynosqlc.core.driver import Driver
|
|
14
|
+
from pynosqlc.core.client import Client
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DriverManager:
|
|
18
|
+
"""Class-level registry for pynosqlc drivers."""
|
|
19
|
+
|
|
20
|
+
_drivers: list["Driver"] = []
|
|
21
|
+
|
|
22
|
+
@classmethod
|
|
23
|
+
def register_driver(cls, driver: "Driver") -> None:
|
|
24
|
+
"""Register a driver instance (idempotent — no duplicates)."""
|
|
25
|
+
if driver not in cls._drivers:
|
|
26
|
+
cls._drivers.append(driver)
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def deregister_driver(cls, driver: "Driver") -> None:
|
|
30
|
+
"""Remove a previously registered driver."""
|
|
31
|
+
cls._drivers = [d for d in cls._drivers if d is not driver]
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
async def get_client(
|
|
35
|
+
cls,
|
|
36
|
+
url: str,
|
|
37
|
+
properties: dict | None = None,
|
|
38
|
+
) -> "Client":
|
|
39
|
+
"""Return a client from the first driver that accepts *url*.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
url: pynosqlc URL (e.g. ``'pynosqlc:memory:'``)
|
|
43
|
+
properties: optional driver-specific connection properties
|
|
44
|
+
|
|
45
|
+
Raises:
|
|
46
|
+
ValueError: if no registered driver accepts the URL
|
|
47
|
+
"""
|
|
48
|
+
if properties is None:
|
|
49
|
+
properties = {}
|
|
50
|
+
for driver in cls._drivers:
|
|
51
|
+
if driver.accepts_url(url):
|
|
52
|
+
return await driver.connect(url, properties)
|
|
53
|
+
raise ValueError(
|
|
54
|
+
f"No suitable driver found for URL: {url!r}. "
|
|
55
|
+
f"Registered drivers: {[type(d).__name__ for d in cls._drivers]}"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def get_drivers(cls) -> list["Driver"]:
|
|
60
|
+
"""Return a copy of the registered driver list."""
|
|
61
|
+
return list(cls._drivers)
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def clear(cls) -> None:
|
|
65
|
+
"""Clear all registered drivers (for testing)."""
|
|
66
|
+
cls._drivers = []
|
pynosqlc/core/errors.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""
|
|
2
|
+
errors.py — Custom exception classes for pynosqlc.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class UnsupportedOperationError(Exception):
|
|
9
|
+
"""Raised when a driver does not implement an optional Collection operation.
|
|
10
|
+
|
|
11
|
+
Callers can ``isinstance``-check this to handle gracefully.
|
|
12
|
+
"""
|
pynosqlc/core/filter.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""
|
|
2
|
+
filter.py — Chainable query filter builder and field-level condition.
|
|
3
|
+
|
|
4
|
+
Usage::
|
|
5
|
+
|
|
6
|
+
ast = Filter.where('age').gt(18).and_('name').eq('Alice').build()
|
|
7
|
+
# → {'type': 'and', 'conditions': [
|
|
8
|
+
# {'type': 'condition', 'field': 'age', 'op': 'gt', 'value': 18},
|
|
9
|
+
# {'type': 'condition', 'field': 'name', 'op': 'eq', 'value': 'Alice'},
|
|
10
|
+
# ]}
|
|
11
|
+
|
|
12
|
+
Compound operators::
|
|
13
|
+
|
|
14
|
+
Filter.or_(filter1.build(), filter2.build())
|
|
15
|
+
# → {'type': 'or', 'conditions': [ast1, ast2]}
|
|
16
|
+
|
|
17
|
+
Filter.where('status').eq('inactive').not_()
|
|
18
|
+
# → {'type': 'not', 'condition': {'type': 'condition', ...}}
|
|
19
|
+
|
|
20
|
+
AST node shapes
|
|
21
|
+
---------------
|
|
22
|
+
Leaf : ``{'type': 'condition', 'field': str, 'op': str, 'value': Any}``
|
|
23
|
+
And : ``{'type': 'and', 'conditions': list[node]}``
|
|
24
|
+
Or : ``{'type': 'or', 'conditions': list[node]}``
|
|
25
|
+
Not : ``{'type': 'not', 'condition': node}``
|
|
26
|
+
|
|
27
|
+
Python keyword avoidance (PEP 8 trailing underscore)
|
|
28
|
+
-----------------------------------------------------
|
|
29
|
+
``and()`` → ``and_()``
|
|
30
|
+
``or()`` → ``or_()`` (classmethod)
|
|
31
|
+
``not()`` → ``not_()``
|
|
32
|
+
FieldCondition: ``in()`` → ``in_()``
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
from typing import Any
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class FieldCondition:
|
|
41
|
+
"""Represents a field-level condition within a :class:`Filter`.
|
|
42
|
+
|
|
43
|
+
Created by :meth:`Filter.where` or :meth:`Filter.and_`.
|
|
44
|
+
Operator methods add the condition to the owning Filter and return
|
|
45
|
+
the Filter for further chaining.
|
|
46
|
+
|
|
47
|
+
Supported operators: eq, ne, gt, gte, lt, lte, contains, in_, nin, exists
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(self, field: str, filter_: "Filter") -> None:
|
|
51
|
+
self._field = field
|
|
52
|
+
self._filter = filter_
|
|
53
|
+
|
|
54
|
+
def eq(self, value: Any) -> "Filter":
|
|
55
|
+
"""Equal: ``field == value``"""
|
|
56
|
+
return self._add("eq", value)
|
|
57
|
+
|
|
58
|
+
def ne(self, value: Any) -> "Filter":
|
|
59
|
+
"""Not equal: ``field != value``"""
|
|
60
|
+
return self._add("ne", value)
|
|
61
|
+
|
|
62
|
+
def gt(self, value: Any) -> "Filter":
|
|
63
|
+
"""Greater than: ``field > value``"""
|
|
64
|
+
return self._add("gt", value)
|
|
65
|
+
|
|
66
|
+
def gte(self, value: Any) -> "Filter":
|
|
67
|
+
"""Greater than or equal: ``field >= value``"""
|
|
68
|
+
return self._add("gte", value)
|
|
69
|
+
|
|
70
|
+
def lt(self, value: Any) -> "Filter":
|
|
71
|
+
"""Less than: ``field < value``"""
|
|
72
|
+
return self._add("lt", value)
|
|
73
|
+
|
|
74
|
+
def lte(self, value: Any) -> "Filter":
|
|
75
|
+
"""Less than or equal: ``field <= value``"""
|
|
76
|
+
return self._add("lte", value)
|
|
77
|
+
|
|
78
|
+
def contains(self, value: Any) -> "Filter":
|
|
79
|
+
"""Contains: field is a string/list that contains *value*."""
|
|
80
|
+
return self._add("contains", value)
|
|
81
|
+
|
|
82
|
+
def in_(self, values: list) -> "Filter":
|
|
83
|
+
"""In: ``field`` is one of *values*."""
|
|
84
|
+
return self._add("in", values)
|
|
85
|
+
|
|
86
|
+
def nin(self, values: list) -> "Filter":
|
|
87
|
+
"""Not in: ``field`` is not one of *values*."""
|
|
88
|
+
return self._add("nin", values)
|
|
89
|
+
|
|
90
|
+
def exists(self, value: bool = True) -> "Filter":
|
|
91
|
+
"""Exists: field is present (True) or absent/None (False)."""
|
|
92
|
+
return self._add("exists", value)
|
|
93
|
+
|
|
94
|
+
# ── Internal ────────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
def _add(self, op: str, value: Any) -> "Filter":
|
|
97
|
+
self._filter._add_condition(
|
|
98
|
+
{"type": "condition", "field": self._field, "op": op, "value": value}
|
|
99
|
+
)
|
|
100
|
+
return self._filter
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class Filter:
|
|
104
|
+
"""Chainable query filter builder.
|
|
105
|
+
|
|
106
|
+
Build a filter using the fluent API, then call :meth:`build` to produce
|
|
107
|
+
the AST dict passed to :meth:`~pynosqlc.core.Collection.find`.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
def __init__(self) -> None:
|
|
111
|
+
self._conditions: list[dict] = []
|
|
112
|
+
|
|
113
|
+
@classmethod
|
|
114
|
+
def where(cls, field: str) -> FieldCondition:
|
|
115
|
+
"""Start a new filter on *field*.
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
A :class:`FieldCondition` whose operator methods return the
|
|
119
|
+
owning :class:`Filter` for further chaining.
|
|
120
|
+
"""
|
|
121
|
+
f = cls()
|
|
122
|
+
return FieldCondition(field, f)
|
|
123
|
+
|
|
124
|
+
@classmethod
|
|
125
|
+
def or_(cls, *filters: "dict | Filter") -> dict:
|
|
126
|
+
"""Create an OR compound of two or more AST nodes or Filter instances.
|
|
127
|
+
|
|
128
|
+
Each argument may be a built AST dict (result of ``filter.build()``)
|
|
129
|
+
or a :class:`Filter` instance (``build()`` is called automatically).
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
``{'type': 'or', 'conditions': [...]}``
|
|
133
|
+
"""
|
|
134
|
+
conditions = [f.build() if isinstance(f, Filter) else f for f in filters]
|
|
135
|
+
return {"type": "or", "conditions": conditions}
|
|
136
|
+
|
|
137
|
+
def and_(self, field: str) -> FieldCondition:
|
|
138
|
+
"""Chain an additional AND condition on a new *field*.
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
A :class:`FieldCondition` whose operator methods return this
|
|
142
|
+
:class:`Filter` for further chaining.
|
|
143
|
+
"""
|
|
144
|
+
return FieldCondition(field, self)
|
|
145
|
+
|
|
146
|
+
def not_(self) -> dict:
|
|
147
|
+
"""Negate this filter.
|
|
148
|
+
|
|
149
|
+
Calls :meth:`build` internally and wraps the result in a not node.
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
``{'type': 'not', 'condition': <ast>}``
|
|
153
|
+
"""
|
|
154
|
+
return {"type": "not", "condition": self.build()}
|
|
155
|
+
|
|
156
|
+
def build(self) -> dict:
|
|
157
|
+
"""Build and return the filter AST.
|
|
158
|
+
|
|
159
|
+
- Zero conditions → ``{'type': 'and', 'conditions': []}``
|
|
160
|
+
- Single condition → the leaf node directly
|
|
161
|
+
- Multiple conditions → ``{'type': 'and', 'conditions': [...]}``
|
|
162
|
+
|
|
163
|
+
Each call returns a fresh copy — mutating the result does not affect
|
|
164
|
+
the Filter.
|
|
165
|
+
"""
|
|
166
|
+
if not self._conditions:
|
|
167
|
+
return {"type": "and", "conditions": []}
|
|
168
|
+
if len(self._conditions) == 1:
|
|
169
|
+
return dict(self._conditions[0])
|
|
170
|
+
return {"type": "and", "conditions": [dict(c) for c in self._conditions]}
|
|
171
|
+
|
|
172
|
+
# ── Internal ────────────────────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
def _add_condition(self, node: dict) -> None:
|
|
175
|
+
"""Append a condition node (called by FieldCondition)."""
|
|
176
|
+
self._conditions.append(node)
|
pynosqlc/core/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""
|
|
2
|
+
compliance.py — Shared driver compliance test suite for pynosqlc drivers.
|
|
3
|
+
|
|
4
|
+
Usage in a driver package's test file::
|
|
5
|
+
|
|
6
|
+
from pynosqlc.core.testing import run_compliance
|
|
7
|
+
from pynosqlc.core import DriverManager
|
|
8
|
+
import pynosqlc.memory
|
|
9
|
+
|
|
10
|
+
async def _factory():
|
|
11
|
+
DriverManager.clear()
|
|
12
|
+
import pynosqlc.memory # re-registers the driver
|
|
13
|
+
return await DriverManager.get_client('pynosqlc:memory:')
|
|
14
|
+
|
|
15
|
+
run_compliance(_factory)
|
|
16
|
+
|
|
17
|
+
``run_compliance`` uses ``sys._getframe(1).f_globals`` to inject a set of
|
|
18
|
+
pytest ``Test*`` classes directly into the calling module's namespace so that
|
|
19
|
+
pytest's standard discovery collects them automatically.
|
|
20
|
+
|
|
21
|
+
Arguments:
|
|
22
|
+
client_factory: ``async def () -> Client`` — called before each test class
|
|
23
|
+
to produce a fresh, open client.
|
|
24
|
+
skip_find: if ``True``, the find-operator test class is omitted
|
|
25
|
+
(useful for backends that do not support scan/filter).
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import sys
|
|
31
|
+
from typing import Any, Callable, Coroutine
|
|
32
|
+
|
|
33
|
+
import pytest
|
|
34
|
+
|
|
35
|
+
from pynosqlc.core.filter import Filter
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def run_compliance(
|
|
39
|
+
client_factory: Callable[[], Coroutine[Any, Any, Any]],
|
|
40
|
+
*,
|
|
41
|
+
skip_find: bool = False,
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Register compliance test classes into the calling module's namespace.
|
|
44
|
+
|
|
45
|
+
Pytest discovers ``Test*`` classes from module globals at collection time.
|
|
46
|
+
This function injects those classes before collection completes by writing
|
|
47
|
+
directly into ``sys._getframe(1).f_globals``.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
client_factory: Async callable returning an open :class:`~pynosqlc.core.Client`.
|
|
51
|
+
skip_find: When ``True``, the find-operator test class is not registered.
|
|
52
|
+
"""
|
|
53
|
+
caller_globals = sys._getframe(1).f_globals
|
|
54
|
+
|
|
55
|
+
# ── KV test class ──────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
class TestKVCompliance:
|
|
58
|
+
"""Key-value compliance: store/get, delete, upsert."""
|
|
59
|
+
|
|
60
|
+
@pytest.fixture(autouse=True)
|
|
61
|
+
async def _setup(self):
|
|
62
|
+
self.client = await client_factory()
|
|
63
|
+
self.col = self.client.get_collection("compliance_kv")
|
|
64
|
+
yield
|
|
65
|
+
if not self.client.is_closed():
|
|
66
|
+
await self.client.close()
|
|
67
|
+
|
|
68
|
+
async def test_store_and_get_retrieves_document(self):
|
|
69
|
+
await self.col.store("kv-1", {"name": "Alice", "age": 30})
|
|
70
|
+
doc = await self.col.get("kv-1")
|
|
71
|
+
assert doc is not None
|
|
72
|
+
assert doc["name"] == "Alice"
|
|
73
|
+
assert doc["age"] == 30
|
|
74
|
+
|
|
75
|
+
async def test_get_returns_none_for_missing_key(self):
|
|
76
|
+
import time
|
|
77
|
+
doc = await self.col.get(f"kv-nonexistent-{int(time.time() * 1000)}")
|
|
78
|
+
assert doc is None
|
|
79
|
+
|
|
80
|
+
async def test_store_overwrites_existing_document(self):
|
|
81
|
+
await self.col.store("kv-upsert", {"name": "Bob", "age": 25})
|
|
82
|
+
await self.col.store("kv-upsert", {"name": "Bob", "age": 26})
|
|
83
|
+
doc = await self.col.get("kv-upsert")
|
|
84
|
+
assert doc is not None
|
|
85
|
+
assert doc["age"] == 26
|
|
86
|
+
|
|
87
|
+
async def test_delete_removes_document(self):
|
|
88
|
+
await self.col.store("kv-del", {"name": "ToDelete"})
|
|
89
|
+
await self.col.delete("kv-del")
|
|
90
|
+
doc = await self.col.get("kv-del")
|
|
91
|
+
assert doc is None
|
|
92
|
+
|
|
93
|
+
async def test_delete_missing_key_does_not_raise(self):
|
|
94
|
+
import time
|
|
95
|
+
await self.col.delete(f"kv-missing-{int(time.time() * 1000)}")
|
|
96
|
+
|
|
97
|
+
# ── Document test class ────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
class TestDocumentCompliance:
|
|
100
|
+
"""Document compliance: insert, update."""
|
|
101
|
+
|
|
102
|
+
@pytest.fixture(autouse=True)
|
|
103
|
+
async def _setup(self):
|
|
104
|
+
self.client = await client_factory()
|
|
105
|
+
self.col = self.client.get_collection("compliance_doc")
|
|
106
|
+
yield
|
|
107
|
+
if not self.client.is_closed():
|
|
108
|
+
await self.client.close()
|
|
109
|
+
|
|
110
|
+
async def test_insert_returns_an_id(self):
|
|
111
|
+
id_ = await self.col.insert({"name": "Charlie", "status": "active"})
|
|
112
|
+
assert isinstance(id_, str)
|
|
113
|
+
assert len(id_) > 0
|
|
114
|
+
|
|
115
|
+
async def test_inserted_document_retrievable_by_id(self):
|
|
116
|
+
id_ = await self.col.insert({"name": "Dana", "score": 99})
|
|
117
|
+
doc = await self.col.get(id_)
|
|
118
|
+
assert doc is not None
|
|
119
|
+
assert doc["name"] == "Dana"
|
|
120
|
+
|
|
121
|
+
async def test_two_inserts_produce_different_ids(self):
|
|
122
|
+
id1 = await self.col.insert({"name": "E1"})
|
|
123
|
+
id2 = await self.col.insert({"name": "E2"})
|
|
124
|
+
assert id1 != id2
|
|
125
|
+
|
|
126
|
+
async def test_update_patches_fields_without_destroying_others(self):
|
|
127
|
+
await self.col.store("upd-1", {"name": "Frank", "age": 40, "country": "AU"})
|
|
128
|
+
await self.col.update("upd-1", {"age": 41})
|
|
129
|
+
doc = await self.col.get("upd-1")
|
|
130
|
+
assert doc is not None
|
|
131
|
+
assert doc["age"] == 41
|
|
132
|
+
assert doc["name"] == "Frank"
|
|
133
|
+
assert doc["country"] == "AU"
|
|
134
|
+
|
|
135
|
+
# ── Lifecycle test class ───────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
class TestLifecycleCompliance:
|
|
138
|
+
"""Client lifecycle compliance."""
|
|
139
|
+
|
|
140
|
+
@pytest.fixture(autouse=True)
|
|
141
|
+
async def _setup(self):
|
|
142
|
+
self.client = await client_factory()
|
|
143
|
+
yield
|
|
144
|
+
if not self.client.is_closed():
|
|
145
|
+
await self.client.close()
|
|
146
|
+
|
|
147
|
+
def test_get_collection_returns_same_instance(self):
|
|
148
|
+
c1 = self.client.get_collection("same-name")
|
|
149
|
+
c2 = self.client.get_collection("same-name")
|
|
150
|
+
assert c1 is c2
|
|
151
|
+
|
|
152
|
+
def test_is_closed_false_for_open_client(self):
|
|
153
|
+
assert self.client.is_closed() is False
|
|
154
|
+
|
|
155
|
+
# ── Register classes into caller's module globals ──────────────────────
|
|
156
|
+
|
|
157
|
+
caller_globals["TestKVCompliance"] = TestKVCompliance
|
|
158
|
+
caller_globals["TestDocumentCompliance"] = TestDocumentCompliance
|
|
159
|
+
caller_globals["TestLifecycleCompliance"] = TestLifecycleCompliance
|
|
160
|
+
|
|
161
|
+
# ── Find test class (optional) ─────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
if skip_find:
|
|
164
|
+
return
|
|
165
|
+
|
|
166
|
+
class TestFindCompliance:
|
|
167
|
+
"""Find-with-filter compliance: all operators + cursor iteration."""
|
|
168
|
+
|
|
169
|
+
# Seed data: same 5 docs as JS compliance suite
|
|
170
|
+
_SEED = [
|
|
171
|
+
("f1", {"name": "Alice", "age": 30, "status": "active", "tags": ["js", "ts"], "score": 85}),
|
|
172
|
+
("f2", {"name": "Bob", "age": 25, "status": "inactive", "tags": ["py"], "score": 70}),
|
|
173
|
+
("f3", {"name": "Charlie", "age": 35, "status": "active", "tags": ["js", "go"], "score": 90}),
|
|
174
|
+
("f4", {"name": "Dana", "age": 22, "status": "pending", "tags": ["ts"], "score": 60}),
|
|
175
|
+
("f5", {"name": "Eve", "age": 30, "status": "active", "score": 95, "email": "eve@example.com"}),
|
|
176
|
+
]
|
|
177
|
+
|
|
178
|
+
@pytest.fixture(autouse=True)
|
|
179
|
+
async def _setup(self):
|
|
180
|
+
self.client = await client_factory()
|
|
181
|
+
self.fcol = self.client.get_collection("compliance_find")
|
|
182
|
+
for key, doc in self._SEED:
|
|
183
|
+
await self.fcol.store(key, doc)
|
|
184
|
+
yield
|
|
185
|
+
if not self.client.is_closed():
|
|
186
|
+
await self.client.close()
|
|
187
|
+
|
|
188
|
+
async def test_eq_finds_matching_documents(self):
|
|
189
|
+
cursor = await self.fcol.find(Filter.where("status").eq("active").build())
|
|
190
|
+
docs = cursor.get_documents()
|
|
191
|
+
await cursor.close()
|
|
192
|
+
assert len(docs) == 3
|
|
193
|
+
assert all(d["status"] == "active" for d in docs)
|
|
194
|
+
|
|
195
|
+
async def test_ne_finds_non_matching_documents(self):
|
|
196
|
+
cursor = await self.fcol.find(Filter.where("status").ne("active").build())
|
|
197
|
+
docs = cursor.get_documents()
|
|
198
|
+
await cursor.close()
|
|
199
|
+
assert len(docs) == 2
|
|
200
|
+
assert all(d["status"] != "active" for d in docs)
|
|
201
|
+
|
|
202
|
+
async def test_gt_finds_documents_greater_than_value(self):
|
|
203
|
+
cursor = await self.fcol.find(Filter.where("age").gt(29).build())
|
|
204
|
+
docs = cursor.get_documents()
|
|
205
|
+
await cursor.close()
|
|
206
|
+
assert len(docs) >= 2
|
|
207
|
+
assert all(d["age"] > 29 for d in docs)
|
|
208
|
+
|
|
209
|
+
async def test_lt_finds_documents_less_than_value(self):
|
|
210
|
+
cursor = await self.fcol.find(Filter.where("age").lt(25).build())
|
|
211
|
+
docs = cursor.get_documents()
|
|
212
|
+
await cursor.close()
|
|
213
|
+
assert all(d["age"] < 25 for d in docs)
|
|
214
|
+
|
|
215
|
+
async def test_gte_finds_documents_gte_value(self):
|
|
216
|
+
cursor = await self.fcol.find(Filter.where("score").gte(90).build())
|
|
217
|
+
docs = cursor.get_documents()
|
|
218
|
+
await cursor.close()
|
|
219
|
+
assert all(d["score"] >= 90 for d in docs)
|
|
220
|
+
assert len(docs) >= 2
|
|
221
|
+
|
|
222
|
+
async def test_lte_finds_documents_lte_value(self):
|
|
223
|
+
cursor = await self.fcol.find(Filter.where("score").lte(70).build())
|
|
224
|
+
docs = cursor.get_documents()
|
|
225
|
+
await cursor.close()
|
|
226
|
+
assert all(d["score"] <= 70 for d in docs)
|
|
227
|
+
|
|
228
|
+
async def test_contains_finds_array_field_contains_value(self):
|
|
229
|
+
cursor = await self.fcol.find(Filter.where("tags").contains("js").build())
|
|
230
|
+
docs = cursor.get_documents()
|
|
231
|
+
await cursor.close()
|
|
232
|
+
assert len(docs) >= 2
|
|
233
|
+
assert all("js" in d["tags"] for d in docs)
|
|
234
|
+
|
|
235
|
+
async def test_in_finds_documents_with_field_in_values(self):
|
|
236
|
+
cursor = await self.fcol.find(
|
|
237
|
+
Filter.where("status").in_(["active", "pending"]).build()
|
|
238
|
+
)
|
|
239
|
+
docs = cursor.get_documents()
|
|
240
|
+
await cursor.close()
|
|
241
|
+
assert all(d["status"] in ["active", "pending"] for d in docs)
|
|
242
|
+
assert len(docs) >= 3
|
|
243
|
+
|
|
244
|
+
async def test_nin_finds_documents_with_field_not_in_values(self):
|
|
245
|
+
cursor = await self.fcol.find(
|
|
246
|
+
Filter.where("status").nin(["inactive", "pending"]).build()
|
|
247
|
+
)
|
|
248
|
+
docs = cursor.get_documents()
|
|
249
|
+
await cursor.close()
|
|
250
|
+
assert all(d["status"] not in ["inactive", "pending"] for d in docs)
|
|
251
|
+
|
|
252
|
+
async def test_exists_true_finds_documents_where_field_present(self):
|
|
253
|
+
cursor = await self.fcol.find(Filter.where("email").exists(True).build())
|
|
254
|
+
docs = cursor.get_documents()
|
|
255
|
+
await cursor.close()
|
|
256
|
+
assert len(docs) >= 1
|
|
257
|
+
assert all(d.get("email") is not None for d in docs)
|
|
258
|
+
|
|
259
|
+
async def test_exists_false_finds_documents_where_field_absent(self):
|
|
260
|
+
cursor = await self.fcol.find(Filter.where("email").exists(False).build())
|
|
261
|
+
docs = cursor.get_documents()
|
|
262
|
+
await cursor.close()
|
|
263
|
+
assert all(d.get("email") is None for d in docs)
|
|
264
|
+
|
|
265
|
+
async def test_compound_and_applies_both_conditions(self):
|
|
266
|
+
ast = Filter.where("status").eq("active").and_("age").gt(29).build()
|
|
267
|
+
cursor = await self.fcol.find(ast)
|
|
268
|
+
docs = cursor.get_documents()
|
|
269
|
+
await cursor.close()
|
|
270
|
+
assert all(d["status"] == "active" and d["age"] > 29 for d in docs)
|
|
271
|
+
assert len(docs) >= 1
|
|
272
|
+
|
|
273
|
+
async def test_cursor_for_await_iteration(self):
|
|
274
|
+
cursor = await self.fcol.find(Filter.where("status").eq("active").build())
|
|
275
|
+
docs = []
|
|
276
|
+
async for doc in cursor:
|
|
277
|
+
docs.append(doc)
|
|
278
|
+
assert len(docs) >= 3
|
|
279
|
+
|
|
280
|
+
caller_globals["TestFindCompliance"] = TestFindCompliance
|