TypeDAL 3.17.3__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/tables.py ADDED
@@ -0,0 +1,1122 @@
1
+ """
2
+ Contains base functionality related to Tables.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import copy
8
+ import csv
9
+ import functools
10
+ import json
11
+ import typing as t
12
+ import uuid
13
+
14
+ import pydal.objects
15
+ from pydal._globals import DEFAULT
16
+
17
+ from .constants import JOIN_OPTIONS
18
+ from .core import TypeDAL
19
+ from .helpers import classproperty, throw
20
+ from .serializers import as_json
21
+ from .types import (
22
+ AnyDict,
23
+ Condition,
24
+ Expression,
25
+ Field,
26
+ OnQuery,
27
+ OpRow,
28
+ OrderBy,
29
+ P,
30
+ Query,
31
+ R,
32
+ Reference,
33
+ Row,
34
+ SelectKwargs,
35
+ Set,
36
+ T,
37
+ T_MetaInstance,
38
+ T_Query,
39
+ Table,
40
+ )
41
+
42
+ if t.TYPE_CHECKING:
43
+ from .relationships import Relationship
44
+ from .rows import PaginatedRows, TypedRows
45
+
46
+
47
+ def reorder_fields(
48
+ table: pydal.objects.Table,
49
+ fields: t.Iterable[str | TypedField[t.Any] | Field],
50
+ keep_others: bool = True,
51
+ ) -> None:
52
+ """
53
+ Reorder fields of a pydal table.
54
+
55
+ Args:
56
+ table: The pydal table object (e.g., db.mytable).
57
+ fields: List of field names (str) or Field objects in desired order.
58
+ keep_others (bool):
59
+ - True (default): keep other fields at the end, in their original order.
60
+ - False: remove other fields (only keep what's specified).
61
+ """
62
+ # Normalize input to field names
63
+ desired = [f.name if isinstance(f, (TypedField, Field, pydal.objects.Field)) else str(f) for f in fields]
64
+
65
+ new_order = [f for f in desired if f in table._fields]
66
+
67
+ if keep_others:
68
+ # Start with desired fields, then append the rest
69
+ new_order.extend(f for f in table._fields if f not in desired)
70
+
71
+ table._fields = new_order
72
+
73
+
74
+ class TableMeta(type):
75
+ """
76
+ This metaclass contains functionality on table classes, that doesn't exist on its instances.
77
+
78
+ Example:
79
+ class MyTable(TypedTable):
80
+ some_field: TypedField[int]
81
+
82
+ MyTable.update_or_insert(...) # should work
83
+
84
+ MyTable.some_field # -> Field, can be used to query etc.
85
+
86
+ row = MyTable.first() # returns instance of MyTable
87
+
88
+ # row.update_or_insert(...) # shouldn't work!
89
+
90
+ row.some_field # -> int, with actual data
91
+
92
+ """
93
+
94
+ # set up by db.define:
95
+ # _db: TypeDAL | None = None
96
+ # _table: Table | None = None
97
+ _db: TypeDAL | None = None
98
+ _table: Table | None = None
99
+ _relationships: dict[str, Relationship[t.Any]] | None = None
100
+
101
+ #########################
102
+ # TypeDAL custom logic: #
103
+ #########################
104
+
105
+ def __set_internals__(self, db: pydal.DAL, table: Table, relationships: dict[str, Relationship[t.Any]]) -> None:
106
+ """
107
+ Store the related database and pydal table for later usage.
108
+ """
109
+ self._db = db
110
+ self._table = table
111
+ self._relationships = relationships
112
+
113
+ def __getattr__(self, col: str) -> t.Optional[Field]:
114
+ """
115
+ Magic method used by TypedTableMeta to get a database field with dot notation on a class.
116
+
117
+ Example:
118
+ SomeTypedTable.col -> db.table.col (via TypedTableMeta.__getattr__)
119
+
120
+ """
121
+ if self._table:
122
+ return getattr(self._table, col, None)
123
+
124
+ return None
125
+
126
+ def _ensure_table_defined(self) -> Table:
127
+ if not self._table:
128
+ raise EnvironmentError("@define or db.define is not called on this class yet!")
129
+ return self._table
130
+
131
+ def __iter__(self) -> t.Generator[Field, None, None]:
132
+ """
133
+ Loop through the columns of this model.
134
+ """
135
+ table = self._ensure_table_defined()
136
+ yield from iter(table)
137
+
138
+ def __getitem__(self, item: str) -> Field:
139
+ """
140
+ Allow dict notation to get a column of this table (-> Field instance).
141
+ """
142
+ table = self._ensure_table_defined()
143
+ return table[item]
144
+
145
+ def __str__(self) -> str:
146
+ """
147
+ Normally, just returns the underlying table name, but with a fallback if the model is unbound.
148
+ """
149
+ if self._table:
150
+ return str(self._table)
151
+ else:
152
+ return f"<unbound table {self.__name__}>"
153
+
154
+ def from_row(self: t.Type[T_MetaInstance], row: pydal.objects.Row) -> T_MetaInstance:
155
+ """
156
+ Create a model instance from a pydal row.
157
+ """
158
+ return self(row)
159
+
160
+ def all(self: t.Type[T_MetaInstance]) -> "TypedRows[T_MetaInstance]":
161
+ """
162
+ Return all rows for this model.
163
+ """
164
+ return self.collect()
165
+
166
+ def get_relationships(self) -> dict[str, Relationship[t.Any]]:
167
+ """
168
+ Return the registered relationships of the current model.
169
+ """
170
+ return self._relationships or {}
171
+
172
+ ##########################
173
+ # TypeDAL Modified Logic #
174
+ ##########################
175
+
176
+ def insert(self: t.Type[T_MetaInstance], **fields: t.Any) -> T_MetaInstance:
177
+ """
178
+ This is only called when db.define is not used as a decorator.
179
+
180
+ cls.__table functions as 'self'
181
+
182
+ Args:
183
+ **fields: t.Anything you want to insert in the database
184
+
185
+ Returns: the ID of the new row.
186
+
187
+ """
188
+ table = self._ensure_table_defined()
189
+
190
+ result = table.insert(**fields)
191
+ # it already is an int but mypy doesn't understand that
192
+ return self(result)
193
+
194
+ def _insert(self, **fields: t.Any) -> str:
195
+ table = self._ensure_table_defined()
196
+
197
+ return str(table._insert(**fields))
198
+
199
+ def bulk_insert(self: t.Type[T_MetaInstance], items: list[AnyDict]) -> "TypedRows[T_MetaInstance]":
200
+ """
201
+ Insert multiple rows, returns a TypedRows set of new instances.
202
+ """
203
+ table = self._ensure_table_defined()
204
+ result = table.bulk_insert(items)
205
+ return self.where(lambda row: row.id.belongs(result)).collect()
206
+
207
+ def update_or_insert(
208
+ self: t.Type[T_MetaInstance],
209
+ query: T_Query | AnyDict = DEFAULT,
210
+ **values: t.Any,
211
+ ) -> T_MetaInstance:
212
+ """
213
+ Update a row if query matches, else insert a new one.
214
+
215
+ Returns the created or updated instance.
216
+ """
217
+ table = self._ensure_table_defined()
218
+
219
+ if query is DEFAULT:
220
+ record = table(**values)
221
+ elif isinstance(query, dict):
222
+ record = table(**query)
223
+ else:
224
+ record = table(query)
225
+
226
+ if not record:
227
+ return self.insert(**values)
228
+
229
+ record.update_record(**values)
230
+ return self(record)
231
+
232
+ def validate_and_insert(
233
+ self: t.Type[T_MetaInstance],
234
+ **fields: t.Any,
235
+ ) -> tuple[t.Optional[T_MetaInstance], t.Optional[dict[str, str]]]:
236
+ """
237
+ Validate input data and then insert a row.
238
+
239
+ Returns a tuple of (the created instance, a dict of errors).
240
+ """
241
+ table = self._ensure_table_defined()
242
+ result = table.validate_and_insert(**fields)
243
+ if row_id := result.get("id"):
244
+ return self(row_id), None
245
+ else:
246
+ return None, result.get("errors")
247
+
248
+ def validate_and_update(
249
+ self: t.Type[T_MetaInstance],
250
+ query: Query,
251
+ **fields: t.Any,
252
+ ) -> tuple[t.Optional[T_MetaInstance], t.Optional[dict[str, str]]]:
253
+ """
254
+ Validate input data and then update max 1 row.
255
+
256
+ Returns a tuple of (the updated instance, a dict of errors).
257
+ """
258
+ table = self._ensure_table_defined()
259
+
260
+ result = table.validate_and_update(query, **fields)
261
+
262
+ if errors := result.get("errors"):
263
+ return None, errors
264
+ elif row_id := result.get("id"):
265
+ return self(row_id), None
266
+ else: # pragma: no cover
267
+ # update on query without result (shouldnt happen)
268
+ return None, None
269
+
270
+ def validate_and_update_or_insert(
271
+ self: t.Type[T_MetaInstance],
272
+ query: Query,
273
+ **fields: t.Any,
274
+ ) -> tuple[t.Optional[T_MetaInstance], t.Optional[dict[str, str]]]:
275
+ """
276
+ Validate input data and then update_and_insert (on max 1 row).
277
+
278
+ Returns a tuple of (the updated/created instance, a dict of errors).
279
+ """
280
+ table = self._ensure_table_defined()
281
+ result = table.validate_and_update_or_insert(query, **fields)
282
+
283
+ if errors := result.get("errors"):
284
+ return None, errors
285
+ elif row_id := result.get("id"):
286
+ return self(row_id), None
287
+ else: # pragma: no cover
288
+ # update on query without result (shouldnt happen)
289
+ return None, None
290
+
291
+ def select(self: t.Type[T_MetaInstance], *a: t.Any, **kw: t.Any) -> "QueryBuilder[T_MetaInstance]":
292
+ """
293
+ See QueryBuilder.select!
294
+ """
295
+ return QueryBuilder(self).select(*a, **kw)
296
+
297
+ def column(self: t.Type[T_MetaInstance], field: T | TypedField[T], **options: t.Unpack[SelectKwargs]) -> list[T]:
298
+ """
299
+ Get all values in a specific column.
300
+
301
+ Shortcut for `.select(field).execute().column(field)`.
302
+ """
303
+ return QueryBuilder(self).select(field, **options).execute().column(field)
304
+
305
+ def paginate(self: t.Type[T_MetaInstance], limit: int, page: int = 1) -> "PaginatedRows[T_MetaInstance]":
306
+ """
307
+ See QueryBuilder.paginate!
308
+ """
309
+ return QueryBuilder(self).paginate(limit=limit, page=page)
310
+
311
+ def chunk(self: t.Type[T_MetaInstance], chunk_size: int) -> t.Generator["TypedRows[T_MetaInstance]", t.Any, None]:
312
+ """
313
+ See QueryBuilder.chunk!
314
+ """
315
+ return QueryBuilder(self).chunk(chunk_size)
316
+
317
+ def where(self: t.Type[T_MetaInstance], *a: t.Any, **kw: t.Any) -> "QueryBuilder[T_MetaInstance]":
318
+ """
319
+ See QueryBuilder.where!
320
+ """
321
+ return QueryBuilder(self).where(*a, **kw)
322
+
323
+ def orderby(self: t.Type[T_MetaInstance], *fields: OrderBy) -> "QueryBuilder[T_MetaInstance]":
324
+ """
325
+ See QueryBuilder.orderby!
326
+ """
327
+ return QueryBuilder(self).orderby(*fields)
328
+
329
+ def cache(self: t.Type[T_MetaInstance], *deps: t.Any, **kwargs: t.Any) -> "QueryBuilder[T_MetaInstance]":
330
+ """
331
+ See QueryBuilder.cache!
332
+ """
333
+ return QueryBuilder(self).cache(*deps, **kwargs)
334
+
335
+ def count(self: t.Type[T_MetaInstance]) -> int:
336
+ """
337
+ See QueryBuilder.count!
338
+ """
339
+ return QueryBuilder(self).count()
340
+
341
+ def exists(self: t.Type[T_MetaInstance]) -> bool:
342
+ """
343
+ See QueryBuilder.exists!
344
+ """
345
+ return QueryBuilder(self).exists()
346
+
347
+ def first(self: t.Type[T_MetaInstance]) -> T_MetaInstance | None:
348
+ """
349
+ See QueryBuilder.first!
350
+ """
351
+ return QueryBuilder(self).first()
352
+
353
+ def first_or_fail(self: t.Type[T_MetaInstance]) -> T_MetaInstance:
354
+ """
355
+ See QueryBuilder.first_or_fail!
356
+ """
357
+ return QueryBuilder(self).first_or_fail()
358
+
359
+ def join(
360
+ self: t.Type[T_MetaInstance],
361
+ *fields: str | t.Type["TypedTable"],
362
+ method: JOIN_OPTIONS = None,
363
+ on: OnQuery | list[Expression] | Expression = None,
364
+ condition: Condition = None,
365
+ condition_and: Condition = None,
366
+ ) -> "QueryBuilder[T_MetaInstance]":
367
+ """
368
+ See QueryBuilder.join!
369
+ """
370
+ return QueryBuilder(self).join(*fields, on=on, condition=condition, method=method, condition_and=condition_and)
371
+
372
+ def collect(self: t.Type[T_MetaInstance], verbose: bool = False) -> "TypedRows[T_MetaInstance]":
373
+ """
374
+ See QueryBuilder.collect!
375
+ """
376
+ return QueryBuilder(self).collect(verbose=verbose)
377
+
378
+ @property
379
+ def ALL(cls) -> pydal.objects.SQLALL:
380
+ """
381
+ Select all fields for this table.
382
+ """
383
+ table = cls._ensure_table_defined()
384
+
385
+ return table.ALL
386
+
387
+ ##########################
388
+ # TypeDAL Shadowed Logic #
389
+ ##########################
390
+ fields: list[str]
391
+
392
+ # other table methods:
393
+
394
+ def truncate(self, mode: str = "") -> None:
395
+ """
396
+ Remove all data and reset index.
397
+ """
398
+ table = self._ensure_table_defined()
399
+ table.truncate(mode)
400
+
401
+ def drop(self, mode: str = "") -> None:
402
+ """
403
+ Remove the underlying table.
404
+ """
405
+ table = self._ensure_table_defined()
406
+ table.drop(mode)
407
+
408
+ def create_index(self, name: str, *fields: str | Field, **kwargs: t.Any) -> bool:
409
+ """
410
+ Add an index on some columns of this table.
411
+ """
412
+ table = self._ensure_table_defined()
413
+ result = table.create_index(name, *fields, **kwargs)
414
+ return t.cast(bool, result)
415
+
416
+ def drop_index(self, name: str, if_exists: bool = False) -> bool:
417
+ """
418
+ Remove an index from this table.
419
+ """
420
+ table = self._ensure_table_defined()
421
+ result = table.drop_index(name, if_exists)
422
+ return t.cast(bool, result)
423
+
424
+ def import_from_csv_file(
425
+ self,
426
+ csvfile: t.TextIO,
427
+ id_map: dict[str, str] = None,
428
+ null: t.Any = "<NULL>",
429
+ unique: str = "uuid",
430
+ id_offset: dict[str, int] = None, # id_offset used only when id_map is None
431
+ transform: t.Callable[[dict[t.Any, t.Any]], dict[t.Any, t.Any]] = None,
432
+ validate: bool = False,
433
+ encoding: str = "utf-8",
434
+ delimiter: str = ",",
435
+ quotechar: str = '"',
436
+ quoting: int = csv.QUOTE_MINIMAL,
437
+ restore: bool = False,
438
+ **kwargs: t.Any,
439
+ ) -> None:
440
+ """
441
+ Load a csv file into the database.
442
+ """
443
+ table = self._ensure_table_defined()
444
+ table.import_from_csv_file(
445
+ csvfile,
446
+ id_map=id_map,
447
+ null=null,
448
+ unique=unique,
449
+ id_offset=id_offset,
450
+ transform=transform,
451
+ validate=validate,
452
+ encoding=encoding,
453
+ delimiter=delimiter,
454
+ quotechar=quotechar,
455
+ quoting=quoting,
456
+ restore=restore,
457
+ **kwargs,
458
+ )
459
+
460
+ def on(self, query: bool | Query) -> Expression:
461
+ """
462
+ Shadow Table.on.
463
+
464
+ Used for joins.
465
+
466
+ See Also:
467
+ http://web2py.com/books/default/chapter/29/06/the-database-abstraction-layer?search=export_to_csv_file#One-to-mt.Any-relation
468
+ """
469
+ table = self._ensure_table_defined()
470
+ return t.cast(Expression, table.on(query))
471
+
472
+ def with_alias(self: t.Type[T_MetaInstance], alias: str) -> t.Type[T_MetaInstance]:
473
+ """
474
+ Shadow Table.with_alias.
475
+
476
+ Useful for joins when joining the same table multiple times.
477
+
478
+ See Also:
479
+ http://web2py.com/books/default/chapter/29/06/the-database-abstraction-layer#One-to-mt.Any-relation
480
+ """
481
+ table = self._ensure_table_defined()
482
+ return t.cast(t.Type[T_MetaInstance], table.with_alias(alias))
483
+
484
+ def unique_alias(self: t.Type[T_MetaInstance]) -> t.Type[T_MetaInstance]:
485
+ """
486
+ Generates a unique alias for this table.
487
+
488
+ Useful for joins when joining the same table multiple times
489
+ and you don't want to keep track of aliases yourself.
490
+ """
491
+ key = f"{self.__name__.lower()}_{hash(uuid.uuid4())}"
492
+ return self.with_alias(key)
493
+
494
+ # hooks:
495
+ def _hook_once(
496
+ cls: t.Type[T_MetaInstance],
497
+ hooks: list[t.Callable[P, R]],
498
+ fn: t.Callable[P, R],
499
+ ) -> t.Type[T_MetaInstance]:
500
+ @functools.wraps(fn)
501
+ def wraps(*a: P.args, **kw: P.kwargs) -> R:
502
+ try:
503
+ return fn(*a, **kw)
504
+ finally:
505
+ hooks.remove(wraps)
506
+
507
+ hooks.append(wraps)
508
+ return cls
509
+
510
+ def before_insert(
511
+ cls: t.Type[T_MetaInstance],
512
+ fn: t.Callable[[T_MetaInstance], t.Optional[bool]] | t.Callable[[OpRow], t.Optional[bool]],
513
+ ) -> t.Type[T_MetaInstance]:
514
+ """
515
+ Add a before insert hook.
516
+ """
517
+ if fn not in cls._before_insert:
518
+ cls._before_insert.append(fn)
519
+ return cls
520
+
521
+ def before_insert_once(
522
+ cls: t.Type[T_MetaInstance],
523
+ fn: t.Callable[[T_MetaInstance], t.Optional[bool]] | t.Callable[[OpRow], t.Optional[bool]],
524
+ ) -> t.Type[T_MetaInstance]:
525
+ """
526
+ Add a before insert hook that only fires once and then removes itself.
527
+ """
528
+ return cls._hook_once(cls._before_insert, fn) # type: ignore
529
+
530
+ def after_insert(
531
+ cls: t.Type[T_MetaInstance],
532
+ fn: (
533
+ t.Callable[[T_MetaInstance, Reference], t.Optional[bool]] | t.Callable[[OpRow, Reference], t.Optional[bool]]
534
+ ),
535
+ ) -> t.Type[T_MetaInstance]:
536
+ """
537
+ Add an after insert hook.
538
+ """
539
+ if fn not in cls._after_insert:
540
+ cls._after_insert.append(fn)
541
+ return cls
542
+
543
+ def after_insert_once(
544
+ cls: t.Type[T_MetaInstance],
545
+ fn: (
546
+ t.Callable[[T_MetaInstance, Reference], t.Optional[bool]] | t.Callable[[OpRow, Reference], t.Optional[bool]]
547
+ ),
548
+ ) -> t.Type[T_MetaInstance]:
549
+ """
550
+ Add an after insert hook that only fires once and then removes itself.
551
+ """
552
+ return cls._hook_once(cls._after_insert, fn) # type: ignore
553
+
554
+ def before_update(
555
+ cls: t.Type[T_MetaInstance],
556
+ fn: t.Callable[[Set, T_MetaInstance], t.Optional[bool]] | t.Callable[[Set, OpRow], t.Optional[bool]],
557
+ ) -> t.Type[T_MetaInstance]:
558
+ """
559
+ Add a before update hook.
560
+ """
561
+ if fn not in cls._before_update:
562
+ cls._before_update.append(fn)
563
+ return cls
564
+
565
+ def before_update_once(
566
+ cls,
567
+ fn: t.Callable[[Set, T_MetaInstance], t.Optional[bool]] | t.Callable[[Set, OpRow], t.Optional[bool]],
568
+ ) -> t.Type[T_MetaInstance]:
569
+ """
570
+ Add a before update hook that only fires once and then removes itself.
571
+ """
572
+ return cls._hook_once(cls._before_update, fn) # type: ignore
573
+
574
+ def after_update(
575
+ cls: t.Type[T_MetaInstance],
576
+ fn: t.Callable[[Set, T_MetaInstance], t.Optional[bool]] | t.Callable[[Set, OpRow], t.Optional[bool]],
577
+ ) -> t.Type[T_MetaInstance]:
578
+ """
579
+ Add an after update hook.
580
+ """
581
+ if fn not in cls._after_update:
582
+ cls._after_update.append(fn)
583
+ return cls
584
+
585
+ def after_update_once(
586
+ cls: t.Type[T_MetaInstance],
587
+ fn: t.Callable[[Set, T_MetaInstance], t.Optional[bool]] | t.Callable[[Set, OpRow], t.Optional[bool]],
588
+ ) -> t.Type[T_MetaInstance]:
589
+ """
590
+ Add an after update hook that only fires once and then removes itself.
591
+ """
592
+ return cls._hook_once(cls._after_update, fn) # type: ignore
593
+
594
+ def before_delete(cls: t.Type[T_MetaInstance], fn: t.Callable[[Set], t.Optional[bool]]) -> t.Type[T_MetaInstance]:
595
+ """
596
+ Add a before delete hook.
597
+ """
598
+ if fn not in cls._before_delete:
599
+ cls._before_delete.append(fn)
600
+ return cls
601
+
602
+ def before_delete_once(
603
+ cls: t.Type[T_MetaInstance],
604
+ fn: t.Callable[[Set], t.Optional[bool]],
605
+ ) -> t.Type[T_MetaInstance]:
606
+ """
607
+ Add a before delete hook that only fires once and then removes itself.
608
+ """
609
+ return cls._hook_once(cls._before_delete, fn)
610
+
611
+ def after_delete(cls: t.Type[T_MetaInstance], fn: t.Callable[[Set], t.Optional[bool]]) -> t.Type[T_MetaInstance]:
612
+ """
613
+ Add an after delete hook.
614
+ """
615
+ if fn not in cls._after_delete:
616
+ cls._after_delete.append(fn)
617
+ return cls
618
+
619
+ def after_delete_once(
620
+ cls: t.Type[T_MetaInstance],
621
+ fn: t.Callable[[Set], t.Optional[bool]],
622
+ ) -> t.Type[T_MetaInstance]:
623
+ """
624
+ Add an after delete hook that only fires once and then removes itself.
625
+ """
626
+ return cls._hook_once(cls._after_delete, fn)
627
+
628
+ def reorder_fields(cls, *fields: str | Field | TypedField[t.Any], keep_others: bool = True) -> None:
629
+ """
630
+ Reorder fields of a typedal table.
631
+
632
+ Args:
633
+ fields: List of field names (str) or Field objects in desired order.
634
+ keep_others (bool):
635
+ - True (default): keep other fields at the end, in their original order.
636
+ - False: remove other fields (only keep what's specified).
637
+ """
638
+ return reorder_fields(cls._table, fields, keep_others=keep_others)
639
+
640
+
641
+ class _TypedTable:
642
+ """
643
+ This class is a final shared parent between TypedTable and Mixins.
644
+
645
+ This needs to exist because otherwise the __on_define__ of Mixins are not executed.
646
+ Notably, this class exists at a level ABOVE the `metaclass=TableMeta`,
647
+ because otherwise typing gets confused when Mixins are used and multiple types could satisfy
648
+ generic 'T subclass of TypedTable'
649
+ -> Setting 'TypedTable' as the parent for Mixin does not work at runtime (and works semi at type check time)
650
+ """
651
+
652
+ id: "TypedField[int]"
653
+
654
+ _before_insert: list[t.Callable[[t.Self], t.Optional[bool]] | t.Callable[[OpRow], t.Optional[bool]]]
655
+ _after_insert: list[
656
+ t.Callable[[t.Self, Reference], t.Optional[bool]] | t.Callable[[OpRow, Reference], t.Optional[bool]]
657
+ ]
658
+ _before_update: list[t.Callable[[Set, t.Self], t.Optional[bool]] | t.Callable[[Set, OpRow], t.Optional[bool]]]
659
+ _after_update: list[t.Callable[[Set, t.Self], t.Optional[bool]] | t.Callable[[Set, OpRow], t.Optional[bool]]]
660
+ _before_delete: list[t.Callable[[Set], t.Optional[bool]]]
661
+ _after_delete: list[t.Callable[[Set], t.Optional[bool]]]
662
+
663
+ @classmethod
664
+ def __on_define__(cls, db: TypeDAL) -> None:
665
+ """
666
+ Method that can be implemented by tables to do an action after db.define is completed.
667
+
668
+ This can be useful if you need to add something like requires=IS_NOT_IN_DB(db, "table.field"),
669
+ where you need a reference to the current database, which may not exist yet when defining the model.
670
+ """
671
+
672
+ @classproperty
673
+ def _hooks(cls) -> dict[str, list[t.Callable[..., t.Optional[bool]]]]:
674
+ return {
675
+ "before_insert": cls._before_insert,
676
+ "after_insert": cls._after_insert,
677
+ "before_update": cls._before_update,
678
+ "after_update": cls._after_update,
679
+ "before_delete": cls._before_delete,
680
+ "after_delete": cls._after_delete,
681
+ }
682
+
683
+
684
+ class TypedTable(_TypedTable, metaclass=TableMeta):
685
+ """
686
+ Enhanded modeling system on top of pydal's Table that adds typing and additional functionality.
687
+ """
688
+
689
+ # set up by 'new':
690
+ _row: Row | None = None
691
+ _rows: tuple[Row, ...] = ()
692
+
693
+ _with: list[str]
694
+
695
+ def _setup_instance_methods(self) -> None:
696
+ self.as_dict = self._as_dict # type: ignore
697
+ self.__json__ = self.as_json = self._as_json # type: ignore
698
+ # self.as_yaml = self._as_yaml # type: ignore
699
+ self.as_xml = self._as_xml # type: ignore
700
+
701
+ self.update = self._update # type: ignore
702
+
703
+ self.delete_record = self._delete_record # type: ignore
704
+ self.update_record = self._update_record # type: ignore
705
+
706
+ def __new__(
707
+ cls,
708
+ row_or_id: t.Union[Row, Query, pydal.objects.Set, int, str, None, "TypedTable"] = None,
709
+ **filters: t.Any,
710
+ ) -> t.Self:
711
+ """
712
+ Create a Typed Rows model instance from an existing row, ID or query.
713
+
714
+ Examples:
715
+ MyTable(1)
716
+ MyTable(id=1)
717
+ MyTable(MyTable.id == 1)
718
+ """
719
+ table = cls._ensure_table_defined()
720
+ inst = super().__new__(cls)
721
+
722
+ if isinstance(row_or_id, TypedTable):
723
+ # existing typed table instance!
724
+ return t.cast(t.Self, row_or_id)
725
+
726
+ elif isinstance(row_or_id, pydal.objects.Row):
727
+ row = row_or_id
728
+ elif row_or_id is not None:
729
+ row = table(row_or_id, **filters)
730
+ elif filters:
731
+ row = table(**filters)
732
+ else:
733
+ # dummy object
734
+ return inst
735
+
736
+ if not row:
737
+ return None # type: ignore
738
+
739
+ inst._row = row
740
+
741
+ if hasattr(row, "id"):
742
+ inst.__dict__.update(row)
743
+ else:
744
+ # deal with _extra (and possibly others?)
745
+ # Row <{actual: {}, _extra: ...}>
746
+ inst.__dict__.update(row[str(cls)])
747
+
748
+ inst._setup_instance_methods()
749
+ return inst
750
+
751
+ def __iter__(self) -> t.Generator[t.Any, None, None]:
752
+ """
753
+ Allows looping through the columns.
754
+ """
755
+ row = self._ensure_matching_row()
756
+ yield from iter(row)
757
+
758
+ def __getitem__(self, item: str) -> t.Any:
759
+ """
760
+ Allows dictionary notation to get columns.
761
+ """
762
+ if item in self.__dict__:
763
+ return self.__dict__.get(item)
764
+
765
+ # fallback to lookup in row
766
+ if self._row:
767
+ return self._row[item]
768
+
769
+ # nothing found!
770
+ raise KeyError(item)
771
+
772
+ def __getattr__(self, item: str) -> t.Any:
773
+ """
774
+ Allows dot notation to get columns.
775
+ """
776
+ if value := self.get(item):
777
+ return value
778
+
779
+ raise AttributeError(item)
780
+
781
+ def keys(self) -> list[str]:
782
+ """
783
+ Return the combination of row + relationship keys.
784
+
785
+ Used by dict(row).
786
+ """
787
+ return list(self._row.keys() if self._row else ()) + getattr(self, "_with", [])
788
+
789
+ def get(self, item: str, default: t.Any = None) -> t.Any:
790
+ """
791
+ Try to get a column from this instance, else return default.
792
+ """
793
+ try:
794
+ return self.__getitem__(item)
795
+ except KeyError:
796
+ return default
797
+
798
+ def __setitem__(self, key: str, value: t.Any) -> None:
799
+ """
800
+ Data can both be updated via dot and dict notation.
801
+ """
802
+ return setattr(self, key, value)
803
+
804
+ def __int__(self) -> int:
805
+ """
806
+ Calling int on a model instance will return its id.
807
+ """
808
+ return getattr(self, "id", 0)
809
+
810
+ def __bool__(self) -> bool:
811
+ """
812
+ If the instance has an underlying row with data, it is truthy.
813
+ """
814
+ return bool(getattr(self, "_row", False))
815
+
816
+ def _ensure_matching_row(self) -> Row:
817
+ row = getattr(self, "_row", None)
818
+ return t.cast(Row, row) or throw(
819
+ EnvironmentError("Trying to access non-existant row. Maybe it was deleted or not yet initialized?")
820
+ )
821
+
822
+ def __repr__(self) -> str:
823
+ """
824
+ String representation of the model instance.
825
+ """
826
+ model_name = self.__class__.__name__
827
+ model_data = {}
828
+
829
+ if self._row:
830
+ model_data = self._row.as_json()
831
+
832
+ details = model_name
833
+ details += f"({model_data})"
834
+
835
+ if relationships := getattr(self, "_with", []):
836
+ details += f" + {relationships}"
837
+
838
+ return f"<{details}>"
839
+
840
+ # serialization
841
+ # underscore variants work for class instances (set up by _setup_instance_methods)
842
+
843
+ @classmethod
844
+ def as_dict(cls, flat: bool = False, sanitize: bool = True) -> AnyDict:
845
+ """
846
+ Dump the object to a plain dict.
847
+
848
+ Can be used as both a class or instance method:
849
+ - dumps the table info if it's a class
850
+ - dumps the row info if it's an instance (see _as_dict)
851
+ """
852
+ table = cls._ensure_table_defined()
853
+ result = table.as_dict(flat, sanitize)
854
+ return t.cast(AnyDict, result)
855
+
856
+ @classmethod
857
+ def as_json(cls, sanitize: bool = True, indent: t.Optional[int] = None, **kwargs: t.Any) -> str:
858
+ """
859
+ Dump the object to json.
860
+
861
+ Can be used as both a class or instance method:
862
+ - dumps the table info if it's a class
863
+ - dumps the row info if it's an instance (see _as_json)
864
+ """
865
+ data = cls.as_dict(sanitize=sanitize)
866
+ return as_json.encode(data, indent=indent, **kwargs)
867
+
868
+ @classmethod
869
+ def as_xml(cls, sanitize: bool = True) -> str: # pragma: no cover
870
+ """
871
+ Dump the object to xml.
872
+
873
+ Can be used as both a class or instance method:
874
+ - dumps the table info if it's a class
875
+ - dumps the row info if it's an instance (see _as_xml)
876
+ """
877
+ table = cls._ensure_table_defined()
878
+ return t.cast(str, table.as_xml(sanitize))
879
+
880
+ @classmethod
881
+ def as_yaml(cls, sanitize: bool = True) -> str:
882
+ """
883
+ Dump the object to yaml.
884
+
885
+ Can be used as both a class or instance method:
886
+ - dumps the table info if it's a class
887
+ - dumps the row info if it's an instance (see _as_yaml)
888
+ """
889
+ table = cls._ensure_table_defined()
890
+ return t.cast(str, table.as_yaml(sanitize))
891
+
892
+ def _as_dict(
893
+ self,
894
+ datetime_to_str: bool = False,
895
+ custom_types: t.Iterable[type] | type | None = None,
896
+ ) -> AnyDict:
897
+ row = self._ensure_matching_row()
898
+
899
+ result = row.as_dict(datetime_to_str=datetime_to_str, custom_types=custom_types)
900
+
901
+ def asdict_method(obj: t.Any) -> t.Any: # pragma: no cover
902
+ if hasattr(obj, "_as_dict"): # typedal
903
+ return obj._as_dict()
904
+ elif hasattr(obj, "as_dict"): # pydal
905
+ return obj.as_dict()
906
+ else: # something else??
907
+ return obj.__dict__
908
+
909
+ if _with := getattr(self, "_with", None):
910
+ for relationship in _with:
911
+ data = self.get(relationship)
912
+
913
+ if isinstance(data, list):
914
+ data = [asdict_method(_) for _ in data]
915
+ elif data:
916
+ data = asdict_method(data)
917
+
918
+ result[relationship] = data
919
+
920
+ return t.cast(AnyDict, result)
921
+
922
+ def _as_json(
923
+ self,
924
+ default: t.Callable[[t.Any], t.Any] = None,
925
+ indent: t.Optional[int] = None,
926
+ **kwargs: t.Any,
927
+ ) -> str:
928
+ data = self._as_dict()
929
+ return as_json.encode(data, default=default, indent=indent, **kwargs)
930
+
931
+ def _as_xml(self, sanitize: bool = True) -> str: # pragma: no cover
932
+ row = self._ensure_matching_row()
933
+ return t.cast(str, row.as_xml(sanitize))
934
+
935
+ # def _as_yaml(self, sanitize: bool = True) -> str:
936
+ # row = self._ensure_matching_row()
937
+ # return t.cast(str, row.as_yaml(sanitize))
938
+
939
+ def __setattr__(self, key: str, value: t.Any) -> None:
940
+ """
941
+ When setting a property on a Typed Table model instance, also update the underlying row.
942
+ """
943
+ if self._row and key in self._row.__dict__ and not callable(value):
944
+ # enables `row.key = value; row.update_record()`
945
+ self._row[key] = value
946
+
947
+ super().__setattr__(key, value)
948
+
949
+ @classmethod
950
+ def update(cls: t.Type[T_MetaInstance], query: Query, **fields: t.Any) -> T_MetaInstance | None:
951
+ """
952
+ Update one record.
953
+
954
+ Example:
955
+ MyTable.update(MyTable.id == 1, name="NewName") -> MyTable
956
+ """
957
+ # todo: update multiple?
958
+ if record := cls(query):
959
+ return record.update_record(**fields)
960
+ else:
961
+ return None
962
+
963
+ def _update(self: T_MetaInstance, **fields: t.Any) -> T_MetaInstance:
964
+ row = self._ensure_matching_row()
965
+ row.update(**fields)
966
+ self.__dict__.update(**fields)
967
+ return self
968
+
969
+ def _update_record(self: T_MetaInstance, **fields: t.Any) -> T_MetaInstance:
970
+ row = self._ensure_matching_row()
971
+ new_row = row.update_record(**fields)
972
+ self.update(**new_row)
973
+ return self
974
+
975
+ def update_record(self: T_MetaInstance, **fields: t.Any) -> T_MetaInstance: # pragma: no cover
976
+ """
977
+ Here as a placeholder for _update_record.
978
+
979
+ Will be replaced on instance creation!
980
+ """
981
+ return self._update_record(**fields)
982
+
983
+ def _delete_record(self) -> int:
984
+ """
985
+ Actual logic in `pydal.helpers.classes.RecordDeleter`.
986
+ """
987
+ row = self._ensure_matching_row()
988
+ result = row.delete_record()
989
+ self.__dict__ = {} # empty self, since row is no more.
990
+ self._row = None # just to be sure
991
+ self._setup_instance_methods()
992
+ # ^ instance methods might've been deleted by emptying dict,
993
+ # but we still want .as_dict to show an error, not the table's as_dict.
994
+ return t.cast(int, result)
995
+
996
+ def delete_record(self) -> int: # pragma: no cover
997
+ """
998
+ Here as a placeholder for _delete_record.
999
+
1000
+ Will be replaced on instance creation!
1001
+ """
1002
+ return self._delete_record()
1003
+
1004
+ # __del__ is also called on the end of a scope so don't remove records on every del!!
1005
+
1006
+ # pickling:
1007
+
1008
+ def __getstate__(self) -> AnyDict:
1009
+ """
1010
+ State to save when pickling.
1011
+
1012
+ Prevents db connection from being pickled.
1013
+ Similar to as_dict but without changing the data of the relationships (dill does that recursively)
1014
+ """
1015
+ row = self._ensure_matching_row()
1016
+ result: AnyDict = row.as_dict()
1017
+
1018
+ if _with := getattr(self, "_with", None):
1019
+ result["_with"] = _with
1020
+ for relationship in _with:
1021
+ data = self.get(relationship)
1022
+
1023
+ result[relationship] = data
1024
+
1025
+ result["_row"] = self._row.as_json() if self._row else ""
1026
+ return result
1027
+
1028
+ def __setstate__(self, state: AnyDict) -> None:
1029
+ """
1030
+ Used by dill when loading from a bytestring.
1031
+ """
1032
+ # as_dict also includes table info, so dump as json to only get the actual row data
1033
+ # then create a new (more empty) row object:
1034
+ state["_row"] = Row(json.loads(state["_row"]))
1035
+ self.__dict__ |= state
1036
+
1037
+ @classmethod
1038
+ def _sql(cls) -> str:
1039
+ """
1040
+ Generate SQL Schema for this table via pydal2sql (if 'migrations' extra is installed).
1041
+ """
1042
+ try:
1043
+ import pydal2sql
1044
+ except ImportError as e: # pragma: no cover
1045
+ raise RuntimeError("Can not generate SQL without the 'migration' extra or `pydal2sql` installed!") from e
1046
+
1047
+ return pydal2sql.generate_sql(cls)
1048
+
1049
+ def render(self, fields: list[Field] = None, compact: bool = False) -> t.Self:
1050
+ """
1051
+ Renders a copy of the object with potentially modified values.
1052
+
1053
+ Args:
1054
+ fields: A list of fields to render. Defaults to all representable fields in the table.
1055
+ compact: Whether to return only the value of the first field if there is only one field.
1056
+
1057
+ Returns:
1058
+ A copy of the object with potentially modified values.
1059
+ """
1060
+ row = copy.deepcopy(self)
1061
+ keys = list(row)
1062
+ if not fields:
1063
+ fields = [self._table[f] for f in self._table._fields]
1064
+ fields = [f for f in fields if isinstance(f, Field) and f.represent]
1065
+
1066
+ for field in fields:
1067
+ if field._table == self._table:
1068
+ row[field.name] = self._db.represent(
1069
+ "rows_render",
1070
+ field,
1071
+ row[field.name],
1072
+ row,
1073
+ )
1074
+ # else: relationship, different logic:
1075
+
1076
+ for relation_name in getattr(row, "_with", []):
1077
+ if relation := self._relationships.get(relation_name):
1078
+ relation_table = relation.table
1079
+ if isinstance(relation_table, str):
1080
+ relation_table = self._db[relation_table]
1081
+
1082
+ relation_row = row[relation_name]
1083
+
1084
+ if isinstance(relation_row, list):
1085
+ # list of rows
1086
+ combined = []
1087
+
1088
+ for related_og in relation_row:
1089
+ related = copy.deepcopy(related_og)
1090
+ for fieldname in related:
1091
+ field = relation_table[fieldname]
1092
+ related[field.name] = self._db.represent(
1093
+ "rows_render",
1094
+ field,
1095
+ related[field.name],
1096
+ related,
1097
+ )
1098
+ combined.append(related)
1099
+
1100
+ row[relation_name] = combined
1101
+ else:
1102
+ # 1 row
1103
+ for fieldname in relation_row:
1104
+ field = relation_table[fieldname]
1105
+ row[relation_name][fieldname] = self._db.represent(
1106
+ "rows_render",
1107
+ field,
1108
+ relation_row[field.name],
1109
+ relation_row,
1110
+ )
1111
+
1112
+ if compact and len(keys) == 1 and keys[0] != "_extra": # pragma: no cover
1113
+ return t.cast(t.Self, row[keys[0]])
1114
+ return row
1115
+
1116
+
1117
+ # backwards compat:
1118
+ TypedRow = TypedTable
1119
+
1120
+ # note: at the bottom to prevent circular import issues:
1121
+ from .fields import TypedField # noqa: E402
1122
+ from .query_builder import QueryBuilder # noqa: E402