ddsql 0.0.4__tar.gz → 0.2.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,22 +1,20 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ddsql
3
- Version: 0.0.4
3
+ Version: 0.2.0
4
4
  Summary: SQL Query Library
5
- License: MIT
6
- License-File: LICENSE
7
5
  Keywords: python,ddsql
8
6
  Author: davyddd
9
- Requires-Python: >=3.9,<3.15
10
- Classifier: License :: OSI Approved :: MIT License
7
+ License-Expression: MIT
8
+ License-File: LICENSE
11
9
  Classifier: Programming Language :: Python :: 3
12
- Classifier: Programming Language :: Python :: 3.9
13
- Classifier: Programming Language :: Python :: 3.10
14
10
  Classifier: Programming Language :: Python :: 3.11
15
11
  Classifier: Programming Language :: Python :: 3.12
16
12
  Classifier: Programming Language :: Python :: 3.13
17
13
  Classifier: Programming Language :: Python :: 3.14
18
- Requires-Dist: ddutils (<0.2.0)
19
- Requires-Dist: jinja2 (>=3.0.0,<4.0.0)
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
20
18
  Project-URL: Repository, https://github.com/davyddd/ddsql
21
19
  Description-Content-Type: text/markdown
22
20
 
@@ -38,6 +36,21 @@ Install the library using pip:
38
36
  pip install ddsql
39
37
  ```
40
38
 
39
+ ## Development
40
+
41
+ The project runs entirely in Docker. Requires Docker and [Fabric](https://www.fabfile.org/) on the host:
42
+
43
+ ```bash
44
+ fab build # build the dev image
45
+ fab tests # run pytest
46
+ fab linters # run ruff (with --fix), ty and complexipy
47
+ fab shell # IPython inside the container
48
+ fab bash # bash inside the container
49
+ ```
50
+
51
+ This project was generated from [dd-lib-stub](https://github.com/davyddd/dd-lib-stub);
52
+ run `copier update` to pull in template updates.
53
+
41
54
  ## Serializer
42
55
 
43
56
  The serializer converts Python types to their SQL representations.
@@ -120,14 +133,67 @@ class CustomSerializer(BaseSerializer):
120
133
  }
121
134
  ```
122
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(using)` 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(using=PostgresDB.REPLICA) as session:
181
+ ...
182
+ ```
183
+
123
184
  ## Adapter
124
185
 
125
- `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
126
187
  and define two required elements:
127
188
  - **serializer** – an instance of a serializer for converting Python types to SQL representations;
128
189
  - **_execute** method – the database-specific query execution logic.
129
190
 
130
191
  ```python
192
+ from collections.abc import Sequence
193
+ from typing import Any
194
+
195
+ from sqlalchemy import text
196
+
131
197
  from ddsql.adapter import Adapter
132
198
  from ddsql.serializers import PostgresSerializer
133
199
 
@@ -135,13 +201,26 @@ from ddsql.serializers import PostgresSerializer
135
201
  class PostgresAdapter(Adapter):
136
202
  serializer = PostgresSerializer()
137
203
 
138
- async def _execute(self) -> Sequence[Dict[str, Any]]:
139
- query = await self.get_query() # get the rendered SQL query
140
- async with Atomic() as postgres_session:
141
- result = await postgres_session.execute(text(query))
204
+ async def _execute(self) -> Sequence[dict[str, Any]]:
205
+ async with atomic(using=self.db) as session:
206
+ query = await self.get_query() # the rendered SQL query
207
+ result = await session.execute(text(query))
142
208
  return [dict(zip(result.keys(), row)) for row in result.fetchall()]
143
209
  ```
144
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(db)` picks the database right before executing, like Django's `using`. `_execute` reads
217
+ `self.db` (`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
+
145
224
  ## SQLBase
146
225
 
147
226
  `SQLBase` is configured once per project and defines which adapters are available for query execution.
@@ -177,7 +256,7 @@ which wraps raw database rows into the specified model (called internally by `Ad
177
256
 
178
257
  Required parameters:
179
258
  - **model** – a declarative class (e.g., dataclass) describing the output result structure;
180
- - **text** or **path** – the SQL template source.
259
+ - **text** or **path** – the SQL template source (inline string or path to a file).
181
260
 
182
261
  ### Inline Template (text)
183
262
 
@@ -193,25 +272,24 @@ query = Query(
193
272
 
194
273
  ### File Template (path)
195
274
 
196
- For file-based templates, set the `SQL_TEMPLATES_DIR` environment variable to the directory containing your SQL files:
197
-
198
- ```bash
199
- export SQL_TEMPLATES_DIR=/app/src/templates/sql/
200
- ```
201
-
202
- Then use a relative path:
275
+ Pass the path to the SQL file as `Path`. The file is checked at construction time, so a wrong path fails
276
+ on import rather than on the first query:
203
277
 
204
278
  ```python
279
+ from pathlib import Path
280
+
205
281
  from ddsql.query import Query
206
282
 
283
+ SQL_TEMPLATES_DIR = Path(__file__).parent / 'templates' / 'sql'
207
284
 
208
- # Loads template from /app/src/templates/sql/users/get_by_id.sql
209
285
  query = Query(
210
- model=User,
211
- path='users/get_by_id.sql'
286
+ model=User,
287
+ path=SQL_TEMPLATES_DIR / 'users' / 'get_by_id.sql',
212
288
  )
213
289
  ```
214
290
 
291
+ `{% include %}` and `{% import %}` inside the template resolve relative to the template's directory.
292
+
215
293
  ### Result
216
294
 
217
295
  The result of query execution is a `Result` object that wraps the data into the specified model:
@@ -224,12 +302,11 @@ The result of query execution is a `Result` object that wraps the data into the
224
302
  ```python
225
303
  from dataclasses import dataclass
226
304
  from datetime import datetime
227
- from typing import Optional
228
305
 
229
- from ddsql.query import Query
230
- from ddsql.sqlbase import SQLBase
231
306
  from ddsql.adapter import Adapter, AdapterDescriptor
307
+ from ddsql.query import Query
232
308
  from ddsql.serializers import PostgresSerializer
309
+ from ddsql.sqlbase import SQLBase
233
310
 
234
311
 
235
312
  class PostgresAdapter(Adapter):
@@ -247,7 +324,7 @@ class SQL(SQLBase):
247
324
  class User:
248
325
  user_id: int
249
326
  name: str
250
- email: Optional[str]
327
+ email: str | None
251
328
  created_at: datetime
252
329
  is_deleted: bool
253
330
 
@@ -273,4 +350,4 @@ async def get_users():
273
350
  .execute()
274
351
  )
275
352
  return result.get_list()
276
- ```
353
+ ```
@@ -16,6 +16,21 @@ Install the library using pip:
16
16
  pip install ddsql
17
17
  ```
18
18
 
19
+ ## Development
20
+
21
+ The project runs entirely in Docker. Requires Docker and [Fabric](https://www.fabfile.org/) on the host:
22
+
23
+ ```bash
24
+ fab build # build the dev image
25
+ fab tests # run pytest
26
+ fab linters # run ruff (with --fix), ty and complexipy
27
+ fab shell # IPython inside the container
28
+ fab bash # bash inside the container
29
+ ```
30
+
31
+ This project was generated from [dd-lib-stub](https://github.com/davyddd/dd-lib-stub);
32
+ run `copier update` to pull in template updates.
33
+
19
34
  ## Serializer
20
35
 
21
36
  The serializer converts Python types to their SQL representations.
@@ -98,14 +113,67 @@ class CustomSerializer(BaseSerializer):
98
113
  }
99
114
  ```
100
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(using)` 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(using=PostgresDB.REPLICA) as session:
161
+ ...
162
+ ```
163
+
101
164
  ## Adapter
102
165
 
103
- `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
104
167
  and define two required elements:
105
168
  - **serializer** – an instance of a serializer for converting Python types to SQL representations;
106
169
  - **_execute** method – the database-specific query execution logic.
107
170
 
108
171
  ```python
172
+ from collections.abc import Sequence
173
+ from typing import Any
174
+
175
+ from sqlalchemy import text
176
+
109
177
  from ddsql.adapter import Adapter
110
178
  from ddsql.serializers import PostgresSerializer
111
179
 
@@ -113,13 +181,26 @@ from ddsql.serializers import PostgresSerializer
113
181
  class PostgresAdapter(Adapter):
114
182
  serializer = PostgresSerializer()
115
183
 
116
- async def _execute(self) -> Sequence[Dict[str, Any]]:
117
- query = await self.get_query() # get the rendered SQL query
118
- async with Atomic() as postgres_session:
119
- result = await postgres_session.execute(text(query))
184
+ async def _execute(self) -> Sequence[dict[str, Any]]:
185
+ async with atomic(using=self.db) as session:
186
+ query = await self.get_query() # the rendered SQL query
187
+ result = await session.execute(text(query))
120
188
  return [dict(zip(result.keys(), row)) for row in result.fetchall()]
121
189
  ```
122
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(db)` picks the database right before executing, like Django's `using`. `_execute` reads
197
+ `self.db` (`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
+
123
204
  ## SQLBase
124
205
 
125
206
  `SQLBase` is configured once per project and defines which adapters are available for query execution.
@@ -155,7 +236,7 @@ which wraps raw database rows into the specified model (called internally by `Ad
155
236
 
156
237
  Required parameters:
157
238
  - **model** – a declarative class (e.g., dataclass) describing the output result structure;
158
- - **text** or **path** – the SQL template source.
239
+ - **text** or **path** – the SQL template source (inline string or path to a file).
159
240
 
160
241
  ### Inline Template (text)
161
242
 
@@ -171,25 +252,24 @@ query = Query(
171
252
 
172
253
  ### File Template (path)
173
254
 
174
- For file-based templates, set the `SQL_TEMPLATES_DIR` environment variable to the directory containing your SQL files:
175
-
176
- ```bash
177
- export SQL_TEMPLATES_DIR=/app/src/templates/sql/
178
- ```
179
-
180
- Then use a relative path:
255
+ Pass the path to the SQL file as `Path`. The file is checked at construction time, so a wrong path fails
256
+ on import rather than on the first query:
181
257
 
182
258
  ```python
259
+ from pathlib import Path
260
+
183
261
  from ddsql.query import Query
184
262
 
263
+ SQL_TEMPLATES_DIR = Path(__file__).parent / 'templates' / 'sql'
185
264
 
186
- # Loads template from /app/src/templates/sql/users/get_by_id.sql
187
265
  query = Query(
188
- model=User,
189
- path='users/get_by_id.sql'
266
+ model=User,
267
+ path=SQL_TEMPLATES_DIR / 'users' / 'get_by_id.sql',
190
268
  )
191
269
  ```
192
270
 
271
+ `{% include %}` and `{% import %}` inside the template resolve relative to the template's directory.
272
+
193
273
  ### Result
194
274
 
195
275
  The result of query execution is a `Result` object that wraps the data into the specified model:
@@ -202,12 +282,11 @@ The result of query execution is a `Result` object that wraps the data into the
202
282
  ```python
203
283
  from dataclasses import dataclass
204
284
  from datetime import datetime
205
- from typing import Optional
206
285
 
207
- from ddsql.query import Query
208
- from ddsql.sqlbase import SQLBase
209
286
  from ddsql.adapter import Adapter, AdapterDescriptor
287
+ from ddsql.query import Query
210
288
  from ddsql.serializers import PostgresSerializer
289
+ from ddsql.sqlbase import SQLBase
211
290
 
212
291
 
213
292
  class PostgresAdapter(Adapter):
@@ -225,7 +304,7 @@ class SQL(SQLBase):
225
304
  class User:
226
305
  user_id: int
227
306
  name: str
228
- email: Optional[str]
307
+ email: str | None
229
308
  created_at: datetime
230
309
  is_deleted: bool
231
310
 
@@ -0,0 +1,3 @@
1
+ from . import adapter, connections, query, serializers, sqlbase
2
+
3
+ __all__ = ('adapter', 'connections', 'query', 'sqlbase', 'serializers')
@@ -0,0 +1,81 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ from abc import ABC, abstractmethod
5
+ from typing import TYPE_CHECKING, Any, Generic, Self, TypeVar, overload
6
+
7
+ from ddutils.annotation_helpers import is_subclass
8
+
9
+ from ddsql.serializers import BaseSerializer
10
+
11
+ if TYPE_CHECKING:
12
+ from collections.abc import Hashable, Sequence
13
+
14
+ from ddsql.query import Result
15
+ from ddsql.sqlbase import SQLBase
16
+
17
+
18
+ class Adapter(ABC):
19
+ serializer: BaseSerializer
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
25
+ serializer = getattr(cls, 'serializer', None)
26
+ serializer_class = getattr(serializer, '__class__', None)
27
+ if not is_subclass(serializer_class, BaseSerializer):
28
+ raise NotImplementedError(
29
+ 'Subclass of Adapter must define a valid serializer attribute that is a subclass of Serializer'
30
+ )
31
+
32
+ def __init__(self, sql: SQLBase) -> None:
33
+ self.sql = sql
34
+ self.db: Hashable | None = None
35
+
36
+ def using(self, db: Hashable) -> Self:
37
+ """
38
+ Picks the database for this execution, like Django's `using`; `_execute` reads `self.db`
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.db = db
45
+ return self
46
+
47
+ async def get_query(self) -> str:
48
+ return await self.sql.query.render_template(
49
+ params=self.sql.params, template_functions=self.serializer.template_functions
50
+ )
51
+
52
+ async def execute(self) -> Result:
53
+ return self.sql.query.build_result(await self._execute())
54
+
55
+ @abstractmethod
56
+ async def _execute(self) -> Sequence[dict[str, Any]]: ...
57
+
58
+
59
+ AdapterT = TypeVar('AdapterT', bound=Adapter)
60
+
61
+
62
+ class AdapterDescriptor(Generic[AdapterT]):
63
+ adapter_class: type[AdapterT]
64
+
65
+ def __init__(self, adapter_class: type[AdapterT]):
66
+ self.adapter_class = adapter_class
67
+
68
+ @overload
69
+ def __get__(self, sql: None, sql_class: type[SQLBase]) -> AdapterDescriptor[AdapterT]: ...
70
+
71
+ @overload
72
+ def __get__(self, sql: SQLBase, sql_class: type[SQLBase] | None = None) -> AdapterT: ...
73
+
74
+ def __get__(self, sql: SQLBase | None, sql_class: type[SQLBase] | None = None) -> AdapterT | AdapterDescriptor[AdapterT]:
75
+ # accessed on the class, like `SQL.postgres`: return the descriptor itself, as `property` does
76
+ if sql is None:
77
+ return self
78
+ return self.adapter_class(sql)
79
+
80
+
81
+ __all__ = ('Adapter', 'AdapterDescriptor')
@@ -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(using=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, using: Hashable | None = None) -> ScopedRegistry[ConnectionT]:
60
+ """The registry of `using`, or of the default connection; `Hashable` because `Adapter.db` is untyped."""
61
+ alias = self.default if using is None else using
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, using: Hashable | None = None) -> ConnectionManagerT:
67
+ return self.connection_manager_class(self.registry(using))
68
+
69
+
70
+ __all__ = ('ConnectionManager', 'ConnectionManagerFactory')
@@ -1,55 +1,51 @@
1
- import os
1
+ from collections.abc import Sequence
2
2
  from pathlib import Path
3
- from typing import Any, Dict, Generic, Optional, Sequence, Tuple, Type, TypeVar
3
+ from typing import Any, Generic, TypeVar
4
4
 
5
5
  from jinja2 import Environment, FileSystemLoader, Template
6
6
 
7
- SQL_TEMPLATES_DIR = os.getenv('SQL_TEMPLATES_DIR')
8
-
9
-
10
7
  DataT = TypeVar('DataT')
11
8
 
12
9
 
13
10
  class Result(Generic[DataT]):
14
11
  rows: Sequence[dict[str, Any]]
15
- model: Type[DataT]
12
+ model: type[DataT]
16
13
 
17
- def __init__(self, rows: Sequence[dict[str, Any]], model: Type[DataT]):
14
+ def __init__(self, rows: Sequence[dict[str, Any]], model: type[DataT]):
18
15
  self.rows = rows
19
16
  self.model = model
20
17
 
21
- def get(self) -> Optional[DataT]:
18
+ def get(self) -> DataT | None:
22
19
  if not self.rows:
23
20
  return None
24
21
  return self.model(**self.rows[0])
25
22
 
26
- def get_list(self) -> Tuple[DataT, ...]:
23
+ def get_list(self) -> tuple[DataT, ...]:
27
24
  return tuple(self.model(**row) for row in self.rows)
28
25
 
29
26
 
30
27
  class Query(Generic[DataT]):
31
- model: Type[DataT]
28
+ model: type[DataT]
32
29
  template: Template
33
30
 
34
- def __init__(self, model: Type[DataT], text: Optional[str] = None, path: Optional[str] = None):
31
+ def __init__(self, model: type[DataT], text: str | None = None, path: Path | None = None):
35
32
  self.model = model
36
33
 
37
34
  self.template = self.get_template(text, path)
38
35
 
39
36
  @staticmethod
40
- def get_template(text: Optional[str] = None, path: Optional[str] = None) -> Template:
37
+ def get_template(text: str | None = None, path: Path | None = None) -> Template:
41
38
  if text:
42
- file_system_loader = FileSystemLoader(Path('.'))
39
+ file_system_loader = None
43
40
  method = 'from_string'
44
41
  query = text
45
42
  elif path:
46
- if SQL_TEMPLATES_DIR is None:
47
- raise ValueError(
48
- 'SQL Templates dir is not defined. Make sure the SQL_TEMPLATES_DIR environment variable is set correctly.'
49
- )
50
- file_system_loader = FileSystemLoader(Path(SQL_TEMPLATES_DIR))
43
+ if not path.is_file():
44
+ raise FileNotFoundError(f'SQL template not found: {path.resolve()}')
45
+ # the loader is rooted at the template directory, so `{% include %}` resolves relative to the file
46
+ file_system_loader = FileSystemLoader(path.parent)
51
47
  method = 'get_template'
52
- query = path
48
+ query = path.name
53
49
  else:
54
50
  raise ValueError('One of `text` or `path` must be specified')
55
51
 
@@ -59,7 +55,7 @@ class Query(Generic[DataT]):
59
55
 
60
56
  return getattr(environment, method)(query)
61
57
 
62
- def update_globals(self, envs: Optional[Dict[str, Any]] = None):
58
+ def update_globals(self, envs: dict[str, Any] | None = None):
63
59
  if envs:
64
60
  self.template.environment.globals.update(envs)
65
61
 
@@ -68,7 +64,7 @@ class Query(Generic[DataT]):
68
64
  strings = sql.split('\n')
69
65
  return '\n'.join([string.strip() for string in strings])
70
66
 
71
- async def render_template(self, params: Dict[str, Any], template_functions: Optional[Dict[str, Any]] = None) -> str:
67
+ async def render_template(self, params: dict[str, Any], template_functions: dict[str, Any] | None = None) -> str:
72
68
  self.update_globals(template_functions)
73
69
 
74
70
  rendered_template = await self.template.render_async(**params)
@@ -1,13 +1,13 @@
1
1
  from collections.abc import Callable, Collection, Mapping
2
2
  from datetime import date, datetime
3
3
  from decimal import Decimal
4
- from typing import Any, Dict, Union
4
+ from typing import Any
5
5
  from uuid import UUID
6
6
 
7
7
 
8
8
  class BaseSerializer:
9
9
  @property
10
- def template_functions(self) -> Dict[str, Callable[[Any], str]]:
10
+ def template_functions(self) -> dict[str, Callable[[Any], str]]:
11
11
  return {'serialize_value': self.serialize_value}
12
12
 
13
13
  def serialize_value(self, value: Any) -> str:
@@ -41,7 +41,7 @@ class BaseSerializer:
41
41
  return f'{value}'.lower()
42
42
 
43
43
  @staticmethod
44
- def serialize_number(value: Union[int, float, Decimal]) -> str:
44
+ def serialize_number(value: int | float | Decimal) -> str:
45
45
  return f'{value}'
46
46
 
47
47
  @staticmethod
@@ -1,11 +1,10 @@
1
1
  from datetime import date, datetime
2
- from typing import Dict
3
2
  from uuid import UUID
4
3
 
5
4
  from ddsql.serializers import BaseSerializer
6
5
 
7
6
 
8
- def _build_escape_translation() -> Dict[int, str]:
7
+ def _build_escape_translation() -> dict[int, str]:
9
8
  # https://clickhouse.com/docs/en/sql-reference/syntax#string
10
9
  translation = {
11
10
  ord('\\'): '\\\\',
@@ -1,6 +1,5 @@
1
1
  import re
2
2
  from datetime import date, datetime
3
- from typing import Dict
4
3
  from uuid import UUID
5
4
 
6
5
  from ddsql.serializers import BaseSerializer
@@ -11,7 +10,7 @@ from ddsql.serializers import BaseSerializer
11
10
  _ESCAPE_SYNTAX_REQUIRED = re.compile(r'[\\\x00-\x1F\x7F]')
12
11
 
13
12
 
14
- def _build_escape_translation() -> Dict[int, str]:
13
+ def _build_escape_translation() -> dict[int, str]:
15
14
  # https://www.postgresql.org/docs/current/sql-syntax-lexical.html#SQL-SYNTAX-STRINGS-ESCAPE
16
15
  translation = {
17
16
  ord('\\'): '\\\\',
@@ -1,12 +1,11 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from abc import ABC
4
- from typing import TYPE_CHECKING, Any, Dict, TypeVar
4
+ from typing import TYPE_CHECKING, Any, TypeVar
5
5
 
6
- from ddutils.annotation_helpers import is_subclass
7
6
  from ddutils.class_helpers import classproperty
8
7
 
9
- from ddsql.adapter import Adapter
8
+ from ddsql.adapter import AdapterDescriptor
10
9
 
11
10
  if TYPE_CHECKING:
12
11
  from ddsql.query import Query
@@ -29,22 +28,18 @@ class SQLBase(ABC):
29
28
  """
30
29
 
31
30
  query: Query
32
- params: Dict[str, Any]
31
+ params: dict[str, Any]
33
32
 
34
- @classmethod
35
33
  def __init_subclass__(cls, **kwargs):
34
+ super().__init_subclass__(**kwargs) # keeps Generic / mixins in the MRO working
36
35
  if not cls.has_adapters:
37
36
  raise NotImplementedError('Subclasses must define at least one adapter')
38
37
 
39
38
  @classproperty
40
- def has_adapters(cls) -> bool:
41
- for field in cls.__annotations__:
42
- adapter = getattr(cls, field, None)
43
- adapter_class = getattr(adapter, '__class__', None)
44
- if adapter_class and is_subclass(adapter_class, Adapter):
45
- return True
46
-
47
- return False
39
+ def has_adapters(cls: type[SQLBase]) -> bool:
40
+ # Descriptors are looked up in the class dictionaries along the MRO, so inherited adapters count
41
+ # and no adapter gets instantiated just for the check
42
+ return any(isinstance(value, AdapterDescriptor) for klass in cls.__mro__ for value in vars(klass).values())
48
43
 
49
44
  def __init__(self, query: Query) -> None:
50
45
  self.query = query
@@ -0,0 +1,53 @@
1
+ [project]
2
+ name = "ddsql"
3
+ description = "SQL Query Library"
4
+ version = "0.2.0"
5
+ license = "MIT"
6
+ license-files = ["LICENSE"]
7
+ readme = "README.md"
8
+ requires-python = ">=3.11,<3.15"
9
+ keywords = [
10
+ "python",
11
+ "ddsql",
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
+ ]
21
+ dependencies = [
22
+ "ddutils>=0.2.0,<0.3.0",
23
+ "jinja2>=3.0.0,<4.0.0",
24
+ ]
25
+
26
+ [[project.authors]]
27
+ name = "davyddd"
28
+
29
+ [project.urls]
30
+ Repository = "https://github.com/davyddd/ddsql"
31
+
32
+ [dependency-groups]
33
+ dev = [
34
+ "ipdb==0.13.13",
35
+ "ipython==9.16.1",
36
+ ]
37
+ test = [
38
+ "pytest==9.0.3",
39
+ "pytest-cov==5.0.0",
40
+ "parameterized==0.9.0",
41
+ ]
42
+ linter = [
43
+ "ruff==0.15.10",
44
+ "ty==0.0.15",
45
+ "complexipy==8.0.1",
46
+ ]
47
+
48
+ [tool.uv.build-backend]
49
+ module-root = ""
50
+
51
+ [build-system]
52
+ requires = ["uv_build>=0.12.0,<0.13.0"]
53
+ build-backend = "uv_build"
@@ -0,0 +1,49 @@
1
+ [project]
2
+ name = "ddsql"
3
+ description = "SQL Query Library"
4
+ version = "0.2.0"
5
+ authors = [{ name = "davyddd" }]
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ readme = "README.md"
9
+ requires-python = ">=3.11,<3.15"
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
+ ]
20
+ dependencies = [
21
+ "ddutils>=0.2.0,<0.3.0",
22
+ "jinja2>=3.0.0,<4.0.0",
23
+ ]
24
+
25
+ [project.urls]
26
+ Repository = "https://github.com/davyddd/ddsql"
27
+
28
+ [dependency-groups]
29
+ dev = [
30
+ "ipdb==0.13.13",
31
+ "ipython==9.16.1",
32
+ ]
33
+ test = [
34
+ "pytest==9.0.3",
35
+ "pytest-cov==5.0.0",
36
+ "parameterized==0.9.0",
37
+ ]
38
+ linter = [
39
+ "ruff==0.15.10",
40
+ "ty==0.0.15",
41
+ "complexipy==8.0.1",
42
+ ]
43
+
44
+ [tool.uv.build-backend]
45
+ module-root = ""
46
+
47
+ [build-system]
48
+ requires = ["uv_build>=0.12.0,<0.13.0"]
49
+ build-backend = "uv_build"
@@ -1,3 +0,0 @@
1
- from . import adapter, query, serializers, sqlbase
2
-
3
- __all__ = ('adapter', 'query', 'sqlbase', 'serializers')
@@ -1,56 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from abc import ABC, abstractmethod
4
- from typing import TYPE_CHECKING, Any, Dict, Generic, Optional, Sequence, Type, TypeVar
5
-
6
- from ddutils.annotation_helpers import is_subclass
7
-
8
- from ddsql.serializers import BaseSerializer
9
-
10
- if TYPE_CHECKING:
11
- from ddsql.query import Result
12
- from ddsql.sqlbase import SQLBase
13
-
14
-
15
- class Adapter(ABC):
16
- serializer: BaseSerializer
17
-
18
- @classmethod
19
- def __init_subclass__(cls, **kwargs):
20
- serializer = getattr(cls, 'serializer', None)
21
- serializer_class = getattr(serializer, '__class__', None)
22
- if not is_subclass(serializer_class, BaseSerializer):
23
- raise NotImplementedError(
24
- 'Subclass of Adapter must define a valid serializer attribute that is a subclass of Serializer'
25
- )
26
-
27
- def __init__(self, sql: SQLBase) -> None:
28
- self.sql = sql
29
-
30
- async def get_query(self) -> str:
31
- return await self.sql.query.render_template(
32
- params=self.sql.params, template_functions=self.serializer.template_functions
33
- )
34
-
35
- async def execute(self) -> Result:
36
- return self.sql.query.build_result(await self._execute())
37
-
38
- @abstractmethod
39
- async def _execute(self) -> Sequence[Dict[str, Any]]: # noqa: UP006
40
- ...
41
-
42
-
43
- AdapterT = TypeVar('AdapterT', bound=Adapter)
44
-
45
-
46
- class AdapterDescriptor(Generic[AdapterT]):
47
- adapter_class: Type[AdapterT] # noqa: UP006
48
-
49
- def __init__(self, adapter_class: Type[AdapterT]): # noqa: UP006
50
- self.adapter_class = adapter_class
51
-
52
- def __get__(self, sql: SQLBase, sql_class: Optional[Type[SQLBase]] = None) -> AdapterT: # noqa: UP006, UP007
53
- return self.adapter_class(sql)
54
-
55
-
56
- __all__ = ('Adapter', 'AdapterDescriptor')
@@ -1,33 +0,0 @@
1
- [tool.poetry]
2
- name = "ddsql"
3
- description = "SQL Query Library"
4
- version = "0.0.4"
5
- authors = ["davyddd"]
6
- license = "MIT"
7
- repository = "https://github.com/davyddd/ddsql"
8
- readme = "README.md"
9
- keywords = ["python", "ddsql"]
10
- packages = [{include = "ddsql"}]
11
-
12
- [tool.poetry.dependencies]
13
- python = ">=3.9,<3.15"
14
- ddutils = "<0.2.0"
15
- jinja2 = ">=3.0.0,<4.0.0"
16
-
17
- [tool.poetry.group.dev.dependencies]
18
- ipdb = "0.13.9"
19
- ipython = "8.12.3"
20
-
21
- [tool.poetry.group.test.dependencies]
22
- pytest = "8.1.1"
23
- pytest-cov = "5.0.0"
24
- parameterized = "0.9.0"
25
- typing-extensions = "^4.12.2"
26
-
27
- [tool.poetry.group.linter.dependencies]
28
- mypy = "1.18.2"
29
- ruff = "0.14.4"
30
-
31
- [build-system]
32
- requires = ["poetry-core>=1.0.0"]
33
- build-backend = "poetry.core.masonry.api"
File without changes
File without changes