TypeDAL 3.12.1__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/cli.py +1 -1
- typedal/config.py +18 -16
- typedal/constants.py +25 -0
- typedal/core.py +202 -2784
- typedal/define.py +188 -0
- typedal/fields.py +319 -30
- typedal/for_py4web.py +2 -3
- typedal/for_web2py.py +1 -1
- typedal/helpers.py +355 -38
- typedal/mixins.py +28 -24
- 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 +3 -3
- {typedal-3.12.1.dist-info → typedal-4.2.0.dist-info}/METADATA +9 -8
- typedal-4.2.0.dist-info/RECORD +25 -0
- {typedal-3.12.1.dist-info → typedal-4.2.0.dist-info}/WHEEL +1 -1
- typedal-3.12.1.dist-info/RECORD +0 -19
- {typedal-3.12.1.dist-info → typedal-4.2.0.dist-info}/entry_points.txt +0 -0
typedal/for_py4web.py
CHANGED
|
@@ -5,7 +5,6 @@ ONLY USE IN COMBINATION WITH PY4WEB!
|
|
|
5
5
|
import typing
|
|
6
6
|
|
|
7
7
|
import threadsafevariable
|
|
8
|
-
from configuraptor.abs import AnyType
|
|
9
8
|
from py4web.core import ICECUBE
|
|
10
9
|
from py4web.core import Fixture as _Fixture
|
|
11
10
|
from pydal.base import MetaDAL, hashlib_md5
|
|
@@ -15,7 +14,7 @@ from .types import AnyDict
|
|
|
15
14
|
from .web2py_py4web_shared import AuthUser
|
|
16
15
|
|
|
17
16
|
|
|
18
|
-
class Fixture(_Fixture):
|
|
17
|
+
class Fixture(_Fixture):
|
|
19
18
|
"""
|
|
20
19
|
Make mypy happy.
|
|
21
20
|
"""
|
|
@@ -68,8 +67,8 @@ def setup_py4web_tables(db: TypeDAL) -> None:
|
|
|
68
67
|
|
|
69
68
|
|
|
70
69
|
__all__ = [
|
|
70
|
+
"DAL",
|
|
71
71
|
"AuthUser",
|
|
72
72
|
"Fixture",
|
|
73
|
-
"DAL",
|
|
74
73
|
"setup_py4web_tables",
|
|
75
74
|
]
|
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.Iterable[str] = None) -> dict[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,315 @@ 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
|
)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
class classproperty:
|
|
332
|
+
"""
|
|
333
|
+
Combination of @classmethod and @property.
|
|
334
|
+
"""
|
|
335
|
+
|
|
336
|
+
def __init__(self, fget: t.Callable[..., t.Any]) -> None:
|
|
337
|
+
"""
|
|
338
|
+
Initialize the classproperty.
|
|
339
|
+
|
|
340
|
+
Args:
|
|
341
|
+
fget: A function that takes the class as an argument and returns a value.
|
|
342
|
+
"""
|
|
343
|
+
self.fget = fget
|
|
344
|
+
|
|
345
|
+
def __get__(self, obj: t.Any, owner: t.Type[T]) -> t.Any:
|
|
346
|
+
"""
|
|
347
|
+
Retrieve the property value.
|
|
348
|
+
|
|
349
|
+
Args:
|
|
350
|
+
obj: The instance of the class (unused).
|
|
351
|
+
owner: The class that owns the property.
|
|
352
|
+
|
|
353
|
+
Returns:
|
|
354
|
+
The value returned by the function.
|
|
355
|
+
"""
|
|
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
|