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

40 from .types import Expression, Query, _Types

41

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

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

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

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

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

47 T = typing.TypeVar("T")

48

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

61

62

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

64 """

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

66

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 )

74

75

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

77 DEFAULT_JOIN_OPTION: JOIN_OPTIONS = "left"

78

79# table-ish paramter:

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

81

82 Condition: typing.TypeAlias = typing.Optional[

83 typing.Callable[

84 # self, other -> Query

85 [P_Table, P_Table],

86 Query | bool,

87 ]

88]

89

90 OnQuery: typing.TypeAlias = typing.Optional[

91 typing.Callable[

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

93 [P_Table, P_Table],

94 list[Expression],

95 ]

96]

97

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

99

100

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

102 """

103 Define a relationship to another table.

104 """

105

106 _type: To_Type

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

108 condition: Condition

109 on: OnQuery

110 multiple: bool

111 join: JOIN_OPTIONS

112

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

126

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

131

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

138

139 if isinstance(self.table, str):

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

141

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 )

152

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

162

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

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

165

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

175

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

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

178

179 return table

180

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

187

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

189 return str(self.table)

190

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

196

197 return str(table)

198

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.

202

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

209

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

217

218

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

224

225 Example:

226 class User(TypedTable):

227 name: str

228

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

230

231 class Post(TypedTable):

232 title: str

233 author: User

234

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

236

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.

239

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

241 class User(TypedTable):

242 ...

243

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

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

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

247 ])

248

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)

252

253

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

259

260 if origin == list:

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

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

263

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

269

270

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

278

279 Example:

280 class MyTable(TypedTable):

281 reference: OtherTable

282

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.

285

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

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

288

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

297

298 field, optional = extract_type_optional(field)

299

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

305

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

310

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

312

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

314

315

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

320

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

373

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

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

376 "notnull": True,

377 }

378

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

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

381

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)

386

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.

392

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)

395

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

403

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

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

406 }

407

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

409

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

411

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

414

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

418

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

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

421

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

428

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

431

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 }

439

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

441

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

443 field = fields[name]

444 typed_field.bind(field, table)

445

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

456

457 return cls

458

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.

463

464 @db.define()

465 class MyTable(TypedTable): ...

466 """

467

468 @typing.overload

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

470 """

471 Typing Overload for define with a class.

472

473 @db.define

474 class MyTable(TypedTable): ...

475 """

476

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.

483

484 Example:

485 @db.define

486 class Person(TypedTable):

487 ...

488

489 class Article(TypedTable):

490 ...

491

492 # at a later time:

493 db.define(Article)

494

495 Returns:

496 the result of pydal.define_table

497 """

498

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

500 return self._define(cls)

501

502 if maybe_cls:

503 return wrapper(maybe_cls)

504

505 return wrapper

506

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

514

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

530

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

532 """

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

534

535 Usually, only a query is passed.

536

537 Example:

538 db(query).select()

539

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

546

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

551

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

553 return typing.cast(TypedSet, _set)

554

555 @classmethod

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

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

558

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)

565

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 )

571

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

596

597 # Optional[type] == type | None

598

599 match typing.get_args(ftype):

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

601 # good union of Nullable

602

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

611

612 @classmethod

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

614 """

615 Convert a annotation into a pydal Field.

616

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

622

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

624

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)

634

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

639

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)

646

647

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

649 """

650 Make mypy happy.

651 """

652

653 id: int # noqa: A003

654

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

656 """

657 Tell mypy a Table supports dictionary notation for columns.

658 """

659

660

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

662 """

663 Make mypy happy.

664 """

665

666

667 class TableMeta(type):

668 """

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

670

671 Example:

672 class MyTable(TypedTable):

673 some_field: TypedField[int]

674

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

676

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

678

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

680

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

682

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

684

685 """

686

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

693

694 #########################

695 # TypeDAL custom logic: #

696 #########################

697

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

705

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.

709

710 Example:

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

712

713 """

714 if self._table:

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

716

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

721

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)

728

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]

735

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

744

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)

750

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

756

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

758 """

759 Convert to a json-dumpable dict.

760

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?

763

764 See Also:

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

766 """

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

768

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

770

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

776

777 ##########################

778 # TypeDAL Modified Logic #

779 ##########################

780

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.

784

785 cls.__table functions as 'self'

786

787 Args:

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

789

790 Returns: the ID of the new row.

791

792 """

793 table = self._ensure_table_defined()

794

795 result = table.insert(**fields)

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

797 return self(result)

798

799 def _insert(self, **fields: Any) -> str:

800 table = self._ensure_table_defined()

801

802 return str(table._insert(**fields))

803

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

811

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.

817

818 Returns the created or updated instance.

819 """

820 table = self._ensure_table_defined()

821

822 if query is DEFAULT:

823 record = table(**values)

824 elif isinstance(query, dict):

825 record = table(**query)

826 else:

827 record = table(query)

828

829 if not record:

830 return self.insert(**values)

831

832 record.update_record(**values)

833 return self(record)

834

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.

840

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

849

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.

855

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

857 """

858 table = self._ensure_table_defined()

859

860 try:

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

862 except Exception as e:

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

864

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

872

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

878

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)

883

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

891

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)

897

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)

903

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)

909

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

911 """

912 See QueryBuilder.count!

913 """

914 return QueryBuilder(self).count()

915

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

917 """

918 See QueryBuilder.first!

919 """

920 return QueryBuilder(self).first()

921

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)

933

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)

939

940 @property

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

942 """

943 Select all fields for this table.

944 """

945 table = cls._ensure_table_defined()

946

947 return table.ALL

948

949 ##########################

950 # TypeDAL Shadowed Logic #

951 ##########################

952 fields: list[str]

953

954 # other table methods:

955

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

957 """

958 Remove the underlying table.

959 """

960 table = self._ensure_table_defined()

961 table.drop(mode)

962

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)

970

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)

978

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 )

1014

1015 def on(self, query: Query | bool) -> Expression:

1016 """

1017 Shadow Table.on.

1018

1019 Used for joins.

1020

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

1026

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

1028 """

1029 Shadow Table.with_alias.

1030

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

1032

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)

1038

1039 # @typing.dataclass_transform()

1040

1041

1042 class TypedTable(metaclass=TableMeta):

1043 """

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

1045 """

1046

1047 # set up by 'new':

1048 _row: Row | None = None

1049

1050 _with: list[str]

1051

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

1053

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

1059

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

1061

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

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

1064

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.

1070

1071 Examples:

1072 MyTable(1)

1073 MyTable(id=1)

1074 MyTable(MyTable.id == 1)

1075 """

1076 table = cls._ensure_table_defined()

1077

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)

1087

1088 if not row:

1089 return None # type: ignore

1090

1091 inst = super().__new__(cls)

1092 inst._row = row

1093 inst.__dict__.update(row)

1094 inst._setup_instance_methods()

1095 return inst

1096

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)

1103

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)

1110

1111 # fallback to lookup in row

1112 if self._row:

1113 return self._row[item]

1114

1115 # nothing found!

1116 raise KeyError(item)

1117

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

1124

1125 raise AttributeError(item)

1126

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

1135

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)

1141

1142 def __int__(self) -> int:

1143 """

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

1145 """

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

1147

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

1153

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

1158

1159 def __repr__(self) -> str:

1160 """

1161 String representation of the model instance.

1162 """

1163 model_name = self.__class__.__name__

1164 model_data = {}

1165

1166 if self._row:

1167 model_data = self._row.as_json()

1168

1169 details = model_name

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

1171

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

1173 details += f" + {relationships}"

1174

1175 return f"<{details}>"

1176

1177 # serialization

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

1179

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.

1184

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)

1192

1193 @classmethod

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

1195 """

1196 Dump the object to json.

1197

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

1204

1205 @classmethod

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

1207 """

1208 Dump the object to xml.

1209

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

1216

1217 @classmethod

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

1219 """

1220 Dump the object to yaml.

1221

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

1228

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)

1234

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

1242

1243 result[relationship] = data

1244

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

1246

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

1257

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

1261

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

1263 # row = self._ensure_matching_row()

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

1265

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

1273

1274 super().__setattr__(key, value)

1275

1276 @classmethod

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

1278 """

1279 Update one record.

1280

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

1288

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

1294

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

1300

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

1302 """

1303 Here as a placeholder for _update_record.

1304

1305 Will be replaced on instance creation!

1306 """

1307 return self._update_record(**fields)

1308

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)

1321

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

1323 """

1324 Here as a placeholder for _delete_record.

1325

1326 Will be replaced on instance creation!

1327 """

1328 return self._delete_record()

1329

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

1331

1332

1333# backwards compat:

1334 TypedRow = TypedTable

1335

1336

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

1338 """

1339 Abstration on top of pydal's query system.

1340 """

1341

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]

1348

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.

1360

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

1372

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 )

1390

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

1394

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

1398

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)

1404

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

1408

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)

1417

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.

1425

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

1430

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

1438

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

1440 new_query &= table[field] == value

1441

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

1453

1454 if subquery:

1455 new_query &= subquery

1456

1457 return self._extend(overwrite_query=new_query)

1458

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.

1468

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

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

1471

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

1476

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

1482

1483 if isinstance(condition, pydal.objects.Query):

1484 condition = as_lambda(condition)

1485

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

1490

1491 if isinstance(on, pydal.objects.Expression):

1492 on = [on]

1493

1494 if isinstance(on, list):

1495 on = as_lambda(on)

1496 relationships = {str(fields[0]): relationship(fields[0], on=on, join=method)}

1497

1498 else:

1499 if fields:

1500 # join on every relationship

1501 relationships = {str(k): relationships[str(k)] for k in fields}

1502

1503 if method:

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

1505

1506 return self._extend(relationships=relationships)

1507

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

1513

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

1518

1519 return arg

1520

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

1530

1531 return None

1532

1533 def _delete(self) -> str:

1534 db = self._get_db()

1535 return str(db(self.query)._delete())

1536

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

1546

1547 return None

1548

1549 def _update(self, **fields: Any) -> str:

1550 db = self._get_db()

1551 return str(db(self.query)._update(**fields))

1552

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)

1562

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)

1566

1567 if self.relationships:

1568 query, select_args = self._handle_relationships_pre_select(query, select_args, select_kwargs, mut_metadata)

1569

1570 return query, select_args, select_kwargs

1571

1572 def to_sql(self) -> str:

1573 """

1574 Generate the SQL for the built query.

1575 """

1576 db = self._get_db()

1577

1578 query, select_args, select_kwargs = self._before_query({})

1579

1580 return str(db(query)._select(*select_args, **select_kwargs))

1581

1582 def _collect(self) -> str:

1583 """

1584 Alias for to_sql, pydal-like syntax.

1585 """

1586 return self.to_sql()

1587

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

1594

1595 db = self._get_db()

1596 metadata = self.metadata.copy()

1597

1598 query, select_args, select_kwargs = self._before_query(metadata)

1599

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

1601

1602 if verbose: # pragma: no cover

1603 print(metadata["sql"])

1604

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

1606

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

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

1609 metadata["final_kwargs"] = select_kwargs

1610

1611 if verbose: # pragma: no cover

1612 print(rows)

1613

1614 if not self.relationships:

1615 # easy

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

1617

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)

1622

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

1632

1633 metadata["relationships"] = set(self.relationships.keys())

1634

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

1640

1641 other = relation.get_table(db)

1642 other = other.with_alias(f"{key}_{hash(relation)}")

1643 join.append(other.on(relation.condition(model, other)))

1644

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

1650

1651 kwargs = {"limitby": limitby}

1652

1653 if join:

1654 kwargs["join"] = join

1655

1656 ids = db(query)._select(model.id, **kwargs)

1657 query = model.id.belongs(ids)

1658 metadata["ids"] = ids

1659

1660 if join:

1661 select_kwargs["join"] = join

1662

1663 left = []

1664

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

1666 other = relation.get_table(db)

1667 method: JOIN_OPTIONS = relation.join or DEFAULT_JOIN_OPTION

1668

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

1670 pre_alias = str(other)

1671

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)

1678

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]

1684

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)

1696

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

1700

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 )

1708

1709 select_args = select_fields.split(", ")

1710

1711 select_kwargs["left"] = left

1712 return query, select_args

1713

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

1722

1723 records = {}

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

1725

1726 for row in rows:

1727 main = row[main_table]

1728 main_id = main.id

1729

1730 if main_id not in records:

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

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

1733

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

1737

1738 # now add other relationship data

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

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

1741

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

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

1744

1745 relation_data = (

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

1747 )

1748

1749 if relation_data.id is None:

1750 # always skip None ids

1751 continue

1752

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

1758

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

1762

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

1768

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

1770 else:

1771 # create single T

1772 records[main_id][column] = instance

1773

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

1775

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

1777 """

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

1779

1780 Basically unwraps Optional type.

1781 """

1782 if result := self.collect():

1783 return result

1784 else:

1785 raise ValueError("Nothing found!")

1786

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

1792

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

1800

1801 query = self._update_query_for_inner(db, model, query)

1802

1803 return db(query).count()

1804

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

1809

1810 other = relation.get_table(db)

1811 other = other.with_alias(f"{key}_{hash(relation)}")

1812 query &= relation.condition(model, other)

1813 return query

1814

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

1822

1823 available = self.count()

1824

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 )

1837

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.

1841

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)

1846

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

1848

1849 rows._query_builder = builder

1850 return rows

1851

1852 def _paginate(

1853 self,

1854 limit: int,

1855 page: int = 1,

1856 ) -> str:

1857 builder = self.__paginate(limit, page)

1858 return builder._collect()

1859

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

1861 """

1862 Get the first row matching the currently built query.

1863

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

1870

1871 def _first(self) -> str:

1872 return self._paginate(page=1, limit=1)

1873

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

1875 """

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

1877

1878 Basically unwraps Optional type.

1879 """

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

1881 return inst

1882 else:

1883 raise ValueError("Nothing found!")

1884

1885

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

1890

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

1897

1898 _type: T_annotation

1899 kwargs: Any

1900

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

1908

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

1914

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

1920

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.

1927

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

1938

1939 def __str__(self) -> str:

1940 """

1941 String representation of a Typed Field.

1942

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

1947

1948 def __repr__(self) -> str:

1949 """

1950 More detailed string representation of a Typed Field.

1951

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

1953 """

1954 s = self.__str__()

1955

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

1968

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

1970

1971 kw = self.kwargs.copy()

1972 kw.pop("type", None)

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

1974

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)

1982

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

1989

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)

1996

1997 # try on actual field:

1998 return getattr(self._field, key)

1999

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)

2005

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)

2011

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)

2017

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)

2023

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)

2029

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)

2035

2036 def __hash__(self) -> int:

2037 """

2038 Shadow Field.__hash__.

2039 """

2040 return hash(self._field)

2041

2042

2043 S = typing.TypeVar("S")

2044

2045

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

2047 """

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

2049 """

2050

2051 records: dict[int, T_MetaInstance]

2052 # _rows: Rows

2053 model: typing.Type[T_MetaInstance]

2054 metadata: dict[str, Any]

2055

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

2062

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!

2072

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

2082

2083 def __len__(self) -> int:

2084 """

2085 Return the count of rows.

2086 """

2087 return len(self.records)

2088

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

2090 """

2091 Loop through the rows.

2092 """

2093 yield from self.records.values()

2094

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

2100

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

2107

2108 return next(iter(self))

2109

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

2116

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

2118 return self[max_id]

2119

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

2128

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

2142

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

2144

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)

2159

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

2161

2162 return self.__class__(

2163 self,

2164 self.model,

2165 removed,

2166 )

2167

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

2173

2174 def __str__(self) -> str:

2175 """

2176 Simple string representation.

2177 """

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

2179

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)

2187

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)

2197

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

2199 """

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

2201

2202 Example:

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

2204 """

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

2206

2207 def as_csv(self) -> str:

2208 """

2209 Dump the data to csv.

2210 """

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

2212

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 )

2236

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

2238

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

2244

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

2250

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

2266

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.

2270

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

2280

2281 raise e

2282

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)

2288

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.

2299

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)

2304

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.

2320

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 )

2335

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)

2344

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

2346 """

2347 For json-fix.

2348 """

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

2350

2351

2352 class Pagination(typing.TypedDict):

2353 """

2354 Pagination key of a paginate dict has these items.

2355 """

2356

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]

2365

2366

2367 class PaginateDict(typing.TypedDict):

2368 """

2369 Result of PaginatedRows.as_dict().

2370 """

2371

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

2373 pagination: Pagination

2374

2375

2376 class PaginatedRows(TypedRows[T_MetaInstance]):

2377 """

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

2379 """

2380

2381 _query_builder: QueryBuilder[T_MetaInstance]

2382

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

2390

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

2392

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

2400

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

2402

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

2404 """

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

2406

2407 All arguments are ignored!

2408 """

2409 pagination_data = self.metadata["pagination"]

2410

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

2412 has_prev_page = pagination_data["current_page"] > 1

2413

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 }

2427

2428

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

2430 """

2431 Used to make pydal Set more typed.

2432

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

2434 """

2435

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)

2442

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

2444 """

2445 Select returns a TypedRows of a user defined table.

2446

2447 Example:

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

2449

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)