ddsql 0.1.0__tar.gz → 0.3.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.
@@ -1,3 +1,23 @@
1
+ Metadata-Version: 2.4
2
+ Name: ddsql
3
+ Version: 0.3.0
4
+ Summary: SQL Query Library
5
+ Keywords: python,ddsql
6
+ Author: davyddd
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.11
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Classifier: Programming Language :: Python :: 3.14
14
+ Classifier: Typing :: Typed
15
+ Requires-Dist: ddutils>=0.2.0,<0.3.0
16
+ Requires-Dist: jinja2>=3.0.0,<4.0.0
17
+ Requires-Python: >=3.11, <3.15
18
+ Project-URL: Repository, https://github.com/davyddd/ddsql
19
+ Description-Content-Type: text/markdown
20
+
1
21
  # DDSQL
2
22
 
3
23
  [![pypi](https://img.shields.io/pypi/v/ddsql.svg)](https://pypi.python.org/pypi/ddsql)
@@ -113,14 +133,67 @@ class CustomSerializer(BaseSerializer):
113
133
  }
114
134
  ```
115
135
 
136
+ ## Connections
137
+
138
+ `ddsql.connections` sits between a `ScopedRegistry` (from [ddutils](https://github.com/davyddd/ddutils)) and
139
+ the code that runs queries. A registry holds one connection per scope: a pooled client per process, a session
140
+ per task. The connections module adds two things on top:
141
+
142
+ - `ConnectionManagerFactory(registries, default, connection_manager_class=ConnectionManager)` resolves a
143
+ connection alias to its registry, like Django's `using`. Calling it opens a connection manager on the
144
+ chosen registry; `registry(alias)` returns the registry itself.
145
+ - `ConnectionManager(registry)` is the default connection manager: an async context manager that hands out
146
+ the connection of the registry's current scope and releases nothing on exit. Enough for clients that are
147
+ pools themselves, such as ClickHouse.
148
+
149
+ Databases with transactions pass their own `connection_manager_class`: a unit of work that begins, commits
150
+ or rolls back and closes the session on exit. It depends on the driver, so it lives in the project.
151
+
152
+ ```python
153
+ import os
154
+ from enum import Enum
155
+
156
+ from ddutils.scoped_registry import ScopedRegistry
157
+
158
+ from ddsql.connections import ConnectionManagerFactory
159
+
160
+
161
+ class ClickhouseDB(str, Enum):
162
+ PRIMARY = 'primary'
163
+
164
+
165
+ client_registries = {
166
+ db: ScopedRegistry(create_func=make_client_for(db), scope_func=os.getpid, destructor_method_name='close')
167
+ for db in ClickhouseDB
168
+ }
169
+ clickhouse = ConnectionManagerFactory(client_registries, default=ClickhouseDB.PRIMARY)
170
+
171
+
172
+ async with clickhouse() as client:
173
+ result = await client.query(sql)
174
+
175
+
176
+ # a transactional database: `Atomic` is your unit of work over `registry.scope()`
177
+ atomic = ConnectionManagerFactory(session_registries, default=PostgresDB.PRIMARY, connection_manager_class=Atomic)
178
+
179
+
180
+ async with atomic(alias=PostgresDB.REPLICA) as session:
181
+ ...
182
+ ```
183
+
116
184
  ## Adapter
117
185
 
118
- `Adapter` encapsulates database interactions. To create an adapter, inherit from the `Adapter` base class
186
+ `Adapter` encapsulates database interactions. To create an adapter, inherit from the `Adapter` base class
119
187
  and define two required elements:
120
188
  - **serializer** – an instance of a serializer for converting Python types to SQL representations;
121
189
  - **_execute** method – the database-specific query execution logic.
122
190
 
123
191
  ```python
192
+ from collections.abc import Sequence
193
+ from typing import Any
194
+
195
+ from sqlalchemy import text
196
+
124
197
  from ddsql.adapter import Adapter
125
198
  from ddsql.serializers import PostgresSerializer
126
199
 
@@ -128,13 +201,26 @@ from ddsql.serializers import PostgresSerializer
128
201
  class PostgresAdapter(Adapter):
129
202
  serializer = PostgresSerializer()
130
203
 
131
- async def _execute(self) -> Sequence[Dict[str, Any]]:
132
- query = await self.get_query() # get the rendered SQL query
133
- async with Atomic() as postgres_session:
134
- result = await postgres_session.execute(text(query))
204
+ async def _execute(self) -> Sequence[dict[str, Any]]:
205
+ async with atomic(alias=self.alias) as session:
206
+ query = await self.get_query() # the rendered SQL query
207
+ result = await session.execute(text(query))
135
208
  return [dict(zip(result.keys(), row)) for row in result.fetchall()]
136
209
  ```
137
210
 
211
+ Intermediate adapter classes without `_execute` are abstract and need no `serializer`; only concrete
212
+ adapters are validated.
213
+
214
+ ### Choosing the database
215
+
216
+ `Adapter.using(alias)` picks the connection alias right before executing, like Django's `using`. `_execute`
217
+ reads `self.alias` (`None` means the default) and passes it to whatever opens the connection, as in the example above:
218
+
219
+ ```python
220
+ await SQL(query).postgres.execute() # default connection
221
+ await SQL(query).postgres.using(PostgresDB.REPLICA).execute()
222
+ ```
223
+
138
224
  ## SQLBase
139
225
 
140
226
  `SQLBase` is configured once per project and defines which adapters are available for query execution.
@@ -216,12 +302,11 @@ The result of query execution is a `Result` object that wraps the data into the
216
302
  ```python
217
303
  from dataclasses import dataclass
218
304
  from datetime import datetime
219
- from typing import Optional
220
305
 
221
- from ddsql.query import Query
222
- from ddsql.sqlbase import SQLBase
223
306
  from ddsql.adapter import Adapter, AdapterDescriptor
307
+ from ddsql.query import Query
224
308
  from ddsql.serializers import PostgresSerializer
309
+ from ddsql.sqlbase import SQLBase
225
310
 
226
311
 
227
312
  class PostgresAdapter(Adapter):
@@ -239,7 +324,7 @@ class SQL(SQLBase):
239
324
  class User:
240
325
  user_id: int
241
326
  name: str
242
- email: Optional[str]
327
+ email: str | None
243
328
  created_at: datetime
244
329
  is_deleted: bool
245
330
 
@@ -1,17 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: ddsql
3
- Version: 0.1.0
4
- Summary: SQL Query Library
5
- Keywords: python,ddsql
6
- Author: davyddd
7
- License-Expression: MIT
8
- License-File: LICENSE
9
- Requires-Dist: ddutils>=0.2.0,<0.3.0
10
- Requires-Dist: jinja2>=3.0.0,<4.0.0
11
- Requires-Python: >=3.11, <3.15
12
- Project-URL: Repository, https://github.com/davyddd/ddsql
13
- Description-Content-Type: text/markdown
14
-
15
1
  # DDSQL
16
2
 
17
3
  [![pypi](https://img.shields.io/pypi/v/ddsql.svg)](https://pypi.python.org/pypi/ddsql)
@@ -127,14 +113,67 @@ class CustomSerializer(BaseSerializer):
127
113
  }
128
114
  ```
129
115
 
116
+ ## Connections
117
+
118
+ `ddsql.connections` sits between a `ScopedRegistry` (from [ddutils](https://github.com/davyddd/ddutils)) and
119
+ the code that runs queries. A registry holds one connection per scope: a pooled client per process, a session
120
+ per task. The connections module adds two things on top:
121
+
122
+ - `ConnectionManagerFactory(registries, default, connection_manager_class=ConnectionManager)` resolves a
123
+ connection alias to its registry, like Django's `using`. Calling it opens a connection manager on the
124
+ chosen registry; `registry(alias)` returns the registry itself.
125
+ - `ConnectionManager(registry)` is the default connection manager: an async context manager that hands out
126
+ the connection of the registry's current scope and releases nothing on exit. Enough for clients that are
127
+ pools themselves, such as ClickHouse.
128
+
129
+ Databases with transactions pass their own `connection_manager_class`: a unit of work that begins, commits
130
+ or rolls back and closes the session on exit. It depends on the driver, so it lives in the project.
131
+
132
+ ```python
133
+ import os
134
+ from enum import Enum
135
+
136
+ from ddutils.scoped_registry import ScopedRegistry
137
+
138
+ from ddsql.connections import ConnectionManagerFactory
139
+
140
+
141
+ class ClickhouseDB(str, Enum):
142
+ PRIMARY = 'primary'
143
+
144
+
145
+ client_registries = {
146
+ db: ScopedRegistry(create_func=make_client_for(db), scope_func=os.getpid, destructor_method_name='close')
147
+ for db in ClickhouseDB
148
+ }
149
+ clickhouse = ConnectionManagerFactory(client_registries, default=ClickhouseDB.PRIMARY)
150
+
151
+
152
+ async with clickhouse() as client:
153
+ result = await client.query(sql)
154
+
155
+
156
+ # a transactional database: `Atomic` is your unit of work over `registry.scope()`
157
+ atomic = ConnectionManagerFactory(session_registries, default=PostgresDB.PRIMARY, connection_manager_class=Atomic)
158
+
159
+
160
+ async with atomic(alias=PostgresDB.REPLICA) as session:
161
+ ...
162
+ ```
163
+
130
164
  ## Adapter
131
165
 
132
- `Adapter` encapsulates database interactions. To create an adapter, inherit from the `Adapter` base class
166
+ `Adapter` encapsulates database interactions. To create an adapter, inherit from the `Adapter` base class
133
167
  and define two required elements:
134
168
  - **serializer** – an instance of a serializer for converting Python types to SQL representations;
135
169
  - **_execute** method – the database-specific query execution logic.
136
170
 
137
171
  ```python
172
+ from collections.abc import Sequence
173
+ from typing import Any
174
+
175
+ from sqlalchemy import text
176
+
138
177
  from ddsql.adapter import Adapter
139
178
  from ddsql.serializers import PostgresSerializer
140
179
 
@@ -142,13 +181,26 @@ from ddsql.serializers import PostgresSerializer
142
181
  class PostgresAdapter(Adapter):
143
182
  serializer = PostgresSerializer()
144
183
 
145
- async def _execute(self) -> Sequence[Dict[str, Any]]:
146
- query = await self.get_query() # get the rendered SQL query
147
- async with Atomic() as postgres_session:
148
- result = await postgres_session.execute(text(query))
184
+ async def _execute(self) -> Sequence[dict[str, Any]]:
185
+ async with atomic(alias=self.alias) as session:
186
+ query = await self.get_query() # the rendered SQL query
187
+ result = await session.execute(text(query))
149
188
  return [dict(zip(result.keys(), row)) for row in result.fetchall()]
150
189
  ```
151
190
 
191
+ Intermediate adapter classes without `_execute` are abstract and need no `serializer`; only concrete
192
+ adapters are validated.
193
+
194
+ ### Choosing the database
195
+
196
+ `Adapter.using(alias)` picks the connection alias right before executing, like Django's `using`. `_execute`
197
+ reads `self.alias` (`None` means the default) and passes it to whatever opens the connection, as in the example above:
198
+
199
+ ```python
200
+ await SQL(query).postgres.execute() # default connection
201
+ await SQL(query).postgres.using(PostgresDB.REPLICA).execute()
202
+ ```
203
+
152
204
  ## SQLBase
153
205
 
154
206
  `SQLBase` is configured once per project and defines which adapters are available for query execution.
@@ -230,12 +282,11 @@ The result of query execution is a `Result` object that wraps the data into the
230
282
  ```python
231
283
  from dataclasses import dataclass
232
284
  from datetime import datetime
233
- from typing import Optional
234
285
 
235
- from ddsql.query import Query
236
- from ddsql.sqlbase import SQLBase
237
286
  from ddsql.adapter import Adapter, AdapterDescriptor
287
+ from ddsql.query import Query
238
288
  from ddsql.serializers import PostgresSerializer
289
+ from ddsql.sqlbase import SQLBase
239
290
 
240
291
 
241
292
  class PostgresAdapter(Adapter):
@@ -253,7 +304,7 @@ class SQL(SQLBase):
253
304
  class User:
254
305
  user_id: int
255
306
  name: str
256
- email: Optional[str]
307
+ email: str | None
257
308
  created_at: datetime
258
309
  is_deleted: bool
259
310
 
@@ -0,0 +1,3 @@
1
+ from . import adapter, connections, query, serializers, sqlbase
2
+
3
+ __all__ = ('adapter', 'connections', 'query', 'sqlbase', 'serializers')
@@ -1,14 +1,15 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import inspect
3
4
  from abc import ABC, abstractmethod
4
- from typing import TYPE_CHECKING, Any, Generic, TypeVar, overload
5
+ from typing import TYPE_CHECKING, Any, Generic, Self, TypeVar, overload
5
6
 
6
7
  from ddutils.annotation_helpers import is_subclass
7
8
 
8
9
  from ddsql.serializers import BaseSerializer
9
10
 
10
11
  if TYPE_CHECKING:
11
- from collections.abc import Sequence
12
+ from collections.abc import Hashable, Sequence
12
13
 
13
14
  from ddsql.query import Result
14
15
  from ddsql.sqlbase import SQLBase
@@ -18,6 +19,9 @@ class Adapter(ABC):
18
19
  serializer: BaseSerializer
19
20
 
20
21
  def __init_subclass__(cls, **kwargs):
22
+ super().__init_subclass__(**kwargs) # keeps Generic / mixins in the MRO working
23
+ if inspect.isabstract(cls): # intermediate base classes declare no serializer
24
+ return
21
25
  serializer = getattr(cls, 'serializer', None)
22
26
  serializer_class = getattr(serializer, '__class__', None)
23
27
  if not is_subclass(serializer_class, BaseSerializer):
@@ -27,6 +31,18 @@ class Adapter(ABC):
27
31
 
28
32
  def __init__(self, sql: SQLBase) -> None:
29
33
  self.sql = sql
34
+ self.alias: Hashable | None = None
35
+
36
+ def using(self, alias: Hashable) -> Self:
37
+ """
38
+ Picks the connection alias for this execution, like Django's `using`; `_execute` reads `self.alias`
39
+ (`None` = default) and passes it to whatever opens the connection:
40
+
41
+ await SQL(query).postgres.execute() # default connection
42
+ await SQL(query).postgres.using(PostgresDB.REPLICA).execute()
43
+ """
44
+ self.alias = alias
45
+ return self
30
46
 
31
47
  async def get_query(self) -> str:
32
48
  return await self.sql.query.render_template(
@@ -0,0 +1,70 @@
1
+ from collections.abc import Callable, Hashable, Mapping
2
+ from typing import Any, Generic, TypeVar
3
+
4
+ from ddutils.scoped_registry import ScopedRegistry
5
+
6
+ # The name a connection is registered under and selected by, like Django's `using` alias
7
+ ConnectionAliasT = TypeVar('ConnectionAliasT', bound=Hashable)
8
+ # The object a registry holds for a connection: a pooled client, a session
9
+ ConnectionT = TypeVar('ConnectionT')
10
+ # The context manager the factory opens on the chosen registry
11
+ ConnectionManagerT = TypeVar('ConnectionManagerT')
12
+
13
+
14
+ class ConnectionManager(Generic[ConnectionT]):
15
+ """
16
+ Default connection manager: hands out the connection of the registry's current scope and releases nothing on exit.
17
+
18
+ The connection is created on first use via `registry.set()` and kept for the life of its scope,
19
+ typically a process, since a client is itself a connection pool. Databases with transactions need their
20
+ own connection manager on top of the registry, e.g. a unit of work that begins, commits and closes a session on exit.
21
+ """
22
+
23
+ def __init__(self, registry: ScopedRegistry[ConnectionT]) -> None:
24
+ self._registry = registry
25
+
26
+ async def __aenter__(self) -> ConnectionT:
27
+ return await self._registry.set()
28
+
29
+ async def __aexit__(self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: Any) -> None:
30
+ return None
31
+
32
+
33
+ class ConnectionManagerFactory(Generic[ConnectionAliasT, ConnectionT, ConnectionManagerT]):
34
+ """
35
+ Resolves a connection alias to its registry and opens a connection manager on it, like Django's `using`.
36
+
37
+ `connection_manager_class` is the context manager opened on the chosen registry: `ConnectionManager` by
38
+ default, a project class for anything that needs setup and teardown around the connection (transactions):
39
+
40
+ clickhouse = ConnectionManagerFactory(client_registries, default=ClickhouseDB.PRIMARY)
41
+ atomic = ConnectionManagerFactory(session_registries, default=PostgresDB.PRIMARY, connection_manager_class=Atomic)
42
+
43
+ async with clickhouse() as client: ...
44
+ async with atomic(alias=PostgresDB.REPLICA) as session: ...
45
+ """
46
+
47
+ def __init__(
48
+ self,
49
+ registries: Mapping[ConnectionAliasT, ScopedRegistry[ConnectionT]],
50
+ default: ConnectionAliasT,
51
+ connection_manager_class: Callable[[ScopedRegistry[ConnectionT]], ConnectionManagerT] = ConnectionManager, # type: ignore[assignment]
52
+ ) -> None:
53
+ if default not in registries:
54
+ raise ValueError(f'Default connection {default!r} is not among the registries')
55
+ self.registries = registries
56
+ self.default = default
57
+ self.connection_manager_class = connection_manager_class
58
+
59
+ def registry(self, alias: Hashable | None = None) -> ScopedRegistry[ConnectionT]:
60
+ """The registry of `alias`, or of the default connection; `Hashable` because `Adapter.alias` is untyped."""
61
+ alias = self.default if alias is None else alias
62
+ if alias not in self.registries:
63
+ raise KeyError(f'Unknown connection {alias!r}; expected one of {list(self.registries)!r}')
64
+ return self.registries[alias] # type: ignore[index]
65
+
66
+ def __call__(self, alias: Hashable | None = None) -> ConnectionManagerT:
67
+ return self.connection_manager_class(self.registry(alias))
68
+
69
+
70
+ __all__ = ('ConnectionManager', 'ConnectionManagerFactory')
@@ -31,6 +31,7 @@ class SQLBase(ABC):
31
31
  params: dict[str, Any]
32
32
 
33
33
  def __init_subclass__(cls, **kwargs):
34
+ super().__init_subclass__(**kwargs) # keeps Generic / mixins in the MRO working
34
35
  if not cls.has_adapters:
35
36
  raise NotImplementedError('Subclasses must define at least one adapter')
36
37
 
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "ddsql"
3
3
  description = "SQL Query Library"
4
- version = "0.1.0"
4
+ version = "0.3.0"
5
5
  license = "MIT"
6
6
  license-files = ["LICENSE"]
7
7
  readme = "README.md"
@@ -10,6 +10,14 @@ keywords = [
10
10
  "python",
11
11
  "ddsql",
12
12
  ]
13
+ classifiers = [
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3.11",
16
+ "Programming Language :: Python :: 3.12",
17
+ "Programming Language :: Python :: 3.13",
18
+ "Programming Language :: Python :: 3.14",
19
+ "Typing :: Typed",
20
+ ]
13
21
  dependencies = [
14
22
  "ddutils>=0.2.0,<0.3.0",
15
23
  "jinja2>=3.0.0,<4.0.0",
@@ -1,13 +1,22 @@
1
1
  [project]
2
2
  name = "ddsql"
3
3
  description = "SQL Query Library"
4
- version = "0.1.0"
4
+ version = "0.3.0"
5
5
  authors = [{ name = "davyddd" }]
6
6
  license = "MIT"
7
7
  license-files = ["LICENSE"]
8
8
  readme = "README.md"
9
9
  requires-python = ">=3.11,<3.15"
10
10
  keywords = ["python", "ddsql"]
11
+ # uv_build does not derive these from requires-python (poetry did); PyPI and the pyversions badge read them
12
+ classifiers = [
13
+ "Programming Language :: Python :: 3",
14
+ "Programming Language :: Python :: 3.11",
15
+ "Programming Language :: Python :: 3.12",
16
+ "Programming Language :: Python :: 3.13",
17
+ "Programming Language :: Python :: 3.14",
18
+ "Typing :: Typed",
19
+ ]
11
20
  dependencies = [
12
21
  "ddutils>=0.2.0,<0.3.0",
13
22
  "jinja2>=3.0.0,<4.0.0",
@@ -1,3 +0,0 @@
1
- from . import adapter, query, serializers, sqlbase
2
-
3
- __all__ = ('adapter', 'query', 'sqlbase', 'serializers')
File without changes
File without changes
File without changes
File without changes