ddsql 0.0.1__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.
- ddsql-0.0.1/LICENSE +21 -0
- ddsql-0.0.1/PKG-INFO +252 -0
- ddsql-0.0.1/README.md +230 -0
- ddsql-0.0.1/ddsql/__init__.py +3 -0
- ddsql-0.0.1/ddsql/adapter.py +56 -0
- ddsql-0.0.1/ddsql/py.typed +0 -0
- ddsql-0.0.1/ddsql/query.py +81 -0
- ddsql-0.0.1/ddsql/serializers/__init__.py +5 -0
- ddsql-0.0.1/ddsql/serializers/base.py +71 -0
- ddsql-0.0.1/ddsql/serializers/clickhouse.py +21 -0
- ddsql-0.0.1/ddsql/serializers/postgres.py +21 -0
- ddsql-0.0.1/ddsql/sqlbase.py +56 -0
- ddsql-0.0.1/pyproject.toml +33 -0
ddsql-0.0.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Artem Davydov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
ddsql-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ddsql
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: SQL Query Library
|
|
5
|
+
License: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Keywords: python,ddsql
|
|
8
|
+
Author: davyddd
|
|
9
|
+
Requires-Python: >=3.9,<3.15
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Requires-Dist: ddutils (<0.2.0)
|
|
19
|
+
Requires-Dist: jinja2 (>=3.0.0,<4.0.0)
|
|
20
|
+
Project-URL: Repository, https://github.com/davyddd/ddsql
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# DDSQL
|
|
24
|
+
|
|
25
|
+
[](https://pypi.python.org/pypi/ddsql)
|
|
26
|
+
[](https://pepy.tech/project/ddsql)
|
|
27
|
+
[](https://github.com/davyddd/ddsql)
|
|
28
|
+
[](https://app.codecov.io/github/davyddd/ddsql)
|
|
29
|
+
[](https://github.com/davyddd/ddsql/blob/main/LICENSE)
|
|
30
|
+
|
|
31
|
+
**DDSQL** is a Python library for building SQL queries with Jinja2 template rendering and database adapter support.
|
|
32
|
+
Query results are automatically deserialized into typed models.
|
|
33
|
+
|
|
34
|
+
## Installation
|
|
35
|
+
|
|
36
|
+
Install the library using pip:
|
|
37
|
+
```bash
|
|
38
|
+
pip install ddsql
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Serializer
|
|
42
|
+
|
|
43
|
+
The serializer converts Python types to their SQL representations.
|
|
44
|
+
You can use one of the built-in serializers (`PostgresSerializer`, `ClickhouseSerializer`)
|
|
45
|
+
or create your own by inheriting from `BaseSerializer`.
|
|
46
|
+
|
|
47
|
+
**Serialization Table**
|
|
48
|
+
|
|
49
|
+
| Python Type | Base | PostgreSQL | ClickHouse |
|
|
50
|
+
|----------------------|--------------------------|-------------------------------------|---------------------------------------------------|
|
|
51
|
+
| `None` | `NULL` | `NULL` | `NULL` |
|
|
52
|
+
| `bool` | `true`/`false` | `true`/`false` | `true`/`false` |
|
|
53
|
+
| `int` | `123` | `123` | `123` |
|
|
54
|
+
| `float`/`Decimal` | `45.67` | `45.67` | `45.67` |
|
|
55
|
+
| `str` | `'value'` | `'value'` | `'value'` |
|
|
56
|
+
| `UUID` | `'550e8400-...'` | `'550e8400-...'::uuid` | `toUUID('550e8400-...')` |
|
|
57
|
+
| `datetime` | `'2025-01-01T12:00:00'` | `'2025-01-01T12:00:00'::timestamp` | `parseDateTimeBestEffort('2025-01-01T12:00:00')` |
|
|
58
|
+
| `date` | `'2025-01-01'` | `'2025-01-01'::date` | `toDate('2025-01-01')` |
|
|
59
|
+
| `list`/`tuple`/`set` | `(item1, item2, ...)` | `(item1, item2, ...)` | `(item1, item2, ...)` |
|
|
60
|
+
|
|
61
|
+
If you need to serialize a type not listed in the table, override the `serialize_other_object` method in your serializer:
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from ddsql.serializers import BaseSerializer
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class CustomSerializer(BaseSerializer):
|
|
68
|
+
def serialize_other_object(self, value):
|
|
69
|
+
if isinstance(value, CustomType):
|
|
70
|
+
return ...
|
|
71
|
+
...
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
To serialize values in SQL templates, wrap parameters with `serialize_value`:
|
|
75
|
+
|
|
76
|
+
```sql
|
|
77
|
+
SELECT *
|
|
78
|
+
FROM users
|
|
79
|
+
WHERE
|
|
80
|
+
name = {{ serialize_value(name) }}
|
|
81
|
+
AND created_at > {{ serialize_value(created_at) }}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
To add custom functions to templates, override the `template_functions` property:
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from ddsql.serializers import BaseSerializer
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class CustomSerializer(BaseSerializer):
|
|
91
|
+
@property
|
|
92
|
+
def template_functions(self):
|
|
93
|
+
return {
|
|
94
|
+
**super().template_functions,
|
|
95
|
+
'some_function': ...,
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Adapter
|
|
100
|
+
|
|
101
|
+
`Adapter` encapsulates database interactions. To create an adapter, inherit from the `Adapter` base class
|
|
102
|
+
and define two required elements:
|
|
103
|
+
- **serializer** – an instance of a serializer for converting Python types to SQL representations;
|
|
104
|
+
- **_execute** method – the database-specific query execution logic.
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
from ddsql.adapter import Adapter
|
|
108
|
+
from ddsql.serializers import PostgresSerializer
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class PostgresAdapter(Adapter):
|
|
112
|
+
serializer = PostgresSerializer()
|
|
113
|
+
|
|
114
|
+
async def _execute(self) -> Sequence[Dict[str, Any]]:
|
|
115
|
+
query = await self.get_query() # get the rendered SQL query
|
|
116
|
+
async with Atomic() as postgres_session:
|
|
117
|
+
result = await postgres_session.execute(text(query))
|
|
118
|
+
return [dict(zip(result.keys(), row)) for row in result.fetchall()]
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## SQLBase
|
|
122
|
+
|
|
123
|
+
`SQLBase` is configured once per project and defines which adapters are available for query execution.
|
|
124
|
+
It serves as the central point that connects queries with database adapters.
|
|
125
|
+
|
|
126
|
+
Create a subclass with one or more adapters:
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
from ddsql.sqlbase import SQLBase
|
|
130
|
+
from ddsql.adapter import AdapterDescriptor
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class SQL(SQLBase):
|
|
134
|
+
postgres: PostgresAdapter = AdapterDescriptor(PostgresAdapter)
|
|
135
|
+
clickhouse: ClickhouseAdapter = AdapterDescriptor(ClickhouseAdapter)
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Execution example:
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
from ddsql.query import Query
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
query = Query(...)
|
|
145
|
+
result = await SQL(query=query).with_params(email='test@test.test', is_deleted=False).postgres.execute()
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## Query
|
|
149
|
+
|
|
150
|
+
`Query` knows where to get the template from and how to render a SQL query.
|
|
151
|
+
It also handles result deserialization via the `build_result` method,
|
|
152
|
+
which wraps raw database rows into the specified model (called internally by `Adapter.execute`).
|
|
153
|
+
|
|
154
|
+
Required parameters:
|
|
155
|
+
- **model** – a declarative class (e.g., dataclass) describing the output result structure;
|
|
156
|
+
- **text** or **path** – the SQL template source.
|
|
157
|
+
|
|
158
|
+
### Inline Template (text)
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
from ddsql.query import Query
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
query = Query(
|
|
165
|
+
model=User,
|
|
166
|
+
text='SELECT user_id, name FROM users WHERE user_id = {{ serialize_value(user_id) }}'
|
|
167
|
+
)
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### File Template (path)
|
|
171
|
+
|
|
172
|
+
For file-based templates, set the `SQL_TEMPLATES_DIR` environment variable to the directory containing your SQL files:
|
|
173
|
+
|
|
174
|
+
```bash
|
|
175
|
+
export SQL_TEMPLATES_DIR=/app/src/templates/sql/
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Then use a relative path:
|
|
179
|
+
|
|
180
|
+
```python
|
|
181
|
+
from ddsql.query import Query
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# Loads template from /app/src/templates/sql/users/get_by_id.sql
|
|
185
|
+
query = Query(
|
|
186
|
+
model=User,
|
|
187
|
+
path='users/get_by_id.sql'
|
|
188
|
+
)
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
### Result
|
|
192
|
+
|
|
193
|
+
The result of query execution is a `Result` object that wraps the data into the specified model:
|
|
194
|
+
- `get()` – returns the first row as a model instance, or `None` if empty;
|
|
195
|
+
- `get_list()` – returns all rows as a tuple of model instances;
|
|
196
|
+
- `rows` – attribute for accessing raw data.
|
|
197
|
+
|
|
198
|
+
## Complete Example
|
|
199
|
+
|
|
200
|
+
```python
|
|
201
|
+
from dataclasses import dataclass
|
|
202
|
+
from datetime import datetime
|
|
203
|
+
from typing import Optional
|
|
204
|
+
|
|
205
|
+
from ddsql.query import Query
|
|
206
|
+
from ddsql.sqlbase import SQLBase
|
|
207
|
+
from ddsql.adapter import Adapter, AdapterDescriptor
|
|
208
|
+
from ddsql.serializers import PostgresSerializer
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class PostgresAdapter(Adapter):
|
|
212
|
+
serializer = PostgresSerializer()
|
|
213
|
+
|
|
214
|
+
async def _execute(self):
|
|
215
|
+
...
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class SQL(SQLBase):
|
|
219
|
+
postgres: PostgresAdapter = AdapterDescriptor(PostgresAdapter)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@dataclass
|
|
223
|
+
class User:
|
|
224
|
+
user_id: int
|
|
225
|
+
name: str
|
|
226
|
+
email: Optional[str]
|
|
227
|
+
created_at: datetime
|
|
228
|
+
is_deleted: bool
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
query = Query(
|
|
232
|
+
model=User,
|
|
233
|
+
text='''
|
|
234
|
+
SELECT *
|
|
235
|
+
FROM users
|
|
236
|
+
WHERE
|
|
237
|
+
created_at > {{ serialize_value(created_after) }}
|
|
238
|
+
LIMIT {{ limit }}
|
|
239
|
+
'''
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
async def get_users():
|
|
244
|
+
result = await (
|
|
245
|
+
SQL(query=query)
|
|
246
|
+
.with_params(created_after=datetime(2025, 1, 1))
|
|
247
|
+
.with_params(limit=10)
|
|
248
|
+
.postgres
|
|
249
|
+
.execute()
|
|
250
|
+
)
|
|
251
|
+
return result.get_list()
|
|
252
|
+
```
|
ddsql-0.0.1/README.md
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# DDSQL
|
|
2
|
+
|
|
3
|
+
[](https://pypi.python.org/pypi/ddsql)
|
|
4
|
+
[](https://pepy.tech/project/ddsql)
|
|
5
|
+
[](https://github.com/davyddd/ddsql)
|
|
6
|
+
[](https://app.codecov.io/github/davyddd/ddsql)
|
|
7
|
+
[](https://github.com/davyddd/ddsql/blob/main/LICENSE)
|
|
8
|
+
|
|
9
|
+
**DDSQL** is a Python library for building SQL queries with Jinja2 template rendering and database adapter support.
|
|
10
|
+
Query results are automatically deserialized into typed models.
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
Install the library using pip:
|
|
15
|
+
```bash
|
|
16
|
+
pip install ddsql
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Serializer
|
|
20
|
+
|
|
21
|
+
The serializer converts Python types to their SQL representations.
|
|
22
|
+
You can use one of the built-in serializers (`PostgresSerializer`, `ClickhouseSerializer`)
|
|
23
|
+
or create your own by inheriting from `BaseSerializer`.
|
|
24
|
+
|
|
25
|
+
**Serialization Table**
|
|
26
|
+
|
|
27
|
+
| Python Type | Base | PostgreSQL | ClickHouse |
|
|
28
|
+
|----------------------|--------------------------|-------------------------------------|---------------------------------------------------|
|
|
29
|
+
| `None` | `NULL` | `NULL` | `NULL` |
|
|
30
|
+
| `bool` | `true`/`false` | `true`/`false` | `true`/`false` |
|
|
31
|
+
| `int` | `123` | `123` | `123` |
|
|
32
|
+
| `float`/`Decimal` | `45.67` | `45.67` | `45.67` |
|
|
33
|
+
| `str` | `'value'` | `'value'` | `'value'` |
|
|
34
|
+
| `UUID` | `'550e8400-...'` | `'550e8400-...'::uuid` | `toUUID('550e8400-...')` |
|
|
35
|
+
| `datetime` | `'2025-01-01T12:00:00'` | `'2025-01-01T12:00:00'::timestamp` | `parseDateTimeBestEffort('2025-01-01T12:00:00')` |
|
|
36
|
+
| `date` | `'2025-01-01'` | `'2025-01-01'::date` | `toDate('2025-01-01')` |
|
|
37
|
+
| `list`/`tuple`/`set` | `(item1, item2, ...)` | `(item1, item2, ...)` | `(item1, item2, ...)` |
|
|
38
|
+
|
|
39
|
+
If you need to serialize a type not listed in the table, override the `serialize_other_object` method in your serializer:
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from ddsql.serializers import BaseSerializer
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class CustomSerializer(BaseSerializer):
|
|
46
|
+
def serialize_other_object(self, value):
|
|
47
|
+
if isinstance(value, CustomType):
|
|
48
|
+
return ...
|
|
49
|
+
...
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
To serialize values in SQL templates, wrap parameters with `serialize_value`:
|
|
53
|
+
|
|
54
|
+
```sql
|
|
55
|
+
SELECT *
|
|
56
|
+
FROM users
|
|
57
|
+
WHERE
|
|
58
|
+
name = {{ serialize_value(name) }}
|
|
59
|
+
AND created_at > {{ serialize_value(created_at) }}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
To add custom functions to templates, override the `template_functions` property:
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from ddsql.serializers import BaseSerializer
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class CustomSerializer(BaseSerializer):
|
|
69
|
+
@property
|
|
70
|
+
def template_functions(self):
|
|
71
|
+
return {
|
|
72
|
+
**super().template_functions,
|
|
73
|
+
'some_function': ...,
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Adapter
|
|
78
|
+
|
|
79
|
+
`Adapter` encapsulates database interactions. To create an adapter, inherit from the `Adapter` base class
|
|
80
|
+
and define two required elements:
|
|
81
|
+
- **serializer** – an instance of a serializer for converting Python types to SQL representations;
|
|
82
|
+
- **_execute** method – the database-specific query execution logic.
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from ddsql.adapter import Adapter
|
|
86
|
+
from ddsql.serializers import PostgresSerializer
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class PostgresAdapter(Adapter):
|
|
90
|
+
serializer = PostgresSerializer()
|
|
91
|
+
|
|
92
|
+
async def _execute(self) -> Sequence[Dict[str, Any]]:
|
|
93
|
+
query = await self.get_query() # get the rendered SQL query
|
|
94
|
+
async with Atomic() as postgres_session:
|
|
95
|
+
result = await postgres_session.execute(text(query))
|
|
96
|
+
return [dict(zip(result.keys(), row)) for row in result.fetchall()]
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## SQLBase
|
|
100
|
+
|
|
101
|
+
`SQLBase` is configured once per project and defines which adapters are available for query execution.
|
|
102
|
+
It serves as the central point that connects queries with database adapters.
|
|
103
|
+
|
|
104
|
+
Create a subclass with one or more adapters:
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
from ddsql.sqlbase import SQLBase
|
|
108
|
+
from ddsql.adapter import AdapterDescriptor
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class SQL(SQLBase):
|
|
112
|
+
postgres: PostgresAdapter = AdapterDescriptor(PostgresAdapter)
|
|
113
|
+
clickhouse: ClickhouseAdapter = AdapterDescriptor(ClickhouseAdapter)
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Execution example:
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
from ddsql.query import Query
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
query = Query(...)
|
|
123
|
+
result = await SQL(query=query).with_params(email='test@test.test', is_deleted=False).postgres.execute()
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Query
|
|
127
|
+
|
|
128
|
+
`Query` knows where to get the template from and how to render a SQL query.
|
|
129
|
+
It also handles result deserialization via the `build_result` method,
|
|
130
|
+
which wraps raw database rows into the specified model (called internally by `Adapter.execute`).
|
|
131
|
+
|
|
132
|
+
Required parameters:
|
|
133
|
+
- **model** – a declarative class (e.g., dataclass) describing the output result structure;
|
|
134
|
+
- **text** or **path** – the SQL template source.
|
|
135
|
+
|
|
136
|
+
### Inline Template (text)
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
from ddsql.query import Query
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
query = Query(
|
|
143
|
+
model=User,
|
|
144
|
+
text='SELECT user_id, name FROM users WHERE user_id = {{ serialize_value(user_id) }}'
|
|
145
|
+
)
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### File Template (path)
|
|
149
|
+
|
|
150
|
+
For file-based templates, set the `SQL_TEMPLATES_DIR` environment variable to the directory containing your SQL files:
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
export SQL_TEMPLATES_DIR=/app/src/templates/sql/
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Then use a relative path:
|
|
157
|
+
|
|
158
|
+
```python
|
|
159
|
+
from ddsql.query import Query
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# Loads template from /app/src/templates/sql/users/get_by_id.sql
|
|
163
|
+
query = Query(
|
|
164
|
+
model=User,
|
|
165
|
+
path='users/get_by_id.sql'
|
|
166
|
+
)
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### Result
|
|
170
|
+
|
|
171
|
+
The result of query execution is a `Result` object that wraps the data into the specified model:
|
|
172
|
+
- `get()` – returns the first row as a model instance, or `None` if empty;
|
|
173
|
+
- `get_list()` – returns all rows as a tuple of model instances;
|
|
174
|
+
- `rows` – attribute for accessing raw data.
|
|
175
|
+
|
|
176
|
+
## Complete Example
|
|
177
|
+
|
|
178
|
+
```python
|
|
179
|
+
from dataclasses import dataclass
|
|
180
|
+
from datetime import datetime
|
|
181
|
+
from typing import Optional
|
|
182
|
+
|
|
183
|
+
from ddsql.query import Query
|
|
184
|
+
from ddsql.sqlbase import SQLBase
|
|
185
|
+
from ddsql.adapter import Adapter, AdapterDescriptor
|
|
186
|
+
from ddsql.serializers import PostgresSerializer
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class PostgresAdapter(Adapter):
|
|
190
|
+
serializer = PostgresSerializer()
|
|
191
|
+
|
|
192
|
+
async def _execute(self):
|
|
193
|
+
...
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class SQL(SQLBase):
|
|
197
|
+
postgres: PostgresAdapter = AdapterDescriptor(PostgresAdapter)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@dataclass
|
|
201
|
+
class User:
|
|
202
|
+
user_id: int
|
|
203
|
+
name: str
|
|
204
|
+
email: Optional[str]
|
|
205
|
+
created_at: datetime
|
|
206
|
+
is_deleted: bool
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
query = Query(
|
|
210
|
+
model=User,
|
|
211
|
+
text='''
|
|
212
|
+
SELECT *
|
|
213
|
+
FROM users
|
|
214
|
+
WHERE
|
|
215
|
+
created_at > {{ serialize_value(created_after) }}
|
|
216
|
+
LIMIT {{ limit }}
|
|
217
|
+
'''
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
async def get_users():
|
|
222
|
+
result = await (
|
|
223
|
+
SQL(query=query)
|
|
224
|
+
.with_params(created_after=datetime(2025, 1, 1))
|
|
225
|
+
.with_params(limit=10)
|
|
226
|
+
.postgres
|
|
227
|
+
.execute()
|
|
228
|
+
)
|
|
229
|
+
return result.get_list()
|
|
230
|
+
```
|
|
@@ -0,0 +1,56 @@
|
|
|
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')
|
|
File without changes
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any, Dict, Generic, Optional, Sequence, Tuple, Type, TypeVar
|
|
4
|
+
|
|
5
|
+
from jinja2 import Environment, FileSystemLoader, Template
|
|
6
|
+
|
|
7
|
+
SQL_TEMPLATES_DIR = os.getenv('SQL_TEMPLATES_DIR')
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
DataT = TypeVar('DataT')
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Result(Generic[DataT]):
|
|
14
|
+
rows: Sequence[dict[str, Any]]
|
|
15
|
+
model: Type[DataT]
|
|
16
|
+
|
|
17
|
+
def __init__(self, rows: Sequence[dict[str, Any]], model: Type[DataT]):
|
|
18
|
+
self.rows = rows
|
|
19
|
+
self.model = model
|
|
20
|
+
|
|
21
|
+
def get(self) -> Optional[DataT]:
|
|
22
|
+
if not self.rows:
|
|
23
|
+
return None
|
|
24
|
+
return self.model(**self.rows[0])
|
|
25
|
+
|
|
26
|
+
def get_list(self) -> Tuple[DataT, ...]:
|
|
27
|
+
return tuple(self.model(**row) for row in self.rows)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Query(Generic[DataT]):
|
|
31
|
+
model: Type[DataT]
|
|
32
|
+
template: Template
|
|
33
|
+
|
|
34
|
+
def __init__(self, model: Type[DataT], text: Optional[str] = None, path: Optional[str] = None):
|
|
35
|
+
self.model = model
|
|
36
|
+
|
|
37
|
+
self.template = self.get_template(text, path)
|
|
38
|
+
|
|
39
|
+
@staticmethod
|
|
40
|
+
def get_template(text: Optional[str] = None, path: Optional[str] = None) -> Template:
|
|
41
|
+
if text:
|
|
42
|
+
file_system_loader = FileSystemLoader(Path('.'))
|
|
43
|
+
method = 'from_string'
|
|
44
|
+
query = text
|
|
45
|
+
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))
|
|
51
|
+
method = 'get_template'
|
|
52
|
+
query = path
|
|
53
|
+
else:
|
|
54
|
+
raise ValueError('One of `text` or `path` must be specified')
|
|
55
|
+
|
|
56
|
+
environment = Environment(
|
|
57
|
+
loader=file_system_loader, trim_blocks=True, lstrip_blocks=True, keep_trailing_newline=True, enable_async=True
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
return getattr(environment, method)(query)
|
|
61
|
+
|
|
62
|
+
def update_globals(self, envs: Optional[Dict[str, Any]] = None):
|
|
63
|
+
if envs:
|
|
64
|
+
self.template.environment.globals.update(envs)
|
|
65
|
+
|
|
66
|
+
@staticmethod
|
|
67
|
+
def format_sql(sql: str) -> str:
|
|
68
|
+
strings = sql.split('\n')
|
|
69
|
+
return '\n'.join([string.strip() for string in strings])
|
|
70
|
+
|
|
71
|
+
async def render_template(self, params: Dict[str, Any], template_functions: Optional[Dict[str, Any]] = None) -> str:
|
|
72
|
+
self.update_globals(template_functions)
|
|
73
|
+
|
|
74
|
+
rendered_template = await self.template.render_async(**params)
|
|
75
|
+
return self.format_sql(rendered_template)
|
|
76
|
+
|
|
77
|
+
def build_result(self, rows: Sequence[dict[str, Any]]) -> Result[DataT]:
|
|
78
|
+
return Result(rows=rows, model=self.model)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
__all__ = ('Result', 'Query')
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from collections.abc import Callable, Collection, Mapping
|
|
2
|
+
from datetime import date, datetime
|
|
3
|
+
from decimal import Decimal
|
|
4
|
+
from typing import Any, Dict, Union
|
|
5
|
+
from uuid import UUID
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class BaseSerializer:
|
|
9
|
+
@property
|
|
10
|
+
def template_functions(self) -> Dict[str, Callable[[Any], str]]:
|
|
11
|
+
return {'serialize_value': self.serialize_value}
|
|
12
|
+
|
|
13
|
+
def serialize_value(self, value: Any) -> str:
|
|
14
|
+
if value is None:
|
|
15
|
+
return self.serialize_none(value)
|
|
16
|
+
elif isinstance(value, bool):
|
|
17
|
+
return self.serialize_bool(value)
|
|
18
|
+
elif isinstance(value, (int, float, Decimal)):
|
|
19
|
+
return self.serialize_number(value)
|
|
20
|
+
elif isinstance(value, str):
|
|
21
|
+
return self.serialize_string(value)
|
|
22
|
+
elif isinstance(value, UUID):
|
|
23
|
+
return self.serialize_uuid(value)
|
|
24
|
+
elif isinstance(value, datetime):
|
|
25
|
+
return self.serialize_datetime(value)
|
|
26
|
+
elif isinstance(value, date):
|
|
27
|
+
# the check for date must come after datetime,
|
|
28
|
+
# because a datetime instance can also be identified as a date
|
|
29
|
+
return self.serialize_date(value)
|
|
30
|
+
elif isinstance(value, Collection) and not isinstance(value, Mapping):
|
|
31
|
+
return self.serialize_collection(value)
|
|
32
|
+
else:
|
|
33
|
+
return self.serialize_other_object(value)
|
|
34
|
+
|
|
35
|
+
@staticmethod
|
|
36
|
+
def serialize_none(value) -> str: # noqa: ARG004
|
|
37
|
+
return 'NULL'
|
|
38
|
+
|
|
39
|
+
@staticmethod
|
|
40
|
+
def serialize_bool(value: bool) -> str:
|
|
41
|
+
return f'{value}'.lower()
|
|
42
|
+
|
|
43
|
+
@staticmethod
|
|
44
|
+
def serialize_number(value: Union[int, float, Decimal]) -> str:
|
|
45
|
+
return f'{value}'
|
|
46
|
+
|
|
47
|
+
@staticmethod
|
|
48
|
+
def serialize_string(value: str) -> str:
|
|
49
|
+
return f"'{value}'"
|
|
50
|
+
|
|
51
|
+
@staticmethod
|
|
52
|
+
def serialize_uuid(value: UUID) -> str:
|
|
53
|
+
return f"'{value}'"
|
|
54
|
+
|
|
55
|
+
@staticmethod
|
|
56
|
+
def serialize_datetime(value: datetime) -> str:
|
|
57
|
+
return f"'{value.isoformat()}'"
|
|
58
|
+
|
|
59
|
+
@staticmethod
|
|
60
|
+
def serialize_date(value: date) -> str:
|
|
61
|
+
return f"'{value.isoformat()}'"
|
|
62
|
+
|
|
63
|
+
def serialize_collection(self, value: Collection) -> str:
|
|
64
|
+
items = ', '.join(self.serialize_value(item) for item in value)
|
|
65
|
+
return f'({items})'
|
|
66
|
+
|
|
67
|
+
def serialize_other_object(self, value: Any) -> str:
|
|
68
|
+
raise NotImplementedError()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
__all__ = ('BaseSerializer',)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from datetime import date, datetime
|
|
2
|
+
from uuid import UUID
|
|
3
|
+
|
|
4
|
+
from ddsql.serializers import BaseSerializer
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ClickhouseSerializer(BaseSerializer):
|
|
8
|
+
@staticmethod
|
|
9
|
+
def serialize_uuid(value: UUID) -> str:
|
|
10
|
+
return f"toUUID('{value}')"
|
|
11
|
+
|
|
12
|
+
@staticmethod
|
|
13
|
+
def serialize_datetime(value: datetime) -> str:
|
|
14
|
+
return f"parseDateTimeBestEffort('{value.isoformat()}')"
|
|
15
|
+
|
|
16
|
+
@staticmethod
|
|
17
|
+
def serialize_date(value: date) -> str:
|
|
18
|
+
return f"toDate('{value.isoformat()}')"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
__all__ = ('ClickhouseSerializer',)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from datetime import date, datetime
|
|
2
|
+
from uuid import UUID
|
|
3
|
+
|
|
4
|
+
from ddsql.serializers import BaseSerializer
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class PostgresSerializer(BaseSerializer):
|
|
8
|
+
@staticmethod
|
|
9
|
+
def serialize_uuid(value: UUID) -> str:
|
|
10
|
+
return f"'{value}'::uuid"
|
|
11
|
+
|
|
12
|
+
@staticmethod
|
|
13
|
+
def serialize_datetime(value: datetime) -> str:
|
|
14
|
+
return f"'{value.isoformat()}'::timestamp"
|
|
15
|
+
|
|
16
|
+
@staticmethod
|
|
17
|
+
def serialize_date(value: date) -> str:
|
|
18
|
+
return f"'{value.isoformat()}'::date"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
__all__ = ('PostgresSerializer',)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC
|
|
4
|
+
from typing import TYPE_CHECKING, Any, Dict
|
|
5
|
+
|
|
6
|
+
from ddutils.annotation_helpers import is_subclass
|
|
7
|
+
from ddutils.class_helpers import classproperty
|
|
8
|
+
|
|
9
|
+
from ddsql.adapter import Adapter
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from ddsql.query import Query
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SQLBase(ABC):
|
|
16
|
+
"""
|
|
17
|
+
Abstract base class for SQL query execution with adapter-based database connections.
|
|
18
|
+
|
|
19
|
+
This class provides a foundation for executing SQL queries against different database backends
|
|
20
|
+
using adapters. It handles query preparation, parameter management, and adapter selection.
|
|
21
|
+
|
|
22
|
+
Subclasses must define at least one adapter as a class attribute.
|
|
23
|
+
|
|
24
|
+
Attributes:
|
|
25
|
+
query: The SQL query to execute, either as a Query object or raw SQL string
|
|
26
|
+
params: Dictionary of parameters to be used in the query
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
query: Query
|
|
30
|
+
params: Dict[str, Any]
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def __init_subclass__(cls, **kwargs):
|
|
34
|
+
if not cls.has_adapters:
|
|
35
|
+
raise NotImplementedError('Subclasses must define at least one adapter')
|
|
36
|
+
|
|
37
|
+
@classproperty
|
|
38
|
+
def has_adapters(cls) -> bool:
|
|
39
|
+
for field in cls.__annotations__:
|
|
40
|
+
adapter = getattr(cls, field, None)
|
|
41
|
+
adapter_class = getattr(adapter, '__class__', None)
|
|
42
|
+
if adapter_class and is_subclass(adapter_class, Adapter):
|
|
43
|
+
return True
|
|
44
|
+
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
def __init__(self, query: Query) -> None:
|
|
48
|
+
self.query = query
|
|
49
|
+
self.params = {}
|
|
50
|
+
|
|
51
|
+
def with_params(self, **params: Any) -> SQLBase:
|
|
52
|
+
self.params = {**self.params, **params}
|
|
53
|
+
return self
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
__all__ = ('SQLBase',)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "ddsql"
|
|
3
|
+
description = "SQL Query Library"
|
|
4
|
+
version = "0.0.1"
|
|
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"
|