@@ -52,64 +52,89 @@ user = await User.upsert_async({"email": "a@example.com"}, name="Bob")
5252```
5353
5454The 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") ` .
5760A call with only a key returns an existing row unchanged, without after-hooks,
5861or 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
7281Before-hooks never run on either path because the branch is unknown beforehand.
7382After-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
7584update set is restricted to the affected ID, ignoring common filters so the hook
7685can 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
8190User.before_insert(validate_user, upsert = " error" ) # Require update_or_insert instead.
8291User.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
90104Inserts apply field defaults and computations. Upsert updates write only explicitly
91105supplied values: omitted ` Field(update=...) ` and ` compute ` fields stay unchanged.
92106Supply these values explicitly if they need to change. TypeDAL cache invalidation
93107runs 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
97117Updates 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.
104131Raw 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
0 commit comments