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 math

9 import types

10 import typing

11 import warnings

12 from collections import defaultdict

13 from decimal import Decimal

14 from typing import Any, Optional

15

16 import pydal

17 from pydal._globals import DEFAULT

18 from pydal.objects import Field

19 from pydal.objects import Query as _Query

20 from pydal.objects import Row, Rows

21 from pydal.objects import Table as _Table

22 from typing_extensions import Self

23

24 from .helpers import (

25 DummyQuery,

26 all_annotations,

27 all_dict,

28 extract_type_optional,

29 filter_out,

30 instanciate,

31 is_union,

32 looks_like,

33 mktable,

34 origin_is_subclass,

35 to_snake,

36 unwrap_type,

37)

38 from .types import Expression, Query, _Types

39

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

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

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

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

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

45 T = typing.TypeVar("T")

46

47 BASIC_MAPPINGS: dict[T_annotation, str] = {

48 str: "string",

49 int: "integer",

50 bool: "boolean",

51 bytes: "blob",

52 float: "double",

53 object: "json",

54 Decimal: "decimal(10,2)",

55 dt.date: "date",

56 dt.time: "time",

57 dt.datetime: "datetime",

58}

59

60

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

62 """

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

64

65 Deprecated

66 """

67 return (

68 isinstance(cls, TypedField)

69 or isinstance(typing.get_origin(cls), type)

70 and issubclass(typing.get_origin(cls), TypedField)

71 )

72

73

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

75 DEFAULT_JOIN_OPTION: JOIN_OPTIONS = "left"

76

77# table-ish paramter:

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

79

80 Condition: typing.TypeAlias = typing.Optional[

81 typing.Callable[

82 # self, other -> Query

83 [P_Table, P_Table],

84 Query | bool,

85 ]

86]

87

88 OnQuery: typing.TypeAlias = typing.Optional[

89 typing.Callable[

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

91 [P_Table, P_Table],

92 list[Expression],

93 ]

94]

95

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

97

98

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

100 """

101 Define a relationship to another table.

102 """

103

104 _type: To_Type

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

106 condition: Condition

107 on: OnQuery

108 multiple: bool

109 join: JOIN_OPTIONS

110

111 def __init__(

112 self,

113 _type: To_Type,

114 condition: Condition = None,

115 join: JOIN_OPTIONS = None,

116 on: OnQuery = None,

117 ):

118 """

119 Should not be called directly, use relationship() instead!

120 """

121 if condition and on:

122 warnings.warn(f"Relation | Both specified! {condition=} {on=} {_type=}")

123 raise ValueError("Please specify either a condition or an 'on' statement for this relationship!")

124

125 self._type = _type

126 self.condition = condition

127 self.join = "left" if on else join # .on is always left join!

128 self.on = on

129

130 if args := typing.get_args(_type):

131 self.table = unwrap_type(args[0])

132 self.multiple = True

133 else:

134 self.table = _type

135 self.multiple = False

136

137 if isinstance(self.table, str):

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

139

140 def clone(self, **update: Any) -> "Relationship[To_Type]":

141 """

142 Create a copy of the relationship, possibly updated.

143 """

144 return self.__class__(

145 update.get("_type") or self._type,

146 update.get("condition") or self.condition,

147 update.get("join") or self.join,

148 update.get("on") or self.on,

149 )

150

151 def __repr__(self) -> str:

152 """

153 Representation of the relationship.

154 """

155 if callback := self.condition or self.on:

156 src_code = inspect.getsource(callback).strip()

157 else:

158 cls_name = self._type if isinstance(self._type, str) else self._type.__name__ # type: ignore

159 src_code = f"to {cls_name} (missing condition)"

160

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

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

163

164 def get_table(self, db: "TypeDAL") -> typing.Type["TypedTable"]:

165 """

166 Get the table this relationship is bound to.

167 """

168 table = self.table # can be a string because db wasn't available yet

169 if isinstance(table, str):

170 if mapped := db._class_map.get(table):

171 # yay

172 return mapped

173

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

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

176

177 return table

178

179 def get_table_name(self) -> str:

180 """

181 Get the name of the table this relationship is bound to.

182 """

183 if isinstance(self.table, str):

184 return self.table

185

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

187 return str(self.table)

188

189 # else: typed table

190 try:

191 table = self.table._ensure_table_defined() if issubclass(self.table, TypedTable) else self.table

192 except Exception: # pragma: no cover

193 table = self.table

194

195 return str(table)

196

197 def __get__(self, instance: Any, owner: Any) -> typing.Optional[list[Any]] | "Relationship[To_Type]":

198 """

199 Relationship is a descriptor class, which can be returned from a class but not an instance.

200

201 For an instance, using .join() will replace the Relationship with the actual data.

202 If you forgot to join, a warning will be shown and empty data will be returned.

203 """

204 if not instance:

205 # relationship queried on class, that's allowed

206 return self

207

208 warnings.warn(

209 "Trying to get data from a relationship object! Did you forget to join it?", category=RuntimeWarning

210 )

211 if self.multiple:

212 return []

213 else:

214 return None

215

216

217 def relationship(

218 _type: To_Type, condition: Condition = None, join: JOIN_OPTIONS = None, on: OnQuery = None

219) -> Relationship[To_Type]:

220 """

221 Define a relationship to another table, when its id is not stored in the current table.

222

223 Example:

224 class User(TypedTable):

225 name: str

226

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

228

229 class Post(TypedTable):

230 title: str

231 author: User

232

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

234

235 Here, Post stores the User ID, but `relationship(list["Post"])` still allows you to get the user's posts.

236 In this case, the join strategy is set to LEFT so users without posts are also still selected.

237

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

239 class User(TypedTable):

240 ...

241

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

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

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

245 ])

246

247 If you'd try to capture this in a single 'condition', pydal would create a cross join which is much less efficient.

248 """

249 return Relationship(_type, condition, join, on)

250

251

252 def _generate_relationship_condition(

253 _: typing.Type["TypedTable"], key: str, field: typing.Union["TypedField[Any]", "Table", typing.Type["TypedTable"]]

254) -> Condition:

255 origin = typing.get_origin(field)

256 # else: generic

257

258 if origin == list:

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

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

261

262 return lambda _self, _other: _self[key].contains(_other.id)

263 else:

264 # normal reference

265 # return lambda _self, _other: cls[key] == field.id

266 return lambda _self, _other: _self[key] == _other.id

267

268

269 def to_relationship(

270 cls: typing.Type["TypedTable"] | type[Any],

271 key: str,

272 field: typing.Union["TypedField[Any]", "Table", typing.Type["TypedTable"]],

273) -> typing.Optional[Relationship[Any]]:

274 """

275 Used to automatically create relationship instance for reference fields.

276

277 Example:

278 class MyTable(TypedTable):

279 reference: OtherTable

280

281 `reference` contains the id of an Other Table row.

282 MyTable.relationships should have 'reference' as a relationship, so `MyTable.join('reference')` should work.

283

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

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

286

287 Also works for list:reference (list[OtherTable]) and TypedField[OtherTable].

288 """

289 if looks_like(field, TypedField):

290 if args := typing.get_args(field):

291 field = args[0]

292 else:

293 # weird

294 return None

295

296 field, optional = extract_type_optional(field)

297

298 try:

299 condition = _generate_relationship_condition(cls, key, field)

300 except Exception as e: # pragma: no cover

301 warnings.warn("Could not generate Relationship condition", source=e)

302 condition = None

303

304 if not condition: # pragma: no cover

305 # something went wrong, not a valid relationship

306 warnings.warn(f"Invalid relationship for {cls.__name__}.{key}: {field}")

307 return None

308

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

310

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

312

313

314 class TypeDAL(pydal.DAL): # type: ignore

315 """

316 Drop-in replacement for pyDAL with layer to convert class-based table definitions to classical pydal define_tables.

317 """

318

319 # dal: Table

320 # def __init__(self,

321 # uri="sqlite://dummy.db",

322 # pool_size=0,

323 # folder=None,

324 # db_codec="UTF-8",

325 # check_reserved=None,

326 # migrate=True,

327 # fake_migrate=False,

328 # migrate_enabled=True,

329 # fake_migrate_all=False,

330 # decode_credentials=False,

331 # driver_args=None,

332 # adapter_args=None,

333 # attempts=5,

334 # auto_import=False,

335 # bigint_id=False,

336 # debug=False,

337 # lazy_tables=False,

338 # db_uid=None,

339 # after_connection=None,

340 # tables=None,

341 # ignore_field_case=True,

342 # entity_quoting=True,

343 # table_hash=None,

344 # ):

345 # super().__init__(

346 # uri,

347 # pool_size,

348 # folder,

349 # db_codec,

350 # check_reserved,

351 # migrate,

352 # fake_migrate,

353 # migrate_enabled,

354 # fake_migrate_all,

355 # decode_credentials,

356 # driver_args,

357 # adapter_args,

358 # attempts,

359 # auto_import,

360 # bigint_id,

361 # debug,

362 # lazy_tables,

363 # db_uid,

364 # after_connection,

365 # tables,

366 # ignore_field_case,

367 # entity_quoting,

368 # table_hash,

369 # )

370 # self.representers[TypedField] = lambda x: x

371

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

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

374 "notnull": True,

375 }

376

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

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

379

380 def _define(self, cls: typing.Type[T]) -> typing.Type[T]:

381 # when __future__.annotations is implemented, cls.__annotations__ will not work anymore as below.

382 # proper way to handle this would be (but gives error right now due to Table implementing magic methods):

383 # typing.get_type_hints(cls, globalns=None, localns=None)

384

385 # dirty way (with evil eval):

386 # [eval(v) for k, v in cls.__annotations__.items()]

387 # this however also stops working when variables outside this scope or even references to other

388 # objects are used. So for now, this package will NOT work when from __future__ import annotations is used,

389 # and might break in the future, when this annotations behavior is enabled by default.

390

391 # non-annotated variables have to be passed to define_table as kwargs

392 full_dict = all_dict(cls) # includes properties from parents (e.g. useful for mixins)

393

394 tablename = self.to_snake(cls.__name__)

395 # grab annotations of cls and it's parents:

396 annotations = all_annotations(cls)

397 # extend with `prop = TypedField()` 'annotations':

398 annotations |= {k: typing.cast(type, v) for k, v in full_dict.items() if is_typed_field(v)}

399 # remove internal stuff:

400 annotations = {k: v for k, v in annotations.items() if not k.startswith("_")}

401

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

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

404 }

405

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

407

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

409

410 # ! dont' use full_dict here:

411 other_kwargs = {k: v for k, v in cls.__dict__.items() if k not in annotations and not k.startswith("_")}

412

413 for key in typedfields.keys() - full_dict.keys():

414 # typed fields that don't haven't been added to the object yet

415 setattr(cls, key, typedfields[key])

416

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

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

419

420 # DEPRECATED: Relationship as annotation is currently not supported!

421 # ensure they are all instances and

422 # not mix of instances (`= relationship()`) and classes (`: Relationship[...]`):

423 # relationships = {

424 # k: v if isinstance(v, Relationship) else to_relationship(cls, k, v) for k, v in relationships.items()

425 # }

426

427 # keys of implicit references (also relationships):

428 reference_field_keys = [k for k, v in fields.items() if v.type.split(" ")[0] in ("list:reference", "reference")]

429

430 # add implicit relationships:

431 # User; list[User]; TypedField[User]; TypedField[list[User]]

432 relationships |= {

433 k: new_relationship

434 for k in reference_field_keys

435 if k not in relationships and (new_relationship := to_relationship(cls, k, annotations[k]))

436 }

437

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

439

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

441 field = fields[name]

442 typed_field.bind(field, table)

443

444 if issubclass(cls, TypedTable):

445 cls.__set_internals__(

446 db=self,

447 table=table,

448 # by now, all relationships should be instances!

449 relationships=typing.cast(dict[str, Relationship[Any]], relationships),

450 )

451 self._class_map[str(table)] = cls

452 else:

453 warnings.warn("db.define used without inheriting TypedTable. This could lead to strange problems!")

454

455 return cls

456

457 @typing.overload

458 def define(self, maybe_cls: None = None) -> typing.Callable[[typing.Type[T]], typing.Type[T]]:

459 """

460 Typing Overload for define without a class.

461

462 @db.define()

463 class MyTable(TypedTable): ...

464 """

465

466 @typing.overload

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

468 """

469 Typing Overload for define with a class.

470

471 @db.define

472 class MyTable(TypedTable): ...

473 """

474

475 def define(

476 self, maybe_cls: typing.Type[T] | None = None

477 ) -> typing.Type[T] | typing.Callable[[typing.Type[T]], typing.Type[T]]:

478 """

479 Can be used as a decorator on a class that inherits `TypedTable`, \

480 or as a regular method if you need to define your classes before you have access to a 'db' instance.

481

482 Example:

483 @db.define

484 class Person(TypedTable):

485 ...

486

487 class Article(TypedTable):

488 ...

489

490 # at a later time:

491 db.define(Article)

492

493 Returns:

494 the result of pydal.define_table

495 """

496

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

498 return self._define(cls)

499

500 if maybe_cls:

501 return wrapper(maybe_cls)

502

503 return wrapper

504

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

506 """

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

508

509 Usually, only a query is passed.

510

511 Example:

512 db(query).select()

513

514 """

515 args = list(_args)

516 if args:

517 cls = args[0]

518 if isinstance(cls, bool):

519 raise ValueError("Don't actually pass a bool to db()! Use a query instead.")

520

521 if isinstance(cls, type) and issubclass(type(cls), type) and issubclass(cls, TypedTable):

522 # table defined without @db.define decorator!

523 _cls: typing.Type[TypedTable] = cls

524 args[0] = _cls.id != None

525

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

527 return typing.cast(TypedSet, _set)

528

529 @classmethod

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

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

532

533 @classmethod

534 def _annotation_to_pydal_fieldtype(

535 cls, _ftype: T_annotation, mut_kw: typing.MutableMapping[str, Any]

536 ) -> Optional[str]:

537 # ftype can be a union or type. typing.cast is sometimes used to tell mypy when it's not a union.

538 ftype = typing.cast(type, _ftype) # cast from typing.Type to type to make mypy happy)

539

540 if isinstance(ftype, str):

541 # extract type from string

542 ftype = typing.get_args(typing.Type[ftype])[0]._evaluate(

543 localns=locals(), globalns=globals(), recursive_guard=frozenset()

544 )

545

546 if mapping := BASIC_MAPPINGS.get(ftype):

547 # basi types

548 return mapping

549 elif isinstance(ftype, _Table):

550 # db.table

551 return f"reference {ftype._tablename}"

552 elif issubclass(type(ftype), type) and issubclass(ftype, TypedTable):

553 # SomeTable

554 snakename = cls.to_snake(ftype.__name__)

555 return f"reference {snakename}"

556 elif isinstance(ftype, TypedField):

557 # FieldType(type, ...)

558 return ftype._to_field(mut_kw)

559 elif origin_is_subclass(ftype, TypedField):

560 # TypedField[int]

561 return cls._annotation_to_pydal_fieldtype(typing.get_args(ftype)[0], mut_kw)

562 elif isinstance(ftype, types.GenericAlias) and typing.get_origin(ftype) in (list, TypedField):

563 # list[str] -> str -> string -> list:string

564 _child_type = typing.get_args(ftype)[0]

565 _child_type = cls._annotation_to_pydal_fieldtype(_child_type, mut_kw)

566 return f"list:{_child_type}"

567 elif is_union(ftype):

568 # str | int -> UnionType

569 # typing.Union[str | int] -> typing._UnionGenericAlias

570

571 # Optional[type] == type | None

572

573 match typing.get_args(ftype):

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

575 # good union of Nullable

576

577 # if a field is optional, it is nullable:

578 mut_kw["notnull"] = False

579 return cls._annotation_to_pydal_fieldtype(_child_type, mut_kw)

580 case _:

581 # two types is not supported by the db!

582 return None

583 else:

584 return None

585

586 @classmethod

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

588 """

589 Convert a annotation into a pydal Field.

590

591 Args:

592 fname: name of the property

593 ftype: annotation of the property

594 kw: when using TypedField or a function returning it (e.g. StringField),

595 keyword args can be used to pass any other settings you would normally to a pydal Field

596

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

598

599 Example:

600 class MyTable:

601 fname: ftype

602 id: int

603 name: str

604 reference: Table

605 other: TypedField(str, default="John Doe") # default will be in kwargs

606 """

607 fname = cls.to_snake(fname)

608

609 if converted_type := cls._annotation_to_pydal_fieldtype(ftype, kw):

610 return cls._build_field(fname, converted_type, **kw)

611 else:

612 raise NotImplementedError(f"Unsupported type {ftype}/{type(ftype)}")

613

614 @staticmethod

615 def to_snake(camel: str) -> str:

616 """

617 Moved to helpers, kept as a static method for legacy reasons.

618 """

619 return to_snake(camel)

620

621

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

623 """

624 Make mypy happy.

625 """

626

627 id: int # noqa: A003

628

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

630 """

631 Tell mypy a Table supports dictionary notation for columns.

632 """

633

634

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

636 """

637 Make mypy happy.

638 """

639

640

641 class TableMeta(type):

642 """

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

644

645 Example:

646 class MyTable(TypedTable):

647 some_field: TypedField[int]

648

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

650

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

652

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

654

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

656

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

658

659 """

660

661 # set up by db.define:

662 # _db: TypeDAL | None = None

663 # _table: Table | None = None

664 _db: TypeDAL | None = None

665 _table: Table | None = None

666 _relationships: dict[str, Relationship[Any]] | None = None

667

668 #########################

669 # TypeDAL custom logic: #

670 #########################

671

672 def __set_internals__(self, db: pydal.DAL, table: Table, relationships: dict[str, Relationship[Any]]) -> None:

673 """

674 Store the related database and pydal table for later usage.

675 """

676 self._db = db

677 self._table = table

678 self._relationships = relationships

679

680 def __getattr__(self, col: str) -> Field:

681 """

682 Magic method used by TypedTableMeta to get a database field with dot notation on a class.

683

684 Example:

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

686

687 """

688 if self._table:

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

690

691 def _ensure_table_defined(self) -> Table:

692 if not self._table:

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

694 return self._table

695

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

697 """

698 Loop through the columns of this model.

699 """

700 table = self._ensure_table_defined()

701 yield from iter(table)

702

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

704 """

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

706 """

707 table = self._ensure_table_defined()

708 return table[item]

709

710 def __str__(self) -> str:

711 """

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

713 """

714 if self._table:

715 return str(self._table)

716 else:

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

718

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

720 """

721 Create a model instance from a pydal row.

722 """

723 return self(row)

724

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

726 """

727 Return all rows for this model.

728 """

729 return self.collect()

730

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

732 """

733 Return the registered relationships of the current model.

734 """

735 return self._relationships or {}

736

737 ##########################

738 # TypeDAL Modified Logic #

739 ##########################

740

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

742 """

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

744

745 cls.__table functions as 'self'

746

747 Args:

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

749

750 Returns: the ID of the new row.

751

752 """

753 table = self._ensure_table_defined()

754

755 result = table.insert(**fields)

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

757 return self(result)

758

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

760 """

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

762 """

763 table = self._ensure_table_defined()

764 result = table.bulk_insert(items)

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

766

767 def update_or_insert(

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

769 ) -> T_MetaInstance:

770 """

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

772

773 Returns the created or updated instance.

774 """

775 table = self._ensure_table_defined()

776

777 if query is DEFAULT:

778 record = table(**values)

779 elif isinstance(query, dict):

780 record = table(**query)

781 else:

782 record = table(query)

783

784 if not record:

785 return self.insert(**values)

786

787 record.update_record(**values)

788 return self(record)

789

790 def validate_and_insert(

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

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

793 """

794 Validate input data and then insert a row.

795

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

797 """

798 table = self._ensure_table_defined()

799 result = table.validate_and_insert(**fields)

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

801 return self(row_id), None

802 else:

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

804

805 def validate_and_update(

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

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

808 """

809 Validate input data and then update max 1 row.

810

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

812 """

813 table = self._ensure_table_defined()

814

815 try:

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

817 except Exception as e:

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

819

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

821 return None, errors

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

823 return self(row_id), None

824 else: # pragma: no cover

825 # update on query without result (shouldnt happen)

826 return None, None

827

828 def validate_and_update_or_insert(

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

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

831 """

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

833

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

835 """

836 table = self._ensure_table_defined()

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

838

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

840 return None, errors

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

842 return self(row_id), None

843 else: # pragma: no cover

844 # update on query without result (shouldnt happen)

845 return None, None

846

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

848 """

849 See QueryBuilder.select!

850 """

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

852

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

854 """

855 See QueryBuilder.paginate!

856 """

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

858

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

860 """

861 See QueryBuilder.where!

862 """

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

864

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

866 """

867 See QueryBuilder.count!

868 """

869 return QueryBuilder(self).count()

870

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

872 """

873 See QueryBuilder.first!

874 """

875 return QueryBuilder(self).first()

876

877 def join(

878 self: typing.Type[T_MetaInstance], *fields: str, method: JOIN_OPTIONS = None

879 ) -> "QueryBuilder[T_MetaInstance]":

880 """

881 See QueryBuilder.join!

882 """

883 return QueryBuilder(self).join(*fields, method=method)

884

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

886 """

887 See QueryBuilder.collect!

888 """

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

890

891 @property

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

893 """

894 Select all fields for this table.

895 """

896 table = cls._ensure_table_defined()

897

898 return table.ALL

899

900 ##########################

901 # TypeDAL Shadowed Logic #

902 ##########################

903 fields: list[str]

904

905 # other table methods:

906

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

908 """

909 Remove the underlying table.

910 """

911 table = self._ensure_table_defined()

912 table.drop(mode)

913

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

915 """

916 Add an index on some columns of this table.

917 """

918 table = self._ensure_table_defined()

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

920 return typing.cast(bool, result)

921

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

923 """

924 Remove an index from this table.

925 """

926 table = self._ensure_table_defined()

927 result = table.drop_index(name, if_exists)

928 return typing.cast(bool, result)

929

930 def import_from_csv_file(

931 self,

932 csvfile: typing.TextIO,

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

934 null: str = "<NULL>",

935 unique: str = "uuid",

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

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

938 validate: bool = False,

939 encoding: str = "utf-8",

940 delimiter: str = ",",

941 quotechar: str = '"',

942 quoting: int = csv.QUOTE_MINIMAL,

943 restore: bool = False,

944 **kwargs: Any,

945 ) -> None:

946 """

947 Load a csv file into the database.

948 """

949 table = self._ensure_table_defined()

950 table.import_from_csv_file(

951 csvfile,

952 id_map=id_map,

953 null=null,

954 unique=unique,

955 id_offset=id_offset,

956 transform=transform,

957 validate=validate,

958 encoding=encoding,

959 delimiter=delimiter,

960 quotechar=quotechar,

961 quoting=quoting,

962 restore=restore,

963 **kwargs,

964 )

965

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

967 """

968 Shadow Table.on.

969

970 Used for joins.

971

972 See Also:

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

974 """

975 table = self._ensure_table_defined()

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

977

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

979 """

980 Shadow Table.with_alias.

981

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

983

984 See Also:

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

986 """

987 table = self._ensure_table_defined()

988 return table.with_alias(alias)

989

990 # @typing.dataclass_transform()

991

992

993 class TypedTable(metaclass=TableMeta):

994 """

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

996 """

997

998 # set up by 'new':

999 _row: Row | None = None

1000

1001 _with: list[str]

1002

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

1004

1005 def _setup_instance_methods(self) -> None:

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

1007 self.as_json = self._as_json # type: ignore

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

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

1010

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

1012

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

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

1015

1016 def __new__(

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

1018 ) -> "TypedTable":

1019 """

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

1021

1022 Examples:

1023 MyTable(1)

1024 MyTable(id=1)

1025 MyTable(MyTable.id == 1)

1026 """

1027 table = cls._ensure_table_defined()

1028

1029 if isinstance(row_or_id, TypedTable):

1030 # existing typed table instance!

1031 return row_or_id

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

1033 row = row_or_id

1034 elif row_or_id:

1035 row = table(row_or_id, **filters)

1036 else:

1037 row = table(**filters)

1038

1039 if not row:

1040 return None # type: ignore

1041

1042 inst = super().__new__(cls)

1043 inst._row = row

1044 inst.__dict__.update(row)

1045 inst._setup_instance_methods()

1046 return inst

1047

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

1049 """

1050 Allows looping through the columns.

1051 """

1052 row = self._ensure_matching_row()

1053 yield from iter(row)

1054

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

1056 """

1057 Allows dictionary notation to get columns.

1058 """

1059 if item in self.__dict__:

1060 return self.__dict__.get(item)

1061

1062 # fallback to lookup in row

1063 if self._row:

1064 return self._row[item]

1065

1066 # nothing found!

1067 raise KeyError(item)

1068

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

1070 """

1071 Allows dot notation to get columns.

1072 """

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

1074 return value

1075

1076 raise AttributeError(item)

1077

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

1079 """

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

1081 """

1082 try:

1083 return self.__getitem__(item)

1084 except KeyError:

1085 return default

1086

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

1088 """

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

1090 """

1091 return setattr(self, key, value)

1092

1093 def __int__(self) -> int:

1094 """

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

1096 """

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

1098

1099 def __bool__(self) -> bool:

1100 """

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

1102 """

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

1104

1105 def _ensure_matching_row(self) -> Row:

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

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

1108 return self._row

1109

1110 def __repr__(self) -> str:

1111 """

1112 String representation of the model instance.

1113 """

1114 model_name = self.__class__.__name__

1115 model_data = {}

1116

1117 if self._row:

1118 model_data = self._row.as_json()

1119

1120 details = model_name

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

1122

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

1124 details += f" + {relationships}"

1125

1126 return f"<{details}>"

1127

1128 # serialization

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

1130

1131 @classmethod

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

1133 """

1134 Dump the object to a plain dict.

1135

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

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

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

1139 """

1140 table = cls._ensure_table_defined()

1141 result = table.as_dict(flat, sanitize)

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

1143

1144 @classmethod

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

1146 """

1147 Dump the object to json.

1148

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

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

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

1152 """

1153 table = cls._ensure_table_defined()

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

1155

1156 @classmethod

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

1158 """

1159 Dump the object to xml.

1160

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

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

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

1164 """

1165 table = cls._ensure_table_defined()

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

1167

1168 @classmethod

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

1170 """

1171 Dump the object to yaml.

1172

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

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

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

1176 """

1177 table = cls._ensure_table_defined()

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

1179

1180 def _as_dict(

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

1182 ) -> dict[str, Any]:

1183 row = self._ensure_matching_row()

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

1185

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

1187 for relationship in _with:

1188 data = self.get(relationship)

1189 if isinstance(data, list):

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

1191 elif data:

1192 data = data.as_dict()

1193

1194 result[relationship] = data

1195

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

1197

1198 def _as_json(

1199 self,

1200 mode: str = "object",

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

1202 colnames: list[str] = None,

1203 serialize: bool = True,

1204 **kwargs: Any,

1205 ) -> str:

1206 row = self._ensure_matching_row()

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

1208

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

1210 row = self._ensure_matching_row()

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

1212

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

1214 # row = self._ensure_matching_row()

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

1216

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

1218 """

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

1220 """

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

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

1223 self._row[key] = value

1224

1225 super().__setattr__(key, value)

1226

1227 @classmethod

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

1229 """

1230 Update one record.

1231

1232 Example:

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

1234 """

1235 if record := cls(query):

1236 return record.update_record(**fields)

1237 else:

1238 return None

1239

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

1241 row = self._ensure_matching_row()

1242 row.update(**fields)

1243 self.__dict__.update(**fields)

1244 return self

1245

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

1247 row = self._ensure_matching_row()

1248 new_row = row.update_record(**fields)

1249 self.update(**new_row)

1250 return self

1251

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

1253 """

1254 Here as a placeholder for _update_record.

1255

1256 Will be replaced on instance creation!

1257 """

1258 return self._update_record(**fields)

1259

1260 def _delete_record(self) -> int:

1261 """

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

1263 """

1264 row = self._ensure_matching_row()

1265 result = row.delete_record()

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

1267 self._row = None # just to be sure

1268 self._setup_instance_methods()

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

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

1271 return typing.cast(int, result)

1272

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

1274 """

1275 Here as a placeholder for _delete_record.

1276

1277 Will be replaced on instance creation!

1278 """

1279 return self._delete_record()

1280

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

1282

1283

1284# backwards compat:

1285 TypedRow = TypedTable

1286

1287

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

1289 """

1290 Abstration on top of pydal's query system.

1291 """

1292

1293 model: typing.Type[T_MetaInstance]

1294 query: Query

1295 select_args: list[Any]

1296 select_kwargs: dict[str, Any]

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

1298 metadata: dict[str, Any]

1299

1300 def __init__(

1301 self,

1302 model: typing.Type[T_MetaInstance],

1303 add_query: Optional[Query] = None,

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

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

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

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

1308 ):

1309 """

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

1311

1312 Example:

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

1314 """

1315 self.model = model

1316 table = model._ensure_table_defined()

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

1318 self.query = add_query or default_query

1319 self.select_args = select_args or []

1320 self.select_kwargs = select_kwargs or {}

1321 self.relationships = relationships or {}

1322 self.metadata = metadata or {}

1323

1324 def _extend(

1325 self,

1326 add_query: Optional[Query] = None,

1327 overwrite_query: Optional[Query] = None,

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

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

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

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

1332 ) -> "QueryBuilder[T_MetaInstance]":

1333 return QueryBuilder(

1334 self.model,

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

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

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

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

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

1340 )

1341

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

1343 """

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

1345

1346 Options:

1347 paraphrased from the web2py pydal docs,

1348 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

1349

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

1351 table.name - sort by name, ascending

1352 ~table.name - sort by name, descending

1353 <random> - sort randomly

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

1355

1356 groupby, having: together with orderby:

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

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

1359

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

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

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

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

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

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

1366 """

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

1368

1369 def where(

1370 self,

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

1372 **filters: Any,

1373 ) -> "QueryBuilder[T_MetaInstance]":

1374 """

1375 Extend the builder's query.

1376

1377 Can be used in multiple ways:

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

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

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

1381

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

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

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

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

1386 """

1387 new_query = self.query

1388 table = self.model._ensure_table_defined()

1389

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

1391 new_query &= table[field] == value

1392

1393 subquery = DummyQuery()

1394 for query_or_lambda in queries_or_lambdas:

1395 if isinstance(query_or_lambda, _Query):

1396 subquery |= query_or_lambda

1397 elif callable(query_or_lambda):

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

1399 subquery |= result

1400 elif isinstance(query_or_lambda, Field) or is_typed_field(query_or_lambda):

1401 subquery |= query_or_lambda != None

1402 else:

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

1404

1405 if subquery:

1406 new_query &= subquery

1407

1408 return self._extend(overwrite_query=new_query)

1409

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

1411 """

1412 Include relationship fields in the result.

1413

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

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

1416

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

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

1419 """

1420 relationships = self.model.get_relationships()

1421

1422 if fields:

1423 # join on every relationship

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

1425

1426 if method:

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

1428

1429 return self._extend(relationships=relationships)

1430

1431 def _get_db(self) -> TypeDAL:

1432 if db := self.model._db:

1433 return db

1434 else: # pragma: no cover

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

1436

1437 def _select_arg_convert(self, arg: Any) -> str | Field:

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

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

1440 arg = arg._field

1441

1442 return arg

1443

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

1445 """

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

1447 """

1448 db = self._get_db()

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

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

1451 # success!

1452 return removed_ids

1453

1454 return None

1455

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

1457 """

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

1459 """

1460 db = self._get_db()

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

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

1463 # success!

1464 return updated_ids

1465

1466 return None

1467

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

1469 """

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

1471 """

1472 if _to is None:

1473 _to = TypedRows

1474

1475 db = self._get_db()

1476

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

1478 select_kwargs = self.select_kwargs.copy()

1479 metadata = self.metadata.copy()

1480 query = self.query

1481 model = self.model

1482

1483 metadata["query"] = query

1484

1485 # require at least id of main table:

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

1487 tablename = str(model)

1488

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

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

1491 select_args.append(model.id)

1492

1493 if self.relationships:

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

1495

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

1497

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

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

1500 metadata["final_kwargs"] = select_kwargs

1501

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

1503

1504 if verbose: # pragma: no cover

1505 print(metadata["sql"])

1506 print(rows)

1507

1508 if not self.relationships:

1509 # easy

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

1511

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

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

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

1515

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

1517

1518 def _handle_relationships_pre_select(

1519 self,

1520 query: Query,

1521 select_args: list[Any],

1522 select_kwargs: dict[str, Any],

1523 metadata: dict[str, Any],

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

1525 db = self._get_db()

1526 model = self.model

1527

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

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

1530 # if limitby + relationships:

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

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

1533 # 3. add joins etc

1534

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

1536 query = model.id.belongs(ids)

1537 metadata["ids"] = ids

1538

1539 left = []

1540

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

1542 other = relation.get_table(db)

1543 method: JOIN_OPTIONS = relation.join or DEFAULT_JOIN_OPTION

1544

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

1546 pre_alias = str(other)

1547

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

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

1550 select_args.append(other.ALL)

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

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

1553 select_args.append(other.id)

1554

1555 if relation.on:

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

1557 on = relation.on(model, other)

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

1559 on = [on]

1560

1561 left.extend(on)

1562 elif method == "left":

1563 # .on not given, generate it:

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

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

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

1567 else:

1568 # else: inner join

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

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

1571

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

1573 # else: only add other.id if missing

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

1575

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

1577 if pre_alias != post_alias:

1578 # replace .select's with aliased:

1579 select_fields = select_fields.replace(

1580 f"{pre_alias}.",

1581 f"{post_alias}.",

1582 )

1583

1584 select_args = select_fields.split(", ")

1585

1586 select_kwargs["left"] = left

1587 return query, select_args

1588

1589 def _collect_with_relationships(

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

1591 ) -> "TypedRows[T_MetaInstance]":

1592 """

1593 Transform the raw rows into Typed Table model instances.

1594 """

1595 db = self._get_db()

1596 main_table = self.model._ensure_table_defined()

1597

1598 records = {}

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

1600

1601 for row in rows:

1602 main = row[main_table]

1603 main_id = main.id

1604

1605 if main_id not in records:

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

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

1608

1609 # setup up all relationship defaults (once)

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

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

1612

1613 # now add other relationship data

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

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

1616

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

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

1619

1620 relation_data = (

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

1622 )

1623

1624 if relation_data.id is None:

1625 # always skip None ids

1626 continue

1627

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

1629 # speed up duplicates

1630 continue

1631 else:

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

1633

1634 relation_table = relation.get_table(db)

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

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

1637

1638 if relation.multiple:

1639 # create list of T

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

1641 # should already be set up before!

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

1643

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

1645 else:

1646 # create single T

1647 records[main_id][column] = instance

1648

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

1650

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

1652 """

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

1654

1655 Basically unwraps Optional type.

1656 """

1657 if result := self.collect():

1658 return result

1659 else:

1660 raise ValueError("Nothing found!")

1661

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

1663 """

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

1665 """

1666 yield from self.collect()

1667

1668 def count(self) -> int:

1669 """

1670 Return the amount of rows matching the current query.

1671 """

1672 db = self._get_db()

1673 return db(self.query).count()

1674

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

1676 """

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

1678

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

1680 can be loaded with relationship data!

1681 """

1682 _from = limit * (page - 1)

1683 _to = limit * page

1684

1685 available = self.count()

1686

1687 builder = self._extend(

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

1689 metadata={

1690 "pagination": {

1691 "limit": limit,

1692 "current_page": page,

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

1694 "rows": available,

1695 "min_max": (_from, _to),

1696 }

1697 },

1698 )

1699

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

1701

1702 rows._query_builder = builder

1703 return rows

1704

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

1706 """

1707 Get the first row matching the currently built query.

1708

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

1710 """

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

1712 return self.model.from_row(row)

1713 else:

1714 return None

1715

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

1717 """

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

1719

1720 Basically unwraps Optional type.

1721 """

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

1723 return inst

1724 else:

1725 raise ValueError("Nothing found!")

1726

1727

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

1729 """

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

1731 """

1732

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

1734 name = ""

1735 _db: Optional[pydal.DAL] = None

1736 _rname: Optional[str] = None

1737 _table: Optional[Table] = None

1738 _field: Optional[Field] = None

1739

1740 _type: T_annotation

1741 kwargs: Any

1742

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

1744 """

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

1746 """

1747 self._type = _type

1748 self.kwargs = settings

1749 super().__init__()

1750

1751 @typing.overload

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

1753 """

1754 row.field -> (actual data).

1755 """

1756

1757 @typing.overload

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

1759 """

1760 Table.field -> Field.

1761 """

1762

1763 def __get__(

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

1765 ) -> typing.Union[T_Value, Field]:

1766 """

1767 Since this class is a Descriptor field, \

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

1769

1770 (this is mostly for mypy/typing)

1771 """

1772 if instance:

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

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

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

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

1777 else:

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

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

1780

1781 def __str__(self) -> str:

1782 """

1783 String representation of a Typed Field.

1784

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

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

1787 """

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

1789

1790 def __repr__(self) -> str:

1791 """

1792 More detailed string representation of a Typed Field.

1793

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

1795 """

1796 s = self.__str__()

1797

1798 if "type" in self.kwargs:

1799 # manual type in kwargs supplied

1800 t = self.kwargs["type"]

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

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

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

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

1805 # list[str] -> 'str'

1806 t = t_args[0].__name__

1807 else: # pragma: no cover

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

1809 t = self._type

1810

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

1812

1813 kw = self.kwargs.copy()

1814 kw.pop("type", None)

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

1816

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

1818 """

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

1820 """

1821 other_kwargs = self.kwargs.copy()

1822 extra_kwargs.update(other_kwargs)

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

1824

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

1826 """

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

1828 """

1829 self._table = table

1830 self._field = field

1831

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

1833 """

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

1835 """

1836 with contextlib.suppress(AttributeError):

1837 return super().__getattribute__(key)

1838

1839 # try on actual field:

1840 return getattr(self._field, key)

1841

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

1843 """

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

1845 """

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

1847

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

1849 """

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

1851 """

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

1853

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

1855 """

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

1857 """

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

1859

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

1861 """

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

1863 """

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

1865

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

1867 """

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

1869 """

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

1871

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

1873 """

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

1875 """

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

1877

1878 def __hash__(self) -> int:

1879 """

1880 Shadow Field.__hash__.

1881 """

1882 return hash(self._field)

1883

1884

1885 S = typing.TypeVar("S")

1886

1887

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

1889 """

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

1891 """

1892

1893 records: dict[int, T_MetaInstance]

1894 # _rows: Rows

1895 model: typing.Type[T_MetaInstance]

1896 metadata: dict[str, Any]

1897

1898 # pseudo-properties: actually stored in _rows

1899 db: TypeDAL

1900 colnames: list[str]

1901 fields: list[Field]

1902 colnames_fields: list[Field]

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

1904

1905 def __init__(

1906 self,

1907 rows: Rows,

1908 model: typing.Type[T_MetaInstance],

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

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

1911 ) -> None:

1912 """

1913 Should not be called manually!

1914

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

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

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

1918 `model` is a Typed Table class

1919 """

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

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

1922 self.model = model

1923 self.metadata = metadata or {}

1924

1925 def __len__(self) -> int:

1926 """

1927 Return the count of rows.

1928 """

1929 return len(self.records)

1930

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

1932 """

1933 Loop through the rows.

1934 """

1935 yield from self.records.values()

1936

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

1938 """

1939 Check if an id exists in this result set.

1940 """

1941 return ind in self.records

1942

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

1944 """

1945 Get the row with the lowest id.

1946 """

1947 if not self.records:

1948 return None

1949

1950 return next(iter(self))

1951

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

1953 """

1954 Get the row with the highest id.

1955 """

1956 if not self.records:

1957 return None

1958

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

1960 return self[max_id]

1961

1962 def find(

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

1964 ) -> "TypedRows[T_MetaInstance]":

1965 """

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

1967 """

1968 if not self.records:

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

1970

1971 records = {}

1972 if limitby:

1973 _min, _max = limitby

1974 else:

1975 _min, _max = 0, len(self)

1976 count = 0

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

1978 if f(row):

1979 if _min <= count:

1980 records[i] = row

1981 count += 1

1982 if count == _max:

1983 break

1984

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

1986

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

1988 """

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

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

1991 """

1992 if not self.records:

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

1994 removed = {}

1995 to_remove = []

1996 for i in self.records:

1997 row = self[i]

1998 if f(row):

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

2000 to_remove.append(i)

2001

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

2003

2004 return self.__class__(

2005 self,

2006 self.model,

2007 removed,

2008 )

2009

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

2011 """

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

2013 """

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

2015

2016 def __str__(self) -> str:

2017 """

2018 Simple string representation.

2019 """

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

2021

2022 def __repr__(self) -> str:

2023 """

2024 Print a table on repr().

2025 """

2026 data = self.as_dict()

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

2028 return mktable(data, headers)

2029

2030 def group_by_value(

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

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

2033 """

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

2035 """

2036 kwargs["one_result"] = one_result

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

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

2039

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

2041 """

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

2043

2044 Example:

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

2046 """

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

2048

2049 def as_csv(self) -> str:

2050 """

2051 Dump the data to csv.

2052 """

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

2054

2055 def as_dict(

2056 self,

2057 key: str = None,

2058 compact: bool = False,

2059 storage_to_dict: bool = False,

2060 datetime_to_str: bool = False,

2061 custom_types: list[type] = None,

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

2063 """

2064 Get the data in a dict of dicts.

2065 """

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

2067 # functionality not guaranteed

2068 return typing.cast(

2069 dict[int, dict[str, Any]],

2070 super().as_dict(

2071 key or "id",

2072 compact,

2073 storage_to_dict,

2074 datetime_to_str,

2075 custom_types,

2076 ),

2077 )

2078

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

2080

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

2082 """

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

2084 """

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

2086

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

2088 """

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

2090 """

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

2092

2093 def as_list(

2094 self,

2095 compact: bool = False,

2096 storage_to_dict: bool = False,

2097 datetime_to_str: bool = False,

2098 custom_types: list[type] = None,

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

2100 """

2101 Get the data in a list of dicts.

2102 """

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

2104 return typing.cast(

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

2106 )

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

2108

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

2110 """

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

2112

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

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

2115 """

2116 try:

2117 return self.records[item]

2118 except KeyError as e:

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

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

2121 return row

2122

2123 raise e

2124

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

2126 """

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

2128 """

2129 return self.records.get(item)

2130

2131 def join(

2132 self,

2133 field: Field | TypedField[Any],

2134 name: str = None,

2135 constraint: Query = None,

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

2137 orderby: str | Field = None,

2138 ) -> T_MetaInstance:

2139 """

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

2141

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

2143 """

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

2145 return typing.cast(T_MetaInstance, result)

2146

2147 def export_to_csv_file(

2148 self,

2149 ofile: typing.TextIO,

2150 null: str = "<NULL>",

2151 delimiter: str = ",",

2152 quotechar: str = '"',

2153 quoting: int = csv.QUOTE_MINIMAL,

2154 represent: bool = False,

2155 colnames: list[str] = None,

2156 write_colnames: bool = True,

2157 *args: Any,

2158 **kwargs: Any,

2159 ) -> None:

2160 """

2161 Shadow export_to_csv_file from Rows, but with typing.

2162

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

2164 """

2165 super().export_to_csv_file(

2166 ofile,

2167 null,

2168 *args,

2169 delimiter=delimiter,

2170 quotechar=quotechar,

2171 quoting=quoting,

2172 represent=represent,

2173 colnames=colnames or self.colnames,

2174 write_colnames=write_colnames,

2175 **kwargs,

2176 )

2177

2178 @classmethod

2179 def from_rows(

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

2181 ) -> "TypedRows[T_MetaInstance]":

2182 """

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

2184 """

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

2186

2187

2188 class PaginatedRows(TypedRows[T_MetaInstance]):

2189 """

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

2191 """

2192

2193 _query_builder: QueryBuilder[T_MetaInstance]

2194

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

2196 """

2197 Get the next page.

2198 """

2199 data = self.metadata["pagination"]

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

2201 raise StopIteration("Final Page")

2202

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

2204

2205 def previous(self) -> Self:

2206 """

2207 Get the previous page.

2208 """

2209 data = self.metadata["pagination"]

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

2211 raise StopIteration("First Page")

2212

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

2214

2215

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

2217 """

2218 Used to make pydal Set more typed.

2219

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

2221 """

2222

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

2224 """

2225 Count returns an int.

2226 """

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

2228 return typing.cast(int, result)

2229

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

2231 """

2232 Select returns a TypedRows of a user defined table.

2233

2234 Example:

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

2236

2237 for row in result:

2238 typing.reveal_type(row) # MyTable

2239 """

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

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