TypeDAL 3.16.4__py3-none-any.whl → 4.2.0__py3-none-any.whl
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.
- typedal/__about__.py +1 -1
- typedal/__init__.py +21 -3
- typedal/caching.py +37 -34
- typedal/config.py +18 -16
- typedal/constants.py +25 -0
- typedal/core.py +188 -3115
- typedal/define.py +188 -0
- typedal/fields.py +293 -34
- typedal/for_py4web.py +1 -1
- typedal/for_web2py.py +1 -1
- typedal/helpers.py +329 -40
- typedal/mixins.py +23 -27
- typedal/query_builder.py +1119 -0
- typedal/relationships.py +390 -0
- typedal/rows.py +524 -0
- typedal/serializers/as_json.py +9 -10
- typedal/tables.py +1131 -0
- typedal/types.py +187 -179
- typedal/web2py_py4web_shared.py +1 -1
- {typedal-3.16.4.dist-info → typedal-4.2.0.dist-info}/METADATA +8 -7
- typedal-4.2.0.dist-info/RECORD +25 -0
- {typedal-3.16.4.dist-info → typedal-4.2.0.dist-info}/WHEEL +1 -1
- typedal-3.16.4.dist-info/RECORD +0 -19
- {typedal-3.16.4.dist-info → typedal-4.2.0.dist-info}/entry_points.txt +0 -0
typedal/for_py4web.py
CHANGED
typedal/for_web2py.py
CHANGED
typedal/helpers.py
CHANGED
|
@@ -2,22 +2,30 @@
|
|
|
2
2
|
Helpers that work independently of core.
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
5
7
|
import datetime as dt
|
|
6
8
|
import fnmatch
|
|
7
9
|
import io
|
|
10
|
+
import re
|
|
11
|
+
import sys
|
|
8
12
|
import types
|
|
9
|
-
import typing
|
|
13
|
+
import typing as t
|
|
10
14
|
from collections import ChainMap
|
|
11
|
-
from typing import Any
|
|
12
15
|
|
|
13
16
|
from pydal import DAL
|
|
14
17
|
|
|
15
|
-
from .types import AnyDict, Field, Table
|
|
18
|
+
from .types import AnyDict, Expression, Field, Row, T, Table, Template # type: ignore
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
import annotationlib
|
|
22
|
+
except ImportError: # pragma: no cover
|
|
23
|
+
annotationlib = None
|
|
16
24
|
|
|
17
|
-
if
|
|
18
|
-
from . import
|
|
25
|
+
if t.TYPE_CHECKING:
|
|
26
|
+
from string.templatelib import Interpolation
|
|
19
27
|
|
|
20
|
-
|
|
28
|
+
from . import TypeDAL, TypedField, TypedTable
|
|
21
29
|
|
|
22
30
|
|
|
23
31
|
def is_union(some_type: type | types.UnionType) -> bool:
|
|
@@ -25,19 +33,34 @@ def is_union(some_type: type | types.UnionType) -> bool:
|
|
|
25
33
|
Check if a type is some type of Union.
|
|
26
34
|
|
|
27
35
|
Args:
|
|
28
|
-
some_type: types.UnionType = type(int | str);
|
|
36
|
+
some_type: types.UnionType = type(int | str); t.Union = t.Union[int, str]
|
|
29
37
|
|
|
30
38
|
"""
|
|
31
|
-
return
|
|
39
|
+
return t.get_origin(some_type) in (types.UnionType, t.Union)
|
|
32
40
|
|
|
33
41
|
|
|
34
|
-
def reversed_mro(cls: type) ->
|
|
42
|
+
def reversed_mro(cls: type) -> t.Iterable[type]:
|
|
35
43
|
"""
|
|
36
44
|
Get the Method Resolution Order (mro) for a class, in reverse order to be used with ChainMap.
|
|
37
45
|
"""
|
|
38
46
|
return reversed(getattr(cls, "__mro__", []))
|
|
39
47
|
|
|
40
48
|
|
|
49
|
+
def _cls_annotations(c: type) -> dict[str, type]: # pragma: no cover
|
|
50
|
+
"""
|
|
51
|
+
Functions to get the annotations of a class (excl inherited, use _all_annotations for that).
|
|
52
|
+
|
|
53
|
+
Uses `annotationlib` if available (since 3.14) and if so, resolves forward references immediately.
|
|
54
|
+
"""
|
|
55
|
+
if annotationlib:
|
|
56
|
+
return t.cast(
|
|
57
|
+
dict[str, type],
|
|
58
|
+
annotationlib.get_annotations(c, format=annotationlib.Format.VALUE, eval_str=True),
|
|
59
|
+
)
|
|
60
|
+
else:
|
|
61
|
+
return getattr(c, "__annotations__", {})
|
|
62
|
+
|
|
63
|
+
|
|
41
64
|
def _all_annotations(cls: type) -> ChainMap[str, type]:
|
|
42
65
|
"""
|
|
43
66
|
Returns a dictionary-like ChainMap that includes annotations for all \
|
|
@@ -45,7 +68,7 @@ def _all_annotations(cls: type) -> ChainMap[str, type]:
|
|
|
45
68
|
"""
|
|
46
69
|
# chainmap reverses the iterable, so reverse again beforehand to keep order normally:
|
|
47
70
|
|
|
48
|
-
return ChainMap(*(c
|
|
71
|
+
return ChainMap(*(_cls_annotations(c) for c in reversed_mro(cls)))
|
|
49
72
|
|
|
50
73
|
|
|
51
74
|
def all_dict(cls: type) -> AnyDict:
|
|
@@ -55,9 +78,9 @@ def all_dict(cls: type) -> AnyDict:
|
|
|
55
78
|
return dict(ChainMap(*(c.__dict__ for c in reversed_mro(cls)))) # type: ignore
|
|
56
79
|
|
|
57
80
|
|
|
58
|
-
def all_annotations(cls: type, _except:
|
|
81
|
+
def all_annotations(cls: type, _except: t.Optional[t.Iterable[str]] = None) -> dict[str, type]:
|
|
59
82
|
"""
|
|
60
|
-
Wrapper around `_all_annotations` that filters away
|
|
83
|
+
Wrapper around `_all_annotations` that filters away t.Any keys in _except.
|
|
61
84
|
|
|
62
85
|
It also flattens the ChainMap to a regular dict.
|
|
63
86
|
"""
|
|
@@ -68,7 +91,7 @@ def all_annotations(cls: type, _except: typing.Optional[typing.Iterable[str]] =
|
|
|
68
91
|
return {k: v for k, v in _all.items() if k not in _except}
|
|
69
92
|
|
|
70
93
|
|
|
71
|
-
def instanciate(cls:
|
|
94
|
+
def instanciate(cls: t.Type[T] | T, with_args: bool = False) -> T:
|
|
72
95
|
"""
|
|
73
96
|
Create an instance of T (if it is a class).
|
|
74
97
|
|
|
@@ -78,20 +101,20 @@ def instanciate(cls: typing.Type[T] | T, with_args: bool = False) -> T:
|
|
|
78
101
|
If with_args: spread the generic args into the class creation
|
|
79
102
|
(needed for e.g. TypedField(str), but not for list[str])
|
|
80
103
|
"""
|
|
81
|
-
if inner_cls :=
|
|
104
|
+
if inner_cls := t.get_origin(cls):
|
|
82
105
|
if not with_args:
|
|
83
|
-
return
|
|
106
|
+
return t.cast(T, inner_cls())
|
|
84
107
|
|
|
85
|
-
args =
|
|
86
|
-
return
|
|
108
|
+
args = t.get_args(cls)
|
|
109
|
+
return t.cast(T, inner_cls(*args))
|
|
87
110
|
|
|
88
111
|
if isinstance(cls, type):
|
|
89
|
-
return
|
|
112
|
+
return t.cast(T, cls())
|
|
90
113
|
|
|
91
114
|
return cls
|
|
92
115
|
|
|
93
116
|
|
|
94
|
-
def origin_is_subclass(obj: Any, _type: type) -> bool:
|
|
117
|
+
def origin_is_subclass(obj: t.Any, _type: type) -> bool:
|
|
95
118
|
"""
|
|
96
119
|
Check if the origin of a generic is a subclass of _type.
|
|
97
120
|
|
|
@@ -99,14 +122,14 @@ def origin_is_subclass(obj: Any, _type: type) -> bool:
|
|
|
99
122
|
origin_is_subclass(list[str], list) -> True
|
|
100
123
|
"""
|
|
101
124
|
return bool(
|
|
102
|
-
|
|
103
|
-
and isinstance(typing.get_origin(obj), type)
|
|
104
|
-
and issubclass(typing.get_origin(obj), _type)
|
|
125
|
+
t.get_origin(obj) and isinstance(t.get_origin(obj), type) and issubclass(t.get_origin(obj), _type),
|
|
105
126
|
)
|
|
106
127
|
|
|
107
128
|
|
|
108
129
|
def mktable(
|
|
109
|
-
data: dict[Any, Any],
|
|
130
|
+
data: dict[t.Any, t.Any],
|
|
131
|
+
header: t.Optional[t.Iterable[str] | range] = None,
|
|
132
|
+
skip_first: bool = True,
|
|
110
133
|
) -> str:
|
|
111
134
|
"""
|
|
112
135
|
Display a table for 'data'.
|
|
@@ -150,11 +173,11 @@ def mktable(
|
|
|
150
173
|
return output.getvalue()
|
|
151
174
|
|
|
152
175
|
|
|
153
|
-
K =
|
|
154
|
-
V =
|
|
176
|
+
K = t.TypeVar("K")
|
|
177
|
+
V = t.TypeVar("V")
|
|
155
178
|
|
|
156
179
|
|
|
157
|
-
def looks_like(v: Any, _type: type[Any]) -> bool:
|
|
180
|
+
def looks_like(v: t.Any, _type: type[t.Any]) -> bool:
|
|
158
181
|
"""
|
|
159
182
|
Returns true if v or v's class is of type _type, including if it is a generic.
|
|
160
183
|
|
|
@@ -166,7 +189,7 @@ def looks_like(v: Any, _type: type[Any]) -> bool:
|
|
|
166
189
|
return isinstance(v, _type) or (isinstance(v, type) and issubclass(v, _type)) or origin_is_subclass(v, _type)
|
|
167
190
|
|
|
168
191
|
|
|
169
|
-
def filter_out(mut_dict: dict[K, V], _type: type[T]) -> dict[K,
|
|
192
|
+
def filter_out(mut_dict: dict[K, V], _type: type[T]) -> dict[K, T]:
|
|
170
193
|
"""
|
|
171
194
|
Split a dictionary into things matching _type and the rest.
|
|
172
195
|
|
|
@@ -182,19 +205,19 @@ def unwrap_type(_type: type) -> type:
|
|
|
182
205
|
Example:
|
|
183
206
|
list[list[str]] -> str
|
|
184
207
|
"""
|
|
185
|
-
while args :=
|
|
208
|
+
while args := t.get_args(_type):
|
|
186
209
|
_type = args[0]
|
|
187
210
|
return _type
|
|
188
211
|
|
|
189
212
|
|
|
190
|
-
@
|
|
213
|
+
@t.overload
|
|
191
214
|
def extract_type_optional(annotation: T) -> tuple[T, bool]:
|
|
192
215
|
"""
|
|
193
216
|
T -> T is not exactly right because you'll get the inner type, but mypy seems happy with this.
|
|
194
217
|
"""
|
|
195
218
|
|
|
196
219
|
|
|
197
|
-
@
|
|
220
|
+
@t.overload
|
|
198
221
|
def extract_type_optional(annotation: None) -> tuple[None, bool]:
|
|
199
222
|
"""
|
|
200
223
|
None leads to None, False.
|
|
@@ -208,10 +231,10 @@ def extract_type_optional(annotation: T | None) -> tuple[T | None, bool]:
|
|
|
208
231
|
if annotation is None:
|
|
209
232
|
return None, False
|
|
210
233
|
|
|
211
|
-
if origin :=
|
|
212
|
-
args =
|
|
234
|
+
if origin := t.get_origin(annotation):
|
|
235
|
+
args = t.get_args(annotation)
|
|
213
236
|
|
|
214
|
-
if origin in (
|
|
237
|
+
if origin in (t.Union, types.UnionType, t.Optional) and args:
|
|
215
238
|
# remove None:
|
|
216
239
|
return next(_ for _ in args if _ and _ != types.NoneType and not isinstance(_, types.NoneType)), True
|
|
217
240
|
|
|
@@ -252,7 +275,7 @@ class DummyQuery:
|
|
|
252
275
|
return False
|
|
253
276
|
|
|
254
277
|
|
|
255
|
-
def as_lambda(value: T) ->
|
|
278
|
+
def as_lambda(value: T) -> t.Callable[..., T]:
|
|
256
279
|
"""
|
|
257
280
|
Wrap value in a callable.
|
|
258
281
|
"""
|
|
@@ -285,21 +308,21 @@ def get_db(table: "TypedTable | Table") -> "DAL":
|
|
|
285
308
|
"""
|
|
286
309
|
Get the underlying DAL instance for a pydal or typedal table.
|
|
287
310
|
"""
|
|
288
|
-
return
|
|
311
|
+
return t.cast("DAL", table._db)
|
|
289
312
|
|
|
290
313
|
|
|
291
314
|
def get_table(table: "TypedTable | Table") -> "Table":
|
|
292
315
|
"""
|
|
293
316
|
Get the underlying pydal table for a typedal table.
|
|
294
317
|
"""
|
|
295
|
-
return
|
|
318
|
+
return t.cast("Table", table._table)
|
|
296
319
|
|
|
297
320
|
|
|
298
|
-
def get_field(field: "TypedField[
|
|
321
|
+
def get_field(field: "TypedField[t.Any] | Field") -> "Field":
|
|
299
322
|
"""
|
|
300
323
|
Get the underlying pydal field from a typedal field.
|
|
301
324
|
"""
|
|
302
|
-
return
|
|
325
|
+
return t.cast(
|
|
303
326
|
"Field",
|
|
304
327
|
field, # Table.field already is a Field, but cast to make sure the editor knows this too.
|
|
305
328
|
)
|
|
@@ -310,7 +333,7 @@ class classproperty:
|
|
|
310
333
|
Combination of @classmethod and @property.
|
|
311
334
|
"""
|
|
312
335
|
|
|
313
|
-
def __init__(self, fget:
|
|
336
|
+
def __init__(self, fget: t.Callable[..., t.Any]) -> None:
|
|
314
337
|
"""
|
|
315
338
|
Initialize the classproperty.
|
|
316
339
|
|
|
@@ -319,7 +342,7 @@ class classproperty:
|
|
|
319
342
|
"""
|
|
320
343
|
self.fget = fget
|
|
321
344
|
|
|
322
|
-
def __get__(self, obj:
|
|
345
|
+
def __get__(self, obj: t.Any, owner: t.Type[T]) -> t.Any:
|
|
323
346
|
"""
|
|
324
347
|
Retrieve the property value.
|
|
325
348
|
|
|
@@ -331,3 +354,269 @@ class classproperty:
|
|
|
331
354
|
The value returned by the function.
|
|
332
355
|
"""
|
|
333
356
|
return self.fget(owner)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def smarter_adapt(db: TypeDAL, placeholder: t.Any) -> str:
|
|
360
|
+
"""
|
|
361
|
+
Smarter adaptation of placeholder to quote if needed.
|
|
362
|
+
|
|
363
|
+
Args:
|
|
364
|
+
db: Database object.
|
|
365
|
+
placeholder: Placeholder object.
|
|
366
|
+
|
|
367
|
+
Returns:
|
|
368
|
+
Quoted placeholder if needed, except for numbers (smart_adapt logic)
|
|
369
|
+
or fields/tables (use already quoted rname).
|
|
370
|
+
"""
|
|
371
|
+
return t.cast(
|
|
372
|
+
str,
|
|
373
|
+
getattr(placeholder, "sql_shortref", None) # for tables
|
|
374
|
+
or getattr(placeholder, "sqlsafe", None) # for fields
|
|
375
|
+
or db._adapter.smart_adapt(placeholder), # for others
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
# https://docs.python.org/3.14/library/string.templatelib.html
|
|
380
|
+
SYSTEM_SUPPORTS_TEMPLATES = sys.version_info > (3, 14)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def process_tstring(template: Template, operation: t.Callable[["Interpolation"], str]) -> str: # pragma: no cover
|
|
384
|
+
"""
|
|
385
|
+
Process a Template string by applying an operation to each interpolation.
|
|
386
|
+
|
|
387
|
+
This function iterates through a Template object, which contains both string literals
|
|
388
|
+
and Interpolation objects. String literals are preserved as-is, while Interpolation
|
|
389
|
+
objects are transformed using the provided operation function.
|
|
390
|
+
|
|
391
|
+
Args:
|
|
392
|
+
template: A Template object containing mixed string literals and Interpolation objects.
|
|
393
|
+
operation: A callable that takes an Interpolation object and returns a string.
|
|
394
|
+
This function will be applied to each interpolated value in the template.
|
|
395
|
+
|
|
396
|
+
Returns:
|
|
397
|
+
str: The processed string with all interpolations replaced by the results of
|
|
398
|
+
applying the operation function.
|
|
399
|
+
|
|
400
|
+
Example:
|
|
401
|
+
Basic f-string functionality can be implemented as:
|
|
402
|
+
|
|
403
|
+
>>> def fstring_operation(interpolation):
|
|
404
|
+
... return str(interpolation.value)
|
|
405
|
+
>>> value = "test"
|
|
406
|
+
>>> template = t"{value = }" # Template string literal
|
|
407
|
+
>>> result = process_tstring(template, fstring_operation)
|
|
408
|
+
>>> print(result) # "value = test"
|
|
409
|
+
|
|
410
|
+
Note:
|
|
411
|
+
This is a generic template processor. The specific behavior depends entirely
|
|
412
|
+
on the operation function provided.
|
|
413
|
+
"""
|
|
414
|
+
return "".join(part if isinstance(part, str) else operation(part) for part in template)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def sql_escape_template(db: TypeDAL, sql_fragment: Template) -> str: # pragma: no cover
|
|
418
|
+
r"""
|
|
419
|
+
Safely escape a Template string for SQL execution using database-specific escaping.
|
|
420
|
+
|
|
421
|
+
This function processes a Template string (t-string) by escaping all interpolated
|
|
422
|
+
values using the database adapter's escape mechanism, preventing SQL injection
|
|
423
|
+
attacks while maintaining the structure of the SQL query.
|
|
424
|
+
|
|
425
|
+
Args:
|
|
426
|
+
db: TypeDAL database connection object that provides the adapter for escaping.
|
|
427
|
+
sql_fragment: A Template object (t-string) containing SQL with interpolated values.
|
|
428
|
+
The interpolated values will be automatically escaped.
|
|
429
|
+
|
|
430
|
+
Returns:
|
|
431
|
+
str: SQL string with all interpolated values properly escaped for safe execution.
|
|
432
|
+
|
|
433
|
+
Example:
|
|
434
|
+
>>> user_input = "'; DROP TABLE users; --"
|
|
435
|
+
>>> query = t"SELECT * FROM users WHERE name = {user_input}"
|
|
436
|
+
>>> safe_query = sql_escape_template(db, query)
|
|
437
|
+
>>> print(safe_query) # "SELECT * FROM users WHERE name = '\'; DROP TABLE users; --'"
|
|
438
|
+
|
|
439
|
+
Security:
|
|
440
|
+
This function is essential for preventing SQL injection attacks when using
|
|
441
|
+
user-provided data in SQL queries. All interpolated values are escaped
|
|
442
|
+
according to the database adapter's rules.
|
|
443
|
+
|
|
444
|
+
Note:
|
|
445
|
+
Only available in Python 3.14+ when SYSTEM_SUPPORTS_TEMPLATES is True.
|
|
446
|
+
For earlier Python versions, use sql_escape() with string formatting.
|
|
447
|
+
"""
|
|
448
|
+
return process_tstring(sql_fragment, lambda part: smarter_adapt(db, part.value))
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def sql_escape(db: TypeDAL, sql_fragment: str | Template, *raw_args: t.Any, **raw_kwargs: t.Any) -> str:
|
|
452
|
+
"""
|
|
453
|
+
Generate escaped SQL fragments with safely substituted placeholders.
|
|
454
|
+
|
|
455
|
+
This function provides secure SQL string construction by escaping all provided
|
|
456
|
+
arguments using the database adapter's escaping mechanism. It supports both
|
|
457
|
+
traditional string formatting (Python < 3.14) and Template strings (Python 3.14+).
|
|
458
|
+
|
|
459
|
+
Args:
|
|
460
|
+
db: TypeDAL database connection object that provides the adapter for escaping.
|
|
461
|
+
sql_fragment: SQL fragment with placeholders (%s for positional, %(name)s for named).
|
|
462
|
+
In Python 3.14+, this can also be a Template (t-string) with
|
|
463
|
+
interpolated values that will be automatically escaped.
|
|
464
|
+
*raw_args: Positional arguments to be escaped and substituted for %s placeholders.
|
|
465
|
+
Only use with string fragments, not Template objects.
|
|
466
|
+
**raw_kwargs: Keyword arguments to be escaped and substituted for %(name)s placeholders.
|
|
467
|
+
Only use with string fragments, not Template objects.
|
|
468
|
+
|
|
469
|
+
Returns:
|
|
470
|
+
str: SQL fragment with all placeholders replaced by properly escaped values.
|
|
471
|
+
|
|
472
|
+
Raises:
|
|
473
|
+
ValueError: If both positional and keyword arguments are provided simultaneously.
|
|
474
|
+
|
|
475
|
+
Examples:
|
|
476
|
+
Positional arguments:
|
|
477
|
+
>>> safe_sql = sql_escape(db, "SELECT * FROM users WHERE id = %s", user_id)
|
|
478
|
+
|
|
479
|
+
Keyword arguments:
|
|
480
|
+
>>> safe_sql = sql_escape(db, "SELECT * FROM users WHERE name = %(name)s", name=username)
|
|
481
|
+
|
|
482
|
+
Template strings (Python 3.14+):
|
|
483
|
+
>>> safe_sql = sql_escape(db, t"SELECT * FROM users WHERE id = {user_id}")
|
|
484
|
+
|
|
485
|
+
Security:
|
|
486
|
+
All arguments are escaped using the database adapter's escaping rules to prevent
|
|
487
|
+
SQL injection attacks. Never concatenate user input directly into SQL strings.
|
|
488
|
+
"""
|
|
489
|
+
if raw_args and raw_kwargs: # pragma: no cover
|
|
490
|
+
raise ValueError("Please provide either args or kwargs, not both.")
|
|
491
|
+
|
|
492
|
+
if SYSTEM_SUPPORTS_TEMPLATES and isinstance(sql_fragment, Template): # pragma: no cover
|
|
493
|
+
return sql_escape_template(db, sql_fragment)
|
|
494
|
+
|
|
495
|
+
if raw_args:
|
|
496
|
+
# list
|
|
497
|
+
return sql_fragment % tuple(smarter_adapt(db, placeholder) for placeholder in raw_args)
|
|
498
|
+
else:
|
|
499
|
+
# dict
|
|
500
|
+
return sql_fragment % {key: smarter_adapt(db, placeholder) for key, placeholder in raw_kwargs.items()}
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def sql_expression(
|
|
504
|
+
db: TypeDAL,
|
|
505
|
+
sql_fragment: str | Template,
|
|
506
|
+
*raw_args: t.Any,
|
|
507
|
+
output_type: str | None = None,
|
|
508
|
+
**raw_kwargs: t.Any,
|
|
509
|
+
) -> Expression:
|
|
510
|
+
"""
|
|
511
|
+
Create a PyDAL Expression object from a raw SQL fragment with safe parameter substitution.
|
|
512
|
+
|
|
513
|
+
This function combines SQL escaping with PyDAL's Expression system, allowing you to
|
|
514
|
+
create database expressions from raw SQL while maintaining security through proper
|
|
515
|
+
parameter escaping.
|
|
516
|
+
|
|
517
|
+
Args:
|
|
518
|
+
db: The TypeDAL database connection object.
|
|
519
|
+
sql_fragment: Raw SQL fragment with placeholders (%s for positional, %(name)s for named).
|
|
520
|
+
In Python 3.14+, this can also be a Template (t-string) with
|
|
521
|
+
interpolated values that will be automatically escaped.
|
|
522
|
+
*raw_args: Positional arguments to be escaped and interpolated into the SQL fragment.
|
|
523
|
+
Only use with string fragments, not Template objects.
|
|
524
|
+
output_type: Optional type hint for the expected output type of the expression.
|
|
525
|
+
This can help with query analysis and optimization.
|
|
526
|
+
**raw_kwargs: Keyword arguments to be escaped and interpolated into the SQL fragment.
|
|
527
|
+
Only use with string fragments, not Template objects.
|
|
528
|
+
|
|
529
|
+
Returns:
|
|
530
|
+
Expression: A PyDAL Expression object wrapping the safely escaped SQL fragment.
|
|
531
|
+
|
|
532
|
+
Examples:
|
|
533
|
+
Creating a complex WHERE clause:
|
|
534
|
+
>>> expr = sql_expression(db,
|
|
535
|
+
... "age > %s AND status = %s",
|
|
536
|
+
... 18, "active",
|
|
537
|
+
... output_type="boolean")
|
|
538
|
+
>>> query = db(expr).select()
|
|
539
|
+
|
|
540
|
+
Using keyword arguments:
|
|
541
|
+
>>> expr = sql_expression(db,
|
|
542
|
+
... "EXTRACT(year FROM %(date_col)s) = %(year)s",
|
|
543
|
+
... date_col="created_at", year=2023,
|
|
544
|
+
... output_type="boolean")
|
|
545
|
+
|
|
546
|
+
Template strings (Python 3.14+):
|
|
547
|
+
>>> min_age = 21
|
|
548
|
+
>>> expr = sql_expression(db, t"age >= {min_age}", output_type="boolean")
|
|
549
|
+
|
|
550
|
+
Security:
|
|
551
|
+
All parameters are escaped using sql_escape() before being wrapped in the Expression,
|
|
552
|
+
ensuring protection against SQL injection attacks.
|
|
553
|
+
|
|
554
|
+
Note:
|
|
555
|
+
The returned Expression can be used anywhere PyDAL expects an expression,
|
|
556
|
+
such as in db().select(), .update(), or .delete() operations.
|
|
557
|
+
"""
|
|
558
|
+
safe_sql = sql_escape(db, sql_fragment, *raw_args, **raw_kwargs)
|
|
559
|
+
|
|
560
|
+
# create a pydal Expression wrapping a raw SQL fragment + placeholders
|
|
561
|
+
return Expression(
|
|
562
|
+
db,
|
|
563
|
+
db._adapter.dialect.raw,
|
|
564
|
+
safe_sql,
|
|
565
|
+
type=output_type, # optional type hint
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def normalize_table_keys(row: Row, pattern: re.Pattern[str] = re.compile(r"^([a-zA-Z_]+)_(\d{5,})$")) -> Row:
|
|
570
|
+
"""
|
|
571
|
+
Normalize table keys in a PyDAL Row object by stripping numeric hash suffixes from table names, \
|
|
572
|
+
only if the suffix is 5 or more digits.
|
|
573
|
+
|
|
574
|
+
For example:
|
|
575
|
+
Row({'articles_12345': {...}}) -> Row({'articles': {...}})
|
|
576
|
+
Row({'articles_123': {...}}) -> unchanged
|
|
577
|
+
|
|
578
|
+
Returns:
|
|
579
|
+
Row: A new Row object with normalized keys.
|
|
580
|
+
"""
|
|
581
|
+
new_data: dict[str, t.Any] = {}
|
|
582
|
+
for key, value in row.items():
|
|
583
|
+
if match := pattern.match(key):
|
|
584
|
+
base, _suffix = match.groups()
|
|
585
|
+
normalized_key = base
|
|
586
|
+
new_data[normalized_key] = value
|
|
587
|
+
else:
|
|
588
|
+
new_data[key] = value
|
|
589
|
+
return Row(new_data)
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def default_representer(field: TypedField[T], value: T, table: t.Type[TypedTable]) -> str:
|
|
593
|
+
"""
|
|
594
|
+
Simply call field.represent on the value.
|
|
595
|
+
"""
|
|
596
|
+
if represent := getattr(field, "represent", None):
|
|
597
|
+
return str(represent(value, table))
|
|
598
|
+
else:
|
|
599
|
+
return repr(value)
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def throw(exc: BaseException) -> t.Never:
|
|
603
|
+
"""
|
|
604
|
+
Raise the given exception.
|
|
605
|
+
|
|
606
|
+
This function provides a functional way to raise exceptions, allowing
|
|
607
|
+
exception raising to be used in expressions where a statement wouldn't work.
|
|
608
|
+
|
|
609
|
+
Args:
|
|
610
|
+
exc: The exception to be raised.
|
|
611
|
+
|
|
612
|
+
Returns:
|
|
613
|
+
Never returns normally as an exception is always raised.
|
|
614
|
+
|
|
615
|
+
Raises:
|
|
616
|
+
BaseException: Always raises the provided exception.
|
|
617
|
+
|
|
618
|
+
Examples:
|
|
619
|
+
>>> value = get_value() or throw(ValueError("No value available"))
|
|
620
|
+
>>> result = data.get('key') if data else throw(KeyError("Missing data"))
|
|
621
|
+
"""
|
|
622
|
+
raise exc
|
typedal/mixins.py
CHANGED
|
@@ -5,26 +5,22 @@ Mixins can add reusable fields and behavior (optimally both, otherwise it doesn'
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
import base64
|
|
8
|
+
import datetime as dt
|
|
8
9
|
import os
|
|
9
|
-
import typing
|
|
10
|
+
import typing as t
|
|
10
11
|
import warnings
|
|
11
|
-
from datetime import datetime
|
|
12
|
-
from typing import Any, Optional
|
|
13
12
|
|
|
14
13
|
from pydal import DAL
|
|
15
14
|
from pydal.validators import IS_NOT_IN_DB, ValidationError
|
|
16
15
|
from slugify import slugify
|
|
17
16
|
|
|
18
|
-
from .core import
|
|
19
|
-
QueryBuilder,
|
|
20
|
-
T_MetaInstance,
|
|
21
|
-
TableMeta,
|
|
22
|
-
TypeDAL,
|
|
23
|
-
TypedTable,
|
|
24
|
-
_TypedTable,
|
|
25
|
-
)
|
|
17
|
+
from .core import TypeDAL
|
|
26
18
|
from .fields import DatetimeField, StringField
|
|
27
|
-
from .
|
|
19
|
+
from .tables import _TypedTable
|
|
20
|
+
from .types import OpRow, Set, T_MetaInstance
|
|
21
|
+
|
|
22
|
+
if t.TYPE_CHECKING:
|
|
23
|
+
from .tables import TypedTable # noqa: F401
|
|
28
24
|
|
|
29
25
|
|
|
30
26
|
class Mixin(_TypedTable):
|
|
@@ -38,9 +34,9 @@ class Mixin(_TypedTable):
|
|
|
38
34
|
('inconsistent method resolution' or 'metaclass conflicts')
|
|
39
35
|
"""
|
|
40
36
|
|
|
41
|
-
__settings__:
|
|
37
|
+
__settings__: t.ClassVar[dict[str, t.Any]]
|
|
42
38
|
|
|
43
|
-
def __init_subclass__(cls, **kwargs: Any):
|
|
39
|
+
def __init_subclass__(cls, **kwargs: t.Any):
|
|
44
40
|
"""
|
|
45
41
|
Ensures __settings__ exists for other mixins.
|
|
46
42
|
"""
|
|
@@ -52,8 +48,8 @@ class TimestampsMixin(Mixin):
|
|
|
52
48
|
A Mixin class for adding timestamp fields to a model.
|
|
53
49
|
"""
|
|
54
50
|
|
|
55
|
-
created_at = DatetimeField(default=datetime.now, writable=False)
|
|
56
|
-
updated_at = DatetimeField(default=datetime.now, writable=False)
|
|
51
|
+
created_at = DatetimeField(default=dt.datetime.now, writable=False)
|
|
52
|
+
updated_at = DatetimeField(default=dt.datetime.now, writable=False)
|
|
57
53
|
|
|
58
54
|
@classmethod
|
|
59
55
|
def __on_define__(cls, db: TypeDAL) -> None:
|
|
@@ -73,7 +69,7 @@ class TimestampsMixin(Mixin):
|
|
|
73
69
|
_: Set: Unused parameter.
|
|
74
70
|
row (OpRow): The row to update.
|
|
75
71
|
"""
|
|
76
|
-
row["updated_at"] = datetime.now()
|
|
72
|
+
row["updated_at"] = dt.datetime.now()
|
|
77
73
|
|
|
78
74
|
cls._before_update.append(set_updated_at)
|
|
79
75
|
|
|
@@ -89,7 +85,7 @@ def slug_random_suffix(length: int = 8) -> str:
|
|
|
89
85
|
return base64.urlsafe_b64encode(os.urandom(length)).rstrip(b"=").decode().strip("=")
|
|
90
86
|
|
|
91
87
|
|
|
92
|
-
T =
|
|
88
|
+
T = t.TypeVar("T")
|
|
93
89
|
|
|
94
90
|
|
|
95
91
|
# noinspection PyPep8Naming
|
|
@@ -112,7 +108,7 @@ class HAS_UNIQUE_SLUG(IS_NOT_IN_DB):
|
|
|
112
108
|
"""
|
|
113
109
|
super().__init__(db, field, error_message)
|
|
114
110
|
|
|
115
|
-
def validate(self, original: T, record_id: Optional[int] = None) -> T:
|
|
111
|
+
def validate(self, original: T, record_id: t.Optional[int] = None) -> T:
|
|
116
112
|
"""
|
|
117
113
|
Performs checks to see if the slug already exists for a different row.
|
|
118
114
|
"""
|
|
@@ -154,7 +150,7 @@ class SlugMixin(Mixin):
|
|
|
154
150
|
# pub:
|
|
155
151
|
slug = StringField(unique=True, writable=False)
|
|
156
152
|
# priv:
|
|
157
|
-
__settings__:
|
|
153
|
+
__settings__: t.TypedDict( # type: ignore
|
|
158
154
|
"SlugFieldSettings",
|
|
159
155
|
{
|
|
160
156
|
"slug_field": str,
|
|
@@ -164,10 +160,10 @@ class SlugMixin(Mixin):
|
|
|
164
160
|
|
|
165
161
|
def __init_subclass__(
|
|
166
162
|
cls,
|
|
167
|
-
slug_field:
|
|
163
|
+
slug_field: t.Optional[str] = None,
|
|
168
164
|
slug_suffix_length: int = 0,
|
|
169
|
-
slug_suffix: Optional[int] = None,
|
|
170
|
-
**kw: Any,
|
|
165
|
+
slug_suffix: t.Optional[int] = None,
|
|
166
|
+
**kw: t.Any,
|
|
171
167
|
) -> None:
|
|
172
168
|
"""
|
|
173
169
|
Bind 'slug field' option to be used later (on_define).
|
|
@@ -180,7 +176,7 @@ class SlugMixin(Mixin):
|
|
|
180
176
|
if slug_field is None:
|
|
181
177
|
raise ValueError(
|
|
182
178
|
"SlugMixin requires a valid slug_field setting: "
|
|
183
|
-
"e.g. `class MyClass(TypedTable, SlugMixin, slug_field='title'): ...`"
|
|
179
|
+
"e.g. `class MyClass(TypedTable, SlugMixin, slug_field='title'): ...`",
|
|
184
180
|
)
|
|
185
181
|
|
|
186
182
|
if slug_suffix:
|
|
@@ -197,7 +193,7 @@ class SlugMixin(Mixin):
|
|
|
197
193
|
|
|
198
194
|
@classmethod
|
|
199
195
|
def __generate_slug_before_insert(cls, row: OpRow) -> None:
|
|
200
|
-
if row.get("slug"):
|
|
196
|
+
if row.get("slug"): # type: ignore
|
|
201
197
|
# manually set -> skip
|
|
202
198
|
return None
|
|
203
199
|
|
|
@@ -235,7 +231,7 @@ class SlugMixin(Mixin):
|
|
|
235
231
|
slug_field.requires = current_requires
|
|
236
232
|
|
|
237
233
|
@classmethod
|
|
238
|
-
def from_slug(cls:
|
|
234
|
+
def from_slug(cls: t.Type[T_MetaInstance], slug: str, join: bool = True) -> t.Optional[T_MetaInstance]:
|
|
239
235
|
"""
|
|
240
236
|
Find a row by its slug.
|
|
241
237
|
"""
|
|
@@ -246,7 +242,7 @@ class SlugMixin(Mixin):
|
|
|
246
242
|
return builder.first()
|
|
247
243
|
|
|
248
244
|
@classmethod
|
|
249
|
-
def from_slug_or_fail(cls:
|
|
245
|
+
def from_slug_or_fail(cls: t.Type[T_MetaInstance], slug: str, join: bool = True) -> T_MetaInstance:
|
|
250
246
|
"""
|
|
251
247
|
Find a row by its slug, or raise an error if it doesn't exist.
|
|
252
248
|
"""
|