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.
@@ -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