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

713 statements  

« prev     ^ index     » next       coverage.py v7.3.2, created at 2023-10-26 10:36 +0200

1""" 

2Core functionality of TypeDAL. 

3""" 

4import contextlib 

5import csv 

6import datetime as dt 

7import inspect 

8import math 

9import types 

10import typing 

11import warnings 

12from collections import defaultdict 

13from decimal import Decimal 

14from typing import Any, Optional 

15 

16import pydal 

17from pydal._globals import DEFAULT 

18from pydal.objects import Field 

19from pydal.objects import Query as _Query 

20from pydal.objects import Row, Rows 

21from pydal.objects import Table as _Table 

22from typing_extensions import Self 

23 

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

38from .types import Expression, Query, _Types 

39 

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

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

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

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

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

45T = typing.TypeVar("T") 

46 

47BASIC_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 

61def 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 

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

75DEFAULT_JOIN_OPTION: JOIN_OPTIONS = "left" 

76 

77# table-ish paramter: 

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

79 

80Condition: typing.TypeAlias = typing.Optional[ 

81 typing.Callable[ 

82 # self, other -> Query 

83 [P_Table, P_Table], 

84 Query | bool, 

85 ] 

86] 

87 

88OnQuery: 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 

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

97 

98 

99class 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 

217def 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 

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

269def 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 

314class 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 

622class 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 

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

636 """ 

637 Make mypy happy. 

638 """ 

639 

640 

641class 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 

993class 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: 

1285TypedRow = TypedTable 

1286 

1287 

1288class 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 

1728class 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 

1885S = typing.TypeVar("S") 

1886 

1887 

1888class 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 

2188class 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 

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