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

736 statements  

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

1""" 

2Core functionality of TypeDAL. 

3""" 

4import contextlib 

5import csv 

6import datetime as dt 

7import inspect 

8import json 

9import math 

10import types 

11import typing 

12import warnings 

13from collections import defaultdict 

14from decimal import Decimal 

15from typing import Any, Optional 

16 

17import pydal 

18from pydal._globals import DEFAULT 

19from pydal.objects import Field 

20from pydal.objects import Query as _Query 

21from pydal.objects import Row, Rows 

22from pydal.objects import Table as _Table 

23from typing_extensions import Self 

24 

25from .helpers import ( 

26 DummyQuery, 

27 all_annotations, 

28 all_dict, 

29 extract_type_optional, 

30 filter_out, 

31 instanciate, 

32 is_union, 

33 looks_like, 

34 mktable, 

35 origin_is_subclass, 

36 to_snake, 

37 unwrap_type, 

38) 

39from .types import Expression, Query, _Types 

40 

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

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

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

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

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

46T = typing.TypeVar("T") 

47 

48BASIC_MAPPINGS: dict[T_annotation, str] = { 

49 str: "string", 

50 int: "integer", 

51 bool: "boolean", 

52 bytes: "blob", 

53 float: "double", 

54 object: "json", 

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

56 dt.date: "date", 

57 dt.time: "time", 

58 dt.datetime: "datetime", 

59} 

60 

61 

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

63 """ 

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

65 

66 Deprecated 

67 """ 

68 return ( 

69 isinstance(cls, TypedField) 

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

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

72 ) 

73 

74 

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

76DEFAULT_JOIN_OPTION: JOIN_OPTIONS = "left" 

77 

78# table-ish paramter: 

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

80 

81Condition: typing.TypeAlias = typing.Optional[ 

82 typing.Callable[ 

83 # self, other -> Query 

84 [P_Table, P_Table], 

85 Query | bool, 

86 ] 

87] 

88 

89OnQuery: typing.TypeAlias = typing.Optional[ 

90 typing.Callable[ 

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

92 [P_Table, P_Table], 

93 list[Expression], 

94 ] 

95] 

96 

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

98 

99 

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

101 """ 

102 Define a relationship to another table. 

103 """ 

104 

105 _type: To_Type 

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

107 condition: Condition 

108 on: OnQuery 

109 multiple: bool 

110 join: JOIN_OPTIONS 

111 

112 def __init__( 

113 self, 

114 _type: To_Type, 

115 condition: Condition = None, 

116 join: JOIN_OPTIONS = None, 

117 on: OnQuery = None, 

118 ): 

119 """ 

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

121 """ 

122 if condition and on: 

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

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

125 

126 self._type = _type 

127 self.condition = condition 

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

129 self.on = on 

130 

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

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

133 self.multiple = True 

134 else: 

135 self.table = _type 

136 self.multiple = False 

137 

138 if isinstance(self.table, str): 

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

140 

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

142 """ 

143 Create a copy of the relationship, possibly updated. 

144 """ 

145 return self.__class__( 

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

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

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

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

150 ) 

151 

152 def __repr__(self) -> str: 

153 """ 

154 Representation of the relationship. 

155 """ 

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

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

158 else: 

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

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

161 

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

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

164 

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

166 """ 

167 Get the table this relationship is bound to. 

168 """ 

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

170 if isinstance(table, str): 

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

172 # yay 

173 return mapped 

174 

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

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

177 

178 return table 

179 

180 def get_table_name(self) -> str: 

181 """ 

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

183 """ 

184 if isinstance(self.table, str): 

185 return self.table 

186 

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

188 return str(self.table) 

189 

190 # else: typed table 

191 try: 

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

193 except Exception: # pragma: no cover 

194 table = self.table 

195 

196 return str(table) 

197 

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

199 """ 

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

201 

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

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

204 """ 

205 if not instance: 

206 # relationship queried on class, that's allowed 

207 return self 

208 

209 warnings.warn( 

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

211 ) 

212 if self.multiple: 

213 return [] 

214 else: 

215 return None 

216 

217 

218def relationship( 

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

220) -> Relationship[To_Type]: 

221 """ 

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

223 

224 Example: 

225 class User(TypedTable): 

226 name: str 

227 

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

229 

230 class Post(TypedTable): 

231 title: str 

232 author: User 

233 

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

235 

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

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

238 

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

240 class User(TypedTable): 

241 ... 

242 

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

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

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

246 ]) 

247 

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

249 """ 

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

251 

252 

253def _generate_relationship_condition( 

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

255) -> Condition: 

256 origin = typing.get_origin(field) 

257 # else: generic 

258 

259 if origin == list: 

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

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

262 

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

264 else: 

265 # normal reference 

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

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

268 

269 

270def to_relationship( 

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

272 key: str, 

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

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

275 """ 

276 Used to automatically create relationship instance for reference fields. 

277 

278 Example: 

279 class MyTable(TypedTable): 

280 reference: OtherTable 

281 

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

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

284 

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

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

287 

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

289 """ 

290 if looks_like(field, TypedField): 

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

292 field = args[0] 

293 else: 

294 # weird 

295 return None 

296 

297 field, optional = extract_type_optional(field) 

298 

299 try: 

300 condition = _generate_relationship_condition(cls, key, field) 

301 except Exception as e: # pragma: no cover 

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

303 condition = None 

304 

305 if not condition: # pragma: no cover 

306 # something went wrong, not a valid relationship 

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

308 return None 

309 

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

311 

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

313 

314 

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

316 """ 

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

318 """ 

319 

320 # dal: Table 

321 # def __init__(self, 

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

323 # pool_size=0, 

324 # folder=None, 

325 # db_codec="UTF-8", 

326 # check_reserved=None, 

327 # migrate=True, 

328 # fake_migrate=False, 

329 # migrate_enabled=True, 

330 # fake_migrate_all=False, 

331 # decode_credentials=False, 

332 # driver_args=None, 

333 # adapter_args=None, 

334 # attempts=5, 

335 # auto_import=False, 

336 # bigint_id=False, 

337 # debug=False, 

338 # lazy_tables=False, 

339 # db_uid=None, 

340 # after_connection=None, 

341 # tables=None, 

342 # ignore_field_case=True, 

343 # entity_quoting=True, 

344 # table_hash=None, 

345 # ): 

346 # super().__init__( 

347 # uri, 

348 # pool_size, 

349 # folder, 

350 # db_codec, 

351 # check_reserved, 

352 # migrate, 

353 # fake_migrate, 

354 # migrate_enabled, 

355 # fake_migrate_all, 

356 # decode_credentials, 

357 # driver_args, 

358 # adapter_args, 

359 # attempts, 

360 # auto_import, 

361 # bigint_id, 

362 # debug, 

363 # lazy_tables, 

364 # db_uid, 

365 # after_connection, 

366 # tables, 

367 # ignore_field_case, 

368 # entity_quoting, 

369 # table_hash, 

370 # ) 

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

372 

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

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

375 "notnull": True, 

376 } 

377 

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

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

380 

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

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

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

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

385 

386 # dirty way (with evil eval): 

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

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

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

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

391 

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

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

394 

395 tablename = self.to_snake(cls.__name__) 

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

397 annotations = all_annotations(cls) 

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

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

400 # remove internal stuff: 

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

402 

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

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

405 } 

406 

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

408 

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

410 

411 # ! dont' use full_dict here: 

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

413 

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

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

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

417 

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

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

420 

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

422 # ensure they are all instances and 

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

424 # relationships = { 

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

426 # } 

427 

428 # keys of implicit references (also relationships): 

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

430 

431 # add implicit relationships: 

432 # User; list[User]; TypedField[User]; TypedField[list[User]] 

433 relationships |= { 

434 k: new_relationship 

435 for k in reference_field_keys 

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

437 } 

438 

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

440 

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

442 field = fields[name] 

443 typed_field.bind(field, table) 

444 

445 if issubclass(cls, TypedTable): 

446 cls.__set_internals__( 

447 db=self, 

448 table=table, 

449 # by now, all relationships should be instances! 

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

451 ) 

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

453 else: 

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

455 

456 return cls 

457 

458 @typing.overload 

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

460 """ 

461 Typing Overload for define without a class. 

462 

463 @db.define() 

464 class MyTable(TypedTable): ... 

465 """ 

466 

467 @typing.overload 

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

469 """ 

470 Typing Overload for define with a class. 

471 

472 @db.define 

473 class MyTable(TypedTable): ... 

474 """ 

475 

476 def define( 

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

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

479 """ 

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

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

482 

483 Example: 

484 @db.define 

485 class Person(TypedTable): 

486 ... 

487 

488 class Article(TypedTable): 

489 ... 

490 

491 # at a later time: 

492 db.define(Article) 

493 

494 Returns: 

495 the result of pydal.define_table 

496 """ 

497 

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

499 return self._define(cls) 

500 

501 if maybe_cls: 

502 return wrapper(maybe_cls) 

503 

504 return wrapper 

505 

506 # def drop(self, table_name: str) -> None: 

507 # """ 

508 # Remove a table by name (both on the database level and the typedal level). 

509 # """ 

510 # # drop calls TypedTable.drop() and removes it from the `_class_map` 

511 # if cls := self._class_map.pop(table_name, None): 

512 # cls.drop() 

513 

514 # def drop_all(self, max_retries: int = None) -> None: 

515 # """ 

516 # Remove all tables and keep doing so until everything is gone! 

517 # """ 

518 # retries = 0 

519 # if max_retries is None: 

520 # max_retries = len(self.tables) 

521 # 

522 # while self.tables: 

523 # retries += 1 

524 # for table in self.tables: 

525 # self.drop(table) 

526 # 

527 # if retries > max_retries: 

528 # raise RuntimeError("Could not delete all tables") 

529 

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

531 """ 

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

533 

534 Usually, only a query is passed. 

535 

536 Example: 

537 db(query).select() 

538 

539 """ 

540 args = list(_args) 

541 if args: 

542 cls = args[0] 

543 if isinstance(cls, bool): 

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

545 

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

547 # table defined without @db.define decorator! 

548 _cls: typing.Type[TypedTable] = cls 

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

550 

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

552 return typing.cast(TypedSet, _set) 

553 

554 @classmethod 

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

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

557 

558 @classmethod 

559 def _annotation_to_pydal_fieldtype( 

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

561 ) -> Optional[str]: 

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

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

564 

565 if isinstance(ftype, str): 

566 # extract type from string 

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

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

569 ) 

570 

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

572 # basi types 

573 return mapping 

574 elif isinstance(ftype, _Table): 

575 # db.table 

576 return f"reference {ftype._tablename}" 

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

578 # SomeTable 

579 snakename = cls.to_snake(ftype.__name__) 

580 return f"reference {snakename}" 

581 elif isinstance(ftype, TypedField): 

582 # FieldType(type, ...) 

583 return ftype._to_field(mut_kw) 

584 elif origin_is_subclass(ftype, TypedField): 

585 # TypedField[int] 

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

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

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

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

590 _child_type = cls._annotation_to_pydal_fieldtype(_child_type, mut_kw) 

591 return f"list:{_child_type}" 

592 elif is_union(ftype): 

593 # str | int -> UnionType 

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

595 

596 # Optional[type] == type | None 

597 

598 match typing.get_args(ftype): 

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

600 # good union of Nullable 

601 

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

603 mut_kw["notnull"] = False 

604 return cls._annotation_to_pydal_fieldtype(_child_type, mut_kw) 

605 case _: 

606 # two types is not supported by the db! 

607 return None 

608 else: 

609 return None 

610 

611 @classmethod 

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

613 """ 

614 Convert a annotation into a pydal Field. 

615 

616 Args: 

617 fname: name of the property 

618 ftype: annotation of the property 

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

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

621 

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

623 

624 Example: 

625 class MyTable: 

626 fname: ftype 

627 id: int 

628 name: str 

629 reference: Table 

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

631 """ 

632 fname = cls.to_snake(fname) 

633 

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

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

636 else: 

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

638 

639 @staticmethod 

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

641 """ 

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

643 """ 

644 return to_snake(camel) 

645 

646 

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

648 """ 

649 Make mypy happy. 

650 """ 

651 

652 id: int # noqa: A003 

653 

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

655 """ 

656 Tell mypy a Table supports dictionary notation for columns. 

657 """ 

658 

659 

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

661 """ 

662 Make mypy happy. 

663 """ 

664 

665 

666class TableMeta(type): 

667 """ 

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

669 

670 Example: 

671 class MyTable(TypedTable): 

672 some_field: TypedField[int] 

673 

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

675 

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

677 

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

679 

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

681 

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

683 

684 """ 

685 

686 # set up by db.define: 

687 # _db: TypeDAL | None = None 

688 # _table: Table | None = None 

689 _db: TypeDAL | None = None 

690 _table: Table | None = None 

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

692 

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

694 # TypeDAL custom logic: # 

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

696 

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

698 """ 

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

700 """ 

701 self._db = db 

702 self._table = table 

703 self._relationships = relationships 

704 

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

706 """ 

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

708 

709 Example: 

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

711 

712 """ 

713 if self._table: 

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

715 

716 def _ensure_table_defined(self) -> Table: 

717 if not self._table: 

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

719 return self._table 

720 

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

722 """ 

723 Loop through the columns of this model. 

724 """ 

725 table = self._ensure_table_defined() 

726 yield from iter(table) 

727 

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

729 """ 

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

731 """ 

732 table = self._ensure_table_defined() 

733 return table[item] 

734 

735 def __str__(self) -> str: 

736 """ 

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

738 """ 

739 if self._table: 

740 return str(self._table) 

741 else: 

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

743 

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

745 """ 

746 Create a model instance from a pydal row. 

747 """ 

748 return self(row) 

749 

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

751 """ 

752 Return all rows for this model. 

753 """ 

754 return self.collect() 

755 

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

757 """ 

758 Convert to a json-dumpable dict. 

759 

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

761 todo: can this be optimized? 

762 

763 See Also: 

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

765 """ 

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

767 

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

769 

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

771 """ 

772 Return the registered relationships of the current model. 

773 """ 

774 return self._relationships or {} 

775 

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

777 # TypeDAL Modified Logic # 

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

779 

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

781 """ 

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

783 

784 cls.__table functions as 'self' 

785 

786 Args: 

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

788 

789 Returns: the ID of the new row. 

790 

791 """ 

792 table = self._ensure_table_defined() 

793 

794 result = table.insert(**fields) 

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

796 return self(result) 

797 

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

799 """ 

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

801 """ 

802 table = self._ensure_table_defined() 

803 result = table.bulk_insert(items) 

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

805 

806 def update_or_insert( 

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

808 ) -> T_MetaInstance: 

809 """ 

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

811 

812 Returns the created or updated instance. 

813 """ 

814 table = self._ensure_table_defined() 

815 

816 if query is DEFAULT: 

817 record = table(**values) 

818 elif isinstance(query, dict): 

819 record = table(**query) 

820 else: 

821 record = table(query) 

822 

823 if not record: 

824 return self.insert(**values) 

825 

826 record.update_record(**values) 

827 return self(record) 

828 

829 def validate_and_insert( 

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

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

832 """ 

833 Validate input data and then insert a row. 

834 

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

836 """ 

837 table = self._ensure_table_defined() 

838 result = table.validate_and_insert(**fields) 

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

840 return self(row_id), None 

841 else: 

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

843 

844 def validate_and_update( 

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

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

847 """ 

848 Validate input data and then update max 1 row. 

849 

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

851 """ 

852 table = self._ensure_table_defined() 

853 

854 try: 

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

856 except Exception as e: 

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

858 

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

860 return None, errors 

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

862 return self(row_id), None 

863 else: # pragma: no cover 

864 # update on query without result (shouldnt happen) 

865 return None, None 

866 

867 def validate_and_update_or_insert( 

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

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

870 """ 

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

872 

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

874 """ 

875 table = self._ensure_table_defined() 

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

877 

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

879 return None, errors 

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

881 return self(row_id), None 

882 else: # pragma: no cover 

883 # update on query without result (shouldnt happen) 

884 return None, None 

885 

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

887 """ 

888 See QueryBuilder.select! 

889 """ 

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

891 

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

893 """ 

894 See QueryBuilder.paginate! 

895 """ 

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

897 

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

899 """ 

900 See QueryBuilder.where! 

901 """ 

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

903 

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

905 """ 

906 See QueryBuilder.count! 

907 """ 

908 return QueryBuilder(self).count() 

909 

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

911 """ 

912 See QueryBuilder.first! 

913 """ 

914 return QueryBuilder(self).first() 

915 

916 def join( 

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

918 ) -> "QueryBuilder[T_MetaInstance]": 

919 """ 

920 See QueryBuilder.join! 

921 """ 

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

923 

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

925 """ 

926 See QueryBuilder.collect! 

927 """ 

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

929 

930 @property 

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

932 """ 

933 Select all fields for this table. 

934 """ 

935 table = cls._ensure_table_defined() 

936 

937 return table.ALL 

938 

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

940 # TypeDAL Shadowed Logic # 

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

942 fields: list[str] 

943 

944 # other table methods: 

945 

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

947 """ 

948 Remove the underlying table. 

949 """ 

950 table = self._ensure_table_defined() 

951 table.drop(mode) 

952 

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

954 """ 

955 Add an index on some columns of this table. 

956 """ 

957 table = self._ensure_table_defined() 

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

959 return typing.cast(bool, result) 

960 

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

962 """ 

963 Remove an index from this table. 

964 """ 

965 table = self._ensure_table_defined() 

966 result = table.drop_index(name, if_exists) 

967 return typing.cast(bool, result) 

968 

969 def import_from_csv_file( 

970 self, 

971 csvfile: typing.TextIO, 

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

973 null: str = "<NULL>", 

974 unique: str = "uuid", 

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

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

977 validate: bool = False, 

978 encoding: str = "utf-8", 

979 delimiter: str = ",", 

980 quotechar: str = '"', 

981 quoting: int = csv.QUOTE_MINIMAL, 

982 restore: bool = False, 

983 **kwargs: Any, 

984 ) -> None: 

985 """ 

986 Load a csv file into the database. 

987 """ 

988 table = self._ensure_table_defined() 

989 table.import_from_csv_file( 

990 csvfile, 

991 id_map=id_map, 

992 null=null, 

993 unique=unique, 

994 id_offset=id_offset, 

995 transform=transform, 

996 validate=validate, 

997 encoding=encoding, 

998 delimiter=delimiter, 

999 quotechar=quotechar, 

1000 quoting=quoting, 

1001 restore=restore, 

1002 **kwargs, 

1003 ) 

1004 

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

1006 """ 

1007 Shadow Table.on. 

1008 

1009 Used for joins. 

1010 

1011 See Also: 

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

1013 """ 

1014 table = self._ensure_table_defined() 

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

1016 

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

1018 """ 

1019 Shadow Table.with_alias. 

1020 

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

1022 

1023 See Also: 

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

1025 """ 

1026 table = self._ensure_table_defined() 

1027 return table.with_alias(alias) 

1028 

1029 # @typing.dataclass_transform() 

1030 

1031 

1032class TypedTable(metaclass=TableMeta): 

1033 """ 

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

1035 """ 

1036 

1037 # set up by 'new': 

1038 _row: Row | None = None 

1039 

1040 _with: list[str] 

1041 

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

1043 

1044 def _setup_instance_methods(self) -> None: 

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

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

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

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

1049 

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

1051 

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

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

1054 

1055 def __new__( 

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

1057 ) -> "TypedTable": 

1058 """ 

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

1060 

1061 Examples: 

1062 MyTable(1) 

1063 MyTable(id=1) 

1064 MyTable(MyTable.id == 1) 

1065 """ 

1066 table = cls._ensure_table_defined() 

1067 

1068 if isinstance(row_or_id, TypedTable): 

1069 # existing typed table instance! 

1070 return row_or_id 

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

1072 row = row_or_id 

1073 elif row_or_id is not None: 

1074 row = table(row_or_id, **filters) 

1075 else: 

1076 row = table(**filters) 

1077 

1078 if not row: 

1079 return None # type: ignore 

1080 

1081 inst = super().__new__(cls) 

1082 inst._row = row 

1083 inst.__dict__.update(row) 

1084 inst._setup_instance_methods() 

1085 return inst 

1086 

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

1088 """ 

1089 Allows looping through the columns. 

1090 """ 

1091 row = self._ensure_matching_row() 

1092 yield from iter(row) 

1093 

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

1095 """ 

1096 Allows dictionary notation to get columns. 

1097 """ 

1098 if item in self.__dict__: 

1099 return self.__dict__.get(item) 

1100 

1101 # fallback to lookup in row 

1102 if self._row: 

1103 return self._row[item] 

1104 

1105 # nothing found! 

1106 raise KeyError(item) 

1107 

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

1109 """ 

1110 Allows dot notation to get columns. 

1111 """ 

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

1113 return value 

1114 

1115 raise AttributeError(item) 

1116 

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

1118 """ 

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

1120 """ 

1121 try: 

1122 return self.__getitem__(item) 

1123 except KeyError: 

1124 return default 

1125 

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

1127 """ 

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

1129 """ 

1130 return setattr(self, key, value) 

1131 

1132 def __int__(self) -> int: 

1133 """ 

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

1135 """ 

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

1137 

1138 def __bool__(self) -> bool: 

1139 """ 

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

1141 """ 

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

1143 

1144 def _ensure_matching_row(self) -> Row: 

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

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

1147 return self._row 

1148 

1149 def __repr__(self) -> str: 

1150 """ 

1151 String representation of the model instance. 

1152 """ 

1153 model_name = self.__class__.__name__ 

1154 model_data = {} 

1155 

1156 if self._row: 

1157 model_data = self._row.as_json() 

1158 

1159 details = model_name 

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

1161 

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

1163 details += f" + {relationships}" 

1164 

1165 return f"<{details}>" 

1166 

1167 # serialization 

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

1169 

1170 @classmethod 

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

1172 """ 

1173 Dump the object to a plain dict. 

1174 

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

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

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

1178 """ 

1179 table = cls._ensure_table_defined() 

1180 result = table.as_dict(flat, sanitize) 

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

1182 

1183 @classmethod 

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

1185 """ 

1186 Dump the object to json. 

1187 

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

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

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

1191 """ 

1192 table = cls._ensure_table_defined() 

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

1194 

1195 @classmethod 

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

1197 """ 

1198 Dump the object to xml. 

1199 

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

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

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

1203 """ 

1204 table = cls._ensure_table_defined() 

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

1206 

1207 @classmethod 

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

1209 """ 

1210 Dump the object to yaml. 

1211 

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

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

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

1215 """ 

1216 table = cls._ensure_table_defined() 

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

1218 

1219 def _as_dict( 

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

1221 ) -> dict[str, Any]: 

1222 row = self._ensure_matching_row() 

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

1224 

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

1226 for relationship in _with: 

1227 data = self.get(relationship) 

1228 if isinstance(data, list): 

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

1230 elif data: 

1231 data = data.as_dict() 

1232 

1233 result[relationship] = data 

1234 

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

1236 

1237 def _as_json( 

1238 self, 

1239 mode: str = "object", 

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

1241 colnames: list[str] = None, 

1242 serialize: bool = True, 

1243 **kwargs: Any, 

1244 ) -> str: 

1245 row = self._ensure_matching_row() 

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

1247 

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

1249 row = self._ensure_matching_row() 

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

1251 

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

1253 # row = self._ensure_matching_row() 

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

1255 

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

1257 """ 

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

1259 """ 

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

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

1262 self._row[key] = value 

1263 

1264 super().__setattr__(key, value) 

1265 

1266 @classmethod 

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

1268 """ 

1269 Update one record. 

1270 

1271 Example: 

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

1273 """ 

1274 if record := cls(query): 

1275 return record.update_record(**fields) 

1276 else: 

1277 return None 

1278 

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

1280 row = self._ensure_matching_row() 

1281 row.update(**fields) 

1282 self.__dict__.update(**fields) 

1283 return self 

1284 

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

1286 row = self._ensure_matching_row() 

1287 new_row = row.update_record(**fields) 

1288 self.update(**new_row) 

1289 return self 

1290 

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

1292 """ 

1293 Here as a placeholder for _update_record. 

1294 

1295 Will be replaced on instance creation! 

1296 """ 

1297 return self._update_record(**fields) 

1298 

1299 def _delete_record(self) -> int: 

1300 """ 

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

1302 """ 

1303 row = self._ensure_matching_row() 

1304 result = row.delete_record() 

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

1306 self._row = None # just to be sure 

1307 self._setup_instance_methods() 

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

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

1310 return typing.cast(int, result) 

1311 

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

1313 """ 

1314 Here as a placeholder for _delete_record. 

1315 

1316 Will be replaced on instance creation! 

1317 """ 

1318 return self._delete_record() 

1319 

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

1321 

1322 

1323# backwards compat: 

1324TypedRow = TypedTable 

1325 

1326 

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

1328 """ 

1329 Abstration on top of pydal's query system. 

1330 """ 

1331 

1332 model: typing.Type[T_MetaInstance] 

1333 query: Query 

1334 select_args: list[Any] 

1335 select_kwargs: dict[str, Any] 

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

1337 metadata: dict[str, Any] 

1338 

1339 def __init__( 

1340 self, 

1341 model: typing.Type[T_MetaInstance], 

1342 add_query: Optional[Query] = None, 

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

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

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

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

1347 ): 

1348 """ 

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

1350 

1351 Example: 

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

1353 """ 

1354 self.model = model 

1355 table = model._ensure_table_defined() 

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

1357 self.query = add_query or default_query 

1358 self.select_args = select_args or [] 

1359 self.select_kwargs = select_kwargs or {} 

1360 self.relationships = relationships or {} 

1361 self.metadata = metadata or {} 

1362 

1363 def _extend( 

1364 self, 

1365 add_query: Optional[Query] = None, 

1366 overwrite_query: Optional[Query] = None, 

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

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

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

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

1371 ) -> "QueryBuilder[T_MetaInstance]": 

1372 return QueryBuilder( 

1373 self.model, 

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

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

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

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

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

1379 ) 

1380 

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

1382 """ 

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

1384 

1385 Options: 

1386 paraphrased from the web2py pydal docs, 

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

1388 

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

1390 table.name - sort by name, ascending 

1391 ~table.name - sort by name, descending 

1392 <random> - sort randomly 

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

1394 

1395 groupby, having: together with orderby: 

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

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

1398 

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

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

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

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

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

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

1405 """ 

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

1407 

1408 def where( 

1409 self, 

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

1411 **filters: Any, 

1412 ) -> "QueryBuilder[T_MetaInstance]": 

1413 """ 

1414 Extend the builder's query. 

1415 

1416 Can be used in multiple ways: 

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

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

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

1420 

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

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

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

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

1425 """ 

1426 new_query = self.query 

1427 table = self.model._ensure_table_defined() 

1428 

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

1430 new_query &= table[field] == value 

1431 

1432 subquery = DummyQuery() 

1433 for query_or_lambda in queries_or_lambdas: 

1434 if isinstance(query_or_lambda, _Query): 

1435 subquery |= query_or_lambda 

1436 elif callable(query_or_lambda): 

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

1438 subquery |= result 

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

1440 subquery |= query_or_lambda != None 

1441 else: 

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

1443 

1444 if subquery: 

1445 new_query &= subquery 

1446 

1447 return self._extend(overwrite_query=new_query) 

1448 

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

1450 """ 

1451 Include relationship fields in the result. 

1452 

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

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

1455 

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

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

1458 """ 

1459 relationships = self.model.get_relationships() 

1460 

1461 if fields: 

1462 # join on every relationship 

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

1464 

1465 if method: 

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

1467 

1468 return self._extend(relationships=relationships) 

1469 

1470 def _get_db(self) -> TypeDAL: 

1471 if db := self.model._db: 

1472 return db 

1473 else: # pragma: no cover 

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

1475 

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

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

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

1479 arg = arg._field 

1480 

1481 return arg 

1482 

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

1484 """ 

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

1486 """ 

1487 db = self._get_db() 

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

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

1490 # success! 

1491 return removed_ids 

1492 

1493 return None 

1494 

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

1496 """ 

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

1498 """ 

1499 db = self._get_db() 

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

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

1502 # success! 

1503 return updated_ids 

1504 

1505 return None 

1506 

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

1508 """ 

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

1510 """ 

1511 if _to is None: 

1512 _to = TypedRows 

1513 

1514 db = self._get_db() 

1515 

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

1517 select_kwargs = self.select_kwargs.copy() 

1518 metadata = self.metadata.copy() 

1519 query = self.query 

1520 model = self.model 

1521 

1522 metadata["query"] = query 

1523 

1524 # require at least id of main table: 

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

1526 tablename = str(model) 

1527 

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

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

1530 select_args.append(model.id) 

1531 

1532 if self.relationships: 

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

1534 

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

1536 

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

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

1539 metadata["final_kwargs"] = select_kwargs 

1540 

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

1542 

1543 if verbose: # pragma: no cover 

1544 print(metadata["sql"]) 

1545 print(rows) 

1546 

1547 if not self.relationships: 

1548 # easy 

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

1550 

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

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

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

1554 

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

1556 

1557 def _handle_relationships_pre_select( 

1558 self, 

1559 query: Query, 

1560 select_args: list[Any], 

1561 select_kwargs: dict[str, Any], 

1562 metadata: dict[str, Any], 

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

1564 db = self._get_db() 

1565 model = self.model 

1566 

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

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

1569 # if limitby + relationships: 

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

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

1572 # 3. add joins etc 

1573 

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

1575 query = model.id.belongs(ids) 

1576 metadata["ids"] = ids 

1577 

1578 left = [] 

1579 

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

1581 other = relation.get_table(db) 

1582 method: JOIN_OPTIONS = relation.join or DEFAULT_JOIN_OPTION 

1583 

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

1585 pre_alias = str(other) 

1586 

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

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

1589 select_args.append(other.ALL) 

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

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

1592 select_args.append(other.id) 

1593 

1594 if relation.on: 

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

1596 on = relation.on(model, other) 

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

1598 on = [on] 

1599 

1600 left.extend(on) 

1601 elif method == "left": 

1602 # .on not given, generate it: 

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

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

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

1606 else: 

1607 # else: inner join 

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

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

1610 

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

1612 # else: only add other.id if missing 

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

1614 

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

1616 if pre_alias != post_alias: 

1617 # replace .select's with aliased: 

1618 select_fields = select_fields.replace( 

1619 f"{pre_alias}.", 

1620 f"{post_alias}.", 

1621 ) 

1622 

1623 select_args = select_fields.split(", ") 

1624 

1625 select_kwargs["left"] = left 

1626 return query, select_args 

1627 

1628 def _collect_with_relationships( 

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

1630 ) -> "TypedRows[T_MetaInstance]": 

1631 """ 

1632 Transform the raw rows into Typed Table model instances. 

1633 """ 

1634 db = self._get_db() 

1635 main_table = self.model._ensure_table_defined() 

1636 

1637 records = {} 

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

1639 

1640 for row in rows: 

1641 main = row[main_table] 

1642 main_id = main.id 

1643 

1644 if main_id not in records: 

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

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

1647 

1648 # setup up all relationship defaults (once) 

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

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

1651 

1652 # now add other relationship data 

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

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

1655 

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

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

1658 

1659 relation_data = ( 

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

1661 ) 

1662 

1663 if relation_data.id is None: 

1664 # always skip None ids 

1665 continue 

1666 

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

1668 # speed up duplicates 

1669 continue 

1670 else: 

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

1672 

1673 relation_table = relation.get_table(db) 

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

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

1676 

1677 if relation.multiple: 

1678 # create list of T 

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

1680 # should already be set up before! 

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

1682 

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

1684 else: 

1685 # create single T 

1686 records[main_id][column] = instance 

1687 

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

1689 

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

1691 """ 

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

1693 

1694 Basically unwraps Optional type. 

1695 """ 

1696 if result := self.collect(): 

1697 return result 

1698 else: 

1699 raise ValueError("Nothing found!") 

1700 

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

1702 """ 

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

1704 """ 

1705 yield from self.collect() 

1706 

1707 def count(self) -> int: 

1708 """ 

1709 Return the amount of rows matching the current query. 

1710 """ 

1711 db = self._get_db() 

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

1713 

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

1715 """ 

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

1717 

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

1719 can be loaded with relationship data! 

1720 """ 

1721 _from = limit * (page - 1) 

1722 _to = limit * page 

1723 

1724 available = self.count() 

1725 

1726 builder = self._extend( 

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

1728 metadata={ 

1729 "pagination": { 

1730 "limit": limit, 

1731 "current_page": page, 

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

1733 "rows": available, 

1734 "min_max": (_from, _to), 

1735 } 

1736 }, 

1737 ) 

1738 

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

1740 

1741 rows._query_builder = builder 

1742 return rows 

1743 

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

1745 """ 

1746 Get the first row matching the currently built query. 

1747 

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

1749 """ 

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

1751 return self.model.from_row(row) 

1752 else: 

1753 return None 

1754 

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

1756 """ 

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

1758 

1759 Basically unwraps Optional type. 

1760 """ 

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

1762 return inst 

1763 else: 

1764 raise ValueError("Nothing found!") 

1765 

1766 

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

1768 """ 

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

1770 """ 

1771 

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

1773 name = "" 

1774 _db: Optional[pydal.DAL] = None 

1775 _rname: Optional[str] = None 

1776 _table: Optional[Table] = None 

1777 _field: Optional[Field] = None 

1778 

1779 _type: T_annotation 

1780 kwargs: Any 

1781 

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

1783 """ 

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

1785 """ 

1786 self._type = _type 

1787 self.kwargs = settings 

1788 super().__init__() 

1789 

1790 @typing.overload 

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

1792 """ 

1793 row.field -> (actual data). 

1794 """ 

1795 

1796 @typing.overload 

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

1798 """ 

1799 Table.field -> Field. 

1800 """ 

1801 

1802 def __get__( 

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

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

1805 """ 

1806 Since this class is a Descriptor field, \ 

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

1808 

1809 (this is mostly for mypy/typing) 

1810 """ 

1811 if instance: 

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

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

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

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

1816 else: 

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

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

1819 

1820 def __str__(self) -> str: 

1821 """ 

1822 String representation of a Typed Field. 

1823 

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

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

1826 """ 

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

1828 

1829 def __repr__(self) -> str: 

1830 """ 

1831 More detailed string representation of a Typed Field. 

1832 

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

1834 """ 

1835 s = self.__str__() 

1836 

1837 if "type" in self.kwargs: 

1838 # manual type in kwargs supplied 

1839 t = self.kwargs["type"] 

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

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

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

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

1844 # list[str] -> 'str' 

1845 t = t_args[0].__name__ 

1846 else: # pragma: no cover 

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

1848 t = self._type 

1849 

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

1851 

1852 kw = self.kwargs.copy() 

1853 kw.pop("type", None) 

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

1855 

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

1857 """ 

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

1859 """ 

1860 other_kwargs = self.kwargs.copy() 

1861 extra_kwargs.update(other_kwargs) 

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

1863 

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

1865 """ 

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

1867 """ 

1868 self._table = table 

1869 self._field = field 

1870 

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

1872 """ 

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

1874 """ 

1875 with contextlib.suppress(AttributeError): 

1876 return super().__getattribute__(key) 

1877 

1878 # try on actual field: 

1879 return getattr(self._field, key) 

1880 

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

1882 """ 

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

1884 """ 

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

1886 

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

1888 """ 

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

1890 """ 

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

1892 

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

1894 """ 

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

1896 """ 

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

1898 

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

1900 """ 

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

1902 """ 

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

1904 

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

1906 """ 

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

1908 """ 

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

1910 

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

1912 """ 

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

1914 """ 

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

1916 

1917 def __hash__(self) -> int: 

1918 """ 

1919 Shadow Field.__hash__. 

1920 """ 

1921 return hash(self._field) 

1922 

1923 

1924S = typing.TypeVar("S") 

1925 

1926 

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

1928 """ 

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

1930 """ 

1931 

1932 records: dict[int, T_MetaInstance] 

1933 # _rows: Rows 

1934 model: typing.Type[T_MetaInstance] 

1935 metadata: dict[str, Any] 

1936 

1937 # pseudo-properties: actually stored in _rows 

1938 db: TypeDAL 

1939 colnames: list[str] 

1940 fields: list[Field] 

1941 colnames_fields: list[Field] 

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

1943 

1944 def __init__( 

1945 self, 

1946 rows: Rows, 

1947 model: typing.Type[T_MetaInstance], 

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

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

1950 ) -> None: 

1951 """ 

1952 Should not be called manually! 

1953 

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

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

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

1957 `model` is a Typed Table class 

1958 """ 

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

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

1961 self.model = model 

1962 self.metadata = metadata or {} 

1963 

1964 def __len__(self) -> int: 

1965 """ 

1966 Return the count of rows. 

1967 """ 

1968 return len(self.records) 

1969 

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

1971 """ 

1972 Loop through the rows. 

1973 """ 

1974 yield from self.records.values() 

1975 

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

1977 """ 

1978 Check if an id exists in this result set. 

1979 """ 

1980 return ind in self.records 

1981 

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

1983 """ 

1984 Get the row with the lowest id. 

1985 """ 

1986 if not self.records: 

1987 return None 

1988 

1989 return next(iter(self)) 

1990 

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

1992 """ 

1993 Get the row with the highest id. 

1994 """ 

1995 if not self.records: 

1996 return None 

1997 

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

1999 return self[max_id] 

2000 

2001 def find( 

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

2003 ) -> "TypedRows[T_MetaInstance]": 

2004 """ 

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

2006 """ 

2007 if not self.records: 

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

2009 

2010 records = {} 

2011 if limitby: 

2012 _min, _max = limitby 

2013 else: 

2014 _min, _max = 0, len(self) 

2015 count = 0 

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

2017 if f(row): 

2018 if _min <= count: 

2019 records[i] = row 

2020 count += 1 

2021 if count == _max: 

2022 break 

2023 

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

2025 

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

2027 """ 

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

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

2030 """ 

2031 if not self.records: 

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

2033 removed = {} 

2034 to_remove = [] 

2035 for i in self.records: 

2036 row = self[i] 

2037 if f(row): 

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

2039 to_remove.append(i) 

2040 

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

2042 

2043 return self.__class__( 

2044 self, 

2045 self.model, 

2046 removed, 

2047 ) 

2048 

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

2050 """ 

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

2052 """ 

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

2054 

2055 def __str__(self) -> str: 

2056 """ 

2057 Simple string representation. 

2058 """ 

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

2060 

2061 def __repr__(self) -> str: 

2062 """ 

2063 Print a table on repr(). 

2064 """ 

2065 data = self.as_dict() 

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

2067 return mktable(data, headers) 

2068 

2069 def group_by_value( 

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

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

2072 """ 

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

2074 """ 

2075 kwargs["one_result"] = one_result 

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

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

2078 

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

2080 """ 

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

2082 

2083 Example: 

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

2085 """ 

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

2087 

2088 def as_csv(self) -> str: 

2089 """ 

2090 Dump the data to csv. 

2091 """ 

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

2093 

2094 def as_dict( 

2095 self, 

2096 key: str = None, 

2097 compact: bool = False, 

2098 storage_to_dict: bool = False, 

2099 datetime_to_str: bool = False, 

2100 custom_types: list[type] = None, 

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

2102 """ 

2103 Get the data in a dict of dicts. 

2104 """ 

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

2106 # functionality not guaranteed 

2107 return typing.cast( 

2108 dict[int, dict[str, Any]], 

2109 super().as_dict( 

2110 key or "id", 

2111 compact, 

2112 storage_to_dict, 

2113 datetime_to_str, 

2114 custom_types, 

2115 ), 

2116 ) 

2117 

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

2119 

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

2121 """ 

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

2123 """ 

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

2125 

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

2127 """ 

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

2129 """ 

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

2131 

2132 def as_list( 

2133 self, 

2134 compact: bool = False, 

2135 storage_to_dict: bool = False, 

2136 datetime_to_str: bool = False, 

2137 custom_types: list[type] = None, 

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

2139 """ 

2140 Get the data in a list of dicts. 

2141 """ 

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

2143 return typing.cast( 

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

2145 ) 

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

2147 

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

2149 """ 

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

2151 

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

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

2154 """ 

2155 try: 

2156 return self.records[item] 

2157 except KeyError as e: 

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

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

2160 return row 

2161 

2162 raise e 

2163 

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

2165 """ 

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

2167 """ 

2168 return self.records.get(item) 

2169 

2170 def join( 

2171 self, 

2172 field: Field | TypedField[Any], 

2173 name: str = None, 

2174 constraint: Query = None, 

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

2176 orderby: str | Field = None, 

2177 ) -> T_MetaInstance: 

2178 """ 

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

2180 

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

2182 """ 

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

2184 return typing.cast(T_MetaInstance, result) 

2185 

2186 def export_to_csv_file( 

2187 self, 

2188 ofile: typing.TextIO, 

2189 null: str = "<NULL>", 

2190 delimiter: str = ",", 

2191 quotechar: str = '"', 

2192 quoting: int = csv.QUOTE_MINIMAL, 

2193 represent: bool = False, 

2194 colnames: list[str] = None, 

2195 write_colnames: bool = True, 

2196 *args: Any, 

2197 **kwargs: Any, 

2198 ) -> None: 

2199 """ 

2200 Shadow export_to_csv_file from Rows, but with typing. 

2201 

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

2203 """ 

2204 super().export_to_csv_file( 

2205 ofile, 

2206 null, 

2207 *args, 

2208 delimiter=delimiter, 

2209 quotechar=quotechar, 

2210 quoting=quoting, 

2211 represent=represent, 

2212 colnames=colnames or self.colnames, 

2213 write_colnames=write_colnames, 

2214 **kwargs, 

2215 ) 

2216 

2217 @classmethod 

2218 def from_rows( 

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

2220 ) -> "TypedRows[T_MetaInstance]": 

2221 """ 

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

2223 """ 

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

2225 

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

2227 """ 

2228 For json-fix. 

2229 """ 

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

2231 

2232 

2233class Pagination(typing.TypedDict): 

2234 """ 

2235 Pagination key of a paginate dict has these items. 

2236 """ 

2237 

2238 total_items: int 

2239 current_page: int 

2240 per_page: int 

2241 total_pages: int 

2242 has_next_page: bool 

2243 has_prev_page: bool 

2244 next_page: Optional[int] 

2245 prev_page: Optional[int] 

2246 

2247 

2248class PaginateDict(typing.TypedDict): 

2249 """ 

2250 Result of PaginatedRows.as_dict(). 

2251 """ 

2252 

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

2254 pagination: Pagination 

2255 

2256 

2257class PaginatedRows(TypedRows[T_MetaInstance]): 

2258 """ 

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

2260 """ 

2261 

2262 _query_builder: QueryBuilder[T_MetaInstance] 

2263 

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

2265 """ 

2266 Get the next page. 

2267 """ 

2268 data = self.metadata["pagination"] 

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

2270 raise StopIteration("Final Page") 

2271 

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

2273 

2274 def previous(self) -> Self: 

2275 """ 

2276 Get the previous page. 

2277 """ 

2278 data = self.metadata["pagination"] 

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

2280 raise StopIteration("First Page") 

2281 

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

2283 

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

2285 """ 

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

2287 

2288 All arguments are ignored! 

2289 """ 

2290 pagination_data = self.metadata["pagination"] 

2291 

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

2293 has_prev_page = pagination_data["current_page"] > 1 

2294 

2295 return { 

2296 "data": super().as_dict(), 

2297 "pagination": { 

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

2299 "current_page": pagination_data["current_page"], 

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

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

2302 "has_next_page": has_next_page, 

2303 "has_prev_page": has_prev_page, 

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

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

2306 }, 

2307 } 

2308 

2309 

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

2311 """ 

2312 Used to make pydal Set more typed. 

2313 

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

2315 """ 

2316 

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

2318 """ 

2319 Count returns an int. 

2320 """ 

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

2322 return typing.cast(int, result) 

2323 

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

2325 """ 

2326 Select returns a TypedRows of a user defined table. 

2327 

2328 Example: 

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

2330 

2331 for row in result: 

2332 typing.reveal_type(row) # MyTable 

2333 """ 

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

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