Coverage for src/typedal/core.py: 100%
799 statements
« prev ^ index » next coverage.py v7.3.2, created at 2023-10-26 15:41 +0200
« prev ^ index » next coverage.py v7.3.2, created at 2023-10-26 15:41 +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 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, 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) -> 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 def _ensure_table_defined(self) -> Table:
718 if not self._table:
719 raise EnvironmentError("@define or db.define is not called on this class yet!")
720 return self._table
722 def __iter__(self) -> typing.Generator[Field, None, None]:
723 """
724 Loop through the columns of this model.
725 """
726 table = self._ensure_table_defined()
727 yield from iter(table)
729 def __getitem__(self, item: str) -> Field:
730 """
731 Allow dict notation to get a column of this table (-> Field instance).
732 """
733 table = self._ensure_table_defined()
734 return table[item]
736 def __str__(self) -> str:
737 """
738 Normally, just returns the underlying table name, but with a fallback if the model is unbound.
739 """
740 if self._table:
741 return str(self._table)
742 else:
743 return f"<unbound table {self.__name__}>"
745 def from_row(self: typing.Type[T_MetaInstance], row: pydal.objects.Row) -> T_MetaInstance:
746 """
747 Create a model instance from a pydal row.
748 """
749 return self(row)
751 def all(self: typing.Type[T_MetaInstance]) -> "TypedRows[T_MetaInstance]": # noqa: A003
752 """
753 Return all rows for this model.
754 """
755 return self.collect()
757 def __json__(self: typing.Type[T_MetaInstance], instance: T_MetaInstance | None = None) -> dict[str, Any]:
758 """
759 Convert to a json-dumpable dict.
761 as_dict is not fully json-dumpable, so use as_json and json.loads to ensure it is dumpable (and loadable).
762 todo: can this be optimized?
764 See Also:
765 https://github.com/jeff-hykin/json_fix
766 """
767 string = instance.as_json() if instance else self.as_json()
769 return typing.cast(dict[str, Any], json.loads(string))
771 def get_relationships(self) -> dict[str, Relationship[Any]]:
772 """
773 Return the registered relationships of the current model.
774 """
775 return self._relationships or {}
777 ##########################
778 # TypeDAL Modified Logic #
779 ##########################
781 def insert(self: typing.Type[T_MetaInstance], **fields: Any) -> T_MetaInstance:
782 """
783 This is only called when db.define is not used as a decorator.
785 cls.__table functions as 'self'
787 Args:
788 **fields: anything you want to insert in the database
790 Returns: the ID of the new row.
792 """
793 table = self._ensure_table_defined()
795 result = table.insert(**fields)
796 # it already is an int but mypy doesn't understand that
797 return self(result)
799 def _insert(self, **fields: Any) -> str:
800 table = self._ensure_table_defined()
802 return str(table._insert(**fields))
804 def bulk_insert(self: typing.Type[T_MetaInstance], items: list[dict[str, Any]]) -> "TypedRows[T_MetaInstance]":
805 """
806 Insert multiple rows, returns a TypedRows set of new instances.
807 """
808 table = self._ensure_table_defined()
809 result = table.bulk_insert(items)
810 return self.where(lambda row: row.id.belongs(result)).collect()
812 def update_or_insert(
813 self: typing.Type[T_MetaInstance], query: T_Query | dict[str, Any] = DEFAULT, **values: Any
814 ) -> T_MetaInstance:
815 """
816 Update a row if query matches, else insert a new one.
818 Returns the created or updated instance.
819 """
820 table = self._ensure_table_defined()
822 if query is DEFAULT:
823 record = table(**values)
824 elif isinstance(query, dict):
825 record = table(**query)
826 else:
827 record = table(query)
829 if not record:
830 return self.insert(**values)
832 record.update_record(**values)
833 return self(record)
835 def validate_and_insert(
836 self: typing.Type[T_MetaInstance], **fields: Any
837 ) -> tuple[Optional[T_MetaInstance], Optional[dict[str, str]]]:
838 """
839 Validate input data and then insert a row.
841 Returns a tuple of (the created instance, a dict of errors).
842 """
843 table = self._ensure_table_defined()
844 result = table.validate_and_insert(**fields)
845 if row_id := result.get("id"):
846 return self(row_id), None
847 else:
848 return None, result.get("errors")
850 def validate_and_update(
851 self: typing.Type[T_MetaInstance], query: Query, **fields: Any
852 ) -> tuple[Optional[T_MetaInstance], Optional[dict[str, str]]]:
853 """
854 Validate input data and then update max 1 row.
856 Returns a tuple of (the updated instance, a dict of errors).
857 """
858 table = self._ensure_table_defined()
860 try:
861 result = table.validate_and_update(query, **fields)
862 except Exception as e:
863 result = {"errors": {"exception": str(e)}}
865 if errors := result.get("errors"):
866 return None, errors
867 elif row_id := result.get("id"):
868 return self(row_id), None
869 else: # pragma: no cover
870 # update on query without result (shouldnt happen)
871 return None, None
873 def validate_and_update_or_insert(
874 self: typing.Type[T_MetaInstance], query: Query, **fields: Any
875 ) -> tuple[Optional[T_MetaInstance], Optional[dict[str, str]]]:
876 """
877 Validate input data and then update_and_insert (on max 1 row).
879 Returns a tuple of (the updated/created instance, a dict of errors).
880 """
881 table = self._ensure_table_defined()
882 result = table.validate_and_update_or_insert(query, **fields)
884 if errors := result.get("errors"):
885 return None, errors
886 elif row_id := result.get("id"):
887 return self(row_id), None
888 else: # pragma: no cover
889 # update on query without result (shouldnt happen)
890 return None, None
892 def select(self: typing.Type[T_MetaInstance], *a: Any, **kw: Any) -> "QueryBuilder[T_MetaInstance]":
893 """
894 See QueryBuilder.select!
895 """
896 return QueryBuilder(self).select(*a, **kw)
898 def paginate(self: typing.Type[T_MetaInstance], limit: int, page: int = 1) -> "PaginatedRows[T_MetaInstance]":
899 """
900 See QueryBuilder.paginate!
901 """
902 return QueryBuilder(self).paginate(limit=limit, page=page)
904 def where(self: typing.Type[T_MetaInstance], *a: Any, **kw: Any) -> "QueryBuilder[T_MetaInstance]":
905 """
906 See QueryBuilder.where!
907 """
908 return QueryBuilder(self).where(*a, **kw)
910 def count(self: typing.Type[T_MetaInstance]) -> int:
911 """
912 See QueryBuilder.count!
913 """
914 return QueryBuilder(self).count()
916 def first(self: typing.Type[T_MetaInstance]) -> T_MetaInstance | None:
917 """
918 See QueryBuilder.first!
919 """
920 return QueryBuilder(self).first()
922 def join(
923 self: typing.Type[T_MetaInstance],
924 *fields: str | typing.Type["TypedTable"],
925 method: JOIN_OPTIONS = None,
926 on: OnQuery | list[Expression] | Expression = None,
927 condition: Condition = None,
928 ) -> "QueryBuilder[T_MetaInstance]":
929 """
930 See QueryBuilder.join!
931 """
932 return QueryBuilder(self).join(*fields, on=on, condition=condition, method=method)
934 def collect(self: typing.Type[T_MetaInstance], verbose: bool = False) -> "TypedRows[T_MetaInstance]":
935 """
936 See QueryBuilder.collect!
937 """
938 return QueryBuilder(self).collect(verbose=verbose)
940 @property
941 def ALL(cls) -> pydal.objects.SQLALL:
942 """
943 Select all fields for this table.
944 """
945 table = cls._ensure_table_defined()
947 return table.ALL
949 ##########################
950 # TypeDAL Shadowed Logic #
951 ##########################
952 fields: list[str]
954 # other table methods:
956 def drop(self, mode: str = "") -> None:
957 """
958 Remove the underlying table.
959 """
960 table = self._ensure_table_defined()
961 table.drop(mode)
963 def create_index(self, name: str, *fields: Field | str, **kwargs: Any) -> bool:
964 """
965 Add an index on some columns of this table.
966 """
967 table = self._ensure_table_defined()
968 result = table.create_index(name, *fields, **kwargs)
969 return typing.cast(bool, result)
971 def drop_index(self, name: str, if_exists: bool = False) -> bool:
972 """
973 Remove an index from this table.
974 """
975 table = self._ensure_table_defined()
976 result = table.drop_index(name, if_exists)
977 return typing.cast(bool, result)
979 def import_from_csv_file(
980 self,
981 csvfile: typing.TextIO,
982 id_map: dict[str, str] = None,
983 null: str = "<NULL>",
984 unique: str = "uuid",
985 id_offset: dict[str, int] = None, # id_offset used only when id_map is None
986 transform: typing.Callable[[dict[Any, Any]], dict[Any, Any]] = None,
987 validate: bool = False,
988 encoding: str = "utf-8",
989 delimiter: str = ",",
990 quotechar: str = '"',
991 quoting: int = csv.QUOTE_MINIMAL,
992 restore: bool = False,
993 **kwargs: Any,
994 ) -> None:
995 """
996 Load a csv file into the database.
997 """
998 table = self._ensure_table_defined()
999 table.import_from_csv_file(
1000 csvfile,
1001 id_map=id_map,
1002 null=null,
1003 unique=unique,
1004 id_offset=id_offset,
1005 transform=transform,
1006 validate=validate,
1007 encoding=encoding,
1008 delimiter=delimiter,
1009 quotechar=quotechar,
1010 quoting=quoting,
1011 restore=restore,
1012 **kwargs,
1013 )
1015 def on(self, query: Query | bool) -> Expression:
1016 """
1017 Shadow Table.on.
1019 Used for joins.
1021 See Also:
1022 http://web2py.com/books/default/chapter/29/06/the-database-abstraction-layer?search=export_to_csv_file#One-to-many-relation
1023 """
1024 table = self._ensure_table_defined()
1025 return typing.cast(Expression, table.on(query))
1027 def with_alias(self, alias: str) -> _Table:
1028 """
1029 Shadow Table.with_alias.
1031 Useful for joins when joining the same table multiple times.
1033 See Also:
1034 http://web2py.com/books/default/chapter/29/06/the-database-abstraction-layer?search=export_to_csv_file#One-to-many-relation
1035 """
1036 table = self._ensure_table_defined()
1037 return table.with_alias(alias)
1039 # @typing.dataclass_transform()
1042class TypedTable(metaclass=TableMeta):
1043 """
1044 Enhanded modeling system on top of pydal's Table that adds typing and additional functionality.
1045 """
1047 # set up by 'new':
1048 _row: Row | None = None
1050 _with: list[str]
1052 id: "TypedField[int]" # noqa: A003
1054 def _setup_instance_methods(self) -> None:
1055 self.as_dict = self._as_dict # type: ignore
1056 self.__json__ = self.as_json = self._as_json # type: ignore
1057 # self.as_yaml = self._as_yaml # type: ignore
1058 self.as_xml = self._as_xml # type: ignore
1060 self.update = self._update # type: ignore
1062 self.delete_record = self._delete_record # type: ignore
1063 self.update_record = self._update_record # type: ignore
1065 def __new__(
1066 cls, row_or_id: typing.Union[Row, Query, pydal.objects.Set, int, str, None, "TypedTable"] = None, **filters: Any
1067 ) -> "TypedTable":
1068 """
1069 Create a Typed Rows model instance from an existing row, ID or query.
1071 Examples:
1072 MyTable(1)
1073 MyTable(id=1)
1074 MyTable(MyTable.id == 1)
1075 """
1076 table = cls._ensure_table_defined()
1078 if isinstance(row_or_id, TypedTable):
1079 # existing typed table instance!
1080 return row_or_id
1081 elif isinstance(row_or_id, pydal.objects.Row):
1082 row = row_or_id
1083 elif row_or_id is not None:
1084 row = table(row_or_id, **filters)
1085 else:
1086 row = table(**filters)
1088 if not row:
1089 return None # type: ignore
1091 inst = super().__new__(cls)
1092 inst._row = row
1093 inst.__dict__.update(row)
1094 inst._setup_instance_methods()
1095 return inst
1097 def __iter__(self) -> typing.Generator[Any, None, None]:
1098 """
1099 Allows looping through the columns.
1100 """
1101 row = self._ensure_matching_row()
1102 yield from iter(row)
1104 def __getitem__(self, item: str) -> Any:
1105 """
1106 Allows dictionary notation to get columns.
1107 """
1108 if item in self.__dict__:
1109 return self.__dict__.get(item)
1111 # fallback to lookup in row
1112 if self._row:
1113 return self._row[item]
1115 # nothing found!
1116 raise KeyError(item)
1118 def __getattr__(self, item: str) -> Any:
1119 """
1120 Allows dot notation to get columns.
1121 """
1122 if value := self.get(item):
1123 return value
1125 raise AttributeError(item)
1127 def get(self, item: str, default: Any = None) -> Any:
1128 """
1129 Try to get a column from this instance, else return default.
1130 """
1131 try:
1132 return self.__getitem__(item)
1133 except KeyError:
1134 return default
1136 def __setitem__(self, key: str, value: Any) -> None:
1137 """
1138 Data can both be updated via dot and dict notation.
1139 """
1140 return setattr(self, key, value)
1142 def __int__(self) -> int:
1143 """
1144 Calling int on a model instance will return its id.
1145 """
1146 return getattr(self, "id", 0)
1148 def __bool__(self) -> bool:
1149 """
1150 If the instance has an underlying row with data, it is truthy.
1151 """
1152 return bool(getattr(self, "_row", False))
1154 def _ensure_matching_row(self) -> Row:
1155 if not getattr(self, "_row", None):
1156 raise EnvironmentError("Trying to access non-existant row. Maybe it was deleted or not yet initialized?")
1157 return self._row
1159 def __repr__(self) -> str:
1160 """
1161 String representation of the model instance.
1162 """
1163 model_name = self.__class__.__name__
1164 model_data = {}
1166 if self._row:
1167 model_data = self._row.as_json()
1169 details = model_name
1170 details += f"({model_data})"
1172 if relationships := getattr(self, "_with", []):
1173 details += f" + {relationships}"
1175 return f"<{details}>"
1177 # serialization
1178 # underscore variants work for class instances (set up by _setup_instance_methods)
1180 @classmethod
1181 def as_dict(cls, flat: bool = False, sanitize: bool = True) -> dict[str, Any]:
1182 """
1183 Dump the object to a plain dict.
1185 Can be used as both a class or instance method:
1186 - dumps the table info if it's a class
1187 - dumps the row info if it's an instance (see _as_dict)
1188 """
1189 table = cls._ensure_table_defined()
1190 result = table.as_dict(flat, sanitize)
1191 return typing.cast(dict[str, Any], result)
1193 @classmethod
1194 def as_json(cls, sanitize: bool = True) -> str:
1195 """
1196 Dump the object to json.
1198 Can be used as both a class or instance method:
1199 - dumps the table info if it's a class
1200 - dumps the row info if it's an instance (see _as_json)
1201 """
1202 table = cls._ensure_table_defined()
1203 return typing.cast(str, table.as_json(sanitize))
1205 @classmethod
1206 def as_xml(cls, sanitize: bool = True) -> str: # pragma: no cover
1207 """
1208 Dump the object to xml.
1210 Can be used as both a class or instance method:
1211 - dumps the table info if it's a class
1212 - dumps the row info if it's an instance (see _as_xml)
1213 """
1214 table = cls._ensure_table_defined()
1215 return typing.cast(str, table.as_xml(sanitize))
1217 @classmethod
1218 def as_yaml(cls, sanitize: bool = True) -> str:
1219 """
1220 Dump the object to yaml.
1222 Can be used as both a class or instance method:
1223 - dumps the table info if it's a class
1224 - dumps the row info if it's an instance (see _as_yaml)
1225 """
1226 table = cls._ensure_table_defined()
1227 return typing.cast(str, table.as_yaml(sanitize))
1229 def _as_dict(
1230 self, datetime_to_str: bool = False, custom_types: typing.Iterable[type] | type | None = None
1231 ) -> dict[str, Any]:
1232 row = self._ensure_matching_row()
1233 result = row.as_dict(datetime_to_str=datetime_to_str, custom_types=custom_types)
1235 if _with := getattr(self, "_with", None):
1236 for relationship in _with:
1237 data = self.get(relationship)
1238 if isinstance(data, list):
1239 data = [_.as_dict() if getattr(_, "as_dict", None) else _ for _ in data]
1240 elif data:
1241 data = data.as_dict()
1243 result[relationship] = data
1245 return typing.cast(dict[str, Any], result)
1247 def _as_json(
1248 self,
1249 mode: str = "object",
1250 default: typing.Callable[[Any], Any] = None,
1251 colnames: list[str] = None,
1252 serialize: bool = True,
1253 **kwargs: Any,
1254 ) -> str:
1255 row = self._ensure_matching_row()
1256 return typing.cast(str, row.as_json(mode, default, colnames, serialize, *kwargs))
1258 def _as_xml(self, sanitize: bool = True) -> str: # pragma: no cover
1259 row = self._ensure_matching_row()
1260 return typing.cast(str, row.as_xml(sanitize))
1262 # def _as_yaml(self, sanitize: bool = True) -> str:
1263 # row = self._ensure_matching_row()
1264 # return typing.cast(str, row.as_yaml(sanitize))
1266 def __setattr__(self, key: str, value: Any) -> None:
1267 """
1268 When setting a property on a Typed Table model instance, also update the underlying row.
1269 """
1270 if self._row and key in self._row.__dict__ and not callable(value):
1271 # enables `row.key = value; row.update_record()`
1272 self._row[key] = value
1274 super().__setattr__(key, value)
1276 @classmethod
1277 def update(cls: typing.Type[T_MetaInstance], query: Query, **fields: Any) -> T_MetaInstance | None:
1278 """
1279 Update one record.
1281 Example:
1282 MyTable.update(MyTable.id == 1, name="NewName") -> MyTable
1283 """
1284 if record := cls(query):
1285 return record.update_record(**fields)
1286 else:
1287 return None
1289 def _update(self: T_MetaInstance, **fields: Any) -> T_MetaInstance:
1290 row = self._ensure_matching_row()
1291 row.update(**fields)
1292 self.__dict__.update(**fields)
1293 return self
1295 def _update_record(self: T_MetaInstance, **fields: Any) -> T_MetaInstance:
1296 row = self._ensure_matching_row()
1297 new_row = row.update_record(**fields)
1298 self.update(**new_row)
1299 return self
1301 def update_record(self: T_MetaInstance, **fields: Any) -> T_MetaInstance: # pragma: no cover
1302 """
1303 Here as a placeholder for _update_record.
1305 Will be replaced on instance creation!
1306 """
1307 return self._update_record(**fields)
1309 def _delete_record(self) -> int:
1310 """
1311 Actual logic in `pydal.helpers.classes.RecordDeleter`.
1312 """
1313 row = self._ensure_matching_row()
1314 result = row.delete_record()
1315 self.__dict__ = {} # empty self, since row is no more.
1316 self._row = None # just to be sure
1317 self._setup_instance_methods()
1318 # ^ instance methods might've been deleted by emptying dict,
1319 # but we still want .as_dict to show an error, not the table's as_dict.
1320 return typing.cast(int, result)
1322 def delete_record(self) -> int: # pragma: no cover
1323 """
1324 Here as a placeholder for _delete_record.
1326 Will be replaced on instance creation!
1327 """
1328 return self._delete_record()
1330 # __del__ is also called on the end of a scope so don't remove records on every del!!
1333# backwards compat:
1334TypedRow = TypedTable
1337class QueryBuilder(typing.Generic[T_MetaInstance]):
1338 """
1339 Abstration on top of pydal's query system.
1340 """
1342 model: typing.Type[T_MetaInstance]
1343 query: Query
1344 select_args: list[Any]
1345 select_kwargs: dict[str, Any]
1346 relationships: dict[str, Relationship[Any]]
1347 metadata: dict[str, Any]
1349 def __init__(
1350 self,
1351 model: typing.Type[T_MetaInstance],
1352 add_query: Optional[Query] = None,
1353 select_args: Optional[list[Any]] = None,
1354 select_kwargs: Optional[dict[str, Any]] = None,
1355 relationships: dict[str, Relationship[Any]] = None,
1356 metadata: dict[str, Any] = None,
1357 ):
1358 """
1359 Normally, you wouldn't manually initialize a QueryBuilder but start using a method on a TypedTable.
1361 Example:
1362 MyTable.where(...) -> QueryBuilder[MyTable]
1363 """
1364 self.model = model
1365 table = model._ensure_table_defined()
1366 default_query = typing.cast(Query, table.id > 0)
1367 self.query = add_query or default_query
1368 self.select_args = select_args or []
1369 self.select_kwargs = select_kwargs or {}
1370 self.relationships = relationships or {}
1371 self.metadata = metadata or {}
1373 def _extend(
1374 self,
1375 add_query: Optional[Query] = None,
1376 overwrite_query: Optional[Query] = None,
1377 select_args: Optional[list[Any]] = None,
1378 select_kwargs: Optional[dict[str, Any]] = None,
1379 relationships: dict[str, Relationship[Any]] = None,
1380 metadata: dict[str, Any] = None,
1381 ) -> "QueryBuilder[T_MetaInstance]":
1382 return QueryBuilder(
1383 self.model,
1384 (add_query & self.query) if add_query else overwrite_query or self.query,
1385 (self.select_args + select_args) if select_args else self.select_args,
1386 (self.select_kwargs | select_kwargs) if select_kwargs else self.select_kwargs,
1387 (self.relationships | relationships) if relationships else self.relationships,
1388 (self.metadata | metadata) if metadata else self.metadata,
1389 )
1391 def select(self, *fields: Any, **options: Any) -> "QueryBuilder[T_MetaInstance]":
1392 """
1393 Fields: database columns by name ('id'), by field reference (table.id) or other (e.g. table.ALL).
1395 Options:
1396 paraphrased from the web2py pydal docs,
1397 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
1399 orderby: field(s) to order by. Supported:
1400 table.name - sort by name, ascending
1401 ~table.name - sort by name, descending
1402 <random> - sort randomly
1403 table.name|table.id - sort by two fields (first name, then id)
1405 groupby, having: together with orderby:
1406 groupby can be a field (e.g. table.name) to group records by
1407 having can be a query, only those `having` the condition are grouped
1409 limitby: tuple of min and max. When using the query builder, .paginate(limit, page) is recommended.
1410 distinct: bool/field. Only select rows that differ
1411 orderby_on_limitby (bool, default: True): by default, an implicit orderby is added when doing limitby.
1412 join: othertable.on(query) - do an INNER JOIN. Using TypeDAL relationships with .join() is recommended!
1413 left: othertable.on(query) - do a LEFT JOIN. Using TypeDAL relationships with .join() is recommended!
1414 cache: cache the query result to speed up repeated queries; e.g. (cache=(cache.ram, 3600), cacheable=True)
1415 """
1416 return self._extend(select_args=list(fields), select_kwargs=options)
1418 def where(
1419 self,
1420 *queries_or_lambdas: Query | typing.Callable[[typing.Type[T_MetaInstance]], Query],
1421 **filters: Any,
1422 ) -> "QueryBuilder[T_MetaInstance]":
1423 """
1424 Extend the builder's query.
1426 Can be used in multiple ways:
1427 .where(Query) -> with a direct query such as `Table.id == 5`
1428 .where(lambda table: table.id == 5) -> with a query via a lambda
1429 .where(id=5) -> via keyword arguments
1431 When using multiple where's, they will be ANDed:
1432 .where(lambda table: table.id == 5).where(lambda table: table.id == 6) == (table.id == 5) & (table.id=6)
1433 When passing multiple queries to a single .where, they will be ORed:
1434 .where(lambda table: table.id == 5, lambda table: table.id == 6) == (table.id == 5) | (table.id=6)
1435 """
1436 new_query = self.query
1437 table = self.model._ensure_table_defined()
1439 for field, value in filters.items():
1440 new_query &= table[field] == value
1442 subquery = DummyQuery()
1443 for query_or_lambda in queries_or_lambdas:
1444 if isinstance(query_or_lambda, _Query):
1445 subquery |= query_or_lambda
1446 elif callable(query_or_lambda):
1447 if result := query_or_lambda(self.model):
1448 subquery |= result
1449 elif isinstance(query_or_lambda, Field) or is_typed_field(query_or_lambda):
1450 subquery |= query_or_lambda != None
1451 else:
1452 raise ValueError(f"Unexpected query type ({type(query_or_lambda)}).")
1454 if subquery:
1455 new_query &= subquery
1457 return self._extend(overwrite_query=new_query)
1459 def join(
1460 self,
1461 *fields: str | typing.Type[TypedTable],
1462 method: JOIN_OPTIONS = None,
1463 on: OnQuery | list[Expression] | Expression = None,
1464 condition: Condition = None,
1465 ) -> "QueryBuilder[T_MetaInstance]":
1466 """
1467 Include relationship fields in the result.
1469 `fields` can be names of Relationships on the current model.
1470 If no fields are passed, all will be used.
1472 By default, the `method` defined in the relationship is used.
1473 This can be overwritten with the `method` keyword argument (left or inner)
1474 """
1475 relationships = self.model.get_relationships()
1477 if condition and on:
1478 raise ValueError("condition and on can not be used together!")
1479 elif condition:
1480 if len(fields) != 1:
1481 raise ValueError("join(field, condition=...) can only be used with exactly one field!")
1483 if isinstance(condition, pydal.objects.Query):
1484 condition = as_lambda(condition)
1486 relationships = {str(fields[0]): relationship(fields[0], condition=condition, join=method)}
1487 elif on:
1488 if len(fields) != 1:
1489 raise ValueError("join(field, on=...) can only be used with exactly one field!")
1491 if isinstance(on, pydal.objects.Expression):
1492 on = [on]
1494 if isinstance(on, list):
1495 on = as_lambda(on)
1496 relationships = {str(fields[0]): relationship(fields[0], on=on, join=method)}
1498 else:
1499 if fields:
1500 # join on every relationship
1501 relationships = {str(k): relationships[str(k)] for k in fields}
1503 if method:
1504 relationships = {str(k): r.clone(join=method) for k, r in relationships.items()}
1506 return self._extend(relationships=relationships)
1508 def _get_db(self) -> TypeDAL:
1509 if db := self.model._db:
1510 return db
1511 else: # pragma: no cover
1512 raise EnvironmentError("@define or db.define is not called on this class yet!")
1514 def _select_arg_convert(self, arg: Any) -> str | Field:
1515 # typedfield are not really used at runtime anymore, but leave it in for safety:
1516 if isinstance(arg, TypedField): # pragma: no cover
1517 arg = arg._field
1519 return arg
1521 def delete(self) -> list[int] | None:
1522 """
1523 Based on the current query, delete rows and return a list of deleted IDs.
1524 """
1525 db = self._get_db()
1526 removed_ids = [_.id for _ in db(self.query).select("id")]
1527 if db(self.query).delete():
1528 # success!
1529 return removed_ids
1531 return None
1533 def _delete(self) -> str:
1534 db = self._get_db()
1535 return str(db(self.query)._delete())
1537 def update(self, **fields: Any) -> list[int] | None:
1538 """
1539 Based on the current query, update `fields` and return a list of updated IDs.
1540 """
1541 db = self._get_db()
1542 updated_ids = db(self.query).select("id").column("id")
1543 if db(self.query).update(**fields):
1544 # success!
1545 return updated_ids
1547 return None
1549 def _update(self, **fields: Any) -> str:
1550 db = self._get_db()
1551 return str(db(self.query)._update(**fields))
1553 def _before_query(self, mut_metadata: dict[str, Any]) -> tuple[Query, list[Any], dict[str, Any]]:
1554 select_args = [self._select_arg_convert(_) for _ in self.select_args] or [self.model.ALL]
1555 select_kwargs = self.select_kwargs.copy()
1556 query = self.query
1557 model = self.model
1558 mut_metadata["query"] = query
1559 # require at least id of main table:
1560 select_fields = ", ".join([str(_) for _ in select_args])
1561 tablename = str(model)
1563 if f"{tablename}.id" not in select_fields:
1564 # fields of other selected, but required ID is missing.
1565 select_args.append(model.id)
1567 if self.relationships:
1568 query, select_args = self._handle_relationships_pre_select(query, select_args, select_kwargs, mut_metadata)
1570 return query, select_args, select_kwargs
1572 def to_sql(self) -> str:
1573 """
1574 Generate the SQL for the built query.
1575 """
1576 db = self._get_db()
1578 query, select_args, select_kwargs = self._before_query({})
1580 return str(db(query)._select(*select_args, **select_kwargs))
1582 def _collect(self) -> str:
1583 """
1584 Alias for to_sql, pydal-like syntax.
1585 """
1586 return self.to_sql()
1588 def collect(self, verbose: bool = False, _to: typing.Type["TypedRows[Any]"] = None) -> "TypedRows[T_MetaInstance]":
1589 """
1590 Execute the built query and turn it into model instances, while handling relationships.
1591 """
1592 if _to is None:
1593 _to = TypedRows
1595 db = self._get_db()
1596 metadata = self.metadata.copy()
1598 query, select_args, select_kwargs = self._before_query(metadata)
1600 metadata["sql"] = db(query)._select(*select_args, **select_kwargs)
1602 if verbose: # pragma: no cover
1603 print(metadata["sql"])
1605 rows: Rows = db(query).select(*select_args, **select_kwargs)
1607 metadata["final_query"] = str(query)
1608 metadata["final_args"] = [str(_) for _ in select_args]
1609 metadata["final_kwargs"] = select_kwargs
1611 if verbose: # pragma: no cover
1612 print(rows)
1614 if not self.relationships:
1615 # easy
1616 return _to.from_rows(rows, self.model, metadata=metadata)
1618 # harder: try to match rows to the belonging objects
1619 # assume structure of {'table': <data>} per row.
1620 # if that's not the case, return default behavior again
1621 return self._collect_with_relationships(rows, metadata=metadata, _to=_to)
1623 def _handle_relationships_pre_select(
1624 self,
1625 query: Query,
1626 select_args: list[Any],
1627 select_kwargs: dict[str, Any],
1628 metadata: dict[str, Any],
1629 ) -> tuple[Query, list[Any]]:
1630 db = self._get_db()
1631 model = self.model
1633 metadata["relationships"] = set(self.relationships.keys())
1635 # query = self._update_query_for_inner(db, model, query)
1636 join = []
1637 for key, relation in self.relationships.items():
1638 if not relation.condition or relation.join != "inner":
1639 continue
1641 other = relation.get_table(db)
1642 other = other.with_alias(f"{key}_{hash(relation)}")
1643 join.append(other.on(relation.condition(model, other)))
1645 if limitby := select_kwargs.pop("limitby", None):
1646 # if limitby + relationships:
1647 # 1. get IDs of main table entries that match 'query'
1648 # 2. change query to .belongs(id)
1649 # 3. add joins etc
1651 kwargs = {"limitby": limitby}
1653 if join:
1654 kwargs["join"] = join
1656 ids = db(query)._select(model.id, **kwargs)
1657 query = model.id.belongs(ids)
1658 metadata["ids"] = ids
1660 if join:
1661 select_kwargs["join"] = join
1663 left = []
1665 for key, relation in self.relationships.items():
1666 other = relation.get_table(db)
1667 method: JOIN_OPTIONS = relation.join or DEFAULT_JOIN_OPTION
1669 select_fields = ", ".join([str(_) for _ in select_args])
1670 pre_alias = str(other)
1672 if f"{other}." not in select_fields:
1673 # no fields of other selected. add .ALL:
1674 select_args.append(other.ALL)
1675 elif f"{other}.id" not in select_fields:
1676 # fields of other selected, but required ID is missing.
1677 select_args.append(other.id)
1679 if relation.on:
1680 # if it has a .on, it's always a left join!
1681 on = relation.on(model, other)
1682 if not isinstance(on, list): # pragma: no cover
1683 on = [on]
1685 left.extend(on)
1686 elif method == "left":
1687 # .on not given, generate it:
1688 other = other.with_alias(f"{key}_{hash(relation)}")
1689 condition = typing.cast(Query, relation.condition(model, other))
1690 left.append(other.on(condition))
1691 else:
1692 # else: inner join (handled earlier)
1693 other = other.with_alias(f"{key}_{hash(relation)}") # only for replace
1694 # other = other.with_alias(f"{key}_{hash(relation)}")
1695 # query &= relation.condition(model, other)
1697 # if no fields of 'other' are included, add other.ALL
1698 # else: only add other.id if missing
1699 select_fields = ", ".join([str(_) for _ in select_args])
1701 post_alias = str(other).split(" AS ")[-1]
1702 if pre_alias != post_alias:
1703 # replace .select's with aliased:
1704 select_fields = select_fields.replace(
1705 f"{pre_alias}.",
1706 f"{post_alias}.",
1707 )
1709 select_args = select_fields.split(", ")
1711 select_kwargs["left"] = left
1712 return query, select_args
1714 def _collect_with_relationships(
1715 self, rows: Rows, metadata: dict[str, Any], _to: typing.Type["TypedRows[Any]"] = None
1716 ) -> "TypedRows[T_MetaInstance]":
1717 """
1718 Transform the raw rows into Typed Table model instances.
1719 """
1720 db = self._get_db()
1721 main_table = self.model._ensure_table_defined()
1723 records = {}
1724 seen_relations: dict[str, set[str]] = defaultdict(set) # main id -> set of col + id for relation
1726 for row in rows:
1727 main = row[main_table]
1728 main_id = main.id
1730 if main_id not in records:
1731 records[main_id] = self.model(main)
1732 records[main_id]._with = list(self.relationships.keys())
1734 # setup up all relationship defaults (once)
1735 for col, relationship in self.relationships.items():
1736 records[main_id][col] = [] if relationship.multiple else None
1738 # now add other relationship data
1739 for column, relation in self.relationships.items():
1740 relationship_column = f"{column}_{hash(relation)}"
1742 # relationship_column works for aliases with the same target column.
1743 # if col + relationship not in the row, just use the regular name.
1745 relation_data = (
1746 row[relationship_column] if relationship_column in row else row[relation.get_table_name()]
1747 )
1749 if relation_data.id is None:
1750 # always skip None ids
1751 continue
1753 if f"{column}-{relation_data.id}" in seen_relations[main_id]:
1754 # speed up duplicates
1755 continue
1756 else:
1757 seen_relations[main_id].add(f"{column}-{relation_data.id}")
1759 relation_table = relation.get_table(db)
1760 # hopefully an instance of a typed table and a regular row otherwise:
1761 instance = relation_table(relation_data) if looks_like(relation_table, TypedTable) else relation_data
1763 if relation.multiple:
1764 # create list of T
1765 if not isinstance(records[main_id].get(column), list): # pragma: no cover
1766 # should already be set up before!
1767 setattr(records[main_id], column, [])
1769 records[main_id][column].append(instance)
1770 else:
1771 # create single T
1772 records[main_id][column] = instance
1774 return _to(rows, self.model, records, metadata=metadata)
1776 def collect_or_fail(self) -> "TypedRows[T_MetaInstance]":
1777 """
1778 Call .collect() and raise an error if nothing found.
1780 Basically unwraps Optional type.
1781 """
1782 if result := self.collect():
1783 return result
1784 else:
1785 raise ValueError("Nothing found!")
1787 def __iter__(self) -> typing.Generator[T_MetaInstance, None, None]:
1788 """
1789 You can start iterating a Query Builder object before calling collect, for ease of use.
1790 """
1791 yield from self.collect()
1793 def count(self) -> int:
1794 """
1795 Return the amount of rows matching the current query.
1796 """
1797 db = self._get_db()
1798 model = self.model
1799 query = self.query
1801 query = self._update_query_for_inner(db, model, query)
1803 return db(query).count()
1805 def _update_query_for_inner(self, db: TypeDAL, model: "typing.Type[T_MetaInstance]", query: Query) -> Query:
1806 for key, relation in self.relationships.items():
1807 if not relation.condition or relation.join != "inner":
1808 continue
1810 other = relation.get_table(db)
1811 other = other.with_alias(f"{key}_{hash(relation)}")
1812 query &= relation.condition(model, other)
1813 return query
1815 def __paginate(
1816 self,
1817 limit: int,
1818 page: int = 1,
1819 ) -> "QueryBuilder[T_MetaInstance]":
1820 _from = limit * (page - 1)
1821 _to = limit * page
1823 available = self.count()
1825 return self._extend(
1826 select_kwargs={"limitby": (_from, _to)},
1827 metadata={
1828 "pagination": {
1829 "limit": limit,
1830 "current_page": page,
1831 "max_page": math.ceil(available / limit),
1832 "rows": available,
1833 "min_max": (_from, _to),
1834 }
1835 },
1836 )
1838 def paginate(self, limit: int, page: int = 1, verbose: bool = False) -> "PaginatedRows[T_MetaInstance]":
1839 """
1840 Paginate transforms the more readable `page` and `limit` to pydals internal limit and offset.
1842 Note: when using relationships, this limit is only applied to the 'main' table and any number of extra rows \
1843 can be loaded with relationship data!
1844 """
1845 builder = self.__paginate(limit, page)
1847 rows = typing.cast(PaginatedRows[T_MetaInstance], builder.collect(verbose=verbose, _to=PaginatedRows))
1849 rows._query_builder = builder
1850 return rows
1852 def _paginate(
1853 self,
1854 limit: int,
1855 page: int = 1,
1856 ) -> str:
1857 builder = self.__paginate(limit, page)
1858 return builder._collect()
1860 def first(self, verbose: bool = False) -> T_MetaInstance | None:
1861 """
1862 Get the first row matching the currently built query.
1864 Also adds paginate, since it would be a waste to select more rows than needed.
1865 """
1866 if row := self.paginate(page=1, limit=1, verbose=verbose).first():
1867 return self.model.from_row(row)
1868 else:
1869 return None
1871 def _first(self) -> str:
1872 return self._paginate(page=1, limit=1)
1874 def first_or_fail(self, verbose: bool = False) -> T_MetaInstance:
1875 """
1876 Call .first() and raise an error if nothing found.
1878 Basically unwraps Optional type.
1879 """
1880 if inst := self.first(verbose=verbose):
1881 return inst
1882 else:
1883 raise ValueError("Nothing found!")
1886class TypedField(typing.Generic[T_Value]): # pragma: no cover
1887 """
1888 Typed version of pydal.Field, which will be converted to a normal Field in the background.
1889 """
1891 # will be set by .bind on db.define
1892 name = ""
1893 _db: Optional[pydal.DAL] = None
1894 _rname: Optional[str] = None
1895 _table: Optional[Table] = None
1896 _field: Optional[Field] = None
1898 _type: T_annotation
1899 kwargs: Any
1901 def __init__(self, _type: typing.Type[T_Value] | types.UnionType = str, /, **settings: Any) -> None: # type: ignore
1902 """
1903 A TypedFieldType should not be inited manually, but TypedField (from `fields.py`) should be used!
1904 """
1905 self._type = _type
1906 self.kwargs = settings
1907 super().__init__()
1909 @typing.overload
1910 def __get__(self, instance: T_MetaInstance, owner: typing.Type[T_MetaInstance]) -> T_Value: # pragma: no cover
1911 """
1912 row.field -> (actual data).
1913 """
1915 @typing.overload
1916 def __get__(self, instance: None, owner: typing.Type[TypedTable]) -> "TypedField[T_Value]": # pragma: no cover
1917 """
1918 Table.field -> Field.
1919 """
1921 def __get__(
1922 self, instance: T_MetaInstance | None, owner: typing.Type[T_MetaInstance]
1923 ) -> typing.Union[T_Value, Field]:
1924 """
1925 Since this class is a Descriptor field, \
1926 it returns something else depending on if it's called on a class or instance.
1928 (this is mostly for mypy/typing)
1929 """
1930 if instance:
1931 # this is only reached in a very specific case:
1932 # an instance of the object was created with a specific set of fields selected (excluding the current one)
1933 # in that case, no value was stored in the owner -> return None (since the field was not selected)
1934 return typing.cast(T_Value, None) # cast as T_Value so mypy understands it for selected fields
1935 else:
1936 # getting as class -> return actual field so pydal understands it when using in query etc.
1937 return typing.cast(TypedField[T_Value], self._field) # pretend it's still typed for IDE support
1939 def __str__(self) -> str:
1940 """
1941 String representation of a Typed Field.
1943 If `type` is set explicitly (e.g. TypedField(str, type="text")), that type is used: `TypedField.text`,
1944 otherwise the type annotation is used (e.g. TypedField(str) -> TypedField.str)
1945 """
1946 return str(self._field) if self._field else ""
1948 def __repr__(self) -> str:
1949 """
1950 More detailed string representation of a Typed Field.
1952 Uses __str__ and adds the provided extra options (kwargs) in the representation.
1953 """
1954 s = self.__str__()
1956 if "type" in self.kwargs:
1957 # manual type in kwargs supplied
1958 t = self.kwargs["type"]
1959 elif issubclass(type, type(self._type)):
1960 # normal type, str.__name__ = 'str'
1961 t = getattr(self._type, "__name__", str(self._type))
1962 elif t_args := typing.get_args(self._type):
1963 # list[str] -> 'str'
1964 t = t_args[0].__name__
1965 else: # pragma: no cover
1966 # fallback - something else, may not even happen, I'm not sure
1967 t = self._type
1969 s = f"TypedField[{t}].{s}" if s else f"TypedField[{t}]"
1971 kw = self.kwargs.copy()
1972 kw.pop("type", None)
1973 return f"<{s} with options {kw}>"
1975 def _to_field(self, extra_kwargs: typing.MutableMapping[str, Any]) -> Optional[str]:
1976 """
1977 Convert a Typed Field instance to a pydal.Field.
1978 """
1979 other_kwargs = self.kwargs.copy()
1980 extra_kwargs.update(other_kwargs)
1981 return extra_kwargs.pop("type", False) or TypeDAL._annotation_to_pydal_fieldtype(self._type, extra_kwargs)
1983 def bind(self, field: pydal.objects.Field, table: pydal.objects.Table) -> None:
1984 """
1985 Bind the right db/table/field info to this class, so queries can be made using `Class.field == ...`.
1986 """
1987 self._table = table
1988 self._field = field
1990 def __getattr__(self, key: str) -> Any:
1991 """
1992 If the regular getattribute does not work, try to get info from the related Field.
1993 """
1994 with contextlib.suppress(AttributeError):
1995 return super().__getattribute__(key)
1997 # try on actual field:
1998 return getattr(self._field, key)
2000 def __eq__(self, other: Any) -> Query:
2001 """
2002 Performing == on a Field will result in a Query.
2003 """
2004 return typing.cast(Query, self._field == other)
2006 def __ne__(self, other: Any) -> Query:
2007 """
2008 Performing != on a Field will result in a Query.
2009 """
2010 return typing.cast(Query, self._field != other)
2012 def __gt__(self, other: Any) -> Query:
2013 """
2014 Performing > on a Field will result in a Query.
2015 """
2016 return typing.cast(Query, self._field > other)
2018 def __lt__(self, other: Any) -> Query:
2019 """
2020 Performing < on a Field will result in a Query.
2021 """
2022 return typing.cast(Query, self._field < other)
2024 def __ge__(self, other: Any) -> Query:
2025 """
2026 Performing >= on a Field will result in a Query.
2027 """
2028 return typing.cast(Query, self._field >= other)
2030 def __le__(self, other: Any) -> Query:
2031 """
2032 Performing <= on a Field will result in a Query.
2033 """
2034 return typing.cast(Query, self._field <= other)
2036 def __hash__(self) -> int:
2037 """
2038 Shadow Field.__hash__.
2039 """
2040 return hash(self._field)
2043S = typing.TypeVar("S")
2046class TypedRows(typing.Collection[T_MetaInstance], Rows):
2047 """
2048 Slighly enhaned and typed functionality on top of pydal Rows (the result of a select).
2049 """
2051 records: dict[int, T_MetaInstance]
2052 # _rows: Rows
2053 model: typing.Type[T_MetaInstance]
2054 metadata: dict[str, Any]
2056 # pseudo-properties: actually stored in _rows
2057 db: TypeDAL
2058 colnames: list[str]
2059 fields: list[Field]
2060 colnames_fields: list[Field]
2061 response: list[tuple[Any, ...]]
2063 def __init__(
2064 self,
2065 rows: Rows,
2066 model: typing.Type[T_MetaInstance],
2067 records: dict[int, T_MetaInstance] = None,
2068 metadata: dict[str, Any] = None,
2069 ) -> None:
2070 """
2071 Should not be called manually!
2073 Normally, the `records` from an existing `Rows` object are used
2074 but these can be overwritten with a `records` dict.
2075 `metadata` can be any (un)structured data
2076 `model` is a Typed Table class
2077 """
2078 records = records or {row.id: model(row) for row in rows}
2079 super().__init__(rows.db, records, rows.colnames, rows.compact, rows.response, rows.fields)
2080 self.model = model
2081 self.metadata = metadata or {}
2083 def __len__(self) -> int:
2084 """
2085 Return the count of rows.
2086 """
2087 return len(self.records)
2089 def __iter__(self) -> typing.Iterator[T_MetaInstance]:
2090 """
2091 Loop through the rows.
2092 """
2093 yield from self.records.values()
2095 def __contains__(self, ind: Any) -> bool:
2096 """
2097 Check if an id exists in this result set.
2098 """
2099 return ind in self.records
2101 def first(self) -> T_MetaInstance | None:
2102 """
2103 Get the row with the lowest id.
2104 """
2105 if not self.records:
2106 return None
2108 return next(iter(self))
2110 def last(self) -> T_MetaInstance | None:
2111 """
2112 Get the row with the highest id.
2113 """
2114 if not self.records:
2115 return None
2117 max_id = max(self.records.keys())
2118 return self[max_id]
2120 def find(
2121 self, f: typing.Callable[[T_MetaInstance], Query], limitby: tuple[int, int] = None
2122 ) -> "TypedRows[T_MetaInstance]":
2123 """
2124 Returns a new Rows object, a subset of the original object, filtered by the function `f`.
2125 """
2126 if not self.records:
2127 return self.__class__(self, self.model, {})
2129 records = {}
2130 if limitby:
2131 _min, _max = limitby
2132 else:
2133 _min, _max = 0, len(self)
2134 count = 0
2135 for i, row in self.records.items():
2136 if f(row):
2137 if _min <= count:
2138 records[i] = row
2139 count += 1
2140 if count == _max:
2141 break
2143 return self.__class__(self, self.model, records)
2145 def exclude(self, f: typing.Callable[[T_MetaInstance], Query]) -> "TypedRows[T_MetaInstance]":
2146 """
2147 Removes elements from the calling Rows object, filtered by the function `f`, \
2148 and returns a new Rows object containing the removed elements.
2149 """
2150 if not self.records:
2151 return self.__class__(self, self.model, {})
2152 removed = {}
2153 to_remove = []
2154 for i in self.records:
2155 row = self[i]
2156 if f(row):
2157 removed[i] = self.records[i]
2158 to_remove.append(i)
2160 [self.records.pop(i) for i in to_remove]
2162 return self.__class__(
2163 self,
2164 self.model,
2165 removed,
2166 )
2168 def sort(self, f: typing.Callable[[T_MetaInstance], Any], reverse: bool = False) -> list[T_MetaInstance]:
2169 """
2170 Returns a list of sorted elements (not sorted in place).
2171 """
2172 return [r for (r, s) in sorted(zip(self.records.values(), self), key=lambda r: f(r[1]), reverse=reverse)]
2174 def __str__(self) -> str:
2175 """
2176 Simple string representation.
2177 """
2178 return f"<TypedRows with {len(self)} records>"
2180 def __repr__(self) -> str:
2181 """
2182 Print a table on repr().
2183 """
2184 data = self.as_dict()
2185 headers = list(next(iter(data.values())).keys())
2186 return mktable(data, headers)
2188 def group_by_value(
2189 self, *fields: str | Field | TypedField[T], one_result: bool = False, **kwargs: Any
2190 ) -> dict[T, list[T_MetaInstance]]:
2191 """
2192 Group the rows by a specific field (which will be the dict key).
2193 """
2194 kwargs["one_result"] = one_result
2195 result = super().group_by_value(*fields, **kwargs)
2196 return typing.cast(dict[T, list[T_MetaInstance]], result)
2198 def column(self, column: str = None) -> list[Any]:
2199 """
2200 Get a list of all values in a specific column.
2202 Example:
2203 rows.column('name') -> ['Name 1', 'Name 2', ...]
2204 """
2205 return typing.cast(list[Any], super().column(column))
2207 def as_csv(self) -> str:
2208 """
2209 Dump the data to csv.
2210 """
2211 return typing.cast(str, super().as_csv())
2213 def as_dict(
2214 self,
2215 key: str = None,
2216 compact: bool = False,
2217 storage_to_dict: bool = False,
2218 datetime_to_str: bool = False,
2219 custom_types: list[type] = None,
2220 ) -> dict[int, dict[str, Any]]:
2221 """
2222 Get the data in a dict of dicts.
2223 """
2224 if any([key, compact, storage_to_dict, datetime_to_str, custom_types]):
2225 # functionality not guaranteed
2226 return typing.cast(
2227 dict[int, dict[str, Any]],
2228 super().as_dict(
2229 key or "id",
2230 compact,
2231 storage_to_dict,
2232 datetime_to_str,
2233 custom_types,
2234 ),
2235 )
2237 return {k: v.as_dict() for k, v in self.records.items()}
2239 def as_json(self, mode: str = "object", default: typing.Callable[[Any], Any] = None) -> str:
2240 """
2241 Turn the data into a dict and then dump to JSON.
2242 """
2243 return typing.cast(str, super().as_json(mode=mode, default=default))
2245 def json(self, mode: str = "object", default: typing.Callable[[Any], Any] = None) -> str:
2246 """
2247 Turn the data into a dict and then dump to JSON.
2248 """
2249 return typing.cast(str, super().as_json(mode=mode, default=default))
2251 def as_list(
2252 self,
2253 compact: bool = False,
2254 storage_to_dict: bool = False,
2255 datetime_to_str: bool = False,
2256 custom_types: list[type] = None,
2257 ) -> list[dict[str, Any]]:
2258 """
2259 Get the data in a list of dicts.
2260 """
2261 if any([compact, storage_to_dict, datetime_to_str, custom_types]):
2262 return typing.cast(
2263 list[dict[str, Any]], super().as_list(compact, storage_to_dict, datetime_to_str, custom_types)
2264 )
2265 return [_.as_dict() for _ in self.records.values()]
2267 def __getitem__(self, item: int) -> T_MetaInstance:
2268 """
2269 You can get a specific row by ID from a typedrows by using rows[idx] notation.
2271 Since pydal's implementation differs (they expect a list instead of a dict with id keys),
2272 using rows[0] will return the first row, regardless of its id.
2273 """
2274 try:
2275 return self.records[item]
2276 except KeyError as e:
2277 if item == 0 and (row := self.first()):
2278 # special case: pydal internals think Rows.records is a list, not a dict
2279 return row
2281 raise e
2283 def get(self, item: int) -> typing.Optional[T_MetaInstance]:
2284 """
2285 Get a row by ID, or receive None if it isn't in this result set.
2286 """
2287 return self.records.get(item)
2289 def join(
2290 self,
2291 field: Field | TypedField[Any],
2292 name: str = None,
2293 constraint: Query = None,
2294 fields: list[str | Field] = None,
2295 orderby: str | Field = None,
2296 ) -> T_MetaInstance:
2297 """
2298 This can be used to JOIN with some relationships after the initial select.
2300 Using the querybuilder's .join() method is prefered!
2301 """
2302 result = super().join(field, name, constraint, fields or [], orderby)
2303 return typing.cast(T_MetaInstance, result)
2305 def export_to_csv_file(
2306 self,
2307 ofile: typing.TextIO,
2308 null: str = "<NULL>",
2309 delimiter: str = ",",
2310 quotechar: str = '"',
2311 quoting: int = csv.QUOTE_MINIMAL,
2312 represent: bool = False,
2313 colnames: list[str] = None,
2314 write_colnames: bool = True,
2315 *args: Any,
2316 **kwargs: Any,
2317 ) -> None:
2318 """
2319 Shadow export_to_csv_file from Rows, but with typing.
2321 See http://web2py.com/books/default/chapter/29/06/the-database-abstraction-layer?search=export_to_csv_file#Exporting-and-importing-data
2322 """
2323 super().export_to_csv_file(
2324 ofile,
2325 null,
2326 *args,
2327 delimiter=delimiter,
2328 quotechar=quotechar,
2329 quoting=quoting,
2330 represent=represent,
2331 colnames=colnames or self.colnames,
2332 write_colnames=write_colnames,
2333 **kwargs,
2334 )
2336 @classmethod
2337 def from_rows(
2338 cls, rows: Rows, model: typing.Type[T_MetaInstance], metadata: dict[str, Any] = None
2339 ) -> "TypedRows[T_MetaInstance]":
2340 """
2341 Internal method to convert a Rows object to a TypedRows.
2342 """
2343 return cls(rows, model, metadata=metadata)
2345 def __json__(self) -> dict[str, Any]:
2346 """
2347 For json-fix.
2348 """
2349 return typing.cast(dict[str, Any], self.as_dict())
2352class Pagination(typing.TypedDict):
2353 """
2354 Pagination key of a paginate dict has these items.
2355 """
2357 total_items: int
2358 current_page: int
2359 per_page: int
2360 total_pages: int
2361 has_next_page: bool
2362 has_prev_page: bool
2363 next_page: Optional[int]
2364 prev_page: Optional[int]
2367class PaginateDict(typing.TypedDict):
2368 """
2369 Result of PaginatedRows.as_dict().
2370 """
2372 data: dict[int, dict[str, Any]]
2373 pagination: Pagination
2376class PaginatedRows(TypedRows[T_MetaInstance]):
2377 """
2378 Extension on top of rows that is used when calling .paginate() instead of .collect().
2379 """
2381 _query_builder: QueryBuilder[T_MetaInstance]
2383 def next(self) -> Self: # noqa: A003
2384 """
2385 Get the next page.
2386 """
2387 data = self.metadata["pagination"]
2388 if data["current_page"] >= data["max_page"]:
2389 raise StopIteration("Final Page")
2391 return self._query_builder.paginate(limit=data["limit"], page=data["current_page"] + 1)
2393 def previous(self) -> Self:
2394 """
2395 Get the previous page.
2396 """
2397 data = self.metadata["pagination"]
2398 if data["current_page"] <= 1:
2399 raise StopIteration("First Page")
2401 return self._query_builder.paginate(limit=data["limit"], page=data["current_page"] - 1)
2403 def as_dict(self, *_: Any, **__: Any) -> PaginateDict: # type: ignore
2404 """
2405 Convert to a dictionary with pagination info and original data.
2407 All arguments are ignored!
2408 """
2409 pagination_data = self.metadata["pagination"]
2411 has_next_page = pagination_data["current_page"] < pagination_data["max_page"]
2412 has_prev_page = pagination_data["current_page"] > 1
2414 return {
2415 "data": super().as_dict(),
2416 "pagination": {
2417 "total_items": pagination_data["rows"],
2418 "current_page": pagination_data["current_page"],
2419 "per_page": pagination_data["limit"],
2420 "total_pages": pagination_data["max_page"],
2421 "has_next_page": has_next_page,
2422 "has_prev_page": has_prev_page,
2423 "next_page": pagination_data["current_page"] + 1 if has_next_page else None,
2424 "prev_page": pagination_data["current_page"] - 1 if has_prev_page else None,
2425 },
2426 }
2429class TypedSet(pydal.objects.Set): # type: ignore # pragma: no cover
2430 """
2431 Used to make pydal Set more typed.
2433 This class is not actually used, only 'cast' by TypeDAL.__call__
2434 """
2436 def count(self, distinct: bool = None, cache: dict[str, Any] = None) -> int:
2437 """
2438 Count returns an int.
2439 """
2440 result = super().count(distinct, cache)
2441 return typing.cast(int, result)
2443 def select(self, *fields: Any, **attributes: Any) -> TypedRows[T_MetaInstance]:
2444 """
2445 Select returns a TypedRows of a user defined table.
2447 Example:
2448 result: TypedRows[MyTable] = db(MyTable.id > 0).select()
2450 for row in result:
2451 typing.reveal_type(row) # MyTable
2452 """
2453 rows = super().select(*fields, **attributes)
2454 return typing.cast(TypedRows[T_MetaInstance], rows)