ddsql 0.0.3__tar.gz → 0.1.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,17 @@
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
+
1
15
  # DDSQL
2
16
 
3
17
  [![pypi](https://img.shields.io/pypi/v/ddsql.svg)](https://pypi.python.org/pypi/ddsql)
@@ -16,6 +30,21 @@ Install the library using pip:
16
30
  pip install ddsql
17
31
  ```
18
32
 
33
+ ## Development
34
+
35
+ The project runs entirely in Docker. Requires Docker and [Fabric](https://www.fabfile.org/) on the host:
36
+
37
+ ```bash
38
+ fab build # build the dev image
39
+ fab tests # run pytest
40
+ fab linters # run ruff (with --fix), ty and complexipy
41
+ fab shell # IPython inside the container
42
+ fab bash # bash inside the container
43
+ ```
44
+
45
+ This project was generated from [dd-lib-stub](https://github.com/davyddd/dd-lib-stub);
46
+ run `copier update` to pull in template updates.
47
+
19
48
  ## Serializer
20
49
 
21
50
  The serializer converts Python types to their SQL representations.
@@ -24,17 +53,17 @@ or create your own by inheriting from `BaseSerializer`.
24
53
 
25
54
  **Serialization Table**
26
55
 
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, ...)` |
56
+ | Python Type | Base | PostgreSQL | ClickHouse |
57
+ |----------------------|-------------------------|------------------------------------|-------------------------------------------------------|
58
+ | `None` | `NULL` | `NULL` | `NULL` |
59
+ | `bool` | `true`/`false` | `true`/`false` | `true`/`false` |
60
+ | `int` | `123` | `123` | `123` |
61
+ | `float`/`Decimal` | `45.67` | `45.67` | `45.67` |
62
+ | `str` | `'value'` | `'value'` | `'value'` |
63
+ | `UUID` | `'550e8400-...'` | `'550e8400-...'::uuid` | `toUUID('550e8400-...')` |
64
+ | `datetime` | `'2025-01-01T12:00:00'` | `'2025-01-01T12:00:00'::timestamp` | `parseDateTime64BestEffort('2025-01-01T12:00:00', 6)` |
65
+ | `date` | `'2025-01-01'` | `'2025-01-01'::date` | `toDate('2025-01-01')` |
66
+ | `list`/`tuple`/`set` | `(item1, item2, ...)` | `(item1, item2, ...)` | `(item1, item2, ...)` |
38
67
 
39
68
  String values are escaped according to the dialect rules, so quotes, backslashes
40
69
  and control characters cannot break the query:
@@ -48,6 +77,16 @@ and control characters cannot break the query:
48
77
  A NUL (`0x00`) character raises `ValueError`, since PostgreSQL cannot store it in text values.
49
78
 
50
79
  The serialized literal is always a single printable line.
80
+
81
+ `datetime` values keep their microseconds and UTC offset:
82
+
83
+ - `ClickhouseSerializer` renders `parseDateTime64BestEffort('...', 6)`, which yields a `DateTime64(6)`
84
+ literal; a timezone-aware value is converted according to its offset, a naive value is interpreted
85
+ in the server time zone. Inserting such a literal into a `DateTime` column silently truncates it
86
+ to seconds, and comparisons with `DateTime` columns work as expected.
87
+ - `PostgresSerializer` renders a timezone-aware value as `'...+03:00'::timestamptz`, so its offset
88
+ is honoured; a naive value is rendered as `'...'::timestamp`, as before.
89
+
51
90
  To customize escaping in your own serializer, override the `escape_string` method.
52
91
 
53
92
  If you need to serialize a type not listed in the table, override the `serialize_other_object` method in your serializer:
@@ -145,7 +184,7 @@ which wraps raw database rows into the specified model (called internally by `Ad
145
184
 
146
185
  Required parameters:
147
186
  - **model** – a declarative class (e.g., dataclass) describing the output result structure;
148
- - **text** or **path** – the SQL template source.
187
+ - **text** or **path** – the SQL template source (inline string or path to a file).
149
188
 
150
189
  ### Inline Template (text)
151
190
 
@@ -161,25 +200,24 @@ query = Query(
161
200
 
162
201
  ### File Template (path)
163
202
 
164
- For file-based templates, set the `SQL_TEMPLATES_DIR` environment variable to the directory containing your SQL files:
165
-
166
- ```bash
167
- export SQL_TEMPLATES_DIR=/app/src/templates/sql/
168
- ```
169
-
170
- Then use a relative path:
203
+ Pass the path to the SQL file as `Path`. The file is checked at construction time, so a wrong path fails
204
+ on import rather than on the first query:
171
205
 
172
206
  ```python
207
+ from pathlib import Path
208
+
173
209
  from ddsql.query import Query
174
210
 
211
+ SQL_TEMPLATES_DIR = Path(__file__).parent / 'templates' / 'sql'
175
212
 
176
- # Loads template from /app/src/templates/sql/users/get_by_id.sql
177
213
  query = Query(
178
- model=User,
179
- path='users/get_by_id.sql'
214
+ model=User,
215
+ path=SQL_TEMPLATES_DIR / 'users' / 'get_by_id.sql',
180
216
  )
181
217
  ```
182
218
 
219
+ `{% include %}` and `{% import %}` inside the template resolve relative to the template's directory.
220
+
183
221
  ### Result
184
222
 
185
223
  The result of query execution is a `Result` object that wraps the data into the specified model:
@@ -1,25 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: ddsql
3
- Version: 0.0.3
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
1
  # DDSQL
24
2
 
25
3
  [![pypi](https://img.shields.io/pypi/v/ddsql.svg)](https://pypi.python.org/pypi/ddsql)
@@ -38,6 +16,21 @@ Install the library using pip:
38
16
  pip install ddsql
39
17
  ```
40
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
+
41
34
  ## Serializer
42
35
 
43
36
  The serializer converts Python types to their SQL representations.
@@ -46,17 +39,17 @@ or create your own by inheriting from `BaseSerializer`.
46
39
 
47
40
  **Serialization Table**
48
41
 
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, ...)` |
42
+ | Python Type | Base | PostgreSQL | ClickHouse |
43
+ |----------------------|-------------------------|------------------------------------|-------------------------------------------------------|
44
+ | `None` | `NULL` | `NULL` | `NULL` |
45
+ | `bool` | `true`/`false` | `true`/`false` | `true`/`false` |
46
+ | `int` | `123` | `123` | `123` |
47
+ | `float`/`Decimal` | `45.67` | `45.67` | `45.67` |
48
+ | `str` | `'value'` | `'value'` | `'value'` |
49
+ | `UUID` | `'550e8400-...'` | `'550e8400-...'::uuid` | `toUUID('550e8400-...')` |
50
+ | `datetime` | `'2025-01-01T12:00:00'` | `'2025-01-01T12:00:00'::timestamp` | `parseDateTime64BestEffort('2025-01-01T12:00:00', 6)` |
51
+ | `date` | `'2025-01-01'` | `'2025-01-01'::date` | `toDate('2025-01-01')` |
52
+ | `list`/`tuple`/`set` | `(item1, item2, ...)` | `(item1, item2, ...)` | `(item1, item2, ...)` |
60
53
 
61
54
  String values are escaped according to the dialect rules, so quotes, backslashes
62
55
  and control characters cannot break the query:
@@ -70,6 +63,16 @@ and control characters cannot break the query:
70
63
  A NUL (`0x00`) character raises `ValueError`, since PostgreSQL cannot store it in text values.
71
64
 
72
65
  The serialized literal is always a single printable line.
66
+
67
+ `datetime` values keep their microseconds and UTC offset:
68
+
69
+ - `ClickhouseSerializer` renders `parseDateTime64BestEffort('...', 6)`, which yields a `DateTime64(6)`
70
+ literal; a timezone-aware value is converted according to its offset, a naive value is interpreted
71
+ in the server time zone. Inserting such a literal into a `DateTime` column silently truncates it
72
+ to seconds, and comparisons with `DateTime` columns work as expected.
73
+ - `PostgresSerializer` renders a timezone-aware value as `'...+03:00'::timestamptz`, so its offset
74
+ is honoured; a naive value is rendered as `'...'::timestamp`, as before.
75
+
73
76
  To customize escaping in your own serializer, override the `escape_string` method.
74
77
 
75
78
  If you need to serialize a type not listed in the table, override the `serialize_other_object` method in your serializer:
@@ -167,7 +170,7 @@ which wraps raw database rows into the specified model (called internally by `Ad
167
170
 
168
171
  Required parameters:
169
172
  - **model** – a declarative class (e.g., dataclass) describing the output result structure;
170
- - **text** or **path** – the SQL template source.
173
+ - **text** or **path** – the SQL template source (inline string or path to a file).
171
174
 
172
175
  ### Inline Template (text)
173
176
 
@@ -183,25 +186,24 @@ query = Query(
183
186
 
184
187
  ### File Template (path)
185
188
 
186
- For file-based templates, set the `SQL_TEMPLATES_DIR` environment variable to the directory containing your SQL files:
187
-
188
- ```bash
189
- export SQL_TEMPLATES_DIR=/app/src/templates/sql/
190
- ```
191
-
192
- Then use a relative path:
189
+ Pass the path to the SQL file as `Path`. The file is checked at construction time, so a wrong path fails
190
+ on import rather than on the first query:
193
191
 
194
192
  ```python
193
+ from pathlib import Path
194
+
195
195
  from ddsql.query import Query
196
196
 
197
+ SQL_TEMPLATES_DIR = Path(__file__).parent / 'templates' / 'sql'
197
198
 
198
- # Loads template from /app/src/templates/sql/users/get_by_id.sql
199
199
  query = Query(
200
- model=User,
201
- path='users/get_by_id.sql'
200
+ model=User,
201
+ path=SQL_TEMPLATES_DIR / 'users' / 'get_by_id.sql',
202
202
  )
203
203
  ```
204
204
 
205
+ `{% include %}` and `{% import %}` inside the template resolve relative to the template's directory.
206
+
205
207
  ### Result
206
208
 
207
209
  The result of query execution is a `Result` object that wraps the data into the specified model:
@@ -263,4 +265,4 @@ async def get_users():
263
265
  .execute()
264
266
  )
265
267
  return result.get_list()
266
- ```
268
+ ```
@@ -1,13 +1,15 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from abc import ABC, abstractmethod
4
- from typing import TYPE_CHECKING, Any, Dict, Generic, Optional, Sequence, Type, TypeVar
4
+ from typing import TYPE_CHECKING, Any, Generic, TypeVar, overload
5
5
 
6
6
  from ddutils.annotation_helpers import is_subclass
7
7
 
8
8
  from ddsql.serializers import BaseSerializer
9
9
 
10
10
  if TYPE_CHECKING:
11
+ from collections.abc import Sequence
12
+
11
13
  from ddsql.query import Result
12
14
  from ddsql.sqlbase import SQLBase
13
15
 
@@ -15,7 +17,6 @@ if TYPE_CHECKING:
15
17
  class Adapter(ABC):
16
18
  serializer: BaseSerializer
17
19
 
18
- @classmethod
19
20
  def __init_subclass__(cls, **kwargs):
20
21
  serializer = getattr(cls, 'serializer', None)
21
22
  serializer_class = getattr(serializer, '__class__', None)
@@ -36,20 +37,28 @@ class Adapter(ABC):
36
37
  return self.sql.query.build_result(await self._execute())
37
38
 
38
39
  @abstractmethod
39
- async def _execute(self) -> Sequence[Dict[str, Any]]: # noqa: UP006
40
- ...
40
+ async def _execute(self) -> Sequence[dict[str, Any]]: ...
41
41
 
42
42
 
43
43
  AdapterT = TypeVar('AdapterT', bound=Adapter)
44
44
 
45
45
 
46
46
  class AdapterDescriptor(Generic[AdapterT]):
47
- adapter_class: Type[AdapterT] # noqa: UP006
47
+ adapter_class: type[AdapterT]
48
48
 
49
- def __init__(self, adapter_class: Type[AdapterT]): # noqa: UP006
49
+ def __init__(self, adapter_class: type[AdapterT]):
50
50
  self.adapter_class = adapter_class
51
51
 
52
- def __get__(self, sql: SQLBase, sql_class: Optional[Type[SQLBase]] = None) -> AdapterT: # noqa: UP006, UP007
52
+ @overload
53
+ def __get__(self, sql: None, sql_class: type[SQLBase]) -> AdapterDescriptor[AdapterT]: ...
54
+
55
+ @overload
56
+ def __get__(self, sql: SQLBase, sql_class: type[SQLBase] | None = None) -> AdapterT: ...
57
+
58
+ def __get__(self, sql: SQLBase | None, sql_class: type[SQLBase] | None = None) -> AdapterT | AdapterDescriptor[AdapterT]:
59
+ # accessed on the class, like `SQL.postgres`: return the descriptor itself, as `property` does
60
+ if sql is None:
61
+ return self
53
62
  return self.adapter_class(sql)
54
63
 
55
64
 
@@ -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('\\'): '\\\\',
@@ -40,7 +39,9 @@ class ClickhouseSerializer(BaseSerializer):
40
39
 
41
40
  @classmethod
42
41
  def serialize_datetime(cls, value: datetime) -> str:
43
- return f"parseDateTimeBestEffort('{cls.escape_string(value.isoformat())}')"
42
+ # parseDateTimeBestEffort returns DateTime (second precision) and silently drops
43
+ # microseconds; the DateTime64 variant keeps them and honours the UTC offset
44
+ return f"parseDateTime64BestEffort('{cls.escape_string(value.isoformat())}', 6)"
44
45
 
45
46
  @classmethod
46
47
  def serialize_date(cls, value: date) -> str:
@@ -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('\\'): '\\\\',
@@ -52,7 +51,10 @@ class PostgresSerializer(BaseSerializer):
52
51
 
53
52
  @classmethod
54
53
  def serialize_datetime(cls, value: datetime) -> str:
55
- return f"'{cls.escape_string(value.isoformat())}'::timestamp"
54
+ # `timestamp` ignores the UTC offset of the literal, so a timezone-aware value
55
+ # is rendered as `timestamptz` to keep its offset; naive values are left as-is
56
+ cast = 'timestamptz' if value.tzinfo is not None else 'timestamp'
57
+ return f"'{cls.escape_string(value.isoformat())}'::{cast}"
56
58
 
57
59
  @classmethod
58
60
  def serialize_date(cls, value: date) -> str:
@@ -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,17 @@ 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):
36
34
  if not cls.has_adapters:
37
35
  raise NotImplementedError('Subclasses must define at least one adapter')
38
36
 
39
37
  @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
38
+ def has_adapters(cls: type[SQLBase]) -> bool:
39
+ # Descriptors are looked up in the class dictionaries along the MRO, so inherited adapters count
40
+ # and no adapter gets instantiated just for the check
41
+ return any(isinstance(value, AdapterDescriptor) for klass in cls.__mro__ for value in vars(klass).values())
48
42
 
49
43
  def __init__(self, query: Query) -> None:
50
44
  self.query = query
@@ -0,0 +1,45 @@
1
+ [project]
2
+ name = "ddsql"
3
+ description = "SQL Query Library"
4
+ version = "0.1.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
+ dependencies = [
14
+ "ddutils>=0.2.0,<0.3.0",
15
+ "jinja2>=3.0.0,<4.0.0",
16
+ ]
17
+
18
+ [[project.authors]]
19
+ name = "davyddd"
20
+
21
+ [project.urls]
22
+ Repository = "https://github.com/davyddd/ddsql"
23
+
24
+ [dependency-groups]
25
+ dev = [
26
+ "ipdb==0.13.13",
27
+ "ipython==9.16.1",
28
+ ]
29
+ test = [
30
+ "pytest==9.0.3",
31
+ "pytest-cov==5.0.0",
32
+ "parameterized==0.9.0",
33
+ ]
34
+ linter = [
35
+ "ruff==0.15.10",
36
+ "ty==0.0.15",
37
+ "complexipy==8.0.1",
38
+ ]
39
+
40
+ [tool.uv.build-backend]
41
+ module-root = ""
42
+
43
+ [build-system]
44
+ requires = ["uv_build>=0.12.0,<0.13.0"]
45
+ build-backend = "uv_build"
@@ -0,0 +1,40 @@
1
+ [project]
2
+ name = "ddsql"
3
+ description = "SQL Query Library"
4
+ version = "0.1.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
+ dependencies = [
12
+ "ddutils>=0.2.0,<0.3.0",
13
+ "jinja2>=3.0.0,<4.0.0",
14
+ ]
15
+
16
+ [project.urls]
17
+ Repository = "https://github.com/davyddd/ddsql"
18
+
19
+ [dependency-groups]
20
+ dev = [
21
+ "ipdb==0.13.13",
22
+ "ipython==9.16.1",
23
+ ]
24
+ test = [
25
+ "pytest==9.0.3",
26
+ "pytest-cov==5.0.0",
27
+ "parameterized==0.9.0",
28
+ ]
29
+ linter = [
30
+ "ruff==0.15.10",
31
+ "ty==0.0.15",
32
+ "complexipy==8.0.1",
33
+ ]
34
+
35
+ [tool.uv.build-backend]
36
+ module-root = ""
37
+
38
+ [build-system]
39
+ requires = ["uv_build>=0.12.0,<0.13.0"]
40
+ build-backend = "uv_build"
@@ -1,33 +0,0 @@
1
- [tool.poetry]
2
- name = "ddsql"
3
- description = "SQL Query Library"
4
- version = "0.0.3"
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
File without changes