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

804 statements  

« prev     ^ index     » next       coverage.py v7.3.2, created at 2023-11-01 12:12 +0100

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 as _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 as_lambda, 

30 extract_type_optional, 

31 filter_out, 

32 instanciate, 

33 is_union, 

34 looks_like, 

35 mktable, 

36 origin_is_subclass, 

37 to_snake, 

38 unwrap_type, 

39) 

40from .types import Expression, Field, PaginateDict, Pagination, Query, _Types 

41 

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

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

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

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

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

47T = typing.TypeVar("T") 

48 

49BASIC_MAPPINGS: dict[T_annotation, str] = { 

50 str: "string", 

51 int: "integer", 

52 bool: "boolean", 

53 bytes: "blob", 

54 float: "double", 

55 object: "json", 

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

57 dt.date: "date", 

58 dt.time: "time", 

59 dt.datetime: "datetime", 

60} 

61 

62 

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

64 """ 

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

66 

67 Deprecated 

68 """ 

69 return ( 

70 isinstance(cls, TypedField) 

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

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

73 ) 

74 

75 

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

77DEFAULT_JOIN_OPTION: JOIN_OPTIONS = "left" 

78 

79# table-ish paramter: 

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

81 

82Condition: typing.TypeAlias = typing.Optional[ 

83 typing.Callable[ 

84 # self, other -> Query 

85 [P_Table, P_Table], 

86 Query | bool, 

87 ] 

88] 

89 

90OnQuery: typing.TypeAlias = typing.Optional[ 

91 typing.Callable[ 

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

93 [P_Table, P_Table], 

94 list[Expression], 

95 ] 

96] 

97 

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

99 

100 

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

102 """ 

103 Define a relationship to another table. 

104 """ 

105 

106 _type: To_Type 

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

108 condition: Condition 

109 on: OnQuery 

110 multiple: bool 

111 join: JOIN_OPTIONS 

112 

113 def __init__( 

114 self, 

115 _type: To_Type, 

116 condition: Condition = None, 

117 join: JOIN_OPTIONS = None, 

118 on: OnQuery = None, 

119 ): 

120 """ 

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

122 """ 

123 if condition and on: 

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

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

126 

127 self._type = _type 

128 self.condition = condition 

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

130 self.on = on 

131 

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

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

134 self.multiple = True 

135 else: 

136 self.table = _type 

137 self.multiple = False 

138 

139 if isinstance(self.table, str): 

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

141 

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

143 """ 

144 Create a copy of the relationship, possibly updated. 

145 """ 

146 return self.__class__( 

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

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

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

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

151 ) 

152 

153 def __repr__(self) -> str: 

154 """ 

155 Representation of the relationship. 

156 """ 

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

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

159 else: 

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

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

162 

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

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

165 

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

167 """ 

168 Get the table this relationship is bound to. 

169 """ 

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

171 if isinstance(table, str): 

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

173 # yay 

174 return mapped 

175 

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

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

178 

179 return table 

180 

181 def get_table_name(self) -> str: 

182 """ 

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

184 """ 

185 if isinstance(self.table, str): 

186 return self.table 

187 

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

189 return str(self.table) 

190 

191 # else: typed table 

192 try: 

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

194 except Exception: # pragma: no cover 

195 table = self.table 

196 

197 return str(table) 

198 

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

200 """ 

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

202 

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

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

205 """ 

206 if not instance: 

207 # relationship queried on class, that's allowed 

208 return self 

209 

210 warnings.warn( 

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

212 ) 

213 if self.multiple: 

214 return [] 

215 else: 

216 return None 

217 

218 

219def relationship( 

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

221) -> Relationship[To_Type]: 

222 """ 

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

224 

225 Example: 

226 class User(TypedTable): 

227 name: str 

228 

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

230 

231 class Post(TypedTable): 

232 title: str 

233 author: User 

234 

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

236 

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

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

239 

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

241 class User(TypedTable): 

242 ... 

243 

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

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

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

247 ]) 

248 

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

250 """ 

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

252 

253 

254def _generate_relationship_condition( 

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

256) -> Condition: 

257 origin = typing.get_origin(field) 

258 # else: generic 

259 

260 if origin == list: 

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

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

263 

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

265 else: 

266 # normal reference 

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

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

269 

270 

271def to_relationship( 

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

273 key: str, 

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

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

276 """ 

277 Used to automatically create relationship instance for reference fields. 

278 

279 Example: 

280 class MyTable(TypedTable): 

281 reference: OtherTable 

282 

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

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

285 

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

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

288 

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

290 """ 

291 if looks_like(field, TypedField): 

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

293 field = args[0] 

294 else: 

295 # weird 

296 return None 

297 

298 field, optional = extract_type_optional(field) 

299 

300 try: 

301 condition = _generate_relationship_condition(cls, key, field) 

302 except Exception as e: # pragma: no cover 

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

304 condition = None 

305 

306 if not condition: # pragma: no cover 

307 # something went wrong, not a valid relationship 

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

309 return None 

310 

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

312 

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

314 

315 

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

317 """ 

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

319 """ 

320 

321 # dal: Table 

322 # def __init__(self, 

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

324 # pool_size=0, 

325 # folder=None, 

326 # db_codec="UTF-8", 

327 # check_reserved=None, 

328 # migrate=True, 

329 # fake_migrate=False, 

330 # migrate_enabled=True, 

331 # fake_migrate_all=False, 

332 # decode_credentials=False, 

333 # driver_args=None, 

334 # adapter_args=None, 

335 # attempts=5, 

336 # auto_import=False, 

337 # bigint_id=False, 

338 # debug=False, 

339 # lazy_tables=False, 

340 # db_uid=None, 

341 # after_connection=None, 

342 # tables=None, 

343 # ignore_field_case=True, 

344 # entity_quoting=True, 

345 # table_hash=None, 

346 # ): 

347 # super().__init__( 

348 # uri, 

349 # pool_size, 

350 # folder, 

351 # db_codec, 

352 # check_reserved, 

353 # migrate, 

354 # fake_migrate, 

355 # migrate_enabled, 

356 # fake_migrate_all, 

357 # decode_credentials, 

358 # driver_args, 

359 # adapter_args, 

360 # attempts, 

361 # auto_import, 

362 # bigint_id, 

363 # debug, 

364 # lazy_tables, 

365 # db_uid, 

366 # after_connection, 

367 # tables, 

368 # ignore_field_case, 

369 # entity_quoting, 

370 # table_hash, 

371 # ) 

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

373 

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

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

376 "notnull": True, 

377 } 

378 

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

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

381 

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

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

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

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

386 

387 # dirty way (with evil eval): 

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

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

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

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

392 

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

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

395 

396 tablename = self.to_snake(cls.__name__) 

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

398 annotations = all_annotations(cls) 

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

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

401 # remove internal stuff: 

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

403 

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

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

406 } 

407 

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

409 

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

411 

412 # ! dont' use full_dict here: 

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

414 

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

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

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

418 

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

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

421 

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

423 # ensure they are all instances and 

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

425 # relationships = { 

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

427 # } 

428 

429 # keys of implicit references (also relationships): 

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

431 

432 # add implicit relationships: 

433 # User; list[User]; TypedField[User]; TypedField[list[User]] 

434 relationships |= { 

435 k: new_relationship 

436 for k in reference_field_keys 

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

438 } 

439 

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

441 

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

443 field = fields[name] 

444 typed_field.bind(field, table) 

445 

446 if issubclass(cls, TypedTable): 

447 cls.__set_internals__( 

448 db=self, 

449 table=table, 

450 # by now, all relationships should be instances! 

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

452 ) 

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

454 else: 

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

456 

457 return cls 

458 

459 @typing.overload 

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

461 """ 

462 Typing Overload for define without a class. 

463 

464 @db.define() 

465 class MyTable(TypedTable): ... 

466 """ 

467 

468 @typing.overload 

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

470 """ 

471 Typing Overload for define with a class. 

472 

473 @db.define 

474 class MyTable(TypedTable): ... 

475 """ 

476 

477 def define( 

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

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

480 """ 

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

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

483 

484 Example: 

485 @db.define 

486 class Person(TypedTable): 

487 ... 

488 

489 class Article(TypedTable): 

490 ... 

491 

492 # at a later time: 

493 db.define(Article) 

494 

495 Returns: 

496 the result of pydal.define_table 

497 """ 

498 

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

500 return self._define(cls) 

501 

502 if maybe_cls: 

503 return wrapper(maybe_cls) 

504 

505 return wrapper 

506 

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

508 # """ 

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

510 # """ 

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

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

513 # cls.drop() 

514 

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

516 # """ 

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

518 # """ 

519 # retries = 0 

520 # if max_retries is None: 

521 # max_retries = len(self.tables) 

522 # 

523 # while self.tables: 

524 # retries += 1 

525 # for table in self.tables: 

526 # self.drop(table) 

527 # 

528 # if retries > max_retries: 

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

530 

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

532 """ 

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

534 

535 Usually, only a query is passed. 

536 

537 Example: 

538 db(query).select() 

539 

540 """ 

541 args = list(_args) 

542 if args: 

543 cls = args[0] 

544 if isinstance(cls, bool): 

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

546 

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

548 # table defined without @db.define decorator! 

549 _cls: typing.Type[TypedTable] = cls 

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

551 

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

553 return typing.cast(TypedSet, _set) 

554 

555 @classmethod 

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

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

558 

559 @classmethod 

560 def _annotation_to_pydal_fieldtype( 

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

562 ) -> Optional[str]: 

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

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

565 

566 if isinstance(ftype, str): 

567 # extract type from string 

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

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

570 ) 

571 

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

573 # basi types 

574 return mapping 

575 elif isinstance(ftype, _Table): 

576 # db.table 

577 return f"reference {ftype._tablename}" 

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

579 # SomeTable 

580 snakename = cls.to_snake(ftype.__name__) 

581 return f"reference {snakename}" 

582 elif isinstance(ftype, TypedField): 

583 # FieldType(type, ...) 

584 return ftype._to_field(mut_kw) 

585 elif origin_is_subclass(ftype, TypedField): 

586 # TypedField[int] 

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

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

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

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

591 _child_type = cls._annotation_to_pydal_fieldtype(_child_type, mut_kw) 

592 return f"list:{_child_type}" 

593 elif is_union(ftype): 

594 # str | int -> UnionType 

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

596 

597 # Optional[type] == type | None 

598 

599 match typing.get_args(ftype): 

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

601 # good union of Nullable 

602 

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

604 mut_kw["notnull"] = False 

605 return cls._annotation_to_pydal_fieldtype(_child_type, mut_kw) 

606 case _: 

607 # two types is not supported by the db! 

608 return None 

609 else: 

610 return None 

611 

612 @classmethod 

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

614 """ 

615 Convert a annotation into a pydal Field. 

616 

617 Args: 

618 fname: name of the property 

619 ftype: annotation of the property 

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

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

622 

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

624 

625 Example: 

626 class MyTable: 

627 fname: ftype 

628 id: int 

629 name: str 

630 reference: Table 

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

632 """ 

633 fname = cls.to_snake(fname) 

634 

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

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

637 else: 

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

639 

640 @staticmethod 

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

642 """ 

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

644 """ 

645 return to_snake(camel) 

646 

647 

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

649 """ 

650 Make mypy happy. 

651 """ 

652 

653 id: int # noqa: A003 

654 

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

656 """ 

657 Tell mypy a Table supports dictionary notation for columns. 

658 """ 

659 

660 

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

662 """ 

663 Make mypy happy. 

664 """ 

665 

666 

667class TableMeta(type): 

668 """ 

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

670 

671 Example: 

672 class MyTable(TypedTable): 

673 some_field: TypedField[int] 

674 

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

676 

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

678 

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

680 

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

682 

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

684 

685 """ 

686 

687 # set up by db.define: 

688 # _db: TypeDAL | None = None 

689 # _table: Table | None = None 

690 _db: TypeDAL | None = None 

691 _table: Table | None = None 

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

693 

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

695 # TypeDAL custom logic: # 

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

697 

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

699 """ 

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

701 """ 

702 self._db = db 

703 self._table = table 

704 self._relationships = relationships 

705 

706 def __getattr__(self, col: str) -> Optional[Field]: 

707 """ 

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

709 

710 Example: 

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

712 

713 """ 

714 if self._table: 

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

716 

717 return None 

718 

719 def _ensure_table_defined(self) -> Table: 

720 if not self._table: 

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

722 return self._table 

723 

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

725 """ 

726 Loop through the columns of this model. 

727 """ 

728 table = self._ensure_table_defined() 

729 yield from iter(table) 

730 

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

732 """ 

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

734 """ 

735 table = self._ensure_table_defined() 

736 return table[item] 

737 

738 def __str__(self) -> str: 

739 """ 

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

741 """ 

742 if self._table: 

743 return str(self._table) 

744 else: 

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

746 

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

748 """ 

749 Create a model instance from a pydal row. 

750 """ 

751 return self(row) 

752 

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

754 """ 

755 Return all rows for this model. 

756 """ 

757 return self.collect() 

758 

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

760 """ 

761 Convert to a json-dumpable dict. 

762 

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

764 todo: can this be optimized? 

765 

766 See Also: 

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

768 """ 

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

770 

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

772 

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

774 """ 

775 Return the registered relationships of the current model. 

776 """ 

777 return self._relationships or {} 

778 

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

780 # TypeDAL Modified Logic # 

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

782 

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

784 """ 

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

786 

787 cls.__table functions as 'self' 

788 

789 Args: 

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

791 

792 Returns: the ID of the new row. 

793 

794 """ 

795 table = self._ensure_table_defined() 

796 

797 result = table.insert(**fields) 

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

799 return self(result) 

800 

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

802 table = self._ensure_table_defined() 

803 

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

805 

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

807 """ 

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

809 """ 

810 table = self._ensure_table_defined() 

811 result = table.bulk_insert(items) 

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

813 

814 def update_or_insert( 

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

816 ) -> T_MetaInstance: 

817 """ 

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

819 

820 Returns the created or updated instance. 

821 """ 

822 table = self._ensure_table_defined() 

823 

824 if query is DEFAULT: 

825 record = table(**values) 

826 elif isinstance(query, dict): 

827 record = table(**query) 

828 else: 

829 record = table(query) 

830 

831 if not record: 

832 return self.insert(**values) 

833 

834 record.update_record(**values) 

835 return self(record) 

836 

837 def validate_and_insert( 

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

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

840 """ 

841 Validate input data and then insert a row. 

842 

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

844 """ 

845 table = self._ensure_table_defined() 

846 result = table.validate_and_insert(**fields) 

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

848 return self(row_id), None 

849 else: 

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

851 

852 def validate_and_update( 

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

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

855 """ 

856 Validate input data and then update max 1 row. 

857 

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

859 """ 

860 table = self._ensure_table_defined() 

861 

862 try: 

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

864 except Exception as e: 

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

866 

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

868 return None, errors 

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

870 return self(row_id), None 

871 else: # pragma: no cover 

872 # update on query without result (shouldnt happen) 

873 return None, None 

874 

875 def validate_and_update_or_insert( 

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

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

878 """ 

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

880 

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

882 """ 

883 table = self._ensure_table_defined() 

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

885 

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

887 return None, errors 

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

889 return self(row_id), None 

890 else: # pragma: no cover 

891 # update on query without result (shouldnt happen) 

892 return None, None 

893 

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

895 """ 

896 See QueryBuilder.select! 

897 """ 

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

899 

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

901 """ 

902 See QueryBuilder.paginate! 

903 """ 

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

905 

906 def chunk( 

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

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

909 """ 

910 See QueryBuilder.chunk! 

911 """ 

912 return QueryBuilder(self).chunk(chunk_size) 

913 

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

915 """ 

916 See QueryBuilder.where! 

917 """ 

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

919 

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

921 """ 

922 See QueryBuilder.count! 

923 """ 

924 return QueryBuilder(self).count() 

925 

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

927 """ 

928 See QueryBuilder.first! 

929 """ 

930 return QueryBuilder(self).first() 

931 

932 def join( 

933 self: typing.Type[T_MetaInstance], 

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

935 method: JOIN_OPTIONS = None, 

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

937 condition: Condition = None, 

938 ) -> "QueryBuilder[T_MetaInstance]": 

939 """ 

940 See QueryBuilder.join! 

941 """ 

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

943 

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

945 """ 

946 See QueryBuilder.collect! 

947 """ 

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

949 

950 @property 

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

952 """ 

953 Select all fields for this table. 

954 """ 

955 table = cls._ensure_table_defined() 

956 

957 return table.ALL 

958 

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

960 # TypeDAL Shadowed Logic # 

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

962 fields: list[str] 

963 

964 # other table methods: 

965 

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

967 """ 

968 Remove the underlying table. 

969 """ 

970 table = self._ensure_table_defined() 

971 table.drop(mode) 

972 

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

974 """ 

975 Add an index on some columns of this table. 

976 """ 

977 table = self._ensure_table_defined() 

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

979 return typing.cast(bool, result) 

980 

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

982 """ 

983 Remove an index from this table. 

984 """ 

985 table = self._ensure_table_defined() 

986 result = table.drop_index(name, if_exists) 

987 return typing.cast(bool, result) 

988 

989 def import_from_csv_file( 

990 self, 

991 csvfile: typing.TextIO, 

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

993 null: Any = "<NULL>", 

994 unique: str = "uuid", 

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

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

997 validate: bool = False, 

998 encoding: str = "utf-8", 

999 delimiter: str = ",", 

1000 quotechar: str = '"', 

1001 quoting: int = csv.QUOTE_MINIMAL, 

1002 restore: bool = False, 

1003 **kwargs: Any, 

1004 ) -> None: 

1005 """ 

1006 Load a csv file into the database. 

1007 """ 

1008 table = self._ensure_table_defined() 

1009 table.import_from_csv_file( 

1010 csvfile, 

1011 id_map=id_map, 

1012 null=null, 

1013 unique=unique, 

1014 id_offset=id_offset, 

1015 transform=transform, 

1016 validate=validate, 

1017 encoding=encoding, 

1018 delimiter=delimiter, 

1019 quotechar=quotechar, 

1020 quoting=quoting, 

1021 restore=restore, 

1022 **kwargs, 

1023 ) 

1024 

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

1026 """ 

1027 Shadow Table.on. 

1028 

1029 Used for joins. 

1030 

1031 See Also: 

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

1033 """ 

1034 table = self._ensure_table_defined() 

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

1036 

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

1038 """ 

1039 Shadow Table.with_alias. 

1040 

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

1042 

1043 See Also: 

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

1045 """ 

1046 table = self._ensure_table_defined() 

1047 return table.with_alias(alias) 

1048 

1049 # @typing.dataclass_transform() 

1050 

1051 

1052class TypedTable(metaclass=TableMeta): 

1053 """ 

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

1055 """ 

1056 

1057 # set up by 'new': 

1058 _row: Row | None = None 

1059 

1060 _with: list[str] 

1061 

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

1063 

1064 def _setup_instance_methods(self) -> None: 

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

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

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

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

1069 

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

1071 

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

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

1074 

1075 def __new__( 

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

1077 ) -> "TypedTable": 

1078 """ 

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

1080 

1081 Examples: 

1082 MyTable(1) 

1083 MyTable(id=1) 

1084 MyTable(MyTable.id == 1) 

1085 """ 

1086 table = cls._ensure_table_defined() 

1087 

1088 if isinstance(row_or_id, TypedTable): 

1089 # existing typed table instance! 

1090 return row_or_id 

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

1092 row = row_or_id 

1093 elif row_or_id is not None: 

1094 row = table(row_or_id, **filters) 

1095 else: 

1096 row = table(**filters) 

1097 

1098 if not row: 

1099 return None # type: ignore 

1100 

1101 inst = super().__new__(cls) 

1102 inst._row = row 

1103 inst.__dict__.update(row) 

1104 inst._setup_instance_methods() 

1105 return inst 

1106 

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

1108 """ 

1109 Allows looping through the columns. 

1110 """ 

1111 row = self._ensure_matching_row() 

1112 yield from iter(row) 

1113 

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

1115 """ 

1116 Allows dictionary notation to get columns. 

1117 """ 

1118 if item in self.__dict__: 

1119 return self.__dict__.get(item) 

1120 

1121 # fallback to lookup in row 

1122 if self._row: 

1123 return self._row[item] 

1124 

1125 # nothing found! 

1126 raise KeyError(item) 

1127 

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

1129 """ 

1130 Allows dot notation to get columns. 

1131 """ 

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

1133 return value 

1134 

1135 raise AttributeError(item) 

1136 

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

1138 """ 

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

1140 """ 

1141 try: 

1142 return self.__getitem__(item) 

1143 except KeyError: 

1144 return default 

1145 

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

1147 """ 

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

1149 """ 

1150 return setattr(self, key, value) 

1151 

1152 def __int__(self) -> int: 

1153 """ 

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

1155 """ 

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

1157 

1158 def __bool__(self) -> bool: 

1159 """ 

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

1161 """ 

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

1163 

1164 def _ensure_matching_row(self) -> Row: 

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

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

1167 return self._row 

1168 

1169 def __repr__(self) -> str: 

1170 """ 

1171 String representation of the model instance. 

1172 """ 

1173 model_name = self.__class__.__name__ 

1174 model_data = {} 

1175 

1176 if self._row: 

1177 model_data = self._row.as_json() 

1178 

1179 details = model_name 

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

1181 

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

1183 details += f" + {relationships}" 

1184 

1185 return f"<{details}>" 

1186 

1187 # serialization 

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

1189 

1190 @classmethod 

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

1192 """ 

1193 Dump the object to a plain dict. 

1194 

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

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

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

1198 """ 

1199 table = cls._ensure_table_defined() 

1200 result = table.as_dict(flat, sanitize) 

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

1202 

1203 @classmethod 

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

1205 """ 

1206 Dump the object to json. 

1207 

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

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

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

1211 """ 

1212 table = cls._ensure_table_defined() 

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

1214 

1215 @classmethod 

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

1217 """ 

1218 Dump the object to xml. 

1219 

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

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

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

1223 """ 

1224 table = cls._ensure_table_defined() 

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

1226 

1227 @classmethod 

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

1229 """ 

1230 Dump the object to yaml. 

1231 

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

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

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

1235 """ 

1236 table = cls._ensure_table_defined() 

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

1238 

1239 def _as_dict( 

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

1241 ) -> dict[str, Any]: 

1242 row = self._ensure_matching_row() 

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

1244 

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

1246 for relationship in _with: 

1247 data = self.get(relationship) 

1248 if isinstance(data, list): 

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

1250 elif data: 

1251 data = data.as_dict() 

1252 

1253 result[relationship] = data 

1254 

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

1256 

1257 def _as_json( 

1258 self, 

1259 mode: str = "object", 

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

1261 colnames: list[str] = None, 

1262 serialize: bool = True, 

1263 **kwargs: Any, 

1264 ) -> str: 

1265 row = self._ensure_matching_row() 

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

1267 

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

1269 row = self._ensure_matching_row() 

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

1271 

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

1273 # row = self._ensure_matching_row() 

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

1275 

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

1277 """ 

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

1279 """ 

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

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

1282 self._row[key] = value 

1283 

1284 super().__setattr__(key, value) 

1285 

1286 @classmethod 

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

1288 """ 

1289 Update one record. 

1290 

1291 Example: 

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

1293 """ 

1294 # todo: update multiple? 

1295 if record := cls(query): 

1296 return record.update_record(**fields) 

1297 else: 

1298 return None 

1299 

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

1301 row = self._ensure_matching_row() 

1302 row.update(**fields) 

1303 self.__dict__.update(**fields) 

1304 return self 

1305 

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

1307 row = self._ensure_matching_row() 

1308 new_row = row.update_record(**fields) 

1309 self.update(**new_row) 

1310 return self 

1311 

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

1313 """ 

1314 Here as a placeholder for _update_record. 

1315 

1316 Will be replaced on instance creation! 

1317 """ 

1318 return self._update_record(**fields) 

1319 

1320 def _delete_record(self) -> int: 

1321 """ 

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

1323 """ 

1324 row = self._ensure_matching_row() 

1325 result = row.delete_record() 

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

1327 self._row = None # just to be sure 

1328 self._setup_instance_methods() 

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

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

1331 return typing.cast(int, result) 

1332 

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

1334 """ 

1335 Here as a placeholder for _delete_record. 

1336 

1337 Will be replaced on instance creation! 

1338 """ 

1339 return self._delete_record() 

1340 

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

1342 

1343 

1344# backwards compat: 

1345TypedRow = TypedTable 

1346 

1347 

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

1349 """ 

1350 Abstration on top of pydal's query system. 

1351 """ 

1352 

1353 model: typing.Type[T_MetaInstance] 

1354 query: Query 

1355 select_args: list[Any] 

1356 select_kwargs: dict[str, Any] 

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

1358 metadata: dict[str, Any] 

1359 

1360 def __init__( 

1361 self, 

1362 model: typing.Type[T_MetaInstance], 

1363 add_query: Optional[Query] = None, 

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

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

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

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

1368 ): 

1369 """ 

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

1371 

1372 Example: 

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

1374 """ 

1375 self.model = model 

1376 table = model._ensure_table_defined() 

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

1378 self.query = add_query or default_query 

1379 self.select_args = select_args or [] 

1380 self.select_kwargs = select_kwargs or {} 

1381 self.relationships = relationships or {} 

1382 self.metadata = metadata or {} 

1383 

1384 def __str__(self) -> str: 

1385 """ 

1386 Simple string representation for the query builder. 

1387 """ 

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

1389 

1390 def __repr__(self) -> str: 

1391 """ 

1392 Advanced string representation for the query builder. 

1393 """ 

1394 return ( 

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

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

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

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

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

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

1401 f">" 

1402 ) 

1403 

1404 def __bool__(self) -> bool: 

1405 """ 

1406 Querybuilder is truthy if it has rows. 

1407 """ 

1408 return self.count() > 0 

1409 

1410 def _extend( 

1411 self, 

1412 add_query: Optional[Query] = None, 

1413 overwrite_query: Optional[Query] = None, 

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

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

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

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

1418 ) -> "QueryBuilder[T_MetaInstance]": 

1419 return QueryBuilder( 

1420 self.model, 

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

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

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

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

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

1426 ) 

1427 

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

1429 """ 

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

1431 

1432 Options: 

1433 paraphrased from the web2py pydal docs, 

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

1435 

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

1437 table.name - sort by name, ascending 

1438 ~table.name - sort by name, descending 

1439 <random> - sort randomly 

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

1441 

1442 groupby, having: together with orderby: 

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

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

1445 

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

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

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

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

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

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

1452 """ 

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

1454 

1455 def where( 

1456 self, 

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

1458 **filters: Any, 

1459 ) -> "QueryBuilder[T_MetaInstance]": 

1460 """ 

1461 Extend the builder's query. 

1462 

1463 Can be used in multiple ways: 

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

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

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

1467 

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

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

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

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

1472 """ 

1473 new_query = self.query 

1474 table = self.model._ensure_table_defined() 

1475 

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

1477 new_query &= table[field] == value 

1478 

1479 subquery: DummyQuery | Query = DummyQuery() 

1480 for query_or_lambda in queries_or_lambdas: 

1481 if isinstance(query_or_lambda, _Query): 

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

1483 elif callable(query_or_lambda): 

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

1485 subquery |= result 

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

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

1488 else: 

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

1490 

1491 if subquery: 

1492 new_query &= subquery 

1493 

1494 return self._extend(overwrite_query=new_query) 

1495 

1496 def join( 

1497 self, 

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

1499 method: JOIN_OPTIONS = None, 

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

1501 condition: Condition = None, 

1502 ) -> "QueryBuilder[T_MetaInstance]": 

1503 """ 

1504 Include relationship fields in the result. 

1505 

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

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

1508 

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

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

1511 """ 

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

1513 

1514 relationships = self.model.get_relationships() 

1515 

1516 if condition and on: 

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

1518 elif condition: 

1519 if len(fields) != 1: 

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

1521 

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

1523 condition = as_lambda(condition) 

1524 

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

1526 elif on: 

1527 if len(fields) != 1: 

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

1529 

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

1531 on = [on] 

1532 

1533 if isinstance(on, list): 

1534 on = as_lambda(on) 

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

1536 

1537 else: 

1538 if fields: 

1539 # join on every relationship 

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

1541 

1542 if method: 

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

1544 

1545 return self._extend(relationships=relationships) 

1546 

1547 def _get_db(self) -> TypeDAL: 

1548 if db := self.model._db: 

1549 return db 

1550 else: # pragma: no cover 

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

1552 

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

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

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

1556 arg = arg._field 

1557 

1558 return arg 

1559 

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

1561 """ 

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

1563 """ 

1564 db = self._get_db() 

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

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

1567 # success! 

1568 return removed_ids 

1569 

1570 return None 

1571 

1572 def _delete(self) -> str: 

1573 db = self._get_db() 

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

1575 

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

1577 """ 

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

1579 """ 

1580 # todo: limit? 

1581 db = self._get_db() 

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

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

1584 # success! 

1585 return updated_ids 

1586 

1587 return None 

1588 

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

1590 db = self._get_db() 

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

1592 

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

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

1595 select_kwargs = self.select_kwargs.copy() 

1596 query = self.query 

1597 model = self.model 

1598 mut_metadata["query"] = query 

1599 # require at least id of main table: 

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

1601 tablename = str(model) 

1602 

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

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

1605 select_args.append(model.id) 

1606 

1607 if self.relationships: 

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

1609 

1610 return query, select_args, select_kwargs 

1611 

1612 def to_sql(self) -> str: 

1613 """ 

1614 Generate the SQL for the built query. 

1615 """ 

1616 db = self._get_db() 

1617 

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

1619 

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

1621 

1622 def _collect(self) -> str: 

1623 """ 

1624 Alias for to_sql, pydal-like syntax. 

1625 """ 

1626 return self.to_sql() 

1627 

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

1629 """ 

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

1631 """ 

1632 if _to is None: 

1633 _to = TypedRows 

1634 

1635 db = self._get_db() 

1636 metadata = self.metadata.copy() 

1637 

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

1639 

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

1641 

1642 if verbose: # pragma: no cover 

1643 print(metadata["sql"]) 

1644 

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

1646 

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

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

1649 metadata["final_kwargs"] = select_kwargs 

1650 

1651 if verbose: # pragma: no cover 

1652 print(rows) 

1653 

1654 if not self.relationships: 

1655 # easy 

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

1657 

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

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

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

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

1662 

1663 def _handle_relationships_pre_select( 

1664 self, 

1665 query: Query, 

1666 select_args: list[Any], 

1667 select_kwargs: dict[str, Any], 

1668 metadata: dict[str, Any], 

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

1670 db = self._get_db() 

1671 model = self.model 

1672 

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

1674 

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

1676 join = [] 

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

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

1679 continue 

1680 

1681 other = relation.get_table(db) 

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

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

1684 

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

1686 # if limitby + relationships: 

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

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

1689 # 3. add joins etc 

1690 

1691 kwargs = {"limitby": limitby} 

1692 

1693 if join: 

1694 kwargs["join"] = join 

1695 

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

1697 query = model.id.belongs(ids) 

1698 metadata["ids"] = ids 

1699 

1700 if join: 

1701 select_kwargs["join"] = join 

1702 

1703 left = [] 

1704 

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

1706 other = relation.get_table(db) 

1707 method: JOIN_OPTIONS = relation.join or DEFAULT_JOIN_OPTION 

1708 

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

1710 pre_alias = str(other) 

1711 

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

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

1714 select_args.append(other.ALL) 

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

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

1717 select_args.append(other.id) 

1718 

1719 if relation.on: 

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

1721 on = relation.on(model, other) 

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

1723 on = [on] 

1724 

1725 left.extend(on) 

1726 elif method == "left": 

1727 # .on not given, generate it: 

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

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

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

1731 else: 

1732 # else: inner join (handled earlier) 

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

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

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

1736 

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

1738 # else: only add other.id if missing 

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

1740 

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

1742 if pre_alias != post_alias: 

1743 # replace .select's with aliased: 

1744 select_fields = select_fields.replace( 

1745 f"{pre_alias}.", 

1746 f"{post_alias}.", 

1747 ) 

1748 

1749 select_args = select_fields.split(", ") 

1750 

1751 select_kwargs["left"] = left 

1752 return query, select_args 

1753 

1754 def _collect_with_relationships( 

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

1756 ) -> "TypedRows[T_MetaInstance]": 

1757 """ 

1758 Transform the raw rows into Typed Table model instances. 

1759 """ 

1760 db = self._get_db() 

1761 main_table = self.model._ensure_table_defined() 

1762 

1763 records = {} 

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

1765 

1766 for row in rows: 

1767 main = row[main_table] 

1768 main_id = main.id 

1769 

1770 if main_id not in records: 

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

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

1773 

1774 # setup up all relationship defaults (once) 

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

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

1777 

1778 # now add other relationship data 

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

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

1781 

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

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

1784 

1785 relation_data = ( 

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

1787 ) 

1788 

1789 if relation_data.id is None: 

1790 # always skip None ids 

1791 continue 

1792 

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

1794 # speed up duplicates 

1795 continue 

1796 else: 

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

1798 

1799 relation_table = relation.get_table(db) 

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

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

1802 

1803 if relation.multiple: 

1804 # create list of T 

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

1806 # should already be set up before! 

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

1808 

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

1810 else: 

1811 # create single T 

1812 records[main_id][column] = instance 

1813 

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

1815 

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

1817 """ 

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

1819 

1820 Basically unwraps Optional type. 

1821 """ 

1822 if result := self.collect(): 

1823 return result 

1824 else: 

1825 raise ValueError("Nothing found!") 

1826 

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

1828 """ 

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

1830 """ 

1831 yield from self.collect() 

1832 

1833 def count(self) -> int: 

1834 """ 

1835 Return the amount of rows matching the current query. 

1836 """ 

1837 db = self._get_db() 

1838 model = self.model 

1839 query = self.query 

1840 

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

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

1843 continue 

1844 

1845 other = relation.get_table(db) 

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

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

1848 

1849 return db(query).count() 

1850 

1851 def __paginate( 

1852 self, 

1853 limit: int, 

1854 page: int = 1, 

1855 ) -> "QueryBuilder[T_MetaInstance]": 

1856 _from = limit * (page - 1) 

1857 _to = limit * page 

1858 

1859 available = self.count() 

1860 

1861 return self._extend( 

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

1863 metadata={ 

1864 "pagination": { 

1865 "limit": limit, 

1866 "current_page": page, 

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

1868 "rows": available, 

1869 "min_max": (_from, _to), 

1870 } 

1871 }, 

1872 ) 

1873 

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

1875 """ 

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

1877 

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

1879 can be loaded with relationship data! 

1880 """ 

1881 builder = self.__paginate(limit, page) 

1882 

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

1884 

1885 rows._query_builder = builder 

1886 return rows 

1887 

1888 def _paginate( 

1889 self, 

1890 limit: int, 

1891 page: int = 1, 

1892 ) -> str: 

1893 builder = self.__paginate(limit, page) 

1894 return builder._collect() 

1895 

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

1897 """ 

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

1899 

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

1901 specified `chunk_size` and yields them as TypedRows. 

1902 

1903 Example: 

1904 ``` 

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

1906 for row in chunk_of_rows: 

1907 # Process each row within the chunk. 

1908 pass 

1909 ``` 

1910 """ 

1911 page = 1 

1912 

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

1914 yield rows 

1915 page += 1 

1916 

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

1918 """ 

1919 Get the first row matching the currently built query. 

1920 

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

1922 """ 

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

1924 return self.model.from_row(row) 

1925 else: 

1926 return None 

1927 

1928 def _first(self) -> str: 

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

1930 

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

1932 """ 

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

1934 

1935 Basically unwraps Optional type. 

1936 """ 

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

1938 return inst 

1939 else: 

1940 raise ValueError("Nothing found!") 

1941 

1942 

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

1944 """ 

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

1946 """ 

1947 

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

1949 name = "" 

1950 _db: Optional[pydal.DAL] = None 

1951 _rname: Optional[str] = None 

1952 _table: Optional[Table] = None 

1953 _field: Optional[Field] = None 

1954 

1955 _type: T_annotation 

1956 kwargs: Any 

1957 

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

1959 """ 

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

1961 """ 

1962 self._type = _type 

1963 self.kwargs = settings 

1964 super().__init__() 

1965 

1966 @typing.overload 

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

1968 """ 

1969 row.field -> (actual data). 

1970 """ 

1971 

1972 @typing.overload 

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

1974 """ 

1975 Table.field -> Field. 

1976 """ 

1977 

1978 def __get__( 

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

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

1981 """ 

1982 Since this class is a Descriptor field, \ 

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

1984 

1985 (this is mostly for mypy/typing) 

1986 """ 

1987 if instance: 

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

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

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

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

1992 else: 

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

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

1995 

1996 def __str__(self) -> str: 

1997 """ 

1998 String representation of a Typed Field. 

1999 

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

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

2002 """ 

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

2004 

2005 def __repr__(self) -> str: 

2006 """ 

2007 More detailed string representation of a Typed Field. 

2008 

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

2010 """ 

2011 s = self.__str__() 

2012 

2013 if "type" in self.kwargs: 

2014 # manual type in kwargs supplied 

2015 t = self.kwargs["type"] 

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

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

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

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

2020 # list[str] -> 'str' 

2021 t = t_args[0].__name__ 

2022 else: # pragma: no cover 

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

2024 t = self._type 

2025 

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

2027 

2028 kw = self.kwargs.copy() 

2029 kw.pop("type", None) 

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

2031 

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

2033 """ 

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

2035 """ 

2036 other_kwargs = self.kwargs.copy() 

2037 extra_kwargs.update(other_kwargs) 

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

2039 

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

2041 """ 

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

2043 """ 

2044 self._table = table 

2045 self._field = field 

2046 

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

2048 """ 

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

2050 """ 

2051 with contextlib.suppress(AttributeError): 

2052 return super().__getattribute__(key) 

2053 

2054 # try on actual field: 

2055 return getattr(self._field, key) 

2056 

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

2058 """ 

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

2060 """ 

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

2062 

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

2064 """ 

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

2066 """ 

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

2068 

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

2070 """ 

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

2072 """ 

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

2074 

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

2076 """ 

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

2078 """ 

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

2080 

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

2082 """ 

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

2084 """ 

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

2086 

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

2088 """ 

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

2090 """ 

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

2092 

2093 def __hash__(self) -> int: 

2094 """ 

2095 Shadow Field.__hash__. 

2096 """ 

2097 return hash(self._field) 

2098 

2099 def __invert__(self) -> Expression: 

2100 """ 

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

2102 """ 

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

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

2105 

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

2107 

2108 

2109S = typing.TypeVar("S") 

2110 

2111 

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

2113 """ 

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

2115 """ 

2116 

2117 records: dict[int, T_MetaInstance] 

2118 # _rows: Rows 

2119 model: typing.Type[T_MetaInstance] 

2120 metadata: dict[str, Any] 

2121 

2122 # pseudo-properties: actually stored in _rows 

2123 db: TypeDAL 

2124 colnames: list[str] 

2125 fields: list[Field] 

2126 colnames_fields: list[Field] 

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

2128 

2129 def __init__( 

2130 self, 

2131 rows: Rows, 

2132 model: typing.Type[T_MetaInstance], 

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

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

2135 ) -> None: 

2136 """ 

2137 Should not be called manually! 

2138 

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

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

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

2142 `model` is a Typed Table class 

2143 """ 

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

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

2146 self.model = model 

2147 self.metadata = metadata or {} 

2148 

2149 def __len__(self) -> int: 

2150 """ 

2151 Return the count of rows. 

2152 """ 

2153 return len(self.records) 

2154 

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

2156 """ 

2157 Loop through the rows. 

2158 """ 

2159 yield from self.records.values() 

2160 

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

2162 """ 

2163 Check if an id exists in this result set. 

2164 """ 

2165 return ind in self.records 

2166 

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

2168 """ 

2169 Get the row with the lowest id. 

2170 """ 

2171 if not self.records: 

2172 return None 

2173 

2174 return next(iter(self)) 

2175 

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

2177 """ 

2178 Get the row with the highest id. 

2179 """ 

2180 if not self.records: 

2181 return None 

2182 

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

2184 return self[max_id] 

2185 

2186 def find( 

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

2188 ) -> "TypedRows[T_MetaInstance]": 

2189 """ 

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

2191 """ 

2192 if not self.records: 

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

2194 

2195 records = {} 

2196 if limitby: 

2197 _min, _max = limitby 

2198 else: 

2199 _min, _max = 0, len(self) 

2200 count = 0 

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

2202 if f(row): 

2203 if _min <= count: 

2204 records[i] = row 

2205 count += 1 

2206 if count == _max: 

2207 break 

2208 

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

2210 

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

2212 """ 

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

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

2215 """ 

2216 if not self.records: 

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

2218 removed = {} 

2219 to_remove = [] 

2220 for i in self.records: 

2221 row = self[i] 

2222 if f(row): 

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

2224 to_remove.append(i) 

2225 

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

2227 

2228 return self.__class__( 

2229 self, 

2230 self.model, 

2231 removed, 

2232 ) 

2233 

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

2235 """ 

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

2237 """ 

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

2239 

2240 def __str__(self) -> str: 

2241 """ 

2242 Simple string representation. 

2243 """ 

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

2245 

2246 def __repr__(self) -> str: 

2247 """ 

2248 Print a table on repr(). 

2249 """ 

2250 data = self.as_dict() 

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

2252 return mktable(data, headers) 

2253 

2254 def group_by_value( 

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

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

2257 """ 

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

2259 """ 

2260 kwargs["one_result"] = one_result 

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

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

2263 

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

2265 """ 

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

2267 

2268 Example: 

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

2270 """ 

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

2272 

2273 def as_csv(self) -> str: 

2274 """ 

2275 Dump the data to csv. 

2276 """ 

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

2278 

2279 def as_dict( 

2280 self, 

2281 key: str = None, 

2282 compact: bool = False, 

2283 storage_to_dict: bool = False, 

2284 datetime_to_str: bool = False, 

2285 custom_types: list[type] = None, 

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

2287 """ 

2288 Get the data in a dict of dicts. 

2289 """ 

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

2291 # functionality not guaranteed 

2292 return typing.cast( 

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

2294 super().as_dict( 

2295 key or "id", 

2296 compact, 

2297 storage_to_dict, 

2298 datetime_to_str, 

2299 custom_types, 

2300 ), 

2301 ) 

2302 

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

2304 

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

2306 """ 

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

2308 """ 

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

2310 

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

2312 """ 

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

2314 """ 

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

2316 

2317 def as_list( 

2318 self, 

2319 compact: bool = False, 

2320 storage_to_dict: bool = False, 

2321 datetime_to_str: bool = False, 

2322 custom_types: list[type] = None, 

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

2324 """ 

2325 Get the data in a list of dicts. 

2326 """ 

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

2328 return typing.cast( 

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

2330 ) 

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

2332 

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

2334 """ 

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

2336 

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

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

2339 """ 

2340 try: 

2341 return self.records[item] 

2342 except KeyError as e: 

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

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

2345 return row 

2346 

2347 raise e 

2348 

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

2350 """ 

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

2352 """ 

2353 return self.records.get(item) 

2354 

2355 def join( 

2356 self, 

2357 field: Field | TypedField[Any], 

2358 name: str = None, 

2359 constraint: Query = None, 

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

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

2362 ) -> T_MetaInstance: 

2363 """ 

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

2365 

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

2367 """ 

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

2369 return typing.cast(T_MetaInstance, result) 

2370 

2371 def export_to_csv_file( 

2372 self, 

2373 ofile: typing.TextIO, 

2374 null: Any = "<NULL>", 

2375 delimiter: str = ",", 

2376 quotechar: str = '"', 

2377 quoting: int = csv.QUOTE_MINIMAL, 

2378 represent: bool = False, 

2379 colnames: list[str] = None, 

2380 write_colnames: bool = True, 

2381 *args: Any, 

2382 **kwargs: Any, 

2383 ) -> None: 

2384 """ 

2385 Shadow export_to_csv_file from Rows, but with typing. 

2386 

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

2388 """ 

2389 super().export_to_csv_file( 

2390 ofile, 

2391 null, 

2392 *args, 

2393 delimiter=delimiter, 

2394 quotechar=quotechar, 

2395 quoting=quoting, 

2396 represent=represent, 

2397 colnames=colnames or self.colnames, 

2398 write_colnames=write_colnames, 

2399 **kwargs, 

2400 ) 

2401 

2402 @classmethod 

2403 def from_rows( 

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

2405 ) -> "TypedRows[T_MetaInstance]": 

2406 """ 

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

2408 """ 

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

2410 

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

2412 """ 

2413 For json-fix. 

2414 """ 

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

2416 

2417 

2418class PaginatedRows(TypedRows[T_MetaInstance]): 

2419 """ 

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

2421 """ 

2422 

2423 _query_builder: QueryBuilder[T_MetaInstance] 

2424 

2425 @property 

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

2427 """ 

2428 Get the underlying data. 

2429 """ 

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

2431 

2432 @property 

2433 def pagination(self) -> Pagination: 

2434 """ 

2435 Get all page info. 

2436 """ 

2437 pagination_data = self.metadata["pagination"] 

2438 

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

2440 has_prev_page = pagination_data["current_page"] > 1 

2441 return { 

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

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

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

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

2446 "has_next_page": has_next_page, 

2447 "has_prev_page": has_prev_page, 

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

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

2450 } 

2451 

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

2453 """ 

2454 Get the next page. 

2455 """ 

2456 data = self.metadata["pagination"] 

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

2458 raise StopIteration("Final Page") 

2459 

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

2461 

2462 def previous(self) -> Self: 

2463 """ 

2464 Get the previous page. 

2465 """ 

2466 data = self.metadata["pagination"] 

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

2468 raise StopIteration("First Page") 

2469 

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

2471 

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

2473 """ 

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

2475 

2476 All arguments are ignored! 

2477 """ 

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

2479 

2480 

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

2482 """ 

2483 Used to make pydal Set more typed. 

2484 

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

2486 """ 

2487 

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

2489 """ 

2490 Count returns an int. 

2491 """ 

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

2493 return typing.cast(int, result) 

2494 

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

2496 """ 

2497 Select returns a TypedRows of a user defined table. 

2498 

2499 Example: 

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

2501 

2502 for row in result: 

2503 typing.reveal_type(row) # MyTable 

2504 """ 

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

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