TypeDAL 3.17.2__py3-none-any.whl → 4.0.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.
Potentially problematic release.
This version of TypeDAL might be problematic. Click here for more details.
- typedal/__about__.py +1 -1
- typedal/__init__.py +9 -9
- typedal/caching.py +36 -33
- typedal/config.py +15 -16
- typedal/constants.py +25 -0
- typedal/core.py +176 -3168
- typedal/define.py +188 -0
- typedal/fields.py +254 -29
- typedal/for_web2py.py +1 -1
- typedal/helpers.py +268 -61
- typedal/mixins.py +21 -25
- typedal/query_builder.py +1059 -0
- typedal/relationships.py +264 -0
- typedal/rows.py +524 -0
- typedal/serializers/as_json.py +9 -10
- typedal/tables.py +1122 -0
- typedal/types.py +183 -177
- typedal/web2py_py4web_shared.py +1 -1
- {typedal-3.17.2.dist-info → typedal-4.0.0.dist-info}/METADATA +8 -7
- typedal-4.0.0.dist-info/RECORD +25 -0
- typedal-3.17.2.dist-info/RECORD +0 -19
- {typedal-3.17.2.dist-info → typedal-4.0.0.dist-info}/WHEEL +0 -0
- {typedal-3.17.2.dist-info → typedal-4.0.0.dist-info}/entry_points.txt +0 -0
typedal/helpers.py
CHANGED
|
@@ -7,19 +7,25 @@ from __future__ import annotations
|
|
|
7
7
|
import datetime as dt
|
|
8
8
|
import fnmatch
|
|
9
9
|
import io
|
|
10
|
+
import re
|
|
11
|
+
import sys
|
|
10
12
|
import types
|
|
11
|
-
import typing
|
|
13
|
+
import typing as t
|
|
12
14
|
from collections import ChainMap
|
|
13
|
-
from typing import Any
|
|
14
15
|
|
|
15
16
|
from pydal import DAL
|
|
16
17
|
|
|
17
|
-
from .types import AnyDict, Expression, Field, Table
|
|
18
|
+
from .types import AnyDict, Expression, Field, Row, T, Table, Template # type: ignore
|
|
18
19
|
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
try:
|
|
21
|
+
import annotationlib
|
|
22
|
+
except ImportError: # pragma: no cover
|
|
23
|
+
annotationlib = None
|
|
24
|
+
|
|
25
|
+
if t.TYPE_CHECKING:
|
|
26
|
+
from string.templatelib import Interpolation
|
|
21
27
|
|
|
22
|
-
|
|
28
|
+
from . import TypeDAL, TypedField, TypedTable
|
|
23
29
|
|
|
24
30
|
|
|
25
31
|
def is_union(some_type: type | types.UnionType) -> bool:
|
|
@@ -27,19 +33,34 @@ def is_union(some_type: type | types.UnionType) -> bool:
|
|
|
27
33
|
Check if a type is some type of Union.
|
|
28
34
|
|
|
29
35
|
Args:
|
|
30
|
-
some_type: types.UnionType = type(int | str);
|
|
36
|
+
some_type: types.UnionType = type(int | str); t.Union = t.Union[int, str]
|
|
31
37
|
|
|
32
38
|
"""
|
|
33
|
-
return
|
|
39
|
+
return t.get_origin(some_type) in (types.UnionType, t.Union)
|
|
34
40
|
|
|
35
41
|
|
|
36
|
-
def reversed_mro(cls: type) ->
|
|
42
|
+
def reversed_mro(cls: type) -> t.Iterable[type]:
|
|
37
43
|
"""
|
|
38
44
|
Get the Method Resolution Order (mro) for a class, in reverse order to be used with ChainMap.
|
|
39
45
|
"""
|
|
40
46
|
return reversed(getattr(cls, "__mro__", []))
|
|
41
47
|
|
|
42
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
|
+
|
|
43
64
|
def _all_annotations(cls: type) -> ChainMap[str, type]:
|
|
44
65
|
"""
|
|
45
66
|
Returns a dictionary-like ChainMap that includes annotations for all \
|
|
@@ -47,7 +68,7 @@ def _all_annotations(cls: type) -> ChainMap[str, type]:
|
|
|
47
68
|
"""
|
|
48
69
|
# chainmap reverses the iterable, so reverse again beforehand to keep order normally:
|
|
49
70
|
|
|
50
|
-
return ChainMap(*(c
|
|
71
|
+
return ChainMap(*(_cls_annotations(c) for c in reversed_mro(cls)))
|
|
51
72
|
|
|
52
73
|
|
|
53
74
|
def all_dict(cls: type) -> AnyDict:
|
|
@@ -57,9 +78,9 @@ def all_dict(cls: type) -> AnyDict:
|
|
|
57
78
|
return dict(ChainMap(*(c.__dict__ for c in reversed_mro(cls)))) # type: ignore
|
|
58
79
|
|
|
59
80
|
|
|
60
|
-
def all_annotations(cls: type, _except:
|
|
81
|
+
def all_annotations(cls: type, _except: t.Optional[t.Iterable[str]] = None) -> dict[str, type]:
|
|
61
82
|
"""
|
|
62
|
-
Wrapper around `_all_annotations` that filters away
|
|
83
|
+
Wrapper around `_all_annotations` that filters away t.Any keys in _except.
|
|
63
84
|
|
|
64
85
|
It also flattens the ChainMap to a regular dict.
|
|
65
86
|
"""
|
|
@@ -70,7 +91,7 @@ def all_annotations(cls: type, _except: typing.Optional[typing.Iterable[str]] =
|
|
|
70
91
|
return {k: v for k, v in _all.items() if k not in _except}
|
|
71
92
|
|
|
72
93
|
|
|
73
|
-
def instanciate(cls:
|
|
94
|
+
def instanciate(cls: t.Type[T] | T, with_args: bool = False) -> T:
|
|
74
95
|
"""
|
|
75
96
|
Create an instance of T (if it is a class).
|
|
76
97
|
|
|
@@ -80,20 +101,20 @@ def instanciate(cls: typing.Type[T] | T, with_args: bool = False) -> T:
|
|
|
80
101
|
If with_args: spread the generic args into the class creation
|
|
81
102
|
(needed for e.g. TypedField(str), but not for list[str])
|
|
82
103
|
"""
|
|
83
|
-
if inner_cls :=
|
|
104
|
+
if inner_cls := t.get_origin(cls):
|
|
84
105
|
if not with_args:
|
|
85
|
-
return
|
|
106
|
+
return t.cast(T, inner_cls())
|
|
86
107
|
|
|
87
|
-
args =
|
|
88
|
-
return
|
|
108
|
+
args = t.get_args(cls)
|
|
109
|
+
return t.cast(T, inner_cls(*args))
|
|
89
110
|
|
|
90
111
|
if isinstance(cls, type):
|
|
91
|
-
return
|
|
112
|
+
return t.cast(T, cls())
|
|
92
113
|
|
|
93
114
|
return cls
|
|
94
115
|
|
|
95
116
|
|
|
96
|
-
def origin_is_subclass(obj: Any, _type: type) -> bool:
|
|
117
|
+
def origin_is_subclass(obj: t.Any, _type: type) -> bool:
|
|
97
118
|
"""
|
|
98
119
|
Check if the origin of a generic is a subclass of _type.
|
|
99
120
|
|
|
@@ -101,15 +122,13 @@ def origin_is_subclass(obj: Any, _type: type) -> bool:
|
|
|
101
122
|
origin_is_subclass(list[str], list) -> True
|
|
102
123
|
"""
|
|
103
124
|
return bool(
|
|
104
|
-
|
|
105
|
-
and isinstance(typing.get_origin(obj), type)
|
|
106
|
-
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),
|
|
107
126
|
)
|
|
108
127
|
|
|
109
128
|
|
|
110
129
|
def mktable(
|
|
111
|
-
data: dict[Any, Any],
|
|
112
|
-
header:
|
|
130
|
+
data: dict[t.Any, t.Any],
|
|
131
|
+
header: t.Optional[t.Iterable[str] | range] = None,
|
|
113
132
|
skip_first: bool = True,
|
|
114
133
|
) -> str:
|
|
115
134
|
"""
|
|
@@ -154,11 +173,11 @@ def mktable(
|
|
|
154
173
|
return output.getvalue()
|
|
155
174
|
|
|
156
175
|
|
|
157
|
-
K =
|
|
158
|
-
V =
|
|
176
|
+
K = t.TypeVar("K")
|
|
177
|
+
V = t.TypeVar("V")
|
|
159
178
|
|
|
160
179
|
|
|
161
|
-
def looks_like(v: Any, _type: type[Any]) -> bool:
|
|
180
|
+
def looks_like(v: t.Any, _type: type[t.Any]) -> bool:
|
|
162
181
|
"""
|
|
163
182
|
Returns true if v or v's class is of type _type, including if it is a generic.
|
|
164
183
|
|
|
@@ -186,19 +205,19 @@ def unwrap_type(_type: type) -> type:
|
|
|
186
205
|
Example:
|
|
187
206
|
list[list[str]] -> str
|
|
188
207
|
"""
|
|
189
|
-
while args :=
|
|
208
|
+
while args := t.get_args(_type):
|
|
190
209
|
_type = args[0]
|
|
191
210
|
return _type
|
|
192
211
|
|
|
193
212
|
|
|
194
|
-
@
|
|
213
|
+
@t.overload
|
|
195
214
|
def extract_type_optional(annotation: T) -> tuple[T, bool]:
|
|
196
215
|
"""
|
|
197
216
|
T -> T is not exactly right because you'll get the inner type, but mypy seems happy with this.
|
|
198
217
|
"""
|
|
199
218
|
|
|
200
219
|
|
|
201
|
-
@
|
|
220
|
+
@t.overload
|
|
202
221
|
def extract_type_optional(annotation: None) -> tuple[None, bool]:
|
|
203
222
|
"""
|
|
204
223
|
None leads to None, False.
|
|
@@ -212,10 +231,10 @@ def extract_type_optional(annotation: T | None) -> tuple[T | None, bool]:
|
|
|
212
231
|
if annotation is None:
|
|
213
232
|
return None, False
|
|
214
233
|
|
|
215
|
-
if origin :=
|
|
216
|
-
args =
|
|
234
|
+
if origin := t.get_origin(annotation):
|
|
235
|
+
args = t.get_args(annotation)
|
|
217
236
|
|
|
218
|
-
if origin in (
|
|
237
|
+
if origin in (t.Union, types.UnionType, t.Optional) and args:
|
|
219
238
|
# remove None:
|
|
220
239
|
return next(_ for _ in args if _ and _ != types.NoneType and not isinstance(_, types.NoneType)), True
|
|
221
240
|
|
|
@@ -256,7 +275,7 @@ class DummyQuery:
|
|
|
256
275
|
return False
|
|
257
276
|
|
|
258
277
|
|
|
259
|
-
def as_lambda(value: T) ->
|
|
278
|
+
def as_lambda(value: T) -> t.Callable[..., T]:
|
|
260
279
|
"""
|
|
261
280
|
Wrap value in a callable.
|
|
262
281
|
"""
|
|
@@ -289,21 +308,21 @@ def get_db(table: "TypedTable | Table") -> "DAL":
|
|
|
289
308
|
"""
|
|
290
309
|
Get the underlying DAL instance for a pydal or typedal table.
|
|
291
310
|
"""
|
|
292
|
-
return
|
|
311
|
+
return t.cast("DAL", table._db)
|
|
293
312
|
|
|
294
313
|
|
|
295
314
|
def get_table(table: "TypedTable | Table") -> "Table":
|
|
296
315
|
"""
|
|
297
316
|
Get the underlying pydal table for a typedal table.
|
|
298
317
|
"""
|
|
299
|
-
return
|
|
318
|
+
return t.cast("Table", table._table)
|
|
300
319
|
|
|
301
320
|
|
|
302
|
-
def get_field(field: "TypedField[
|
|
321
|
+
def get_field(field: "TypedField[t.Any] | Field") -> "Field":
|
|
303
322
|
"""
|
|
304
323
|
Get the underlying pydal field from a typedal field.
|
|
305
324
|
"""
|
|
306
|
-
return
|
|
325
|
+
return t.cast(
|
|
307
326
|
"Field",
|
|
308
327
|
field, # Table.field already is a Field, but cast to make sure the editor knows this too.
|
|
309
328
|
)
|
|
@@ -314,7 +333,7 @@ class classproperty:
|
|
|
314
333
|
Combination of @classmethod and @property.
|
|
315
334
|
"""
|
|
316
335
|
|
|
317
|
-
def __init__(self, fget:
|
|
336
|
+
def __init__(self, fget: t.Callable[..., t.Any]) -> None:
|
|
318
337
|
"""
|
|
319
338
|
Initialize the classproperty.
|
|
320
339
|
|
|
@@ -323,7 +342,7 @@ class classproperty:
|
|
|
323
342
|
"""
|
|
324
343
|
self.fget = fget
|
|
325
344
|
|
|
326
|
-
def __get__(self, obj:
|
|
345
|
+
def __get__(self, obj: t.Any, owner: t.Type[T]) -> t.Any:
|
|
327
346
|
"""
|
|
328
347
|
Retrieve the property value.
|
|
329
348
|
|
|
@@ -337,7 +356,7 @@ class classproperty:
|
|
|
337
356
|
return self.fget(owner)
|
|
338
357
|
|
|
339
358
|
|
|
340
|
-
def smarter_adapt(db: TypeDAL, placeholder: Any) -> str:
|
|
359
|
+
def smarter_adapt(db: TypeDAL, placeholder: t.Any) -> str:
|
|
341
360
|
"""
|
|
342
361
|
Smarter adaptation of placeholder to quote if needed.
|
|
343
362
|
|
|
@@ -349,7 +368,7 @@ def smarter_adapt(db: TypeDAL, placeholder: Any) -> str:
|
|
|
349
368
|
Quoted placeholder if needed, except for numbers (smart_adapt logic)
|
|
350
369
|
or fields/tables (use already quoted rname).
|
|
351
370
|
"""
|
|
352
|
-
return
|
|
371
|
+
return t.cast(
|
|
353
372
|
str,
|
|
354
373
|
getattr(placeholder, "sql_shortref", None) # for tables
|
|
355
374
|
or getattr(placeholder, "sqlsafe", None) # for fields
|
|
@@ -357,26 +376,123 @@ def smarter_adapt(db: TypeDAL, placeholder: Any) -> str:
|
|
|
357
376
|
)
|
|
358
377
|
|
|
359
378
|
|
|
360
|
-
|
|
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
|
|
361
384
|
"""
|
|
362
|
-
|
|
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.
|
|
363
390
|
|
|
364
391
|
Args:
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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.
|
|
369
468
|
|
|
370
469
|
Returns:
|
|
371
|
-
|
|
470
|
+
str: SQL fragment with all placeholders replaced by properly escaped values.
|
|
372
471
|
|
|
373
472
|
Raises:
|
|
374
|
-
ValueError: If both
|
|
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.
|
|
375
488
|
"""
|
|
376
489
|
if raw_args and raw_kwargs: # pragma: no cover
|
|
377
490
|
raise ValueError("Please provide either args or kwargs, not both.")
|
|
378
491
|
|
|
379
|
-
|
|
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:
|
|
380
496
|
# list
|
|
381
497
|
return sql_fragment % tuple(smarter_adapt(db, placeholder) for placeholder in raw_args)
|
|
382
498
|
else:
|
|
@@ -386,23 +502,58 @@ def sql_escape(db: TypeDAL, sql_fragment: str, *raw_args: Any, **raw_kwargs: Any
|
|
|
386
502
|
|
|
387
503
|
def sql_expression(
|
|
388
504
|
db: TypeDAL,
|
|
389
|
-
sql_fragment: str,
|
|
390
|
-
*raw_args: Any,
|
|
505
|
+
sql_fragment: str | Template,
|
|
506
|
+
*raw_args: t.Any,
|
|
391
507
|
output_type: str | None = None,
|
|
392
|
-
**raw_kwargs: Any,
|
|
508
|
+
**raw_kwargs: t.Any,
|
|
393
509
|
) -> Expression:
|
|
394
510
|
"""
|
|
395
|
-
|
|
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.
|
|
396
516
|
|
|
397
517
|
Args:
|
|
398
|
-
db: The TypeDAL object.
|
|
399
|
-
sql_fragment:
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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.
|
|
403
528
|
|
|
404
529
|
Returns:
|
|
405
|
-
A
|
|
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.
|
|
406
557
|
"""
|
|
407
558
|
safe_sql = sql_escape(db, sql_fragment, *raw_args, **raw_kwargs)
|
|
408
559
|
|
|
@@ -413,3 +564,59 @@ def sql_expression(
|
|
|
413
564
|
safe_sql,
|
|
414
565
|
type=output_type, # optional type hint
|
|
415
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).
|
|
@@ -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
|
"""
|