TypeDAL 3.16.5__tar.gz → 3.17.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of TypeDAL might be problematic. Click here for more details.

Files changed (59) hide show
  1. {typedal-3.16.5 → typedal-3.17.1}/CHANGELOG.md +12 -0
  2. {typedal-3.16.5 → typedal-3.17.1}/PKG-INFO +1 -1
  3. {typedal-3.16.5 → typedal-3.17.1}/src/typedal/__about__.py +1 -1
  4. typedal-3.17.1/src/typedal/__init__.py +30 -0
  5. {typedal-3.16.5 → typedal-3.17.1}/src/typedal/cli.py +2 -1
  6. {typedal-3.16.5 → typedal-3.17.1}/src/typedal/core.py +71 -33
  7. {typedal-3.16.5 → typedal-3.17.1}/src/typedal/fields.py +40 -6
  8. {typedal-3.16.5 → typedal-3.17.1}/src/typedal/helpers.py +86 -4
  9. {typedal-3.16.5 → typedal-3.17.1}/src/typedal/mixins.py +2 -2
  10. {typedal-3.16.5 → typedal-3.17.1}/tests/test_cli.py +1 -1
  11. {typedal-3.16.5 → typedal-3.17.1}/tests/test_helpers.py +39 -2
  12. typedal-3.16.5/src/typedal/__init__.py +0 -13
  13. {typedal-3.16.5 → typedal-3.17.1}/.github/workflows/su6.yml +0 -0
  14. {typedal-3.16.5 → typedal-3.17.1}/.gitignore +0 -0
  15. {typedal-3.16.5 → typedal-3.17.1}/.readthedocs.yml +0 -0
  16. {typedal-3.16.5 → typedal-3.17.1}/README.md +0 -0
  17. {typedal-3.16.5 → typedal-3.17.1}/coverage.svg +0 -0
  18. {typedal-3.16.5 → typedal-3.17.1}/docs/1_getting_started.md +0 -0
  19. {typedal-3.16.5 → typedal-3.17.1}/docs/2_defining_tables.md +0 -0
  20. {typedal-3.16.5 → typedal-3.17.1}/docs/3_building_queries.md +0 -0
  21. {typedal-3.16.5 → typedal-3.17.1}/docs/4_relationships.md +0 -0
  22. {typedal-3.16.5 → typedal-3.17.1}/docs/5_py4web.md +0 -0
  23. {typedal-3.16.5 → typedal-3.17.1}/docs/6_migrations.md +0 -0
  24. {typedal-3.16.5 → typedal-3.17.1}/docs/7_mixins.md +0 -0
  25. {typedal-3.16.5 → typedal-3.17.1}/docs/css/code_blocks.css +0 -0
  26. {typedal-3.16.5 → typedal-3.17.1}/docs/index.md +0 -0
  27. {typedal-3.16.5 → typedal-3.17.1}/docs/requirements.txt +0 -0
  28. {typedal-3.16.5 → typedal-3.17.1}/example_new.py +0 -0
  29. {typedal-3.16.5 → typedal-3.17.1}/example_old.py +0 -0
  30. {typedal-3.16.5 → typedal-3.17.1}/mkdocs.yml +0 -0
  31. {typedal-3.16.5 → typedal-3.17.1}/pyproject.toml +0 -0
  32. {typedal-3.16.5 → typedal-3.17.1}/src/typedal/caching.py +0 -0
  33. {typedal-3.16.5 → typedal-3.17.1}/src/typedal/config.py +0 -0
  34. {typedal-3.16.5 → typedal-3.17.1}/src/typedal/for_py4web.py +0 -0
  35. {typedal-3.16.5 → typedal-3.17.1}/src/typedal/for_web2py.py +0 -0
  36. {typedal-3.16.5 → typedal-3.17.1}/src/typedal/py.typed +0 -0
  37. {typedal-3.16.5 → typedal-3.17.1}/src/typedal/serializers/as_json.py +0 -0
  38. {typedal-3.16.5 → typedal-3.17.1}/src/typedal/types.py +0 -0
  39. {typedal-3.16.5 → typedal-3.17.1}/src/typedal/web2py_py4web_shared.py +0 -0
  40. {typedal-3.16.5 → typedal-3.17.1}/tests/__init__.py +0 -0
  41. {typedal-3.16.5 → typedal-3.17.1}/tests/configs/simple.toml +0 -0
  42. {typedal-3.16.5 → typedal-3.17.1}/tests/configs/valid.env +0 -0
  43. {typedal-3.16.5 → typedal-3.17.1}/tests/configs/valid.toml +0 -0
  44. {typedal-3.16.5 → typedal-3.17.1}/tests/test_config.py +0 -0
  45. {typedal-3.16.5 → typedal-3.17.1}/tests/test_docs_examples.py +0 -0
  46. {typedal-3.16.5 → typedal-3.17.1}/tests/test_json.py +0 -0
  47. {typedal-3.16.5 → typedal-3.17.1}/tests/test_main.py +0 -0
  48. {typedal-3.16.5 → typedal-3.17.1}/tests/test_mixins.py +0 -0
  49. {typedal-3.16.5 → typedal-3.17.1}/tests/test_mypy.py +0 -0
  50. {typedal-3.16.5 → typedal-3.17.1}/tests/test_orm.py +0 -0
  51. {typedal-3.16.5 → typedal-3.17.1}/tests/test_py4web.py +0 -0
  52. {typedal-3.16.5 → typedal-3.17.1}/tests/test_query_builder.py +0 -0
  53. {typedal-3.16.5 → typedal-3.17.1}/tests/test_relationships.py +0 -0
  54. {typedal-3.16.5 → typedal-3.17.1}/tests/test_row.py +0 -0
  55. {typedal-3.16.5 → typedal-3.17.1}/tests/test_stats.py +0 -0
  56. {typedal-3.16.5 → typedal-3.17.1}/tests/test_table.py +0 -0
  57. {typedal-3.16.5 → typedal-3.17.1}/tests/test_web2py.py +0 -0
  58. {typedal-3.16.5 → typedal-3.17.1}/tests/test_xx_others.py +0 -0
  59. {typedal-3.16.5 → typedal-3.17.1}/tests/timings.py +0 -0
@@ -2,6 +2,18 @@
2
2
 
3
3
  <!--next-version-placeholder-->
4
4
 
5
+ ## v3.17.1 (2025-09-20)
6
+
7
+ ### Fix
8
+
9
+ * Smarter adapt so Fields and Tables can also be safely inserted in `sql_expression` ([`d5cad6a`](https://github.com/trialandsuccess/TypeDAL/commit/d5cad6a13ddf50ec6e5762075fbeae1e44067da6))
10
+
11
+ ## v3.17.0 (2025-09-20)
12
+
13
+ ### Feature
14
+
15
+ * Improved Expression support via `db.sql_expression` for more flexibility ([`2892aa4`](https://github.com/trialandsuccess/TypeDAL/commit/2892aa452a0b9563ff742a2a7a00927b89b440b9))
16
+
5
17
  ## v3.16.5 (2025-09-08)
6
18
 
7
19
  ### Fix
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: TypeDAL
3
- Version: 3.16.5
3
+ Version: 3.17.1
4
4
  Summary: Typing support for PyDAL
5
5
  Project-URL: Documentation, https://typedal.readthedocs.io/
6
6
  Project-URL: Issues, https://github.com/trialandsuccess/TypeDAL/issues
@@ -5,4 +5,4 @@ This file contains the Version info for this package.
5
5
  # SPDX-FileCopyrightText: 2023-present Robin van der Noord <robinvandernoord@gmail.com>
6
6
  #
7
7
  # SPDX-License-Identifier: MIT
8
- __version__ = "3.16.5"
8
+ __version__ = "3.17.1"
@@ -0,0 +1,30 @@
1
+ """
2
+ TypeDAL Library.
3
+ """
4
+
5
+ from . import fields
6
+ from .core import (
7
+ Relationship,
8
+ TypeDAL,
9
+ TypedField,
10
+ TypedRows,
11
+ TypedTable,
12
+ relationship,
13
+ )
14
+ from .helpers import sql_expression
15
+
16
+ try:
17
+ from .for_py4web import DAL as P4W_DAL
18
+ except ImportError: # pragma: no cover
19
+ P4W_DAL = None # type: ignore
20
+
21
+ __all__ = [
22
+ "Relationship",
23
+ "TypeDAL",
24
+ "TypedField",
25
+ "TypedRows",
26
+ "TypedTable",
27
+ "fields",
28
+ "relationship",
29
+ "sql_expression",
30
+ ]
@@ -392,7 +392,8 @@ def fake_migrations(
392
392
 
393
393
  previously_migrated = (
394
394
  db(
395
- db.ewh_implemented_features.name.belongs(to_fake) & (db.ewh_implemented_features.installed == True) # noqa E712
395
+ db.ewh_implemented_features.name.belongs(to_fake)
396
+ & (db.ewh_implemented_features.installed == True) # noqa E712
396
397
  )
397
398
  .select(db.ewh_implemented_features.name)
398
399
  .column("name")
@@ -25,10 +25,12 @@ from typing import Any, Optional, Type
25
25
 
26
26
  import pydal
27
27
  from pydal._globals import DEFAULT
28
- from pydal.objects import Field as _Field
29
- from pydal.objects import Query as _Query
28
+
29
+ # from pydal.objects import Field as _Field
30
+ # from pydal.objects import Query as _Query
30
31
  from pydal.objects import Row
31
- from pydal.objects import Table as _Table
32
+
33
+ # from pydal.objects import Table as _Table
32
34
  from typing_extensions import Self, Unpack
33
35
 
34
36
  from .config import TypeDALConfig, load_config
@@ -45,6 +47,7 @@ from .helpers import (
45
47
  looks_like,
46
48
  mktable,
47
49
  origin_is_subclass,
50
+ sql_expression,
48
51
  to_snake,
49
52
  unwrap_type,
50
53
  )
@@ -71,7 +74,7 @@ from .types import (
71
74
 
72
75
  # use typing.cast(type, ...) to make mypy happy with unions
73
76
  T_annotation = Type[Any] | types.UnionType
74
- T_Query = typing.Union["Table", Query, bool, None, "TypedTable", Type["TypedTable"]]
77
+ T_Query = typing.Union["Table", Query, bool, None, "TypedTable", Type["TypedTable"], Expression]
75
78
  T_Value = typing.TypeVar("T_Value") # actual type of the Field (via Generic)
76
79
  T_MetaInstance = typing.TypeVar("T_MetaInstance", bound="TypedTable") # bound="TypedTable"; bound="TableMeta"
77
80
  T = typing.TypeVar("T")
@@ -132,7 +135,7 @@ class Relationship(typing.Generic[To_Type]):
132
135
  Define a relationship to another table.
133
136
  """
134
137
 
135
- _type: To_Type
138
+ _type: Type[To_Type]
136
139
  table: Type["TypedTable"] | type | str
137
140
  condition: Condition
138
141
  condition_and: Condition
@@ -142,7 +145,7 @@ class Relationship(typing.Generic[To_Type]):
142
145
 
143
146
  def __init__(
144
147
  self,
145
- _type: To_Type,
148
+ _type: Type[To_Type],
146
149
  condition: Condition = None,
147
150
  join: JOIN_OPTIONS = None,
148
151
  on: OnQuery = None,
@@ -165,7 +168,7 @@ class Relationship(typing.Generic[To_Type]):
165
168
  self.table = unwrap_type(args[0])
166
169
  self.multiple = True
167
170
  else:
168
- self.table = _type
171
+ self.table = typing.cast(type[TypedTable], _type)
169
172
  self.multiple = False
170
173
 
171
174
  if isinstance(self.table, str):
@@ -234,7 +237,7 @@ class Relationship(typing.Generic[To_Type]):
234
237
 
235
238
  return str(table)
236
239
 
237
- def __get__(self, instance: Any, owner: Any) -> typing.Optional[list[Any]] | "Relationship[To_Type]":
240
+ def __get__(self, instance: Any, owner: Any) -> "typing.Optional[list[Any]] | Relationship[To_Type]":
238
241
  """
239
242
  Relationship is a descriptor class, which can be returned from a class but not an instance.
240
243
 
@@ -756,7 +759,7 @@ class TypeDAL(pydal.DAL): # type: ignore
756
759
  if mapping := BASIC_MAPPINGS.get(ftype):
757
760
  # basi types
758
761
  return mapping
759
- elif isinstance(ftype, _Table):
762
+ elif isinstance(ftype, pydal.objects.Table):
760
763
  # db.table
761
764
  return f"reference {ftype._tablename}"
762
765
  elif issubclass(type(ftype), type) and issubclass(ftype, TypedTable):
@@ -829,13 +832,34 @@ class TypeDAL(pydal.DAL): # type: ignore
829
832
  """
830
833
  return to_snake(camel)
831
834
 
835
+ def sql_expression(
836
+ self,
837
+ sql_fragment: str,
838
+ *raw_args: Any,
839
+ output_type: str | None = None,
840
+ **raw_kwargs: Any,
841
+ ) -> Expression:
842
+ """
843
+ Creates a pydal Expression object representing a raw SQL fragment.
844
+
845
+ Args:
846
+ sql_fragment: The raw SQL fragment.
847
+ *raw_args: Arguments to be interpolated into the SQL fragment.
848
+ output_type: The expected output type of the expression.
849
+ **raw_kwargs: Keyword arguments to be interpolated into the SQL fragment.
850
+
851
+ Returns:
852
+ A pydal Expression object.
853
+ """
854
+ return sql_expression(self, sql_fragment, *raw_args, output_type=output_type, **raw_kwargs)
855
+
832
856
 
833
857
  def default_representer(field: TypedField[T], value: T, table: Type[TypedTable]) -> str:
834
858
  """
835
859
  Simply call field.represent on the value.
836
860
  """
837
861
  if represent := getattr(field, "represent", None):
838
- return field.represent(value, table)
862
+ return str(represent(value, table))
839
863
  else:
840
864
  return repr(value)
841
865
 
@@ -848,7 +872,7 @@ R = typing.TypeVar("R")
848
872
 
849
873
  def reorder_fields(
850
874
  table: pydal.objects.Table,
851
- fields: typing.Iterable[str | Field | TypedField],
875
+ fields: typing.Iterable[str | Field | TypedField[Any]],
852
876
  keep_others: bool = True,
853
877
  ) -> None:
854
878
  """
@@ -1423,7 +1447,7 @@ class TableMeta(type):
1423
1447
  """
1424
1448
  return cls._hook_once(cls._after_delete, fn)
1425
1449
 
1426
- def reorder_fields(cls, *fields: str | Field | TypedField, keep_others: bool = True):
1450
+ def reorder_fields(cls, *fields: str | Field | TypedField[Any], keep_others: bool = True) -> None:
1427
1451
  """
1428
1452
  Reorder fields of a typedal table.
1429
1453
 
@@ -1433,7 +1457,6 @@ class TableMeta(type):
1433
1457
  - True (default): keep other fields at the end, in their original order.
1434
1458
  - False: remove other fields (only keep what's specified).
1435
1459
  """
1436
-
1437
1460
  return reorder_fields(cls._table, fields, keep_others=keep_others)
1438
1461
 
1439
1462
 
@@ -1770,13 +1793,13 @@ class TypedTable(_TypedTable, metaclass=TableMeta):
1770
1793
 
1771
1794
  raise AttributeError(item)
1772
1795
 
1773
- def keys(self):
1796
+ def keys(self) -> list[str]:
1774
1797
  """
1775
1798
  Return the combination of row + relationship keys.
1776
1799
 
1777
1800
  Used by dict(row).
1778
1801
  """
1779
- return list(self._row.keys()) + getattr(self, "_with", [])
1802
+ return list(self._row.keys() if self._row else ()) + getattr(self, "_with", [])
1780
1803
 
1781
1804
  def get(self, item: str, default: Any = None) -> Any:
1782
1805
  """
@@ -2037,7 +2060,17 @@ class TypedTable(_TypedTable, metaclass=TableMeta):
2037
2060
 
2038
2061
  return pydal2sql.generate_sql(cls)
2039
2062
 
2040
- def render(self, fields=None, compact=False) -> Self:
2063
+ def render(self, fields: list[Field] = None, compact: bool = False) -> Self:
2064
+ """
2065
+ Renders a copy of the object with potentially modified values.
2066
+
2067
+ Args:
2068
+ fields: A list of fields to render. Defaults to all representable fields in the table.
2069
+ compact: Whether to return only the value of the first field if there is only one field.
2070
+
2071
+ Returns:
2072
+ A copy of the object with potentially modified values.
2073
+ """
2041
2074
  row = copy.deepcopy(self)
2042
2075
  keys = list(row)
2043
2076
  if not fields:
@@ -2091,7 +2124,7 @@ class TypedTable(_TypedTable, metaclass=TableMeta):
2091
2124
  )
2092
2125
 
2093
2126
  if compact and len(keys) == 1 and keys[0] != "_extra": # pragma: no cover
2094
- return row[keys[0]]
2127
+ return typing.cast(Self, row[keys[0]])
2095
2128
  return row
2096
2129
 
2097
2130
 
@@ -2290,11 +2323,11 @@ class TypedRows(typing.Collection[T_MetaInstance], Rows):
2290
2323
 
2291
2324
  def as_dict(
2292
2325
  self,
2293
- key: str | Field = None,
2326
+ key: str | Field | None = None,
2294
2327
  compact: bool = False,
2295
2328
  storage_to_dict: bool = False,
2296
2329
  datetime_to_str: bool = False,
2297
- custom_types: list[type] = None,
2330
+ custom_types: list[type] | None = None,
2298
2331
  ) -> dict[int, AnyDict]:
2299
2332
  """
2300
2333
  Get the data in a dict of dicts.
@@ -2468,10 +2501,12 @@ class TypedRows(typing.Collection[T_MetaInstance], Rows):
2468
2501
  self.__dict__.update(state)
2469
2502
  # db etc. set after undill by caching.py
2470
2503
 
2471
- def render(self, i=None, fields=None) -> typing.Generator[T_MetaInstance, None, None]:
2504
+ def render(
2505
+ self, i: int | None = None, fields: list[Field] | None = None
2506
+ ) -> typing.Generator[T_MetaInstance, None, None]:
2472
2507
  """
2473
- Takes an index and returns a copy of the indexed row with values
2474
- transformed via the "represent" attributes of the associated fields.
2508
+ Takes an index and returns a copy of the indexed row with values \
2509
+ transformed via the "represent" attributes of the associated fields.
2475
2510
 
2476
2511
  Args:
2477
2512
  i: index. If not specified, a generator is returned for iteration
@@ -2481,7 +2516,7 @@ class TypedRows(typing.Collection[T_MetaInstance], Rows):
2481
2516
  """
2482
2517
  if i is None:
2483
2518
  # difference: uses .keys() instead of index
2484
- return (self.render(i, fields=fields) for i in self.records.keys())
2519
+ return (self.render(i, fields=fields) for i in self.records)
2485
2520
 
2486
2521
  if not self.db.has_representer("rows_render"): # pragma: no cover
2487
2522
  raise RuntimeError(
@@ -2503,10 +2538,10 @@ from .caching import ( # noqa: E402
2503
2538
  )
2504
2539
 
2505
2540
 
2506
- def normalize_table_keys(row: Row, pattern: re.Pattern = re.compile(r"^([a-zA-Z_]+)_(\d{5,})$")) -> Row:
2541
+ def normalize_table_keys(row: Row, pattern: re.Pattern[str] = re.compile(r"^([a-zA-Z_]+)_(\d{5,})$")) -> Row:
2507
2542
  """
2508
- Normalize table keys in a PyDAL Row object by stripping numeric hash suffixes
2509
- from table names, only if the suffix is 5 or more digits.
2543
+ Normalize table keys in a PyDAL Row object by stripping numeric hash suffixes from table names, \
2544
+ only if the suffix is 5 or more digits.
2510
2545
 
2511
2546
  For example:
2512
2547
  Row({'articles_12345': {...}}) -> Row({'articles': {...}})
@@ -2645,7 +2680,7 @@ class QueryBuilder(typing.Generic[T_MetaInstance]):
2645
2680
 
2646
2681
  def where(
2647
2682
  self,
2648
- *queries_or_lambdas: Query | typing.Callable[[Type[T_MetaInstance]], Query] | dict,
2683
+ *queries_or_lambdas: Query | typing.Callable[[Type[T_MetaInstance]], Query] | dict[str, Any],
2649
2684
  **filters: Any,
2650
2685
  ) -> "QueryBuilder[T_MetaInstance]":
2651
2686
  """
@@ -2669,15 +2704,15 @@ class QueryBuilder(typing.Generic[T_MetaInstance]):
2669
2704
  filters,
2670
2705
  )
2671
2706
 
2672
- subquery: DummyQuery | Query = DummyQuery()
2707
+ subquery = typing.cast(Query, DummyQuery())
2673
2708
  for query_part in queries_or_lambdas:
2674
- if isinstance(query_part, _Query):
2709
+ if isinstance(query_part, (Field, pydal.objects.Field)) or is_typed_field(query_part):
2710
+ subquery |= typing.cast(Query, query_part != None)
2711
+ elif isinstance(query_part, (pydal.objects.Query, Expression, pydal.objects.Expression)):
2675
2712
  subquery |= typing.cast(Query, query_part)
2676
2713
  elif callable(query_part):
2677
2714
  if result := query_part(self.model):
2678
2715
  subquery |= result
2679
- elif isinstance(query_part, (Field, _Field)) or is_typed_field(query_part):
2680
- subquery |= typing.cast(Query, query_part != None)
2681
2716
  elif isinstance(query_part, dict):
2682
2717
  subsubquery = DummyQuery()
2683
2718
  for field, value in query_part.items():
@@ -2727,8 +2762,9 @@ class QueryBuilder(typing.Generic[T_MetaInstance]):
2727
2762
  if isinstance(condition, pydal.objects.Query):
2728
2763
  condition = as_lambda(condition)
2729
2764
 
2765
+ to_field = typing.cast(Type[TypedTable], fields[0])
2730
2766
  relationships = {
2731
- str(fields[0]): Relationship(fields[0], condition=condition, join=method, condition_and=condition_and)
2767
+ str(to_field): Relationship(to_field, condition=condition, join=method, condition_and=condition_and)
2732
2768
  }
2733
2769
  elif on:
2734
2770
  if len(fields) != 1:
@@ -2739,7 +2775,9 @@ class QueryBuilder(typing.Generic[T_MetaInstance]):
2739
2775
 
2740
2776
  if isinstance(on, list):
2741
2777
  on = as_lambda(on)
2742
- relationships = {str(fields[0]): Relationship(fields[0], on=on, join=method, condition_and=condition_and)}
2778
+
2779
+ to_field = typing.cast(Type[TypedTable], fields[0])
2780
+ relationships = {str(to_field): Relationship(to_field, on=on, join=method, condition_and=condition_and)}
2743
2781
 
2744
2782
  else:
2745
2783
  if fields:
@@ -256,18 +256,50 @@ def TimestampField(**kw: Unpack[FieldSettings]) -> TypedField[dt.datetime]:
256
256
  )
257
257
 
258
258
 
259
- def safe_decode_native_point(value: str | None):
259
+ def safe_decode_native_point(value: str | None) -> tuple[float, ...]:
260
+ """
261
+ Safely decode a string into a tuple of floats.
262
+
263
+ The function attempts to parse the input string using `ast.literal_eval`.
264
+ If the parsing is successful, the function casts the parsed value to a tuple of floats and returns it.
265
+ Otherwise, the function returns an empty tuple.
266
+
267
+ Args:
268
+ value: The string to decode.
269
+
270
+ Returns:
271
+ A tuple of floats.
272
+ """
260
273
  if not value:
261
274
  return ()
262
275
 
263
276
  try:
264
- return ast.literal_eval(value)
277
+ parsed = ast.literal_eval(value)
278
+ return typing.cast(tuple[float, ...], parsed)
265
279
  except ValueError: # pragma: no cover
266
280
  # should not happen when inserted with `safe_encode_native_point` but you never know
267
281
  return ()
268
282
 
269
283
 
270
- def safe_encode_native_point(value: tuple[str, str] | str) -> str:
284
+ def safe_encode_native_point(value: tuple[str, str] | tuple[float, float] | str) -> str:
285
+ """
286
+
287
+ Safe encodes a point value.
288
+
289
+ The function takes a point value as input.
290
+ It can be a string in the format "x,y" or a tuple of two numbers.
291
+ The function converts the string to a tuple if necessary, validates the tuple,
292
+ and formats it into the expected string format.
293
+
294
+ Args:
295
+ value: The point value to be encoded.
296
+
297
+ Returns:
298
+ The encoded point value as a string in the format "x,y".
299
+
300
+ Raises:
301
+ ValueError: If the input value is not a valid point.
302
+ """
271
303
  if not value:
272
304
  return ""
273
305
 
@@ -276,13 +308,15 @@ def safe_encode_native_point(value: tuple[str, str] | str) -> str:
276
308
  value = value.strip("() ")
277
309
  if not value:
278
310
  return ""
279
- value = tuple(float(x.strip()) for x in value.split(","))
311
+ value_tup = tuple(float(x.strip()) for x in value.split(","))
312
+ else:
313
+ value_tup = value # type: ignore
280
314
 
281
315
  # Validate and format
282
- if len(value) != 2:
316
+ if len(value_tup) != 2:
283
317
  raise ValueError("Point must have exactly 2 coordinates")
284
318
 
285
- x, y = value
319
+ x, y = value_tup
286
320
  return f"({x},{y})"
287
321
 
288
322
 
@@ -2,6 +2,8 @@
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
@@ -12,10 +14,10 @@ from typing import Any
12
14
 
13
15
  from pydal import DAL
14
16
 
15
- from .types import AnyDict, Field, Table
17
+ from .types import AnyDict, Expression, Field, Table
16
18
 
17
19
  if typing.TYPE_CHECKING:
18
- from . import TypeDAL, TypedField, TypedTable # noqa: F401
20
+ from . import TypeDAL, TypedField, TypedTable
19
21
 
20
22
  T = typing.TypeVar("T")
21
23
 
@@ -101,12 +103,14 @@ def origin_is_subclass(obj: Any, _type: type) -> bool:
101
103
  return bool(
102
104
  typing.get_origin(obj)
103
105
  and isinstance(typing.get_origin(obj), type)
104
- and issubclass(typing.get_origin(obj), _type)
106
+ and issubclass(typing.get_origin(obj), _type),
105
107
  )
106
108
 
107
109
 
108
110
  def mktable(
109
- data: dict[Any, Any], header: typing.Optional[typing.Iterable[str] | range] = None, skip_first: bool = True
111
+ data: dict[Any, Any],
112
+ header: typing.Optional[typing.Iterable[str] | range] = None,
113
+ skip_first: bool = True,
110
114
  ) -> str:
111
115
  """
112
116
  Display a table for 'data'.
@@ -331,3 +335,81 @@ class classproperty:
331
335
  The value returned by the function.
332
336
  """
333
337
  return self.fget(owner)
338
+
339
+
340
+ def smarter_adapt(db: TypeDAL, placeholder: Any) -> str:
341
+ """
342
+ Smarter adaptation of placeholder to quote if needed.
343
+
344
+ Args:
345
+ db: Database object.
346
+ placeholder: Placeholder object.
347
+
348
+ Returns:
349
+ Quoted placeholder if needed, except for numbers (smart_adapt logic)
350
+ or fields/tables (use already quoted rname).
351
+ """
352
+ return typing.cast(
353
+ str,
354
+ getattr(placeholder, "sql_shortref", None) # for tables
355
+ or getattr(placeholder, "sqlsafe", None) # for fields
356
+ or db._adapter.smart_adapt(placeholder), # for others
357
+ )
358
+
359
+
360
+ def sql_escape(db: TypeDAL, sql_fragment: str, *raw_args: Any, **raw_kwargs: Any) -> str:
361
+ """
362
+ Generates escaped SQL fragments with placeholders.
363
+
364
+ Args:
365
+ db: Database object.
366
+ sql_fragment: SQL fragment with placeholders.
367
+ *raw_args: Positional arguments to be escaped.
368
+ **raw_kwargs: Keyword arguments to be escaped.
369
+
370
+ Returns:
371
+ Escaped SQL fragment with placeholders replaced with escaped values.
372
+
373
+ Raises:
374
+ ValueError: If both args and kwargs are provided.
375
+ """
376
+ if raw_args and raw_kwargs: # pragma: no cover
377
+ raise ValueError("Please provide either args or kwargs, not both.")
378
+
379
+ elif raw_args:
380
+ # list
381
+ return sql_fragment % tuple(smarter_adapt(db, placeholder) for placeholder in raw_args)
382
+ else:
383
+ # dict
384
+ return sql_fragment % {key: smarter_adapt(db, placeholder) for key, placeholder in raw_kwargs.items()}
385
+
386
+
387
+ def sql_expression(
388
+ db: TypeDAL,
389
+ sql_fragment: str,
390
+ *raw_args: Any,
391
+ output_type: str | None = None,
392
+ **raw_kwargs: Any,
393
+ ) -> Expression:
394
+ """
395
+ Creates a pydal Expression object representing a raw SQL fragment.
396
+
397
+ Args:
398
+ db: The TypeDAL object.
399
+ sql_fragment: The raw SQL fragment.
400
+ *raw_args: Arguments to be interpolated into the SQL fragment.
401
+ output_type: The expected output type of the expression.
402
+ **raw_kwargs: Keyword arguments to be interpolated into the SQL fragment.
403
+
404
+ Returns:
405
+ A pydal Expression object.
406
+ """
407
+ safe_sql = sql_escape(db, sql_fragment, *raw_args, **raw_kwargs)
408
+
409
+ # create a pydal Expression wrapping a raw SQL fragment + placeholders
410
+ return Expression(
411
+ db,
412
+ db._adapter.dialect.raw,
413
+ safe_sql,
414
+ type=output_type, # optional type hint
415
+ )
@@ -180,7 +180,7 @@ class SlugMixin(Mixin):
180
180
  if slug_field is None:
181
181
  raise ValueError(
182
182
  "SlugMixin requires a valid slug_field setting: "
183
- "e.g. `class MyClass(TypedTable, SlugMixin, slug_field='title'): ...`"
183
+ "e.g. `class MyClass(TypedTable, SlugMixin, slug_field='title'): ...`",
184
184
  )
185
185
 
186
186
  if slug_suffix:
@@ -197,7 +197,7 @@ class SlugMixin(Mixin):
197
197
 
198
198
  @classmethod
199
199
  def __generate_slug_before_insert(cls, row: OpRow) -> None:
200
- if row.get("slug"):
200
+ if row.get("slug"): # type: ignore
201
201
  # manually set -> skip
202
202
  return None
203
203
 
@@ -7,7 +7,7 @@ from src.typedal.__about__ import __version__
7
7
  from src.typedal.cli import app, get_output_format
8
8
 
9
9
  # by default, click's cli runner mixes stdout and stderr for some reason...
10
- runner = CliRunner(mix_stderr=False)
10
+ runner = CliRunner()
11
11
 
12
12
 
13
13
  def test_version():
@@ -5,6 +5,7 @@ import pydal
5
5
  import pytest
6
6
  from pydal import DAL
7
7
 
8
+ from src.typedal import TypeDAL, TypedTable, sql_expression
8
9
  from src.typedal.caching import get_expire
9
10
  from src.typedal.helpers import (
10
11
  DummyQuery,
@@ -23,8 +24,7 @@ from src.typedal.helpers import (
23
24
  to_snake,
24
25
  unwrap_type,
25
26
  )
26
- from typedal import TypeDAL, TypedTable
27
- from typedal.types import Field
27
+ from src.typedal.types import Field
28
28
 
29
29
 
30
30
  def test_is_union():
@@ -207,3 +207,40 @@ def test_get_functions():
207
207
  field = get_field(TestGetFunctions.string)
208
208
  print(type(field))
209
209
  assert isinstance(field, Field)
210
+
211
+
212
+ def test_sql_expression():
213
+ # note: only %s works since .adapt does something like
214
+ # -> "'%s'" % obj.replace("'", "''")
215
+ # depending on the driver
216
+
217
+ @database.define()
218
+ class TestSqlExpression(TypedTable):
219
+ value: str
220
+
221
+ TestSqlExpression.insert(value="This value may only be accessed after 2024")
222
+
223
+ expr1 = sql_expression(database, "date('now') > %s", "2025-01-01")
224
+ expr2 = database.sql_expression("date('now') > %(value)s", value="2025-01-01")
225
+
226
+ assert str(expr1) == str(expr2)
227
+ assert str(expr1) == "date('now') > '2025-01-01'"
228
+ # past -> should yield result
229
+ result = database(expr1).select(TestSqlExpression.value, expr2)[0]
230
+ assert result
231
+ assert result[TestSqlExpression.value] == "This value may only be accessed after 2024"
232
+ assert result[expr2] == result[expr1] == 1
233
+
234
+ result2 = TestSqlExpression.where(expr1).select(TestSqlExpression.value, expr2).first()
235
+ assert result2
236
+ assert result2[TestSqlExpression.value] == "This value may only be accessed after 2024"
237
+ assert result2[expr2] == result2[expr1] == 1
238
+
239
+ expr3 = database.sql_expression("date('now') > %(value)s", value="3000-01-01")
240
+ # far future -> should not yield result
241
+ result3 = database(expr3).select(TestSqlExpression.value, expr3).as_list()
242
+ assert not result3
243
+
244
+ # test quoting fields and tables:
245
+ assert str(database.sql_expression("LOWER(%s)", TestSqlExpression.value)) == 'LOWER("test_sql_expression"."value")'
246
+ assert str(database.sql_expression("LOWER(%s.value)", TestSqlExpression)) == 'LOWER("test_sql_expression".value)'
@@ -1,13 +0,0 @@
1
- """
2
- TypeDAL Library.
3
- """
4
-
5
- from . import fields
6
- from .core import Relationship, TypeDAL, TypedField, TypedRows, TypedTable, relationship
7
-
8
- try:
9
- from .for_py4web import DAL as P4W_DAL
10
- except ImportError: # pragma: no cover
11
- P4W_DAL = None # type: ignore
12
-
13
- __all__ = ["Relationship", "TypeDAL", "TypedField", "TypedRows", "TypedTable", "fields", "relationship"]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes