Coverage for src/typedal/core.py: 100%
804 statements
« prev ^ index » next coverage.py v7.3.2, created at 2023-11-01 12:12 +0100
« prev ^ index » next coverage.py v7.3.2, created at 2023-11-01 12:12 +0100
1"""
2Core functionality of TypeDAL.
3"""
4import contextlib
5import csv
6import datetime as dt
7import inspect
8import json
9import math
10import types
11import typing
12import warnings
13from collections import defaultdict
14from decimal import Decimal
15from typing import Any, Optional
17import pydal
18from pydal._globals import DEFAULT
19from pydal.objects import Field as _Field
20from pydal.objects import Query as _Query
21from pydal.objects import Row, Rows
22from pydal.objects import Table as _Table
23from typing_extensions import Self
25from .helpers import (
26 DummyQuery,
27 all_annotations,
28 all_dict,
29 as_lambda,
30 extract_type_optional,
31 filter_out,
32 instanciate,
33 is_union,
34 looks_like,
35 mktable,
36 origin_is_subclass,
37 to_snake,
38 unwrap_type,
39)
40from .types import Expression, Field, PaginateDict, Pagination, Query, _Types
42# use typing.cast(type, ...) to make mypy happy with unions
43T_annotation = typing.Type[Any] | types.UnionType
44T_Query = typing.Union["Table", Query, bool, None, "TypedTable", typing.Type["TypedTable"]]
45T_Value = typing.TypeVar("T_Value") # actual type of the Field (via Generic)
46T_MetaInstance = typing.TypeVar("T_MetaInstance", bound="TypedTable") # bound="TypedTable"; bound="TableMeta"
47T = typing.TypeVar("T")
49BASIC_MAPPINGS: dict[T_annotation, str] = {
50 str: "string",
51 int: "integer",
52 bool: "boolean",
53 bytes: "blob",
54 float: "double",
55 object: "json",
56 Decimal: "decimal(10,2)",
57 dt.date: "date",
58 dt.time: "time",
59 dt.datetime: "datetime",
60}
63def is_typed_field(cls: Any) -> typing.TypeGuard["TypedField[Any]"]:
64 """
65 Is `cls` an instance or subclass of TypedField?
67 Deprecated
68 """
69 return (
70 isinstance(cls, TypedField)
71 or isinstance(typing.get_origin(cls), type)
72 and issubclass(typing.get_origin(cls), TypedField)
73 )
76JOIN_OPTIONS = typing.Literal["left", "inner", None]
77DEFAULT_JOIN_OPTION: JOIN_OPTIONS = "left"
79# table-ish paramter:
80P_Table = typing.Union[typing.Type["TypedTable"], pydal.objects.Table]
82Condition: typing.TypeAlias = typing.Optional[
83 typing.Callable[
84 # self, other -> Query
85 [P_Table, P_Table],
86 Query | bool,
87 ]
88]
90OnQuery: typing.TypeAlias = typing.Optional[
91 typing.Callable[
92 # self, other -> list of .on statements
93 [P_Table, P_Table],
94 list[Expression],
95 ]
96]
98To_Type = typing.TypeVar("To_Type", type[Any], typing.Type[Any], str)
101class Relationship(typing.Generic[To_Type]):
102 """
103 Define a relationship to another table.
104 """
106 _type: To_Type
107 table: typing.Type["TypedTable"] | type | str
108 condition: Condition
109 on: OnQuery
110 multiple: bool
111 join: JOIN_OPTIONS
113 def __init__(
114 self,
115 _type: To_Type,
116 condition: Condition = None,
117 join: JOIN_OPTIONS = None,
118 on: OnQuery = None,
119 ):
120 """
121 Should not be called directly, use relationship() instead!
122 """
123 if condition and on:
124 warnings.warn(f"Relation | Both specified! {condition=} {on=} {_type=}")
125 raise ValueError("Please specify either a condition or an 'on' statement for this relationship!")
127 self._type = _type
128 self.condition = condition
129 self.join = "left" if on else join # .on is always left join!
130 self.on = on
132 if args := typing.get_args(_type):
133 self.table = unwrap_type(args[0])
134 self.multiple = True
135 else:
136 self.table = _type
137 self.multiple = False
139 if isinstance(self.table, str):
140 self.table = TypeDAL.to_snake(self.table)
142 def clone(self, **update: Any) -> "Relationship[To_Type]":
143 """
144 Create a copy of the relationship, possibly updated.
145 """
146 return self.__class__(
147 update.get("_type") or self._type,
148 update.get("condition") or self.condition,
149 update.get("join") or self.join,
150 update.get("on") or self.on,
151 )
153 def __repr__(self) -> str:
154 """
155 Representation of the relationship.
156 """
157 if callback := self.condition or self.on:
158 src_code = inspect.getsource(callback).strip()
159 else:
160 cls_name = self._type if isinstance(self._type, str) else self._type.__name__ # type: ignore
161 src_code = f"to {cls_name} (missing condition)"
163 join = f":{self.join}" if self.join else ""
164 return f"<Relationship{join} {src_code}>"
166 def get_table(self, db: "TypeDAL") -> typing.Type["TypedTable"]:
167 """
168 Get the table this relationship is bound to.
169 """
170 table = self.table # can be a string because db wasn't available yet
171 if isinstance(table, str):
172 if mapped := db._class_map.get(table):
173 # yay
174 return mapped
176 # boo, fall back to untyped table but pretend it is typed:
177 return typing.cast(typing.Type["TypedTable"], db[table]) # eh close enough!
179 return table
181 def get_table_name(self) -> str:
182 """
183 Get the name of the table this relationship is bound to.
184 """
185 if isinstance(self.table, str):
186 return self.table
188 if isinstance(self.table, pydal.objects.Table):
189 return str(self.table)
191 # else: typed table
192 try:
193 table = self.table._ensure_table_defined() if issubclass(self.table, TypedTable) else self.table
194 except Exception: # pragma: no cover
195 table = self.table
197 return str(table)
199 def __get__(self, instance: Any, owner: Any) -> typing.Optional[list[Any]] | "Relationship[To_Type]":
200 """
201 Relationship is a descriptor class, which can be returned from a class but not an instance.
203 For an instance, using .join() will replace the Relationship with the actual data.
204 If you forgot to join, a warning will be shown and empty data will be returned.
205 """
206 if not instance:
207 # relationship queried on class, that's allowed
208 return self
210 warnings.warn(
211 "Trying to get data from a relationship object! Did you forget to join it?", category=RuntimeWarning
212 )
213 if self.multiple:
214 return []
215 else:
216 return None
219def relationship(
220 _type: To_Type, condition: Condition = None, join: JOIN_OPTIONS = None, on: OnQuery = None
221) -> Relationship[To_Type]:
222 """
223 Define a relationship to another table, when its id is not stored in the current table.
225 Example:
226 class User(TypedTable):
227 name: str
229 posts = relationship(list["Post"], condition=lambda self, post: self.id == post.author, join='left')
231 class Post(TypedTable):
232 title: str
233 author: User
235 User.join("posts").first() # User instance with list[Post] in .posts
237 Here, Post stores the User ID, but `relationship(list["Post"])` still allows you to get the user's posts.
238 In this case, the join strategy is set to LEFT so users without posts are also still selected.
240 For complex queries with a pivot table, a `on` can be set insteaad of `condition`:
241 class User(TypedTable):
242 ...
244 tags = relationship(list["Tag"], on=lambda self, tag: [
245 Tagged.on(Tagged.entity == entity.gid),
246 Tag.on((Tagged.tag == tag.id)),
247 ])
249 If you'd try to capture this in a single 'condition', pydal would create a cross join which is much less efficient.
250 """
251 return Relationship(_type, condition, join, on)
254def _generate_relationship_condition(
255 _: typing.Type["TypedTable"], key: str, field: typing.Union["TypedField[Any]", "Table", typing.Type["TypedTable"]]
256) -> Condition:
257 origin = typing.get_origin(field)
258 # else: generic
260 if origin == list:
261 # field = typing.get_args(field)[0] # actual field
262 # return lambda _self, _other: cls[key].contains(field)
264 return lambda _self, _other: _self[key].contains(_other.id)
265 else:
266 # normal reference
267 # return lambda _self, _other: cls[key] == field.id
268 return lambda _self, _other: _self[key] == _other.id
271def to_relationship(
272 cls: typing.Type["TypedTable"] | type[Any],
273 key: str,
274 field: typing.Union["TypedField[Any]", "Table", typing.Type["TypedTable"]],
275) -> typing.Optional[Relationship[Any]]:
276 """
277 Used to automatically create relationship instance for reference fields.
279 Example:
280 class MyTable(TypedTable):
281 reference: OtherTable
283 `reference` contains the id of an Other Table row.
284 MyTable.relationships should have 'reference' as a relationship, so `MyTable.join('reference')` should work.
286 This function will automatically perform this logic (called in db.define):
287 to_relationship(MyTable, 'reference', OtherTable) -> Relationship[OtherTable]
289 Also works for list:reference (list[OtherTable]) and TypedField[OtherTable].
290 """
291 if looks_like(field, TypedField):
292 if args := typing.get_args(field):
293 field = args[0]
294 else:
295 # weird
296 return None
298 field, optional = extract_type_optional(field)
300 try:
301 condition = _generate_relationship_condition(cls, key, field)
302 except Exception as e: # pragma: no cover
303 warnings.warn("Could not generate Relationship condition", source=e)
304 condition = None
306 if not condition: # pragma: no cover
307 # something went wrong, not a valid relationship
308 warnings.warn(f"Invalid relationship for {cls.__name__}.{key}: {field}")
309 return None
311 join = "left" if optional or typing.get_origin(field) == list else "inner"
313 return Relationship(typing.cast(type[TypedTable], field), condition, typing.cast(JOIN_OPTIONS, join))
316class TypeDAL(pydal.DAL): # type: ignore
317 """
318 Drop-in replacement for pyDAL with layer to convert class-based table definitions to classical pydal define_tables.
319 """
321 # dal: Table
322 # def __init__(self,
323 # uri="sqlite://dummy.db",
324 # pool_size=0,
325 # folder=None,
326 # db_codec="UTF-8",
327 # check_reserved=None,
328 # migrate=True,
329 # fake_migrate=False,
330 # migrate_enabled=True,
331 # fake_migrate_all=False,
332 # decode_credentials=False,
333 # driver_args=None,
334 # adapter_args=None,
335 # attempts=5,
336 # auto_import=False,
337 # bigint_id=False,
338 # debug=False,
339 # lazy_tables=False,
340 # db_uid=None,
341 # after_connection=None,
342 # tables=None,
343 # ignore_field_case=True,
344 # entity_quoting=True,
345 # table_hash=None,
346 # ):
347 # super().__init__(
348 # uri,
349 # pool_size,
350 # folder,
351 # db_codec,
352 # check_reserved,
353 # migrate,
354 # fake_migrate,
355 # migrate_enabled,
356 # fake_migrate_all,
357 # decode_credentials,
358 # driver_args,
359 # adapter_args,
360 # attempts,
361 # auto_import,
362 # bigint_id,
363 # debug,
364 # lazy_tables,
365 # db_uid,
366 # after_connection,
367 # tables,
368 # ignore_field_case,
369 # entity_quoting,
370 # table_hash,
371 # )
372 # self.representers[TypedField] = lambda x: x
374 default_kwargs: typing.ClassVar[typing.Dict[str, Any]] = {
375 # fields are 'required' (notnull) by default:
376 "notnull": True,
377 }
379 # maps table name to typedal class, for resolving future references
380 _class_map: typing.ClassVar[dict[str, typing.Type["TypedTable"]]] = {}
382 def _define(self, cls: typing.Type[T]) -> typing.Type[T]:
383 # when __future__.annotations is implemented, cls.__annotations__ will not work anymore as below.
384 # proper way to handle this would be (but gives error right now due to Table implementing magic methods):
385 # typing.get_type_hints(cls, globalns=None, localns=None)
387 # dirty way (with evil eval):
388 # [eval(v) for k, v in cls.__annotations__.items()]
389 # this however also stops working when variables outside this scope or even references to other
390 # objects are used. So for now, this package will NOT work when from __future__ import annotations is used,
391 # and might break in the future, when this annotations behavior is enabled by default.
393 # non-annotated variables have to be passed to define_table as kwargs
394 full_dict = all_dict(cls) # includes properties from parents (e.g. useful for mixins)
396 tablename = self.to_snake(cls.__name__)
397 # grab annotations of cls and it's parents:
398 annotations = all_annotations(cls)
399 # extend with `prop = TypedField()` 'annotations':
400 annotations |= {k: typing.cast(type, v) for k, v in full_dict.items() if is_typed_field(v)}
401 # remove internal stuff:
402 annotations = {k: v for k, v in annotations.items() if not k.startswith("_")}
404 typedfields: dict[str, TypedField[Any]] = {
405 k: instanciate(v, True) for k, v in annotations.items() if is_typed_field(v)
406 }
408 relationships: dict[str, type[Relationship[Any]]] = filter_out(annotations, Relationship)
410 fields = {fname: self._to_field(fname, ftype) for fname, ftype in annotations.items()}
412 # ! dont' use full_dict here:
413 other_kwargs = {k: v for k, v in cls.__dict__.items() if k not in annotations and not k.startswith("_")}
415 for key in typedfields.keys() - full_dict.keys():
416 # typed fields that don't haven't been added to the object yet
417 setattr(cls, key, typedfields[key])
419 # start with base classes and overwrite with current class:
420 relationships = filter_out(full_dict, Relationship) | relationships | filter_out(other_kwargs, Relationship)
422 # DEPRECATED: Relationship as annotation is currently not supported!
423 # ensure they are all instances and
424 # not mix of instances (`= relationship()`) and classes (`: Relationship[...]`):
425 # relationships = {
426 # k: v if isinstance(v, Relationship) else to_relationship(cls, k, v) for k, v in relationships.items()
427 # }
429 # keys of implicit references (also relationships):
430 reference_field_keys = [k for k, v in fields.items() if v.type.split(" ")[0] in ("list:reference", "reference")]
432 # add implicit relationships:
433 # User; list[User]; TypedField[User]; TypedField[list[User]]
434 relationships |= {
435 k: new_relationship
436 for k in reference_field_keys
437 if k not in relationships and (new_relationship := to_relationship(cls, k, annotations[k]))
438 }
440 table: Table = self.define_table(tablename, *fields.values(), **other_kwargs)
442 for name, typed_field in typedfields.items():
443 field = fields[name]
444 typed_field.bind(field, table)
446 if issubclass(cls, TypedTable):
447 cls.__set_internals__(
448 db=self,
449 table=table,
450 # by now, all relationships should be instances!
451 relationships=typing.cast(dict[str, Relationship[Any]], relationships),
452 )
453 self._class_map[str(table)] = cls
454 else:
455 warnings.warn("db.define used without inheriting TypedTable. This could lead to strange problems!")
457 return cls
459 @typing.overload
460 def define(self, maybe_cls: None = None) -> typing.Callable[[typing.Type[T]], typing.Type[T]]:
461 """
462 Typing Overload for define without a class.
464 @db.define()
465 class MyTable(TypedTable): ...
466 """
468 @typing.overload
469 def define(self, maybe_cls: typing.Type[T]) -> typing.Type[T]:
470 """
471 Typing Overload for define with a class.
473 @db.define
474 class MyTable(TypedTable): ...
475 """
477 def define(
478 self, maybe_cls: typing.Type[T] | None = None
479 ) -> typing.Type[T] | typing.Callable[[typing.Type[T]], typing.Type[T]]:
480 """
481 Can be used as a decorator on a class that inherits `TypedTable`, \
482 or as a regular method if you need to define your classes before you have access to a 'db' instance.
484 Example:
485 @db.define
486 class Person(TypedTable):
487 ...
489 class Article(TypedTable):
490 ...
492 # at a later time:
493 db.define(Article)
495 Returns:
496 the result of pydal.define_table
497 """
499 def wrapper(cls: typing.Type[T]) -> typing.Type[T]:
500 return self._define(cls)
502 if maybe_cls:
503 return wrapper(maybe_cls)
505 return wrapper
507 # def drop(self, table_name: str) -> None:
508 # """
509 # Remove a table by name (both on the database level and the typedal level).
510 # """
511 # # drop calls TypedTable.drop() and removes it from the `_class_map`
512 # if cls := self._class_map.pop(table_name, None):
513 # cls.drop()
515 # def drop_all(self, max_retries: int = None) -> None:
516 # """
517 # Remove all tables and keep doing so until everything is gone!
518 # """
519 # retries = 0
520 # if max_retries is None:
521 # max_retries = len(self.tables)
522 #
523 # while self.tables:
524 # retries += 1
525 # for table in self.tables:
526 # self.drop(table)
527 #
528 # if retries > max_retries:
529 # raise RuntimeError("Could not delete all tables")
531 def __call__(self, *_args: T_Query, **kwargs: Any) -> "TypedSet":
532 """
533 A db instance can be called directly to perform a query.
535 Usually, only a query is passed.
537 Example:
538 db(query).select()
540 """
541 args = list(_args)
542 if args:
543 cls = args[0]
544 if isinstance(cls, bool):
545 raise ValueError("Don't actually pass a bool to db()! Use a query instead.")
547 if isinstance(cls, type) and issubclass(type(cls), type) and issubclass(cls, TypedTable):
548 # table defined without @db.define decorator!
549 _cls: typing.Type[TypedTable] = cls
550 args[0] = _cls.id != None
552 _set = super().__call__(*args, **kwargs)
553 return typing.cast(TypedSet, _set)
555 @classmethod
556 def _build_field(cls, name: str, _type: str, **kw: Any) -> Field:
557 return Field(name, _type, **{**cls.default_kwargs, **kw})
559 @classmethod
560 def _annotation_to_pydal_fieldtype(
561 cls, _ftype: T_annotation, mut_kw: typing.MutableMapping[str, Any]
562 ) -> Optional[str]:
563 # ftype can be a union or type. typing.cast is sometimes used to tell mypy when it's not a union.
564 ftype = typing.cast(type, _ftype) # cast from typing.Type to type to make mypy happy)
566 if isinstance(ftype, str):
567 # extract type from string
568 ftype = typing.get_args(typing.Type[ftype])[0]._evaluate(
569 localns=locals(), globalns=globals(), recursive_guard=frozenset()
570 )
572 if mapping := BASIC_MAPPINGS.get(ftype):
573 # basi types
574 return mapping
575 elif isinstance(ftype, _Table):
576 # db.table
577 return f"reference {ftype._tablename}"
578 elif issubclass(type(ftype), type) and issubclass(ftype, TypedTable):
579 # SomeTable
580 snakename = cls.to_snake(ftype.__name__)
581 return f"reference {snakename}"
582 elif isinstance(ftype, TypedField):
583 # FieldType(type, ...)
584 return ftype._to_field(mut_kw)
585 elif origin_is_subclass(ftype, TypedField):
586 # TypedField[int]
587 return cls._annotation_to_pydal_fieldtype(typing.get_args(ftype)[0], mut_kw)
588 elif isinstance(ftype, types.GenericAlias) and typing.get_origin(ftype) in (list, TypedField):
589 # list[str] -> str -> string -> list:string
590 _child_type = typing.get_args(ftype)[0]
591 _child_type = cls._annotation_to_pydal_fieldtype(_child_type, mut_kw)
592 return f"list:{_child_type}"
593 elif is_union(ftype):
594 # str | int -> UnionType
595 # typing.Union[str | int] -> typing._UnionGenericAlias
597 # Optional[type] == type | None
599 match typing.get_args(ftype):
600 case (_child_type, _Types.NONETYPE) | (_Types.NONETYPE, _child_type):
601 # good union of Nullable
603 # if a field is optional, it is nullable:
604 mut_kw["notnull"] = False
605 return cls._annotation_to_pydal_fieldtype(_child_type, mut_kw)
606 case _:
607 # two types is not supported by the db!
608 return None
609 else:
610 return None
612 @classmethod
613 def _to_field(cls, fname: str, ftype: type, **kw: Any) -> Field:
614 """
615 Convert a annotation into a pydal Field.
617 Args:
618 fname: name of the property
619 ftype: annotation of the property
620 kw: when using TypedField or a function returning it (e.g. StringField),
621 keyword args can be used to pass any other settings you would normally to a pydal Field
623 -> pydal.Field(fname, ftype, **kw)
625 Example:
626 class MyTable:
627 fname: ftype
628 id: int
629 name: str
630 reference: Table
631 other: TypedField(str, default="John Doe") # default will be in kwargs
632 """
633 fname = cls.to_snake(fname)
635 if converted_type := cls._annotation_to_pydal_fieldtype(ftype, kw):
636 return cls._build_field(fname, converted_type, **kw)
637 else:
638 raise NotImplementedError(f"Unsupported type {ftype}/{type(ftype)}")
640 @staticmethod
641 def to_snake(camel: str) -> str:
642 """
643 Moved to helpers, kept as a static method for legacy reasons.
644 """
645 return to_snake(camel)
648class TableProtocol(typing.Protocol): # pragma: no cover
649 """
650 Make mypy happy.
651 """
653 id: int # noqa: A003
655 def __getitem__(self, item: str) -> Field:
656 """
657 Tell mypy a Table supports dictionary notation for columns.
658 """
661class Table(_Table, TableProtocol): # type: ignore
662 """
663 Make mypy happy.
664 """
667class TableMeta(type):
668 """
669 This metaclass contains functionality on table classes, that doesn't exist on its instances.
671 Example:
672 class MyTable(TypedTable):
673 some_field: TypedField[int]
675 MyTable.update_or_insert(...) # should work
677 MyTable.some_field # -> Field, can be used to query etc.
679 row = MyTable.first() # returns instance of MyTable
681 # row.update_or_insert(...) # shouldn't work!
683 row.some_field # -> int, with actual data
685 """
687 # set up by db.define:
688 # _db: TypeDAL | None = None
689 # _table: Table | None = None
690 _db: TypeDAL | None = None
691 _table: Table | None = None
692 _relationships: dict[str, Relationship[Any]] | None = None
694 #########################
695 # TypeDAL custom logic: #
696 #########################
698 def __set_internals__(self, db: pydal.DAL, table: Table, relationships: dict[str, Relationship[Any]]) -> None:
699 """
700 Store the related database and pydal table for later usage.
701 """
702 self._db = db
703 self._table = table
704 self._relationships = relationships
706 def __getattr__(self, col: str) -> Optional[Field]:
707 """
708 Magic method used by TypedTableMeta to get a database field with dot notation on a class.
710 Example:
711 SomeTypedTable.col -> db.table.col (via TypedTableMeta.__getattr__)
713 """
714 if self._table:
715 return getattr(self._table, col, None)
717 return None
719 def _ensure_table_defined(self) -> Table:
720 if not self._table:
721 raise EnvironmentError("@define or db.define is not called on this class yet!")
722 return self._table
724 def __iter__(self) -> typing.Generator[Field, None, None]:
725 """
726 Loop through the columns of this model.
727 """
728 table = self._ensure_table_defined()
729 yield from iter(table)
731 def __getitem__(self, item: str) -> Field:
732 """
733 Allow dict notation to get a column of this table (-> Field instance).
734 """
735 table = self._ensure_table_defined()
736 return table[item]
738 def __str__(self) -> str:
739 """
740 Normally, just returns the underlying table name, but with a fallback if the model is unbound.
741 """
742 if self._table:
743 return str(self._table)
744 else:
745 return f"<unbound table {self.__name__}>"
747 def from_row(self: typing.Type[T_MetaInstance], row: pydal.objects.Row) -> T_MetaInstance:
748 """
749 Create a model instance from a pydal row.
750 """
751 return self(row)
753 def all(self: typing.Type[T_MetaInstance]) -> "TypedRows[T_MetaInstance]": # noqa: A003
754 """
755 Return all rows for this model.
756 """
757 return self.collect()
759 def __json__(self: typing.Type[T_MetaInstance], instance: T_MetaInstance | None = None) -> dict[str, Any]:
760 """
761 Convert to a json-dumpable dict.
763 as_dict is not fully json-dumpable, so use as_json and json.loads to ensure it is dumpable (and loadable).
764 todo: can this be optimized?
766 See Also:
767 https://github.com/jeff-hykin/json_fix
768 """
769 string = instance.as_json() if instance else self.as_json()
771 return typing.cast(dict[str, Any], json.loads(string))
773 def get_relationships(self) -> dict[str, Relationship[Any]]:
774 """
775 Return the registered relationships of the current model.
776 """
777 return self._relationships or {}
779 ##########################
780 # TypeDAL Modified Logic #
781 ##########################
783 def insert(self: typing.Type[T_MetaInstance], **fields: Any) -> T_MetaInstance:
784 """
785 This is only called when db.define is not used as a decorator.
787 cls.__table functions as 'self'
789 Args:
790 **fields: anything you want to insert in the database
792 Returns: the ID of the new row.
794 """
795 table = self._ensure_table_defined()
797 result = table.insert(**fields)
798 # it already is an int but mypy doesn't understand that
799 return self(result)
801 def _insert(self, **fields: Any) -> str:
802 table = self._ensure_table_defined()
804 return str(table._insert(**fields))
806 def bulk_insert(self: typing.Type[T_MetaInstance], items: list[dict[str, Any]]) -> "TypedRows[T_MetaInstance]":
807 """
808 Insert multiple rows, returns a TypedRows set of new instances.
809 """
810 table = self._ensure_table_defined()
811 result = table.bulk_insert(items)
812 return self.where(lambda row: row.id.belongs(result)).collect()
814 def update_or_insert(
815 self: typing.Type[T_MetaInstance], query: T_Query | dict[str, Any] = DEFAULT, **values: Any
816 ) -> T_MetaInstance:
817 """
818 Update a row if query matches, else insert a new one.
820 Returns the created or updated instance.
821 """
822 table = self._ensure_table_defined()
824 if query is DEFAULT:
825 record = table(**values)
826 elif isinstance(query, dict):
827 record = table(**query)
828 else:
829 record = table(query)
831 if not record:
832 return self.insert(**values)
834 record.update_record(**values)
835 return self(record)
837 def validate_and_insert(
838 self: typing.Type[T_MetaInstance], **fields: Any
839 ) -> tuple[Optional[T_MetaInstance], Optional[dict[str, str]]]:
840 """
841 Validate input data and then insert a row.
843 Returns a tuple of (the created instance, a dict of errors).
844 """
845 table = self._ensure_table_defined()
846 result = table.validate_and_insert(**fields)
847 if row_id := result.get("id"):
848 return self(row_id), None
849 else:
850 return None, result.get("errors")
852 def validate_and_update(
853 self: typing.Type[T_MetaInstance], query: Query, **fields: Any
854 ) -> tuple[Optional[T_MetaInstance], Optional[dict[str, str]]]:
855 """
856 Validate input data and then update max 1 row.
858 Returns a tuple of (the updated instance, a dict of errors).
859 """
860 table = self._ensure_table_defined()
862 try:
863 result = table.validate_and_update(query, **fields)
864 except Exception as e:
865 result = {"errors": {"exception": str(e)}}
867 if errors := result.get("errors"):
868 return None, errors
869 elif row_id := result.get("id"):
870 return self(row_id), None
871 else: # pragma: no cover
872 # update on query without result (shouldnt happen)
873 return None, None
875 def validate_and_update_or_insert(
876 self: typing.Type[T_MetaInstance], query: Query, **fields: Any
877 ) -> tuple[Optional[T_MetaInstance], Optional[dict[str, str]]]:
878 """
879 Validate input data and then update_and_insert (on max 1 row).
881 Returns a tuple of (the updated/created instance, a dict of errors).
882 """
883 table = self._ensure_table_defined()
884 result = table.validate_and_update_or_insert(query, **fields)
886 if errors := result.get("errors"):
887 return None, errors
888 elif row_id := result.get("id"):
889 return self(row_id), None
890 else: # pragma: no cover
891 # update on query without result (shouldnt happen)
892 return None, None
894 def select(self: typing.Type[T_MetaInstance], *a: Any, **kw: Any) -> "QueryBuilder[T_MetaInstance]":
895 """
896 See QueryBuilder.select!
897 """
898 return QueryBuilder(self).select(*a, **kw)
900 def paginate(self: typing.Type[T_MetaInstance], limit: int, page: int = 1) -> "PaginatedRows[T_MetaInstance]":
901 """
902 See QueryBuilder.paginate!
903 """
904 return QueryBuilder(self).paginate(limit=limit, page=page)
906 def chunk(
907 self: typing.Type[T_MetaInstance], chunk_size: int
908 ) -> typing.Generator["TypedRows[T_MetaInstance]", Any, None]:
909 """
910 See QueryBuilder.chunk!
911 """
912 return QueryBuilder(self).chunk(chunk_size)
914 def where(self: typing.Type[T_MetaInstance], *a: Any, **kw: Any) -> "QueryBuilder[T_MetaInstance]":
915 """
916 See QueryBuilder.where!
917 """
918 return QueryBuilder(self).where(*a, **kw)
920 def count(self: typing.Type[T_MetaInstance]) -> int:
921 """
922 See QueryBuilder.count!
923 """
924 return QueryBuilder(self).count()
926 def first(self: typing.Type[T_MetaInstance]) -> T_MetaInstance | None:
927 """
928 See QueryBuilder.first!
929 """
930 return QueryBuilder(self).first()
932 def join(
933 self: typing.Type[T_MetaInstance],
934 *fields: str | typing.Type["TypedTable"],
935 method: JOIN_OPTIONS = None,
936 on: OnQuery | list[Expression] | Expression = None,
937 condition: Condition = None,
938 ) -> "QueryBuilder[T_MetaInstance]":
939 """
940 See QueryBuilder.join!
941 """
942 return QueryBuilder(self).join(*fields, on=on, condition=condition, method=method)
944 def collect(self: typing.Type[T_MetaInstance], verbose: bool = False) -> "TypedRows[T_MetaInstance]":
945 """
946 See QueryBuilder.collect!
947 """
948 return QueryBuilder(self).collect(verbose=verbose)
950 @property
951 def ALL(cls) -> pydal.objects.SQLALL:
952 """
953 Select all fields for this table.
954 """
955 table = cls._ensure_table_defined()
957 return table.ALL
959 ##########################
960 # TypeDAL Shadowed Logic #
961 ##########################
962 fields: list[str]
964 # other table methods:
966 def drop(self, mode: str = "") -> None:
967 """
968 Remove the underlying table.
969 """
970 table = self._ensure_table_defined()
971 table.drop(mode)
973 def create_index(self, name: str, *fields: Field | str, **kwargs: Any) -> bool:
974 """
975 Add an index on some columns of this table.
976 """
977 table = self._ensure_table_defined()
978 result = table.create_index(name, *fields, **kwargs)
979 return typing.cast(bool, result)
981 def drop_index(self, name: str, if_exists: bool = False) -> bool:
982 """
983 Remove an index from this table.
984 """
985 table = self._ensure_table_defined()
986 result = table.drop_index(name, if_exists)
987 return typing.cast(bool, result)
989 def import_from_csv_file(
990 self,
991 csvfile: typing.TextIO,
992 id_map: dict[str, str] = None,
993 null: Any = "<NULL>",
994 unique: str = "uuid",
995 id_offset: dict[str, int] = None, # id_offset used only when id_map is None
996 transform: typing.Callable[[dict[Any, Any]], dict[Any, Any]] = None,
997 validate: bool = False,
998 encoding: str = "utf-8",
999 delimiter: str = ",",
1000 quotechar: str = '"',
1001 quoting: int = csv.QUOTE_MINIMAL,
1002 restore: bool = False,
1003 **kwargs: Any,
1004 ) -> None:
1005 """
1006 Load a csv file into the database.
1007 """
1008 table = self._ensure_table_defined()
1009 table.import_from_csv_file(
1010 csvfile,
1011 id_map=id_map,
1012 null=null,
1013 unique=unique,
1014 id_offset=id_offset,
1015 transform=transform,
1016 validate=validate,
1017 encoding=encoding,
1018 delimiter=delimiter,
1019 quotechar=quotechar,
1020 quoting=quoting,
1021 restore=restore,
1022 **kwargs,
1023 )
1025 def on(self, query: Query | bool) -> Expression:
1026 """
1027 Shadow Table.on.
1029 Used for joins.
1031 See Also:
1032 http://web2py.com/books/default/chapter/29/06/the-database-abstraction-layer?search=export_to_csv_file#One-to-many-relation
1033 """
1034 table = self._ensure_table_defined()
1035 return typing.cast(Expression, table.on(query))
1037 def with_alias(self, alias: str) -> _Table:
1038 """
1039 Shadow Table.with_alias.
1041 Useful for joins when joining the same table multiple times.
1043 See Also:
1044 http://web2py.com/books/default/chapter/29/06/the-database-abstraction-layer?search=export_to_csv_file#One-to-many-relation
1045 """
1046 table = self._ensure_table_defined()
1047 return table.with_alias(alias)
1049 # @typing.dataclass_transform()
1052class TypedTable(metaclass=TableMeta):
1053 """
1054 Enhanded modeling system on top of pydal's Table that adds typing and additional functionality.
1055 """
1057 # set up by 'new':
1058 _row: Row | None = None
1060 _with: list[str]
1062 id: "TypedField[int]" # noqa: A003
1064 def _setup_instance_methods(self) -> None:
1065 self.as_dict = self._as_dict # type: ignore
1066 self.__json__ = self.as_json = self._as_json # type: ignore
1067 # self.as_yaml = self._as_yaml # type: ignore
1068 self.as_xml = self._as_xml # type: ignore
1070 self.update = self._update # type: ignore
1072 self.delete_record = self._delete_record # type: ignore
1073 self.update_record = self._update_record # type: ignore
1075 def __new__(
1076 cls, row_or_id: typing.Union[Row, Query, pydal.objects.Set, int, str, None, "TypedTable"] = None, **filters: Any
1077 ) -> "TypedTable":
1078 """
1079 Create a Typed Rows model instance from an existing row, ID or query.
1081 Examples:
1082 MyTable(1)
1083 MyTable(id=1)
1084 MyTable(MyTable.id == 1)
1085 """
1086 table = cls._ensure_table_defined()
1088 if isinstance(row_or_id, TypedTable):
1089 # existing typed table instance!
1090 return row_or_id
1091 elif isinstance(row_or_id, pydal.objects.Row):
1092 row = row_or_id
1093 elif row_or_id is not None:
1094 row = table(row_or_id, **filters)
1095 else:
1096 row = table(**filters)
1098 if not row:
1099 return None # type: ignore
1101 inst = super().__new__(cls)
1102 inst._row = row
1103 inst.__dict__.update(row)
1104 inst._setup_instance_methods()
1105 return inst
1107 def __iter__(self) -> typing.Generator[Any, None, None]:
1108 """
1109 Allows looping through the columns.
1110 """
1111 row = self._ensure_matching_row()
1112 yield from iter(row)
1114 def __getitem__(self, item: str) -> Any:
1115 """
1116 Allows dictionary notation to get columns.
1117 """
1118 if item in self.__dict__:
1119 return self.__dict__.get(item)
1121 # fallback to lookup in row
1122 if self._row:
1123 return self._row[item]
1125 # nothing found!
1126 raise KeyError(item)
1128 def __getattr__(self, item: str) -> Any:
1129 """
1130 Allows dot notation to get columns.
1131 """
1132 if value := self.get(item):
1133 return value
1135 raise AttributeError(item)
1137 def get(self, item: str, default: Any = None) -> Any:
1138 """
1139 Try to get a column from this instance, else return default.
1140 """
1141 try:
1142 return self.__getitem__(item)
1143 except KeyError:
1144 return default
1146 def __setitem__(self, key: str, value: Any) -> None:
1147 """
1148 Data can both be updated via dot and dict notation.
1149 """
1150 return setattr(self, key, value)
1152 def __int__(self) -> int:
1153 """
1154 Calling int on a model instance will return its id.
1155 """
1156 return getattr(self, "id", 0)
1158 def __bool__(self) -> bool:
1159 """
1160 If the instance has an underlying row with data, it is truthy.
1161 """
1162 return bool(getattr(self, "_row", False))
1164 def _ensure_matching_row(self) -> Row:
1165 if not getattr(self, "_row", None):
1166 raise EnvironmentError("Trying to access non-existant row. Maybe it was deleted or not yet initialized?")
1167 return self._row
1169 def __repr__(self) -> str:
1170 """
1171 String representation of the model instance.
1172 """
1173 model_name = self.__class__.__name__
1174 model_data = {}
1176 if self._row:
1177 model_data = self._row.as_json()
1179 details = model_name
1180 details += f"({model_data})"
1182 if relationships := getattr(self, "_with", []):
1183 details += f" + {relationships}"
1185 return f"<{details}>"
1187 # serialization
1188 # underscore variants work for class instances (set up by _setup_instance_methods)
1190 @classmethod
1191 def as_dict(cls, flat: bool = False, sanitize: bool = True) -> dict[str, Any]:
1192 """
1193 Dump the object to a plain dict.
1195 Can be used as both a class or instance method:
1196 - dumps the table info if it's a class
1197 - dumps the row info if it's an instance (see _as_dict)
1198 """
1199 table = cls._ensure_table_defined()
1200 result = table.as_dict(flat, sanitize)
1201 return typing.cast(dict[str, Any], result)
1203 @classmethod
1204 def as_json(cls, sanitize: bool = True) -> str:
1205 """
1206 Dump the object to json.
1208 Can be used as both a class or instance method:
1209 - dumps the table info if it's a class
1210 - dumps the row info if it's an instance (see _as_json)
1211 """
1212 table = cls._ensure_table_defined()
1213 return typing.cast(str, table.as_json(sanitize))
1215 @classmethod
1216 def as_xml(cls, sanitize: bool = True) -> str: # pragma: no cover
1217 """
1218 Dump the object to xml.
1220 Can be used as both a class or instance method:
1221 - dumps the table info if it's a class
1222 - dumps the row info if it's an instance (see _as_xml)
1223 """
1224 table = cls._ensure_table_defined()
1225 return typing.cast(str, table.as_xml(sanitize))
1227 @classmethod
1228 def as_yaml(cls, sanitize: bool = True) -> str:
1229 """
1230 Dump the object to yaml.
1232 Can be used as both a class or instance method:
1233 - dumps the table info if it's a class
1234 - dumps the row info if it's an instance (see _as_yaml)
1235 """
1236 table = cls._ensure_table_defined()
1237 return typing.cast(str, table.as_yaml(sanitize))
1239 def _as_dict(
1240 self, datetime_to_str: bool = False, custom_types: typing.Iterable[type] | type | None = None
1241 ) -> dict[str, Any]:
1242 row = self._ensure_matching_row()
1243 result = row.as_dict(datetime_to_str=datetime_to_str, custom_types=custom_types)
1245 if _with := getattr(self, "_with", None):
1246 for relationship in _with:
1247 data = self.get(relationship)
1248 if isinstance(data, list):
1249 data = [_.as_dict() if getattr(_, "as_dict", None) else _ for _ in data]
1250 elif data:
1251 data = data.as_dict()
1253 result[relationship] = data
1255 return typing.cast(dict[str, Any], result)
1257 def _as_json(
1258 self,
1259 mode: str = "object",
1260 default: typing.Callable[[Any], Any] = None,
1261 colnames: list[str] = None,
1262 serialize: bool = True,
1263 **kwargs: Any,
1264 ) -> str:
1265 row = self._ensure_matching_row()
1266 return typing.cast(str, row.as_json(mode, default, colnames, serialize, *kwargs))
1268 def _as_xml(self, sanitize: bool = True) -> str: # pragma: no cover
1269 row = self._ensure_matching_row()
1270 return typing.cast(str, row.as_xml(sanitize))
1272 # def _as_yaml(self, sanitize: bool = True) -> str:
1273 # row = self._ensure_matching_row()
1274 # return typing.cast(str, row.as_yaml(sanitize))
1276 def __setattr__(self, key: str, value: Any) -> None:
1277 """
1278 When setting a property on a Typed Table model instance, also update the underlying row.
1279 """
1280 if self._row and key in self._row.__dict__ and not callable(value):
1281 # enables `row.key = value; row.update_record()`
1282 self._row[key] = value
1284 super().__setattr__(key, value)
1286 @classmethod
1287 def update(cls: typing.Type[T_MetaInstance], query: Query, **fields: Any) -> T_MetaInstance | None:
1288 """
1289 Update one record.
1291 Example:
1292 MyTable.update(MyTable.id == 1, name="NewName") -> MyTable
1293 """
1294 # todo: update multiple?
1295 if record := cls(query):
1296 return record.update_record(**fields)
1297 else:
1298 return None
1300 def _update(self: T_MetaInstance, **fields: Any) -> T_MetaInstance:
1301 row = self._ensure_matching_row()
1302 row.update(**fields)
1303 self.__dict__.update(**fields)
1304 return self
1306 def _update_record(self: T_MetaInstance, **fields: Any) -> T_MetaInstance:
1307 row = self._ensure_matching_row()
1308 new_row = row.update_record(**fields)
1309 self.update(**new_row)
1310 return self
1312 def update_record(self: T_MetaInstance, **fields: Any) -> T_MetaInstance: # pragma: no cover
1313 """
1314 Here as a placeholder for _update_record.
1316 Will be replaced on instance creation!
1317 """
1318 return self._update_record(**fields)
1320 def _delete_record(self) -> int:
1321 """
1322 Actual logic in `pydal.helpers.classes.RecordDeleter`.
1323 """
1324 row = self._ensure_matching_row()
1325 result = row.delete_record()
1326 self.__dict__ = {} # empty self, since row is no more.
1327 self._row = None # just to be sure
1328 self._setup_instance_methods()
1329 # ^ instance methods might've been deleted by emptying dict,
1330 # but we still want .as_dict to show an error, not the table's as_dict.
1331 return typing.cast(int, result)
1333 def delete_record(self) -> int: # pragma: no cover
1334 """
1335 Here as a placeholder for _delete_record.
1337 Will be replaced on instance creation!
1338 """
1339 return self._delete_record()
1341 # __del__ is also called on the end of a scope so don't remove records on every del!!
1344# backwards compat:
1345TypedRow = TypedTable
1348class QueryBuilder(typing.Generic[T_MetaInstance]):
1349 """
1350 Abstration on top of pydal's query system.
1351 """
1353 model: typing.Type[T_MetaInstance]
1354 query: Query
1355 select_args: list[Any]
1356 select_kwargs: dict[str, Any]
1357 relationships: dict[str, Relationship[Any]]
1358 metadata: dict[str, Any]
1360 def __init__(
1361 self,
1362 model: typing.Type[T_MetaInstance],
1363 add_query: Optional[Query] = None,
1364 select_args: Optional[list[Any]] = None,
1365 select_kwargs: Optional[dict[str, Any]] = None,
1366 relationships: dict[str, Relationship[Any]] = None,
1367 metadata: dict[str, Any] = None,
1368 ):
1369 """
1370 Normally, you wouldn't manually initialize a QueryBuilder but start using a method on a TypedTable.
1372 Example:
1373 MyTable.where(...) -> QueryBuilder[MyTable]
1374 """
1375 self.model = model
1376 table = model._ensure_table_defined()
1377 default_query = typing.cast(Query, table.id > 0)
1378 self.query = add_query or default_query
1379 self.select_args = select_args or []
1380 self.select_kwargs = select_kwargs or {}
1381 self.relationships = relationships or {}
1382 self.metadata = metadata or {}
1384 def __str__(self) -> str:
1385 """
1386 Simple string representation for the query builder.
1387 """
1388 return f"QueryBuilder for {self.model}"
1390 def __repr__(self) -> str:
1391 """
1392 Advanced string representation for the query builder.
1393 """
1394 return (
1395 f"<QueryBuilder for {self.model} with "
1396 f"{len(self.select_args)} select args; "
1397 f"{len(self.select_kwargs)} select kwargs; "
1398 f"{len(self.relationships)} relationships; "
1399 f"query: {bool(self.query)}; "
1400 f"metadata: {self.metadata}; "
1401 f">"
1402 )
1404 def __bool__(self) -> bool:
1405 """
1406 Querybuilder is truthy if it has rows.
1407 """
1408 return self.count() > 0
1410 def _extend(
1411 self,
1412 add_query: Optional[Query] = None,
1413 overwrite_query: Optional[Query] = None,
1414 select_args: Optional[list[Any]] = None,
1415 select_kwargs: Optional[dict[str, Any]] = None,
1416 relationships: dict[str, Relationship[Any]] = None,
1417 metadata: dict[str, Any] = None,
1418 ) -> "QueryBuilder[T_MetaInstance]":
1419 return QueryBuilder(
1420 self.model,
1421 (add_query & self.query) if add_query else overwrite_query or self.query,
1422 (self.select_args + select_args) if select_args else self.select_args,
1423 (self.select_kwargs | select_kwargs) if select_kwargs else self.select_kwargs,
1424 (self.relationships | relationships) if relationships else self.relationships,
1425 (self.metadata | metadata) if metadata else self.metadata,
1426 )
1428 def select(self, *fields: Any, **options: Any) -> "QueryBuilder[T_MetaInstance]":
1429 """
1430 Fields: database columns by name ('id'), by field reference (table.id) or other (e.g. table.ALL).
1432 Options:
1433 paraphrased from the web2py pydal docs,
1434 For more info, see http://www.web2py.com/books/default/chapter/29/06/the-database-abstraction-layer#orderby-groupby-limitby-distinct-having-orderby_on_limitby-join-left-cache
1436 orderby: field(s) to order by. Supported:
1437 table.name - sort by name, ascending
1438 ~table.name - sort by name, descending
1439 <random> - sort randomly
1440 table.name|table.id - sort by two fields (first name, then id)
1442 groupby, having: together with orderby:
1443 groupby can be a field (e.g. table.name) to group records by
1444 having can be a query, only those `having` the condition are grouped
1446 limitby: tuple of min and max. When using the query builder, .paginate(limit, page) is recommended.
1447 distinct: bool/field. Only select rows that differ
1448 orderby_on_limitby (bool, default: True): by default, an implicit orderby is added when doing limitby.
1449 join: othertable.on(query) - do an INNER JOIN. Using TypeDAL relationships with .join() is recommended!
1450 left: othertable.on(query) - do a LEFT JOIN. Using TypeDAL relationships with .join() is recommended!
1451 cache: cache the query result to speed up repeated queries; e.g. (cache=(cache.ram, 3600), cacheable=True)
1452 """
1453 return self._extend(select_args=list(fields), select_kwargs=options)
1455 def where(
1456 self,
1457 *queries_or_lambdas: Query | typing.Callable[[typing.Type[T_MetaInstance]], Query],
1458 **filters: Any,
1459 ) -> "QueryBuilder[T_MetaInstance]":
1460 """
1461 Extend the builder's query.
1463 Can be used in multiple ways:
1464 .where(Query) -> with a direct query such as `Table.id == 5`
1465 .where(lambda table: table.id == 5) -> with a query via a lambda
1466 .where(id=5) -> via keyword arguments
1468 When using multiple where's, they will be ANDed:
1469 .where(lambda table: table.id == 5).where(lambda table: table.id == 6) == (table.id == 5) & (table.id=6)
1470 When passing multiple queries to a single .where, they will be ORed:
1471 .where(lambda table: table.id == 5, lambda table: table.id == 6) == (table.id == 5) | (table.id=6)
1472 """
1473 new_query = self.query
1474 table = self.model._ensure_table_defined()
1476 for field, value in filters.items():
1477 new_query &= table[field] == value
1479 subquery: DummyQuery | Query = DummyQuery()
1480 for query_or_lambda in queries_or_lambdas:
1481 if isinstance(query_or_lambda, _Query):
1482 subquery |= typing.cast(Query, query_or_lambda)
1483 elif callable(query_or_lambda):
1484 if result := query_or_lambda(self.model):
1485 subquery |= result
1486 elif isinstance(query_or_lambda, (Field, _Field)) or is_typed_field(query_or_lambda):
1487 subquery |= typing.cast(Query, query_or_lambda != None)
1488 else:
1489 raise ValueError(f"Unexpected query type ({type(query_or_lambda)}).")
1491 if subquery:
1492 new_query &= subquery
1494 return self._extend(overwrite_query=new_query)
1496 def join(
1497 self,
1498 *fields: str | typing.Type[TypedTable],
1499 method: JOIN_OPTIONS = None,
1500 on: OnQuery | list[Expression] | Expression = None,
1501 condition: Condition = None,
1502 ) -> "QueryBuilder[T_MetaInstance]":
1503 """
1504 Include relationship fields in the result.
1506 `fields` can be names of Relationships on the current model.
1507 If no fields are passed, all will be used.
1509 By default, the `method` defined in the relationship is used.
1510 This can be overwritten with the `method` keyword argument (left or inner)
1511 """
1512 # todo: allow limiting amount of related rows returned for join?
1514 relationships = self.model.get_relationships()
1516 if condition and on:
1517 raise ValueError("condition and on can not be used together!")
1518 elif condition:
1519 if len(fields) != 1:
1520 raise ValueError("join(field, condition=...) can only be used with exactly one field!")
1522 if isinstance(condition, pydal.objects.Query):
1523 condition = as_lambda(condition)
1525 relationships = {str(fields[0]): relationship(fields[0], condition=condition, join=method)}
1526 elif on:
1527 if len(fields) != 1:
1528 raise ValueError("join(field, on=...) can only be used with exactly one field!")
1530 if isinstance(on, pydal.objects.Expression):
1531 on = [on]
1533 if isinstance(on, list):
1534 on = as_lambda(on)
1535 relationships = {str(fields[0]): relationship(fields[0], on=on, join=method)}
1537 else:
1538 if fields:
1539 # join on every relationship
1540 relationships = {str(k): relationships[str(k)] for k in fields}
1542 if method:
1543 relationships = {str(k): r.clone(join=method) for k, r in relationships.items()}
1545 return self._extend(relationships=relationships)
1547 def _get_db(self) -> TypeDAL:
1548 if db := self.model._db:
1549 return db
1550 else: # pragma: no cover
1551 raise EnvironmentError("@define or db.define is not called on this class yet!")
1553 def _select_arg_convert(self, arg: Any) -> Any:
1554 # typedfield are not really used at runtime anymore, but leave it in for safety:
1555 if isinstance(arg, TypedField): # pragma: no cover
1556 arg = arg._field
1558 return arg
1560 def delete(self) -> list[int] | None:
1561 """
1562 Based on the current query, delete rows and return a list of deleted IDs.
1563 """
1564 db = self._get_db()
1565 removed_ids = [_.id for _ in db(self.query).select("id")]
1566 if db(self.query).delete():
1567 # success!
1568 return removed_ids
1570 return None
1572 def _delete(self) -> str:
1573 db = self._get_db()
1574 return str(db(self.query)._delete())
1576 def update(self, **fields: Any) -> list[int] | None:
1577 """
1578 Based on the current query, update `fields` and return a list of updated IDs.
1579 """
1580 # todo: limit?
1581 db = self._get_db()
1582 updated_ids = db(self.query).select("id").column("id")
1583 if db(self.query).update(**fields):
1584 # success!
1585 return updated_ids
1587 return None
1589 def _update(self, **fields: Any) -> str:
1590 db = self._get_db()
1591 return str(db(self.query)._update(**fields))
1593 def _before_query(self, mut_metadata: dict[str, Any]) -> tuple[Query, list[Any], dict[str, Any]]:
1594 select_args = [self._select_arg_convert(_) for _ in self.select_args] or [self.model.ALL]
1595 select_kwargs = self.select_kwargs.copy()
1596 query = self.query
1597 model = self.model
1598 mut_metadata["query"] = query
1599 # require at least id of main table:
1600 select_fields = ", ".join([str(_) for _ in select_args])
1601 tablename = str(model)
1603 if f"{tablename}.id" not in select_fields:
1604 # fields of other selected, but required ID is missing.
1605 select_args.append(model.id)
1607 if self.relationships:
1608 query, select_args = self._handle_relationships_pre_select(query, select_args, select_kwargs, mut_metadata)
1610 return query, select_args, select_kwargs
1612 def to_sql(self) -> str:
1613 """
1614 Generate the SQL for the built query.
1615 """
1616 db = self._get_db()
1618 query, select_args, select_kwargs = self._before_query({})
1620 return str(db(query)._select(*select_args, **select_kwargs))
1622 def _collect(self) -> str:
1623 """
1624 Alias for to_sql, pydal-like syntax.
1625 """
1626 return self.to_sql()
1628 def collect(self, verbose: bool = False, _to: typing.Type["TypedRows[Any]"] = None) -> "TypedRows[T_MetaInstance]":
1629 """
1630 Execute the built query and turn it into model instances, while handling relationships.
1631 """
1632 if _to is None:
1633 _to = TypedRows
1635 db = self._get_db()
1636 metadata = self.metadata.copy()
1638 query, select_args, select_kwargs = self._before_query(metadata)
1640 metadata["sql"] = db(query)._select(*select_args, **select_kwargs)
1642 if verbose: # pragma: no cover
1643 print(metadata["sql"])
1645 rows: Rows = db(query).select(*select_args, **select_kwargs)
1647 metadata["final_query"] = str(query)
1648 metadata["final_args"] = [str(_) for _ in select_args]
1649 metadata["final_kwargs"] = select_kwargs
1651 if verbose: # pragma: no cover
1652 print(rows)
1654 if not self.relationships:
1655 # easy
1656 return _to.from_rows(rows, self.model, metadata=metadata)
1658 # harder: try to match rows to the belonging objects
1659 # assume structure of {'table': <data>} per row.
1660 # if that's not the case, return default behavior again
1661 return self._collect_with_relationships(rows, metadata=metadata, _to=_to)
1663 def _handle_relationships_pre_select(
1664 self,
1665 query: Query,
1666 select_args: list[Any],
1667 select_kwargs: dict[str, Any],
1668 metadata: dict[str, Any],
1669 ) -> tuple[Query, list[Any]]:
1670 db = self._get_db()
1671 model = self.model
1673 metadata["relationships"] = set(self.relationships.keys())
1675 # query = self._update_query_for_inner(db, model, query)
1676 join = []
1677 for key, relation in self.relationships.items():
1678 if not relation.condition or relation.join != "inner":
1679 continue
1681 other = relation.get_table(db)
1682 other = other.with_alias(f"{key}_{hash(relation)}")
1683 join.append(other.on(relation.condition(model, other)))
1685 if limitby := select_kwargs.pop("limitby", None):
1686 # if limitby + relationships:
1687 # 1. get IDs of main table entries that match 'query'
1688 # 2. change query to .belongs(id)
1689 # 3. add joins etc
1691 kwargs = {"limitby": limitby}
1693 if join:
1694 kwargs["join"] = join
1696 ids = db(query)._select(model.id, **kwargs)
1697 query = model.id.belongs(ids)
1698 metadata["ids"] = ids
1700 if join:
1701 select_kwargs["join"] = join
1703 left = []
1705 for key, relation in self.relationships.items():
1706 other = relation.get_table(db)
1707 method: JOIN_OPTIONS = relation.join or DEFAULT_JOIN_OPTION
1709 select_fields = ", ".join([str(_) for _ in select_args])
1710 pre_alias = str(other)
1712 if f"{other}." not in select_fields:
1713 # no fields of other selected. add .ALL:
1714 select_args.append(other.ALL)
1715 elif f"{other}.id" not in select_fields:
1716 # fields of other selected, but required ID is missing.
1717 select_args.append(other.id)
1719 if relation.on:
1720 # if it has a .on, it's always a left join!
1721 on = relation.on(model, other)
1722 if not isinstance(on, list): # pragma: no cover
1723 on = [on]
1725 left.extend(on)
1726 elif method == "left":
1727 # .on not given, generate it:
1728 other = other.with_alias(f"{key}_{hash(relation)}")
1729 condition = typing.cast(Query, relation.condition(model, other))
1730 left.append(other.on(condition))
1731 else:
1732 # else: inner join (handled earlier)
1733 other = other.with_alias(f"{key}_{hash(relation)}") # only for replace
1734 # other = other.with_alias(f"{key}_{hash(relation)}")
1735 # query &= relation.condition(model, other)
1737 # if no fields of 'other' are included, add other.ALL
1738 # else: only add other.id if missing
1739 select_fields = ", ".join([str(_) for _ in select_args])
1741 post_alias = str(other).split(" AS ")[-1]
1742 if pre_alias != post_alias:
1743 # replace .select's with aliased:
1744 select_fields = select_fields.replace(
1745 f"{pre_alias}.",
1746 f"{post_alias}.",
1747 )
1749 select_args = select_fields.split(", ")
1751 select_kwargs["left"] = left
1752 return query, select_args
1754 def _collect_with_relationships(
1755 self, rows: Rows, metadata: dict[str, Any], _to: typing.Type["TypedRows[Any]"] = None
1756 ) -> "TypedRows[T_MetaInstance]":
1757 """
1758 Transform the raw rows into Typed Table model instances.
1759 """
1760 db = self._get_db()
1761 main_table = self.model._ensure_table_defined()
1763 records = {}
1764 seen_relations: dict[str, set[str]] = defaultdict(set) # main id -> set of col + id for relation
1766 for row in rows:
1767 main = row[main_table]
1768 main_id = main.id
1770 if main_id not in records:
1771 records[main_id] = self.model(main)
1772 records[main_id]._with = list(self.relationships.keys())
1774 # setup up all relationship defaults (once)
1775 for col, relationship in self.relationships.items():
1776 records[main_id][col] = [] if relationship.multiple else None
1778 # now add other relationship data
1779 for column, relation in self.relationships.items():
1780 relationship_column = f"{column}_{hash(relation)}"
1782 # relationship_column works for aliases with the same target column.
1783 # if col + relationship not in the row, just use the regular name.
1785 relation_data = (
1786 row[relationship_column] if relationship_column in row else row[relation.get_table_name()]
1787 )
1789 if relation_data.id is None:
1790 # always skip None ids
1791 continue
1793 if f"{column}-{relation_data.id}" in seen_relations[main_id]:
1794 # speed up duplicates
1795 continue
1796 else:
1797 seen_relations[main_id].add(f"{column}-{relation_data.id}")
1799 relation_table = relation.get_table(db)
1800 # hopefully an instance of a typed table and a regular row otherwise:
1801 instance = relation_table(relation_data) if looks_like(relation_table, TypedTable) else relation_data
1803 if relation.multiple:
1804 # create list of T
1805 if not isinstance(records[main_id].get(column), list): # pragma: no cover
1806 # should already be set up before!
1807 setattr(records[main_id], column, [])
1809 records[main_id][column].append(instance)
1810 else:
1811 # create single T
1812 records[main_id][column] = instance
1814 return _to(rows, self.model, records, metadata=metadata)
1816 def collect_or_fail(self) -> "TypedRows[T_MetaInstance]":
1817 """
1818 Call .collect() and raise an error if nothing found.
1820 Basically unwraps Optional type.
1821 """
1822 if result := self.collect():
1823 return result
1824 else:
1825 raise ValueError("Nothing found!")
1827 def __iter__(self) -> typing.Generator[T_MetaInstance, None, None]:
1828 """
1829 You can start iterating a Query Builder object before calling collect, for ease of use.
1830 """
1831 yield from self.collect()
1833 def count(self) -> int:
1834 """
1835 Return the amount of rows matching the current query.
1836 """
1837 db = self._get_db()
1838 model = self.model
1839 query = self.query
1841 for key, relation in self.relationships.items():
1842 if not relation.condition or relation.join != "inner":
1843 continue
1845 other = relation.get_table(db)
1846 other = other.with_alias(f"{key}_{hash(relation)}")
1847 query &= relation.condition(model, other)
1849 return db(query).count()
1851 def __paginate(
1852 self,
1853 limit: int,
1854 page: int = 1,
1855 ) -> "QueryBuilder[T_MetaInstance]":
1856 _from = limit * (page - 1)
1857 _to = limit * page
1859 available = self.count()
1861 return self._extend(
1862 select_kwargs={"limitby": (_from, _to)},
1863 metadata={
1864 "pagination": {
1865 "limit": limit,
1866 "current_page": page,
1867 "max_page": math.ceil(available / limit),
1868 "rows": available,
1869 "min_max": (_from, _to),
1870 }
1871 },
1872 )
1874 def paginate(self, limit: int, page: int = 1, verbose: bool = False) -> "PaginatedRows[T_MetaInstance]":
1875 """
1876 Paginate transforms the more readable `page` and `limit` to pydals internal limit and offset.
1878 Note: when using relationships, this limit is only applied to the 'main' table and any number of extra rows \
1879 can be loaded with relationship data!
1880 """
1881 builder = self.__paginate(limit, page)
1883 rows = typing.cast(PaginatedRows[T_MetaInstance], builder.collect(verbose=verbose, _to=PaginatedRows))
1885 rows._query_builder = builder
1886 return rows
1888 def _paginate(
1889 self,
1890 limit: int,
1891 page: int = 1,
1892 ) -> str:
1893 builder = self.__paginate(limit, page)
1894 return builder._collect()
1896 def chunk(self, chunk_size: int) -> typing.Generator["TypedRows[T_MetaInstance]", Any, None]:
1897 """
1898 Generator that yields rows from a paginated source in chunks.
1900 This function retrieves rows from a paginated data source in chunks of the
1901 specified `chunk_size` and yields them as TypedRows.
1903 Example:
1904 ```
1905 for chunk_of_rows in Table.where(SomeTable.id > 5).chunk(100):
1906 for row in chunk_of_rows:
1907 # Process each row within the chunk.
1908 pass
1909 ```
1910 """
1911 page = 1
1913 while rows := self.__paginate(chunk_size, page).collect():
1914 yield rows
1915 page += 1
1917 def first(self, verbose: bool = False) -> T_MetaInstance | None:
1918 """
1919 Get the first row matching the currently built query.
1921 Also adds paginate, since it would be a waste to select more rows than needed.
1922 """
1923 if row := self.paginate(page=1, limit=1, verbose=verbose).first():
1924 return self.model.from_row(row)
1925 else:
1926 return None
1928 def _first(self) -> str:
1929 return self._paginate(page=1, limit=1)
1931 def first_or_fail(self, verbose: bool = False) -> T_MetaInstance:
1932 """
1933 Call .first() and raise an error if nothing found.
1935 Basically unwraps Optional type.
1936 """
1937 if inst := self.first(verbose=verbose):
1938 return inst
1939 else:
1940 raise ValueError("Nothing found!")
1943class TypedField(typing.Generic[T_Value]): # pragma: no cover
1944 """
1945 Typed version of pydal.Field, which will be converted to a normal Field in the background.
1946 """
1948 # will be set by .bind on db.define
1949 name = ""
1950 _db: Optional[pydal.DAL] = None
1951 _rname: Optional[str] = None
1952 _table: Optional[Table] = None
1953 _field: Optional[Field] = None
1955 _type: T_annotation
1956 kwargs: Any
1958 def __init__(self, _type: typing.Type[T_Value] | types.UnionType = str, /, **settings: Any) -> None: # type: ignore
1959 """
1960 A TypedFieldType should not be inited manually, but TypedField (from `fields.py`) should be used!
1961 """
1962 self._type = _type
1963 self.kwargs = settings
1964 super().__init__()
1966 @typing.overload
1967 def __get__(self, instance: T_MetaInstance, owner: typing.Type[T_MetaInstance]) -> T_Value: # pragma: no cover
1968 """
1969 row.field -> (actual data).
1970 """
1972 @typing.overload
1973 def __get__(self, instance: None, owner: typing.Type[TypedTable]) -> "TypedField[T_Value]": # pragma: no cover
1974 """
1975 Table.field -> Field.
1976 """
1978 def __get__(
1979 self, instance: T_MetaInstance | None, owner: typing.Type[T_MetaInstance]
1980 ) -> typing.Union[T_Value, "TypedField[T_Value]"]:
1981 """
1982 Since this class is a Descriptor field, \
1983 it returns something else depending on if it's called on a class or instance.
1985 (this is mostly for mypy/typing)
1986 """
1987 if instance:
1988 # this is only reached in a very specific case:
1989 # an instance of the object was created with a specific set of fields selected (excluding the current one)
1990 # in that case, no value was stored in the owner -> return None (since the field was not selected)
1991 return typing.cast(T_Value, None) # cast as T_Value so mypy understands it for selected fields
1992 else:
1993 # getting as class -> return actual field so pydal understands it when using in query etc.
1994 return typing.cast(TypedField[T_Value], self._field) # pretend it's still typed for IDE support
1996 def __str__(self) -> str:
1997 """
1998 String representation of a Typed Field.
2000 If `type` is set explicitly (e.g. TypedField(str, type="text")), that type is used: `TypedField.text`,
2001 otherwise the type annotation is used (e.g. TypedField(str) -> TypedField.str)
2002 """
2003 return str(self._field) if self._field else ""
2005 def __repr__(self) -> str:
2006 """
2007 More detailed string representation of a Typed Field.
2009 Uses __str__ and adds the provided extra options (kwargs) in the representation.
2010 """
2011 s = self.__str__()
2013 if "type" in self.kwargs:
2014 # manual type in kwargs supplied
2015 t = self.kwargs["type"]
2016 elif issubclass(type, type(self._type)):
2017 # normal type, str.__name__ = 'str'
2018 t = getattr(self._type, "__name__", str(self._type))
2019 elif t_args := typing.get_args(self._type):
2020 # list[str] -> 'str'
2021 t = t_args[0].__name__
2022 else: # pragma: no cover
2023 # fallback - something else, may not even happen, I'm not sure
2024 t = self._type
2026 s = f"TypedField[{t}].{s}" if s else f"TypedField[{t}]"
2028 kw = self.kwargs.copy()
2029 kw.pop("type", None)
2030 return f"<{s} with options {kw}>"
2032 def _to_field(self, extra_kwargs: typing.MutableMapping[str, Any]) -> Optional[str]:
2033 """
2034 Convert a Typed Field instance to a pydal.Field.
2035 """
2036 other_kwargs = self.kwargs.copy()
2037 extra_kwargs.update(other_kwargs)
2038 return extra_kwargs.pop("type", False) or TypeDAL._annotation_to_pydal_fieldtype(self._type, extra_kwargs)
2040 def bind(self, field: pydal.objects.Field, table: pydal.objects.Table) -> None:
2041 """
2042 Bind the right db/table/field info to this class, so queries can be made using `Class.field == ...`.
2043 """
2044 self._table = table
2045 self._field = field
2047 def __getattr__(self, key: str) -> Any:
2048 """
2049 If the regular getattribute does not work, try to get info from the related Field.
2050 """
2051 with contextlib.suppress(AttributeError):
2052 return super().__getattribute__(key)
2054 # try on actual field:
2055 return getattr(self._field, key)
2057 def __eq__(self, other: Any) -> Query:
2058 """
2059 Performing == on a Field will result in a Query.
2060 """
2061 return typing.cast(Query, self._field == other)
2063 def __ne__(self, other: Any) -> Query:
2064 """
2065 Performing != on a Field will result in a Query.
2066 """
2067 return typing.cast(Query, self._field != other)
2069 def __gt__(self, other: Any) -> Query:
2070 """
2071 Performing > on a Field will result in a Query.
2072 """
2073 return typing.cast(Query, self._field > other)
2075 def __lt__(self, other: Any) -> Query:
2076 """
2077 Performing < on a Field will result in a Query.
2078 """
2079 return typing.cast(Query, self._field < other)
2081 def __ge__(self, other: Any) -> Query:
2082 """
2083 Performing >= on a Field will result in a Query.
2084 """
2085 return typing.cast(Query, self._field >= other)
2087 def __le__(self, other: Any) -> Query:
2088 """
2089 Performing <= on a Field will result in a Query.
2090 """
2091 return typing.cast(Query, self._field <= other)
2093 def __hash__(self) -> int:
2094 """
2095 Shadow Field.__hash__.
2096 """
2097 return hash(self._field)
2099 def __invert__(self) -> Expression:
2100 """
2101 Performing ~ on a Field will result in an Expression.
2102 """
2103 if not self._field: # pragma: no cover
2104 raise ValueError("Unbound Field can not be inverted!")
2106 return typing.cast(Expression, ~self._field)
2109S = typing.TypeVar("S")
2112class TypedRows(typing.Collection[T_MetaInstance], Rows):
2113 """
2114 Slighly enhaned and typed functionality on top of pydal Rows (the result of a select).
2115 """
2117 records: dict[int, T_MetaInstance]
2118 # _rows: Rows
2119 model: typing.Type[T_MetaInstance]
2120 metadata: dict[str, Any]
2122 # pseudo-properties: actually stored in _rows
2123 db: TypeDAL
2124 colnames: list[str]
2125 fields: list[Field]
2126 colnames_fields: list[Field]
2127 response: list[tuple[Any, ...]]
2129 def __init__(
2130 self,
2131 rows: Rows,
2132 model: typing.Type[T_MetaInstance],
2133 records: dict[int, T_MetaInstance] = None,
2134 metadata: dict[str, Any] = None,
2135 ) -> None:
2136 """
2137 Should not be called manually!
2139 Normally, the `records` from an existing `Rows` object are used
2140 but these can be overwritten with a `records` dict.
2141 `metadata` can be any (un)structured data
2142 `model` is a Typed Table class
2143 """
2144 records = records or {row.id: model(row) for row in rows}
2145 super().__init__(rows.db, records, rows.colnames, rows.compact, rows.response, rows.fields)
2146 self.model = model
2147 self.metadata = metadata or {}
2149 def __len__(self) -> int:
2150 """
2151 Return the count of rows.
2152 """
2153 return len(self.records)
2155 def __iter__(self) -> typing.Iterator[T_MetaInstance]:
2156 """
2157 Loop through the rows.
2158 """
2159 yield from self.records.values()
2161 def __contains__(self, ind: Any) -> bool:
2162 """
2163 Check if an id exists in this result set.
2164 """
2165 return ind in self.records
2167 def first(self) -> T_MetaInstance | None:
2168 """
2169 Get the row with the lowest id.
2170 """
2171 if not self.records:
2172 return None
2174 return next(iter(self))
2176 def last(self) -> T_MetaInstance | None:
2177 """
2178 Get the row with the highest id.
2179 """
2180 if not self.records:
2181 return None
2183 max_id = max(self.records.keys())
2184 return self[max_id]
2186 def find(
2187 self, f: typing.Callable[[T_MetaInstance], Query], limitby: tuple[int, int] = None
2188 ) -> "TypedRows[T_MetaInstance]":
2189 """
2190 Returns a new Rows object, a subset of the original object, filtered by the function `f`.
2191 """
2192 if not self.records:
2193 return self.__class__(self, self.model, {})
2195 records = {}
2196 if limitby:
2197 _min, _max = limitby
2198 else:
2199 _min, _max = 0, len(self)
2200 count = 0
2201 for i, row in self.records.items():
2202 if f(row):
2203 if _min <= count:
2204 records[i] = row
2205 count += 1
2206 if count == _max:
2207 break
2209 return self.__class__(self, self.model, records)
2211 def exclude(self, f: typing.Callable[[T_MetaInstance], Query]) -> "TypedRows[T_MetaInstance]":
2212 """
2213 Removes elements from the calling Rows object, filtered by the function `f`, \
2214 and returns a new Rows object containing the removed elements.
2215 """
2216 if not self.records:
2217 return self.__class__(self, self.model, {})
2218 removed = {}
2219 to_remove = []
2220 for i in self.records:
2221 row = self[i]
2222 if f(row):
2223 removed[i] = self.records[i]
2224 to_remove.append(i)
2226 [self.records.pop(i) for i in to_remove]
2228 return self.__class__(
2229 self,
2230 self.model,
2231 removed,
2232 )
2234 def sort(self, f: typing.Callable[[T_MetaInstance], Any], reverse: bool = False) -> list[T_MetaInstance]:
2235 """
2236 Returns a list of sorted elements (not sorted in place).
2237 """
2238 return [r for (r, s) in sorted(zip(self.records.values(), self), key=lambda r: f(r[1]), reverse=reverse)]
2240 def __str__(self) -> str:
2241 """
2242 Simple string representation.
2243 """
2244 return f"<TypedRows with {len(self)} records>"
2246 def __repr__(self) -> str:
2247 """
2248 Print a table on repr().
2249 """
2250 data = self.as_dict()
2251 headers = list(next(iter(data.values())).keys())
2252 return mktable(data, headers)
2254 def group_by_value(
2255 self, *fields: str | Field | TypedField[T], one_result: bool = False, **kwargs: Any
2256 ) -> dict[T, list[T_MetaInstance]]:
2257 """
2258 Group the rows by a specific field (which will be the dict key).
2259 """
2260 kwargs["one_result"] = one_result
2261 result = super().group_by_value(*fields, **kwargs)
2262 return typing.cast(dict[T, list[T_MetaInstance]], result)
2264 def column(self, column: str = None) -> list[Any]:
2265 """
2266 Get a list of all values in a specific column.
2268 Example:
2269 rows.column('name') -> ['Name 1', 'Name 2', ...]
2270 """
2271 return typing.cast(list[Any], super().column(column))
2273 def as_csv(self) -> str:
2274 """
2275 Dump the data to csv.
2276 """
2277 return typing.cast(str, super().as_csv())
2279 def as_dict(
2280 self,
2281 key: str = None,
2282 compact: bool = False,
2283 storage_to_dict: bool = False,
2284 datetime_to_str: bool = False,
2285 custom_types: list[type] = None,
2286 ) -> dict[int, dict[str, Any]]:
2287 """
2288 Get the data in a dict of dicts.
2289 """
2290 if any([key, compact, storage_to_dict, datetime_to_str, custom_types]):
2291 # functionality not guaranteed
2292 return typing.cast(
2293 dict[int, dict[str, Any]],
2294 super().as_dict(
2295 key or "id",
2296 compact,
2297 storage_to_dict,
2298 datetime_to_str,
2299 custom_types,
2300 ),
2301 )
2303 return {k: v.as_dict() for k, v in self.records.items()}
2305 def as_json(self, mode: str = "object", default: typing.Callable[[Any], Any] = None) -> str:
2306 """
2307 Turn the data into a dict and then dump to JSON.
2308 """
2309 return typing.cast(str, super().as_json(mode=mode, default=default))
2311 def json(self, mode: str = "object", default: typing.Callable[[Any], Any] = None) -> str:
2312 """
2313 Turn the data into a dict and then dump to JSON.
2314 """
2315 return typing.cast(str, super().as_json(mode=mode, default=default))
2317 def as_list(
2318 self,
2319 compact: bool = False,
2320 storage_to_dict: bool = False,
2321 datetime_to_str: bool = False,
2322 custom_types: list[type] = None,
2323 ) -> list[dict[str, Any]]:
2324 """
2325 Get the data in a list of dicts.
2326 """
2327 if any([compact, storage_to_dict, datetime_to_str, custom_types]):
2328 return typing.cast(
2329 list[dict[str, Any]], super().as_list(compact, storage_to_dict, datetime_to_str, custom_types)
2330 )
2331 return [_.as_dict() for _ in self.records.values()]
2333 def __getitem__(self, item: int) -> T_MetaInstance:
2334 """
2335 You can get a specific row by ID from a typedrows by using rows[idx] notation.
2337 Since pydal's implementation differs (they expect a list instead of a dict with id keys),
2338 using rows[0] will return the first row, regardless of its id.
2339 """
2340 try:
2341 return self.records[item]
2342 except KeyError as e:
2343 if item == 0 and (row := self.first()):
2344 # special case: pydal internals think Rows.records is a list, not a dict
2345 return row
2347 raise e
2349 def get(self, item: int) -> typing.Optional[T_MetaInstance]:
2350 """
2351 Get a row by ID, or receive None if it isn't in this result set.
2352 """
2353 return self.records.get(item)
2355 def join(
2356 self,
2357 field: Field | TypedField[Any],
2358 name: str = None,
2359 constraint: Query = None,
2360 fields: list[str | Field] = None,
2361 orderby: Optional[str | Field] = None,
2362 ) -> T_MetaInstance:
2363 """
2364 This can be used to JOIN with some relationships after the initial select.
2366 Using the querybuilder's .join() method is prefered!
2367 """
2368 result = super().join(field, name, constraint, fields or [], orderby)
2369 return typing.cast(T_MetaInstance, result)
2371 def export_to_csv_file(
2372 self,
2373 ofile: typing.TextIO,
2374 null: Any = "<NULL>",
2375 delimiter: str = ",",
2376 quotechar: str = '"',
2377 quoting: int = csv.QUOTE_MINIMAL,
2378 represent: bool = False,
2379 colnames: list[str] = None,
2380 write_colnames: bool = True,
2381 *args: Any,
2382 **kwargs: Any,
2383 ) -> None:
2384 """
2385 Shadow export_to_csv_file from Rows, but with typing.
2387 See http://web2py.com/books/default/chapter/29/06/the-database-abstraction-layer?search=export_to_csv_file#Exporting-and-importing-data
2388 """
2389 super().export_to_csv_file(
2390 ofile,
2391 null,
2392 *args,
2393 delimiter=delimiter,
2394 quotechar=quotechar,
2395 quoting=quoting,
2396 represent=represent,
2397 colnames=colnames or self.colnames,
2398 write_colnames=write_colnames,
2399 **kwargs,
2400 )
2402 @classmethod
2403 def from_rows(
2404 cls, rows: Rows, model: typing.Type[T_MetaInstance], metadata: dict[str, Any] = None
2405 ) -> "TypedRows[T_MetaInstance]":
2406 """
2407 Internal method to convert a Rows object to a TypedRows.
2408 """
2409 return cls(rows, model, metadata=metadata)
2411 def __json__(self) -> dict[str, Any]:
2412 """
2413 For json-fix.
2414 """
2415 return typing.cast(dict[str, Any], self.as_dict())
2418class PaginatedRows(TypedRows[T_MetaInstance]):
2419 """
2420 Extension on top of rows that is used when calling .paginate() instead of .collect().
2421 """
2423 _query_builder: QueryBuilder[T_MetaInstance]
2425 @property
2426 def data(self) -> list[T_MetaInstance]:
2427 """
2428 Get the underlying data.
2429 """
2430 return list(self.records.values())
2432 @property
2433 def pagination(self) -> Pagination:
2434 """
2435 Get all page info.
2436 """
2437 pagination_data = self.metadata["pagination"]
2439 has_next_page = pagination_data["current_page"] < pagination_data["max_page"]
2440 has_prev_page = pagination_data["current_page"] > 1
2441 return {
2442 "total_items": pagination_data["rows"],
2443 "current_page": pagination_data["current_page"],
2444 "per_page": pagination_data["limit"],
2445 "total_pages": pagination_data["max_page"],
2446 "has_next_page": has_next_page,
2447 "has_prev_page": has_prev_page,
2448 "next_page": pagination_data["current_page"] + 1 if has_next_page else None,
2449 "prev_page": pagination_data["current_page"] - 1 if has_prev_page else None,
2450 }
2452 def next(self) -> Self: # noqa: A003
2453 """
2454 Get the next page.
2455 """
2456 data = self.metadata["pagination"]
2457 if data["current_page"] >= data["max_page"]:
2458 raise StopIteration("Final Page")
2460 return self._query_builder.paginate(limit=data["limit"], page=data["current_page"] + 1)
2462 def previous(self) -> Self:
2463 """
2464 Get the previous page.
2465 """
2466 data = self.metadata["pagination"]
2467 if data["current_page"] <= 1:
2468 raise StopIteration("First Page")
2470 return self._query_builder.paginate(limit=data["limit"], page=data["current_page"] - 1)
2472 def as_dict(self, *_: Any, **__: Any) -> PaginateDict: # type: ignore
2473 """
2474 Convert to a dictionary with pagination info and original data.
2476 All arguments are ignored!
2477 """
2478 return {"data": super().as_dict(), "pagination": self.pagination}
2481class TypedSet(pydal.objects.Set): # type: ignore # pragma: no cover
2482 """
2483 Used to make pydal Set more typed.
2485 This class is not actually used, only 'cast' by TypeDAL.__call__
2486 """
2488 def count(self, distinct: bool = None, cache: dict[str, Any] = None) -> int:
2489 """
2490 Count returns an int.
2491 """
2492 result = super().count(distinct, cache)
2493 return typing.cast(int, result)
2495 def select(self, *fields: Any, **attributes: Any) -> TypedRows[T_MetaInstance]:
2496 """
2497 Select returns a TypedRows of a user defined table.
2499 Example:
2500 result: TypedRows[MyTable] = db(MyTable.id > 0).select()
2502 for row in result:
2503 typing.reveal_type(row) # MyTable
2504 """
2505 rows = super().select(*fields, **attributes)
2506 return typing.cast(TypedRows[T_MetaInstance], rows)