Skip to content

Commit 018fe0a

Browse files
committed
Merge release/typedal-v6 into fix/per-db-cache-models
Resolves the conflict in caching.py by keeping release/typedal-v6's invalidation logic (table-wide invalidation for plain Sets, skipping empty results) with the per-database `db` argument. Drops the module-global binding workaround from `test_upsert_invalidates_cache`, which per-database cache models make unnecessary. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0181W827mjAmRu6ECjtQTU5v
2 parents 25d0395 + 79894f5 commit 018fe0a

27 files changed

Lines changed: 1317 additions & 294 deletions

‎.github/workflows/checks.yml‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
name: run checks
2+
on:
3+
push:
4+
branches-ignore:
5+
- master
6+
jobs:
7+
check_min:
8+
name: Lint, format and test on lowest Python
9+
runs-on: ubuntu-latest
10+
steps:
11+
- uses: actions/checkout@v3
12+
- uses: actions/setup-python@v4
13+
with:
14+
python-version: '3.12'
15+
- uses: yezz123/setup-uv@v4
16+
with:
17+
uv-venv: ".venv"
18+
- run: uv pip install .[dev,all]
19+
# ruff + ty
20+
- run: edwh lint --output ci
21+
# what `edwh fmt` would change: import order and formatting
22+
- run: ruff check --select I . && ruff format --check .
23+
# pytest with coverage over src/; [tool.coverage.report] fail_under enforces 100%
24+
- run: edwh test.run
25+
26+
check_max:
27+
name: Lint, format and test on highest Python
28+
runs-on: ubuntu-latest
29+
steps:
30+
- uses: actions/checkout@v3
31+
- uses: actions/setup-python@v4
32+
with:
33+
python-version: '3.15'
34+
allow-prereleases: true
35+
- uses: yezz123/setup-uv@v4
36+
with:
37+
uv-venv: ".venv"
38+
- run: uv pip install .[dev,all]
39+
# ruff + ty
40+
- run: edwh lint --output ci
41+
# what `edwh fmt` would change: import order and formatting
42+
- run: ruff check --select I . && ruff format --check .
43+
# pytest with coverage over src/; [tool.coverage.report] fail_under enforces 100%
44+
- run: edwh test.run

‎.github/workflows/su6.yml‎

Lines changed: 0 additions & 34 deletions
This file was deleted.

‎README.md‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,9 @@
22

33
[![PyPI - Version](https://img.shields.io/pypi/v/TypeDAL.svg)](https://pypi.org/project/TypeDAL)
44
[![PyPI - Python Version](https://img.shields.io/pypi/pyversions/TypeDAL.svg)](https://pypi.org/project/TypeDAL)
5-
[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
5+
[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
66
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7-
[![su6 checks](https://github.com/trialandsuccess/TypeDAL/actions/workflows/su6.yml/badge.svg?branch=development)](https://github.com/trialandsuccess/TypeDAL/actions)
8-
![coverage.svg](coverage.svg)
7+
[![checks](https://github.com/trialandsuccess/TypeDAL/actions/workflows/checks.yml/badge.svg?branch=development)](https://github.com/trialandsuccess/TypeDAL/actions)
98

109
Typing support for [PyDAL](http://web2py.com/books/default/chapter/29/6).
1110
This package aims to improve the typing support for PyDAL. By using classes instead of the define_table method,

‎coverage.svg‎

Lines changed: 0 additions & 1 deletion
This file was deleted.

‎docs/10_advanced_apis.md‎

Lines changed: 56 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -52,64 +52,89 @@ user = await User.upsert_async({"email": "a@example.com"}, name="Bob")
5252
```
5353

5454
The key must be nonempty, contain known fields, exclude `id`, and have no `None`
55-
values. Key fields cannot also occur in the values. To change a key field, use
55+
values; its values must be plain scalars (`str`, `int`, `float`, `bool`, `bytes`, `Decimal`, `UUID`,
56+
dates and times). Key fields cannot also occur in the values, and `id` can't be a value either
57+
(it would re-key the row). All of these raise `UpsertKeyError` (a `ValueError`) before any SQL runs.
58+
To change a key field, use
5659
`update_or_insert({"email": "old@example.com"}, email="new@example.com")`.
5760
A call with only a key returns an existing row unchanged, without after-hooks,
5861
or inserts a new row using defaults.
5962

60-
PostgreSQL uses atomic `INSERT ... ON CONFLICT ... RETURNING`. A key without a
61-
matching unique constraint or index raises `UpsertKeyError`. SQLite, MySQL, and
62-
tables with a common filter use a Python lookup followed by insert or update.
63-
This path respects the common filter, does not inspect indexes, and raises
64-
`UpsertAmbiguityError` if the key matches multiple visible rows. It is not atomic
65-
under concurrent writes, so database unique constraints remain recommended.
66-
Conflicts on a different unique column raise the database's normal integrity error.
67-
PostgreSQL checks the proposed insert's constraints before resolving the conflict;
68-
provide required insertion values even when you expect to update an existing row.
69-
The key-only existing-row path avoids attempting that insertion.
70-
It also skips PostgreSQL's conflict-target validation because it executes no write.
63+
#### Native and fallback paths
64+
65+
Only PostgreSQL has a native path: an atomic `INSERT ... ON CONFLICT ... RETURNING`. A key without a
66+
matching unique constraint or index raises `UpsertKeyError`. PostgreSQL checks the proposed insert's
67+
constraints before resolving the conflict; provide required insertion values even when you expect to update
68+
an existing row. The key-only existing-row path avoids attempting that insertion, and skips the
69+
conflict-target validation because it executes no write.
70+
71+
SQLite and MySQL always use the fallback: a lookup by key followed by an insert or update. PostgreSQL uses it too
72+
for tables with a common filter or a multi-tenant (`request_tenant`) field, because `ON CONFLICT` would bypass those
73+
filters, and when the values replace an upload field with `autodelete=True`. The fallback respects the filters,
74+
does not inspect indexes, and raises `UpsertAmbiguityError` if the key matches multiple visible rows. It is not
75+
atomic under concurrent writes, so database unique constraints remain recommended. On SQLite 3.35+ the update
76+
branch reads the row back with `UPDATE ... RETURNING`; MySQL and older SQLite need one extra `SELECT`.
77+
Conflicts on a different unique column raise the database's normal integrity error on every backend.
78+
79+
#### Hooks
7180

7281
Before-hooks never run on either path because the branch is unknown beforehand.
7382
After-hooks run for the branch that occurred: `after_insert(row, id)` or
7483
`after_update(affected_set, row)`. The hook row is PyDAL's operation row, and the
7584
update set is restricted to the affected ID, ignoring common filters so the hook
7685
can still access a row moved outside its filter.
7786

78-
Register a before-hook's upsert policy explicitly:
87+
Register a before-hook's upsert policy explicitly (also accepted by `before_insert_once`/`before_update_once`):
7988

8089
```python
8190
User.before_insert(validate_user, upsert="error") # Require update_or_insert instead.
8291
User.before_update(normalize_user, upsert="ignore") # Skip silently during upsert.
8392
```
8493

85-
An unmarked before-hook emits `UpsertHooksWarning` and is skipped, including mixin
86-
hooks for slugs and timestamps. An `"error"` registration raises `UpsertHookError`
87-
before executing SQL. Policies belong to each model registration; they do not
88-
change ordinary inserts, updates, or `update_or_insert`.
94+
An unmarked before-hook you registered emits `UpsertHooksWarning` (pointing at the `upsert()` call) and is skipped.
95+
An `"error"` registration raises `UpsertHookError` before executing SQL. Policies belong to each model registration;
96+
they do not change ordinary inserts, updates, or `update_or_insert`.
97+
PyDAL's own upload hooks, present on every table, don't warn: upsert stores uploaded files itself and, on the update
98+
branch, removes the replaced file for `autodelete` fields just like a normal update.
99+
100+
The built-in `SlugMixin` and `TimestampsMixin` register their hooks with `upsert="error"`: skipping them would insert
101+
rows without a slug or leave `updated_at` stale, so `upsert()` on those tables raises `UpsertHookError`.
102+
Use `update_or_insert` there.
89103

90104
Inserts apply field defaults and computations. Upsert updates write only explicitly
91105
supplied values: omitted `Field(update=...)` and `compute` fields stay unchanged.
92106
Supply these values explicitly if they need to change. TypeDAL cache invalidation
93107
runs through the normal after-hooks.
94108

109+
#### Decimal values
110+
111+
PyDAL writes decimal values into SQL unquoted. Since 6.0, TypeDAL converts every value for a `decimal` field to
112+
`Decimal` first (for inserts, updates, queries and upserts alike) and raises `ValueError` for anything that isn't a
113+
finite number, so a string from request data can't change the statement.
114+
95115
### Affected-ID update hooks since 6.0
96116

97117
Updates through TypeDAL's query builders, `db(query).update(...)`, and record updates
98-
pass `Set(id.belongs(affected_ids))` to after-update hooks instead of the original
99-
query. Hooks receive an `AffectedSet` whose `affected_ids` exposes the IDs already
100-
captured by the write, including for cache invalidation. PostgreSQL and supported
101-
SQLite versions obtain IDs with `UPDATE ... RETURNING`.
102-
MySQL selects IDs before updating without locking; concurrent changes can make that
103-
selected list differ from the rows actually updated.
118+
pass an `AffectedSet` (matching the updated rows by primary key) to after-update hooks instead of the original
119+
query, so hooks still find rows whose filtered columns the update changed. Its `affected_ids` lists the primary
120+
keys already captured by the write; for keyed tables (`primarykey=[...]`) these are the key values, or tuples for
121+
composite keys. Calling `.where(...)` or `rows(...)` on it returns a narrowed (plain) `UpdateSet`.
122+
123+
After-update hooks only run when at least one row was updated. That's unchanged from PyDAL, which only calls them
124+
for a nonzero rowcount.
125+
126+
PostgreSQL and SQLite 3.35+ obtain the IDs with `UPDATE ... RETURNING`. MySQL and older SQLite select the IDs first
127+
(MySQL with `FOR UPDATE`) and then update exactly those rows, so a row that starts matching in between is neither
128+
updated nor reported; the returned count is the real rowcount. That costs one extra `SELECT` per update on those
129+
backends whenever IDs are needed: with an after-update hook (including TypeDAL's own cache invalidation) or for a
130+
QueryBuilder `update()`, which returns the IDs.
104131
Raw updates with no after-hooks use ordinary rowcount without collecting IDs.
105-
QueryBuilder updates always collect IDs for their return value. Large affected-ID
106-
lists currently stay in memory; there is no threshold or temporary-table strategy.
107-
108-
PyDAL's reverse-reference LazySets and `db.smart_query(...)` construct plain Sets
109-
and retain PyDAL's original-query after-hook semantics. Their hooks receive a plain
110-
`Set` without `affected_ids`; cache invalidation falls back to selecting IDs from
111-
that query. Updates that change the query's predicate can therefore miss cache
112-
invalidation on these PyDAL paths.
132+
Large affected-ID lists currently stay in memory; there is no threshold or temporary-table strategy.
133+
134+
PyDAL's reverse-reference LazySets (`row.articles.update(...)`) and `db.smart_query(...)` construct plain Sets,
135+
which keep PyDAL's original-query after-hook semantics: their hooks receive that plain `Set` without
136+
`affected_ids`. Because the original query may no longer match the updated rows, TypeDAL's cache invalidation
137+
drops every cached result that depends on the table in that case.
113138

114139
### Validation and general update-or-insert
115140

‎docs/2_defining_tables.md‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ This can be done just as web2py does (
117117
see [their docs](http://www.web2py.com/books/default/chapter/29/06/the-database-abstraction-layer#callbacks-on-record-insert-delete-and-update))
118118

119119
```python
120-
from typedal import TypedTable
120+
from typedal import AffectedSet, TypedTable
121121
from typedal.types import OpRow, Reference, Set
122122

123123

@@ -144,8 +144,12 @@ def my_before_update(query: Set, changes: OpRow):
144144
# return True to cancel
145145

146146

147-
def my_after_update(query: Set, changes: OpRow):
148-
"""`changes` that were applied to the row selection Set"""
147+
def my_after_update(rows: AffectedSet, changes: OpRow):
148+
"""
149+
`changes` that were applied. `rows` selects exactly the updated rows (by id), even if the update
150+
changed the columns the original query filtered on. `rows.affected_ids` lists their ids.
151+
Only called when at least one row was updated.
152+
"""
149153

150154

151155
MyTable.before_update(my_before_update)
@@ -168,5 +172,10 @@ row.delete_record() # to trigger
168172
MyTable.where(...).delete() # to trigger
169173
```
170174

175+
Since 6.0, `after_update` receives an `AffectedSet` instead of a Set with the original update query
176+
(see [10. Advanced APIs](./10_advanced_apis.md#affected-id-update-hooks-since-60)).
177+
Before-hooks also accept an `upsert=` policy, which decides what `upsert()` does with them,
178+
since upsert can't run before-hooks (see [Unique-key upsert](./10_advanced_apis.md#unique-key-upsert-in-v6)).
179+
171180
Now that we have some tables, it's time to actually query them! Let's go
172181
to [3. Building Queries](./3_building_queries.md) to learn how.

‎docs/3_building_queries.md‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,46 @@ Person.join("articles", method="inner") # will only yield persons that have rel
169169

170170
For more details about relationships and joins, see [4. Relationships](./4_relationships.md).
171171

172+
### cross_join
173+
174+
Since 6.0, a query that mentions a table without relating it to the rest of the query raises
175+
`ImplicitCrossJoinError` instead of silently producing a `CROSS JOIN` (every row combined with every other row).
176+
This catches a common mistake: filtering on a table that isn't joined.
177+
178+
```python
179+
from typedal import ImplicitCrossJoinError
180+
181+
Person.where(Article.title == "Hello") # raises ImplicitCrossJoinError when the query is built
182+
Person.select(Person.ALL, Article.ALL) # same: nothing relates article to person
183+
184+
# fine, the comparison links the two tables:
185+
Person.where(Person.id == Article.author)
186+
# also fine: any comparison mentioning both tables counts as a link
187+
Person.where((Person.age + Article.word_count) > 1000)
188+
```
189+
190+
If you really want a cross join, ask for it explicitly with `cross_join()`.
191+
Tables reached through a cross-joined table are accepted too:
192+
193+
```python
194+
Person.cross_join(Color) # every person combined with every color
195+
Person.cross_join(Article).where(Article.id == Comment.article) # comment is linked via article
196+
```
197+
198+
`AliasedTableMismatchError` (a subclass of `ImplicitCrossJoinError`) is raised when you filter on a table
199+
that is also joined through a relationship. A relationship join uses an alias, so `where(Article.x > 0)` refers to a
200+
*second*, unjoined copy of `article`. Put the condition on the relationship instead:
201+
202+
```python
203+
# wrong: Article here is not the joined (aliased) articles table
204+
Person.join("articles", method="inner").where(Article.published == True)
205+
206+
# right:
207+
Person.join("articles", method="inner", condition_and=lambda person, article: article.published == True)
208+
```
209+
210+
If you do want that second, independent copy, `cross_join(Article)` makes it explicit and exempts it from this check.
211+
172212
### groupby & having
173213

174214
Group query results by one or more fields, typically used with aggregate functions like `count()`, `sum()`, `avg()`,

0 commit comments

Comments
 (0)