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

799 statements  

« prev     ^ index     » next       coverage.py v7.3.2, created at 2023-10-26 15:41 +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 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, 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) -> 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 def _ensure_table_defined(self) -> Table: 

718 if not self._table: 

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

720 return self._table 

721 

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

723 """ 

724 Loop through the columns of this model. 

725 """ 

726 table = self._ensure_table_defined() 

727 yield from iter(table) 

728 

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

730 """ 

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

732 """ 

733 table = self._ensure_table_defined() 

734 return table[item] 

735 

736 def __str__(self) -> str: 

737 """ 

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

739 """ 

740 if self._table: 

741 return str(self._table) 

742 else: 

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

744 

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

746 """ 

747 Create a model instance from a pydal row. 

748 """ 

749 return self(row) 

750 

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

752 """ 

753 Return all rows for this model. 

754 """ 

755 return self.collect() 

756 

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

758 """ 

759 Convert to a json-dumpable dict. 

760 

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

762 todo: can this be optimized? 

763 

764 See Also: 

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

766 """ 

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

768 

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

770 

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

772 """ 

773 Return the registered relationships of the current model. 

774 """ 

775 return self._relationships or {} 

776 

777 ########################## 

778 # TypeDAL Modified Logic # 

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

780 

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

782 """ 

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

784 

785 cls.__table functions as 'self' 

786 

787 Args: 

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

789 

790 Returns: the ID of the new row. 

791 

792 """ 

793 table = self._ensure_table_defined() 

794 

795 result = table.insert(**fields) 

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

797 return self(result) 

798 

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

800 table = self._ensure_table_defined() 

801 

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

803 

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

805 """ 

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

807 """ 

808 table = self._ensure_table_defined() 

809 result = table.bulk_insert(items) 

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

811 

812 def update_or_insert( 

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

814 ) -> T_MetaInstance: 

815 """ 

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

817 

818 Returns the created or updated instance. 

819 """ 

820 table = self._ensure_table_defined() 

821 

822 if query is DEFAULT: 

823 record = table(**values) 

824 elif isinstance(query, dict): 

825 record = table(**query) 

826 else: 

827 record = table(query) 

828 

829 if not record: 

830 return self.insert(**values) 

831 

832 record.update_record(**values) 

833 return self(record) 

834 

835 def validate_and_insert( 

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

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

838 """ 

839 Validate input data and then insert a row. 

840 

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

842 """ 

843 table = self._ensure_table_defined() 

844 result = table.validate_and_insert(**fields) 

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

846 return self(row_id), None 

847 else: 

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

849 

850 def validate_and_update( 

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

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

853 """ 

854 Validate input data and then update max 1 row. 

855 

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

857 """ 

858 table = self._ensure_table_defined() 

859 

860 try: 

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

862 except Exception as e: 

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

864 

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

866 return None, errors 

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

868 return self(row_id), None 

869 else: # pragma: no cover 

870 # update on query without result (shouldnt happen) 

871 return None, None 

872 

873 def validate_and_update_or_insert( 

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

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

876 """ 

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

878 

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

880 """ 

881 table = self._ensure_table_defined() 

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

883 

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

885 return None, errors 

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

887 return self(row_id), None 

888 else: # pragma: no cover 

889 # update on query without result (shouldnt happen) 

890 return None, None 

891 

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

893 """ 

894 See QueryBuilder.select! 

895 """ 

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

897 

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

899 """ 

900 See QueryBuilder.paginate! 

901 """ 

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

903 

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

905 """ 

906 See QueryBuilder.where! 

907 """ 

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

909 

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

911 """ 

912 See QueryBuilder.count! 

913 """ 

914 return QueryBuilder(self).count() 

915 

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

917 """ 

918 See QueryBuilder.first! 

919 """ 

920 return QueryBuilder(self).first() 

921 

922 def join( 

923 self: typing.Type[T_MetaInstance], 

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

925 method: JOIN_OPTIONS = None, 

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

927 condition: Condition = None, 

928 ) -> "QueryBuilder[T_MetaInstance]": 

929 """ 

930 See QueryBuilder.join! 

931 """ 

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

933 

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

935 """ 

936 See QueryBuilder.collect! 

937 """ 

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

939 

940 @property 

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

942 """ 

943 Select all fields for this table. 

944 """ 

945 table = cls._ensure_table_defined() 

946 

947 return table.ALL 

948 

949 ########################## 

950 # TypeDAL Shadowed Logic # 

951 ########################## 

952 fields: list[str] 

953 

954 # other table methods: 

955 

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

957 """ 

958 Remove the underlying table. 

959 """ 

960 table = self._ensure_table_defined() 

961 table.drop(mode) 

962 

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

964 """ 

965 Add an index on some columns of this table. 

966 """ 

967 table = self._ensure_table_defined() 

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

969 return typing.cast(bool, result) 

970 

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

972 """ 

973 Remove an index from this table. 

974 """ 

975 table = self._ensure_table_defined() 

976 result = table.drop_index(name, if_exists) 

977 return typing.cast(bool, result) 

978 

979 def import_from_csv_file( 

980 self, 

981 csvfile: typing.TextIO, 

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

983 null: str = "<NULL>", 

984 unique: str = "uuid", 

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

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

987 validate: bool = False, 

988 encoding: str = "utf-8", 

989 delimiter: str = ",", 

990 quotechar: str = '"', 

991 quoting: int = csv.QUOTE_MINIMAL, 

992 restore: bool = False, 

993 **kwargs: Any, 

994 ) -> None: 

995 """ 

996 Load a csv file into the database. 

997 """ 

998 table = self._ensure_table_defined() 

999 table.import_from_csv_file( 

1000 csvfile, 

1001 id_map=id_map, 

1002 null=null, 

1003 unique=unique, 

1004 id_offset=id_offset, 

1005 transform=transform, 

1006 validate=validate, 

1007 encoding=encoding, 

1008 delimiter=delimiter, 

1009 quotechar=quotechar, 

1010 quoting=quoting, 

1011 restore=restore, 

1012 **kwargs, 

1013 ) 

1014 

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

1016 """ 

1017 Shadow Table.on. 

1018 

1019 Used for joins. 

1020 

1021 See Also: 

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

1023 """ 

1024 table = self._ensure_table_defined() 

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

1026 

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

1028 """ 

1029 Shadow Table.with_alias. 

1030 

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

1032 

1033 See Also: 

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

1035 """ 

1036 table = self._ensure_table_defined() 

1037 return table.with_alias(alias) 

1038 

1039 # @typing.dataclass_transform() 

1040 

1041 

1042class TypedTable(metaclass=TableMeta): 

1043 """ 

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

1045 """ 

1046 

1047 # set up by 'new': 

1048 _row: Row | None = None 

1049 

1050 _with: list[str] 

1051 

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

1053 

1054 def _setup_instance_methods(self) -> None: 

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

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

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

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

1059 

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

1061 

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

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

1064 

1065 def __new__( 

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

1067 ) -> "TypedTable": 

1068 """ 

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

1070 

1071 Examples: 

1072 MyTable(1) 

1073 MyTable(id=1) 

1074 MyTable(MyTable.id == 1) 

1075 """ 

1076 table = cls._ensure_table_defined() 

1077 

1078 if isinstance(row_or_id, TypedTable): 

1079 # existing typed table instance! 

1080 return row_or_id 

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

1082 row = row_or_id 

1083 elif row_or_id is not None: 

1084 row = table(row_or_id, **filters) 

1085 else: 

1086 row = table(**filters) 

1087 

1088 if not row: 

1089 return None # type: ignore 

1090 

1091 inst = super().__new__(cls) 

1092 inst._row = row 

1093 inst.__dict__.update(row) 

1094 inst._setup_instance_methods() 

1095 return inst 

1096 

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

1098 """ 

1099 Allows looping through the columns. 

1100 """ 

1101 row = self._ensure_matching_row() 

1102 yield from iter(row) 

1103 

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

1105 """ 

1106 Allows dictionary notation to get columns. 

1107 """ 

1108 if item in self.__dict__: 

1109 return self.__dict__.get(item) 

1110 

1111 # fallback to lookup in row 

1112 if self._row: 

1113 return self._row[item] 

1114 

1115 # nothing found! 

1116 raise KeyError(item) 

1117 

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

1119 """ 

1120 Allows dot notation to get columns. 

1121 """ 

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

1123 return value 

1124 

1125 raise AttributeError(item) 

1126 

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

1128 """ 

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

1130 """ 

1131 try: 

1132 return self.__getitem__(item) 

1133 except KeyError: 

1134 return default 

1135 

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

1137 """ 

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

1139 """ 

1140 return setattr(self, key, value) 

1141 

1142 def __int__(self) -> int: 

1143 """ 

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

1145 """ 

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

1147 

1148 def __bool__(self) -> bool: 

1149 """ 

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

1151 """ 

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

1153 

1154 def _ensure_matching_row(self) -> Row: 

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

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

1157 return self._row 

1158 

1159 def __repr__(self) -> str: 

1160 """ 

1161 String representation of the model instance. 

1162 """ 

1163 model_name = self.__class__.__name__ 

1164 model_data = {} 

1165 

1166 if self._row: 

1167 model_data = self._row.as_json() 

1168 

1169 details = model_name 

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

1171 

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

1173 details += f" + {relationships}" 

1174 

1175 return f"<{details}>" 

1176 

1177 # serialization 

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

1179 

1180 @classmethod 

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

1182 """ 

1183 Dump the object to a plain dict. 

1184 

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

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

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

1188 """ 

1189 table = cls._ensure_table_defined() 

1190 result = table.as_dict(flat, sanitize) 

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

1192 

1193 @classmethod 

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

1195 """ 

1196 Dump the object to json. 

1197 

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

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

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

1201 """ 

1202 table = cls._ensure_table_defined() 

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

1204 

1205 @classmethod 

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

1207 """ 

1208 Dump the object to xml. 

1209 

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

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

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

1213 """ 

1214 table = cls._ensure_table_defined() 

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

1216 

1217 @classmethod 

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

1219 """ 

1220 Dump the object to yaml. 

1221 

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

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

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

1225 """ 

1226 table = cls._ensure_table_defined() 

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

1228 

1229 def _as_dict( 

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

1231 ) -> dict[str, Any]: 

1232 row = self._ensure_matching_row() 

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

1234 

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

1236 for relationship in _with: 

1237 data = self.get(relationship) 

1238 if isinstance(data, list): 

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

1240 elif data: 

1241 data = data.as_dict() 

1242 

1243 result[relationship] = data 

1244 

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

1246 

1247 def _as_json( 

1248 self, 

1249 mode: str = "object", 

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

1251 colnames: list[str] = None, 

1252 serialize: bool = True, 

1253 **kwargs: Any, 

1254 ) -> str: 

1255 row = self._ensure_matching_row() 

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

1257 

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

1259 row = self._ensure_matching_row() 

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

1261 

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

1263 # row = self._ensure_matching_row() 

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

1265 

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

1267 """ 

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

1269 """ 

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

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

1272 self._row[key] = value 

1273 

1274 super().__setattr__(key, value) 

1275 

1276 @classmethod 

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

1278 """ 

1279 Update one record. 

1280 

1281 Example: 

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

1283 """ 

1284 if record := cls(query): 

1285 return record.update_record(**fields) 

1286 else: 

1287 return None 

1288 

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

1290 row = self._ensure_matching_row() 

1291 row.update(**fields) 

1292 self.__dict__.update(**fields) 

1293 return self 

1294 

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

1296 row = self._ensure_matching_row() 

1297 new_row = row.update_record(**fields) 

1298 self.update(**new_row) 

1299 return self 

1300 

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

1302 """ 

1303 Here as a placeholder for _update_record. 

1304 

1305 Will be replaced on instance creation! 

1306 """ 

1307 return self._update_record(**fields) 

1308 

1309 def _delete_record(self) -> int: 

1310 """ 

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

1312 """ 

1313 row = self._ensure_matching_row() 

1314 result = row.delete_record() 

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

1316 self._row = None # just to be sure 

1317 self._setup_instance_methods() 

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

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

1320 return typing.cast(int, result) 

1321 

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

1323 """ 

1324 Here as a placeholder for _delete_record. 

1325 

1326 Will be replaced on instance creation! 

1327 """ 

1328 return self._delete_record() 

1329 

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

1331 

1332 

1333# backwards compat: 

1334TypedRow = TypedTable 

1335 

1336 

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

1338 """ 

1339 Abstration on top of pydal's query system. 

1340 """ 

1341 

1342 model: typing.Type[T_MetaInstance] 

1343 query: Query 

1344 select_args: list[Any] 

1345 select_kwargs: dict[str, Any] 

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

1347 metadata: dict[str, Any] 

1348 

1349 def __init__( 

1350 self, 

1351 model: typing.Type[T_MetaInstance], 

1352 add_query: Optional[Query] = None, 

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

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

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

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

1357 ): 

1358 """ 

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

1360 

1361 Example: 

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

1363 """ 

1364 self.model = model 

1365 table = model._ensure_table_defined() 

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

1367 self.query = add_query or default_query 

1368 self.select_args = select_args or [] 

1369 self.select_kwargs = select_kwargs or {} 

1370 self.relationships = relationships or {} 

1371 self.metadata = metadata or {} 

1372 

1373 def _extend( 

1374 self, 

1375 add_query: Optional[Query] = None, 

1376 overwrite_query: Optional[Query] = None, 

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

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

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

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

1381 ) -> "QueryBuilder[T_MetaInstance]": 

1382 return QueryBuilder( 

1383 self.model, 

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

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

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

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

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

1389 ) 

1390 

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

1392 """ 

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

1394 

1395 Options: 

1396 paraphrased from the web2py pydal docs, 

1397 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 

1398 

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

1400 table.name - sort by name, ascending 

1401 ~table.name - sort by name, descending 

1402 <random> - sort randomly 

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

1404 

1405 groupby, having: together with orderby: 

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

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

1408 

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

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

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

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

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

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

1415 """ 

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

1417 

1418 def where( 

1419 self, 

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

1421 **filters: Any, 

1422 ) -> "QueryBuilder[T_MetaInstance]": 

1423 """ 

1424 Extend the builder's query. 

1425 

1426 Can be used in multiple ways: 

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

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

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

1430 

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

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

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

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

1435 """ 

1436 new_query = self.query 

1437 table = self.model._ensure_table_defined() 

1438 

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

1440 new_query &= table[field] == value 

1441 

1442 subquery = DummyQuery() 

1443 for query_or_lambda in queries_or_lambdas: 

1444 if isinstance(query_or_lambda, _Query): 

1445 subquery |= query_or_lambda 

1446 elif callable(query_or_lambda): 

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

1448 subquery |= result 

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

1450 subquery |= query_or_lambda != None 

1451 else: 

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

1453 

1454 if subquery: 

1455 new_query &= subquery 

1456 

1457 return self._extend(overwrite_query=new_query) 

1458 

1459 def join( 

1460 self, 

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

1462 method: JOIN_OPTIONS = None, 

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

1464 condition: Condition = None, 

1465 ) -> "QueryBuilder[T_MetaInstance]": 

1466 """ 

1467 Include relationship fields in the result. 

1468 

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

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

1471 

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

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

1474 """ 

1475 relationships = self.model.get_relationships() 

1476 

1477 if condition and on: 

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

1479 elif condition: 

1480 if len(fields) != 1: 

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

1482 

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

1484 condition = as_lambda(condition) 

1485 

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

1487 elif on: 

1488 if len(fields) != 1: 

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

1490 

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

1492 on = [on] 

1493 

1494 if isinstance(on, list): 

1495 on = as_lambda(on) 

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

1497 

1498 else: 

1499 if fields: 

1500 # join on every relationship 

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

1502 

1503 if method: 

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

1505 

1506 return self._extend(relationships=relationships) 

1507 

1508 def _get_db(self) -> TypeDAL: 

1509 if db := self.model._db: 

1510 return db 

1511 else: # pragma: no cover 

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

1513 

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

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

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

1517 arg = arg._field 

1518 

1519 return arg 

1520 

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

1522 """ 

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

1524 """ 

1525 db = self._get_db() 

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

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

1528 # success! 

1529 return removed_ids 

1530 

1531 return None 

1532 

1533 def _delete(self) -> str: 

1534 db = self._get_db() 

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

1536 

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

1538 """ 

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

1540 """ 

1541 db = self._get_db() 

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

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

1544 # success! 

1545 return updated_ids 

1546 

1547 return None 

1548 

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

1550 db = self._get_db() 

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

1552 

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

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

1555 select_kwargs = self.select_kwargs.copy() 

1556 query = self.query 

1557 model = self.model 

1558 mut_metadata["query"] = query 

1559 # require at least id of main table: 

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

1561 tablename = str(model) 

1562 

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

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

1565 select_args.append(model.id) 

1566 

1567 if self.relationships: 

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

1569 

1570 return query, select_args, select_kwargs 

1571 

1572 def to_sql(self) -> str: 

1573 """ 

1574 Generate the SQL for the built query. 

1575 """ 

1576 db = self._get_db() 

1577 

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

1579 

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

1581 

1582 def _collect(self) -> str: 

1583 """ 

1584 Alias for to_sql, pydal-like syntax. 

1585 """ 

1586 return self.to_sql() 

1587 

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

1589 """ 

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

1591 """ 

1592 if _to is None: 

1593 _to = TypedRows 

1594 

1595 db = self._get_db() 

1596 metadata = self.metadata.copy() 

1597 

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

1599 

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

1601 

1602 if verbose: # pragma: no cover 

1603 print(metadata["sql"]) 

1604 

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

1606 

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

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

1609 metadata["final_kwargs"] = select_kwargs 

1610 

1611 if verbose: # pragma: no cover 

1612 print(rows) 

1613 

1614 if not self.relationships: 

1615 # easy 

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

1617 

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

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

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

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

1622 

1623 def _handle_relationships_pre_select( 

1624 self, 

1625 query: Query, 

1626 select_args: list[Any], 

1627 select_kwargs: dict[str, Any], 

1628 metadata: dict[str, Any], 

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

1630 db = self._get_db() 

1631 model = self.model 

1632 

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

1634 

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

1636 join = [] 

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

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

1639 continue 

1640 

1641 other = relation.get_table(db) 

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

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

1644 

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

1646 # if limitby + relationships: 

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

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

1649 # 3. add joins etc 

1650 

1651 kwargs = {"limitby": limitby} 

1652 

1653 if join: 

1654 kwargs["join"] = join 

1655 

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

1657 query = model.id.belongs(ids) 

1658 metadata["ids"] = ids 

1659 

1660 if join: 

1661 select_kwargs["join"] = join 

1662 

1663 left = [] 

1664 

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

1666 other = relation.get_table(db) 

1667 method: JOIN_OPTIONS = relation.join or DEFAULT_JOIN_OPTION 

1668 

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

1670 pre_alias = str(other) 

1671 

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

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

1674 select_args.append(other.ALL) 

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

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

1677 select_args.append(other.id) 

1678 

1679 if relation.on: 

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

1681 on = relation.on(model, other) 

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

1683 on = [on] 

1684 

1685 left.extend(on) 

1686 elif method == "left": 

1687 # .on not given, generate it: 

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

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

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

1691 else: 

1692 # else: inner join (handled earlier) 

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

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

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

1696 

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

1698 # else: only add other.id if missing 

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

1700 

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

1702 if pre_alias != post_alias: 

1703 # replace .select's with aliased: 

1704 select_fields = select_fields.replace( 

1705 f"{pre_alias}.", 

1706 f"{post_alias}.", 

1707 ) 

1708 

1709 select_args = select_fields.split(", ") 

1710 

1711 select_kwargs["left"] = left 

1712 return query, select_args 

1713 

1714 def _collect_with_relationships( 

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

1716 ) -> "TypedRows[T_MetaInstance]": 

1717 """ 

1718 Transform the raw rows into Typed Table model instances. 

1719 """ 

1720 db = self._get_db() 

1721 main_table = self.model._ensure_table_defined() 

1722 

1723 records = {} 

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

1725 

1726 for row in rows: 

1727 main = row[main_table] 

1728 main_id = main.id 

1729 

1730 if main_id not in records: 

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

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

1733 

1734 # setup up all relationship defaults (once) 

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

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

1737 

1738 # now add other relationship data 

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

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

1741 

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

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

1744 

1745 relation_data = ( 

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

1747 ) 

1748 

1749 if relation_data.id is None: 

1750 # always skip None ids 

1751 continue 

1752 

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

1754 # speed up duplicates 

1755 continue 

1756 else: 

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

1758 

1759 relation_table = relation.get_table(db) 

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

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

1762 

1763 if relation.multiple: 

1764 # create list of T 

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

1766 # should already be set up before! 

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

1768 

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

1770 else: 

1771 # create single T 

1772 records[main_id][column] = instance 

1773 

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

1775 

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

1777 """ 

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

1779 

1780 Basically unwraps Optional type. 

1781 """ 

1782 if result := self.collect(): 

1783 return result 

1784 else: 

1785 raise ValueError("Nothing found!") 

1786 

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

1788 """ 

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

1790 """ 

1791 yield from self.collect() 

1792 

1793 def count(self) -> int: 

1794 """ 

1795 Return the amount of rows matching the current query. 

1796 """ 

1797 db = self._get_db() 

1798 model = self.model 

1799 query = self.query 

1800 

1801 query = self._update_query_for_inner(db, model, query) 

1802 

1803 return db(query).count() 

1804 

1805 def _update_query_for_inner(self, db: TypeDAL, model: "typing.Type[T_MetaInstance]", query: Query) -> Query: 

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

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

1808 continue 

1809 

1810 other = relation.get_table(db) 

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

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

1813 return query 

1814 

1815 def __paginate( 

1816 self, 

1817 limit: int, 

1818 page: int = 1, 

1819 ) -> "QueryBuilder[T_MetaInstance]": 

1820 _from = limit * (page - 1) 

1821 _to = limit * page 

1822 

1823 available = self.count() 

1824 

1825 return self._extend( 

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

1827 metadata={ 

1828 "pagination": { 

1829 "limit": limit, 

1830 "current_page": page, 

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

1832 "rows": available, 

1833 "min_max": (_from, _to), 

1834 } 

1835 }, 

1836 ) 

1837 

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

1839 """ 

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

1841 

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

1843 can be loaded with relationship data! 

1844 """ 

1845 builder = self.__paginate(limit, page) 

1846 

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

1848 

1849 rows._query_builder = builder 

1850 return rows 

1851 

1852 def _paginate( 

1853 self, 

1854 limit: int, 

1855 page: int = 1, 

1856 ) -> str: 

1857 builder = self.__paginate(limit, page) 

1858 return builder._collect() 

1859 

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

1861 """ 

1862 Get the first row matching the currently built query. 

1863 

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

1865 """ 

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

1867 return self.model.from_row(row) 

1868 else: 

1869 return None 

1870 

1871 def _first(self) -> str: 

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

1873 

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

1875 """ 

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

1877 

1878 Basically unwraps Optional type. 

1879 """ 

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

1881 return inst 

1882 else: 

1883 raise ValueError("Nothing found!") 

1884 

1885 

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

1887 """ 

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

1889 """ 

1890 

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

1892 name = "" 

1893 _db: Optional[pydal.DAL] = None 

1894 _rname: Optional[str] = None 

1895 _table: Optional[Table] = None 

1896 _field: Optional[Field] = None 

1897 

1898 _type: T_annotation 

1899 kwargs: Any 

1900 

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

1902 """ 

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

1904 """ 

1905 self._type = _type 

1906 self.kwargs = settings 

1907 super().__init__() 

1908 

1909 @typing.overload 

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

1911 """ 

1912 row.field -> (actual data). 

1913 """ 

1914 

1915 @typing.overload 

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

1917 """ 

1918 Table.field -> Field. 

1919 """ 

1920 

1921 def __get__( 

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

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

1924 """ 

1925 Since this class is a Descriptor field, \ 

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

1927 

1928 (this is mostly for mypy/typing) 

1929 """ 

1930 if instance: 

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

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

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

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

1935 else: 

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

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

1938 

1939 def __str__(self) -> str: 

1940 """ 

1941 String representation of a Typed Field. 

1942 

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

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

1945 """ 

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

1947 

1948 def __repr__(self) -> str: 

1949 """ 

1950 More detailed string representation of a Typed Field. 

1951 

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

1953 """ 

1954 s = self.__str__() 

1955 

1956 if "type" in self.kwargs: 

1957 # manual type in kwargs supplied 

1958 t = self.kwargs["type"] 

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

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

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

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

1963 # list[str] -> 'str' 

1964 t = t_args[0].__name__ 

1965 else: # pragma: no cover 

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

1967 t = self._type 

1968 

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

1970 

1971 kw = self.kwargs.copy() 

1972 kw.pop("type", None) 

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

1974 

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

1976 """ 

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

1978 """ 

1979 other_kwargs = self.kwargs.copy() 

1980 extra_kwargs.update(other_kwargs) 

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

1982 

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

1984 """ 

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

1986 """ 

1987 self._table = table 

1988 self._field = field 

1989 

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

1991 """ 

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

1993 """ 

1994 with contextlib.suppress(AttributeError): 

1995 return super().__getattribute__(key) 

1996 

1997 # try on actual field: 

1998 return getattr(self._field, key) 

1999 

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

2001 """ 

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

2003 """ 

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

2005 

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

2007 """ 

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

2009 """ 

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

2011 

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

2013 """ 

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

2015 """ 

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

2017 

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

2019 """ 

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

2021 """ 

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

2023 

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

2025 """ 

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

2027 """ 

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

2029 

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

2031 """ 

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

2033 """ 

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

2035 

2036 def __hash__(self) -> int: 

2037 """ 

2038 Shadow Field.__hash__. 

2039 """ 

2040 return hash(self._field) 

2041 

2042 

2043S = typing.TypeVar("S") 

2044 

2045 

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

2047 """ 

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

2049 """ 

2050 

2051 records: dict[int, T_MetaInstance] 

2052 # _rows: Rows 

2053 model: typing.Type[T_MetaInstance] 

2054 metadata: dict[str, Any] 

2055 

2056 # pseudo-properties: actually stored in _rows 

2057 db: TypeDAL 

2058 colnames: list[str] 

2059 fields: list[Field] 

2060 colnames_fields: list[Field] 

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

2062 

2063 def __init__( 

2064 self, 

2065 rows: Rows, 

2066 model: typing.Type[T_MetaInstance], 

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

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

2069 ) -> None: 

2070 """ 

2071 Should not be called manually! 

2072 

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

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

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

2076 `model` is a Typed Table class 

2077 """ 

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

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

2080 self.model = model 

2081 self.metadata = metadata or {} 

2082 

2083 def __len__(self) -> int: 

2084 """ 

2085 Return the count of rows. 

2086 """ 

2087 return len(self.records) 

2088 

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

2090 """ 

2091 Loop through the rows. 

2092 """ 

2093 yield from self.records.values() 

2094 

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

2096 """ 

2097 Check if an id exists in this result set. 

2098 """ 

2099 return ind in self.records 

2100 

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

2102 """ 

2103 Get the row with the lowest id. 

2104 """ 

2105 if not self.records: 

2106 return None 

2107 

2108 return next(iter(self)) 

2109 

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

2111 """ 

2112 Get the row with the highest id. 

2113 """ 

2114 if not self.records: 

2115 return None 

2116 

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

2118 return self[max_id] 

2119 

2120 def find( 

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

2122 ) -> "TypedRows[T_MetaInstance]": 

2123 """ 

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

2125 """ 

2126 if not self.records: 

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

2128 

2129 records = {} 

2130 if limitby: 

2131 _min, _max = limitby 

2132 else: 

2133 _min, _max = 0, len(self) 

2134 count = 0 

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

2136 if f(row): 

2137 if _min <= count: 

2138 records[i] = row 

2139 count += 1 

2140 if count == _max: 

2141 break 

2142 

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

2144 

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

2146 """ 

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

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

2149 """ 

2150 if not self.records: 

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

2152 removed = {} 

2153 to_remove = [] 

2154 for i in self.records: 

2155 row = self[i] 

2156 if f(row): 

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

2158 to_remove.append(i) 

2159 

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

2161 

2162 return self.__class__( 

2163 self, 

2164 self.model, 

2165 removed, 

2166 ) 

2167 

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

2169 """ 

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

2171 """ 

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

2173 

2174 def __str__(self) -> str: 

2175 """ 

2176 Simple string representation. 

2177 """ 

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

2179 

2180 def __repr__(self) -> str: 

2181 """ 

2182 Print a table on repr(). 

2183 """ 

2184 data = self.as_dict() 

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

2186 return mktable(data, headers) 

2187 

2188 def group_by_value( 

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

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

2191 """ 

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

2193 """ 

2194 kwargs["one_result"] = one_result 

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

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

2197 

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

2199 """ 

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

2201 

2202 Example: 

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

2204 """ 

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

2206 

2207 def as_csv(self) -> str: 

2208 """ 

2209 Dump the data to csv. 

2210 """ 

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

2212 

2213 def as_dict( 

2214 self, 

2215 key: str = None, 

2216 compact: bool = False, 

2217 storage_to_dict: bool = False, 

2218 datetime_to_str: bool = False, 

2219 custom_types: list[type] = None, 

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

2221 """ 

2222 Get the data in a dict of dicts. 

2223 """ 

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

2225 # functionality not guaranteed 

2226 return typing.cast( 

2227 dict[int, dict[str, Any]], 

2228 super().as_dict( 

2229 key or "id", 

2230 compact, 

2231 storage_to_dict, 

2232 datetime_to_str, 

2233 custom_types, 

2234 ), 

2235 ) 

2236 

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

2238 

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

2240 """ 

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

2242 """ 

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

2244 

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

2246 """ 

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

2248 """ 

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

2250 

2251 def as_list( 

2252 self, 

2253 compact: bool = False, 

2254 storage_to_dict: bool = False, 

2255 datetime_to_str: bool = False, 

2256 custom_types: list[type] = None, 

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

2258 """ 

2259 Get the data in a list of dicts. 

2260 """ 

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

2262 return typing.cast( 

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

2264 ) 

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

2266 

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

2268 """ 

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

2270 

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

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

2273 """ 

2274 try: 

2275 return self.records[item] 

2276 except KeyError as e: 

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

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

2279 return row 

2280 

2281 raise e 

2282 

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

2284 """ 

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

2286 """ 

2287 return self.records.get(item) 

2288 

2289 def join( 

2290 self, 

2291 field: Field | TypedField[Any], 

2292 name: str = None, 

2293 constraint: Query = None, 

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

2295 orderby: str | Field = None, 

2296 ) -> T_MetaInstance: 

2297 """ 

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

2299 

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

2301 """ 

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

2303 return typing.cast(T_MetaInstance, result) 

2304 

2305 def export_to_csv_file( 

2306 self, 

2307 ofile: typing.TextIO, 

2308 null: str = "<NULL>", 

2309 delimiter: str = ",", 

2310 quotechar: str = '"', 

2311 quoting: int = csv.QUOTE_MINIMAL, 

2312 represent: bool = False, 

2313 colnames: list[str] = None, 

2314 write_colnames: bool = True, 

2315 *args: Any, 

2316 **kwargs: Any, 

2317 ) -> None: 

2318 """ 

2319 Shadow export_to_csv_file from Rows, but with typing. 

2320 

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

2322 """ 

2323 super().export_to_csv_file( 

2324 ofile, 

2325 null, 

2326 *args, 

2327 delimiter=delimiter, 

2328 quotechar=quotechar, 

2329 quoting=quoting, 

2330 represent=represent, 

2331 colnames=colnames or self.colnames, 

2332 write_colnames=write_colnames, 

2333 **kwargs, 

2334 ) 

2335 

2336 @classmethod 

2337 def from_rows( 

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

2339 ) -> "TypedRows[T_MetaInstance]": 

2340 """ 

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

2342 """ 

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

2344 

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

2346 """ 

2347 For json-fix. 

2348 """ 

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

2350 

2351 

2352class Pagination(typing.TypedDict): 

2353 """ 

2354 Pagination key of a paginate dict has these items. 

2355 """ 

2356 

2357 total_items: int 

2358 current_page: int 

2359 per_page: int 

2360 total_pages: int 

2361 has_next_page: bool 

2362 has_prev_page: bool 

2363 next_page: Optional[int] 

2364 prev_page: Optional[int] 

2365 

2366 

2367class PaginateDict(typing.TypedDict): 

2368 """ 

2369 Result of PaginatedRows.as_dict(). 

2370 """ 

2371 

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

2373 pagination: Pagination 

2374 

2375 

2376class PaginatedRows(TypedRows[T_MetaInstance]): 

2377 """ 

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

2379 """ 

2380 

2381 _query_builder: QueryBuilder[T_MetaInstance] 

2382 

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

2384 """ 

2385 Get the next page. 

2386 """ 

2387 data = self.metadata["pagination"] 

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

2389 raise StopIteration("Final Page") 

2390 

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

2392 

2393 def previous(self) -> Self: 

2394 """ 

2395 Get the previous page. 

2396 """ 

2397 data = self.metadata["pagination"] 

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

2399 raise StopIteration("First Page") 

2400 

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

2402 

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

2404 """ 

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

2406 

2407 All arguments are ignored! 

2408 """ 

2409 pagination_data = self.metadata["pagination"] 

2410 

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

2412 has_prev_page = pagination_data["current_page"] > 1 

2413 

2414 return { 

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

2416 "pagination": { 

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

2418 "current_page": pagination_data["current_page"], 

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

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

2421 "has_next_page": has_next_page, 

2422 "has_prev_page": has_prev_page, 

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

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

2425 }, 

2426 } 

2427 

2428 

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

2430 """ 

2431 Used to make pydal Set more typed. 

2432 

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

2434 """ 

2435 

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

2437 """ 

2438 Count returns an int. 

2439 """ 

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

2441 return typing.cast(int, result) 

2442 

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

2444 """ 

2445 Select returns a TypedRows of a user defined table. 

2446 

2447 Example: 

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

2449 

2450 for row in result: 

2451 typing.reveal_type(row) # MyTable 

2452 """ 

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

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