Coverage for src/typedal/core.py: 100%
736 statements
« prev ^ index » next coverage.py v7.3.2, created at 2023-10-26 12:56 +0200
« prev ^ index » next coverage.py v7.3.2, created at 2023-10-26 12:56 +0200
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
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 extract_type_optional,
30 filter_out,
31 instanciate,
32 is_union,
33 looks_like,
34 mktable,
35 origin_is_subclass,
36 to_snake,
37 unwrap_type,
38)
39from .types import Expression, Query, _Types
41# use typing.cast(type, ...) to make mypy happy with unions
42T_annotation = typing.Type[Any] | types.UnionType
43T_Query = typing.Union["Table", Query, bool, None, "TypedTable", typing.Type["TypedTable"]]
44T_Value = typing.TypeVar("T_Value") # actual type of the Field (via Generic)
45T_MetaInstance = typing.TypeVar("T_MetaInstance", bound="TypedTable") # bound="TypedTable"; bound="TableMeta"
46T = typing.TypeVar("T")
48BASIC_MAPPINGS: dict[T_annotation, str] = {
49 str: "string",
50 int: "integer",
51 bool: "boolean",
52 bytes: "blob",
53 float: "double",
54 object: "json",
55 Decimal: "decimal(10,2)",
56 dt.date: "date",
57 dt.time: "time",
58 dt.datetime: "datetime",
59}
62def is_typed_field(cls: Any) -> typing.TypeGuard["TypedField[Any]"]:
63 """
64 Is `cls` an instance or subclass of TypedField?
66 Deprecated
67 """
68 return (
69 isinstance(cls, TypedField)
70 or isinstance(typing.get_origin(cls), type)
71 and issubclass(typing.get_origin(cls), TypedField)
72 )
75JOIN_OPTIONS = typing.Literal["left", "inner", None]
76DEFAULT_JOIN_OPTION: JOIN_OPTIONS = "left"
78# table-ish paramter:
79P_Table = typing.Union[typing.Type["TypedTable"], pydal.objects.Table]
81Condition: typing.TypeAlias = typing.Optional[
82 typing.Callable[
83 # self, other -> Query
84 [P_Table, P_Table],
85 Query | bool,
86 ]
87]
89OnQuery: typing.TypeAlias = typing.Optional[
90 typing.Callable[
91 # self, other -> list of .on statements
92 [P_Table, P_Table],
93 list[Expression],
94 ]
95]
97To_Type = typing.TypeVar("To_Type", type[Any], typing.Type[Any], str)
100class Relationship(typing.Generic[To_Type]):
101 """
102 Define a relationship to another table.
103 """
105 _type: To_Type
106 table: typing.Type["TypedTable"] | type | str
107 condition: Condition
108 on: OnQuery
109 multiple: bool
110 join: JOIN_OPTIONS
112 def __init__(
113 self,
114 _type: To_Type,
115 condition: Condition = None,
116 join: JOIN_OPTIONS = None,
117 on: OnQuery = None,
118 ):
119 """
120 Should not be called directly, use relationship() instead!
121 """
122 if condition and on:
123 warnings.warn(f"Relation | Both specified! {condition=} {on=} {_type=}")
124 raise ValueError("Please specify either a condition or an 'on' statement for this relationship!")
126 self._type = _type
127 self.condition = condition
128 self.join = "left" if on else join # .on is always left join!
129 self.on = on
131 if args := typing.get_args(_type):
132 self.table = unwrap_type(args[0])
133 self.multiple = True
134 else:
135 self.table = _type
136 self.multiple = False
138 if isinstance(self.table, str):
139 self.table = TypeDAL.to_snake(self.table)
141 def clone(self, **update: Any) -> "Relationship[To_Type]":
142 """
143 Create a copy of the relationship, possibly updated.
144 """
145 return self.__class__(
146 update.get("_type") or self._type,
147 update.get("condition") or self.condition,
148 update.get("join") or self.join,
149 update.get("on") or self.on,
150 )
152 def __repr__(self) -> str:
153 """
154 Representation of the relationship.
155 """
156 if callback := self.condition or self.on:
157 src_code = inspect.getsource(callback).strip()
158 else:
159 cls_name = self._type if isinstance(self._type, str) else self._type.__name__ # type: ignore
160 src_code = f"to {cls_name} (missing condition)"
162 join = f":{self.join}" if self.join else ""
163 return f"<Relationship{join} {src_code}>"
165 def get_table(self, db: "TypeDAL") -> typing.Type["TypedTable"]:
166 """
167 Get the table this relationship is bound to.
168 """
169 table = self.table # can be a string because db wasn't available yet
170 if isinstance(table, str):
171 if mapped := db._class_map.get(table):
172 # yay
173 return mapped
175 # boo, fall back to untyped table but pretend it is typed:
176 return typing.cast(typing.Type["TypedTable"], db[table]) # eh close enough!
178 return table
180 def get_table_name(self) -> str:
181 """
182 Get the name of the table this relationship is bound to.
183 """
184 if isinstance(self.table, str):
185 return self.table
187 if isinstance(self.table, pydal.objects.Table):
188 return str(self.table)
190 # else: typed table
191 try:
192 table = self.table._ensure_table_defined() if issubclass(self.table, TypedTable) else self.table
193 except Exception: # pragma: no cover
194 table = self.table
196 return str(table)
198 def __get__(self, instance: Any, owner: Any) -> typing.Optional[list[Any]] | "Relationship[To_Type]":
199 """
200 Relationship is a descriptor class, which can be returned from a class but not an instance.
202 For an instance, using .join() will replace the Relationship with the actual data.
203 If you forgot to join, a warning will be shown and empty data will be returned.
204 """
205 if not instance:
206 # relationship queried on class, that's allowed
207 return self
209 warnings.warn(
210 "Trying to get data from a relationship object! Did you forget to join it?", category=RuntimeWarning
211 )
212 if self.multiple:
213 return []
214 else:
215 return None
218def relationship(
219 _type: To_Type, condition: Condition = None, join: JOIN_OPTIONS = None, on: OnQuery = None
220) -> Relationship[To_Type]:
221 """
222 Define a relationship to another table, when its id is not stored in the current table.
224 Example:
225 class User(TypedTable):
226 name: str
228 posts = relationship(list["Post"], condition=lambda self, post: self.id == post.author, join='left')
230 class Post(TypedTable):
231 title: str
232 author: User
234 User.join("posts").first() # User instance with list[Post] in .posts
236 Here, Post stores the User ID, but `relationship(list["Post"])` still allows you to get the user's posts.
237 In this case, the join strategy is set to LEFT so users without posts are also still selected.
239 For complex queries with a pivot table, a `on` can be set insteaad of `condition`:
240 class User(TypedTable):
241 ...
243 tags = relationship(list["Tag"], on=lambda self, tag: [
244 Tagged.on(Tagged.entity == entity.gid),
245 Tag.on((Tagged.tag == tag.id)),
246 ])
248 If you'd try to capture this in a single 'condition', pydal would create a cross join which is much less efficient.
249 """
250 return Relationship(_type, condition, join, on)
253def _generate_relationship_condition(
254 _: typing.Type["TypedTable"], key: str, field: typing.Union["TypedField[Any]", "Table", typing.Type["TypedTable"]]
255) -> Condition:
256 origin = typing.get_origin(field)
257 # else: generic
259 if origin == list:
260 # field = typing.get_args(field)[0] # actual field
261 # return lambda _self, _other: cls[key].contains(field)
263 return lambda _self, _other: _self[key].contains(_other.id)
264 else:
265 # normal reference
266 # return lambda _self, _other: cls[key] == field.id
267 return lambda _self, _other: _self[key] == _other.id
270def to_relationship(
271 cls: typing.Type["TypedTable"] | type[Any],
272 key: str,
273 field: typing.Union["TypedField[Any]", "Table", typing.Type["TypedTable"]],
274) -> typing.Optional[Relationship[Any]]:
275 """
276 Used to automatically create relationship instance for reference fields.
278 Example:
279 class MyTable(TypedTable):
280 reference: OtherTable
282 `reference` contains the id of an Other Table row.
283 MyTable.relationships should have 'reference' as a relationship, so `MyTable.join('reference')` should work.
285 This function will automatically perform this logic (called in db.define):
286 to_relationship(MyTable, 'reference', OtherTable) -> Relationship[OtherTable]
288 Also works for list:reference (list[OtherTable]) and TypedField[OtherTable].
289 """
290 if looks_like(field, TypedField):
291 if args := typing.get_args(field):
292 field = args[0]
293 else:
294 # weird
295 return None
297 field, optional = extract_type_optional(field)
299 try:
300 condition = _generate_relationship_condition(cls, key, field)
301 except Exception as e: # pragma: no cover
302 warnings.warn("Could not generate Relationship condition", source=e)
303 condition = None
305 if not condition: # pragma: no cover
306 # something went wrong, not a valid relationship
307 warnings.warn(f"Invalid relationship for {cls.__name__}.{key}: {field}")
308 return None
310 join = "left" if optional or typing.get_origin(field) == list else "inner"
312 return Relationship(typing.cast(type[TypedTable], field), condition, typing.cast(JOIN_OPTIONS, join))
315class TypeDAL(pydal.DAL): # type: ignore
316 """
317 Drop-in replacement for pyDAL with layer to convert class-based table definitions to classical pydal define_tables.
318 """
320 # dal: Table
321 # def __init__(self,
322 # uri="sqlite://dummy.db",
323 # pool_size=0,
324 # folder=None,
325 # db_codec="UTF-8",
326 # check_reserved=None,
327 # migrate=True,
328 # fake_migrate=False,
329 # migrate_enabled=True,
330 # fake_migrate_all=False,
331 # decode_credentials=False,
332 # driver_args=None,
333 # adapter_args=None,
334 # attempts=5,
335 # auto_import=False,
336 # bigint_id=False,
337 # debug=False,
338 # lazy_tables=False,
339 # db_uid=None,
340 # after_connection=None,
341 # tables=None,
342 # ignore_field_case=True,
343 # entity_quoting=True,
344 # table_hash=None,
345 # ):
346 # super().__init__(
347 # uri,
348 # pool_size,
349 # folder,
350 # db_codec,
351 # check_reserved,
352 # migrate,
353 # fake_migrate,
354 # migrate_enabled,
355 # fake_migrate_all,
356 # decode_credentials,
357 # driver_args,
358 # adapter_args,
359 # attempts,
360 # auto_import,
361 # bigint_id,
362 # debug,
363 # lazy_tables,
364 # db_uid,
365 # after_connection,
366 # tables,
367 # ignore_field_case,
368 # entity_quoting,
369 # table_hash,
370 # )
371 # self.representers[TypedField] = lambda x: x
373 default_kwargs: typing.ClassVar[typing.Dict[str, Any]] = {
374 # fields are 'required' (notnull) by default:
375 "notnull": True,
376 }
378 # maps table name to typedal class, for resolving future references
379 _class_map: typing.ClassVar[dict[str, typing.Type["TypedTable"]]] = {}
381 def _define(self, cls: typing.Type[T]) -> typing.Type[T]:
382 # when __future__.annotations is implemented, cls.__annotations__ will not work anymore as below.
383 # proper way to handle this would be (but gives error right now due to Table implementing magic methods):
384 # typing.get_type_hints(cls, globalns=None, localns=None)
386 # dirty way (with evil eval):
387 # [eval(v) for k, v in cls.__annotations__.items()]
388 # this however also stops working when variables outside this scope or even references to other
389 # objects are used. So for now, this package will NOT work when from __future__ import annotations is used,
390 # and might break in the future, when this annotations behavior is enabled by default.
392 # non-annotated variables have to be passed to define_table as kwargs
393 full_dict = all_dict(cls) # includes properties from parents (e.g. useful for mixins)
395 tablename = self.to_snake(cls.__name__)
396 # grab annotations of cls and it's parents:
397 annotations = all_annotations(cls)
398 # extend with `prop = TypedField()` 'annotations':
399 annotations |= {k: typing.cast(type, v) for k, v in full_dict.items() if is_typed_field(v)}
400 # remove internal stuff:
401 annotations = {k: v for k, v in annotations.items() if not k.startswith("_")}
403 typedfields: dict[str, TypedField[Any]] = {
404 k: instanciate(v, True) for k, v in annotations.items() if is_typed_field(v)
405 }
407 relationships: dict[str, type[Relationship[Any]]] = filter_out(annotations, Relationship)
409 fields = {fname: self._to_field(fname, ftype) for fname, ftype in annotations.items()}
411 # ! dont' use full_dict here:
412 other_kwargs = {k: v for k, v in cls.__dict__.items() if k not in annotations and not k.startswith("_")}
414 for key in typedfields.keys() - full_dict.keys():
415 # typed fields that don't haven't been added to the object yet
416 setattr(cls, key, typedfields[key])
418 # start with base classes and overwrite with current class:
419 relationships = filter_out(full_dict, Relationship) | relationships | filter_out(other_kwargs, Relationship)
421 # DEPRECATED: Relationship as annotation is currently not supported!
422 # ensure they are all instances and
423 # not mix of instances (`= relationship()`) and classes (`: Relationship[...]`):
424 # relationships = {
425 # k: v if isinstance(v, Relationship) else to_relationship(cls, k, v) for k, v in relationships.items()
426 # }
428 # keys of implicit references (also relationships):
429 reference_field_keys = [k for k, v in fields.items() if v.type.split(" ")[0] in ("list:reference", "reference")]
431 # add implicit relationships:
432 # User; list[User]; TypedField[User]; TypedField[list[User]]
433 relationships |= {
434 k: new_relationship
435 for k in reference_field_keys
436 if k not in relationships and (new_relationship := to_relationship(cls, k, annotations[k]))
437 }
439 table: Table = self.define_table(tablename, *fields.values(), **other_kwargs)
441 for name, typed_field in typedfields.items():
442 field = fields[name]
443 typed_field.bind(field, table)
445 if issubclass(cls, TypedTable):
446 cls.__set_internals__(
447 db=self,
448 table=table,
449 # by now, all relationships should be instances!
450 relationships=typing.cast(dict[str, Relationship[Any]], relationships),
451 )
452 self._class_map[str(table)] = cls
453 else:
454 warnings.warn("db.define used without inheriting TypedTable. This could lead to strange problems!")
456 return cls
458 @typing.overload
459 def define(self, maybe_cls: None = None) -> typing.Callable[[typing.Type[T]], typing.Type[T]]:
460 """
461 Typing Overload for define without a class.
463 @db.define()
464 class MyTable(TypedTable): ...
465 """
467 @typing.overload
468 def define(self, maybe_cls: typing.Type[T]) -> typing.Type[T]:
469 """
470 Typing Overload for define with a class.
472 @db.define
473 class MyTable(TypedTable): ...
474 """
476 def define(
477 self, maybe_cls: typing.Type[T] | None = None
478 ) -> typing.Type[T] | typing.Callable[[typing.Type[T]], typing.Type[T]]:
479 """
480 Can be used as a decorator on a class that inherits `TypedTable`, \
481 or as a regular method if you need to define your classes before you have access to a 'db' instance.
483 Example:
484 @db.define
485 class Person(TypedTable):
486 ...
488 class Article(TypedTable):
489 ...
491 # at a later time:
492 db.define(Article)
494 Returns:
495 the result of pydal.define_table
496 """
498 def wrapper(cls: typing.Type[T]) -> typing.Type[T]:
499 return self._define(cls)
501 if maybe_cls:
502 return wrapper(maybe_cls)
504 return wrapper
506 # def drop(self, table_name: str) -> None:
507 # """
508 # Remove a table by name (both on the database level and the typedal level).
509 # """
510 # # drop calls TypedTable.drop() and removes it from the `_class_map`
511 # if cls := self._class_map.pop(table_name, None):
512 # cls.drop()
514 # def drop_all(self, max_retries: int = None) -> None:
515 # """
516 # Remove all tables and keep doing so until everything is gone!
517 # """
518 # retries = 0
519 # if max_retries is None:
520 # max_retries = len(self.tables)
521 #
522 # while self.tables:
523 # retries += 1
524 # for table in self.tables:
525 # self.drop(table)
526 #
527 # if retries > max_retries:
528 # raise RuntimeError("Could not delete all tables")
530 def __call__(self, *_args: T_Query, **kwargs: Any) -> "TypedSet":
531 """
532 A db instance can be called directly to perform a query.
534 Usually, only a query is passed.
536 Example:
537 db(query).select()
539 """
540 args = list(_args)
541 if args:
542 cls = args[0]
543 if isinstance(cls, bool):
544 raise ValueError("Don't actually pass a bool to db()! Use a query instead.")
546 if isinstance(cls, type) and issubclass(type(cls), type) and issubclass(cls, TypedTable):
547 # table defined without @db.define decorator!
548 _cls: typing.Type[TypedTable] = cls
549 args[0] = _cls.id != None
551 _set = super().__call__(*args, **kwargs)
552 return typing.cast(TypedSet, _set)
554 @classmethod
555 def _build_field(cls, name: str, _type: str, **kw: Any) -> Field:
556 return Field(name, _type, **{**cls.default_kwargs, **kw})
558 @classmethod
559 def _annotation_to_pydal_fieldtype(
560 cls, _ftype: T_annotation, mut_kw: typing.MutableMapping[str, Any]
561 ) -> Optional[str]:
562 # ftype can be a union or type. typing.cast is sometimes used to tell mypy when it's not a union.
563 ftype = typing.cast(type, _ftype) # cast from typing.Type to type to make mypy happy)
565 if isinstance(ftype, str):
566 # extract type from string
567 ftype = typing.get_args(typing.Type[ftype])[0]._evaluate(
568 localns=locals(), globalns=globals(), recursive_guard=frozenset()
569 )
571 if mapping := BASIC_MAPPINGS.get(ftype):
572 # basi types
573 return mapping
574 elif isinstance(ftype, _Table):
575 # db.table
576 return f"reference {ftype._tablename}"
577 elif issubclass(type(ftype), type) and issubclass(ftype, TypedTable):
578 # SomeTable
579 snakename = cls.to_snake(ftype.__name__)
580 return f"reference {snakename}"
581 elif isinstance(ftype, TypedField):
582 # FieldType(type, ...)
583 return ftype._to_field(mut_kw)
584 elif origin_is_subclass(ftype, TypedField):
585 # TypedField[int]
586 return cls._annotation_to_pydal_fieldtype(typing.get_args(ftype)[0], mut_kw)
587 elif isinstance(ftype, types.GenericAlias) and typing.get_origin(ftype) in (list, TypedField):
588 # list[str] -> str -> string -> list:string
589 _child_type = typing.get_args(ftype)[0]
590 _child_type = cls._annotation_to_pydal_fieldtype(_child_type, mut_kw)
591 return f"list:{_child_type}"
592 elif is_union(ftype):
593 # str | int -> UnionType
594 # typing.Union[str | int] -> typing._UnionGenericAlias
596 # Optional[type] == type | None
598 match typing.get_args(ftype):
599 case (_child_type, _Types.NONETYPE) | (_Types.NONETYPE, _child_type):
600 # good union of Nullable
602 # if a field is optional, it is nullable:
603 mut_kw["notnull"] = False
604 return cls._annotation_to_pydal_fieldtype(_child_type, mut_kw)
605 case _:
606 # two types is not supported by the db!
607 return None
608 else:
609 return None
611 @classmethod
612 def _to_field(cls, fname: str, ftype: type, **kw: Any) -> Field:
613 """
614 Convert a annotation into a pydal Field.
616 Args:
617 fname: name of the property
618 ftype: annotation of the property
619 kw: when using TypedField or a function returning it (e.g. StringField),
620 keyword args can be used to pass any other settings you would normally to a pydal Field
622 -> pydal.Field(fname, ftype, **kw)
624 Example:
625 class MyTable:
626 fname: ftype
627 id: int
628 name: str
629 reference: Table
630 other: TypedField(str, default="John Doe") # default will be in kwargs
631 """
632 fname = cls.to_snake(fname)
634 if converted_type := cls._annotation_to_pydal_fieldtype(ftype, kw):
635 return cls._build_field(fname, converted_type, **kw)
636 else:
637 raise NotImplementedError(f"Unsupported type {ftype}/{type(ftype)}")
639 @staticmethod
640 def to_snake(camel: str) -> str:
641 """
642 Moved to helpers, kept as a static method for legacy reasons.
643 """
644 return to_snake(camel)
647class TableProtocol(typing.Protocol): # pragma: no cover
648 """
649 Make mypy happy.
650 """
652 id: int # noqa: A003
654 def __getitem__(self, item: str) -> Field:
655 """
656 Tell mypy a Table supports dictionary notation for columns.
657 """
660class Table(_Table, TableProtocol): # type: ignore
661 """
662 Make mypy happy.
663 """
666class TableMeta(type):
667 """
668 This metaclass contains functionality on table classes, that doesn't exist on its instances.
670 Example:
671 class MyTable(TypedTable):
672 some_field: TypedField[int]
674 MyTable.update_or_insert(...) # should work
676 MyTable.some_field # -> Field, can be used to query etc.
678 row = MyTable.first() # returns instance of MyTable
680 # row.update_or_insert(...) # shouldn't work!
682 row.some_field # -> int, with actual data
684 """
686 # set up by db.define:
687 # _db: TypeDAL | None = None
688 # _table: Table | None = None
689 _db: TypeDAL | None = None
690 _table: Table | None = None
691 _relationships: dict[str, Relationship[Any]] | None = None
693 #########################
694 # TypeDAL custom logic: #
695 #########################
697 def __set_internals__(self, db: pydal.DAL, table: Table, relationships: dict[str, Relationship[Any]]) -> None:
698 """
699 Store the related database and pydal table for later usage.
700 """
701 self._db = db
702 self._table = table
703 self._relationships = relationships
705 def __getattr__(self, col: str) -> Field:
706 """
707 Magic method used by TypedTableMeta to get a database field with dot notation on a class.
709 Example:
710 SomeTypedTable.col -> db.table.col (via TypedTableMeta.__getattr__)
712 """
713 if self._table:
714 return getattr(self._table, col, None)
716 def _ensure_table_defined(self) -> Table:
717 if not self._table:
718 raise EnvironmentError("@define or db.define is not called on this class yet!")
719 return self._table
721 def __iter__(self) -> typing.Generator[Field, None, None]:
722 """
723 Loop through the columns of this model.
724 """
725 table = self._ensure_table_defined()
726 yield from iter(table)
728 def __getitem__(self, item: str) -> Field:
729 """
730 Allow dict notation to get a column of this table (-> Field instance).
731 """
732 table = self._ensure_table_defined()
733 return table[item]
735 def __str__(self) -> str:
736 """
737 Normally, just returns the underlying table name, but with a fallback if the model is unbound.
738 """
739 if self._table:
740 return str(self._table)
741 else:
742 return f"<unbound table {self.__name__}>"
744 def from_row(self: typing.Type[T_MetaInstance], row: pydal.objects.Row) -> T_MetaInstance:
745 """
746 Create a model instance from a pydal row.
747 """
748 return self(row)
750 def all(self: typing.Type[T_MetaInstance]) -> "TypedRows[T_MetaInstance]": # noqa: A003
751 """
752 Return all rows for this model.
753 """
754 return self.collect()
756 def __json__(self: typing.Type[T_MetaInstance], instance: T_MetaInstance | None = None) -> dict[str, Any]:
757 """
758 Convert to a json-dumpable dict.
760 as_dict is not fully json-dumpable, so use as_json and json.loads to ensure it is dumpable (and loadable).
761 todo: can this be optimized?
763 See Also:
764 https://github.com/jeff-hykin/json_fix
765 """
766 string = instance.as_json() if instance else self.as_json()
768 return typing.cast(dict[str, Any], json.loads(string))
770 def get_relationships(self) -> dict[str, Relationship[Any]]:
771 """
772 Return the registered relationships of the current model.
773 """
774 return self._relationships or {}
776 ##########################
777 # TypeDAL Modified Logic #
778 ##########################
780 def insert(self: typing.Type[T_MetaInstance], **fields: Any) -> T_MetaInstance:
781 """
782 This is only called when db.define is not used as a decorator.
784 cls.__table functions as 'self'
786 Args:
787 **fields: anything you want to insert in the database
789 Returns: the ID of the new row.
791 """
792 table = self._ensure_table_defined()
794 result = table.insert(**fields)
795 # it already is an int but mypy doesn't understand that
796 return self(result)
798 def bulk_insert(self: typing.Type[T_MetaInstance], items: list[dict[str, Any]]) -> "TypedRows[T_MetaInstance]":
799 """
800 Insert multiple rows, returns a TypedRows set of new instances.
801 """
802 table = self._ensure_table_defined()
803 result = table.bulk_insert(items)
804 return self.where(lambda row: row.id.belongs(result)).collect()
806 def update_or_insert(
807 self: typing.Type[T_MetaInstance], query: T_Query | dict[str, Any] = DEFAULT, **values: Any
808 ) -> T_MetaInstance:
809 """
810 Update a row if query matches, else insert a new one.
812 Returns the created or updated instance.
813 """
814 table = self._ensure_table_defined()
816 if query is DEFAULT:
817 record = table(**values)
818 elif isinstance(query, dict):
819 record = table(**query)
820 else:
821 record = table(query)
823 if not record:
824 return self.insert(**values)
826 record.update_record(**values)
827 return self(record)
829 def validate_and_insert(
830 self: typing.Type[T_MetaInstance], **fields: Any
831 ) -> tuple[Optional[T_MetaInstance], Optional[dict[str, str]]]:
832 """
833 Validate input data and then insert a row.
835 Returns a tuple of (the created instance, a dict of errors).
836 """
837 table = self._ensure_table_defined()
838 result = table.validate_and_insert(**fields)
839 if row_id := result.get("id"):
840 return self(row_id), None
841 else:
842 return None, result.get("errors")
844 def validate_and_update(
845 self: typing.Type[T_MetaInstance], query: Query, **fields: Any
846 ) -> tuple[Optional[T_MetaInstance], Optional[dict[str, str]]]:
847 """
848 Validate input data and then update max 1 row.
850 Returns a tuple of (the updated instance, a dict of errors).
851 """
852 table = self._ensure_table_defined()
854 try:
855 result = table.validate_and_update(query, **fields)
856 except Exception as e:
857 result = {"errors": {"exception": str(e)}}
859 if errors := result.get("errors"):
860 return None, errors
861 elif row_id := result.get("id"):
862 return self(row_id), None
863 else: # pragma: no cover
864 # update on query without result (shouldnt happen)
865 return None, None
867 def validate_and_update_or_insert(
868 self: typing.Type[T_MetaInstance], query: Query, **fields: Any
869 ) -> tuple[Optional[T_MetaInstance], Optional[dict[str, str]]]:
870 """
871 Validate input data and then update_and_insert (on max 1 row).
873 Returns a tuple of (the updated/created instance, a dict of errors).
874 """
875 table = self._ensure_table_defined()
876 result = table.validate_and_update_or_insert(query, **fields)
878 if errors := result.get("errors"):
879 return None, errors
880 elif row_id := result.get("id"):
881 return self(row_id), None
882 else: # pragma: no cover
883 # update on query without result (shouldnt happen)
884 return None, None
886 def select(self: typing.Type[T_MetaInstance], *a: Any, **kw: Any) -> "QueryBuilder[T_MetaInstance]":
887 """
888 See QueryBuilder.select!
889 """
890 return QueryBuilder(self).select(*a, **kw)
892 def paginate(self: typing.Type[T_MetaInstance], limit: int, page: int = 1) -> "PaginatedRows[T_MetaInstance]":
893 """
894 See QueryBuilder.paginate!
895 """
896 return QueryBuilder(self).paginate(limit=limit, page=page)
898 def where(self: typing.Type[T_MetaInstance], *a: Any, **kw: Any) -> "QueryBuilder[T_MetaInstance]":
899 """
900 See QueryBuilder.where!
901 """
902 return QueryBuilder(self).where(*a, **kw)
904 def count(self: typing.Type[T_MetaInstance]) -> int:
905 """
906 See QueryBuilder.count!
907 """
908 return QueryBuilder(self).count()
910 def first(self: typing.Type[T_MetaInstance]) -> T_MetaInstance | None:
911 """
912 See QueryBuilder.first!
913 """
914 return QueryBuilder(self).first()
916 def join(
917 self: typing.Type[T_MetaInstance], *fields: str, method: JOIN_OPTIONS = None
918 ) -> "QueryBuilder[T_MetaInstance]":
919 """
920 See QueryBuilder.join!
921 """
922 return QueryBuilder(self).join(*fields, method=method)
924 def collect(self: typing.Type[T_MetaInstance], verbose: bool = False) -> "TypedRows[T_MetaInstance]":
925 """
926 See QueryBuilder.collect!
927 """
928 return QueryBuilder(self).collect(verbose=verbose)
930 @property
931 def ALL(cls) -> pydal.objects.SQLALL:
932 """
933 Select all fields for this table.
934 """
935 table = cls._ensure_table_defined()
937 return table.ALL
939 ##########################
940 # TypeDAL Shadowed Logic #
941 ##########################
942 fields: list[str]
944 # other table methods:
946 def drop(self, mode: str = "") -> None:
947 """
948 Remove the underlying table.
949 """
950 table = self._ensure_table_defined()
951 table.drop(mode)
953 def create_index(self, name: str, *fields: Field | str, **kwargs: Any) -> bool:
954 """
955 Add an index on some columns of this table.
956 """
957 table = self._ensure_table_defined()
958 result = table.create_index(name, *fields, **kwargs)
959 return typing.cast(bool, result)
961 def drop_index(self, name: str, if_exists: bool = False) -> bool:
962 """
963 Remove an index from this table.
964 """
965 table = self._ensure_table_defined()
966 result = table.drop_index(name, if_exists)
967 return typing.cast(bool, result)
969 def import_from_csv_file(
970 self,
971 csvfile: typing.TextIO,
972 id_map: dict[str, str] = None,
973 null: str = "<NULL>",
974 unique: str = "uuid",
975 id_offset: dict[str, int] = None, # id_offset used only when id_map is None
976 transform: typing.Callable[[dict[Any, Any]], dict[Any, Any]] = None,
977 validate: bool = False,
978 encoding: str = "utf-8",
979 delimiter: str = ",",
980 quotechar: str = '"',
981 quoting: int = csv.QUOTE_MINIMAL,
982 restore: bool = False,
983 **kwargs: Any,
984 ) -> None:
985 """
986 Load a csv file into the database.
987 """
988 table = self._ensure_table_defined()
989 table.import_from_csv_file(
990 csvfile,
991 id_map=id_map,
992 null=null,
993 unique=unique,
994 id_offset=id_offset,
995 transform=transform,
996 validate=validate,
997 encoding=encoding,
998 delimiter=delimiter,
999 quotechar=quotechar,
1000 quoting=quoting,
1001 restore=restore,
1002 **kwargs,
1003 )
1005 def on(self, query: Query) -> Expression:
1006 """
1007 Shadow Table.on.
1009 Used for joins.
1011 See Also:
1012 http://web2py.com/books/default/chapter/29/06/the-database-abstraction-layer?search=export_to_csv_file#One-to-many-relation
1013 """
1014 table = self._ensure_table_defined()
1015 return typing.cast(Expression, table.on(query))
1017 def with_alias(self, alias: str) -> _Table:
1018 """
1019 Shadow Table.with_alias.
1021 Useful for joins when joining the same table multiple times.
1023 See Also:
1024 http://web2py.com/books/default/chapter/29/06/the-database-abstraction-layer?search=export_to_csv_file#One-to-many-relation
1025 """
1026 table = self._ensure_table_defined()
1027 return table.with_alias(alias)
1029 # @typing.dataclass_transform()
1032class TypedTable(metaclass=TableMeta):
1033 """
1034 Enhanded modeling system on top of pydal's Table that adds typing and additional functionality.
1035 """
1037 # set up by 'new':
1038 _row: Row | None = None
1040 _with: list[str]
1042 id: "TypedField[int]" # noqa: A003
1044 def _setup_instance_methods(self) -> None:
1045 self.as_dict = self._as_dict # type: ignore
1046 self.__json__ = self.as_json = self._as_json # type: ignore
1047 # self.as_yaml = self._as_yaml # type: ignore
1048 self.as_xml = self._as_xml # type: ignore
1050 self.update = self._update # type: ignore
1052 self.delete_record = self._delete_record # type: ignore
1053 self.update_record = self._update_record # type: ignore
1055 def __new__(
1056 cls, row_or_id: typing.Union[Row, Query, pydal.objects.Set, int, str, None, "TypedTable"] = None, **filters: Any
1057 ) -> "TypedTable":
1058 """
1059 Create a Typed Rows model instance from an existing row, ID or query.
1061 Examples:
1062 MyTable(1)
1063 MyTable(id=1)
1064 MyTable(MyTable.id == 1)
1065 """
1066 table = cls._ensure_table_defined()
1068 if isinstance(row_or_id, TypedTable):
1069 # existing typed table instance!
1070 return row_or_id
1071 elif isinstance(row_or_id, pydal.objects.Row):
1072 row = row_or_id
1073 elif row_or_id is not None:
1074 row = table(row_or_id, **filters)
1075 else:
1076 row = table(**filters)
1078 if not row:
1079 return None # type: ignore
1081 inst = super().__new__(cls)
1082 inst._row = row
1083 inst.__dict__.update(row)
1084 inst._setup_instance_methods()
1085 return inst
1087 def __iter__(self) -> typing.Generator[Any, None, None]:
1088 """
1089 Allows looping through the columns.
1090 """
1091 row = self._ensure_matching_row()
1092 yield from iter(row)
1094 def __getitem__(self, item: str) -> Any:
1095 """
1096 Allows dictionary notation to get columns.
1097 """
1098 if item in self.__dict__:
1099 return self.__dict__.get(item)
1101 # fallback to lookup in row
1102 if self._row:
1103 return self._row[item]
1105 # nothing found!
1106 raise KeyError(item)
1108 def __getattr__(self, item: str) -> Any:
1109 """
1110 Allows dot notation to get columns.
1111 """
1112 if value := self.get(item):
1113 return value
1115 raise AttributeError(item)
1117 def get(self, item: str, default: Any = None) -> Any:
1118 """
1119 Try to get a column from this instance, else return default.
1120 """
1121 try:
1122 return self.__getitem__(item)
1123 except KeyError:
1124 return default
1126 def __setitem__(self, key: str, value: Any) -> None:
1127 """
1128 Data can both be updated via dot and dict notation.
1129 """
1130 return setattr(self, key, value)
1132 def __int__(self) -> int:
1133 """
1134 Calling int on a model instance will return its id.
1135 """
1136 return getattr(self, "id", 0)
1138 def __bool__(self) -> bool:
1139 """
1140 If the instance has an underlying row with data, it is truthy.
1141 """
1142 return bool(getattr(self, "_row", False))
1144 def _ensure_matching_row(self) -> Row:
1145 if not getattr(self, "_row", None):
1146 raise EnvironmentError("Trying to access non-existant row. Maybe it was deleted or not yet initialized?")
1147 return self._row
1149 def __repr__(self) -> str:
1150 """
1151 String representation of the model instance.
1152 """
1153 model_name = self.__class__.__name__
1154 model_data = {}
1156 if self._row:
1157 model_data = self._row.as_json()
1159 details = model_name
1160 details += f"({model_data})"
1162 if relationships := getattr(self, "_with", []):
1163 details += f" + {relationships}"
1165 return f"<{details}>"
1167 # serialization
1168 # underscore variants work for class instances (set up by _setup_instance_methods)
1170 @classmethod
1171 def as_dict(cls, flat: bool = False, sanitize: bool = True) -> dict[str, Any]:
1172 """
1173 Dump the object to a plain dict.
1175 Can be used as both a class or instance method:
1176 - dumps the table info if it's a class
1177 - dumps the row info if it's an instance (see _as_dict)
1178 """
1179 table = cls._ensure_table_defined()
1180 result = table.as_dict(flat, sanitize)
1181 return typing.cast(dict[str, Any], result)
1183 @classmethod
1184 def as_json(cls, sanitize: bool = True) -> str:
1185 """
1186 Dump the object to json.
1188 Can be used as both a class or instance method:
1189 - dumps the table info if it's a class
1190 - dumps the row info if it's an instance (see _as_json)
1191 """
1192 table = cls._ensure_table_defined()
1193 return typing.cast(str, table.as_json(sanitize))
1195 @classmethod
1196 def as_xml(cls, sanitize: bool = True) -> str: # pragma: no cover
1197 """
1198 Dump the object to xml.
1200 Can be used as both a class or instance method:
1201 - dumps the table info if it's a class
1202 - dumps the row info if it's an instance (see _as_xml)
1203 """
1204 table = cls._ensure_table_defined()
1205 return typing.cast(str, table.as_xml(sanitize))
1207 @classmethod
1208 def as_yaml(cls, sanitize: bool = True) -> str:
1209 """
1210 Dump the object to yaml.
1212 Can be used as both a class or instance method:
1213 - dumps the table info if it's a class
1214 - dumps the row info if it's an instance (see _as_yaml)
1215 """
1216 table = cls._ensure_table_defined()
1217 return typing.cast(str, table.as_yaml(sanitize))
1219 def _as_dict(
1220 self, datetime_to_str: bool = False, custom_types: typing.Iterable[type] | type | None = None
1221 ) -> dict[str, Any]:
1222 row = self._ensure_matching_row()
1223 result = row.as_dict(datetime_to_str=datetime_to_str, custom_types=custom_types)
1225 if _with := getattr(self, "_with", None):
1226 for relationship in _with:
1227 data = self.get(relationship)
1228 if isinstance(data, list):
1229 data = [_.as_dict() if getattr(_, "as_dict", None) else _ for _ in data]
1230 elif data:
1231 data = data.as_dict()
1233 result[relationship] = data
1235 return typing.cast(dict[str, Any], result)
1237 def _as_json(
1238 self,
1239 mode: str = "object",
1240 default: typing.Callable[[Any], Any] = None,
1241 colnames: list[str] = None,
1242 serialize: bool = True,
1243 **kwargs: Any,
1244 ) -> str:
1245 row = self._ensure_matching_row()
1246 return typing.cast(str, row.as_json(mode, default, colnames, serialize, *kwargs))
1248 def _as_xml(self, sanitize: bool = True) -> str: # pragma: no cover
1249 row = self._ensure_matching_row()
1250 return typing.cast(str, row.as_xml(sanitize))
1252 # def _as_yaml(self, sanitize: bool = True) -> str:
1253 # row = self._ensure_matching_row()
1254 # return typing.cast(str, row.as_yaml(sanitize))
1256 def __setattr__(self, key: str, value: Any) -> None:
1257 """
1258 When setting a property on a Typed Table model instance, also update the underlying row.
1259 """
1260 if self._row and key in self._row.__dict__ and not callable(value):
1261 # enables `row.key = value; row.update_record()`
1262 self._row[key] = value
1264 super().__setattr__(key, value)
1266 @classmethod
1267 def update(cls: typing.Type[T_MetaInstance], query: Query, **fields: Any) -> T_MetaInstance | None:
1268 """
1269 Update one record.
1271 Example:
1272 MyTable.update(MyTable.id == 1, name="NewName") -> MyTable
1273 """
1274 if record := cls(query):
1275 return record.update_record(**fields)
1276 else:
1277 return None
1279 def _update(self: T_MetaInstance, **fields: Any) -> T_MetaInstance:
1280 row = self._ensure_matching_row()
1281 row.update(**fields)
1282 self.__dict__.update(**fields)
1283 return self
1285 def _update_record(self: T_MetaInstance, **fields: Any) -> T_MetaInstance:
1286 row = self._ensure_matching_row()
1287 new_row = row.update_record(**fields)
1288 self.update(**new_row)
1289 return self
1291 def update_record(self: T_MetaInstance, **fields: Any) -> T_MetaInstance: # pragma: no cover
1292 """
1293 Here as a placeholder for _update_record.
1295 Will be replaced on instance creation!
1296 """
1297 return self._update_record(**fields)
1299 def _delete_record(self) -> int:
1300 """
1301 Actual logic in `pydal.helpers.classes.RecordDeleter`.
1302 """
1303 row = self._ensure_matching_row()
1304 result = row.delete_record()
1305 self.__dict__ = {} # empty self, since row is no more.
1306 self._row = None # just to be sure
1307 self._setup_instance_methods()
1308 # ^ instance methods might've been deleted by emptying dict,
1309 # but we still want .as_dict to show an error, not the table's as_dict.
1310 return typing.cast(int, result)
1312 def delete_record(self) -> int: # pragma: no cover
1313 """
1314 Here as a placeholder for _delete_record.
1316 Will be replaced on instance creation!
1317 """
1318 return self._delete_record()
1320 # __del__ is also called on the end of a scope so don't remove records on every del!!
1323# backwards compat:
1324TypedRow = TypedTable
1327class QueryBuilder(typing.Generic[T_MetaInstance]):
1328 """
1329 Abstration on top of pydal's query system.
1330 """
1332 model: typing.Type[T_MetaInstance]
1333 query: Query
1334 select_args: list[Any]
1335 select_kwargs: dict[str, Any]
1336 relationships: dict[str, Relationship[Any]]
1337 metadata: dict[str, Any]
1339 def __init__(
1340 self,
1341 model: typing.Type[T_MetaInstance],
1342 add_query: Optional[Query] = None,
1343 select_args: Optional[list[Any]] = None,
1344 select_kwargs: Optional[dict[str, Any]] = None,
1345 relationships: dict[str, Relationship[Any]] = None,
1346 metadata: dict[str, Any] = None,
1347 ):
1348 """
1349 Normally, you wouldn't manually initialize a QueryBuilder but start using a method on a TypedTable.
1351 Example:
1352 MyTable.where(...) -> QueryBuilder[MyTable]
1353 """
1354 self.model = model
1355 table = model._ensure_table_defined()
1356 default_query = typing.cast(Query, table.id > 0)
1357 self.query = add_query or default_query
1358 self.select_args = select_args or []
1359 self.select_kwargs = select_kwargs or {}
1360 self.relationships = relationships or {}
1361 self.metadata = metadata or {}
1363 def _extend(
1364 self,
1365 add_query: Optional[Query] = None,
1366 overwrite_query: Optional[Query] = None,
1367 select_args: Optional[list[Any]] = None,
1368 select_kwargs: Optional[dict[str, Any]] = None,
1369 relationships: dict[str, Relationship[Any]] = None,
1370 metadata: dict[str, Any] = None,
1371 ) -> "QueryBuilder[T_MetaInstance]":
1372 return QueryBuilder(
1373 self.model,
1374 (add_query & self.query) if add_query else overwrite_query or self.query,
1375 (self.select_args + select_args) if select_args else self.select_args,
1376 (self.select_kwargs | select_kwargs) if select_kwargs else self.select_kwargs,
1377 (self.relationships | relationships) if relationships else self.relationships,
1378 (self.metadata | metadata) if metadata else self.metadata,
1379 )
1381 def select(self, *fields: Any, **options: Any) -> "QueryBuilder[T_MetaInstance]":
1382 """
1383 Fields: database columns by name ('id'), by field reference (table.id) or other (e.g. table.ALL).
1385 Options:
1386 paraphrased from the web2py pydal docs,
1387 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
1389 orderby: field(s) to order by. Supported:
1390 table.name - sort by name, ascending
1391 ~table.name - sort by name, descending
1392 <random> - sort randomly
1393 table.name|table.id - sort by two fields (first name, then id)
1395 groupby, having: together with orderby:
1396 groupby can be a field (e.g. table.name) to group records by
1397 having can be a query, only those `having` the condition are grouped
1399 limitby: tuple of min and max. When using the query builder, .paginate(limit, page) is recommended.
1400 distinct: bool/field. Only select rows that differ
1401 orderby_on_limitby (bool, default: True): by default, an implicit orderby is added when doing limitby.
1402 join: othertable.on(query) - do an INNER JOIN. Using TypeDAL relationships with .join() is recommended!
1403 left: othertable.on(query) - do a LEFT JOIN. Using TypeDAL relationships with .join() is recommended!
1404 cache: cache the query result to speed up repeated queries; e.g. (cache=(cache.ram, 3600), cacheable=True)
1405 """
1406 return self._extend(select_args=list(fields), select_kwargs=options)
1408 def where(
1409 self,
1410 *queries_or_lambdas: Query | typing.Callable[[typing.Type[T_MetaInstance]], Query],
1411 **filters: Any,
1412 ) -> "QueryBuilder[T_MetaInstance]":
1413 """
1414 Extend the builder's query.
1416 Can be used in multiple ways:
1417 .where(Query) -> with a direct query such as `Table.id == 5`
1418 .where(lambda table: table.id == 5) -> with a query via a lambda
1419 .where(id=5) -> via keyword arguments
1421 When using multiple where's, they will be ANDed:
1422 .where(lambda table: table.id == 5).where(lambda table: table.id == 6) == (table.id == 5) & (table.id=6)
1423 When passing multiple queries to a single .where, they will be ORed:
1424 .where(lambda table: table.id == 5, lambda table: table.id == 6) == (table.id == 5) | (table.id=6)
1425 """
1426 new_query = self.query
1427 table = self.model._ensure_table_defined()
1429 for field, value in filters.items():
1430 new_query &= table[field] == value
1432 subquery = DummyQuery()
1433 for query_or_lambda in queries_or_lambdas:
1434 if isinstance(query_or_lambda, _Query):
1435 subquery |= query_or_lambda
1436 elif callable(query_or_lambda):
1437 if result := query_or_lambda(self.model):
1438 subquery |= result
1439 elif isinstance(query_or_lambda, Field) or is_typed_field(query_or_lambda):
1440 subquery |= query_or_lambda != None
1441 else:
1442 raise ValueError(f"Unexpected query type ({type(query_or_lambda)}).")
1444 if subquery:
1445 new_query &= subquery
1447 return self._extend(overwrite_query=new_query)
1449 def join(self, *fields: str, method: JOIN_OPTIONS = None) -> "QueryBuilder[T_MetaInstance]":
1450 """
1451 Include relationship fields in the result.
1453 `fields` can be names of Relationships on the current model.
1454 If no fields are passed, all will be used.
1456 By default, the `method` defined in the relationship is used.
1457 This can be overwritten with the `method` keyword argument (left or inner)
1458 """
1459 relationships = self.model.get_relationships()
1461 if fields:
1462 # join on every relationship
1463 relationships = {k: relationships[k] for k in fields}
1465 if method:
1466 relationships = {k: r.clone(join=method) for k, r in relationships.items()}
1468 return self._extend(relationships=relationships)
1470 def _get_db(self) -> TypeDAL:
1471 if db := self.model._db:
1472 return db
1473 else: # pragma: no cover
1474 raise EnvironmentError("@define or db.define is not called on this class yet!")
1476 def _select_arg_convert(self, arg: Any) -> str | Field:
1477 # typedfield are not really used at runtime anymore, but leave it in for safety:
1478 if isinstance(arg, TypedField): # pragma: no cover
1479 arg = arg._field
1481 return arg
1483 def delete(self) -> list[int] | None:
1484 """
1485 Based on the current query, delete rows and return a list of deleted IDs.
1486 """
1487 db = self._get_db()
1488 removed_ids = [_.id for _ in db(self.query).select("id")]
1489 if db(self.query).delete():
1490 # success!
1491 return removed_ids
1493 return None
1495 def update(self, **fields: Any) -> list[int] | None:
1496 """
1497 Based on the current query, update `fields` and return a list of updated IDs.
1498 """
1499 db = self._get_db()
1500 updated_ids = db(self.query).select("id").column("id")
1501 if db(self.query).update(**fields):
1502 # success!
1503 return updated_ids
1505 return None
1507 def collect(self, verbose: bool = False, _to: typing.Type["TypedRows[Any]"] = None) -> "TypedRows[T_MetaInstance]":
1508 """
1509 Execute the built query and turn it into model instances, while handling relationships.
1510 """
1511 if _to is None:
1512 _to = TypedRows
1514 db = self._get_db()
1516 select_args = [self._select_arg_convert(_) for _ in self.select_args] or [self.model.ALL]
1517 select_kwargs = self.select_kwargs.copy()
1518 metadata = self.metadata.copy()
1519 query = self.query
1520 model = self.model
1522 metadata["query"] = query
1524 # require at least id of main table:
1525 select_fields = ", ".join([str(_) for _ in select_args])
1526 tablename = str(model)
1528 if f"{tablename}.id" not in select_fields:
1529 # fields of other selected, but required ID is missing.
1530 select_args.append(model.id)
1532 if self.relationships:
1533 query, select_args = self._handle_relationships_pre_select(query, select_args, select_kwargs, metadata)
1535 rows: Rows = db(query).select(*select_args, **select_kwargs)
1537 metadata["final_query"] = str(query)
1538 metadata["final_args"] = [str(_) for _ in select_args]
1539 metadata["final_kwargs"] = select_kwargs
1541 metadata["sql"] = db(query)._select(*select_args, **select_kwargs)
1543 if verbose: # pragma: no cover
1544 print(metadata["sql"])
1545 print(rows)
1547 if not self.relationships:
1548 # easy
1549 return _to.from_rows(rows, self.model, metadata=metadata)
1551 # harder: try to match rows to the belonging objects
1552 # assume structure of {'table': <data>} per row.
1553 # if that's not the case, return default behavior again
1555 return self._collect_with_relationships(rows, metadata=metadata, _to=_to)
1557 def _handle_relationships_pre_select(
1558 self,
1559 query: Query,
1560 select_args: list[Any],
1561 select_kwargs: dict[str, Any],
1562 metadata: dict[str, Any],
1563 ) -> tuple[Query, list[Any]]:
1564 db = self._get_db()
1565 model = self.model
1567 metadata["relationships"] = set(self.relationships.keys())
1568 if limitby := select_kwargs.pop("limitby", None):
1569 # if limitby + relationships:
1570 # 1. get IDs of main table entries that match 'query'
1571 # 2. change query to .belongs(id)
1572 # 3. add joins etc
1574 ids = db(query)._select(model.id, limitby=limitby)
1575 query = model.id.belongs(ids)
1576 metadata["ids"] = ids
1578 left = []
1580 for key, relation in self.relationships.items():
1581 other = relation.get_table(db)
1582 method: JOIN_OPTIONS = relation.join or DEFAULT_JOIN_OPTION
1584 select_fields = ", ".join([str(_) for _ in select_args])
1585 pre_alias = str(other)
1587 if f"{other}." not in select_fields:
1588 # no fields of other selected. add .ALL:
1589 select_args.append(other.ALL)
1590 elif f"{other}.id" not in select_fields:
1591 # fields of other selected, but required ID is missing.
1592 select_args.append(other.id)
1594 if relation.on:
1595 # if it has a .on, it's always a left join!
1596 on = relation.on(model, other)
1597 if not isinstance(on, list): # pragma: no cover
1598 on = [on]
1600 left.extend(on)
1601 elif method == "left":
1602 # .on not given, generate it:
1603 other = other.with_alias(f"{key}_{hash(relation)}")
1604 condition = typing.cast(Query, relation.condition(model, other))
1605 left.append(other.on(condition))
1606 else:
1607 # else: inner join
1608 other = other.with_alias(f"{key}_{hash(relation)}")
1609 query &= relation.condition(model, other)
1611 # if no fields of 'other' are included, add other.ALL
1612 # else: only add other.id if missing
1613 select_fields = ", ".join([str(_) for _ in select_args])
1615 post_alias = str(other).split(" AS ")[-1]
1616 if pre_alias != post_alias:
1617 # replace .select's with aliased:
1618 select_fields = select_fields.replace(
1619 f"{pre_alias}.",
1620 f"{post_alias}.",
1621 )
1623 select_args = select_fields.split(", ")
1625 select_kwargs["left"] = left
1626 return query, select_args
1628 def _collect_with_relationships(
1629 self, rows: Rows, metadata: dict[str, Any], _to: typing.Type["TypedRows[Any]"] = None
1630 ) -> "TypedRows[T_MetaInstance]":
1631 """
1632 Transform the raw rows into Typed Table model instances.
1633 """
1634 db = self._get_db()
1635 main_table = self.model._ensure_table_defined()
1637 records = {}
1638 seen_relations: dict[str, set[str]] = defaultdict(set) # main id -> set of col + id for relation
1640 for row in rows:
1641 main = row[main_table]
1642 main_id = main.id
1644 if main_id not in records:
1645 records[main_id] = self.model(main)
1646 records[main_id]._with = list(self.relationships.keys())
1648 # setup up all relationship defaults (once)
1649 for col, relationship in self.relationships.items():
1650 records[main_id][col] = [] if relationship.multiple else None
1652 # now add other relationship data
1653 for column, relation in self.relationships.items():
1654 relationship_column = f"{column}_{hash(relation)}"
1656 # relationship_column works for aliases with the same target column.
1657 # if col + relationship not in the row, just use the regular name.
1659 relation_data = (
1660 row[relationship_column] if relationship_column in row else row[relation.get_table_name()]
1661 )
1663 if relation_data.id is None:
1664 # always skip None ids
1665 continue
1667 if f"{column}-{relation_data.id}" in seen_relations[main_id]:
1668 # speed up duplicates
1669 continue
1670 else:
1671 seen_relations[main_id].add(f"{column}-{relation_data.id}")
1673 relation_table = relation.get_table(db)
1674 # hopefully an instance of a typed table and a regular row otherwise:
1675 instance = relation_table(relation_data) if looks_like(relation_table, TypedTable) else relation_data
1677 if relation.multiple:
1678 # create list of T
1679 if not isinstance(records[main_id].get(column), list): # pragma: no cover
1680 # should already be set up before!
1681 setattr(records[main_id], column, [])
1683 records[main_id][column].append(instance)
1684 else:
1685 # create single T
1686 records[main_id][column] = instance
1688 return _to(rows, self.model, records, metadata=metadata)
1690 def collect_or_fail(self) -> "TypedRows[T_MetaInstance]":
1691 """
1692 Call .collect() and raise an error if nothing found.
1694 Basically unwraps Optional type.
1695 """
1696 if result := self.collect():
1697 return result
1698 else:
1699 raise ValueError("Nothing found!")
1701 def __iter__(self) -> typing.Generator[T_MetaInstance, None, None]:
1702 """
1703 You can start iterating a Query Builder object before calling collect, for ease of use.
1704 """
1705 yield from self.collect()
1707 def count(self) -> int:
1708 """
1709 Return the amount of rows matching the current query.
1710 """
1711 db = self._get_db()
1712 return db(self.query).count()
1714 def paginate(self, limit: int, page: int = 1, verbose: bool = False) -> "PaginatedRows[T_MetaInstance]":
1715 """
1716 Paginate transforms the more readable `page` and `limit` to pydals internal limit and offset.
1718 Note: when using relationships, this limit is only applied to the 'main' table and any number of extra rows \
1719 can be loaded with relationship data!
1720 """
1721 _from = limit * (page - 1)
1722 _to = limit * page
1724 available = self.count()
1726 builder = self._extend(
1727 select_kwargs={"limitby": (_from, _to)},
1728 metadata={
1729 "pagination": {
1730 "limit": limit,
1731 "current_page": page,
1732 "max_page": math.ceil(available / limit),
1733 "rows": available,
1734 "min_max": (_from, _to),
1735 }
1736 },
1737 )
1739 rows = typing.cast(PaginatedRows[T_MetaInstance], builder.collect(verbose=verbose, _to=PaginatedRows))
1741 rows._query_builder = builder
1742 return rows
1744 def first(self, verbose: bool = False) -> T_MetaInstance | None:
1745 """
1746 Get the first row matching the currently built query.
1748 Also adds paginate, since it would be a waste to select more rows than needed.
1749 """
1750 if row := self.paginate(page=1, limit=1, verbose=verbose).first():
1751 return self.model.from_row(row)
1752 else:
1753 return None
1755 def first_or_fail(self, verbose: bool = False) -> T_MetaInstance:
1756 """
1757 Call .first() and raise an error if nothing found.
1759 Basically unwraps Optional type.
1760 """
1761 if inst := self.first(verbose=verbose):
1762 return inst
1763 else:
1764 raise ValueError("Nothing found!")
1767class TypedField(typing.Generic[T_Value]): # pragma: no cover
1768 """
1769 Typed version of pydal.Field, which will be converted to a normal Field in the background.
1770 """
1772 # will be set by .bind on db.define
1773 name = ""
1774 _db: Optional[pydal.DAL] = None
1775 _rname: Optional[str] = None
1776 _table: Optional[Table] = None
1777 _field: Optional[Field] = None
1779 _type: T_annotation
1780 kwargs: Any
1782 def __init__(self, _type: typing.Type[T_Value] | types.UnionType = str, /, **settings: Any) -> None: # type: ignore
1783 """
1784 A TypedFieldType should not be inited manually, but TypedField (from `fields.py`) should be used!
1785 """
1786 self._type = _type
1787 self.kwargs = settings
1788 super().__init__()
1790 @typing.overload
1791 def __get__(self, instance: T_MetaInstance, owner: typing.Type[T_MetaInstance]) -> T_Value: # pragma: no cover
1792 """
1793 row.field -> (actual data).
1794 """
1796 @typing.overload
1797 def __get__(self, instance: None, owner: typing.Type[TypedTable]) -> "TypedField[T_Value]": # pragma: no cover
1798 """
1799 Table.field -> Field.
1800 """
1802 def __get__(
1803 self, instance: T_MetaInstance | None, owner: typing.Type[T_MetaInstance]
1804 ) -> typing.Union[T_Value, Field]:
1805 """
1806 Since this class is a Descriptor field, \
1807 it returns something else depending on if it's called on a class or instance.
1809 (this is mostly for mypy/typing)
1810 """
1811 if instance:
1812 # this is only reached in a very specific case:
1813 # an instance of the object was created with a specific set of fields selected (excluding the current one)
1814 # in that case, no value was stored in the owner -> return None (since the field was not selected)
1815 return typing.cast(T_Value, None) # cast as T_Value so mypy understands it for selected fields
1816 else:
1817 # getting as class -> return actual field so pydal understands it when using in query etc.
1818 return typing.cast(TypedField[T_Value], self._field) # pretend it's still typed for IDE support
1820 def __str__(self) -> str:
1821 """
1822 String representation of a Typed Field.
1824 If `type` is set explicitly (e.g. TypedField(str, type="text")), that type is used: `TypedField.text`,
1825 otherwise the type annotation is used (e.g. TypedField(str) -> TypedField.str)
1826 """
1827 return str(self._field) if self._field else ""
1829 def __repr__(self) -> str:
1830 """
1831 More detailed string representation of a Typed Field.
1833 Uses __str__ and adds the provided extra options (kwargs) in the representation.
1834 """
1835 s = self.__str__()
1837 if "type" in self.kwargs:
1838 # manual type in kwargs supplied
1839 t = self.kwargs["type"]
1840 elif issubclass(type, type(self._type)):
1841 # normal type, str.__name__ = 'str'
1842 t = getattr(self._type, "__name__", str(self._type))
1843 elif t_args := typing.get_args(self._type):
1844 # list[str] -> 'str'
1845 t = t_args[0].__name__
1846 else: # pragma: no cover
1847 # fallback - something else, may not even happen, I'm not sure
1848 t = self._type
1850 s = f"TypedField[{t}].{s}" if s else f"TypedField[{t}]"
1852 kw = self.kwargs.copy()
1853 kw.pop("type", None)
1854 return f"<{s} with options {kw}>"
1856 def _to_field(self, extra_kwargs: typing.MutableMapping[str, Any]) -> Optional[str]:
1857 """
1858 Convert a Typed Field instance to a pydal.Field.
1859 """
1860 other_kwargs = self.kwargs.copy()
1861 extra_kwargs.update(other_kwargs)
1862 return extra_kwargs.pop("type", False) or TypeDAL._annotation_to_pydal_fieldtype(self._type, extra_kwargs)
1864 def bind(self, field: pydal.objects.Field, table: pydal.objects.Table) -> None:
1865 """
1866 Bind the right db/table/field info to this class, so queries can be made using `Class.field == ...`.
1867 """
1868 self._table = table
1869 self._field = field
1871 def __getattr__(self, key: str) -> Any:
1872 """
1873 If the regular getattribute does not work, try to get info from the related Field.
1874 """
1875 with contextlib.suppress(AttributeError):
1876 return super().__getattribute__(key)
1878 # try on actual field:
1879 return getattr(self._field, key)
1881 def __eq__(self, other: Any) -> Query:
1882 """
1883 Performing == on a Field will result in a Query.
1884 """
1885 return typing.cast(Query, self._field == other)
1887 def __ne__(self, other: Any) -> Query:
1888 """
1889 Performing != on a Field will result in a Query.
1890 """
1891 return typing.cast(Query, self._field != other)
1893 def __gt__(self, other: Any) -> Query:
1894 """
1895 Performing > on a Field will result in a Query.
1896 """
1897 return typing.cast(Query, self._field > other)
1899 def __lt__(self, other: Any) -> Query:
1900 """
1901 Performing < on a Field will result in a Query.
1902 """
1903 return typing.cast(Query, self._field < other)
1905 def __ge__(self, other: Any) -> Query:
1906 """
1907 Performing >= on a Field will result in a Query.
1908 """
1909 return typing.cast(Query, self._field >= other)
1911 def __le__(self, other: Any) -> Query:
1912 """
1913 Performing <= on a Field will result in a Query.
1914 """
1915 return typing.cast(Query, self._field <= other)
1917 def __hash__(self) -> int:
1918 """
1919 Shadow Field.__hash__.
1920 """
1921 return hash(self._field)
1924S = typing.TypeVar("S")
1927class TypedRows(typing.Collection[T_MetaInstance], Rows):
1928 """
1929 Slighly enhaned and typed functionality on top of pydal Rows (the result of a select).
1930 """
1932 records: dict[int, T_MetaInstance]
1933 # _rows: Rows
1934 model: typing.Type[T_MetaInstance]
1935 metadata: dict[str, Any]
1937 # pseudo-properties: actually stored in _rows
1938 db: TypeDAL
1939 colnames: list[str]
1940 fields: list[Field]
1941 colnames_fields: list[Field]
1942 response: list[tuple[Any, ...]]
1944 def __init__(
1945 self,
1946 rows: Rows,
1947 model: typing.Type[T_MetaInstance],
1948 records: dict[int, T_MetaInstance] = None,
1949 metadata: dict[str, Any] = None,
1950 ) -> None:
1951 """
1952 Should not be called manually!
1954 Normally, the `records` from an existing `Rows` object are used
1955 but these can be overwritten with a `records` dict.
1956 `metadata` can be any (un)structured data
1957 `model` is a Typed Table class
1958 """
1959 records = records or {row.id: model(row) for row in rows}
1960 super().__init__(rows.db, records, rows.colnames, rows.compact, rows.response, rows.fields)
1961 self.model = model
1962 self.metadata = metadata or {}
1964 def __len__(self) -> int:
1965 """
1966 Return the count of rows.
1967 """
1968 return len(self.records)
1970 def __iter__(self) -> typing.Iterator[T_MetaInstance]:
1971 """
1972 Loop through the rows.
1973 """
1974 yield from self.records.values()
1976 def __contains__(self, ind: Any) -> bool:
1977 """
1978 Check if an id exists in this result set.
1979 """
1980 return ind in self.records
1982 def first(self) -> T_MetaInstance | None:
1983 """
1984 Get the row with the lowest id.
1985 """
1986 if not self.records:
1987 return None
1989 return next(iter(self))
1991 def last(self) -> T_MetaInstance | None:
1992 """
1993 Get the row with the highest id.
1994 """
1995 if not self.records:
1996 return None
1998 max_id = max(self.records.keys())
1999 return self[max_id]
2001 def find(
2002 self, f: typing.Callable[[T_MetaInstance], Query], limitby: tuple[int, int] = None
2003 ) -> "TypedRows[T_MetaInstance]":
2004 """
2005 Returns a new Rows object, a subset of the original object, filtered by the function `f`.
2006 """
2007 if not self.records:
2008 return self.__class__(self, self.model, {})
2010 records = {}
2011 if limitby:
2012 _min, _max = limitby
2013 else:
2014 _min, _max = 0, len(self)
2015 count = 0
2016 for i, row in self.records.items():
2017 if f(row):
2018 if _min <= count:
2019 records[i] = row
2020 count += 1
2021 if count == _max:
2022 break
2024 return self.__class__(self, self.model, records)
2026 def exclude(self, f: typing.Callable[[T_MetaInstance], Query]) -> "TypedRows[T_MetaInstance]":
2027 """
2028 Removes elements from the calling Rows object, filtered by the function `f`, \
2029 and returns a new Rows object containing the removed elements.
2030 """
2031 if not self.records:
2032 return self.__class__(self, self.model, {})
2033 removed = {}
2034 to_remove = []
2035 for i in self.records:
2036 row = self[i]
2037 if f(row):
2038 removed[i] = self.records[i]
2039 to_remove.append(i)
2041 [self.records.pop(i) for i in to_remove]
2043 return self.__class__(
2044 self,
2045 self.model,
2046 removed,
2047 )
2049 def sort(self, f: typing.Callable[[T_MetaInstance], Any], reverse: bool = False) -> list[T_MetaInstance]:
2050 """
2051 Returns a list of sorted elements (not sorted in place).
2052 """
2053 return [r for (r, s) in sorted(zip(self.records.values(), self), key=lambda r: f(r[1]), reverse=reverse)]
2055 def __str__(self) -> str:
2056 """
2057 Simple string representation.
2058 """
2059 return f"<TypedRows with {len(self)} records>"
2061 def __repr__(self) -> str:
2062 """
2063 Print a table on repr().
2064 """
2065 data = self.as_dict()
2066 headers = list(next(iter(data.values())).keys())
2067 return mktable(data, headers)
2069 def group_by_value(
2070 self, *fields: str | Field | TypedField[T], one_result: bool = False, **kwargs: Any
2071 ) -> dict[T, list[T_MetaInstance]]:
2072 """
2073 Group the rows by a specific field (which will be the dict key).
2074 """
2075 kwargs["one_result"] = one_result
2076 result = super().group_by_value(*fields, **kwargs)
2077 return typing.cast(dict[T, list[T_MetaInstance]], result)
2079 def column(self, column: str = None) -> list[Any]:
2080 """
2081 Get a list of all values in a specific column.
2083 Example:
2084 rows.column('name') -> ['Name 1', 'Name 2', ...]
2085 """
2086 return typing.cast(list[Any], super().column(column))
2088 def as_csv(self) -> str:
2089 """
2090 Dump the data to csv.
2091 """
2092 return typing.cast(str, super().as_csv())
2094 def as_dict(
2095 self,
2096 key: str = None,
2097 compact: bool = False,
2098 storage_to_dict: bool = False,
2099 datetime_to_str: bool = False,
2100 custom_types: list[type] = None,
2101 ) -> dict[int, dict[str, Any]]:
2102 """
2103 Get the data in a dict of dicts.
2104 """
2105 if any([key, compact, storage_to_dict, datetime_to_str, custom_types]):
2106 # functionality not guaranteed
2107 return typing.cast(
2108 dict[int, dict[str, Any]],
2109 super().as_dict(
2110 key or "id",
2111 compact,
2112 storage_to_dict,
2113 datetime_to_str,
2114 custom_types,
2115 ),
2116 )
2118 return {k: v.as_dict() for k, v in self.records.items()}
2120 def as_json(self, mode: str = "object", default: typing.Callable[[Any], Any] = None) -> str:
2121 """
2122 Turn the data into a dict and then dump to JSON.
2123 """
2124 return typing.cast(str, super().as_json(mode=mode, default=default))
2126 def json(self, mode: str = "object", default: typing.Callable[[Any], Any] = None) -> str:
2127 """
2128 Turn the data into a dict and then dump to JSON.
2129 """
2130 return typing.cast(str, super().as_json(mode=mode, default=default))
2132 def as_list(
2133 self,
2134 compact: bool = False,
2135 storage_to_dict: bool = False,
2136 datetime_to_str: bool = False,
2137 custom_types: list[type] = None,
2138 ) -> list[dict[str, Any]]:
2139 """
2140 Get the data in a list of dicts.
2141 """
2142 if any([compact, storage_to_dict, datetime_to_str, custom_types]):
2143 return typing.cast(
2144 list[dict[str, Any]], super().as_list(compact, storage_to_dict, datetime_to_str, custom_types)
2145 )
2146 return [_.as_dict() for _ in self.records.values()]
2148 def __getitem__(self, item: int) -> T_MetaInstance:
2149 """
2150 You can get a specific row by ID from a typedrows by using rows[idx] notation.
2152 Since pydal's implementation differs (they expect a list instead of a dict with id keys),
2153 using rows[0] will return the first row, regardless of its id.
2154 """
2155 try:
2156 return self.records[item]
2157 except KeyError as e:
2158 if item == 0 and (row := self.first()):
2159 # special case: pydal internals think Rows.records is a list, not a dict
2160 return row
2162 raise e
2164 def get(self, item: int) -> typing.Optional[T_MetaInstance]:
2165 """
2166 Get a row by ID, or receive None if it isn't in this result set.
2167 """
2168 return self.records.get(item)
2170 def join(
2171 self,
2172 field: Field | TypedField[Any],
2173 name: str = None,
2174 constraint: Query = None,
2175 fields: list[str | Field] = None,
2176 orderby: str | Field = None,
2177 ) -> T_MetaInstance:
2178 """
2179 This can be used to JOIN with some relationships after the initial select.
2181 Using the querybuilder's .join() method is prefered!
2182 """
2183 result = super().join(field, name, constraint, fields or [], orderby)
2184 return typing.cast(T_MetaInstance, result)
2186 def export_to_csv_file(
2187 self,
2188 ofile: typing.TextIO,
2189 null: str = "<NULL>",
2190 delimiter: str = ",",
2191 quotechar: str = '"',
2192 quoting: int = csv.QUOTE_MINIMAL,
2193 represent: bool = False,
2194 colnames: list[str] = None,
2195 write_colnames: bool = True,
2196 *args: Any,
2197 **kwargs: Any,
2198 ) -> None:
2199 """
2200 Shadow export_to_csv_file from Rows, but with typing.
2202 See http://web2py.com/books/default/chapter/29/06/the-database-abstraction-layer?search=export_to_csv_file#Exporting-and-importing-data
2203 """
2204 super().export_to_csv_file(
2205 ofile,
2206 null,
2207 *args,
2208 delimiter=delimiter,
2209 quotechar=quotechar,
2210 quoting=quoting,
2211 represent=represent,
2212 colnames=colnames or self.colnames,
2213 write_colnames=write_colnames,
2214 **kwargs,
2215 )
2217 @classmethod
2218 def from_rows(
2219 cls, rows: Rows, model: typing.Type[T_MetaInstance], metadata: dict[str, Any] = None
2220 ) -> "TypedRows[T_MetaInstance]":
2221 """
2222 Internal method to convert a Rows object to a TypedRows.
2223 """
2224 return cls(rows, model, metadata=metadata)
2226 def __json__(self) -> dict[str, Any]:
2227 """
2228 For json-fix.
2229 """
2230 return typing.cast(dict[str, Any], self.as_dict())
2233class Pagination(typing.TypedDict):
2234 """
2235 Pagination key of a paginate dict has these items.
2236 """
2238 total_items: int
2239 current_page: int
2240 per_page: int
2241 total_pages: int
2242 has_next_page: bool
2243 has_prev_page: bool
2244 next_page: Optional[int]
2245 prev_page: Optional[int]
2248class PaginateDict(typing.TypedDict):
2249 """
2250 Result of PaginatedRows.as_dict().
2251 """
2253 data: dict[int, dict[str, Any]]
2254 pagination: Pagination
2257class PaginatedRows(TypedRows[T_MetaInstance]):
2258 """
2259 Extension on top of rows that is used when calling .paginate() instead of .collect().
2260 """
2262 _query_builder: QueryBuilder[T_MetaInstance]
2264 def next(self) -> Self: # noqa: A003
2265 """
2266 Get the next page.
2267 """
2268 data = self.metadata["pagination"]
2269 if data["current_page"] >= data["max_page"]:
2270 raise StopIteration("Final Page")
2272 return self._query_builder.paginate(limit=data["limit"], page=data["current_page"] + 1)
2274 def previous(self) -> Self:
2275 """
2276 Get the previous page.
2277 """
2278 data = self.metadata["pagination"]
2279 if data["current_page"] <= 1:
2280 raise StopIteration("First Page")
2282 return self._query_builder.paginate(limit=data["limit"], page=data["current_page"] - 1)
2284 def as_dict(self, *_: Any, **__: Any) -> PaginateDict: # type: ignore
2285 """
2286 Convert to a dictionary with pagination info and original data.
2288 All arguments are ignored!
2289 """
2290 pagination_data = self.metadata["pagination"]
2292 has_next_page = pagination_data["current_page"] < pagination_data["max_page"]
2293 has_prev_page = pagination_data["current_page"] > 1
2295 return {
2296 "data": super().as_dict(),
2297 "pagination": {
2298 "total_items": pagination_data["rows"],
2299 "current_page": pagination_data["current_page"],
2300 "per_page": pagination_data["limit"],
2301 "total_pages": pagination_data["max_page"],
2302 "has_next_page": has_next_page,
2303 "has_prev_page": has_prev_page,
2304 "next_page": pagination_data["current_page"] + 1 if has_next_page else None,
2305 "prev_page": pagination_data["current_page"] - 1 if has_prev_page else None,
2306 },
2307 }
2310class TypedSet(pydal.objects.Set): # type: ignore # pragma: no cover
2311 """
2312 Used to make pydal Set more typed.
2314 This class is not actually used, only 'cast' by TypeDAL.__call__
2315 """
2317 def count(self, distinct: bool = None, cache: dict[str, Any] = None) -> int:
2318 """
2319 Count returns an int.
2320 """
2321 result = super().count(distinct, cache)
2322 return typing.cast(int, result)
2324 def select(self, *fields: Any, **attributes: Any) -> TypedRows[T_MetaInstance]:
2325 """
2326 Select returns a TypedRows of a user defined table.
2328 Example:
2329 result: TypedRows[MyTable] = db(MyTable.id > 0).select()
2331 for row in result:
2332 typing.reveal_type(row) # MyTable
2333 """
2334 rows = super().select(*fields, **attributes)
2335 return typing.cast(TypedRows[T_MetaInstance], rows)