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

903 statements  

« prev     ^ index     » next       coverage.py v7.3.2, created at 2023-12-18 16:41 +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 pathlib import Path 

16from typing import Any, Optional 

17 

18import pydal 

19from pydal._globals import DEFAULT 

20from pydal.objects import Field as _Field 

21from pydal.objects import Query as _Query 

22from pydal.objects import Row 

23from pydal.objects import Table as _Table 

24from typing_extensions import Self 

25 

26from .config import TypeDALConfig, load_config 

27from .helpers import ( 

28 DummyQuery, 

29 all_annotations, 

30 all_dict, 

31 as_lambda, 

32 extract_type_optional, 

33 filter_out, 

34 instanciate, 

35 is_union, 

36 looks_like, 

37 mktable, 

38 origin_is_subclass, 

39 to_snake, 

40 unwrap_type, 

41) 

42from .serializers import as_json 

43from .types import ( 

44 AfterDeleteCallable, 

45 AfterInsertCallable, 

46 AfterUpdateCallable, 

47 BeforeDeleteCallable, 

48 BeforeInsertCallable, 

49 BeforeUpdateCallable, 

50 CacheMetadata, 

51 Expression, 

52 Field, 

53 Metadata, 

54 PaginateDict, 

55 Pagination, 

56 Query, 

57 Rows, 

58 Validator, 

59 _Types, 

60) 

61 

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

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

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

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

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

67T = typing.TypeVar("T") 

68 

69BASIC_MAPPINGS: dict[T_annotation, str] = { 

70 str: "string", 

71 int: "integer", 

72 bool: "boolean", 

73 bytes: "blob", 

74 float: "double", 

75 object: "json", 

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

77 dt.date: "date", 

78 dt.time: "time", 

79 dt.datetime: "datetime", 

80} 

81 

82 

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

84 """ 

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

86 

87 Deprecated 

88 """ 

89 return ( 

90 isinstance(cls, TypedField) 

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

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

93 ) 

94 

95 

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

97DEFAULT_JOIN_OPTION: JOIN_OPTIONS = "left" 

98 

99# table-ish paramter: 

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

101 

102Condition: typing.TypeAlias = typing.Optional[ 

103 typing.Callable[ 

104 # self, other -> Query 

105 [P_Table, P_Table], 

106 Query | bool, 

107 ] 

108] 

109 

110OnQuery: typing.TypeAlias = typing.Optional[ 

111 typing.Callable[ 

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

113 [P_Table, P_Table], 

114 list[Expression], 

115 ] 

116] 

117 

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

119 

120 

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

122 """ 

123 Define a relationship to another table. 

124 """ 

125 

126 _type: To_Type 

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

128 condition: Condition 

129 on: OnQuery 

130 multiple: bool 

131 join: JOIN_OPTIONS 

132 

133 def __init__( 

134 self, 

135 _type: To_Type, 

136 condition: Condition = None, 

137 join: JOIN_OPTIONS = None, 

138 on: OnQuery = None, 

139 ): 

140 """ 

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

142 """ 

143 if condition and on: 

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

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

146 

147 self._type = _type 

148 self.condition = condition 

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

150 self.on = on 

151 

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

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

154 self.multiple = True 

155 else: 

156 self.table = _type 

157 self.multiple = False 

158 

159 if isinstance(self.table, str): 

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

161 

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

163 """ 

164 Create a copy of the relationship, possibly updated. 

165 """ 

166 return self.__class__( 

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

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

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

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

171 ) 

172 

173 def __repr__(self) -> str: 

174 """ 

175 Representation of the relationship. 

176 """ 

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

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

179 else: 

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

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

182 

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

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

185 

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

187 """ 

188 Get the table this relationship is bound to. 

189 """ 

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

191 if isinstance(table, str): 

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

193 # yay 

194 return mapped 

195 

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

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

198 

199 return table 

200 

201 def get_table_name(self) -> str: 

202 """ 

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

204 """ 

205 if isinstance(self.table, str): 

206 return self.table 

207 

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

209 return str(self.table) 

210 

211 # else: typed table 

212 try: 

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

214 except Exception: # pragma: no cover 

215 table = self.table 

216 

217 return str(table) 

218 

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

220 """ 

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

222 

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

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

225 """ 

226 if not instance: 

227 # relationship queried on class, that's allowed 

228 return self 

229 

230 warnings.warn( 

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

232 ) 

233 if self.multiple: 

234 return [] 

235 else: 

236 return None 

237 

238 

239def relationship( 

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

241) -> Relationship[To_Type]: 

242 """ 

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

244 

245 Example: 

246 class User(TypedTable): 

247 name: str 

248 

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

250 

251 class Post(TypedTable): 

252 title: str 

253 author: User 

254 

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

256 

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

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

259 

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

261 class User(TypedTable): 

262 ... 

263 

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

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

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

267 ]) 

268 

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

270 """ 

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

272 

273 

274def _generate_relationship_condition( 

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

276) -> Condition: 

277 origin = typing.get_origin(field) 

278 # else: generic 

279 

280 if origin == list: 

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

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

283 

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

285 else: 

286 # normal reference 

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

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

289 

290 

291def to_relationship( 

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

293 key: str, 

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

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

296 """ 

297 Used to automatically create relationship instance for reference fields. 

298 

299 Example: 

300 class MyTable(TypedTable): 

301 reference: OtherTable 

302 

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

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

305 

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

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

308 

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

310 """ 

311 if looks_like(field, TypedField): 

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

313 field = args[0] 

314 else: 

315 # weird 

316 return None 

317 

318 field, optional = extract_type_optional(field) 

319 

320 try: 

321 condition = _generate_relationship_condition(cls, key, field) 

322 except Exception as e: # pragma: no cover 

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

324 condition = None 

325 

326 if not condition: # pragma: no cover 

327 # something went wrong, not a valid relationship 

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

329 return None 

330 

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

332 

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

334 

335 

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

337 """ 

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

339 """ 

340 

341 _config: TypeDALConfig 

342 

343 def __init__( 

344 self, 

345 uri: Optional[str] = None, # default from config or 'sqlite:memory' 

346 pool_size: int = None, # default 1 if sqlite else 3 

347 folder: Optional[str | Path] = None, # default 'databases' in config 

348 db_codec: str = "UTF-8", 

349 check_reserved: Optional[list[str]] = None, 

350 migrate: Optional[bool] = None, # default True by config 

351 fake_migrate: Optional[bool] = None, # default False by config 

352 migrate_enabled: bool = True, 

353 fake_migrate_all: bool = False, 

354 decode_credentials: bool = False, 

355 driver_args: Optional[dict[str, Any]] = None, 

356 adapter_args: Optional[dict[str, Any]] = None, 

357 attempts: int = 5, 

358 auto_import: bool = False, 

359 bigint_id: bool = False, 

360 debug: bool = False, 

361 lazy_tables: bool = False, 

362 db_uid: Optional[str] = None, 

363 after_connection: typing.Callable[..., Any] = None, 

364 tables: Optional[list[str]] = None, 

365 ignore_field_case: bool = True, 

366 entity_quoting: bool = True, 

367 table_hash: Optional[str] = None, 

368 enable_typedal_caching: bool = None, 

369 use_pyproject: bool | str = True, 

370 use_env: bool | str = True, 

371 ) -> None: 

372 """ 

373 Adds some internal tables after calling pydal's default init. 

374 

375 Set enable_typedal_caching to False to disable this behavior. 

376 """ 

377 config = load_config(_use_pyproject=use_pyproject, _use_env=use_env) 

378 config.update( 

379 database=uri, 

380 dialect=uri.split(":")[0] if uri and ":" in uri else None, 

381 folder=folder, 

382 migrate=migrate, 

383 fake_migrate=fake_migrate, 

384 caching=enable_typedal_caching, 

385 pool_size=pool_size, 

386 ) 

387 

388 self._config = config 

389 

390 if config.folder: 

391 Path(config.folder).mkdir(exist_ok=True) 

392 

393 super().__init__( 

394 config.database, 

395 config.pool_size, 

396 config.folder, 

397 db_codec, 

398 check_reserved, 

399 config.migrate, 

400 config.fake_migrate, 

401 migrate_enabled, 

402 fake_migrate_all, 

403 decode_credentials, 

404 driver_args, 

405 adapter_args, 

406 attempts, 

407 auto_import, 

408 bigint_id, 

409 debug, 

410 lazy_tables, 

411 db_uid, 

412 after_connection, 

413 tables, 

414 ignore_field_case, 

415 entity_quoting, 

416 table_hash, 

417 ) 

418 

419 if config.caching: 

420 self.try_define(_TypedalCache) 

421 self.try_define(_TypedalCacheDependency) 

422 

423 def try_define(self, model: typing.Type[T], verbose: bool = False) -> typing.Type[T]: 

424 """ 

425 Try to define a model with migrate or fall back to fake migrate. 

426 """ 

427 try: 

428 return self.define(model, migrate=True) 

429 except Exception as e: 

430 # clean up: 

431 self.rollback() 

432 if (tablename := self.to_snake(model.__name__)) and tablename in dir(self): 

433 delattr(self, tablename) 

434 

435 if verbose: 

436 warnings.warn(f"{model} could not be migrated, try faking", source=e, category=RuntimeWarning) 

437 

438 # try again: 

439 return self.define(model, migrate=True, fake_migrate=True, redefine=True) 

440 

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

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

443 "notnull": True, 

444 } 

445 

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

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

448 

449 def _define(self, cls: typing.Type[T], **kwargs: Any) -> typing.Type[T]: 

450 # todo: new relationship item added should also invalidate (previously unrelated) cache result 

451 

452 # todo: option to enable/disable cache dependency behavior: 

453 # - don't set _before_update and _before_delete 

454 # - don't add TypedalCacheDependency entry 

455 # - don't invalidate other item on new row of this type 

456 

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

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

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

460 

461 # dirty way (with evil eval): 

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

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

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

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

466 

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

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

469 

470 tablename = self.to_snake(cls.__name__) 

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

472 annotations = all_annotations(cls) 

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

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

475 # remove internal stuff: 

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

477 

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

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

480 } 

481 

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

483 

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

485 

486 # ! dont' use full_dict here: 

487 other_kwargs = kwargs | { 

488 k: v for k, v in cls.__dict__.items() if k not in annotations and not k.startswith("_") 

489 } # other_kwargs was previously used to pass kwargs to typedal, but use @define(**kwargs) for that. 

490 # now it's only used to extract relationships from the object. 

491 # other properties of the class (incl methods) should not be touched 

492 

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

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

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

496 

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

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

499 

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

501 # ensure they are all instances and 

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

503 # relationships = { 

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

505 # } 

506 

507 # keys of implicit references (also relationships): 

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

509 

510 # add implicit relationships: 

511 # User; list[User]; TypedField[User]; TypedField[list[User]] 

512 relationships |= { 

513 k: new_relationship 

514 for k in reference_field_keys 

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

516 } 

517 

518 cache_dependency = kwargs.pop("cache_dependency", True) 

519 

520 table: Table = self.define_table(tablename, *fields.values(), **kwargs) 

521 

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

523 field = fields[name] 

524 typed_field.bind(field, table) 

525 

526 if issubclass(cls, TypedTable): 

527 cls.__set_internals__( 

528 db=self, 

529 table=table, 

530 # by now, all relationships should be instances! 

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

532 ) 

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

534 cls.__on_define__(self) 

535 else: 

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

537 

538 if not tablename.startswith("typedal_") and cache_dependency: 

539 table._before_update.append(lambda s, _: _remove_cache(s, tablename)) 

540 table._before_delete.append(lambda s: _remove_cache(s, tablename)) 

541 

542 return cls 

543 

544 @typing.overload 

545 def define(self, maybe_cls: None = None, **kwargs: Any) -> typing.Callable[[typing.Type[T]], typing.Type[T]]: 

546 """ 

547 Typing Overload for define without a class. 

548 

549 @db.define() 

550 class MyTable(TypedTable): ... 

551 """ 

552 

553 @typing.overload 

554 def define(self, maybe_cls: typing.Type[T], **kwargs: Any) -> typing.Type[T]: 

555 """ 

556 Typing Overload for define with a class. 

557 

558 @db.define 

559 class MyTable(TypedTable): ... 

560 """ 

561 

562 def define( 

563 self, maybe_cls: typing.Type[T] | None = None, **kwargs: Any 

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

565 """ 

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

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

568 

569 You can also pass extra arguments to db.define_table. 

570 See http://www.web2py.com/books/default/chapter/29/06/the-database-abstraction-layer#Table-constructor 

571 

572 Example: 

573 @db.define 

574 class Person(TypedTable): 

575 ... 

576 

577 class Article(TypedTable): 

578 ... 

579 

580 # at a later time: 

581 db.define(Article) 

582 

583 Returns: 

584 the result of pydal.define_table 

585 """ 

586 

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

588 return self._define(cls, **kwargs) 

589 

590 if maybe_cls: 

591 return wrapper(maybe_cls) 

592 

593 return wrapper 

594 

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

596 # """ 

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

598 # """ 

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

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

601 # cls.drop() 

602 

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

604 # """ 

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

606 # """ 

607 # retries = 0 

608 # if max_retries is None: 

609 # max_retries = len(self.tables) 

610 # 

611 # while self.tables: 

612 # retries += 1 

613 # for table in self.tables: 

614 # self.drop(table) 

615 # 

616 # if retries > max_retries: 

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

618 

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

620 """ 

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

622 

623 Usually, only a query is passed. 

624 

625 Example: 

626 db(query).select() 

627 

628 """ 

629 args = list(_args) 

630 if args: 

631 cls = args[0] 

632 if isinstance(cls, bool): 

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

634 

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

636 # table defined without @db.define decorator! 

637 _cls: typing.Type[TypedTable] = cls 

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

639 

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

641 return typing.cast(TypedSet, _set) 

642 

643 def __getitem__(self, key: str) -> "Table": 

644 """ 

645 Allows dynamically accessing a table by its name as a string. 

646 

647 Example: 

648 db['users'] -> user 

649 """ 

650 return typing.cast(Table, super().__getitem__(str(key))) 

651 

652 @classmethod 

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

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

655 

656 @classmethod 

657 def _annotation_to_pydal_fieldtype( 

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

659 ) -> Optional[str]: 

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

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

662 

663 if isinstance(ftype, str): 

664 # extract type from string 

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

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

667 ) 

668 

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

670 # basi types 

671 return mapping 

672 elif isinstance(ftype, _Table): 

673 # db.table 

674 return f"reference {ftype._tablename}" 

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

676 # SomeTable 

677 snakename = cls.to_snake(ftype.__name__) 

678 return f"reference {snakename}" 

679 elif isinstance(ftype, TypedField): 

680 # FieldType(type, ...) 

681 return ftype._to_field(mut_kw) 

682 elif origin_is_subclass(ftype, TypedField): 

683 # TypedField[int] 

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

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

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

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

688 _child_type = cls._annotation_to_pydal_fieldtype(_child_type, mut_kw) 

689 return f"list:{_child_type}" 

690 elif is_union(ftype): 

691 # str | int -> UnionType 

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

693 

694 # Optional[type] == type | None 

695 

696 match typing.get_args(ftype): 

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

698 # good union of Nullable 

699 

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

701 mut_kw["notnull"] = False 

702 return cls._annotation_to_pydal_fieldtype(_child_type, mut_kw) 

703 case _: 

704 # two types is not supported by the db! 

705 return None 

706 else: 

707 return None 

708 

709 @classmethod 

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

711 """ 

712 Convert a annotation into a pydal Field. 

713 

714 Args: 

715 fname: name of the property 

716 ftype: annotation of the property 

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

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

719 

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

721 

722 Example: 

723 class MyTable: 

724 fname: ftype 

725 id: int 

726 name: str 

727 reference: Table 

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

729 """ 

730 fname = cls.to_snake(fname) 

731 

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

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

734 else: 

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

736 

737 @staticmethod 

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

739 """ 

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

741 """ 

742 return to_snake(camel) 

743 

744 

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

746 """ 

747 Make mypy happy. 

748 """ 

749 

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

751 

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

753 """ 

754 Tell mypy a Table supports dictionary notation for columns. 

755 """ 

756 

757 

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

759 """ 

760 Make mypy happy. 

761 """ 

762 

763 

764class TableMeta(type): 

765 """ 

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

767 

768 Example: 

769 class MyTable(TypedTable): 

770 some_field: TypedField[int] 

771 

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

773 

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

775 

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

777 

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

779 

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

781 

782 """ 

783 

784 # set up by db.define: 

785 # _db: TypeDAL | None = None 

786 # _table: Table | None = None 

787 _db: TypeDAL | None = None 

788 _table: Table | None = None 

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

790 

791 ######################### 

792 # TypeDAL custom logic: # 

793 ######################### 

794 

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

796 """ 

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

798 """ 

799 self._db = db 

800 self._table = table 

801 self._relationships = relationships 

802 

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

804 """ 

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

806 

807 Example: 

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

809 

810 """ 

811 if self._table: 

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

813 

814 return None 

815 

816 def _ensure_table_defined(self) -> Table: 

817 if not self._table: 

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

819 return self._table 

820 

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

822 """ 

823 Loop through the columns of this model. 

824 """ 

825 table = self._ensure_table_defined() 

826 yield from iter(table) 

827 

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

829 """ 

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

831 """ 

832 table = self._ensure_table_defined() 

833 return table[item] 

834 

835 def __str__(self) -> str: 

836 """ 

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

838 """ 

839 if self._table: 

840 return str(self._table) 

841 else: 

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

843 

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

845 """ 

846 Create a model instance from a pydal row. 

847 """ 

848 return self(row) 

849 

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

851 """ 

852 Return all rows for this model. 

853 """ 

854 return self.collect() 

855 

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

857 """ 

858 Convert to a json-dumpable dict. 

859 

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

861 todo: can this be optimized? 

862 

863 See Also: 

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

865 """ 

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

867 

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

869 

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

871 """ 

872 Return the registered relationships of the current model. 

873 """ 

874 return self._relationships or {} 

875 

876 ########################## 

877 # TypeDAL Modified Logic # 

878 ########################## 

879 

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

881 """ 

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

883 

884 cls.__table functions as 'self' 

885 

886 Args: 

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

888 

889 Returns: the ID of the new row. 

890 

891 """ 

892 table = self._ensure_table_defined() 

893 

894 result = table.insert(**fields) 

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

896 return self(result) 

897 

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

899 table = self._ensure_table_defined() 

900 

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

902 

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

904 """ 

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

906 """ 

907 table = self._ensure_table_defined() 

908 result = table.bulk_insert(items) 

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

910 

911 def update_or_insert( 

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

913 ) -> T_MetaInstance: 

914 """ 

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

916 

917 Returns the created or updated instance. 

918 """ 

919 table = self._ensure_table_defined() 

920 

921 if query is DEFAULT: 

922 record = table(**values) 

923 elif isinstance(query, dict): 

924 record = table(**query) 

925 else: 

926 record = table(query) 

927 

928 if not record: 

929 return self.insert(**values) 

930 

931 record.update_record(**values) 

932 return self(record) 

933 

934 def validate_and_insert( 

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

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

937 """ 

938 Validate input data and then insert a row. 

939 

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

941 """ 

942 table = self._ensure_table_defined() 

943 result = table.validate_and_insert(**fields) 

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

945 return self(row_id), None 

946 else: 

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

948 

949 def validate_and_update( 

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

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

952 """ 

953 Validate input data and then update max 1 row. 

954 

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

956 """ 

957 table = self._ensure_table_defined() 

958 

959 try: 

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

961 except Exception as e: 

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

963 

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

965 return None, errors 

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

967 return self(row_id), None 

968 else: # pragma: no cover 

969 # update on query without result (shouldnt happen) 

970 return None, None 

971 

972 def validate_and_update_or_insert( 

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

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

975 """ 

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

977 

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

979 """ 

980 table = self._ensure_table_defined() 

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

982 

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

984 return None, errors 

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

986 return self(row_id), None 

987 else: # pragma: no cover 

988 # update on query without result (shouldnt happen) 

989 return None, None 

990 

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

992 """ 

993 See QueryBuilder.select! 

994 """ 

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

996 

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

998 """ 

999 See QueryBuilder.paginate! 

1000 """ 

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

1002 

1003 def chunk( 

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

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

1006 """ 

1007 See QueryBuilder.chunk! 

1008 """ 

1009 return QueryBuilder(self).chunk(chunk_size) 

1010 

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

1012 """ 

1013 See QueryBuilder.where! 

1014 """ 

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

1016 

1017 def cache(self: typing.Type[T_MetaInstance], *deps: Any, **kwargs: Any) -> "QueryBuilder[T_MetaInstance]": 

1018 """ 

1019 See QueryBuilder.cache! 

1020 """ 

1021 return QueryBuilder(self).cache(*deps, **kwargs) 

1022 

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

1024 """ 

1025 See QueryBuilder.count! 

1026 """ 

1027 return QueryBuilder(self).count() 

1028 

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

1030 """ 

1031 See QueryBuilder.first! 

1032 """ 

1033 return QueryBuilder(self).first() 

1034 

1035 def join( 

1036 self: typing.Type[T_MetaInstance], 

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

1038 method: JOIN_OPTIONS = None, 

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

1040 condition: Condition = None, 

1041 ) -> "QueryBuilder[T_MetaInstance]": 

1042 """ 

1043 See QueryBuilder.join! 

1044 """ 

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

1046 

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

1048 """ 

1049 See QueryBuilder.collect! 

1050 """ 

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

1052 

1053 @property 

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

1055 """ 

1056 Select all fields for this table. 

1057 """ 

1058 table = cls._ensure_table_defined() 

1059 

1060 return table.ALL 

1061 

1062 ########################## 

1063 # TypeDAL Shadowed Logic # 

1064 ########################## 

1065 fields: list[str] 

1066 

1067 # other table methods: 

1068 

1069 def truncate(self, mode: str = "") -> None: 

1070 """ 

1071 Remove all data and reset index. 

1072 """ 

1073 table = self._ensure_table_defined() 

1074 table.truncate(mode) 

1075 

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

1077 """ 

1078 Remove the underlying table. 

1079 """ 

1080 table = self._ensure_table_defined() 

1081 table.drop(mode) 

1082 

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

1084 """ 

1085 Add an index on some columns of this table. 

1086 """ 

1087 table = self._ensure_table_defined() 

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

1089 return typing.cast(bool, result) 

1090 

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

1092 """ 

1093 Remove an index from this table. 

1094 """ 

1095 table = self._ensure_table_defined() 

1096 result = table.drop_index(name, if_exists) 

1097 return typing.cast(bool, result) 

1098 

1099 def import_from_csv_file( 

1100 self, 

1101 csvfile: typing.TextIO, 

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

1103 null: Any = "<NULL>", 

1104 unique: str = "uuid", 

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

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

1107 validate: bool = False, 

1108 encoding: str = "utf-8", 

1109 delimiter: str = ",", 

1110 quotechar: str = '"', 

1111 quoting: int = csv.QUOTE_MINIMAL, 

1112 restore: bool = False, 

1113 **kwargs: Any, 

1114 ) -> None: 

1115 """ 

1116 Load a csv file into the database. 

1117 """ 

1118 table = self._ensure_table_defined() 

1119 table.import_from_csv_file( 

1120 csvfile, 

1121 id_map=id_map, 

1122 null=null, 

1123 unique=unique, 

1124 id_offset=id_offset, 

1125 transform=transform, 

1126 validate=validate, 

1127 encoding=encoding, 

1128 delimiter=delimiter, 

1129 quotechar=quotechar, 

1130 quoting=quoting, 

1131 restore=restore, 

1132 **kwargs, 

1133 ) 

1134 

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

1136 """ 

1137 Shadow Table.on. 

1138 

1139 Used for joins. 

1140 

1141 See Also: 

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

1143 """ 

1144 table = self._ensure_table_defined() 

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

1146 

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

1148 """ 

1149 Shadow Table.with_alias. 

1150 

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

1152 

1153 See Also: 

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

1155 """ 

1156 table = self._ensure_table_defined() 

1157 return table.with_alias(alias) 

1158 

1159 # @typing.dataclass_transform() 

1160 

1161 

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

1163 """ 

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

1165 """ 

1166 

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

1168 name = "" 

1169 _db: Optional[pydal.DAL] = None 

1170 _rname: Optional[str] = None 

1171 _table: Optional[Table] = None 

1172 _field: Optional[Field] = None 

1173 

1174 _type: T_annotation 

1175 kwargs: Any 

1176 

1177 requires: Validator | typing.Iterable[Validator] 

1178 

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

1180 """ 

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

1182 """ 

1183 self._type = _type 

1184 self.kwargs = settings 

1185 super().__init__() 

1186 

1187 @typing.overload 

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

1189 """ 

1190 row.field -> (actual data). 

1191 """ 

1192 

1193 @typing.overload 

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

1195 """ 

1196 Table.field -> Field. 

1197 """ 

1198 

1199 def __get__( 

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

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

1202 """ 

1203 Since this class is a Descriptor field, \ 

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

1205 

1206 (this is mostly for mypy/typing) 

1207 """ 

1208 if instance: 

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

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

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

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

1213 else: 

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

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

1216 

1217 def __str__(self) -> str: 

1218 """ 

1219 String representation of a Typed Field. 

1220 

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

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

1223 """ 

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

1225 

1226 def __repr__(self) -> str: 

1227 """ 

1228 More detailed string representation of a Typed Field. 

1229 

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

1231 """ 

1232 s = self.__str__() 

1233 

1234 if "type" in self.kwargs: 

1235 # manual type in kwargs supplied 

1236 t = self.kwargs["type"] 

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

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

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

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

1241 # list[str] -> 'str' 

1242 t = t_args[0].__name__ 

1243 else: # pragma: no cover 

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

1245 t = self._type 

1246 

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

1248 

1249 kw = self.kwargs.copy() 

1250 kw.pop("type", None) 

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

1252 

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

1254 """ 

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

1256 """ 

1257 other_kwargs = self.kwargs.copy() 

1258 extra_kwargs.update(other_kwargs) 

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

1260 

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

1262 """ 

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

1264 """ 

1265 self._table = table 

1266 self._field = field 

1267 

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

1269 """ 

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

1271 """ 

1272 with contextlib.suppress(AttributeError): 

1273 return super().__getattribute__(key) 

1274 

1275 # try on actual field: 

1276 return getattr(self._field, key) 

1277 

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

1279 """ 

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

1281 """ 

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

1283 

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

1285 """ 

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

1287 """ 

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

1289 

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

1291 """ 

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

1293 """ 

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

1295 

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

1297 """ 

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

1299 """ 

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

1301 

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

1303 """ 

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

1305 """ 

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

1307 

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

1309 """ 

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

1311 """ 

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

1313 

1314 def __hash__(self) -> int: 

1315 """ 

1316 Shadow Field.__hash__. 

1317 """ 

1318 return hash(self._field) 

1319 

1320 def __invert__(self) -> Expression: 

1321 """ 

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

1323 """ 

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

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

1326 

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

1328 

1329 

1330class TypedTable(metaclass=TableMeta): 

1331 """ 

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

1333 """ 

1334 

1335 # set up by 'new': 

1336 _row: Row | None = None 

1337 

1338 _with: list[str] 

1339 

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

1341 

1342 _before_insert: list[BeforeInsertCallable] 

1343 _after_insert: list[AfterInsertCallable] 

1344 _before_update: list[BeforeUpdateCallable] 

1345 _after_update: list[AfterUpdateCallable] 

1346 _before_delete: list[BeforeDeleteCallable] 

1347 _after_delete: list[AfterDeleteCallable] 

1348 

1349 def _setup_instance_methods(self) -> None: 

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

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

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

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

1354 

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

1356 

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

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

1359 

1360 def __new__( 

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

1362 ) -> "TypedTable": 

1363 """ 

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

1365 

1366 Examples: 

1367 MyTable(1) 

1368 MyTable(id=1) 

1369 MyTable(MyTable.id == 1) 

1370 """ 

1371 table = cls._ensure_table_defined() 

1372 inst = super().__new__(cls) 

1373 

1374 if isinstance(row_or_id, TypedTable): 

1375 # existing typed table instance! 

1376 return row_or_id 

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

1378 row = row_or_id 

1379 elif row_or_id is not None: 

1380 row = table(row_or_id, **filters) 

1381 elif filters: 

1382 row = table(**filters) 

1383 else: 

1384 # dummy object 

1385 return inst 

1386 

1387 if not row: 

1388 return None # type: ignore 

1389 

1390 inst._row = row 

1391 inst.__dict__.update(row) 

1392 inst._setup_instance_methods() 

1393 return inst 

1394 

1395 @classmethod 

1396 def __on_define__(cls, db: TypeDAL) -> None: 

1397 """ 

1398 Method that can be implemented by tables to do an action after db.define is completed. 

1399 

1400 This can be useful if you need to add something like requires=IS_NOT_IN_DB(db, "table.field"), 

1401 where you need a reference to the current database, which may not exist yet when defining the model. 

1402 """ 

1403 

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

1405 """ 

1406 Allows looping through the columns. 

1407 """ 

1408 row = self._ensure_matching_row() 

1409 yield from iter(row) 

1410 

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

1412 """ 

1413 Allows dictionary notation to get columns. 

1414 """ 

1415 if item in self.__dict__: 

1416 return self.__dict__.get(item) 

1417 

1418 # fallback to lookup in row 

1419 if self._row: 

1420 return self._row[item] 

1421 

1422 # nothing found! 

1423 raise KeyError(item) 

1424 

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

1426 """ 

1427 Allows dot notation to get columns. 

1428 """ 

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

1430 return value 

1431 

1432 raise AttributeError(item) 

1433 

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

1435 """ 

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

1437 """ 

1438 try: 

1439 return self.__getitem__(item) 

1440 except KeyError: 

1441 return default 

1442 

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

1444 """ 

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

1446 """ 

1447 return setattr(self, key, value) 

1448 

1449 def __int__(self) -> int: 

1450 """ 

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

1452 """ 

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

1454 

1455 def __bool__(self) -> bool: 

1456 """ 

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

1458 """ 

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

1460 

1461 def _ensure_matching_row(self) -> Row: 

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

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

1464 return self._row 

1465 

1466 def __repr__(self) -> str: 

1467 """ 

1468 String representation of the model instance. 

1469 """ 

1470 model_name = self.__class__.__name__ 

1471 model_data = {} 

1472 

1473 if self._row: 

1474 model_data = self._row.as_json() 

1475 

1476 details = model_name 

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

1478 

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

1480 details += f" + {relationships}" 

1481 

1482 return f"<{details}>" 

1483 

1484 # serialization 

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

1486 

1487 @classmethod 

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

1489 """ 

1490 Dump the object to a plain dict. 

1491 

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

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

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

1495 """ 

1496 table = cls._ensure_table_defined() 

1497 result = table.as_dict(flat, sanitize) 

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

1499 

1500 @classmethod 

1501 def as_json(cls, sanitize: bool = True, indent: Optional[int] = None, **kwargs: Any) -> str: 

1502 """ 

1503 Dump the object to json. 

1504 

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

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

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

1508 """ 

1509 data = cls.as_dict(sanitize=sanitize) 

1510 return as_json.encode(data, indent=indent, **kwargs) 

1511 

1512 @classmethod 

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

1514 """ 

1515 Dump the object to xml. 

1516 

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

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

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

1520 """ 

1521 table = cls._ensure_table_defined() 

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

1523 

1524 @classmethod 

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

1526 """ 

1527 Dump the object to yaml. 

1528 

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

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

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

1532 """ 

1533 table = cls._ensure_table_defined() 

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

1535 

1536 def _as_dict( 

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

1538 ) -> dict[str, Any]: 

1539 row = self._ensure_matching_row() 

1540 

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

1542 

1543 def asdict_method(obj: Any) -> Any: # pragma: no cover 

1544 if hasattr(obj, "_as_dict"): # typedal 

1545 return obj._as_dict() 

1546 elif hasattr(obj, "as_dict"): # pydal 

1547 return obj.as_dict() 

1548 else: # something else?? 

1549 return obj.__dict__ 

1550 

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

1552 for relationship in _with: 

1553 data = self.get(relationship) 

1554 

1555 if isinstance(data, list): 

1556 data = [asdict_method(_) for _ in data] 

1557 elif data: 

1558 data = asdict_method(data) 

1559 

1560 result[relationship] = data 

1561 

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

1563 

1564 def _as_json( 

1565 self, 

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

1567 indent: Optional[int] = None, 

1568 **kwargs: Any, 

1569 ) -> str: 

1570 data = self._as_dict() 

1571 return as_json.encode(data, default=default, indent=indent, **kwargs) 

1572 

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

1574 row = self._ensure_matching_row() 

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

1576 

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

1578 # row = self._ensure_matching_row() 

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

1580 

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

1582 """ 

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

1584 """ 

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

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

1587 self._row[key] = value 

1588 

1589 super().__setattr__(key, value) 

1590 

1591 @classmethod 

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

1593 """ 

1594 Update one record. 

1595 

1596 Example: 

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

1598 """ 

1599 # todo: update multiple? 

1600 if record := cls(query): 

1601 return record.update_record(**fields) 

1602 else: 

1603 return None 

1604 

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

1606 row = self._ensure_matching_row() 

1607 row.update(**fields) 

1608 self.__dict__.update(**fields) 

1609 return self 

1610 

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

1612 row = self._ensure_matching_row() 

1613 new_row = row.update_record(**fields) 

1614 self.update(**new_row) 

1615 return self 

1616 

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

1618 """ 

1619 Here as a placeholder for _update_record. 

1620 

1621 Will be replaced on instance creation! 

1622 """ 

1623 return self._update_record(**fields) 

1624 

1625 def _delete_record(self) -> int: 

1626 """ 

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

1628 """ 

1629 row = self._ensure_matching_row() 

1630 result = row.delete_record() 

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

1632 self._row = None # just to be sure 

1633 self._setup_instance_methods() 

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

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

1636 return typing.cast(int, result) 

1637 

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

1639 """ 

1640 Here as a placeholder for _delete_record. 

1641 

1642 Will be replaced on instance creation! 

1643 """ 

1644 return self._delete_record() 

1645 

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

1647 

1648 # pickling: 

1649 

1650 def __getstate__(self) -> dict[str, Any]: 

1651 """ 

1652 State to save when pickling. 

1653 

1654 Prevents db connection from being pickled. 

1655 Similar to as_dict but without changing the data of the relationships (dill does that recursively) 

1656 """ 

1657 row = self._ensure_matching_row() 

1658 result: dict[str, Any] = row.as_dict() 

1659 

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

1661 result["_with"] = _with 

1662 for relationship in _with: 

1663 data = self.get(relationship) 

1664 

1665 result[relationship] = data 

1666 

1667 result["_row"] = self._row.as_json() if self._row else "" 

1668 return result 

1669 

1670 def __setstate__(self, state: dict[str, Any]) -> None: 

1671 """ 

1672 Used by dill when loading from a bytestring. 

1673 """ 

1674 # as_dict also includes table info, so dump as json to only get the actual row data 

1675 # then create a new (more empty) row object: 

1676 state["_row"] = Row(json.loads(state["_row"])) 

1677 self.__dict__ |= state 

1678 

1679 

1680# backwards compat: 

1681TypedRow = TypedTable 

1682 

1683 

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

1685 """ 

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

1687 """ 

1688 

1689 records: dict[int, T_MetaInstance] 

1690 # _rows: Rows 

1691 model: typing.Type[T_MetaInstance] 

1692 metadata: Metadata 

1693 

1694 # pseudo-properties: actually stored in _rows 

1695 db: TypeDAL 

1696 colnames: list[str] 

1697 fields: list[Field] 

1698 colnames_fields: list[Field] 

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

1700 

1701 def __init__( 

1702 self, 

1703 rows: Rows, 

1704 model: typing.Type[T_MetaInstance], 

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

1706 metadata: Metadata = None, 

1707 ) -> None: 

1708 """ 

1709 Should not be called manually! 

1710 

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

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

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

1714 `model` is a Typed Table class 

1715 """ 

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

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

1718 self.model = model 

1719 self.metadata = metadata or {} 

1720 self.colnames = rows.colnames 

1721 

1722 def __len__(self) -> int: 

1723 """ 

1724 Return the count of rows. 

1725 """ 

1726 return len(self.records) 

1727 

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

1729 """ 

1730 Loop through the rows. 

1731 """ 

1732 yield from self.records.values() 

1733 

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

1735 """ 

1736 Check if an id exists in this result set. 

1737 """ 

1738 return ind in self.records 

1739 

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

1741 """ 

1742 Get the row with the lowest id. 

1743 """ 

1744 if not self.records: 

1745 return None 

1746 

1747 return next(iter(self)) 

1748 

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

1750 """ 

1751 Get the row with the highest id. 

1752 """ 

1753 if not self.records: 

1754 return None 

1755 

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

1757 return self[max_id] 

1758 

1759 def find( 

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

1761 ) -> "TypedRows[T_MetaInstance]": 

1762 """ 

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

1764 """ 

1765 if not self.records: 

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

1767 

1768 records = {} 

1769 if limitby: 

1770 _min, _max = limitby 

1771 else: 

1772 _min, _max = 0, len(self) 

1773 count = 0 

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

1775 if f(row): 

1776 if _min <= count: 

1777 records[i] = row 

1778 count += 1 

1779 if count == _max: 

1780 break 

1781 

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

1783 

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

1785 """ 

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

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

1788 """ 

1789 if not self.records: 

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

1791 removed = {} 

1792 to_remove = [] 

1793 for i in self.records: 

1794 row = self[i] 

1795 if f(row): 

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

1797 to_remove.append(i) 

1798 

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

1800 

1801 return self.__class__( 

1802 self, 

1803 self.model, 

1804 removed, 

1805 ) 

1806 

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

1808 """ 

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

1810 """ 

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

1812 

1813 def __str__(self) -> str: 

1814 """ 

1815 Simple string representation. 

1816 """ 

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

1818 

1819 def __repr__(self) -> str: 

1820 """ 

1821 Print a table on repr(). 

1822 """ 

1823 data = self.as_dict() 

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

1825 return mktable(data, headers) 

1826 

1827 def group_by_value( 

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

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

1830 """ 

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

1832 """ 

1833 kwargs["one_result"] = one_result 

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

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

1836 

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

1838 """ 

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

1840 

1841 Example: 

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

1843 """ 

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

1845 

1846 def as_csv(self) -> str: 

1847 """ 

1848 Dump the data to csv. 

1849 """ 

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

1851 

1852 def as_dict( 

1853 self, 

1854 key: str = None, 

1855 compact: bool = False, 

1856 storage_to_dict: bool = False, 

1857 datetime_to_str: bool = False, 

1858 custom_types: list[type] = None, 

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

1860 """ 

1861 Get the data in a dict of dicts. 

1862 """ 

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

1864 # functionality not guaranteed 

1865 return typing.cast( 

1866 dict[int, dict[str, Any]], 

1867 super().as_dict( 

1868 key or "id", 

1869 compact, 

1870 storage_to_dict, 

1871 datetime_to_str, 

1872 custom_types, 

1873 ), 

1874 ) 

1875 

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

1877 

1878 def as_json(self, default: typing.Callable[[Any], Any] = None, indent: Optional[int] = None, **kwargs: Any) -> str: 

1879 """ 

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

1881 """ 

1882 data = self.as_list() 

1883 

1884 # print('typedrows.as_json') 

1885 # print(data) 

1886 # print('---') 

1887 

1888 return as_json.encode(data, default=default, indent=indent, **kwargs) 

1889 

1890 def json(self, default: typing.Callable[[Any], Any] = None, indent: Optional[int] = None, **kwargs: Any) -> str: 

1891 """ 

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

1893 """ 

1894 return self.as_json(default=default, indent=indent, **kwargs) 

1895 

1896 def as_list( 

1897 self, 

1898 compact: bool = False, 

1899 storage_to_dict: bool = False, 

1900 datetime_to_str: bool = False, 

1901 custom_types: list[type] = None, 

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

1903 """ 

1904 Get the data in a list of dicts. 

1905 """ 

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

1907 return typing.cast( 

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

1909 ) 

1910 

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

1912 

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

1914 """ 

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

1916 

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

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

1919 """ 

1920 try: 

1921 return self.records[item] 

1922 except KeyError as e: 

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

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

1925 return row 

1926 

1927 raise e 

1928 

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

1930 """ 

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

1932 """ 

1933 return self.records.get(item) 

1934 

1935 def update(self, **new_values: Any) -> bool: 

1936 """ 

1937 Update the current rows in the database with new_values. 

1938 """ 

1939 # cast to make mypy understand .id is a TypedField and not an int! 

1940 table = typing.cast(typing.Type[TypedTable], self.model._ensure_table_defined()) 

1941 

1942 ids = set(self.column("id")) 

1943 query = table.id.belongs(ids) 

1944 return bool(self.db(query).update(**new_values)) 

1945 

1946 def delete(self) -> bool: 

1947 """ 

1948 Delete the currently selected rows from the database. 

1949 """ 

1950 # cast to make mypy understand .id is a TypedField and not an int! 

1951 table = typing.cast(typing.Type[TypedTable], self.model._ensure_table_defined()) 

1952 

1953 ids = set(self.column("id")) 

1954 query = table.id.belongs(ids) 

1955 return bool(self.db(query).delete()) 

1956 

1957 def join( 

1958 self, 

1959 field: "Field | TypedField[Any]", 

1960 name: str = None, 

1961 constraint: Query = None, 

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

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

1964 ) -> T_MetaInstance: 

1965 """ 

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

1967 

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

1969 """ 

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

1971 return typing.cast(T_MetaInstance, result) 

1972 

1973 def export_to_csv_file( 

1974 self, 

1975 ofile: typing.TextIO, 

1976 null: Any = "<NULL>", 

1977 delimiter: str = ",", 

1978 quotechar: str = '"', 

1979 quoting: int = csv.QUOTE_MINIMAL, 

1980 represent: bool = False, 

1981 colnames: list[str] = None, 

1982 write_colnames: bool = True, 

1983 *args: Any, 

1984 **kwargs: Any, 

1985 ) -> None: 

1986 """ 

1987 Shadow export_to_csv_file from Rows, but with typing. 

1988 

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

1990 """ 

1991 super().export_to_csv_file( 

1992 ofile, 

1993 null, 

1994 *args, 

1995 delimiter=delimiter, 

1996 quotechar=quotechar, 

1997 quoting=quoting, 

1998 represent=represent, 

1999 colnames=colnames or self.colnames, 

2000 write_colnames=write_colnames, 

2001 **kwargs, 

2002 ) 

2003 

2004 @classmethod 

2005 def from_rows( 

2006 cls, rows: Rows, model: typing.Type[T_MetaInstance], metadata: Metadata = None 

2007 ) -> "TypedRows[T_MetaInstance]": 

2008 """ 

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

2010 """ 

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

2012 

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

2014 """ 

2015 For json-fix. 

2016 """ 

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

2018 

2019 def __getstate__(self) -> dict[str, Any]: 

2020 """ 

2021 Used by dill to dump to bytes (exclude db connection etc). 

2022 """ 

2023 return { 

2024 "metadata": json.dumps(self.metadata, default=str), 

2025 "records": self.records, 

2026 "model": str(self.model._table), 

2027 "colnames": self.colnames, 

2028 } 

2029 

2030 def __setstate__(self, state: dict[str, Any]) -> None: 

2031 """ 

2032 Used by dill when loading from a bytestring. 

2033 """ 

2034 state["metadata"] = json.loads(state["metadata"]) 

2035 self.__dict__.update(state) 

2036 # db etc. set after undill by caching.py 

2037 

2038 

2039from .caching import ( # noqa: E402 

2040 _remove_cache, 

2041 _TypedalCache, 

2042 _TypedalCacheDependency, 

2043 create_and_hash_cache_key, 

2044 get_expire, 

2045 load_from_cache, 

2046 save_to_cache, 

2047) 

2048 

2049 

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

2051 """ 

2052 Abstration on top of pydal's query system. 

2053 """ 

2054 

2055 model: typing.Type[T_MetaInstance] 

2056 query: Query 

2057 select_args: list[Any] 

2058 select_kwargs: dict[str, Any] 

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

2060 metadata: Metadata 

2061 

2062 def __init__( 

2063 self, 

2064 model: typing.Type[T_MetaInstance], 

2065 add_query: Optional[Query] = None, 

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

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

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

2069 metadata: Metadata = None, 

2070 ): 

2071 """ 

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

2073 

2074 Example: 

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

2076 """ 

2077 self.model = model 

2078 table = model._ensure_table_defined() 

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

2080 self.query = add_query or default_query 

2081 self.select_args = select_args or [] 

2082 self.select_kwargs = select_kwargs or {} 

2083 self.relationships = relationships or {} 

2084 self.metadata = metadata or {} 

2085 

2086 def __str__(self) -> str: 

2087 """ 

2088 Simple string representation for the query builder. 

2089 """ 

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

2091 

2092 def __repr__(self) -> str: 

2093 """ 

2094 Advanced string representation for the query builder. 

2095 """ 

2096 return ( 

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

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

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

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

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

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

2103 f">" 

2104 ) 

2105 

2106 def __bool__(self) -> bool: 

2107 """ 

2108 Querybuilder is truthy if it has rows. 

2109 """ 

2110 return self.count() > 0 

2111 

2112 def _extend( 

2113 self, 

2114 add_query: Optional[Query] = None, 

2115 overwrite_query: Optional[Query] = None, 

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

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

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

2119 metadata: Metadata = None, 

2120 ) -> "QueryBuilder[T_MetaInstance]": 

2121 return QueryBuilder( 

2122 self.model, 

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

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

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

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

2127 (self.metadata | (metadata or {})) if metadata else self.metadata, 

2128 ) 

2129 

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

2131 """ 

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

2133 

2134 Options: 

2135 paraphrased from the web2py pydal docs, 

2136 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 

2137 

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

2139 table.name - sort by name, ascending 

2140 ~table.name - sort by name, descending 

2141 <random> - sort randomly 

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

2143 

2144 groupby, having: together with orderby: 

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

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

2147 

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

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

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

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

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

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

2154 """ 

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

2156 

2157 def where( 

2158 self, 

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

2160 **filters: Any, 

2161 ) -> "QueryBuilder[T_MetaInstance]": 

2162 """ 

2163 Extend the builder's query. 

2164 

2165 Can be used in multiple ways: 

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

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

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

2169 

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

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

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

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

2174 """ 

2175 new_query = self.query 

2176 table = self.model._ensure_table_defined() 

2177 

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

2179 new_query &= table[field] == value 

2180 

2181 subquery: DummyQuery | Query = DummyQuery() 

2182 for query_or_lambda in queries_or_lambdas: 

2183 if isinstance(query_or_lambda, _Query): 

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

2185 elif callable(query_or_lambda): 

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

2187 subquery |= result 

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

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

2190 else: 

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

2192 

2193 if subquery: 

2194 new_query &= subquery 

2195 

2196 return self._extend(overwrite_query=new_query) 

2197 

2198 def join( 

2199 self, 

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

2201 method: JOIN_OPTIONS = None, 

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

2203 condition: Condition = None, 

2204 ) -> "QueryBuilder[T_MetaInstance]": 

2205 """ 

2206 Include relationship fields in the result. 

2207 

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

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

2210 

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

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

2213 """ 

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

2215 

2216 relationships = self.model.get_relationships() 

2217 

2218 if condition and on: 

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

2220 elif condition: 

2221 if len(fields) != 1: 

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

2223 

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

2225 condition = as_lambda(condition) 

2226 

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

2228 elif on: 

2229 if len(fields) != 1: 

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

2231 

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

2233 on = [on] 

2234 

2235 if isinstance(on, list): 

2236 on = as_lambda(on) 

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

2238 

2239 else: 

2240 if fields: 

2241 # join on every relationship 

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

2243 

2244 if method: 

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

2246 

2247 return self._extend(relationships=relationships) 

2248 

2249 def cache( 

2250 self, *deps: Any, expires_at: Optional[dt.datetime] = None, ttl: Optional[int | dt.timedelta] = None 

2251 ) -> "QueryBuilder[T_MetaInstance]": 

2252 """ 

2253 Enable caching for this query to load repeated calls from a dill row \ 

2254 instead of executing the sql and collecing matching rows again. 

2255 """ 

2256 existing = self.metadata.get("cache", {}) 

2257 

2258 metadata: Metadata = {} 

2259 

2260 cache_meta = typing.cast( 

2261 CacheMetadata, 

2262 self.metadata.get("cache", {}) 

2263 | { 

2264 "enabled": True, 

2265 "depends_on": existing.get("depends_on", []) + [str(_) for _ in deps], 

2266 "expires_at": get_expire(expires_at=expires_at, ttl=ttl), 

2267 }, 

2268 ) 

2269 

2270 metadata["cache"] = cache_meta 

2271 return self._extend(metadata=metadata) 

2272 

2273 def _get_db(self) -> TypeDAL: 

2274 if db := self.model._db: 

2275 return db 

2276 else: # pragma: no cover 

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

2278 

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

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

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

2282 arg = arg._field 

2283 

2284 return arg 

2285 

2286 def delete(self) -> list[int]: 

2287 """ 

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

2289 """ 

2290 db = self._get_db() 

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

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

2293 # success! 

2294 return removed_ids 

2295 

2296 return [] 

2297 

2298 def _delete(self) -> str: 

2299 db = self._get_db() 

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

2301 

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

2303 """ 

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

2305 """ 

2306 # todo: limit? 

2307 db = self._get_db() 

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

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

2310 # success! 

2311 return updated_ids 

2312 

2313 return [] 

2314 

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

2316 db = self._get_db() 

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

2318 

2319 def _before_query(self, mut_metadata: Metadata, add_id: bool = True) -> tuple[Query, list[Any], dict[str, Any]]: 

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

2321 select_kwargs = self.select_kwargs.copy() 

2322 query = self.query 

2323 model = self.model 

2324 mut_metadata["query"] = query 

2325 # require at least id of main table: 

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

2327 tablename = str(model) 

2328 

2329 if add_id and f"{tablename}.id" not in select_fields: 

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

2331 select_args.append(model.id) 

2332 

2333 if self.relationships: 

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

2335 

2336 return query, select_args, select_kwargs 

2337 

2338 def to_sql(self, add_id: bool = False) -> str: 

2339 """ 

2340 Generate the SQL for the built query. 

2341 """ 

2342 db = self._get_db() 

2343 

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

2345 

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

2347 

2348 def _collect(self) -> str: 

2349 """ 

2350 Alias for to_sql, pydal-like syntax. 

2351 """ 

2352 return self.to_sql() 

2353 

2354 def _collect_cached(self, metadata: Metadata) -> "TypedRows[T_MetaInstance] | None": 

2355 expires_at = metadata["cache"].get("expires_at") 

2356 metadata["cache"] |= { 

2357 # key is partly dependant on cache metadata but not these: 

2358 "key": None, 

2359 "status": None, 

2360 "cached_at": None, 

2361 "expires_at": None, 

2362 } 

2363 

2364 _, key = create_and_hash_cache_key( 

2365 self.model, 

2366 metadata, 

2367 self.query, 

2368 self.select_args, 

2369 self.select_kwargs, 

2370 self.relationships.keys(), 

2371 ) 

2372 

2373 # re-set after creating key: 

2374 metadata["cache"]["expires_at"] = expires_at 

2375 metadata["cache"]["key"] = key 

2376 

2377 return load_from_cache(key, self._get_db()) 

2378 

2379 def collect( 

2380 self, verbose: bool = False, _to: typing.Type["TypedRows[Any]"] = None, add_id: bool = True 

2381 ) -> "TypedRows[T_MetaInstance]": 

2382 """ 

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

2384 """ 

2385 if _to is None: 

2386 _to = TypedRows 

2387 

2388 db = self._get_db() 

2389 metadata = typing.cast(Metadata, self.metadata.copy()) 

2390 

2391 if metadata.get("cache", {}).get("enabled") and (result := self._collect_cached(metadata)): 

2392 return result 

2393 

2394 query, select_args, select_kwargs = self._before_query(metadata, add_id=add_id) 

2395 

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

2397 

2398 if verbose: # pragma: no cover 

2399 print(metadata["sql"]) 

2400 

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

2402 

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

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

2405 metadata["final_kwargs"] = select_kwargs 

2406 

2407 if verbose: # pragma: no cover 

2408 print(rows) 

2409 

2410 if not self.relationships: 

2411 # easy 

2412 typed_rows = _to.from_rows(rows, self.model, metadata=metadata) 

2413 

2414 else: 

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

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

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

2418 typed_rows = self._collect_with_relationships(rows, metadata=metadata, _to=_to) 

2419 

2420 # only saves if requested in metadata: 

2421 return save_to_cache(typed_rows, rows) 

2422 

2423 def _handle_relationships_pre_select( 

2424 self, 

2425 query: Query, 

2426 select_args: list[Any], 

2427 select_kwargs: dict[str, Any], 

2428 metadata: Metadata, 

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

2430 db = self._get_db() 

2431 model = self.model 

2432 

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

2434 

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

2436 join = [] 

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

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

2439 continue 

2440 

2441 other = relation.get_table(db) 

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

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

2444 

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

2446 # if limitby + relationships: 

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

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

2449 # 3. add joins etc 

2450 

2451 kwargs = {"limitby": limitby} 

2452 

2453 if join: 

2454 kwargs["join"] = join 

2455 

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

2457 query = model.id.belongs(ids) 

2458 metadata["ids"] = ids 

2459 

2460 if join: 

2461 select_kwargs["join"] = join 

2462 

2463 left = [] 

2464 

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

2466 other = relation.get_table(db) 

2467 method: JOIN_OPTIONS = relation.join or DEFAULT_JOIN_OPTION 

2468 

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

2470 pre_alias = str(other) 

2471 

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

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

2474 select_args.append(other.ALL) 

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

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

2477 select_args.append(other.id) 

2478 

2479 if relation.on: 

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

2481 on = relation.on(model, other) 

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

2483 on = [on] 

2484 

2485 left.extend(on) 

2486 elif method == "left": 

2487 # .on not given, generate it: 

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

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

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

2491 else: 

2492 # else: inner join (handled earlier) 

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

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

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

2496 

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

2498 # else: only add other.id if missing 

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

2500 

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

2502 if pre_alias != post_alias: 

2503 # replace .select's with aliased: 

2504 select_fields = select_fields.replace( 

2505 f"{pre_alias}.", 

2506 f"{post_alias}.", 

2507 ) 

2508 

2509 select_args = select_fields.split(", ") 

2510 

2511 select_kwargs["left"] = left 

2512 return query, select_args 

2513 

2514 def _collect_with_relationships( 

2515 self, rows: Rows, metadata: Metadata, _to: typing.Type["TypedRows[Any]"] 

2516 ) -> "TypedRows[T_MetaInstance]": 

2517 """ 

2518 Transform the raw rows into Typed Table model instances. 

2519 """ 

2520 db = self._get_db() 

2521 main_table = self.model._ensure_table_defined() 

2522 

2523 records = {} 

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

2525 

2526 for row in rows: 

2527 main = row[main_table] 

2528 main_id = main.id 

2529 

2530 if main_id not in records: 

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

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

2533 

2534 # setup up all relationship defaults (once) 

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

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

2537 

2538 # now add other relationship data 

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

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

2541 

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

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

2544 

2545 relation_data = ( 

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

2547 ) 

2548 

2549 if relation_data.id is None: 

2550 # always skip None ids 

2551 continue 

2552 

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

2554 # speed up duplicates 

2555 continue 

2556 else: 

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

2558 

2559 relation_table = relation.get_table(db) 

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

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

2562 

2563 if relation.multiple: 

2564 # create list of T 

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

2566 # should already be set up before! 

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

2568 

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

2570 else: 

2571 # create single T 

2572 records[main_id][column] = instance 

2573 

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

2575 

2576 def collect_or_fail(self, exception: Exception = None) -> "TypedRows[T_MetaInstance]": 

2577 """ 

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

2579 

2580 Basically unwraps Optional type. 

2581 """ 

2582 if result := self.collect(): 

2583 return result 

2584 

2585 if not exception: 

2586 exception = ValueError("Nothing found!") 

2587 

2588 raise exception 

2589 

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

2591 """ 

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

2593 """ 

2594 yield from self.collect() 

2595 

2596 def count(self) -> int: 

2597 """ 

2598 Return the amount of rows matching the current query. 

2599 """ 

2600 db = self._get_db() 

2601 model = self.model 

2602 query = self.query 

2603 

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

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

2606 continue 

2607 

2608 other = relation.get_table(db) 

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

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

2611 

2612 return db(query).count() 

2613 

2614 def __paginate( 

2615 self, 

2616 limit: int, 

2617 page: int = 1, 

2618 ) -> "QueryBuilder[T_MetaInstance]": 

2619 _from = limit * (page - 1) 

2620 _to = limit * page 

2621 

2622 available = self.count() 

2623 

2624 metadata: Metadata = {} 

2625 

2626 metadata["pagination"] = { 

2627 "limit": limit, 

2628 "current_page": page, 

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

2630 "rows": available, 

2631 "min_max": (_from, _to), 

2632 } 

2633 

2634 return self._extend(select_kwargs={"limitby": (_from, _to)}, metadata=metadata) 

2635 

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

2637 """ 

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

2639 

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

2641 can be loaded with relationship data! 

2642 """ 

2643 builder = self.__paginate(limit, page) 

2644 

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

2646 

2647 rows._query_builder = builder 

2648 return rows 

2649 

2650 def _paginate( 

2651 self, 

2652 limit: int, 

2653 page: int = 1, 

2654 ) -> str: 

2655 builder = self.__paginate(limit, page) 

2656 return builder._collect() 

2657 

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

2659 """ 

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

2661 

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

2663 specified `chunk_size` and yields them as TypedRows. 

2664 

2665 Example: 

2666 ``` 

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

2668 for row in chunk_of_rows: 

2669 # Process each row within the chunk. 

2670 pass 

2671 ``` 

2672 """ 

2673 page = 1 

2674 

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

2676 yield rows 

2677 page += 1 

2678 

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

2680 """ 

2681 Get the first row matching the currently built query. 

2682 

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

2684 """ 

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

2686 return self.model.from_row(row) 

2687 else: 

2688 return None 

2689 

2690 def _first(self) -> str: 

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

2692 

2693 def first_or_fail(self, exception: Exception = None, verbose: bool = False) -> T_MetaInstance: 

2694 """ 

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

2696 

2697 Basically unwraps Optional type. 

2698 """ 

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

2700 return inst 

2701 

2702 if not exception: 

2703 exception = ValueError("Nothing found!") 

2704 

2705 raise exception 

2706 

2707 

2708S = typing.TypeVar("S") 

2709 

2710 

2711class PaginatedRows(TypedRows[T_MetaInstance]): 

2712 """ 

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

2714 """ 

2715 

2716 _query_builder: QueryBuilder[T_MetaInstance] 

2717 

2718 @property 

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

2720 """ 

2721 Get the underlying data. 

2722 """ 

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

2724 

2725 @property 

2726 def pagination(self) -> Pagination: 

2727 """ 

2728 Get all page info. 

2729 """ 

2730 pagination_data = self.metadata["pagination"] 

2731 

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

2733 has_prev_page = pagination_data["current_page"] > 1 

2734 return { 

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

2736 "current_page": pagination_data["current_page"], 

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

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

2739 "has_next_page": has_next_page, 

2740 "has_prev_page": has_prev_page, 

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

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

2743 } 

2744 

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

2746 """ 

2747 Get the next page. 

2748 """ 

2749 data = self.metadata["pagination"] 

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

2751 raise StopIteration("Final Page") 

2752 

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

2754 

2755 def previous(self) -> Self: 

2756 """ 

2757 Get the previous page. 

2758 """ 

2759 data = self.metadata["pagination"] 

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

2761 raise StopIteration("First Page") 

2762 

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

2764 

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

2766 """ 

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

2768 

2769 All arguments are ignored! 

2770 """ 

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

2772 

2773 

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

2775 """ 

2776 Used to make pydal Set more typed. 

2777 

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

2779 """ 

2780 

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

2782 """ 

2783 Count returns an int. 

2784 """ 

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

2786 return typing.cast(int, result) 

2787 

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

2789 """ 

2790 Select returns a TypedRows of a user defined table. 

2791 

2792 Example: 

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

2794 

2795 for row in result: 

2796 typing.reveal_type(row) # MyTable 

2797 """ 

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

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