Coverage for src/typedal/core.py: 100%

1"""

2 Core functionality of TypeDAL.

3"""

4 import contextlib

5 import csv

6 import datetime as dt

7 import inspect

8 import json

9 import math

10 import types

11 import typing

12 import warnings

13 from collections import defaultdict

14 from decimal import Decimal

15 from typing import Any, Optional

16

17 import pydal

18 from pydal._globals import DEFAULT

19 from pydal.objects import Field

20 from pydal.objects import Query as _Query

21 from pydal.objects import Row, Rows

22 from pydal.objects import Table as _Table

23 from typing_extensions import Self

24

25 from .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)

39 from .types import Expression, Query, _Types

40

41# use typing.cast(type, ...) to make mypy happy with unions

42 T_annotation = typing.Type[Any] | types.UnionType

43 T_Query = typing.Union["Table", Query, bool, None, "TypedTable", typing.Type["TypedTable"]]

44 T_Value = typing.TypeVar("T_Value") # actual type of the Field (via Generic)

45 T_MetaInstance = typing.TypeVar("T_MetaInstance", bound="TypedTable") # bound="TypedTable"; bound="TableMeta"

46 T = typing.TypeVar("T")

47

48 BASIC_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}

60

61

62 def is_typed_field(cls: Any) -> typing.TypeGuard["TypedField[Any]"]:

63 """

64 Is `cls` an instance or subclass of TypedField?

65

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 )

73

74

75 JOIN_OPTIONS = typing.Literal["left", "inner", None]

76 DEFAULT_JOIN_OPTION: JOIN_OPTIONS = "left"

77

78# table-ish paramter:

79 P_Table = typing.Union[typing.Type["TypedTable"], pydal.objects.Table]

80

81 Condition: typing.TypeAlias = typing.Optional[

82 typing.Callable[

83 # self, other -> Query

84 [P_Table, P_Table],

85 Query | bool,

86 ]

87]

88

89 OnQuery: typing.TypeAlias = typing.Optional[

90 typing.Callable[

91 # self, other -> list of .on statements

92 [P_Table, P_Table],

93 list[Expression],

94 ]

95]

96

97 To_Type = typing.TypeVar("To_Type", type[Any], typing.Type[Any], str)

98

99

100 class Relationship(typing.Generic[To_Type]):

101 """

102 Define a relationship to another table.

103 """

104

105 _type: To_Type

106 table: typing.Type["TypedTable"] | type | str

107 condition: Condition

108 on: OnQuery

109 multiple: bool

110 join: JOIN_OPTIONS

111

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!")

125

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

130

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

137

138 if isinstance(self.table, str):

139 self.table = TypeDAL.to_snake(self.table)

140

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 )

151

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)"

161

162 join = f":{self.join}" if self.join else ""

163 return f"<Relationship{join} {src_code}>"

164

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

174

175 # boo, fall back to untyped table but pretend it is typed:

176 return typing.cast(typing.Type["TypedTable"], db[table]) # eh close enough!

177

178 return table

179

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

186

187 if isinstance(self.table, pydal.objects.Table):

188 return str(self.table)

189

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

195

196 return str(table)

197

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.

201

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

208

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

216

217

218 def 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.

223

224 Example:

225 class User(TypedTable):

226 name: str

227

228 posts = relationship(list["Post"], condition=lambda self, post: self.id == post.author, join='left')

229

230 class Post(TypedTable):

231 title: str

232 author: User

233

234 User.join("posts").first() # User instance with list[Post] in .posts

235

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.

238

239 For complex queries with a pivot table, a `on` can be set insteaad of `condition`:

240 class User(TypedTable):

241 ...

242

243 tags = relationship(list["Tag"], on=lambda self, tag: [

244 Tagged.on(Tagged.entity == entity.gid),

245 Tag.on((Tagged.tag == tag.id)),

246 ])

247

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)

251

252

253 def _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

258

259 if origin == list:

260 # field = typing.get_args(field)[0] # actual field

261 # return lambda _self, _other: cls[key].contains(field)

262

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

268

269

270 def 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.

277

278 Example:

279 class MyTable(TypedTable):

280 reference: OtherTable

281

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.

284

285 This function will automatically perform this logic (called in db.define):

286 to_relationship(MyTable, 'reference', OtherTable) -> Relationship[OtherTable]

287

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

296

297 field, optional = extract_type_optional(field)

298

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

304

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

309

310 join = "left" if optional or typing.get_origin(field) == list else "inner"

311

312 return Relationship(typing.cast(type[TypedTable], field), condition, typing.cast(JOIN_OPTIONS, join))

313

314

315 class 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 """

319

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

372

373 default_kwargs: typing.ClassVar[typing.Dict[str, Any]] = {

374 # fields are 'required' (notnull) by default:

375 "notnull": True,

376 }

377

378 # maps table name to typedal class, for resolving future references

379 _class_map: typing.ClassVar[dict[str, typing.Type["TypedTable"]]] = {}

380

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)

385

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.

391

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)

394

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("_")}

402

403 typedfields: dict[str, TypedField[Any]] = {

404 k: instanciate(v, True) for k, v in annotations.items() if is_typed_field(v)

405 }

406

407 relationships: dict[str, type[Relationship[Any]]] = filter_out(annotations, Relationship)

408

409 fields = {fname: self._to_field(fname, ftype) for fname, ftype in annotations.items()}

410

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("_")}

413

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])

417

418 # start with base classes and overwrite with current class:

419 relationships = filter_out(full_dict, Relationship) | relationships | filter_out(other_kwargs, Relationship)

420

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 # }

427

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")]

430

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 }

438

439 table: Table = self.define_table(tablename, *fields.values(), **other_kwargs)

440

441 for name, typed_field in typedfields.items():

442 field = fields[name]

443 typed_field.bind(field, table)

444

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!")

455

456 return cls

457

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.

462

463 @db.define()

464 class MyTable(TypedTable): ...

465 """

466

467 @typing.overload

468 def define(self, maybe_cls: typing.Type[T]) -> typing.Type[T]:

469 """

470 Typing Overload for define with a class.

471

472 @db.define

473 class MyTable(TypedTable): ...

474 """

475

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.

482

483 Example:

484 @db.define

485 class Person(TypedTable):

486 ...

487

488 class Article(TypedTable):

489 ...

490

491 # at a later time:

492 db.define(Article)

493

494 Returns:

495 the result of pydal.define_table

496 """

497

498 def wrapper(cls: typing.Type[T]) -> typing.Type[T]:

499 return self._define(cls)

500

501 if maybe_cls:

502 return wrapper(maybe_cls)

503

504 return wrapper

505

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()

513

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")

529

530 def __call__(self, *_args: T_Query, **kwargs: Any) -> "TypedSet":

531 """

532 A db instance can be called directly to perform a query.

533

534 Usually, only a query is passed.

535

536 Example:

537 db(query).select()

538

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.")

545

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

550

551 _set = super().__call__(*args, **kwargs)

552 return typing.cast(TypedSet, _set)

553

554 @classmethod

555 def _build_field(cls, name: str, _type: str, **kw: Any) -> Field:

556 return Field(name, _type, **{**cls.default_kwargs, **kw})

557

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)

564

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 )

570

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

595

596 # Optional[type] == type | None

597

598 match typing.get_args(ftype):

599 case (_child_type, _Types.NONETYPE) | (_Types.NONETYPE, _child_type):

600 # good union of Nullable

601

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

610

611 @classmethod

612 def _to_field(cls, fname: str, ftype: type, **kw: Any) -> Field:

613 """

614 Convert a annotation into a pydal Field.

615

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

621

622 -> pydal.Field(fname, ftype, **kw)

623

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)

633

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)}")

638

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)

645

646

647 class TableProtocol(typing.Protocol): # pragma: no cover

648 """

649 Make mypy happy.

650 """

651

652 id: int # noqa: A003

653

654 def __getitem__(self, item: str) -> Field:

655 """

656 Tell mypy a Table supports dictionary notation for columns.

657 """

658

659

660 class Table(_Table, TableProtocol): # type: ignore

661 """

662 Make mypy happy.

663 """

664

665

666 class TableMeta(type):

667 """

668 This metaclass contains functionality on table classes, that doesn't exist on its instances.

669

670 Example:

671 class MyTable(TypedTable):

672 some_field: TypedField[int]

673

674 MyTable.update_or_insert(...) # should work

675

676 MyTable.some_field # -> Field, can be used to query etc.

677

678 row = MyTable.first() # returns instance of MyTable

679

680 # row.update_or_insert(...) # shouldn't work!

681

682 row.some_field # -> int, with actual data

683

684 """

685

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

692

693 #########################

694 # TypeDAL custom logic: #

695 #########################

696

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

704

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.

708

709 Example:

710 SomeTypedTable.col -> db.table.col (via TypedTableMeta.__getattr__)

711

712 """

713 if self._table:

714 return getattr(self._table, col, None)

715

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

720

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)

727

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]

734

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__}>"

743

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)

749

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()

755

756 def __json__(self: typing.Type[T_MetaInstance], instance: T_MetaInstance | None = None) -> dict[str, Any]:

757 """

758 Convert to a json-dumpable dict.

759

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?

762

763 See Also:

764 https://github.com/jeff-hykin/json_fix

765 """

766 string = instance.as_json() if instance else self.as_json()

767

768 return typing.cast(dict[str, Any], json.loads(string))

769

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 {}

775

776 ##########################

777 # TypeDAL Modified Logic #

778 ##########################

779

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.

783

784 cls.__table functions as 'self'

785

786 Args:

787 **fields: anything you want to insert in the database

788

789 Returns: the ID of the new row.

790

791 """

792 table = self._ensure_table_defined()

793

794 result = table.insert(**fields)

795 # it already is an int but mypy doesn't understand that

796 return self(result)

797

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()

805

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.

811

812 Returns the created or updated instance.

813 """

814 table = self._ensure_table_defined()

815

816 if query is DEFAULT:

817 record = table(**values)

818 elif isinstance(query, dict):

819 record = table(**query)

820 else:

821 record = table(query)

822

823 if not record:

824 return self.insert(**values)

825

826 record.update_record(**values)

827 return self(record)

828

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.

834

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")

843

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.

849

850 Returns a tuple of (the updated instance, a dict of errors).

851 """

852 table = self._ensure_table_defined()

853

854 try:

855 result = table.validate_and_update(query, **fields)

856 except Exception as e:

857 result = {"errors": {"exception": str(e)}}

858

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

866

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

872

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)

877

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

885

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)

891

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)

897

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)

903

904 def count(self: typing.Type[T_MetaInstance]) -> int:

905 """

906 See QueryBuilder.count!

907 """

908 return QueryBuilder(self).count()

909

910 def first(self: typing.Type[T_MetaInstance]) -> T_MetaInstance | None:

911 """

912 See QueryBuilder.first!

913 """

914 return QueryBuilder(self).first()

915

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)

923

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)

929

930 @property

931 def ALL(cls) -> pydal.objects.SQLALL:

932 """

933 Select all fields for this table.

934 """

935 table = cls._ensure_table_defined()

936

937 return table.ALL

938

939 ##########################

940 # TypeDAL Shadowed Logic #

941 ##########################

942 fields: list[str]

943

944 # other table methods:

945

946 def drop(self, mode: str = "") -> None:

947 """

948 Remove the underlying table.

949 """

950 table = self._ensure_table_defined()

951 table.drop(mode)

952

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)

960

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)

968

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 )

1004

1005 def on(self, query: Query) -> Expression:

1006 """

1007 Shadow Table.on.

1008

1009 Used for joins.

1010

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))

1016

1017 def with_alias(self, alias: str) -> _Table:

1018 """

1019 Shadow Table.with_alias.

1020

1021 Useful for joins when joining the same table multiple times.

1022

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)

1028

1029 # @typing.dataclass_transform()

1030

1031

1032 class TypedTable(metaclass=TableMeta):

1033 """

1034 Enhanded modeling system on top of pydal's Table that adds typing and additional functionality.

1035 """

1036

1037 # set up by 'new':

1038 _row: Row | None = None

1039

1040 _with: list[str]

1041

1042 id: "TypedField[int]" # noqa: A003

1043

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

1049

1050 self.update = self._update # type: ignore

1051

1052 self.delete_record = self._delete_record # type: ignore

1053 self.update_record = self._update_record # type: ignore

1054

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.

1060

1061 Examples:

1062 MyTable(1)

1063 MyTable(id=1)

1064 MyTable(MyTable.id == 1)

1065 """

1066 table = cls._ensure_table_defined()

1067

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)

1077

1078 if not row:

1079 return None # type: ignore

1080

1081 inst = super().__new__(cls)

1082 inst._row = row

1083 inst.__dict__.update(row)

1084 inst._setup_instance_methods()

1085 return inst

1086

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)

1093

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)

1100

1101 # fallback to lookup in row

1102 if self._row:

1103 return self._row[item]

1104

1105 # nothing found!

1106 raise KeyError(item)

1107

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

1114

1115 raise AttributeError(item)

1116

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

1125

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)

1131

1132 def __int__(self) -> int:

1133 """

1134 Calling int on a model instance will return its id.

1135 """

1136 return getattr(self, "id", 0)

1137

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))

1143

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

1148

1149 def __repr__(self) -> str:

1150 """

1151 String representation of the model instance.

1152 """

1153 model_name = self.__class__.__name__

1154 model_data = {}

1155

1156 if self._row:

1157 model_data = self._row.as_json()

1158

1159 details = model_name

1160 details += f"({model_data})"

1161

1162 if relationships := getattr(self, "_with", []):

1163 details += f" + {relationships}"

1164

1165 return f"<{details}>"

1166

1167 # serialization

1168 # underscore variants work for class instances (set up by _setup_instance_methods)

1169

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.

1174

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)

1182

1183 @classmethod

1184 def as_json(cls, sanitize: bool = True) -> str:

1185 """

1186 Dump the object to json.

1187

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))

1194

1195 @classmethod

1196 def as_xml(cls, sanitize: bool = True) -> str: # pragma: no cover

1197 """

1198 Dump the object to xml.

1199

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))

1206

1207 @classmethod

1208 def as_yaml(cls, sanitize: bool = True) -> str:

1209 """

1210 Dump the object to yaml.

1211

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))

1218

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)

1224

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()

1232

1233 result[relationship] = data

1234

1235 return typing.cast(dict[str, Any], result)

1236

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))

1247

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))

1251

1252 # def _as_yaml(self, sanitize: bool = True) -> str:

1253 # row = self._ensure_matching_row()

1254 # return typing.cast(str, row.as_yaml(sanitize))

1255

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

1263

1264 super().__setattr__(key, value)

1265

1266 @classmethod

1267 def update(cls: typing.Type[T_MetaInstance], query: Query, **fields: Any) -> T_MetaInstance | None:

1268 """

1269 Update one record.

1270

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

1278

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

1284

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

1290

1291 def update_record(self: T_MetaInstance, **fields: Any) -> T_MetaInstance: # pragma: no cover

1292 """

1293 Here as a placeholder for _update_record.

1294

1295 Will be replaced on instance creation!

1296 """

1297 return self._update_record(**fields)

1298

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)

1311

1312 def delete_record(self) -> int: # pragma: no cover

1313 """

1314 Here as a placeholder for _delete_record.

1315

1316 Will be replaced on instance creation!

1317 """

1318 return self._delete_record()

1319

1320 # __del__ is also called on the end of a scope so don't remove records on every del!!

1321

1322

1323# backwards compat:

1324 TypedRow = TypedTable

1325

1326

1327 class QueryBuilder(typing.Generic[T_MetaInstance]):

1328 """

1329 Abstration on top of pydal's query system.

1330 """

1331

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]

1338

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.

1350

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 {}

1362

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 )

1380

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

1384

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

1388

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)

1394

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

1398

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)

1407

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.

1415

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

1420

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()

1428

1429 for field, value in filters.items():

1430 new_query &= table[field] == value

1431

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)}).")

1443

1444 if subquery:

1445 new_query &= subquery

1446

1447 return self._extend(overwrite_query=new_query)

1448

1449 def join(self, *fields: str, method: JOIN_OPTIONS = None) -> "QueryBuilder[T_MetaInstance]":

1450 """

1451 Include relationship fields in the result.

1452

1453 `fields` can be names of Relationships on the current model.

1454 If no fields are passed, all will be used.

1455

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()

1460

1461 if fields:

1462 # join on every relationship

1463 relationships = {k: relationships[k] for k in fields}

1464

1465 if method:

1466 relationships = {k: r.clone(join=method) for k, r in relationships.items()}

1467

1468 return self._extend(relationships=relationships)

1469

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!")

1475

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

1480

1481 return arg

1482

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

1492

1493 return None

1494

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

1504

1505 return None

1506

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

1513

1514 db = self._get_db()

1515

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

1521

1522 metadata["query"] = query

1523

1524 # require at least id of main table:

1525 select_fields = ", ".join([str(_) for _ in select_args])

1526 tablename = str(model)

1527

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)

1531

1532 if self.relationships:

1533 query, select_args = self._handle_relationships_pre_select(query, select_args, select_kwargs, metadata)

1534

1535 rows: Rows = db(query).select(*select_args, **select_kwargs)

1536

1537 metadata["final_query"] = str(query)

1538 metadata["final_args"] = [str(_) for _ in select_args]

1539 metadata["final_kwargs"] = select_kwargs

1540

1541 metadata["sql"] = db(query)._select(*select_args, **select_kwargs)

1542

1543 if verbose: # pragma: no cover

1544 print(metadata["sql"])

1545 print(rows)

1546

1547 if not self.relationships:

1548 # easy

1549 return _to.from_rows(rows, self.model, metadata=metadata)

1550

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

1554

1555 return self._collect_with_relationships(rows, metadata=metadata, _to=_to)

1556

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

1566

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

1573

1574 ids = db(query)._select(model.id, limitby=limitby)

1575 query = model.id.belongs(ids)

1576 metadata["ids"] = ids

1577

1578 left = []

1579

1580 for key, relation in self.relationships.items():

1581 other = relation.get_table(db)

1582 method: JOIN_OPTIONS = relation.join or DEFAULT_JOIN_OPTION

1583

1584 select_fields = ", ".join([str(_) for _ in select_args])

1585 pre_alias = str(other)

1586

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)

1593

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]

1599

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)

1610

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])

1614

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 )

1622

1623 select_args = select_fields.split(", ")

1624

1625 select_kwargs["left"] = left

1626 return query, select_args

1627

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()

1636

1637 records = {}

1638 seen_relations: dict[str, set[str]] = defaultdict(set) # main id -> set of col + id for relation

1639

1640 for row in rows:

1641 main = row[main_table]

1642 main_id = main.id

1643

1644 if main_id not in records:

1645 records[main_id] = self.model(main)

1646 records[main_id]._with = list(self.relationships.keys())

1647

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

1651

1652 # now add other relationship data

1653 for column, relation in self.relationships.items():

1654 relationship_column = f"{column}_{hash(relation)}"

1655

1656 # relationship_column works for aliases with the same target column.

1657 # if col + relationship not in the row, just use the regular name.

1658

1659 relation_data = (

1660 row[relationship_column] if relationship_column in row else row[relation.get_table_name()]

1661 )

1662

1663 if relation_data.id is None:

1664 # always skip None ids

1665 continue

1666

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}")

1672

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

1676

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, [])

1682

1683 records[main_id][column].append(instance)

1684 else:

1685 # create single T

1686 records[main_id][column] = instance

1687

1688 return _to(rows, self.model, records, metadata=metadata)

1689

1690 def collect_or_fail(self) -> "TypedRows[T_MetaInstance]":

1691 """

1692 Call .collect() and raise an error if nothing found.

1693

1694 Basically unwraps Optional type.

1695 """

1696 if result := self.collect():

1697 return result

1698 else:

1699 raise ValueError("Nothing found!")

1700

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()

1706

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()

1713

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.

1717

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

1723

1724 available = self.count()

1725

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 )

1738

1739 rows = typing.cast(PaginatedRows[T_MetaInstance], builder.collect(verbose=verbose, _to=PaginatedRows))

1740

1741 rows._query_builder = builder

1742 return rows

1743

1744 def first(self, verbose: bool = False) -> T_MetaInstance | None:

1745 """

1746 Get the first row matching the currently built query.

1747

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

1754

1755 def first_or_fail(self, verbose: bool = False) -> T_MetaInstance:

1756 """

1757 Call .first() and raise an error if nothing found.

1758

1759 Basically unwraps Optional type.

1760 """

1761 if inst := self.first(verbose=verbose):

1762 return inst

1763 else:

1764 raise ValueError("Nothing found!")

1765

1766

1767 class 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 """

1771

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

1778

1779 _type: T_annotation

1780 kwargs: Any

1781

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__()

1789

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 """

1795

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 """

1801

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.

1808

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

1819

1820 def __str__(self) -> str:

1821 """

1822 String representation of a Typed Field.

1823

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 ""

1828

1829 def __repr__(self) -> str:

1830 """

1831 More detailed string representation of a Typed Field.

1832

1833 Uses __str__ and adds the provided extra options (kwargs) in the representation.

1834 """

1835 s = self.__str__()

1836

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

1849

1850 s = f"TypedField[{t}].{s}" if s else f"TypedField[{t}]"

1851

1852 kw = self.kwargs.copy()

1853 kw.pop("type", None)

1854 return f"<{s} with options {kw}>"

1855

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)

1863

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

1870

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)

1877

1878 # try on actual field:

1879 return getattr(self._field, key)

1880

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)

1886

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)

1892

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)

1898

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)

1904

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)

1910

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)

1916

1917 def __hash__(self) -> int:

1918 """

1919 Shadow Field.__hash__.

1920 """

1921 return hash(self._field)

1922

1923

1924 S = typing.TypeVar("S")

1925

1926

1927 class TypedRows(typing.Collection[T_MetaInstance], Rows):

1928 """

1929 Slighly enhaned and typed functionality on top of pydal Rows (the result of a select).

1930 """

1931

1932 records: dict[int, T_MetaInstance]

1933 # _rows: Rows

1934 model: typing.Type[T_MetaInstance]

1935 metadata: dict[str, Any]

1936

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, ...]]

1943

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!

1953

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 {}

1963

1964 def __len__(self) -> int:

1965 """

1966 Return the count of rows.

1967 """

1968 return len(self.records)

1969

1970 def __iter__(self) -> typing.Iterator[T_MetaInstance]:

1971 """

1972 Loop through the rows.

1973 """

1974 yield from self.records.values()

1975

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

1981

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

1988

1989 return next(iter(self))

1990

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

1997

1998 max_id = max(self.records.keys())

1999 return self[max_id]

2000

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, {})

2009

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

2023

2024 return self.__class__(self, self.model, records)

2025

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)

2040

2041 [self.records.pop(i) for i in to_remove]

2042

2043 return self.__class__(

2044 self,

2045 self.model,

2046 removed,

2047 )

2048

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)]

2054

2055 def __str__(self) -> str:

2056 """

2057 Simple string representation.

2058 """

2059 return f"<TypedRows with {len(self)} records>"

2060

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)

2068

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)

2078

2079 def column(self, column: str = None) -> list[Any]:

2080 """

2081 Get a list of all values in a specific column.

2082

2083 Example:

2084 rows.column('name') -> ['Name 1', 'Name 2', ...]

2085 """

2086 return typing.cast(list[Any], super().column(column))

2087

2088 def as_csv(self) -> str:

2089 """

2090 Dump the data to csv.

2091 """

2092 return typing.cast(str, super().as_csv())

2093

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 )

2117

2118 return {k: v.as_dict() for k, v in self.records.items()}

2119

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))

2125

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))

2131

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()]

2147

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.

2151

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

2161

2162 raise e

2163

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)

2169

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.

2180

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)

2185

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.

2201

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 )

2216

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)

2225

2226 def __json__(self) -> dict[str, Any]:

2227 """

2228 For json-fix.

2229 """

2230 return typing.cast(dict[str, Any], self.as_dict())

2231

2232

2233 class Pagination(typing.TypedDict):

2234 """

2235 Pagination key of a paginate dict has these items.

2236 """

2237

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]

2246

2247

2248 class PaginateDict(typing.TypedDict):

2249 """

2250 Result of PaginatedRows.as_dict().

2251 """

2252

2253 data: dict[int, dict[str, Any]]

2254 pagination: Pagination

2255

2256

2257 class PaginatedRows(TypedRows[T_MetaInstance]):

2258 """

2259 Extension on top of rows that is used when calling .paginate() instead of .collect().

2260 """

2261

2262 _query_builder: QueryBuilder[T_MetaInstance]

2263

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")

2271

2272 return self._query_builder.paginate(limit=data["limit"], page=data["current_page"] + 1)

2273

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")

2281

2282 return self._query_builder.paginate(limit=data["limit"], page=data["current_page"] - 1)

2283

2284 def as_dict(self, *_: Any, **__: Any) -> PaginateDict: # type: ignore

2285 """

2286 Convert to a dictionary with pagination info and original data.

2287

2288 All arguments are ignored!

2289 """

2290 pagination_data = self.metadata["pagination"]

2291

2292 has_next_page = pagination_data["current_page"] < pagination_data["max_page"]

2293 has_prev_page = pagination_data["current_page"] > 1

2294

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 }

2308

2309

2310 class TypedSet(pydal.objects.Set): # type: ignore # pragma: no cover

2311 """

2312 Used to make pydal Set more typed.

2313

2314 This class is not actually used, only 'cast' by TypeDAL.__call__

2315 """

2316

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)

2323

2324 def select(self, *fields: Any, **attributes: Any) -> TypedRows[T_MetaInstance]:

2325 """

2326 Select returns a TypedRows of a user defined table.

2327

2328 Example:

2329 result: TypedRows[MyTable] = db(MyTable.id > 0).select()

2330

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)