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.
@@ -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")]