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 as _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, Field, PaginateDict, Pagination, 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) -> Optional[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 return None

718

719 def _ensure_table_defined(self) -> Table:

720 if not self._table:

721 raise EnvironmentError("@define or db.define is not called on this class yet!")

722 return self._table

723

724 def __iter__(self) -> typing.Generator[Field, None, None]:

725 """

726 Loop through the columns of this model.

727 """

728 table = self._ensure_table_defined()

729 yield from iter(table)

730

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

732 """

733 Allow dict notation to get a column of this table (-> Field instance).

734 """

735 table = self._ensure_table_defined()

736 return table[item]

737

738 def __str__(self) -> str:

739 """

740 Normally, just returns the underlying table name, but with a fallback if the model is unbound.

741 """

742 if self._table:

743 return str(self._table)

744 else:

745 return f"<unbound table {self.__name__}>"

746

747 def from_row(self: typing.Type[T_MetaInstance], row: pydal.objects.Row) -> T_MetaInstance:

748 """

749 Create a model instance from a pydal row.

750 """

751 return self(row)

752

753 def all(self: typing.Type[T_MetaInstance]) -> "TypedRows[T_MetaInstance]": # noqa: A003

754 """

755 Return all rows for this model.

756 """

757 return self.collect()

758

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

760 """

761 Convert to a json-dumpable dict.

762

763 as_dict is not fully json-dumpable, so use as_json and json.loads to ensure it is dumpable (and loadable).

764 todo: can this be optimized?

765

766 See Also:

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

768 """

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

770

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

772

773 def get_relationships(self) -> dict[str, Relationship[Any]]:

774 """

775 Return the registered relationships of the current model.

776 """

777 return self._relationships or {}

778

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

780 # TypeDAL Modified Logic #

781 ##########################

782

783 def insert(self: typing.Type[T_MetaInstance], **fields: Any) -> T_MetaInstance:

784 """

785 This is only called when db.define is not used as a decorator.

786

787 cls.__table functions as 'self'

788

789 Args:

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

791

792 Returns: the ID of the new row.

793

794 """

795 table = self._ensure_table_defined()

796

797 result = table.insert(**fields)

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

799 return self(result)

800

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

802 table = self._ensure_table_defined()

803

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

805

806 def bulk_insert(self: typing.Type[T_MetaInstance], items: list[dict[str, Any]]) -> "TypedRows[T_MetaInstance]":

807 """

808 Insert multiple rows, returns a TypedRows set of new instances.

809 """

810 table = self._ensure_table_defined()

811 result = table.bulk_insert(items)

812 return self.where(lambda row: row.id.belongs(result)).collect()

813

814 def update_or_insert(

815 self: typing.Type[T_MetaInstance], query: T_Query | dict[str, Any] = DEFAULT, **values: Any

816 ) -> T_MetaInstance:

817 """

818 Update a row if query matches, else insert a new one.

819

820 Returns the created or updated instance.

821 """

822 table = self._ensure_table_defined()

823

824 if query is DEFAULT:

825 record = table(**values)

826 elif isinstance(query, dict):

827 record = table(**query)

828 else:

829 record = table(query)

830

831 if not record:

832 return self.insert(**values)

833

834 record.update_record(**values)

835 return self(record)

836

837 def validate_and_insert(

838 self: typing.Type[T_MetaInstance], **fields: Any

839 ) -> tuple[Optional[T_MetaInstance], Optional[dict[str, str]]]:

840 """

841 Validate input data and then insert a row.

842

843 Returns a tuple of (the created instance, a dict of errors).

844 """

845 table = self._ensure_table_defined()

846 result = table.validate_and_insert(**fields)

847 if row_id := result.get("id"):

848 return self(row_id), None

849 else:

850 return None, result.get("errors")

851

852 def validate_and_update(

853 self: typing.Type[T_MetaInstance], query: Query, **fields: Any

854 ) -> tuple[Optional[T_MetaInstance], Optional[dict[str, str]]]:

855 """

856 Validate input data and then update max 1 row.

857

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

859 """

860 table = self._ensure_table_defined()

861

862 try:

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

864 except Exception as e:

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

866

867 if errors := result.get("errors"):

868 return None, errors

869 elif row_id := result.get("id"):

870 return self(row_id), None

871 else: # pragma: no cover

872 # update on query without result (shouldnt happen)

873 return None, None

874

875 def validate_and_update_or_insert(

876 self: typing.Type[T_MetaInstance], query: Query, **fields: Any

877 ) -> tuple[Optional[T_MetaInstance], Optional[dict[str, str]]]:

878 """

879 Validate input data and then update_and_insert (on max 1 row).

880

881 Returns a tuple of (the updated/created instance, a dict of errors).

882 """

883 table = self._ensure_table_defined()

884 result = table.validate_and_update_or_insert(query, **fields)

885

886 if errors := result.get("errors"):

887 return None, errors

888 elif row_id := result.get("id"):

889 return self(row_id), None

890 else: # pragma: no cover

891 # update on query without result (shouldnt happen)

892 return None, None

893

894 def select(self: typing.Type[T_MetaInstance], *a: Any, **kw: Any) -> "QueryBuilder[T_MetaInstance]":

895 """

896 See QueryBuilder.select!

897 """

898 return QueryBuilder(self).select(*a, **kw)

899

900 def paginate(self: typing.Type[T_MetaInstance], limit: int, page: int = 1) -> "PaginatedRows[T_MetaInstance]":

901 """

902 See QueryBuilder.paginate!

903 """

904 return QueryBuilder(self).paginate(limit=limit, page=page)

905

906 def chunk(

907 self: typing.Type[T_MetaInstance], chunk_size: int

908 ) -> typing.Generator["TypedRows[T_MetaInstance]", Any, None]:

909 """

910 See QueryBuilder.chunk!

911 """

912 return QueryBuilder(self).chunk(chunk_size)

913

914 def where(self: typing.Type[T_MetaInstance], *a: Any, **kw: Any) -> "QueryBuilder[T_MetaInstance]":

915 """

916 See QueryBuilder.where!

917 """

918 return QueryBuilder(self).where(*a, **kw)

919

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

921 """

922 See QueryBuilder.count!

923 """

924 return QueryBuilder(self).count()

925

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

927 """

928 See QueryBuilder.first!

929 """

930 return QueryBuilder(self).first()

931

932 def join(

933 self: typing.Type[T_MetaInstance],

934 *fields: str | typing.Type["TypedTable"],

935 method: JOIN_OPTIONS = None,

936 on: OnQuery | list[Expression] | Expression = None,

937 condition: Condition = None,

938 ) -> "QueryBuilder[T_MetaInstance]":

939 """

940 See QueryBuilder.join!

941 """

942 return QueryBuilder(self).join(*fields, on=on, condition=condition, method=method)

943

944 def collect(self: typing.Type[T_MetaInstance], verbose: bool = False) -> "TypedRows[T_MetaInstance]":

945 """

946 See QueryBuilder.collect!

947 """

948 return QueryBuilder(self).collect(verbose=verbose)

949

950 @property

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

952 """

953 Select all fields for this table.

954 """

955 table = cls._ensure_table_defined()

956

957 return table.ALL

958

959 ##########################

960 # TypeDAL Shadowed Logic #

961 ##########################

962 fields: list[str]

963

964 # other table methods:

965

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

967 """

968 Remove the underlying table.

969 """

970 table = self._ensure_table_defined()

971 table.drop(mode)

972

973 def create_index(self, name: str, *fields: Field | str, **kwargs: Any) -> bool:

974 """

975 Add an index on some columns of this table.

976 """

977 table = self._ensure_table_defined()

978 result = table.create_index(name, *fields, **kwargs)

979 return typing.cast(bool, result)

980

981 def drop_index(self, name: str, if_exists: bool = False) -> bool:

982 """

983 Remove an index from this table.

984 """

985 table = self._ensure_table_defined()

986 result = table.drop_index(name, if_exists)

987 return typing.cast(bool, result)

988

989 def import_from_csv_file(

990 self,

991 csvfile: typing.TextIO,

992 id_map: dict[str, str] = None,

993 null: Any = "<NULL>",

994 unique: str = "uuid",

995 id_offset: dict[str, int] = None, # id_offset used only when id_map is None

996 transform: typing.Callable[[dict[Any, Any]], dict[Any, Any]] = None,

997 validate: bool = False,

998 encoding: str = "utf-8",

999 delimiter: str = ",",

1000 quotechar: str = '"',

1001 quoting: int = csv.QUOTE_MINIMAL,

1002 restore: bool = False,

1003 **kwargs: Any,

1004 ) -> None:

1005 """

1006 Load a csv file into the database.

1007 """

1008 table = self._ensure_table_defined()

1009 table.import_from_csv_file(

1010 csvfile,

1011 id_map=id_map,

1012 null=null,

1013 unique=unique,

1014 id_offset=id_offset,

1015 transform=transform,

1016 validate=validate,

1017 encoding=encoding,

1018 delimiter=delimiter,

1019 quotechar=quotechar,

1020 quoting=quoting,

1021 restore=restore,

1022 **kwargs,

1023 )

1024

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

1026 """

1027 Shadow Table.on.

1028

1029 Used for joins.

1030

1031 See Also:

1032 http://web2py.com/books/default/chapter/29/06/the-database-abstraction-layer?search=export_to_csv_file#One-to-many-relation

1033 """

1034 table = self._ensure_table_defined()

1035 return typing.cast(Expression, table.on(query))

1036

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

1038 """

1039 Shadow Table.with_alias.

1040

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

1042

1043 See Also:

1044 http://web2py.com/books/default/chapter/29/06/the-database-abstraction-layer?search=export_to_csv_file#One-to-many-relation

1045 """

1046 table = self._ensure_table_defined()

1047 return table.with_alias(alias)

1048

1049 # @typing.dataclass_transform()

1050

1051

1052 class TypedTable(metaclass=TableMeta):

1053 """

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

1055 """

1056

1057 # set up by 'new':

1058 _row: Row | None = None

1059

1060 _with: list[str]

1061

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

1063

1064 def _setup_instance_methods(self) -> None:

1065 self.as_dict = self._as_dict # type: ignore

1066 self.__json__ = self.as_json = self._as_json # type: ignore

1067 # self.as_yaml = self._as_yaml # type: ignore

1068 self.as_xml = self._as_xml # type: ignore

1069

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

1071

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

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

1074

1075 def __new__(

1076 cls, row_or_id: typing.Union[Row, Query, pydal.objects.Set, int, str, None, "TypedTable"] = None, **filters: Any

1077 ) -> "TypedTable":

1078 """

1079 Create a Typed Rows model instance from an existing row, ID or query.

1080

1081 Examples:

1082 MyTable(1)

1083 MyTable(id=1)

1084 MyTable(MyTable.id == 1)

1085 """

1086 table = cls._ensure_table_defined()

1087

1088 if isinstance(row_or_id, TypedTable):

1089 # existing typed table instance!

1090 return row_or_id

1091 elif isinstance(row_or_id, pydal.objects.Row):

1092 row = row_or_id

1093 elif row_or_id is not None:

1094 row = table(row_or_id, **filters)

1095 else:

1096 row = table(**filters)

1097

1098 if not row:

1099 return None # type: ignore

1100

1101 inst = super().__new__(cls)

1102 inst._row = row

1103 inst.__dict__.update(row)

1104 inst._setup_instance_methods()

1105 return inst

1106

1107 def __iter__(self) -> typing.Generator[Any, None, None]:

1108 """

1109 Allows looping through the columns.

1110 """

1111 row = self._ensure_matching_row()

1112 yield from iter(row)

1113

1114 def __getitem__(self, item: str) -> Any:

1115 """

1116 Allows dictionary notation to get columns.

1117 """

1118 if item in self.__dict__:

1119 return self.__dict__.get(item)

1120

1121 # fallback to lookup in row

1122 if self._row:

1123 return self._row[item]

1124

1125 # nothing found!

1126 raise KeyError(item)

1127

1128 def __getattr__(self, item: str) -> Any:

1129 """

1130 Allows dot notation to get columns.

1131 """

1132 if value := self.get(item):

1133 return value

1134

1135 raise AttributeError(item)

1136

1137 def get(self, item: str, default: Any = None) -> Any:

1138 """

1139 Try to get a column from this instance, else return default.

1140 """

1141 try:

1142 return self.__getitem__(item)

1143 except KeyError:

1144 return default

1145

1146 def __setitem__(self, key: str, value: Any) -> None:

1147 """

1148 Data can both be updated via dot and dict notation.

1149 """

1150 return setattr(self, key, value)

1151

1152 def __int__(self) -> int:

1153 """

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

1155 """

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

1157

1158 def __bool__(self) -> bool:

1159 """

1160 If the instance has an underlying row with data, it is truthy.

1161 """

1162 return bool(getattr(self, "_row", False))

1163

1164 def _ensure_matching_row(self) -> Row:

1165 if not getattr(self, "_row", None):

1166 raise EnvironmentError("Trying to access non-existant row. Maybe it was deleted or not yet initialized?")

1167 return self._row

1168

1169 def __repr__(self) -> str:

1170 """

1171 String representation of the model instance.

1172 """

1173 model_name = self.__class__.__name__

1174 model_data = {}

1175

1176 if self._row:

1177 model_data = self._row.as_json()

1178

1179 details = model_name

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

1181

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

1183 details += f" + {relationships}"

1184

1185 return f"<{details}>"

1186

1187 # serialization

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

1189

1190 @classmethod

1191 def as_dict(cls, flat: bool = False, sanitize: bool = True) -> dict[str, Any]:

1192 """

1193 Dump the object to a plain dict.

1194

1195 Can be used as both a class or instance method:

1196 - dumps the table info if it's a class

1197 - dumps the row info if it's an instance (see _as_dict)

1198 """

1199 table = cls._ensure_table_defined()

1200 result = table.as_dict(flat, sanitize)

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

1202

1203 @classmethod

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

1205 """

1206 Dump the object to json.

1207

1208 Can be used as both a class or instance method:

1209 - dumps the table info if it's a class

1210 - dumps the row info if it's an instance (see _as_json)

1211 """

1212 table = cls._ensure_table_defined()

1213 return typing.cast(str, table.as_json(sanitize))

1214

1215 @classmethod

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

1217 """

1218 Dump the object to xml.

1219

1220 Can be used as both a class or instance method:

1221 - dumps the table info if it's a class

1222 - dumps the row info if it's an instance (see _as_xml)

1223 """

1224 table = cls._ensure_table_defined()

1225 return typing.cast(str, table.as_xml(sanitize))

1226

1227 @classmethod

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

1229 """

1230 Dump the object to yaml.

1231

1232 Can be used as both a class or instance method:

1233 - dumps the table info if it's a class

1234 - dumps the row info if it's an instance (see _as_yaml)

1235 """

1236 table = cls._ensure_table_defined()

1237 return typing.cast(str, table.as_yaml(sanitize))

1238

1239 def _as_dict(

1240 self, datetime_to_str: bool = False, custom_types: typing.Iterable[type] | type | None = None

1241 ) -> dict[str, Any]:

1242 row = self._ensure_matching_row()

1243 result = row.as_dict(datetime_to_str=datetime_to_str, custom_types=custom_types)

1244

1245 if _with := getattr(self, "_with", None):

1246 for relationship in _with:

1247 data = self.get(relationship)

1248 if isinstance(data, list):

1249 data = [_.as_dict() if getattr(_, "as_dict", None) else _ for _ in data]

1250 elif data:

1251 data = data.as_dict()

1252

1253 result[relationship] = data

1254

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

1256

1257 def _as_json(

1258 self,

1259 mode: str = "object",

1260 default: typing.Callable[[Any], Any] = None,

1261 colnames: list[str] = None,

1262 serialize: bool = True,

1263 **kwargs: Any,

1264 ) -> str:

1265 row = self._ensure_matching_row()

1266 return typing.cast(str, row.as_json(mode, default, colnames, serialize, *kwargs))

1267

1268 def _as_xml(self, sanitize: bool = True) -> str: # pragma: no cover

1269 row = self._ensure_matching_row()

1270 return typing.cast(str, row.as_xml(sanitize))

1271

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

1273 # row = self._ensure_matching_row()

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

1275

1276 def __setattr__(self, key: str, value: Any) -> None:

1277 """

1278 When setting a property on a Typed Table model instance, also update the underlying row.

1279 """

1280 if self._row and key in self._row.__dict__ and not callable(value):

1281 # enables `row.key = value; row.update_record()`

1282 self._row[key] = value

1283

1284 super().__setattr__(key, value)

1285

1286 @classmethod

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

1288 """

1289 Update one record.

1290

1291 Example:

1292 MyTable.update(MyTable.id == 1, name="NewName") -> MyTable

1293 """

1294 # todo: update multiple?

1295 if record := cls(query):

1296 return record.update_record(**fields)

1297 else:

1298 return None

1299

1300 def _update(self: T_MetaInstance, **fields: Any) -> T_MetaInstance:

1301 row = self._ensure_matching_row()

1302 row.update(**fields)

1303 self.__dict__.update(**fields)

1304 return self

1305

1306 def _update_record(self: T_MetaInstance, **fields: Any) -> T_MetaInstance:

1307 row = self._ensure_matching_row()

1308 new_row = row.update_record(**fields)

1309 self.update(**new_row)

1310 return self

1311

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

1313 """

1314 Here as a placeholder for _update_record.

1315

1316 Will be replaced on instance creation!

1317 """

1318 return self._update_record(**fields)

1319

1320 def _delete_record(self) -> int:

1321 """

1322 Actual logic in `pydal.helpers.classes.RecordDeleter`.

1323 """

1324 row = self._ensure_matching_row()

1325 result = row.delete_record()

1326 self.__dict__ = {} # empty self, since row is no more.

1327 self._row = None # just to be sure

1328 self._setup_instance_methods()

1329 # ^ instance methods might've been deleted by emptying dict,

1330 # but we still want .as_dict to show an error, not the table's as_dict.

1331 return typing.cast(int, result)

1332

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

1334 """

1335 Here as a placeholder for _delete_record.

1336

1337 Will be replaced on instance creation!

1338 """

1339 return self._delete_record()

1340

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

1342

1343

1344# backwards compat:

1345 TypedRow = TypedTable

1346

1347

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

1349 """

1350 Abstration on top of pydal's query system.

1351 """

1352

1353 model: typing.Type[T_MetaInstance]

1354 query: Query

1355 select_args: list[Any]

1356 select_kwargs: dict[str, Any]

1357 relationships: dict[str, Relationship[Any]]

1358 metadata: dict[str, Any]

1359

1360 def __init__(

1361 self,

1362 model: typing.Type[T_MetaInstance],

1363 add_query: Optional[Query] = None,

1364 select_args: Optional[list[Any]] = None,

1365 select_kwargs: Optional[dict[str, Any]] = None,

1366 relationships: dict[str, Relationship[Any]] = None,

1367 metadata: dict[str, Any] = None,

1368 ):

1369 """

1370 Normally, you wouldn't manually initialize a QueryBuilder but start using a method on a TypedTable.

1371

1372 Example:

1373 MyTable.where(...) -> QueryBuilder[MyTable]

1374 """

1375 self.model = model

1376 table = model._ensure_table_defined()

1377 default_query = typing.cast(Query, table.id > 0)

1378 self.query = add_query or default_query

1379 self.select_args = select_args or []

1380 self.select_kwargs = select_kwargs or {}

1381 self.relationships = relationships or {}

1382 self.metadata = metadata or {}

1383

1384 def __str__(self) -> str:

1385 """

1386 Simple string representation for the query builder.

1387 """

1388 return f"QueryBuilder for {self.model}"

1389

1390 def __repr__(self) -> str:

1391 """

1392 Advanced string representation for the query builder.

1393 """

1394 return (

1395 f"<QueryBuilder for {self.model} with "

1396 f"{len(self.select_args)} select args; "

1397 f"{len(self.select_kwargs)} select kwargs; "

1398 f"{len(self.relationships)} relationships; "

1399 f"query: {bool(self.query)}; "

1400 f"metadata: {self.metadata}; "

1401 f">"

1402 )

1403

1404 def __bool__(self) -> bool:

1405 """

1406 Querybuilder is truthy if it has rows.

1407 """

1408 return self.count() > 0

1409

1410 def _extend(

1411 self,

1412 add_query: Optional[Query] = None,

1413 overwrite_query: Optional[Query] = None,

1414 select_args: Optional[list[Any]] = None,

1415 select_kwargs: Optional[dict[str, Any]] = None,

1416 relationships: dict[str, Relationship[Any]] = None,

1417 metadata: dict[str, Any] = None,

1418 ) -> "QueryBuilder[T_MetaInstance]":

1419 return QueryBuilder(

1420 self.model,

1421 (add_query & self.query) if add_query else overwrite_query or self.query,

1422 (self.select_args + select_args) if select_args else self.select_args,

1423 (self.select_kwargs | select_kwargs) if select_kwargs else self.select_kwargs,

1424 (self.relationships | relationships) if relationships else self.relationships,

1425 (self.metadata | metadata) if metadata else self.metadata,

1426 )

1427

1428 def select(self, *fields: Any, **options: Any) -> "QueryBuilder[T_MetaInstance]":

1429 """

1430 Fields: database columns by name ('id'), by field reference (table.id) or other (e.g. table.ALL).

1431

1432 Options:

1433 paraphrased from the web2py pydal docs,

1434 For more info, see http://www.web2py.com/books/default/chapter/29/06/the-database-abstraction-layer#orderby-groupby-limitby-distinct-having-orderby_on_limitby-join-left-cache

1435

1436 orderby: field(s) to order by. Supported:

1437 table.name - sort by name, ascending

1438 ~table.name - sort by name, descending

1439 <random> - sort randomly

1440 table.name|table.id - sort by two fields (first name, then id)

1441

1442 groupby, having: together with orderby:

1443 groupby can be a field (e.g. table.name) to group records by

1444 having can be a query, only those `having` the condition are grouped

1445

1446 limitby: tuple of min and max. When using the query builder, .paginate(limit, page) is recommended.

1447 distinct: bool/field. Only select rows that differ

1448 orderby_on_limitby (bool, default: True): by default, an implicit orderby is added when doing limitby.

1449 join: othertable.on(query) - do an INNER JOIN. Using TypeDAL relationships with .join() is recommended!

1450 left: othertable.on(query) - do a LEFT JOIN. Using TypeDAL relationships with .join() is recommended!

1451 cache: cache the query result to speed up repeated queries; e.g. (cache=(cache.ram, 3600), cacheable=True)

1452 """

1453 return self._extend(select_args=list(fields), select_kwargs=options)

1454

1455 def where(

1456 self,

1457 *queries_or_lambdas: Query | typing.Callable[[typing.Type[T_MetaInstance]], Query],

1458 **filters: Any,

1459 ) -> "QueryBuilder[T_MetaInstance]":

1460 """

1461 Extend the builder's query.

1462

1463 Can be used in multiple ways:

1464 .where(Query) -> with a direct query such as `Table.id == 5`

1465 .where(lambda table: table.id == 5) -> with a query via a lambda

1466 .where(id=5) -> via keyword arguments

1467

1468 When using multiple where's, they will be ANDed:

1469 .where(lambda table: table.id == 5).where(lambda table: table.id == 6) == (table.id == 5) & (table.id=6)

1470 When passing multiple queries to a single .where, they will be ORed:

1471 .where(lambda table: table.id == 5, lambda table: table.id == 6) == (table.id == 5) | (table.id=6)

1472 """

1473 new_query = self.query

1474 table = self.model._ensure_table_defined()

1475

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

1477 new_query &= table[field] == value

1478

1479 subquery: DummyQuery | Query = DummyQuery()

1480 for query_or_lambda in queries_or_lambdas:

1481 if isinstance(query_or_lambda, _Query):

1482 subquery |= typing.cast(Query, query_or_lambda)

1483 elif callable(query_or_lambda):

1484 if result := query_or_lambda(self.model):

1485 subquery |= result

1486 elif isinstance(query_or_lambda, (Field, _Field)) or is_typed_field(query_or_lambda):

1487 subquery |= typing.cast(Query, query_or_lambda != None)

1488 else:

1489 raise ValueError(f"Unexpected query type ({type(query_or_lambda)}).")

1490

1491 if subquery:

1492 new_query &= subquery

1493

1494 return self._extend(overwrite_query=new_query)

1495

1496 def join(

1497 self,

1498 *fields: str | typing.Type[TypedTable],

1499 method: JOIN_OPTIONS = None,

1500 on: OnQuery | list[Expression] | Expression = None,

1501 condition: Condition = None,

1502 ) -> "QueryBuilder[T_MetaInstance]":

1503 """

1504 Include relationship fields in the result.

1505

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

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

1508

1509 By default, the `method` defined in the relationship is used.

1510 This can be overwritten with the `method` keyword argument (left or inner)

1511 """

1512 # todo: allow limiting amount of related rows returned for join?

1513

1514 relationships = self.model.get_relationships()

1515

1516 if condition and on:

1517 raise ValueError("condition and on can not be used together!")

1518 elif condition:

1519 if len(fields) != 1:

1520 raise ValueError("join(field, condition=...) can only be used with exactly one field!")

1521

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

1523 condition = as_lambda(condition)

1524

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

1526 elif on:

1527 if len(fields) != 1:

1528 raise ValueError("join(field, on=...) can only be used with exactly one field!")

1529

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

1531 on = [on]

1532

1533 if isinstance(on, list):

1534 on = as_lambda(on)

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

1536

1537 else:

1538 if fields:

1539 # join on every relationship

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

1541

1542 if method:

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

1544

1545 return self._extend(relationships=relationships)

1546

1547 def _get_db(self) -> TypeDAL:

1548 if db := self.model._db:

1549 return db

1550 else: # pragma: no cover

1551 raise EnvironmentError("@define or db.define is not called on this class yet!")

1552

1553 def _select_arg_convert(self, arg: Any) -> Any:

1554 # typedfield are not really used at runtime anymore, but leave it in for safety:

1555 if isinstance(arg, TypedField): # pragma: no cover

1556 arg = arg._field

1557

1558 return arg

1559

1560 def delete(self) -> list[int] | None:

1561 """

1562 Based on the current query, delete rows and return a list of deleted IDs.

1563 """

1564 db = self._get_db()

1565 removed_ids = [_.id for _ in db(self.query).select("id")]

1566 if db(self.query).delete():

1567 # success!

1568 return removed_ids

1569

1570 return None

1571

1572 def _delete(self) -> str:

1573 db = self._get_db()

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

1575

1576 def update(self, **fields: Any) -> list[int] | None:

1577 """

1578 Based on the current query, update `fields` and return a list of updated IDs.

1579 """

1580 # todo: limit?

1581 db = self._get_db()

1582 updated_ids = db(self.query).select("id").column("id")

1583 if db(self.query).update(**fields):

1584 # success!

1585 return updated_ids

1586

1587 return None

1588

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

1590 db = self._get_db()

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

1592

1593 def _before_query(self, mut_metadata: dict[str, Any]) -> tuple[Query, list[Any], dict[str, Any]]:

1594 select_args = [self._select_arg_convert(_) for _ in self.select_args] or [self.model.ALL]

1595 select_kwargs = self.select_kwargs.copy()

1596 query = self.query

1597 model = self.model

1598 mut_metadata["query"] = query

1599 # require at least id of main table:

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

1601 tablename = str(model)

1602

1603 if f"{tablename}.id" not in select_fields:

1604 # fields of other selected, but required ID is missing.

1605 select_args.append(model.id)

1606

1607 if self.relationships:

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

1609

1610 return query, select_args, select_kwargs

1611

1612 def to_sql(self) -> str:

1613 """

1614 Generate the SQL for the built query.

1615 """

1616 db = self._get_db()

1617

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

1619

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

1621

1622 def _collect(self) -> str:

1623 """

1624 Alias for to_sql, pydal-like syntax.

1625 """

1626 return self.to_sql()

1627

1628 def collect(self, verbose: bool = False, _to: typing.Type["TypedRows[Any]"] = None) -> "TypedRows[T_MetaInstance]":

1629 """

1630 Execute the built query and turn it into model instances, while handling relationships.

1631 """

1632 if _to is None:

1633 _to = TypedRows

1634

1635 db = self._get_db()

1636 metadata = self.metadata.copy()

1637

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

1639

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

1641

1642 if verbose: # pragma: no cover

1643 print(metadata["sql"])

1644

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

1646

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

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

1649 metadata["final_kwargs"] = select_kwargs

1650

1651 if verbose: # pragma: no cover

1652 print(rows)

1653

1654 if not self.relationships:

1655 # easy

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

1657

1658 # harder: try to match rows to the belonging objects

1659 # assume structure of {'table': <data>} per row.

1660 # if that's not the case, return default behavior again

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

1662

1663 def _handle_relationships_pre_select(

1664 self,

1665 query: Query,

1666 select_args: list[Any],

1667 select_kwargs: dict[str, Any],

1668 metadata: dict[str, Any],

1669 ) -> tuple[Query, list[Any]]:

1670 db = self._get_db()

1671 model = self.model

1672

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

1674

1675 # query = self._update_query_for_inner(db, model, query)

1676 join = []

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

1678 if not relation.condition or relation.join != "inner":

1679 continue

1680

1681 other = relation.get_table(db)

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

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

1684

1685 if limitby := select_kwargs.pop("limitby", None):

1686 # if limitby + relationships:

1687 # 1. get IDs of main table entries that match 'query'

1688 # 2. change query to .belongs(id)

1689 # 3. add joins etc

1690

1691 kwargs = {"limitby": limitby}

1692

1693 if join:

1694 kwargs["join"] = join

1695

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

1697 query = model.id.belongs(ids)

1698 metadata["ids"] = ids

1699

1700 if join:

1701 select_kwargs["join"] = join

1702

1703 left = []

1704

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

1706 other = relation.get_table(db)

1707 method: JOIN_OPTIONS = relation.join or DEFAULT_JOIN_OPTION

1708

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

1710 pre_alias = str(other)

1711

1712 if f"{other}." not in select_fields:

1713 # no fields of other selected. add .ALL:

1714 select_args.append(other.ALL)

1715 elif f"{other}.id" not in select_fields:

1716 # fields of other selected, but required ID is missing.

1717 select_args.append(other.id)

1718

1719 if relation.on:

1720 # if it has a .on, it's always a left join!

1721 on = relation.on(model, other)

1722 if not isinstance(on, list): # pragma: no cover

1723 on = [on]

1724

1725 left.extend(on)

1726 elif method == "left":

1727 # .on not given, generate it:

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

1729 condition = typing.cast(Query, relation.condition(model, other))

1730 left.append(other.on(condition))

1731 else:

1732 # else: inner join (handled earlier)

1733 other = other.with_alias(f"{key}_{hash(relation)}") # only for replace

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

1735 # query &= relation.condition(model, other)

1736

1737 # if no fields of 'other' are included, add other.ALL

1738 # else: only add other.id if missing

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

1740

1741 post_alias = str(other).split(" AS ")[-1]

1742 if pre_alias != post_alias:

1743 # replace .select's with aliased:

1744 select_fields = select_fields.replace(

1745 f"{pre_alias}.",

1746 f"{post_alias}.",

1747 )

1748

1749 select_args = select_fields.split(", ")

1750

1751 select_kwargs["left"] = left

1752 return query, select_args

1753

1754 def _collect_with_relationships(

1755 self, rows: Rows, metadata: dict[str, Any], _to: typing.Type["TypedRows[Any]"] = None

1756 ) -> "TypedRows[T_MetaInstance]":

1757 """

1758 Transform the raw rows into Typed Table model instances.

1759 """

1760 db = self._get_db()

1761 main_table = self.model._ensure_table_defined()

1762

1763 records = {}

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

1765

1766 for row in rows:

1767 main = row[main_table]

1768 main_id = main.id

1769

1770 if main_id not in records:

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

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

1773

1774 # setup up all relationship defaults (once)

1775 for col, relationship in self.relationships.items():

1776 records[main_id][col] = [] if relationship.multiple else None

1777

1778 # now add other relationship data

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

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

1781

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

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

1784

1785 relation_data = (

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

1787 )

1788

1789 if relation_data.id is None:

1790 # always skip None ids

1791 continue

1792

1793 if f"{column}-{relation_data.id}" in seen_relations[main_id]:

1794 # speed up duplicates

1795 continue

1796 else:

1797 seen_relations[main_id].add(f"{column}-{relation_data.id}")

1798

1799 relation_table = relation.get_table(db)

1800 # hopefully an instance of a typed table and a regular row otherwise:

1801 instance = relation_table(relation_data) if looks_like(relation_table, TypedTable) else relation_data

1802

1803 if relation.multiple:

1804 # create list of T

1805 if not isinstance(records[main_id].get(column), list): # pragma: no cover

1806 # should already be set up before!

1807 setattr(records[main_id], column, [])

1808

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

1810 else:

1811 # create single T

1812 records[main_id][column] = instance

1813

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

1815

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

1817 """

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

1819

1820 Basically unwraps Optional type.

1821 """

1822 if result := self.collect():

1823 return result

1824 else:

1825 raise ValueError("Nothing found!")

1826

1827 def __iter__(self) -> typing.Generator[T_MetaInstance, None, None]:

1828 """

1829 You can start iterating a Query Builder object before calling collect, for ease of use.

1830 """

1831 yield from self.collect()

1832

1833 def count(self) -> int:

1834 """

1835 Return the amount of rows matching the current query.

1836 """

1837 db = self._get_db()

1838 model = self.model

1839 query = self.query

1840

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

1842 if not relation.condition or relation.join != "inner":

1843 continue

1844

1845 other = relation.get_table(db)

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

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

1848

1849 return db(query).count()

1850

1851 def __paginate(

1852 self,

1853 limit: int,

1854 page: int = 1,

1855 ) -> "QueryBuilder[T_MetaInstance]":

1856 _from = limit * (page - 1)

1857 _to = limit * page

1858

1859 available = self.count()

1860

1861 return self._extend(

1862 select_kwargs={"limitby": (_from, _to)},

1863 metadata={

1864 "pagination": {

1865 "limit": limit,

1866 "current_page": page,

1867 "max_page": math.ceil(available / limit),

1868 "rows": available,

1869 "min_max": (_from, _to),

1870 }

1871 },

1872 )

1873

1874 def paginate(self, limit: int, page: int = 1, verbose: bool = False) -> "PaginatedRows[T_MetaInstance]":

1875 """

1876 Paginate transforms the more readable `page` and `limit` to pydals internal limit and offset.

1877

1878 Note: when using relationships, this limit is only applied to the 'main' table and any number of extra rows \

1879 can be loaded with relationship data!

1880 """

1881 builder = self.__paginate(limit, page)

1882

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

1884

1885 rows._query_builder = builder

1886 return rows

1887

1888 def _paginate(

1889 self,

1890 limit: int,

1891 page: int = 1,

1892 ) -> str:

1893 builder = self.__paginate(limit, page)

1894 return builder._collect()

1895

1896 def chunk(self, chunk_size: int) -> typing.Generator["TypedRows[T_MetaInstance]", Any, None]:

1897 """

1898 Generator that yields rows from a paginated source in chunks.

1899

1900 This function retrieves rows from a paginated data source in chunks of the

1901 specified `chunk_size` and yields them as TypedRows.

1902

1903 Example:

1904 ```

1905 for chunk_of_rows in Table.where(SomeTable.id > 5).chunk(100):

1906 for row in chunk_of_rows:

1907 # Process each row within the chunk.

1908 pass

1909 ```

1910 """

1911 page = 1

1912

1913 while rows := self.__paginate(chunk_size, page).collect():

1914 yield rows

1915 page += 1

1916

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

1918 """

1919 Get the first row matching the currently built query.

1920

1921 Also adds paginate, since it would be a waste to select more rows than needed.

1922 """

1923 if row := self.paginate(page=1, limit=1, verbose=verbose).first():

1924 return self.model.from_row(row)

1925 else:

1926 return None

1927

1928 def _first(self) -> str:

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

1930

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

1932 """

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

1934

1935 Basically unwraps Optional type.

1936 """

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

1938 return inst

1939 else:

1940 raise ValueError("Nothing found!")

1941

1942

1943 class TypedField(typing.Generic[T_Value]): # pragma: no cover

1944 """

1945 Typed version of pydal.Field, which will be converted to a normal Field in the background.

1946 """

1947

1948 # will be set by .bind on db.define

1949 name = ""

1950 _db: Optional[pydal.DAL] = None

1951 _rname: Optional[str] = None

1952 _table: Optional[Table] = None

1953 _field: Optional[Field] = None

1954

1955 _type: T_annotation

1956 kwargs: Any

1957

1958 def __init__(self, _type: typing.Type[T_Value] | types.UnionType = str, /, **settings: Any) -> None: # type: ignore

1959 """

1960 A TypedFieldType should not be inited manually, but TypedField (from `fields.py`) should be used!

1961 """

1962 self._type = _type

1963 self.kwargs = settings

1964 super().__init__()

1965

1966 @typing.overload

1967 def __get__(self, instance: T_MetaInstance, owner: typing.Type[T_MetaInstance]) -> T_Value: # pragma: no cover

1968 """

1969 row.field -> (actual data).

1970 """

1971

1972 @typing.overload

1973 def __get__(self, instance: None, owner: typing.Type[TypedTable]) -> "TypedField[T_Value]": # pragma: no cover

1974 """

1975 Table.field -> Field.

1976 """

1977

1978 def __get__(

1979 self, instance: T_MetaInstance | None, owner: typing.Type[T_MetaInstance]

1980 ) -> typing.Union[T_Value, "TypedField[T_Value]"]:

1981 """

1982 Since this class is a Descriptor field, \

1983 it returns something else depending on if it's called on a class or instance.

1984

1985 (this is mostly for mypy/typing)

1986 """

1987 if instance:

1988 # this is only reached in a very specific case:

1989 # an instance of the object was created with a specific set of fields selected (excluding the current one)

1990 # in that case, no value was stored in the owner -> return None (since the field was not selected)

1991 return typing.cast(T_Value, None) # cast as T_Value so mypy understands it for selected fields

1992 else:

1993 # getting as class -> return actual field so pydal understands it when using in query etc.

1994 return typing.cast(TypedField[T_Value], self._field) # pretend it's still typed for IDE support

1995

1996 def __str__(self) -> str:

1997 """

1998 String representation of a Typed Field.

1999

2000 If `type` is set explicitly (e.g. TypedField(str, type="text")), that type is used: `TypedField.text`,

2001 otherwise the type annotation is used (e.g. TypedField(str) -> TypedField.str)

2002 """

2003 return str(self._field) if self._field else ""

2004

2005 def __repr__(self) -> str:

2006 """

2007 More detailed string representation of a Typed Field.

2008

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

2010 """

2011 s = self.__str__()

2012

2013 if "type" in self.kwargs:

2014 # manual type in kwargs supplied

2015 t = self.kwargs["type"]

2016 elif issubclass(type, type(self._type)):

2017 # normal type, str.__name__ = 'str'

2018 t = getattr(self._type, "__name__", str(self._type))

2019 elif t_args := typing.get_args(self._type):

2020 # list[str] -> 'str'

2021 t = t_args[0].__name__

2022 else: # pragma: no cover

2023 # fallback - something else, may not even happen, I'm not sure

2024 t = self._type

2025

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

2027

2028 kw = self.kwargs.copy()

2029 kw.pop("type", None)

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

2031

2032 def _to_field(self, extra_kwargs: typing.MutableMapping[str, Any]) -> Optional[str]:

2033 """

2034 Convert a Typed Field instance to a pydal.Field.

2035 """

2036 other_kwargs = self.kwargs.copy()

2037 extra_kwargs.update(other_kwargs)

2038 return extra_kwargs.pop("type", False) or TypeDAL._annotation_to_pydal_fieldtype(self._type, extra_kwargs)

2039

2040 def bind(self, field: pydal.objects.Field, table: pydal.objects.Table) -> None:

2041 """

2042 Bind the right db/table/field info to this class, so queries can be made using `Class.field == ...`.

2043 """

2044 self._table = table

2045 self._field = field

2046

2047 def __getattr__(self, key: str) -> Any:

2048 """

2049 If the regular getattribute does not work, try to get info from the related Field.

2050 """

2051 with contextlib.suppress(AttributeError):

2052 return super().__getattribute__(key)

2053

2054 # try on actual field:

2055 return getattr(self._field, key)

2056

2057 def __eq__(self, other: Any) -> Query:

2058 """

2059 Performing == on a Field will result in a Query.

2060 """

2061 return typing.cast(Query, self._field == other)

2062

2063 def __ne__(self, other: Any) -> Query:

2064 """

2065 Performing != on a Field will result in a Query.

2066 """

2067 return typing.cast(Query, self._field != other)

2068

2069 def __gt__(self, other: Any) -> Query:

2070 """

2071 Performing > on a Field will result in a Query.

2072 """

2073 return typing.cast(Query, self._field > other)

2074

2075 def __lt__(self, other: Any) -> Query:

2076 """

2077 Performing < on a Field will result in a Query.

2078 """

2079 return typing.cast(Query, self._field < other)

2080

2081 def __ge__(self, other: Any) -> Query:

2082 """

2083 Performing >= on a Field will result in a Query.

2084 """

2085 return typing.cast(Query, self._field >= other)

2086

2087 def __le__(self, other: Any) -> Query:

2088 """

2089 Performing <= on a Field will result in a Query.

2090 """

2091 return typing.cast(Query, self._field <= other)

2092

2093 def __hash__(self) -> int:

2094 """

2095 Shadow Field.__hash__.

2096 """

2097 return hash(self._field)

2098

2099 def __invert__(self) -> Expression:

2100 """

2101 Performing ~ on a Field will result in an Expression.

2102 """

2103 if not self._field: # pragma: no cover

2104 raise ValueError("Unbound Field can not be inverted!")

2105

2106 return typing.cast(Expression, ~self._field)

2107

2108

2109 S = typing.TypeVar("S")

2110

2111

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

2113 """

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

2115 """

2116

2117 records: dict[int, T_MetaInstance]

2118 # _rows: Rows

2119 model: typing.Type[T_MetaInstance]

2120 metadata: dict[str, Any]

2121

2122 # pseudo-properties: actually stored in _rows

2123 db: TypeDAL

2124 colnames: list[str]

2125 fields: list[Field]

2126 colnames_fields: list[Field]

2127 response: list[tuple[Any, ...]]

2128

2129 def __init__(

2130 self,

2131 rows: Rows,

2132 model: typing.Type[T_MetaInstance],

2133 records: dict[int, T_MetaInstance] = None,

2134 metadata: dict[str, Any] = None,

2135 ) -> None:

2136 """

2137 Should not be called manually!

2138

2139 Normally, the `records` from an existing `Rows` object are used

2140 but these can be overwritten with a `records` dict.

2141 `metadata` can be any (un)structured data

2142 `model` is a Typed Table class

2143 """

2144 records = records or {row.id: model(row) for row in rows}

2145 super().__init__(rows.db, records, rows.colnames, rows.compact, rows.response, rows.fields)

2146 self.model = model

2147 self.metadata = metadata or {}

2148

2149 def __len__(self) -> int:

2150 """

2151 Return the count of rows.

2152 """

2153 return len(self.records)

2154

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

2156 """

2157 Loop through the rows.

2158 """

2159 yield from self.records.values()

2160

2161 def __contains__(self, ind: Any) -> bool:

2162 """

2163 Check if an id exists in this result set.

2164 """

2165 return ind in self.records

2166

2167 def first(self) -> T_MetaInstance | None:

2168 """

2169 Get the row with the lowest id.

2170 """

2171 if not self.records:

2172 return None

2173

2174 return next(iter(self))

2175

2176 def last(self) -> T_MetaInstance | None:

2177 """

2178 Get the row with the highest id.

2179 """

2180 if not self.records:

2181 return None

2182

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

2184 return self[max_id]

2185

2186 def find(

2187 self, f: typing.Callable[[T_MetaInstance], Query], limitby: tuple[int, int] = None

2188 ) -> "TypedRows[T_MetaInstance]":

2189 """

2190 Returns a new Rows object, a subset of the original object, filtered by the function `f`.

2191 """

2192 if not self.records:

2193 return self.__class__(self, self.model, {})

2194

2195 records = {}

2196 if limitby:

2197 _min, _max = limitby

2198 else:

2199 _min, _max = 0, len(self)

2200 count = 0

2201 for i, row in self.records.items():

2202 if f(row):

2203 if _min <= count:

2204 records[i] = row

2205 count += 1

2206 if count == _max:

2207 break

2208

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

2210

2211 def exclude(self, f: typing.Callable[[T_MetaInstance], Query]) -> "TypedRows[T_MetaInstance]":

2212 """

2213 Removes elements from the calling Rows object, filtered by the function `f`, \

2214 and returns a new Rows object containing the removed elements.

2215 """

2216 if not self.records:

2217 return self.__class__(self, self.model, {})

2218 removed = {}

2219 to_remove = []

2220 for i in self.records:

2221 row = self[i]

2222 if f(row):

2223 removed[i] = self.records[i]

2224 to_remove.append(i)

2225

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

2227

2228 return self.__class__(

2229 self,

2230 self.model,

2231 removed,

2232 )

2233

2234 def sort(self, f: typing.Callable[[T_MetaInstance], Any], reverse: bool = False) -> list[T_MetaInstance]:

2235 """

2236 Returns a list of sorted elements (not sorted in place).

2237 """

2238 return [r for (r, s) in sorted(zip(self.records.values(), self), key=lambda r: f(r[1]), reverse=reverse)]

2239

2240 def __str__(self) -> str:

2241 """

2242 Simple string representation.

2243 """

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

2245

2246 def __repr__(self) -> str:

2247 """

2248 Print a table on repr().

2249 """

2250 data = self.as_dict()

2251 headers = list(next(iter(data.values())).keys())

2252 return mktable(data, headers)

2253

2254 def group_by_value(

2255 self, *fields: str | Field | TypedField[T], one_result: bool = False, **kwargs: Any

2256 ) -> dict[T, list[T_MetaInstance]]:

2257 """

2258 Group the rows by a specific field (which will be the dict key).

2259 """

2260 kwargs["one_result"] = one_result

2261 result = super().group_by_value(*fields, **kwargs)

2262 return typing.cast(dict[T, list[T_MetaInstance]], result)

2263

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

2265 """

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

2267

2268 Example:

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

2270 """

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

2272

2273 def as_csv(self) -> str:

2274 """

2275 Dump the data to csv.

2276 """

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

2278

2279 def as_dict(

2280 self,

2281 key: str = None,

2282 compact: bool = False,

2283 storage_to_dict: bool = False,

2284 datetime_to_str: bool = False,

2285 custom_types: list[type] = None,

2286 ) -> dict[int, dict[str, Any]]:

2287 """

2288 Get the data in a dict of dicts.

2289 """

2290 if any([key, compact, storage_to_dict, datetime_to_str, custom_types]):

2291 # functionality not guaranteed

2292 return typing.cast(

2293 dict[int, dict[str, Any]],

2294 super().as_dict(

2295 key or "id",

2296 compact,

2297 storage_to_dict,

2298 datetime_to_str,

2299 custom_types,

2300 ),

2301 )

2302

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

2304

2305 def as_json(self, mode: str = "object", default: typing.Callable[[Any], Any] = None) -> str:

2306 """

2307 Turn the data into a dict and then dump to JSON.

2308 """

2309 return typing.cast(str, super().as_json(mode=mode, default=default))

2310

2311 def json(self, mode: str = "object", default: typing.Callable[[Any], Any] = None) -> str:

2312 """

2313 Turn the data into a dict and then dump to JSON.

2314 """

2315 return typing.cast(str, super().as_json(mode=mode, default=default))

2316

2317 def as_list(

2318 self,

2319 compact: bool = False,

2320 storage_to_dict: bool = False,

2321 datetime_to_str: bool = False,

2322 custom_types: list[type] = None,

2323 ) -> list[dict[str, Any]]:

2324 """

2325 Get the data in a list of dicts.

2326 """

2327 if any([compact, storage_to_dict, datetime_to_str, custom_types]):

2328 return typing.cast(

2329 list[dict[str, Any]], super().as_list(compact, storage_to_dict, datetime_to_str, custom_types)

2330 )

2331 return [_.as_dict() for _ in self.records.values()]

2332

2333 def __getitem__(self, item: int) -> T_MetaInstance:

2334 """

2335 You can get a specific row by ID from a typedrows by using rows[idx] notation.

2336

2337 Since pydal's implementation differs (they expect a list instead of a dict with id keys),

2338 using rows[0] will return the first row, regardless of its id.

2339 """

2340 try:

2341 return self.records[item]

2342 except KeyError as e:

2343 if item == 0 and (row := self.first()):

2344 # special case: pydal internals think Rows.records is a list, not a dict

2345 return row

2346

2347 raise e

2348

2349 def get(self, item: int) -> typing.Optional[T_MetaInstance]:

2350 """

2351 Get a row by ID, or receive None if it isn't in this result set.

2352 """

2353 return self.records.get(item)

2354

2355 def join(

2356 self,

2357 field: Field | TypedField[Any],

2358 name: str = None,

2359 constraint: Query = None,

2360 fields: list[str | Field] = None,

2361 orderby: Optional[str | Field] = None,

2362 ) -> T_MetaInstance:

2363 """

2364 This can be used to JOIN with some relationships after the initial select.

2365

2366 Using the querybuilder's .join() method is prefered!

2367 """

2368 result = super().join(field, name, constraint, fields or [], orderby)

2369 return typing.cast(T_MetaInstance, result)

2370

2371 def export_to_csv_file(

2372 self,

2373 ofile: typing.TextIO,

2374 null: Any = "<NULL>",

2375 delimiter: str = ",",

2376 quotechar: str = '"',

2377 quoting: int = csv.QUOTE_MINIMAL,

2378 represent: bool = False,

2379 colnames: list[str] = None,

2380 write_colnames: bool = True,

2381 *args: Any,

2382 **kwargs: Any,

2383 ) -> None:

2384 """

2385 Shadow export_to_csv_file from Rows, but with typing.

2386

2387 See http://web2py.com/books/default/chapter/29/06/the-database-abstraction-layer?search=export_to_csv_file#Exporting-and-importing-data

2388 """

2389 super().export_to_csv_file(

2390 ofile,

2391 null,

2392 *args,

2393 delimiter=delimiter,

2394 quotechar=quotechar,

2395 quoting=quoting,

2396 represent=represent,

2397 colnames=colnames or self.colnames,

2398 write_colnames=write_colnames,

2399 **kwargs,

2400 )

2401

2402 @classmethod

2403 def from_rows(

2404 cls, rows: Rows, model: typing.Type[T_MetaInstance], metadata: dict[str, Any] = None

2405 ) -> "TypedRows[T_MetaInstance]":

2406 """

2407 Internal method to convert a Rows object to a TypedRows.

2408 """

2409 return cls(rows, model, metadata=metadata)

2410

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

2412 """

2413 For json-fix.

2414 """

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

2416

2417

2418 class PaginatedRows(TypedRows[T_MetaInstance]):

2419 """

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

2421 """

2422

2423 _query_builder: QueryBuilder[T_MetaInstance]

2424

2425 @property

2426 def data(self) -> list[T_MetaInstance]:

2427 """

2428 Get the underlying data.

2429 """

2430 return list(self.records.values())

2431

2432 @property

2433 def pagination(self) -> Pagination:

2434 """

2435 Get all page info.

2436 """

2437 pagination_data = self.metadata["pagination"]

2438

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

2440 has_prev_page = pagination_data["current_page"] > 1

2441 return {

2442 "total_items": pagination_data["rows"],

2443 "current_page": pagination_data["current_page"],

2444 "per_page": pagination_data["limit"],

2445 "total_pages": pagination_data["max_page"],

2446 "has_next_page": has_next_page,

2447 "has_prev_page": has_prev_page,

2448 "next_page": pagination_data["current_page"] + 1 if has_next_page else None,

2449 "prev_page": pagination_data["current_page"] - 1 if has_prev_page else None,

2450 }

2451

2452 def next(self) -> Self: # noqa: A003

2453 """

2454 Get the next page.

2455 """

2456 data = self.metadata["pagination"]

2457 if data["current_page"] >= data["max_page"]:

2458 raise StopIteration("Final Page")

2459

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

2461

2462 def previous(self) -> Self:

2463 """

2464 Get the previous page.

2465 """

2466 data = self.metadata["pagination"]

2467 if data["current_page"] <= 1:

2468 raise StopIteration("First Page")

2469

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

2471

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

2473 """

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

2475

2476 All arguments are ignored!

2477 """

2478 return {"data": super().as_dict(), "pagination": self.pagination}

2479

2480

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

2482 """

2483 Used to make pydal Set more typed.

2484

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

2486 """

2487

2488 def count(self, distinct: bool = None, cache: dict[str, Any] = None) -> int:

2489 """

2490 Count returns an int.

2491 """

2492 result = super().count(distinct, cache)

2493 return typing.cast(int, result)

2494

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

2496 """

2497 Select returns a TypedRows of a user defined table.

2498

2499 Example:

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

2501

2502 for row in result:

2503 typing.reveal_type(row) # MyTable

2504 """

2505 rows = super().select(*fields, **attributes)

2506 return typing.cast(TypedRows[T_MetaInstance], rows)