alt-python-pynosqlc-cosmosdb 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- alt_python_pynosqlc_cosmosdb-1.0.0/.gitignore +30 -0
- alt_python_pynosqlc_cosmosdb-1.0.0/PKG-INFO +97 -0
- alt_python_pynosqlc_cosmosdb-1.0.0/README.md +73 -0
- alt_python_pynosqlc_cosmosdb-1.0.0/pynosqlc/cosmosdb/__init__.py +12 -0
- alt_python_pynosqlc_cosmosdb-1.0.0/pynosqlc/cosmosdb/cosmos_client.py +94 -0
- alt_python_pynosqlc_cosmosdb-1.0.0/pynosqlc/cosmosdb/cosmos_collection.py +114 -0
- alt_python_pynosqlc_cosmosdb-1.0.0/pynosqlc/cosmosdb/cosmos_driver.py +92 -0
- alt_python_pynosqlc_cosmosdb-1.0.0/pynosqlc/cosmosdb/cosmos_filter_translator.py +211 -0
- alt_python_pynosqlc_cosmosdb-1.0.0/pyproject.toml +46 -0
- alt_python_pynosqlc_cosmosdb-1.0.0/tests/__init__.py +0 -0
- alt_python_pynosqlc_cosmosdb-1.0.0/tests/test_compliance.py +61 -0
- alt_python_pynosqlc_cosmosdb-1.0.0/tests/test_cosmos_filter_translator.py +378 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
|
|
2
|
+
# ── GSD baseline (auto-generated) ──
|
|
3
|
+
.gsd
|
|
4
|
+
.DS_Store
|
|
5
|
+
Thumbs.db
|
|
6
|
+
*.swp
|
|
7
|
+
*.swo
|
|
8
|
+
*~
|
|
9
|
+
.idea/
|
|
10
|
+
.vscode/
|
|
11
|
+
*.code-workspace
|
|
12
|
+
.env
|
|
13
|
+
.env.*
|
|
14
|
+
!.env.example
|
|
15
|
+
node_modules/
|
|
16
|
+
.next/
|
|
17
|
+
dist/
|
|
18
|
+
build/
|
|
19
|
+
__pycache__/
|
|
20
|
+
*.pyc
|
|
21
|
+
.venv/
|
|
22
|
+
venv/
|
|
23
|
+
target/
|
|
24
|
+
vendor/
|
|
25
|
+
*.log
|
|
26
|
+
coverage/
|
|
27
|
+
.cache/
|
|
28
|
+
tmp/
|
|
29
|
+
|
|
30
|
+
/.bg-shell/
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: alt-python-pynosqlc-cosmosdb
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: CosmosDB driver for pynosqlc
|
|
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,azure,cosmosdb,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
|
+
Requires-Python: >=3.12
|
|
21
|
+
Requires-Dist: alt-python-pynosqlc-core
|
|
22
|
+
Requires-Dist: azure-cosmos>=4.7
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# pynosqlc-cosmosdb
|
|
26
|
+
|
|
27
|
+
Azure Cosmos DB driver for [pynosqlc](https://github.com/alt-python/pynosqlc) — connects to Azure Cosmos DB (or the local emulator) via the azure-cosmos async client.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
pip install alt-python-pynosqlc-cosmosdb
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Requirements
|
|
36
|
+
|
|
37
|
+
- Python 3.12+
|
|
38
|
+
- azure-cosmos 4.7+
|
|
39
|
+
- An Azure Cosmos DB account or the [Cosmos DB emulator](https://docs.microsoft.com/azure/cosmos-db/local-emulator)
|
|
40
|
+
|
|
41
|
+
## Usage
|
|
42
|
+
|
|
43
|
+
### Azure (production)
|
|
44
|
+
|
|
45
|
+
Pass the account endpoint in the URL and the account key in the properties dict:
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
import asyncio
|
|
49
|
+
from pynosqlc.core import DriverManager, Filter
|
|
50
|
+
import pynosqlc.cosmosdb # auto-registers CosmosDriver
|
|
51
|
+
|
|
52
|
+
async def main():
|
|
53
|
+
async with await DriverManager.get_client(
|
|
54
|
+
'pynosqlc:cosmosdb:https://myaccount.documents.azure.com:443/',
|
|
55
|
+
properties={'key': '<your-account-key>', 'db_id': 'mydb'},
|
|
56
|
+
) as client:
|
|
57
|
+
col = client.get_collection('orders')
|
|
58
|
+
await col.store('o1', {'item': 'widget', 'qty': 5})
|
|
59
|
+
f = Filter.where('qty').gt(0).build()
|
|
60
|
+
async for doc in await col.find(f):
|
|
61
|
+
print(doc)
|
|
62
|
+
|
|
63
|
+
asyncio.run(main())
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Cosmos DB Emulator (local)
|
|
67
|
+
|
|
68
|
+
Use `local` as the target — the driver connects to `http://localhost:8081` with
|
|
69
|
+
the well-known emulator master key automatically:
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
async with await DriverManager.get_client(
|
|
73
|
+
'pynosqlc:cosmosdb:local',
|
|
74
|
+
properties={'db_id': 'mydb'},
|
|
75
|
+
) as client:
|
|
76
|
+
...
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## URL scheme
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
pynosqlc:cosmosdb:<endpoint-or-local>
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
| Target | Resolves to |
|
|
86
|
+
|---|---|
|
|
87
|
+
| `local` or `localhost` | `http://localhost:8081` with emulator master key |
|
|
88
|
+
| `localhost:PORT` | `http://localhost:PORT` with emulator master key |
|
|
89
|
+
| `https://...` | Azure Cosmos DB account endpoint (requires `key` property) |
|
|
90
|
+
|
|
91
|
+
Optional properties:
|
|
92
|
+
|
|
93
|
+
| Key | Description |
|
|
94
|
+
|---|---|
|
|
95
|
+
| `key` | Account key (required for `https://` targets; omitted for emulator) |
|
|
96
|
+
| `db_id` | Database name (default: `'pynosqlc'`) |
|
|
97
|
+
| `endpoint` | Override endpoint URL (for `local`/`localhost` targets) |
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# pynosqlc-cosmosdb
|
|
2
|
+
|
|
3
|
+
Azure Cosmos DB driver for [pynosqlc](https://github.com/alt-python/pynosqlc) — connects to Azure Cosmos DB (or the local emulator) via the azure-cosmos async client.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
pip install alt-python-pynosqlc-cosmosdb
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Requirements
|
|
12
|
+
|
|
13
|
+
- Python 3.12+
|
|
14
|
+
- azure-cosmos 4.7+
|
|
15
|
+
- An Azure Cosmos DB account or the [Cosmos DB emulator](https://docs.microsoft.com/azure/cosmos-db/local-emulator)
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
### Azure (production)
|
|
20
|
+
|
|
21
|
+
Pass the account endpoint in the URL and the account key in the properties dict:
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
import asyncio
|
|
25
|
+
from pynosqlc.core import DriverManager, Filter
|
|
26
|
+
import pynosqlc.cosmosdb # auto-registers CosmosDriver
|
|
27
|
+
|
|
28
|
+
async def main():
|
|
29
|
+
async with await DriverManager.get_client(
|
|
30
|
+
'pynosqlc:cosmosdb:https://myaccount.documents.azure.com:443/',
|
|
31
|
+
properties={'key': '<your-account-key>', 'db_id': 'mydb'},
|
|
32
|
+
) as client:
|
|
33
|
+
col = client.get_collection('orders')
|
|
34
|
+
await col.store('o1', {'item': 'widget', 'qty': 5})
|
|
35
|
+
f = Filter.where('qty').gt(0).build()
|
|
36
|
+
async for doc in await col.find(f):
|
|
37
|
+
print(doc)
|
|
38
|
+
|
|
39
|
+
asyncio.run(main())
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Cosmos DB Emulator (local)
|
|
43
|
+
|
|
44
|
+
Use `local` as the target — the driver connects to `http://localhost:8081` with
|
|
45
|
+
the well-known emulator master key automatically:
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
async with await DriverManager.get_client(
|
|
49
|
+
'pynosqlc:cosmosdb:local',
|
|
50
|
+
properties={'db_id': 'mydb'},
|
|
51
|
+
) as client:
|
|
52
|
+
...
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## URL scheme
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
pynosqlc:cosmosdb:<endpoint-or-local>
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
| Target | Resolves to |
|
|
62
|
+
|---|---|
|
|
63
|
+
| `local` or `localhost` | `http://localhost:8081` with emulator master key |
|
|
64
|
+
| `localhost:PORT` | `http://localhost:PORT` with emulator master key |
|
|
65
|
+
| `https://...` | Azure Cosmos DB account endpoint (requires `key` property) |
|
|
66
|
+
|
|
67
|
+
Optional properties:
|
|
68
|
+
|
|
69
|
+
| Key | Description |
|
|
70
|
+
|---|---|
|
|
71
|
+
| `key` | Account key (required for `https://` targets; omitted for emulator) |
|
|
72
|
+
| `db_id` | Database name (default: `'pynosqlc'`) |
|
|
73
|
+
| `endpoint` | Override endpoint URL (for `local`/`localhost` targets) |
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""
|
|
2
|
+
pynosqlc.cosmosdb — Azure Cosmos DB driver for pynosqlc.
|
|
3
|
+
|
|
4
|
+
Importing this package auto-registers the CosmosDriver with the global
|
|
5
|
+
DriverManager via the module-level ``_driver`` sentinel in cosmos_driver.py.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from pynosqlc.cosmosdb.cosmos_driver import CosmosDriver, _driver
|
|
9
|
+
from pynosqlc.cosmosdb.cosmos_client import CosmosClient
|
|
10
|
+
from pynosqlc.cosmosdb.cosmos_collection import CosmosCollection
|
|
11
|
+
|
|
12
|
+
__all__ = ["CosmosDriver", "CosmosClient", "CosmosCollection", "_driver"]
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cosmos_client.py — CosmosClient: a pynosqlc Client backed by azure-cosmos aio.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from azure.cosmos import PartitionKey
|
|
10
|
+
from azure.cosmos.aio import CosmosClient as NativeCosmosClient
|
|
11
|
+
|
|
12
|
+
from pynosqlc.core.client import Client
|
|
13
|
+
from pynosqlc.cosmosdb.cosmos_collection import CosmosCollection
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CosmosClient(Client):
|
|
17
|
+
"""A pynosqlc Client backed by :class:`azure.cosmos.aio.CosmosClient`.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
url: the original ``pynosqlc:cosmosdb:<target>`` URL.
|
|
21
|
+
endpoint: the Cosmos DB endpoint URL.
|
|
22
|
+
key: the Cosmos DB account key.
|
|
23
|
+
db_id: the database name to use (created if it does not exist).
|
|
24
|
+
client_kwargs: extra keyword arguments forwarded to
|
|
25
|
+
:class:`azure.cosmos.aio.CosmosClient`.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
url: str,
|
|
31
|
+
endpoint: str,
|
|
32
|
+
key: str,
|
|
33
|
+
db_id: str,
|
|
34
|
+
client_kwargs: dict | None = None,
|
|
35
|
+
) -> None:
|
|
36
|
+
super().__init__({"url": url})
|
|
37
|
+
self._endpoint = endpoint
|
|
38
|
+
self._key = key
|
|
39
|
+
self._db_id = db_id
|
|
40
|
+
self._client_kwargs: dict = client_kwargs or {}
|
|
41
|
+
self._native_ctx: NativeCosmosClient | None = None
|
|
42
|
+
self._database: Any = None
|
|
43
|
+
self._container_cache: dict[str, Any] = {}
|
|
44
|
+
|
|
45
|
+
async def _open(self) -> None:
|
|
46
|
+
"""Open the native Cosmos DB client and resolve the target database.
|
|
47
|
+
|
|
48
|
+
Must be called by the driver after constructing this client.
|
|
49
|
+
"""
|
|
50
|
+
self._native_ctx = NativeCosmosClient(
|
|
51
|
+
url=self._endpoint,
|
|
52
|
+
credential=self._key,
|
|
53
|
+
**self._client_kwargs,
|
|
54
|
+
)
|
|
55
|
+
await self._native_ctx.__aenter__()
|
|
56
|
+
self._database = await self._native_ctx.create_database_if_not_exists(
|
|
57
|
+
id=self._db_id
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
async def _close(self) -> None:
|
|
61
|
+
"""Exit the native Cosmos DB client context manager."""
|
|
62
|
+
if self._native_ctx is not None:
|
|
63
|
+
await self._native_ctx.__aexit__(None, None, None)
|
|
64
|
+
self._native_ctx = None
|
|
65
|
+
self._database = None
|
|
66
|
+
self._container_cache.clear()
|
|
67
|
+
|
|
68
|
+
def _get_collection(self, name: str) -> CosmosCollection:
|
|
69
|
+
"""Create and return a :class:`CosmosCollection` for *name*."""
|
|
70
|
+
return CosmosCollection(self, name)
|
|
71
|
+
|
|
72
|
+
async def ensure_container(self, name: str) -> Any:
|
|
73
|
+
"""Return a Cosmos DB container proxy for *name*, creating it if needed.
|
|
74
|
+
|
|
75
|
+
Uses ``/id`` as the partition key path. The container proxy is cached
|
|
76
|
+
after the first successful call so the SDK round-trip is only paid once
|
|
77
|
+
per container name per client lifetime.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
name: the container (collection) name.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
The :class:`azure.cosmos.aio.ContainerProxy` for the container.
|
|
84
|
+
"""
|
|
85
|
+
if name in self._container_cache:
|
|
86
|
+
return self._container_cache[name]
|
|
87
|
+
|
|
88
|
+
container = await self._database.create_container_if_not_exists(
|
|
89
|
+
id=name,
|
|
90
|
+
# PartitionKey must come from azure.cosmos (sync), not azure.cosmos.aio
|
|
91
|
+
partition_key=PartitionKey(path="/id"),
|
|
92
|
+
)
|
|
93
|
+
self._container_cache[name] = container
|
|
94
|
+
return container
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cosmos_collection.py — CosmosCollection: a pynosqlc Collection backed by a
|
|
3
|
+
Cosmos DB container.
|
|
4
|
+
|
|
5
|
+
The document primary key is stored as the Cosmos DB item ``id`` field.
|
|
6
|
+
Internal Cosmos DB metadata fields (prefixed with ``_``) are stripped from all
|
|
7
|
+
returned documents.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import uuid
|
|
13
|
+
|
|
14
|
+
from azure.cosmos.exceptions import CosmosResourceNotFoundError
|
|
15
|
+
|
|
16
|
+
from pynosqlc.core.collection import Collection
|
|
17
|
+
from pynosqlc.core.cursor import Cursor
|
|
18
|
+
from pynosqlc.cosmosdb.cosmos_filter_translator import CosmosFilterTranslator
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _strip_internal(item: dict) -> dict:
|
|
22
|
+
"""Remove Cosmos DB internal metadata fields (those starting with ``_``).
|
|
23
|
+
|
|
24
|
+
The ``id`` field is preserved — it is the user-visible document key.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
item: a raw Cosmos DB item dict.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
The item with all ``_``-prefixed keys removed.
|
|
31
|
+
"""
|
|
32
|
+
return {k: v for k, v in item.items() if not k.startswith("_")}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class CosmosCollection(Collection):
|
|
36
|
+
"""A pynosqlc Collection backed by a Cosmos DB container.
|
|
37
|
+
|
|
38
|
+
The container is created on first access with ``/id`` as the partition key.
|
|
39
|
+
:meth:`CosmosClient.ensure_container` is called at the start of every
|
|
40
|
+
operation — it is a no-op after the first successful call.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
client: the owning :class:`~pynosqlc.cosmosdb.CosmosClient`.
|
|
44
|
+
name: the container / collection name.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(self, client, name: str) -> None:
|
|
48
|
+
super().__init__(client, name)
|
|
49
|
+
|
|
50
|
+
async def _get(self, key: str) -> dict | None:
|
|
51
|
+
"""Retrieve a document by its ``id``."""
|
|
52
|
+
container = await self._client.ensure_container(self._name)
|
|
53
|
+
try:
|
|
54
|
+
item = await container.read_item(item=key, partition_key=key)
|
|
55
|
+
except CosmosResourceNotFoundError:
|
|
56
|
+
return None
|
|
57
|
+
return _strip_internal(item)
|
|
58
|
+
|
|
59
|
+
async def _store(self, key: str, doc: dict) -> None:
|
|
60
|
+
"""Upsert a document, setting ``id = key``."""
|
|
61
|
+
container = await self._client.ensure_container(self._name)
|
|
62
|
+
await container.upsert_item(body={**doc, "id": key})
|
|
63
|
+
|
|
64
|
+
async def _delete(self, key: str) -> None:
|
|
65
|
+
"""Delete the document at ``id = key``. Silent if not found."""
|
|
66
|
+
container = await self._client.ensure_container(self._name)
|
|
67
|
+
try:
|
|
68
|
+
await container.delete_item(item=key, partition_key=key)
|
|
69
|
+
except CosmosResourceNotFoundError:
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
async def _insert(self, doc: dict) -> str:
|
|
73
|
+
"""Insert a document with a generated UUID ``id``; return the id."""
|
|
74
|
+
container = await self._client.ensure_container(self._name)
|
|
75
|
+
id_ = str(uuid.uuid4())
|
|
76
|
+
await container.upsert_item(body={**doc, "id": id_})
|
|
77
|
+
return id_
|
|
78
|
+
|
|
79
|
+
async def _update(self, key: str, patch: dict) -> None:
|
|
80
|
+
"""Patch the document at ``id = key`` by merging *patch* into it.
|
|
81
|
+
|
|
82
|
+
Reads the current document, merges the patch (shallow), and upserts.
|
|
83
|
+
The ``id`` field is always preserved as *key*.
|
|
84
|
+
"""
|
|
85
|
+
container = await self._client.ensure_container(self._name)
|
|
86
|
+
existing = await self._get(key) or {}
|
|
87
|
+
merged = {**existing, **patch, "id": key}
|
|
88
|
+
await container.upsert_item(body=merged)
|
|
89
|
+
|
|
90
|
+
async def _find(self, ast: dict) -> Cursor:
|
|
91
|
+
"""Find documents matching the filter AST.
|
|
92
|
+
|
|
93
|
+
Translates the AST to a Cosmos DB SQL WHERE clause, runs the query,
|
|
94
|
+
strips internal fields from each result, and wraps results in a
|
|
95
|
+
:class:`~pynosqlc.core.Cursor`.
|
|
96
|
+
"""
|
|
97
|
+
container = await self._client.ensure_container(self._name)
|
|
98
|
+
|
|
99
|
+
where_clause, parameters = CosmosFilterTranslator.translate(ast)
|
|
100
|
+
|
|
101
|
+
if where_clause is not None:
|
|
102
|
+
sql = f"SELECT * FROM c WHERE {where_clause}"
|
|
103
|
+
else:
|
|
104
|
+
sql = "SELECT * FROM c"
|
|
105
|
+
|
|
106
|
+
docs = [
|
|
107
|
+
_strip_internal(item)
|
|
108
|
+
async for item in container.query_items(
|
|
109
|
+
query=sql,
|
|
110
|
+
parameters=parameters,
|
|
111
|
+
)
|
|
112
|
+
]
|
|
113
|
+
|
|
114
|
+
return Cursor(docs)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cosmos_driver.py — CosmosDriver: connects to Azure Cosmos DB via azure-cosmos.
|
|
3
|
+
|
|
4
|
+
URL scheme: pynosqlc:cosmosdb:<target>
|
|
5
|
+
where <target> is one of:
|
|
6
|
+
local → Cosmos DB emulator at http://localhost:8081
|
|
7
|
+
localhost → same as local
|
|
8
|
+
localhost:PORT → Cosmos DB emulator at http://localhost:PORT
|
|
9
|
+
https://... → Azure Cosmos DB endpoint (requires 'key' property)
|
|
10
|
+
|
|
11
|
+
Auto-registers with DriverManager on import.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from pynosqlc.core.driver import Driver
|
|
17
|
+
from pynosqlc.core.driver_manager import DriverManager
|
|
18
|
+
from pynosqlc.cosmosdb.cosmos_client import CosmosClient
|
|
19
|
+
|
|
20
|
+
# Well-known Cosmos DB emulator master key.
|
|
21
|
+
_EMULATOR_KEY = (
|
|
22
|
+
"C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw=="
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CosmosDriver(Driver):
|
|
27
|
+
"""Driver that creates :class:`CosmosClient` instances.
|
|
28
|
+
|
|
29
|
+
URL prefix: ``pynosqlc:cosmosdb:``
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
URL_PREFIX: str = "pynosqlc:cosmosdb:"
|
|
33
|
+
|
|
34
|
+
def accepts_url(self, url: str) -> bool:
|
|
35
|
+
"""Return ``True`` for ``'pynosqlc:cosmosdb:'`` URLs."""
|
|
36
|
+
return isinstance(url, str) and url.startswith(self.URL_PREFIX)
|
|
37
|
+
|
|
38
|
+
async def connect(
|
|
39
|
+
self,
|
|
40
|
+
url: str,
|
|
41
|
+
properties: dict | None = None,
|
|
42
|
+
) -> CosmosClient:
|
|
43
|
+
"""Create and return an open :class:`CosmosClient`.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
url: ``pynosqlc:cosmosdb:<target>``
|
|
47
|
+
properties: optional dict; supports:
|
|
48
|
+
- ``endpoint``: override endpoint URL (for local target)
|
|
49
|
+
- ``key``: account key (required for https:// target)
|
|
50
|
+
- ``db_id``: database name (default: ``'pynosqlc'``)
|
|
51
|
+
- any extra keyword args passed through to
|
|
52
|
+
:class:`azure.cosmos.aio.CosmosClient`
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
An open :class:`CosmosClient`.
|
|
56
|
+
|
|
57
|
+
Raises:
|
|
58
|
+
ValueError: if an ``https://`` target is used without ``key``.
|
|
59
|
+
"""
|
|
60
|
+
props = properties or {}
|
|
61
|
+
|
|
62
|
+
target = url[len(self.URL_PREFIX):]
|
|
63
|
+
db_id = props.pop("db_id", "pynosqlc")
|
|
64
|
+
|
|
65
|
+
# Determine endpoint and key from the target string.
|
|
66
|
+
if target in ("local", "localhost") or target.startswith("localhost:"):
|
|
67
|
+
# Cosmos DB emulator
|
|
68
|
+
endpoint = props.pop(
|
|
69
|
+
"endpoint",
|
|
70
|
+
"http://localhost:8081",
|
|
71
|
+
)
|
|
72
|
+
key = props.pop("key", _EMULATOR_KEY)
|
|
73
|
+
elif target.startswith("https://"):
|
|
74
|
+
endpoint = target
|
|
75
|
+
if "key" not in props:
|
|
76
|
+
raise ValueError(
|
|
77
|
+
f"CosmosDriver: 'key' property is required for endpoint {target!r}"
|
|
78
|
+
)
|
|
79
|
+
key = props.pop("key")
|
|
80
|
+
else:
|
|
81
|
+
# Treat unknown targets as the endpoint directly.
|
|
82
|
+
endpoint = target
|
|
83
|
+
key = props.pop("key", _EMULATOR_KEY)
|
|
84
|
+
|
|
85
|
+
client = CosmosClient(url, endpoint, key, db_id, props)
|
|
86
|
+
await client._open()
|
|
87
|
+
return client
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# Auto-register on import — a single shared instance is sufficient.
|
|
91
|
+
_driver = CosmosDriver()
|
|
92
|
+
DriverManager.register_driver(_driver)
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cosmos_filter_translator.py — Translates a pynosqlc Filter AST to a Cosmos DB
|
|
3
|
+
SQL WHERE clause with positional ``@vN`` parameters.
|
|
4
|
+
|
|
5
|
+
Returns
|
|
6
|
+
-------
|
|
7
|
+
tuple[str | None, list[dict]]
|
|
8
|
+
``(where_clause, parameters)``
|
|
9
|
+
|
|
10
|
+
* ``where_clause`` is the SQL fragment to embed after ``WHERE``, or
|
|
11
|
+
``None`` when the AST matches everything.
|
|
12
|
+
* ``parameters`` is the list of ``{"name": "@vN", "value": <val>}`` dicts
|
|
13
|
+
that ``container.query_items(query=..., parameters=...)`` expects.
|
|
14
|
+
|
|
15
|
+
Design
|
|
16
|
+
------
|
|
17
|
+
A fresh ``_TranslatorState`` is created for each ``translate()`` call. The
|
|
18
|
+
state carries a single monotonically-increasing ``idx`` counter used for both
|
|
19
|
+
the ``@vN`` parameter names. Field paths are inlined directly into the SQL
|
|
20
|
+
string using bracket notation (``c["fieldname"]``) so no separate name-alias
|
|
21
|
+
dict is needed.
|
|
22
|
+
|
|
23
|
+
Bracket notation avoids Cosmos DB reserved-word conflicts and handles field
|
|
24
|
+
names with spaces or special characters.
|
|
25
|
+
|
|
26
|
+
Supported operators
|
|
27
|
+
-------------------
|
|
28
|
+
eq, ne, gt, gte, lt, lte, contains, in, nin, exists
|
|
29
|
+
|
|
30
|
+
Composite node types
|
|
31
|
+
--------------------
|
|
32
|
+
and, or, not
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
from typing import Any
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class CosmosFilterTranslator:
|
|
41
|
+
"""Stateless translator from pynosqlc Filter AST to Cosmos DB SQL pair."""
|
|
42
|
+
|
|
43
|
+
@staticmethod
|
|
44
|
+
def translate(
|
|
45
|
+
ast: dict | None,
|
|
46
|
+
) -> tuple[str | None, list[dict]]:
|
|
47
|
+
"""Translate a Filter AST node to a Cosmos DB SQL (clause, params) pair.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
ast: a Filter AST node, or ``None`` / empty dict (matches all).
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
A tuple of ``(where_clause, parameters)``. When the AST is falsy
|
|
54
|
+
or has no conditions, returns ``(None, [])``.
|
|
55
|
+
|
|
56
|
+
Raises:
|
|
57
|
+
ValueError: if an unknown AST node type or operator is encountered.
|
|
58
|
+
"""
|
|
59
|
+
if not ast:
|
|
60
|
+
return (None, [])
|
|
61
|
+
|
|
62
|
+
# An 'and' / 'or' node with an empty conditions list also means "match all"
|
|
63
|
+
if ast.get("type") in ("and", "or") and not ast.get("conditions"):
|
|
64
|
+
return (None, [])
|
|
65
|
+
|
|
66
|
+
state = _TranslatorState()
|
|
67
|
+
expr = state._node(ast)
|
|
68
|
+
if expr is None:
|
|
69
|
+
return (None, [])
|
|
70
|
+
return (expr, state.parameters)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
# Internal stateful translator
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class _TranslatorState:
|
|
79
|
+
"""Carries mutable translation state for a single translate() call."""
|
|
80
|
+
|
|
81
|
+
def __init__(self) -> None:
|
|
82
|
+
self.idx: int = 0
|
|
83
|
+
self.parameters: list[dict] = []
|
|
84
|
+
|
|
85
|
+
# ── Counter helpers ──────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
@staticmethod
|
|
88
|
+
def _field_path(field: str) -> str:
|
|
89
|
+
"""Return the Cosmos SQL bracket-notation path for *field*."""
|
|
90
|
+
return f'c["{field}"]'
|
|
91
|
+
|
|
92
|
+
def _value_alias(self, value: Any) -> str:
|
|
93
|
+
"""Allocate the next @vN alias, record in parameters, and return alias."""
|
|
94
|
+
alias = f"@v{self.idx}"
|
|
95
|
+
self.parameters.append({"name": alias, "value": value})
|
|
96
|
+
self.idx += 1
|
|
97
|
+
return alias
|
|
98
|
+
|
|
99
|
+
# ── Node dispatcher ──────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
def _node(self, ast: dict) -> str | None:
|
|
102
|
+
"""Recursively translate an AST node to a SQL expression string."""
|
|
103
|
+
node_type = ast.get("type")
|
|
104
|
+
|
|
105
|
+
if node_type == "and":
|
|
106
|
+
return self._and_node(ast)
|
|
107
|
+
|
|
108
|
+
if node_type == "or":
|
|
109
|
+
return self._or_node(ast)
|
|
110
|
+
|
|
111
|
+
if node_type == "not":
|
|
112
|
+
return self._not_node(ast)
|
|
113
|
+
|
|
114
|
+
if node_type == "condition":
|
|
115
|
+
return self._condition(ast)
|
|
116
|
+
|
|
117
|
+
raise ValueError(f"Unknown filter AST node type: {node_type!r}")
|
|
118
|
+
|
|
119
|
+
# ── Composite nodes ──────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
def _and_node(self, ast: dict) -> str | None:
|
|
122
|
+
conditions = ast.get("conditions") or []
|
|
123
|
+
if not conditions:
|
|
124
|
+
return None
|
|
125
|
+
parts = [self._node(c) for c in conditions]
|
|
126
|
+
parts = [p for p in parts if p is not None]
|
|
127
|
+
if not parts:
|
|
128
|
+
return None
|
|
129
|
+
if len(parts) == 1:
|
|
130
|
+
return parts[0]
|
|
131
|
+
joined = " AND ".join(f"({p})" for p in parts)
|
|
132
|
+
return joined
|
|
133
|
+
|
|
134
|
+
def _or_node(self, ast: dict) -> str | None:
|
|
135
|
+
conditions = ast.get("conditions") or []
|
|
136
|
+
if not conditions:
|
|
137
|
+
return None
|
|
138
|
+
parts = [self._node(c) for c in conditions]
|
|
139
|
+
parts = [p for p in parts if p is not None]
|
|
140
|
+
if not parts:
|
|
141
|
+
return None
|
|
142
|
+
if len(parts) == 1:
|
|
143
|
+
return parts[0]
|
|
144
|
+
joined = " OR ".join(f"({p})" for p in parts)
|
|
145
|
+
return joined
|
|
146
|
+
|
|
147
|
+
def _not_node(self, ast: dict) -> str:
|
|
148
|
+
inner = self._node(ast["condition"])
|
|
149
|
+
return f"NOT ({inner})"
|
|
150
|
+
|
|
151
|
+
# ── Leaf condition ───────────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
def _condition(self, node: dict) -> str:
|
|
154
|
+
field: str = node["field"]
|
|
155
|
+
op: str = node["op"]
|
|
156
|
+
value: Any = node.get("value")
|
|
157
|
+
|
|
158
|
+
fp = self._field_path(field)
|
|
159
|
+
|
|
160
|
+
if op == "eq":
|
|
161
|
+
va = self._value_alias(value)
|
|
162
|
+
return f'{fp} = {va}'
|
|
163
|
+
|
|
164
|
+
if op == "ne":
|
|
165
|
+
va = self._value_alias(value)
|
|
166
|
+
return f'{fp} != {va}'
|
|
167
|
+
|
|
168
|
+
if op == "gt":
|
|
169
|
+
va = self._value_alias(value)
|
|
170
|
+
return f'{fp} > {va}'
|
|
171
|
+
|
|
172
|
+
if op == "gte":
|
|
173
|
+
va = self._value_alias(value)
|
|
174
|
+
return f'{fp} >= {va}'
|
|
175
|
+
|
|
176
|
+
if op == "lt":
|
|
177
|
+
va = self._value_alias(value)
|
|
178
|
+
return f'{fp} < {va}'
|
|
179
|
+
|
|
180
|
+
if op == "lte":
|
|
181
|
+
va = self._value_alias(value)
|
|
182
|
+
return f'{fp} <= {va}'
|
|
183
|
+
|
|
184
|
+
if op == "contains":
|
|
185
|
+
va = self._value_alias(value)
|
|
186
|
+
return f'ARRAY_CONTAINS({fp}, {va})'
|
|
187
|
+
|
|
188
|
+
if op == "exists":
|
|
189
|
+
# exists adds no parameter entry
|
|
190
|
+
if value:
|
|
191
|
+
return f'IS_DEFINED({fp})'
|
|
192
|
+
else:
|
|
193
|
+
return f'NOT IS_DEFINED({fp})'
|
|
194
|
+
|
|
195
|
+
if op == "in":
|
|
196
|
+
values = value or []
|
|
197
|
+
if not values:
|
|
198
|
+
return "1=0"
|
|
199
|
+
aliases = [self._value_alias(v) for v in values]
|
|
200
|
+
alias_list = ", ".join(aliases)
|
|
201
|
+
return f'{fp} IN ({alias_list})'
|
|
202
|
+
|
|
203
|
+
if op == "nin":
|
|
204
|
+
values = value or []
|
|
205
|
+
if not values:
|
|
206
|
+
return "1=1"
|
|
207
|
+
aliases = [self._value_alias(v) for v in values]
|
|
208
|
+
alias_list = ", ".join(aliases)
|
|
209
|
+
return f'NOT ({fp} IN ({alias_list}))'
|
|
210
|
+
|
|
211
|
+
raise ValueError(f"Unknown filter operator: {op!r}")
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "alt-python-pynosqlc-cosmosdb"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "CosmosDB driver for pynosqlc"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"alt-python-pynosqlc-core",
|
|
9
|
+
"azure-cosmos>=4.7",
|
|
10
|
+
]
|
|
11
|
+
authors = [
|
|
12
|
+
{name = "Craig Parravicini"},
|
|
13
|
+
{name = "Claude (Anthropic)"},
|
|
14
|
+
]
|
|
15
|
+
license = {text = "MIT"}
|
|
16
|
+
keywords = ["nosql", "database", "async", "cosmosdb", "azure", "driver"]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 5 - Production/Stable",
|
|
19
|
+
"Framework :: AsyncIO",
|
|
20
|
+
"Intended Audience :: Developers",
|
|
21
|
+
"License :: OSI Approved :: MIT License",
|
|
22
|
+
"Programming Language :: Python :: 3",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Topic :: Database",
|
|
25
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Homepage = "https://github.com/alt-python/pynosqlc"
|
|
30
|
+
Repository = "https://github.com/alt-python/pynosqlc"
|
|
31
|
+
Documentation = "https://github.com/alt-python/pynosqlc#getting-started"
|
|
32
|
+
"Bug Tracker" = "https://github.com/alt-python/pynosqlc/issues"
|
|
33
|
+
|
|
34
|
+
[build-system]
|
|
35
|
+
requires = ["hatchling"]
|
|
36
|
+
build-backend = "hatchling.build"
|
|
37
|
+
|
|
38
|
+
[tool.hatch.build.targets.wheel]
|
|
39
|
+
packages = ["pynosqlc"]
|
|
40
|
+
|
|
41
|
+
[tool.uv.sources]
|
|
42
|
+
alt-python-pynosqlc-core = { workspace = true }
|
|
43
|
+
|
|
44
|
+
[tool.pytest.ini_options]
|
|
45
|
+
testpaths = ["tests"]
|
|
46
|
+
asyncio_mode = "auto"
|
|
File without changes
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""
|
|
2
|
+
test_compliance.py — CosmosDB driver compliance tests.
|
|
3
|
+
|
|
4
|
+
Wires the shared pynosqlc.core compliance suite into the cosmosdb package.
|
|
5
|
+
Each test run gets a fresh CosmosClient connected to a real Cosmos DB instance.
|
|
6
|
+
|
|
7
|
+
Set COSMOS_ENDPOINT to override the default local endpoint (default: http://localhost:8081).
|
|
8
|
+
Set COSMOS_DB to override the database name (default: pynosqlc_ci).
|
|
9
|
+
Tests are skipped automatically if the Cosmos emulator is not reachable.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
|
|
16
|
+
import pytest
|
|
17
|
+
|
|
18
|
+
from pynosqlc.core import DriverManager
|
|
19
|
+
from pynosqlc.core.testing import run_compliance
|
|
20
|
+
import pynosqlc.cosmosdb # noqa: F401 — registers CosmosDriver on import
|
|
21
|
+
from pynosqlc.cosmosdb.cosmos_driver import _driver
|
|
22
|
+
|
|
23
|
+
COSMOS_ENDPOINT = os.environ.get("COSMOS_ENDPOINT", "http://localhost:8081")
|
|
24
|
+
COSMOS_DB = os.environ.get("COSMOS_DB", "pynosqlc_ci")
|
|
25
|
+
COSMOS_URL = "pynosqlc:cosmosdb:local"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def _factory():
|
|
29
|
+
"""Return a fresh, open CosmosClient for each test class fixture.
|
|
30
|
+
|
|
31
|
+
Clears and re-registers the driver, connects to Cosmos DB, deletes any
|
|
32
|
+
leftover compliance containers so ensure_container recreates them cleanly,
|
|
33
|
+
and returns the client.
|
|
34
|
+
|
|
35
|
+
Skips the test if the Cosmos emulator is not reachable.
|
|
36
|
+
"""
|
|
37
|
+
DriverManager.clear()
|
|
38
|
+
DriverManager.register_driver(_driver)
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
client = await DriverManager.get_client(
|
|
42
|
+
COSMOS_URL,
|
|
43
|
+
{"db_id": COSMOS_DB, "endpoint": COSMOS_ENDPOINT},
|
|
44
|
+
)
|
|
45
|
+
except Exception as e:
|
|
46
|
+
pytest.skip(f"CosmosDB not available: {e}")
|
|
47
|
+
|
|
48
|
+
# Delete compliance containers from any prior run so tests are isolated.
|
|
49
|
+
# Deleting the container and removing it from the cache forces ensure_container
|
|
50
|
+
# to recreate it fresh on first use — simpler than clearing items.
|
|
51
|
+
for name in ("compliance_kv", "compliance_doc", "compliance_find"):
|
|
52
|
+
try:
|
|
53
|
+
await client._database.delete_container(name)
|
|
54
|
+
client._container_cache.pop(name, None)
|
|
55
|
+
except Exception:
|
|
56
|
+
pass # Container may not exist yet; that's fine.
|
|
57
|
+
|
|
58
|
+
return client
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
run_compliance(_factory)
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unit tests for CosmosFilterTranslator.
|
|
3
|
+
|
|
4
|
+
Covers:
|
|
5
|
+
- All 10 operators: eq, ne, gt, gte, lt, lte, contains, in, nin, exists
|
|
6
|
+
- and-node with 1 condition unwraps (no wrapping parens)
|
|
7
|
+
- and-node with 2 conditions → "(expr1) AND (expr2)"
|
|
8
|
+
- or-node with 1 condition unwraps
|
|
9
|
+
- or-node with 2 conditions → "(expr1) OR (expr2)"
|
|
10
|
+
- not-node → "NOT (expr)"
|
|
11
|
+
- None ast → (None, [])
|
|
12
|
+
- empty dict ast → (None, [])
|
|
13
|
+
- empty and/or conditions → (None, [])
|
|
14
|
+
- in_ multi-value expansion with real SQL IN
|
|
15
|
+
- nin multi-value expansion with NOT (...IN...)
|
|
16
|
+
- in_ with empty list → "1=0"
|
|
17
|
+
- nin with empty list → "1=1"
|
|
18
|
+
- exists(True) / exists(False) add no parameter entry
|
|
19
|
+
- unknown op raises ValueError
|
|
20
|
+
- unknown node type raises ValueError
|
|
21
|
+
- compound filter uses distinct @vN aliases with no collisions
|
|
22
|
+
- bracket notation c["field"] is used for all field paths
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import pytest
|
|
26
|
+
|
|
27
|
+
from pynosqlc.cosmosdb.cosmos_filter_translator import CosmosFilterTranslator
|
|
28
|
+
|
|
29
|
+
translate = CosmosFilterTranslator.translate
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
# Helpers
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
def cond(field, op, value=None):
|
|
37
|
+
"""Convenience factory for condition AST nodes."""
|
|
38
|
+
node = {"type": "condition", "field": field, "op": op}
|
|
39
|
+
if value is not None:
|
|
40
|
+
node["value"] = value
|
|
41
|
+
return node
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def param(name: str, value) -> dict:
|
|
45
|
+
"""Convenience factory for a Cosmos parameter dict."""
|
|
46
|
+
return {"name": name, "value": value}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
# None / empty input
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
class TestEmptyInput:
|
|
54
|
+
def test_none_returns_none_empty(self):
|
|
55
|
+
assert translate(None) == (None, [])
|
|
56
|
+
|
|
57
|
+
def test_empty_dict_returns_none_empty(self):
|
|
58
|
+
assert translate({}) == (None, [])
|
|
59
|
+
|
|
60
|
+
def test_empty_and_conditions_returns_none_empty(self):
|
|
61
|
+
assert translate({"type": "and", "conditions": []}) == (None, [])
|
|
62
|
+
|
|
63
|
+
def test_empty_or_conditions_returns_none_empty(self):
|
|
64
|
+
assert translate({"type": "or", "conditions": []}) == (None, [])
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
# All 10 operators on a regular field
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
class TestOperators:
|
|
72
|
+
def test_eq(self):
|
|
73
|
+
expr, params = translate(cond("name", "eq", "Alice"))
|
|
74
|
+
assert expr == 'c["name"] = @v0'
|
|
75
|
+
assert params == [param("@v0", "Alice")]
|
|
76
|
+
|
|
77
|
+
def test_ne(self):
|
|
78
|
+
expr, params = translate(cond("status", "ne", "inactive"))
|
|
79
|
+
assert expr == 'c["status"] != @v0'
|
|
80
|
+
assert params == [param("@v0", "inactive")]
|
|
81
|
+
|
|
82
|
+
def test_gt(self):
|
|
83
|
+
expr, params = translate(cond("age", "gt", 18))
|
|
84
|
+
assert expr == 'c["age"] > @v0'
|
|
85
|
+
assert params == [param("@v0", 18)]
|
|
86
|
+
|
|
87
|
+
def test_gte(self):
|
|
88
|
+
expr, params = translate(cond("score", "gte", 90))
|
|
89
|
+
assert expr == 'c["score"] >= @v0'
|
|
90
|
+
assert params == [param("@v0", 90)]
|
|
91
|
+
|
|
92
|
+
def test_lt(self):
|
|
93
|
+
expr, params = translate(cond("price", "lt", 100))
|
|
94
|
+
assert expr == 'c["price"] < @v0'
|
|
95
|
+
assert params == [param("@v0", 100)]
|
|
96
|
+
|
|
97
|
+
def test_lte(self):
|
|
98
|
+
expr, params = translate(cond("rank", "lte", 5))
|
|
99
|
+
assert expr == 'c["rank"] <= @v0'
|
|
100
|
+
assert params == [param("@v0", 5)]
|
|
101
|
+
|
|
102
|
+
def test_contains(self):
|
|
103
|
+
expr, params = translate(cond("tags", "contains", "python"))
|
|
104
|
+
assert expr == 'ARRAY_CONTAINS(c["tags"], @v0)'
|
|
105
|
+
assert params == [param("@v0", "python")]
|
|
106
|
+
|
|
107
|
+
def test_exists_true(self):
|
|
108
|
+
expr, params = translate(cond("email", "exists", True))
|
|
109
|
+
assert expr == 'IS_DEFINED(c["email"])'
|
|
110
|
+
assert params == []
|
|
111
|
+
|
|
112
|
+
def test_exists_false(self):
|
|
113
|
+
expr, params = translate(cond("deleted_at", "exists", False))
|
|
114
|
+
assert expr == 'NOT IS_DEFINED(c["deleted_at"])'
|
|
115
|
+
assert params == []
|
|
116
|
+
|
|
117
|
+
def test_in(self):
|
|
118
|
+
expr, params = translate(cond("category", "in", ["a", "b", "c"]))
|
|
119
|
+
assert expr == 'c["category"] IN (@v0, @v1, @v2)'
|
|
120
|
+
assert params == [
|
|
121
|
+
param("@v0", "a"),
|
|
122
|
+
param("@v1", "b"),
|
|
123
|
+
param("@v2", "c"),
|
|
124
|
+
]
|
|
125
|
+
|
|
126
|
+
def test_nin(self):
|
|
127
|
+
expr, params = translate(cond("role", "nin", ["admin", "root"]))
|
|
128
|
+
assert expr == 'NOT (c["role"] IN (@v0, @v1))'
|
|
129
|
+
assert params == [
|
|
130
|
+
param("@v0", "admin"),
|
|
131
|
+
param("@v1", "root"),
|
|
132
|
+
]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ---------------------------------------------------------------------------
|
|
136
|
+
# in / nin expansion and edge cases
|
|
137
|
+
# ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
class TestInNinExpansion:
|
|
140
|
+
def test_in_single_value(self):
|
|
141
|
+
expr, params = translate(cond("x", "in", ["only"]))
|
|
142
|
+
assert expr == 'c["x"] IN (@v0)'
|
|
143
|
+
assert params == [param("@v0", "only")]
|
|
144
|
+
|
|
145
|
+
def test_in_multi_value_aliases_present(self):
|
|
146
|
+
expr, params = translate(cond("x", "in", [1, 2, 3, 4]))
|
|
147
|
+
assert expr == 'c["x"] IN (@v0, @v1, @v2, @v3)'
|
|
148
|
+
assert [p["name"] for p in params] == ["@v0", "@v1", "@v2", "@v3"]
|
|
149
|
+
assert [p["value"] for p in params] == [1, 2, 3, 4]
|
|
150
|
+
|
|
151
|
+
def test_nin_produces_not_in(self):
|
|
152
|
+
expr, params = translate(cond("status", "nin", ["a", "b"]))
|
|
153
|
+
assert expr == 'NOT (c["status"] IN (@v0, @v1))'
|
|
154
|
+
assert params == [param("@v0", "a"), param("@v1", "b")]
|
|
155
|
+
|
|
156
|
+
def test_nin_multi_value(self):
|
|
157
|
+
expr, params = translate(cond("x", "nin", [10, 20, 30]))
|
|
158
|
+
assert expr == 'NOT (c["x"] IN (@v0, @v1, @v2))'
|
|
159
|
+
assert params == [
|
|
160
|
+
param("@v0", 10),
|
|
161
|
+
param("@v1", 20),
|
|
162
|
+
param("@v2", 30),
|
|
163
|
+
]
|
|
164
|
+
|
|
165
|
+
def test_in_empty_list_never_matches(self):
|
|
166
|
+
expr, params = translate(cond("x", "in", []))
|
|
167
|
+
assert expr == "1=0"
|
|
168
|
+
assert params == []
|
|
169
|
+
|
|
170
|
+
def test_nin_empty_list_always_matches(self):
|
|
171
|
+
expr, params = translate(cond("x", "nin", []))
|
|
172
|
+
assert expr == "1=1"
|
|
173
|
+
assert params == []
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# ---------------------------------------------------------------------------
|
|
177
|
+
# exists does not add to parameters
|
|
178
|
+
# ---------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
class TestExistsNoValue:
|
|
181
|
+
def test_exists_true_no_parameter(self):
|
|
182
|
+
_, params = translate(cond("field", "exists", True))
|
|
183
|
+
assert params == []
|
|
184
|
+
|
|
185
|
+
def test_exists_false_no_parameter(self):
|
|
186
|
+
_, params = translate(cond("field", "exists", False))
|
|
187
|
+
assert params == []
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ---------------------------------------------------------------------------
|
|
191
|
+
# and-node
|
|
192
|
+
# ---------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
class TestAndNode:
|
|
195
|
+
def test_single_condition_unwraps(self):
|
|
196
|
+
node = {"type": "and", "conditions": [cond("x", "eq", 1)]}
|
|
197
|
+
expr, params = translate(node)
|
|
198
|
+
assert expr == 'c["x"] = @v0'
|
|
199
|
+
assert params == [param("@v0", 1)]
|
|
200
|
+
|
|
201
|
+
def test_two_conditions_produces_and(self):
|
|
202
|
+
node = {
|
|
203
|
+
"type": "and",
|
|
204
|
+
"conditions": [cond("x", "eq", 1), cond("y", "gt", 0)],
|
|
205
|
+
}
|
|
206
|
+
expr, params = translate(node)
|
|
207
|
+
assert expr == '(c["x"] = @v0) AND (c["y"] > @v1)'
|
|
208
|
+
assert params == [param("@v0", 1), param("@v1", 0)]
|
|
209
|
+
|
|
210
|
+
def test_three_conditions(self):
|
|
211
|
+
node = {
|
|
212
|
+
"type": "and",
|
|
213
|
+
"conditions": [
|
|
214
|
+
cond("a", "eq", 1),
|
|
215
|
+
cond("b", "ne", 2),
|
|
216
|
+
cond("c", "gt", 3),
|
|
217
|
+
],
|
|
218
|
+
}
|
|
219
|
+
expr, params = translate(node)
|
|
220
|
+
assert expr == '(c["a"] = @v0) AND (c["b"] != @v1) AND (c["c"] > @v2)'
|
|
221
|
+
assert len(params) == 3
|
|
222
|
+
|
|
223
|
+
def test_empty_conditions_returns_none(self):
|
|
224
|
+
node = {"type": "and", "conditions": []}
|
|
225
|
+
assert translate(node) == (None, [])
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# ---------------------------------------------------------------------------
|
|
229
|
+
# or-node
|
|
230
|
+
# ---------------------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
class TestOrNode:
|
|
233
|
+
def test_single_condition_unwraps(self):
|
|
234
|
+
node = {"type": "or", "conditions": [cond("x", "eq", 1)]}
|
|
235
|
+
expr, params = translate(node)
|
|
236
|
+
assert expr == 'c["x"] = @v0'
|
|
237
|
+
|
|
238
|
+
def test_two_conditions_produces_or(self):
|
|
239
|
+
node = {
|
|
240
|
+
"type": "or",
|
|
241
|
+
"conditions": [cond("a", "lt", 5), cond("b", "gte", 10)],
|
|
242
|
+
}
|
|
243
|
+
expr, params = translate(node)
|
|
244
|
+
assert expr == '(c["a"] < @v0) OR (c["b"] >= @v1)'
|
|
245
|
+
assert params == [param("@v0", 5), param("@v1", 10)]
|
|
246
|
+
|
|
247
|
+
def test_empty_conditions_returns_none(self):
|
|
248
|
+
node = {"type": "or", "conditions": []}
|
|
249
|
+
assert translate(node) == (None, [])
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# ---------------------------------------------------------------------------
|
|
253
|
+
# not-node
|
|
254
|
+
# ---------------------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
class TestNotNode:
|
|
257
|
+
def test_not_wraps_with_not(self):
|
|
258
|
+
node = {"type": "not", "condition": cond("active", "eq", True)}
|
|
259
|
+
expr, params = translate(node)
|
|
260
|
+
assert expr == 'NOT (c["active"] = @v0)'
|
|
261
|
+
assert params == [param("@v0", True)]
|
|
262
|
+
|
|
263
|
+
def test_not_with_and_inner(self):
|
|
264
|
+
inner = {
|
|
265
|
+
"type": "and",
|
|
266
|
+
"conditions": [cond("x", "gt", 0), cond("y", "lt", 10)],
|
|
267
|
+
}
|
|
268
|
+
node = {"type": "not", "condition": inner}
|
|
269
|
+
expr, _ = translate(node)
|
|
270
|
+
assert expr == 'NOT ((c["x"] > @v0) AND (c["y"] < @v1))'
|
|
271
|
+
|
|
272
|
+
def test_not_exists(self):
|
|
273
|
+
node = {"type": "not", "condition": cond("field", "exists", True)}
|
|
274
|
+
expr, params = translate(node)
|
|
275
|
+
assert expr == 'NOT (IS_DEFINED(c["field"]))'
|
|
276
|
+
assert params == []
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
# ---------------------------------------------------------------------------
|
|
280
|
+
# Error cases
|
|
281
|
+
# ---------------------------------------------------------------------------
|
|
282
|
+
|
|
283
|
+
class TestErrors:
|
|
284
|
+
def test_unknown_op_raises_value_error(self):
|
|
285
|
+
with pytest.raises(ValueError, match="Unknown filter operator"):
|
|
286
|
+
translate(cond("x", "regex", ".*"))
|
|
287
|
+
|
|
288
|
+
def test_unknown_node_type_raises_value_error(self):
|
|
289
|
+
with pytest.raises(ValueError, match="Unknown filter AST node type"):
|
|
290
|
+
translate({"type": "xor", "conditions": []})
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
# ---------------------------------------------------------------------------
|
|
294
|
+
# Global counter: no alias collisions in compound filters
|
|
295
|
+
# ---------------------------------------------------------------------------
|
|
296
|
+
|
|
297
|
+
class TestGlobalCounters:
|
|
298
|
+
def test_distinct_value_aliases_across_fields(self):
|
|
299
|
+
"""A compound filter over three distinct fields must use @v0/@v1/@v2
|
|
300
|
+
with no collisions, and parameters list preserves insertion order."""
|
|
301
|
+
node = {
|
|
302
|
+
"type": "and",
|
|
303
|
+
"conditions": [
|
|
304
|
+
cond("alpha", "eq", 1),
|
|
305
|
+
cond("beta", "gt", 2),
|
|
306
|
+
cond("gamma", "lte", 3),
|
|
307
|
+
],
|
|
308
|
+
}
|
|
309
|
+
expr, params = translate(node)
|
|
310
|
+
assert expr == (
|
|
311
|
+
'(c["alpha"] = @v0) AND (c["beta"] > @v1) AND (c["gamma"] <= @v2)'
|
|
312
|
+
)
|
|
313
|
+
assert params == [
|
|
314
|
+
param("@v0", 1),
|
|
315
|
+
param("@v1", 2),
|
|
316
|
+
param("@v2", 3),
|
|
317
|
+
]
|
|
318
|
+
|
|
319
|
+
def test_in_then_eq_no_collision(self):
|
|
320
|
+
"""in_ expands multiple @vN aliases; a subsequent field must continue
|
|
321
|
+
from the correct counter, not restart at @v0."""
|
|
322
|
+
node = {
|
|
323
|
+
"type": "and",
|
|
324
|
+
"conditions": [
|
|
325
|
+
cond("status", "in", ["a", "b"]),
|
|
326
|
+
cond("age", "gt", 18),
|
|
327
|
+
],
|
|
328
|
+
}
|
|
329
|
+
expr, params = translate(node)
|
|
330
|
+
assert params[0] == param("@v0", "a")
|
|
331
|
+
assert params[1] == param("@v1", "b")
|
|
332
|
+
assert params[2] == param("@v2", 18)
|
|
333
|
+
assert 'c["status"] IN (@v0, @v1)' in expr
|
|
334
|
+
assert 'c["age"] > @v2' in expr
|
|
335
|
+
|
|
336
|
+
def test_exists_does_not_consume_counter(self):
|
|
337
|
+
"""exists must not consume an @vN slot; a following condition must use
|
|
338
|
+
@v0, not @v1."""
|
|
339
|
+
node = {
|
|
340
|
+
"type": "and",
|
|
341
|
+
"conditions": [
|
|
342
|
+
cond("email", "exists", True),
|
|
343
|
+
cond("age", "gt", 18),
|
|
344
|
+
],
|
|
345
|
+
}
|
|
346
|
+
expr, params = translate(node)
|
|
347
|
+
# Only one parameter — from the gt condition
|
|
348
|
+
assert params == [param("@v0", 18)]
|
|
349
|
+
assert 'IS_DEFINED(c["email"])' in expr
|
|
350
|
+
assert 'c["age"] > @v0' in expr
|
|
351
|
+
|
|
352
|
+
def test_nin_then_eq_no_collision(self):
|
|
353
|
+
"""nin_ expands multiple @vN aliases; subsequent fields continue."""
|
|
354
|
+
node = {
|
|
355
|
+
"type": "and",
|
|
356
|
+
"conditions": [
|
|
357
|
+
cond("role", "nin", ["admin", "root"]),
|
|
358
|
+
cond("active", "eq", True),
|
|
359
|
+
],
|
|
360
|
+
}
|
|
361
|
+
expr, params = translate(node)
|
|
362
|
+
assert params[0] == param("@v0", "admin")
|
|
363
|
+
assert params[1] == param("@v1", "root")
|
|
364
|
+
assert params[2] == param("@v2", True)
|
|
365
|
+
assert 'NOT (c["role"] IN (@v0, @v1))' in expr
|
|
366
|
+
assert 'c["active"] = @v2' in expr
|
|
367
|
+
|
|
368
|
+
def test_bracket_notation_for_reserved_word_field(self):
|
|
369
|
+
"""Field named 'value' (a Cosmos reserved word) uses bracket notation."""
|
|
370
|
+
expr, params = translate(cond("value", "eq", 42))
|
|
371
|
+
assert expr == 'c["value"] = @v0'
|
|
372
|
+
assert params == [param("@v0", 42)]
|
|
373
|
+
|
|
374
|
+
def test_bracket_notation_for_field_with_space(self):
|
|
375
|
+
"""Field names with spaces are valid in bracket notation."""
|
|
376
|
+
expr, params = translate(cond("first name", "eq", "Bob"))
|
|
377
|
+
assert expr == 'c["first name"] = @v0'
|
|
378
|
+
assert params == [param("@v0", "Bob")]
|