TypeDAL 4.0.2__tar.gz → 4.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. {typedal-4.0.2 → typedal-4.1.0}/CHANGELOG.md +17 -0
  2. {typedal-4.0.2 → typedal-4.1.0}/PKG-INFO +1 -1
  3. typedal-4.1.0/docs/1_getting_started.md +91 -0
  4. {typedal-4.0.2 → typedal-4.1.0}/docs/2_defining_tables.md +2 -1
  5. {typedal-4.0.2 → typedal-4.1.0}/docs/3_building_queries.md +89 -24
  6. {typedal-4.0.2 → typedal-4.1.0}/docs/4_relationships.md +81 -11
  7. typedal-4.1.0/docs/6_migrations.md +90 -0
  8. typedal-4.1.0/docs/7_configuration.md +225 -0
  9. typedal-4.0.2/docs/7_mixins.md → typedal-4.1.0/docs/8_mixins.md +30 -12
  10. typedal-4.1.0/docs/index.md +10 -0
  11. {typedal-4.0.2 → typedal-4.1.0}/mkdocs.yml +2 -1
  12. {typedal-4.0.2 → typedal-4.1.0}/pyproject.toml +2 -0
  13. {typedal-4.0.2 → typedal-4.1.0}/src/typedal/__about__.py +1 -1
  14. {typedal-4.0.2 → typedal-4.1.0}/src/typedal/caching.py +1 -1
  15. {typedal-4.0.2 → typedal-4.1.0}/src/typedal/cli.py +2 -1
  16. {typedal-4.0.2 → typedal-4.1.0}/src/typedal/config.py +3 -0
  17. {typedal-4.0.2 → typedal-4.1.0}/src/typedal/core.py +4 -2
  18. {typedal-4.0.2 → typedal-4.1.0}/src/typedal/define.py +3 -3
  19. {typedal-4.0.2 → typedal-4.1.0}/src/typedal/for_py4web.py +1 -1
  20. {typedal-4.0.2 → typedal-4.1.0}/src/typedal/helpers.py +1 -1
  21. {typedal-4.0.2 → typedal-4.1.0}/src/typedal/query_builder.py +80 -34
  22. {typedal-4.0.2 → typedal-4.1.0}/src/typedal/relationships.py +145 -19
  23. {typedal-4.0.2 → typedal-4.1.0}/src/typedal/rows.py +1 -1
  24. {typedal-4.0.2 → typedal-4.1.0}/src/typedal/tables.py +10 -1
  25. {typedal-4.0.2 → typedal-4.1.0}/src/typedal/types.py +9 -9
  26. {typedal-4.0.2 → typedal-4.1.0}/tests/test_relationships.py +98 -34
  27. typedal-4.0.2/docs/1_getting_started.md +0 -34
  28. typedal-4.0.2/docs/6_migrations.md +0 -125
  29. typedal-4.0.2/docs/index.md +0 -9
  30. {typedal-4.0.2 → typedal-4.1.0}/.github/workflows/su6.yml +0 -0
  31. {typedal-4.0.2 → typedal-4.1.0}/.gitignore +0 -0
  32. {typedal-4.0.2 → typedal-4.1.0}/.readthedocs.yml +0 -0
  33. {typedal-4.0.2 → typedal-4.1.0}/README.md +0 -0
  34. {typedal-4.0.2 → typedal-4.1.0}/coverage.svg +0 -0
  35. {typedal-4.0.2 → typedal-4.1.0}/docs/5_py4web.md +0 -0
  36. {typedal-4.0.2 → typedal-4.1.0}/docs/css/code_blocks.css +0 -0
  37. {typedal-4.0.2 → typedal-4.1.0}/docs/requirements.txt +0 -0
  38. {typedal-4.0.2 → typedal-4.1.0}/example_new.py +0 -0
  39. {typedal-4.0.2 → typedal-4.1.0}/example_old.py +0 -0
  40. {typedal-4.0.2 → typedal-4.1.0}/src/typedal/__init__.py +0 -0
  41. {typedal-4.0.2 → typedal-4.1.0}/src/typedal/constants.py +0 -0
  42. {typedal-4.0.2 → typedal-4.1.0}/src/typedal/fields.py +0 -0
  43. {typedal-4.0.2 → typedal-4.1.0}/src/typedal/for_web2py.py +0 -0
  44. {typedal-4.0.2 → typedal-4.1.0}/src/typedal/mixins.py +0 -0
  45. {typedal-4.0.2 → typedal-4.1.0}/src/typedal/py.typed +0 -0
  46. {typedal-4.0.2 → typedal-4.1.0}/src/typedal/serializers/as_json.py +0 -0
  47. {typedal-4.0.2 → typedal-4.1.0}/src/typedal/web2py_py4web_shared.py +0 -0
  48. {typedal-4.0.2 → typedal-4.1.0}/tests/__init__.py +0 -0
  49. {typedal-4.0.2 → typedal-4.1.0}/tests/configs/simple.toml +0 -0
  50. {typedal-4.0.2 → typedal-4.1.0}/tests/configs/valid.env +0 -0
  51. {typedal-4.0.2 → typedal-4.1.0}/tests/configs/valid.toml +0 -0
  52. {typedal-4.0.2 → typedal-4.1.0}/tests/py314_tests.py +0 -0
  53. {typedal-4.0.2 → typedal-4.1.0}/tests/test_cli.py +0 -0
  54. {typedal-4.0.2 → typedal-4.1.0}/tests/test_config.py +0 -0
  55. {typedal-4.0.2 → typedal-4.1.0}/tests/test_docs_examples.py +0 -0
  56. {typedal-4.0.2 → typedal-4.1.0}/tests/test_helpers.py +0 -0
  57. {typedal-4.0.2 → typedal-4.1.0}/tests/test_json.py +0 -0
  58. {typedal-4.0.2 → typedal-4.1.0}/tests/test_main.py +0 -0
  59. {typedal-4.0.2 → typedal-4.1.0}/tests/test_mixins.py +0 -0
  60. {typedal-4.0.2 → typedal-4.1.0}/tests/test_mypy.py +0 -0
  61. {typedal-4.0.2 → typedal-4.1.0}/tests/test_orm.py +0 -0
  62. {typedal-4.0.2 → typedal-4.1.0}/tests/test_py4web.py +0 -0
  63. {typedal-4.0.2 → typedal-4.1.0}/tests/test_query_builder.py +0 -0
  64. {typedal-4.0.2 → typedal-4.1.0}/tests/test_row.py +0 -0
  65. {typedal-4.0.2 → typedal-4.1.0}/tests/test_stats.py +0 -0
  66. {typedal-4.0.2 → typedal-4.1.0}/tests/test_table.py +0 -0
  67. {typedal-4.0.2 → typedal-4.1.0}/tests/test_web2py.py +0 -0
  68. {typedal-4.0.2 → typedal-4.1.0}/tests/test_xx_others.py +0 -0
  69. {typedal-4.0.2 → typedal-4.1.0}/tests/timings.py +0 -0
@@ -2,6 +2,23 @@
2
2
 
3
3
  <!--next-version-placeholder-->
4
4
 
5
+ ## v4.1.0 (2025-11-26)
6
+
7
+ ### Feature
8
+
9
+ * **relationships:** Improved join features such as lazy=LazyPolicy, explicit=bool ([`f612d43`](https://github.com/trialandsuccess/TypeDAL/commit/f612d43b3a8f474a39bbcc68c1ce9b688939b13a))
10
+
11
+ ### Fix
12
+
13
+ * Move `LazyPolicy` to config so TypeDALConfig works on < 3.14 again ([`7ad2b19`](https://github.com/trialandsuccess/TypeDAL/commit/7ad2b1934e129307f7aca9d20055e10df5636c92))
14
+
15
+ ### Documentation
16
+
17
+ * Implemented outstanding todo's into documenation ([`6acb6d8`](https://github.com/trialandsuccess/TypeDAL/commit/6acb6d8ec0ae0ce5b92533e607e4d9a1b65600bc))
18
+ * Continued working on docs todo list ([`022c95d`](https://github.com/trialandsuccess/TypeDAL/commit/022c95dc276ca356cf7e2c884e4d6d6b179aa108))
19
+ * Started on the todo list of doc changes ([`2ad04c4`](https://github.com/trialandsuccess/TypeDAL/commit/2ad04c4de619af9ec1335c645d3e0ad11b25f9a0))
20
+ * Added todo for what to update in docs ([`00eba3e`](https://github.com/trialandsuccess/TypeDAL/commit/00eba3e5d0b347a05d13bfa9dee36054cb261648))
21
+
5
22
  ## v4.0.2 (2025-10-14)
6
23
 
7
24
  ### Fix
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: TypeDAL
3
- Version: 4.0.2
3
+ Version: 4.1.0
4
4
  Summary: Typing support for PyDAL
5
5
  Project-URL: Documentation, https://typedal.readthedocs.io/
6
6
  Project-URL: Issues, https://github.com/trialandsuccess/TypeDAL/issues
@@ -0,0 +1,91 @@
1
+ # 1. Getting Started
2
+
3
+ TypeDAL is built on top of pyDAL, which has excellent documentation. It is recommended to be familiar with at least the
4
+ basics of this abstraction, before using this library. TypeDAL uses many of the same concepts, but with slightly
5
+ different wording and syntax in some cases.
6
+
7
+ [web2py - Chapter 6: The database abstraction layer](http://www.web2py.com/books/default/chapter/29/06/the-database-abstraction-layer)
8
+
9
+ ---
10
+
11
+ ### Installation
12
+
13
+ ```shell
14
+ pip install typedal
15
+ # or, if you're using py4web:
16
+ pip install typedal[py4web]
17
+ ```
18
+
19
+ ### First Steps
20
+
21
+ ```python
22
+ from typedal import TypeDAL
23
+ # or, if in py4web:
24
+ from typedal.for_py4web import TypeDAL
25
+
26
+ db = TypeDAL("sqlite:memory")
27
+ ```
28
+
29
+ TypeDAL accepts the same connection string format and other arguments as `pydal.DAL`.
30
+ Again, see
31
+ [their documentation](http://www.web2py.com/books/default/chapter/29/06/the-database-abstraction-layer#The-DAL-A-quick-tour)
32
+ for more info about this. For additional configuration options specific to TypeDAL, see the [7. Advanced Configuration](./7_configuration.md) page.
33
+
34
+ When using py4web, it is recommended to import the py4web-specific TypeDAL, which is a Fixture that handles database
35
+ connections on request (just like the py4web specific DAL class does). More information about this can be found on [5. py4web](./5_py4web.md)
36
+
37
+ ### Simple Queries
38
+
39
+ For direct SQL access, use `executesql()`:
40
+
41
+ ```python
42
+ rows = db.executesql("SELECT * FROM some_table")
43
+ ```
44
+
45
+ #### Safely Injecting Variables
46
+
47
+ Use t-strings (Python 3.14+) for automatic SQL escaping:
48
+
49
+ ```python
50
+ name = "Robert'); DROP TABLE Students;--"
51
+ rows = db.executesql(t"SELECT * FROM some_table WHERE name = {name}")
52
+ ```
53
+
54
+ Or use the `placeholders` argument with positional or named parameters:
55
+
56
+ ```python
57
+ # Positional
58
+ rows = db.executesql(
59
+ "SELECT * FROM some_table WHERE name = %s AND age > %s",
60
+ placeholders=[name, 18]
61
+ )
62
+
63
+ # Named
64
+ rows = db.executesql(
65
+ "SELECT * FROM some_table WHERE name = %(name)s AND age > %(age)s",
66
+ placeholders={"name": name, "age": 18}
67
+ )
68
+ ```
69
+
70
+ #### Result Formatting
71
+
72
+ By default, `executesql()` returns rows as tuples. To map results to specific fields, use `fields` (takes
73
+ Field/TypedField objects) or `colnames` (takes column name strings):
74
+
75
+ ```python
76
+ rows = db.executesql(
77
+ "SELECT id, name FROM some_table",
78
+ colnames=["id", "name"]
79
+ )
80
+
81
+ rows = db.executesql(
82
+ "SELECT id, name FROM some_table",
83
+ fields=[some_table.id, some_table.name] # Requires table definition
84
+ )
85
+ ```
86
+
87
+ You can also use `as_dict` or `as_ordered_dict` to return dictionaries instead of tuples.
88
+
89
+ Most of the time, you probably don't want to write raw queries. For that, you'll need to define some tables!
90
+ Head to [2. Defining Tables](./2_defining_tables.md) to learn how.
91
+
@@ -102,5 +102,6 @@ def my_after_delete(query: Set):
102
102
 
103
103
  row.delete_record() # to trigger
104
104
  MyTable.where(...).delete() # to trigger
105
-
106
105
  ```
106
+
107
+ "Now that we have some tables, it's time to actually query them! Let's go to [3. Building Queries](./3_building_queries.md) to learn how.
@@ -30,27 +30,33 @@ The query builder uses the builder pattern, so you can keep adding to it (in any
30
30
  data:
31
31
 
32
32
  ```python
33
- Person
33
+ builder = Person
34
34
  .where(Person.id > 0)
35
- .where(Person.id < 99, Person.id == 101)
35
+ .where(Person.id < 99, Person.id == 101) # id < 99 OR id == 101
36
36
  .select(Reference.id, Reference.title)
37
37
  .join('reference')
38
38
  .select(Person.ALL)
39
- .paginate(limit=5, page=2) # final step: actually runs the query
39
+
40
+ # final step: actually runs the query:
41
+ builder.paginate(limit=5, page=2)
42
+
43
+ # to get the SQL (for debugging or subselects), you can use:
44
+ builder.to_sql()
40
45
  ```
41
46
 
42
47
  ```sql
43
- SELECT "person".*,
44
- "reference"."id",
45
- "reference"."title"
46
- FROM "person",
47
- "reference"
48
- WHERE (("person"."id" IN (SELECT "person"."id"
49
- FROM "person"
50
- WHERE ((("person"."id" > 0)) AND
51
- (("person"."id" < 99) OR ("person"."id" = 101)))
52
- ORDER BY "person"."id" LIMIT 1 OFFSET 0))
53
- AND ("person"."reference" = "reference"."id") );
48
+ SELECT "person".*
49
+ , "reference"."id"
50
+ , "reference"."title"
51
+ FROM "person"
52
+ , "reference"
53
+ WHERE (("person"."id" IN (SELECT "person"."id"
54
+ FROM "person"
55
+ WHERE ((("person"."id" > 0)) AND
56
+ (("person"."id" < 99) OR ("person"."id" = 101)))
57
+ ORDER BY "person"."id"
58
+ LIMIT 1 OFFSET 0))
59
+ AND ("person"."reference" = "reference"."id"));
54
60
  ```
55
61
 
56
62
  ### where
@@ -58,27 +64,81 @@ WHERE (("person"."id" IN (SELECT "person"."id"
58
64
  In pydal, this is the part that would be in `db(...)`.
59
65
  Can be used in multiple ways:
60
66
 
61
- - `.where(Query)` -> with a direct query such as `Table.id == 5`
67
+ - `.where(query)` -> with a direct query such as `query = (Table.id == 5)`
62
68
  - `.where(lambda table: table.id == 5)` -> with a query via a lambda
63
69
  - `.where(id=5)` -> via keyword arguments
70
+ - `.where({"id": 5})` -> via a dictionary (equivalent to keyword args)
71
+ - `.where(Table.field)` -> with a Field directly, checks if field is not null
72
+
73
+ When using multiple `.where()` calls, they will be ANDed together:
74
+ `.where(lambda table: table.id == 5).where(active=True)` equals `(table.id == 5) & (table.active == True)`
64
75
 
65
- When using multiple where's, they will be ANDed:
66
- `.where(lambda table: table.id == 5).where(lambda table: table.id == 6)` equals `(table.id == 5) & (table.id=6)`
67
- When passing multiple queries to a single .where, they will be ORed:
68
- `.where(lambda table: table.id == 5, lambda table: table.id == 6)` equals `(table.id == 5) | (table.id=6)`
76
+ When passing multiple arguments to a single `.where()`, they will be ORed:
77
+ `.where({"id": 5}, {"id": 6})` equals `(table.id == 5) | (table.id == 6)`
69
78
 
70
79
  ### select
71
80
 
72
- Here you can enter any number of fields as arguments: database columns by name ('id'), by field reference (table.id) or
73
- other (e.g. table.ALL).
81
+ Here you can enter any number of fields as arguments: database columns by name ('id'), by field reference (Table.id),
82
+ other (e.g. Table.ALL), or Expression objects.
74
83
 
75
84
  ```python
76
85
  Person.select('id', Person.name, Person.ALL) # defaults to Person.ALL if select is omitted.
77
86
  ```
78
87
 
79
- You can also specify extra options such as `orderby` here. For supported keyword arguments, see
88
+ You can also specify extra options as keyword arguments. Supported options are: `orderby`, `groupby`, `limitby`,
89
+ `distinct`, `having`, `orderby_on_limitby`, `join`, `left`, `cache`, see also
80
90
  the [web2py docs](http://www.web2py.com/books/default/chapter/29/06/the-database-abstraction-layer#orderby-groupby-limitby-distinct-having-orderby_on_limitby-join-left-cache).
81
91
 
92
+ ```python
93
+ Person.select(Person.name, distinct=True)
94
+ ```
95
+
96
+ If you only want a list of name strings (in this example) instead of Person instances, you could use the column() method
97
+ instead:
98
+
99
+ ```python
100
+ Person.column(Person.name, distinct=True)
101
+ ```
102
+
103
+ You can use `.orderby(*fields)` as an alternative to `select(orderby=...)`:
104
+
105
+ ```python
106
+ Person.where(...).orderby(~Person.name, "age")
107
+ ```
108
+
109
+ `.orderby()` accepts field references (`Table.field` or `"field_name""`), reverse ordering (`~Table.field`), or the
110
+ literal `"<random>"`. Multiple field references can be passed (except when using `<random>`).
111
+
112
+ #### Raw SQL Expressions
113
+
114
+ For complex SQL that can't be expressed with field references, use `sql_expression()`:
115
+
116
+ ```python
117
+ # Simple arithmetic
118
+ expr = db.sql_expression("age * 2")
119
+ Person.select(expr)
120
+
121
+ # Safe parameter injection with t-strings (Python 3.14+)
122
+ min_age = 21
123
+ expr = db.sql_expression(t"age >= {min_age}", output_type="boolean")
124
+ Person.where(expr).select()
125
+
126
+ # Positional arguments
127
+ expr = db.sql_expression("age > %s AND status = %s", 18, "active", output_type="boolean")
128
+ Person.where(expr).select()
129
+
130
+ # Named arguments
131
+ expr = db.sql_expression(
132
+ "EXTRACT(year FROM %(date_col)s) = %(year)s",
133
+ date_col="created_at",
134
+ year=2023,
135
+ output_type="boolean"
136
+ )
137
+ Person.where(expr).select()
138
+ ```
139
+
140
+ Expressions can be used in `where()`, `select()`, `orderby()`, and other query methods.
141
+
82
142
  ### join
83
143
 
84
144
  Include relationship fields in the result.
@@ -93,6 +153,8 @@ This can be overwritten with the `method` keyword argument (left or inner)
93
153
  Person.join('articles', method='inner') # will only yield persons that have related articles
94
154
  ```
95
155
 
156
+ For more details about relationships and joins, see [4. Relationships](./4_relationships.md).
157
+
96
158
  ### cache
97
159
 
98
160
  ```python
@@ -120,7 +182,7 @@ time.
120
182
  class ...
121
183
  ```
122
184
 
123
- In order to enable this functionality, TypeDAL adds a `before update` and `before delete` hook to your tables,
185
+ In order to enable this functionality, TypeDAL adds a `before update` and `before delete` hook to your tables,
124
186
  which manages the dependencies. You can disable this behavior by passing `cache_dependency=False` to `db.define`.
125
187
  Be aware doing this might break some caching functionality!
126
188
 
@@ -136,12 +198,15 @@ The Query Builder has a few operations that don't return a new builder instance:
136
198
  - paginate: this works similarly to `collect`, but returns a PaginatedRows instead, which has a `.next()`
137
199
  and `.previous()` method to easily load more pages.
138
200
  - collect_or_fail: where `collect` may return an empty result, this variant will raise an error if there are no results.
201
+ - execute: get the raw rows matching your query as returned by pydal, without entity mapping or relationship loading.
202
+ Useful for subqueries or when you need lower-level control.
139
203
  - first: get the first entity matching your query, possibly with relationships loaded (if .join was used)
140
204
  - first_or_fail: where `first` may return an empty result, this variant will raise an error if there are no results.
205
+ - to_sql: get the SQL string that would run, useful for debugging, subqueries and other advanced SQL operations.
141
206
  - update: instead of selecting rows, update those matching the current query (see [Delete](#delete))
142
207
  - delete: instead of selecting rows, delete those matching the current query (see [Update](#update))
143
208
 
144
- Additionally, you can directly call `.all()`, `.collect()`, `.count()`, `.first()` on a model.
209
+ Additionally, you can directly call `.all()`, `.collect()`, `.count()`, `.first()` on a model (e.g. `User.all()`).
145
210
 
146
211
  ## Update
147
212
 
@@ -20,25 +20,37 @@ class Post(TypedTable):
20
20
  author: Author
21
21
 
22
22
 
23
- authors_with_roles = Author.join('role').collect()
23
+ authors_with_roles = Author.join('roles').collect()
24
24
  posts_with_author = Post.join().collect() # join can be called without arguments to join all relationships (in this case only 'author')
25
+ post_deep = Post.join("author.roles").collect() # nested relationship, accessible via post.author.roles
25
26
  ```
26
27
 
27
28
  In this example, the `Post` table contains a reference to the `Author` table. In that case, the `Author` `id` is stored
28
- in the
29
- `Post`'s `author` column.
29
+ in the `Post`'s `author` column.
30
30
  Furthermore, the `Author` table contains a `list:reference` to `list[Role]`. This means multiple `id`s from the `Role`
31
- table
32
- can be stored in the `roles` column of `Author`.
31
+ table can be stored in the `roles` column of `Author`.
33
32
 
34
33
  For these two cases, a Relationship is set-up automatically, which means `.join()` can work with those.
35
34
 
35
+ ### Alternative Join Syntax
36
+
37
+ You can pass the relationship object directly instead of its name as a string:
38
+
39
+ ```python
40
+ posts_with_author = Post.join(Post.author).collect()
41
+ ```
42
+
43
+ This works, but note that `Post.author` is typed as `Relationship[Author]` at the class level, while `row.author` is
44
+ typed as `Author` at the instance level. Some editors may complain about type mismatches when using this syntax (e.g.,
45
+ reporting that `list[Tag]` isn't a `Relationship`). If you encounter type checking issues, use the string syntax
46
+ instead.
47
+
36
48
  ## Other Relationships
37
49
 
38
50
  To get the reverse relationship, you'll have to tell TypeDAL how the two tables relate to each other (since guessing is
39
51
  complex and unreliable).
40
52
 
41
- For example, to set up the reverse relationshp from author to posts:
53
+ For example, to set up the reverse relationship from author to posts:
42
54
 
43
55
  ```python
44
56
  @db.define()
@@ -46,7 +58,6 @@ class Author(TypedTable):
46
58
  name: str
47
59
 
48
60
  posts = relationship(list["Post"], condition=lambda author, post: author.id == post.author, join="left")
49
-
50
61
  ```
51
62
 
52
63
  Note that `"Post"` is in quotes. This is because the `Post` class is defined later, so a reference to it is not
@@ -62,7 +73,7 @@ class Role(TypedTable):
62
73
  authors = relationship(list["Author"], condition=lambda role, author: author.roles.contains(role.id), join="left")
63
74
  ```
64
75
 
65
- Here, contains is used since `Author.roles` is a `list:reference`.
76
+ Here, `contains` is used since `Author.roles` is a `list:reference`.
66
77
  See [the web2py docs](http://www.web2py.com/books/default/chapter/29/06/the-database-abstraction-layer#list-type-and-contains)
67
78
  for more details.
68
79
 
@@ -82,8 +93,8 @@ class Sidekick(TypedTable):
82
93
  superhero: SuperHero
83
94
  ```
84
95
 
85
- In this example, `Relationship["Sidekick"]` is added as an extra type hint, since the reference to the table
86
- in `relationship("Sidekick", ...)` is a string. This has to be passed as a string, since the Sidekick class is defined
96
+ In this example, `Relationship["Sidekick"]` is added as an extra type hint, since the reference to the table in
97
+ `relationship("Sidekick", ...)` is a string. This has to be passed as a string, since the Sidekick class is defined
87
98
  after the superhero class.
88
99
  Adding the `Relationship["Sidekick"]` hint is optional, but recommended to improve editor support.
89
100
 
@@ -107,6 +118,7 @@ class Post(TypedTable):
107
118
  tag.on(tag.id == tagged.tag),
108
119
  ])
109
120
 
121
+
110
122
  # without unique alias:
111
123
 
112
124
  @db.define()
@@ -126,7 +138,65 @@ class Tagged(TypedTable):
126
138
  ```
127
139
 
128
140
  Instead of a `condition`, it is recommended to define an `on`. Using a condition is possible, but could lead to pydal
129
- generation a `CROSS JOIN` instead of a `LEFT JOIN`, which is bad for performance.
141
+ generating a `CROSS JOIN` instead of a `LEFT JOIN`, which is bad for performance.
130
142
  In this example, `Tag` is connected to `Post` and vice versa via the `Tagged` table.
131
143
  It is recommended to use the tables received as arguments from the lambda (e.g. `tag.on` instead of `Tag.on` directly),
132
144
  since these use aliases under the hood, which prevents conflicts when joining the same table multiple times.
145
+
146
+ ## Lazy Loading and Explicit Relationships
147
+
148
+ ### Lazy Policy
149
+
150
+ The `lazy` parameter on a relationship controls what happens when you access relationship data without explicitly
151
+ joining it first:
152
+
153
+ ```python
154
+ @db.define()
155
+ class User(TypedTable):
156
+ name: str
157
+ posts = relationship(list["Post"], condition=lambda user, post: user.id == post.author, lazy="forbid")
158
+ ```
159
+
160
+ Available policies:
161
+
162
+ - **`"forbid"`**: Raises an error. Prevents N+1 query problems by making them fail fast.
163
+ - **`"warn"`**: Returns an empty value (empty list or `None`) with a console warning.
164
+ - **`"ignore"`**: Returns an empty value silently.
165
+ - **`"tolerate"`**: Fetches the data but logs a warning about potential performance issues.
166
+ - **`"allow"`**: Fetches the data silently.
167
+
168
+ If `lazy=None` (the default), the relationship uses the database's default lazy policy. You can set this globally via
169
+ `TypeDAL`'s `lazy_policy` option (see [7. Advanced Configuration](./7_configuration.md) for configuration details),
170
+ which defaults to `"tolerate"`.
171
+
172
+ ### Explicit Relationships
173
+
174
+ Use `explicit=True` for relationships that are expensive to join or rarely needed:
175
+
176
+ ```python
177
+ @db.define()
178
+ class User(TypedTable):
179
+ name: str
180
+ audit_logs = relationship(list["AuditLog"], condition=lambda user, log: user.id == log.user, explicit=True)
181
+ ```
182
+
183
+ When you call `.join()` without arguments, explicit relationships are skipped:
184
+
185
+ ```python
186
+ user = User.join().first() # user.audit_logs follows the lazy policy (empty/error/warning depending on setting)
187
+ ```
188
+
189
+ To include an explicit relationship, reference it by name:
190
+
191
+ ```python
192
+ user = User.join("audit_logs").first() # now user.audit_logs is populated
193
+ ```
194
+
195
+ ## What's Next?
196
+
197
+ Depending on your setup:
198
+
199
+ - **Using py4web or web2py?** → [5. py4web & web2py](./5_py4web.md)
200
+ - **Ready to manage your database?** → [6. Migrations](./6_migrations.md)
201
+ - **Dive deeper into functionality?** → [8.: Mixins](./8_mixins.md)
202
+
@@ -0,0 +1,90 @@
1
+ # 6. Migrations
2
+
3
+ By default, pydal manages migrations in your database schema
4
+ automatically ([See also: the web2py docs](http://www.web2py.com/books/default/chapter/29/06/the-database-abstraction-layer#Migrations)).
5
+ This can be problematic in a production environment where you want to disable automatic table changes and manage these
6
+ by hand.
7
+
8
+ TypeDAL integrates with [`edwh-migrate`](https://pypi.org/project/edwh-migrate/) to make this easier. With this tool,
9
+ you write migrations (`CREATE`, `ALTER`, `DROP` statements) in SQL and it keeps track of which actions have already been
10
+ executed on your database.
11
+
12
+ To make this process easier, TypeDAL also integrates with [`pydal2sql`](https://pypi.org/project/pydal2sql/), which can
13
+ convert your pydal/TypeDAL table definitions into `CREATE` statements for new tables, or `ALTER` statements for existing
14
+ tables.
15
+
16
+ ## Installation
17
+
18
+ To enable the migrations functionality within TypeDAL, install it with the migrations extra:
19
+
20
+ ```bash
21
+ pip install typedal[migrations] # also included in typedal[all]
22
+ ```
23
+
24
+ ## Minimal Configuration
25
+
26
+ To use migrations, you need to configure TypeDAL in your `pyproject.toml`. At minimum, you must set:
27
+
28
+ - `database`: Your database URI
29
+ - `dialect`: The database type (e.g., `sqlite`, `postgres`)
30
+ - `migrate`: Set to `false` to disable pydal's automatic migrations
31
+ - `flag_location`: Where edwh-migrate stores its migration tracking
32
+ - `input`: Path to your table definitions (e.g., `data_model.py`)
33
+ - `output`: Where generated migrations are written (e.g., `migrations/`)
34
+
35
+ Optionally:
36
+
37
+ - `database_to_restore`: Path to a SQL file to restore before running migrations on a fresh database.
38
+
39
+ Here's a minimal example:
40
+
41
+ ```toml
42
+ [tool.typedal]
43
+ database = "sqlite://"
44
+ dialect = "sqlite"
45
+ migrate = false
46
+ flag_location = "migrations/.flags"
47
+ input = "path/to/data_model.py"
48
+ output = "path/to/migrations.py"
49
+ ```
50
+
51
+ For dynamic properties or secrets (like a database with credentials),
52
+ add them to your `.env` file or set them as environment variables (optionally prefixed with `TYPEDAL_`):
53
+
54
+ ```env
55
+ TYPEDAL_DATABASE = "psql://user:password@host:5432/database"
56
+ ```
57
+
58
+ > **Full configuration reference**: For all available options, multiple connections, environment overrides, and other
59
+ > settings, see [7. Configuration](./7_configuration.md).
60
+
61
+ You can generate a config interactively with `typedal setup`, or view your current config with `typedal --show-config`.
62
+
63
+ ## Generate Migrations (pydal2sql)
64
+
65
+ With your config in place, generate migrations from your table definitions:
66
+
67
+ ```bash
68
+ typedal migrations.generate
69
+ ```
70
+
71
+ You can override config values with CLI flags. See all options:
72
+
73
+ ```bash
74
+ typedal migrations.generate --help
75
+ ```
76
+
77
+ ## Run Migrations (edwh-migrate)
78
+
79
+ Apply your migrations to the database:
80
+
81
+ ```bash
82
+ typedal migrations.run
83
+ ```
84
+
85
+ With a correctly configured setup, this should function without extra arguments.
86
+ You can however overwrite the behavior as defined in the config. See all options:
87
+
88
+ ```bash
89
+ typedal migrations.run --help
90
+ ```