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/relationships.py
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Contains base functionality related to Relationships.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
import typing as t
|
|
7
|
+
import warnings
|
|
8
|
+
|
|
9
|
+
import pydal.objects
|
|
10
|
+
|
|
11
|
+
from .config import LazyPolicy
|
|
12
|
+
from .constants import JOIN_OPTIONS
|
|
13
|
+
from .core import TypeDAL
|
|
14
|
+
from .fields import TypedField
|
|
15
|
+
from .helpers import extract_type_optional, looks_like, unwrap_type
|
|
16
|
+
from .types import Condition, OnQuery, T_Field
|
|
17
|
+
|
|
18
|
+
To_Type = t.TypeVar("To_Type")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# default lazy policy is defined at the TypeDAL() instance settings level
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Relationship(t.Generic[To_Type]):
|
|
25
|
+
"""
|
|
26
|
+
Define a relationship to another table.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
_type: t.Type[To_Type]
|
|
30
|
+
table: t.Type["TypedTable"] | type | str # use get_table() to resolve later on
|
|
31
|
+
condition: Condition
|
|
32
|
+
condition_and: Condition
|
|
33
|
+
on: OnQuery
|
|
34
|
+
multiple: bool
|
|
35
|
+
join: JOIN_OPTIONS
|
|
36
|
+
_lazy: LazyPolicy | None
|
|
37
|
+
nested: dict[str, t.Self]
|
|
38
|
+
explicit: bool
|
|
39
|
+
name: str | None = None # set by __set_name__
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
_type: t.Type[To_Type],
|
|
44
|
+
condition: Condition = None,
|
|
45
|
+
join: JOIN_OPTIONS = None,
|
|
46
|
+
on: OnQuery = None,
|
|
47
|
+
condition_and: Condition = None,
|
|
48
|
+
nested: dict[str, t.Self] = None,
|
|
49
|
+
lazy: LazyPolicy | None = None,
|
|
50
|
+
explicit: bool = False,
|
|
51
|
+
):
|
|
52
|
+
"""
|
|
53
|
+
Should not be called directly, use relationship() instead!
|
|
54
|
+
"""
|
|
55
|
+
if condition and on:
|
|
56
|
+
raise self._error_duplicate_condition(condition, on)
|
|
57
|
+
|
|
58
|
+
self._type = _type
|
|
59
|
+
self.condition = condition
|
|
60
|
+
self.join = "left" if on else join # .on is always left join!
|
|
61
|
+
self.on = on
|
|
62
|
+
self.condition_and = condition_and
|
|
63
|
+
self._lazy = lazy
|
|
64
|
+
|
|
65
|
+
if args := t.get_args(_type):
|
|
66
|
+
self.table = unwrap_type(args[0])
|
|
67
|
+
self.multiple = True
|
|
68
|
+
else:
|
|
69
|
+
self.table = t.cast(type[TypedTable], _type)
|
|
70
|
+
self.multiple = False
|
|
71
|
+
|
|
72
|
+
if isinstance(self.table, str):
|
|
73
|
+
self.table = TypeDAL.to_snake(self.table)
|
|
74
|
+
|
|
75
|
+
self.explicit = explicit
|
|
76
|
+
self.nested = nested or {}
|
|
77
|
+
|
|
78
|
+
def clone(self, **update: t.Any) -> "Relationship[To_Type]":
|
|
79
|
+
"""
|
|
80
|
+
Create a copy of the relationship, possibly updated.
|
|
81
|
+
"""
|
|
82
|
+
condition = update.get("condition")
|
|
83
|
+
on = update.get("on")
|
|
84
|
+
|
|
85
|
+
if on and condition: # pragma: no cover
|
|
86
|
+
raise self._error_duplicate_condition(condition, on)
|
|
87
|
+
|
|
88
|
+
return self.__class__(
|
|
89
|
+
update.get("_type") or self._type,
|
|
90
|
+
None if on else (condition or self.condition),
|
|
91
|
+
update.get("join") or self.join,
|
|
92
|
+
None if condition else (on or self.on),
|
|
93
|
+
update.get("condition_and") or self.condition_and,
|
|
94
|
+
(self.nested | extra) if (extra := update.get("nested")) else self.nested, # type: ignore
|
|
95
|
+
update.get("lazy") or self._lazy,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
@staticmethod
|
|
99
|
+
def _error_duplicate_condition(condition: Condition, on: OnQuery) -> t.Never:
|
|
100
|
+
warnings.warn(f"Relation | Both specified! {condition=} {on=}")
|
|
101
|
+
raise ValueError("Please specify either a condition or an 'on' statement for this relationship!")
|
|
102
|
+
|
|
103
|
+
def __repr__(self) -> str:
|
|
104
|
+
"""
|
|
105
|
+
Representation of the relationship.
|
|
106
|
+
"""
|
|
107
|
+
if callback := self.condition or self.on:
|
|
108
|
+
src_code = inspect.getsource(callback).strip()
|
|
109
|
+
|
|
110
|
+
if c_and := self.condition_and:
|
|
111
|
+
and_code = inspect.getsource(c_and).strip()
|
|
112
|
+
src_code += " AND " + and_code
|
|
113
|
+
else:
|
|
114
|
+
cls_name = self._type if isinstance(self._type, str) else self._type.__name__
|
|
115
|
+
src_code = f"to {cls_name} (missing condition)"
|
|
116
|
+
|
|
117
|
+
join = f":{self.join}" if self.join else ""
|
|
118
|
+
lazy_str = f" lazy={self.lazy}" if self.lazy != "warn" else ""
|
|
119
|
+
return f"<Relationship{join}{lazy_str} {src_code}>"
|
|
120
|
+
|
|
121
|
+
def __set_name__(self, owner: t.Type["TypedTable"], name: str) -> None:
|
|
122
|
+
"""Called automatically when assigned to a class attribute."""
|
|
123
|
+
self.name = name
|
|
124
|
+
|
|
125
|
+
def get_table(self, db: "TypeDAL") -> t.Type["TypedTable"]:
|
|
126
|
+
"""
|
|
127
|
+
Get the table this relationship is bound to.
|
|
128
|
+
"""
|
|
129
|
+
table = self.table # can be a string because db wasn't available yet
|
|
130
|
+
|
|
131
|
+
if isinstance(table, str):
|
|
132
|
+
if mapped := db._class_map.get(table):
|
|
133
|
+
# yay
|
|
134
|
+
return mapped
|
|
135
|
+
|
|
136
|
+
# boo, fall back to untyped table but pretend it is typed:
|
|
137
|
+
return t.cast(t.Type["TypedTable"], db[table]) # eh close enough!
|
|
138
|
+
|
|
139
|
+
return table
|
|
140
|
+
|
|
141
|
+
def get_db(self) -> TypeDAL | None:
|
|
142
|
+
"""
|
|
143
|
+
Retrieves the database instance associated with the table.
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
TypeDAL | None: The database instance if it exists, or None otherwise.
|
|
147
|
+
"""
|
|
148
|
+
return getattr(self.table, "_db", None)
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def lazy(self) -> LazyPolicy:
|
|
152
|
+
"""
|
|
153
|
+
Gets the lazy policy configured in the current context.
|
|
154
|
+
|
|
155
|
+
The method first checks for a customized lazy policy for this relationship.
|
|
156
|
+
If not found, it attempts to retrieve the lazy policy from the database.
|
|
157
|
+
If neither option is available, it returns a conservative fallback value.
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
LazyPolicy or str: The configured lazy policy or a fallback value.
|
|
161
|
+
"""
|
|
162
|
+
if customized := self._lazy:
|
|
163
|
+
return customized
|
|
164
|
+
|
|
165
|
+
if db := self.get_db():
|
|
166
|
+
return db._config.lazy_policy
|
|
167
|
+
|
|
168
|
+
# conservative fallback:
|
|
169
|
+
return "warn"
|
|
170
|
+
|
|
171
|
+
def get_table_name(self) -> str:
|
|
172
|
+
"""
|
|
173
|
+
Get the name of the table this relationship is bound to.
|
|
174
|
+
"""
|
|
175
|
+
if isinstance(self.table, str):
|
|
176
|
+
return self.table
|
|
177
|
+
|
|
178
|
+
if isinstance(self.table, pydal.objects.Table):
|
|
179
|
+
return str(self.table)
|
|
180
|
+
|
|
181
|
+
# else: typed table
|
|
182
|
+
try:
|
|
183
|
+
table = self.table._ensure_table_defined() if issubclass(self.table, TypedTable) else self.table
|
|
184
|
+
except Exception: # pragma: no cover
|
|
185
|
+
table = self.table
|
|
186
|
+
|
|
187
|
+
return str(table)
|
|
188
|
+
|
|
189
|
+
def __get__(
|
|
190
|
+
self,
|
|
191
|
+
instance: "TypedTable",
|
|
192
|
+
owner: t.Type["TypedTable"],
|
|
193
|
+
) -> "t.Optional[list[t.Any]] | Relationship[To_Type]":
|
|
194
|
+
"""
|
|
195
|
+
Relationship is a descriptor class, which can be returned from a class but not an instance.
|
|
196
|
+
|
|
197
|
+
For an instance, using .join() will replace the Relationship with the actual data.
|
|
198
|
+
Behavior when accessed without joining depends on the lazy policy.
|
|
199
|
+
"""
|
|
200
|
+
if not instance:
|
|
201
|
+
# relationship queried on class, that's allowed
|
|
202
|
+
return self
|
|
203
|
+
|
|
204
|
+
# instance: TypedTable instance
|
|
205
|
+
# owner: TypedTable class
|
|
206
|
+
|
|
207
|
+
if not self.name: # pragma: no cover
|
|
208
|
+
raise ValueError("Relationship does not seem to be connected to a table field.")
|
|
209
|
+
|
|
210
|
+
# Handle different lazy policies
|
|
211
|
+
if self.lazy == "forbid": # pragma: no cover
|
|
212
|
+
raise AttributeError(
|
|
213
|
+
f"Accessing relationship '{self.name}' without joining is forbidden. "
|
|
214
|
+
f"Use .join('{self.name}') in your query or set lazy='allow' if this is intentional.",
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
fallback_value: t.Optional[list[t.Any]] = [] if self.multiple else None
|
|
218
|
+
|
|
219
|
+
if self.lazy == "ignore": # pragma: no cover
|
|
220
|
+
# Return empty silently
|
|
221
|
+
return fallback_value
|
|
222
|
+
|
|
223
|
+
if self.lazy == "warn":
|
|
224
|
+
# Warn and return empty
|
|
225
|
+
warnings.warn(
|
|
226
|
+
f"Trying to access relationship '{self.name}' without joining. "
|
|
227
|
+
f"Did you forget to use .join('{self.name}')? Returning empty value.",
|
|
228
|
+
category=RuntimeWarning,
|
|
229
|
+
)
|
|
230
|
+
return fallback_value
|
|
231
|
+
|
|
232
|
+
# For "tolerate" and "allow", we fetch the data
|
|
233
|
+
try:
|
|
234
|
+
resolved_table = self.get_table(instance._db)
|
|
235
|
+
|
|
236
|
+
builder = owner.where(id=instance.id).join(self.name)
|
|
237
|
+
if issubclass(resolved_table, TypedTable) or isinstance(resolved_table, pydal.objects.Table):
|
|
238
|
+
# is a table so we can select ALL and ignore non-required fields of parent row:
|
|
239
|
+
builder = builder.select(owner.id, resolved_table.ALL)
|
|
240
|
+
|
|
241
|
+
if self.lazy == "tolerate":
|
|
242
|
+
warnings.warn(
|
|
243
|
+
f"Lazy loading relationship '{self.name}'. "
|
|
244
|
+
"This performs an extra database query. "
|
|
245
|
+
f"Consider using .join('{self.name}') for better performance.",
|
|
246
|
+
category=RuntimeWarning,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
return builder.first()[self.name] # type: ignore
|
|
250
|
+
except Exception as e: # pragma: no cover
|
|
251
|
+
warnings.warn(
|
|
252
|
+
f"Failed to lazy load relationship '{self.name}': {e}",
|
|
253
|
+
category=RuntimeWarning,
|
|
254
|
+
source=e,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
return fallback_value
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def relationship(
|
|
261
|
+
_type: t.Type[To_Type] | str,
|
|
262
|
+
condition: Condition = None,
|
|
263
|
+
join: JOIN_OPTIONS = None,
|
|
264
|
+
on: OnQuery = None,
|
|
265
|
+
lazy: LazyPolicy | None = None,
|
|
266
|
+
explicit: bool = False,
|
|
267
|
+
) -> To_Type:
|
|
268
|
+
"""
|
|
269
|
+
Define a relationship to another table, when its id is not stored in the current table.
|
|
270
|
+
|
|
271
|
+
Args:
|
|
272
|
+
_type: The type of the related table. Use list[Type] for one-to-many relationships.
|
|
273
|
+
condition: Lambda function defining the join condition between tables.
|
|
274
|
+
Example: lambda self, post: self.id == post.author
|
|
275
|
+
join: Join strategy ('left', 'inner', etc.). Defaults to 'left' when using 'on'.
|
|
276
|
+
on: Alternative to condition for complex queries with pivot tables.
|
|
277
|
+
Allows specifying multiple join conditions to avoid cross joins.
|
|
278
|
+
lazy: Controls behavior when accessing relationship data without explicitly joining:
|
|
279
|
+
- "forbid": Raise an error (strictest, prevents N+1 queries)
|
|
280
|
+
- "warn": Return empty value with warning
|
|
281
|
+
- "ignore": Return empty value silently
|
|
282
|
+
- "tolerate": Fetch data with warning (convenient but warns about performance)
|
|
283
|
+
- "allow": Fetch data silently (most permissive, use only for known cheap queries)
|
|
284
|
+
explicit: If True, this relationship is only joined when explicitly requested
|
|
285
|
+
(e.g. User.join("tags")). Bare User.join() calls will skip it.
|
|
286
|
+
Useful for expensive or rarely-needed relationships. Defaults to False.
|
|
287
|
+
|
|
288
|
+
Example:
|
|
289
|
+
class User(TypedTable):
|
|
290
|
+
name: str
|
|
291
|
+
|
|
292
|
+
posts = relationship(list["Post"], condition=lambda self, post: self.id == post.author, join='left')
|
|
293
|
+
|
|
294
|
+
class Post(TypedTable):
|
|
295
|
+
title: str
|
|
296
|
+
author: User
|
|
297
|
+
|
|
298
|
+
User.join("posts").first() # User instance with list[Post] in .posts
|
|
299
|
+
|
|
300
|
+
Here, Post stores the User ID, but `relationship(list["Post"])` still allows you to get the user's posts.
|
|
301
|
+
In this case, the join strategy is set to LEFT so users without posts are also still selected.
|
|
302
|
+
|
|
303
|
+
For complex queries with a pivot table, 'on' can be set instead of 'condition':
|
|
304
|
+
class User(TypedTable):
|
|
305
|
+
...
|
|
306
|
+
|
|
307
|
+
tags = relationship(list["Tag"], on=lambda self, tag: [
|
|
308
|
+
Tagged.on(Tagged.entity == entity.gid),
|
|
309
|
+
Tag.on((Tagged.tag == tag.id)),
|
|
310
|
+
])
|
|
311
|
+
|
|
312
|
+
If you'd try to capture this in a single 'condition', pydal would create a cross join which is much less efficient.
|
|
313
|
+
"""
|
|
314
|
+
return t.cast(
|
|
315
|
+
# note: The descriptor `Relationship[To_Type]` is more correct, but pycharm doesn't really get that.
|
|
316
|
+
# so for ease of use, just cast to the refered type for now!
|
|
317
|
+
# e.g. x = relationship(Author) -> x: Author
|
|
318
|
+
To_Type,
|
|
319
|
+
Relationship(_type, condition, join, on, lazy=lazy, explicit=explicit), # type: ignore
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _generate_relationship_condition(_: t.Type["TypedTable"], key: str, field: T_Field) -> Condition:
|
|
324
|
+
origin = t.get_origin(field)
|
|
325
|
+
# else: generic
|
|
326
|
+
|
|
327
|
+
if origin is list:
|
|
328
|
+
# field = typing.get_args(field)[0] # actual field
|
|
329
|
+
# return lambda _self, _other: cls[key].contains(field)
|
|
330
|
+
|
|
331
|
+
return lambda _self, _other: _self[key].contains(_other.id)
|
|
332
|
+
else:
|
|
333
|
+
# normal reference
|
|
334
|
+
# return lambda _self, _other: cls[key] == field.id
|
|
335
|
+
return lambda _self, _other: _self[key] == _other.id
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def to_relationship(
|
|
339
|
+
cls: t.Type["TypedTable"] | type[t.Any],
|
|
340
|
+
key: str,
|
|
341
|
+
field: T_Field,
|
|
342
|
+
) -> t.Optional[Relationship[t.Any]]:
|
|
343
|
+
"""
|
|
344
|
+
Used to automatically create relationship instance for reference fields.
|
|
345
|
+
|
|
346
|
+
Example:
|
|
347
|
+
class MyTable(TypedTable):
|
|
348
|
+
reference: OtherTable
|
|
349
|
+
|
|
350
|
+
`reference` contains the id of an Other Table row.
|
|
351
|
+
MyTable.relationships should have 'reference' as a relationship, so `MyTable.join('reference')` should work.
|
|
352
|
+
|
|
353
|
+
This function will automatically perform this logic (called in db.define):
|
|
354
|
+
to_relationship(MyTable, 'reference', OtherTable) -> Relationship[OtherTable]
|
|
355
|
+
|
|
356
|
+
Also works for list:reference (list[OtherTable]) and TypedField[OtherTable].
|
|
357
|
+
"""
|
|
358
|
+
if looks_like(field, TypedField):
|
|
359
|
+
# typing.get_args works for list[str] but not for TypedField[role] :(
|
|
360
|
+
if args := t.get_args(field):
|
|
361
|
+
# TypedField[SomeType] -> SomeType
|
|
362
|
+
field = args[0]
|
|
363
|
+
elif hasattr(field, "_type"):
|
|
364
|
+
# TypedField(SomeType) -> SomeType
|
|
365
|
+
field = t.cast(T_Field, field._type)
|
|
366
|
+
else: # pragma: no cover
|
|
367
|
+
# weird
|
|
368
|
+
return None
|
|
369
|
+
|
|
370
|
+
field, optional = extract_type_optional(field)
|
|
371
|
+
|
|
372
|
+
try:
|
|
373
|
+
condition = _generate_relationship_condition(cls, key, field)
|
|
374
|
+
except Exception as e: # pragma: no cover
|
|
375
|
+
warnings.warn("Could not generate Relationship condition", source=e)
|
|
376
|
+
condition = None
|
|
377
|
+
|
|
378
|
+
if not condition: # pragma: no cover
|
|
379
|
+
# something went wrong, not a valid relationship
|
|
380
|
+
warnings.warn(f"Invalid relationship for {cls.__name__}.{key}: {field}")
|
|
381
|
+
return None
|
|
382
|
+
|
|
383
|
+
join = "left" if optional or t.get_origin(field) is list else "inner"
|
|
384
|
+
|
|
385
|
+
return Relationship(t.cast(type[TypedTable], field), condition, t.cast(JOIN_OPTIONS, join))
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
# note: these imports exist at the bottom of this file to prevent circular import issues:
|
|
389
|
+
|
|
390
|
+
from .tables import TypedTable # noqa: E402
|