Skip to content

release: typedal 6.0 - #18

Merged
robinvandernoord merged 23 commits into
masterfrom
release/typedal-v6
Oct 5, 2026
Merged

robinvandernoord merged 23 commits into
masterfrom
release/typedal-v6

Conversation

@robinvandernoord

@robinvandernoord robinvandernoord commented Oct 1, 2026 •

Copy link
Copy Markdown
Member

TypeDAL 6.0: Upsert, No Cross Feelings

Release date: 05-10-2026


TypeDAL 6.0 adds unique-key upserts and stops queries from silently turning into cross joins. It also changes what after-update hooks receive and how the cache API is called, so read the migration section before upgrading.

What's New

Unique-key upserts

upsert(key, **values) inserts or updates a row by a unique key and returns the typed instance. upsert_async has the same contract.

user = User.upsert({"email": "a@example.com"}, name="Alice")
user = await User.upsert_async({"email": "a@example.com"}, name="Bob")

On PostgreSQL this is an atomic INSERT ... ON CONFLICT ... RETURNING, which requires a matching unique constraint or index. SQLite, MySQL, and PostgreSQL tables with common filters or a request_tenant field use a lookup followed by an insert or update. That fallback respects your filters but isn't atomic under concurrent writes, so keep a database unique constraint on the key. It raises UpsertAmbiguityError when the key matches more than one row.

Upsert can't know beforehand whether it will insert or update, so before-hooks never run. After-hooks run for the branch that actually happened. Every before-hook you register now accepts an upsert= policy that decides what happens instead:

User.before_insert(validate_user, upsert="error")    # upsert() raises UpsertHookError
User.before_update(normalize_user, upsert="ignore")  # skipped silently during upsert

An unmarked before-hook emits UpsertHooksWarning and is skipped. Invalid keys (empty, containing id or None, non-scalar values, overlapping with the values) raise UpsertKeyError before any SQL runs.

No more implicit cross joins

A query that mentions a table without relating it to the rest of the query now raises ImplicitCrossJoinError when it's built, instead of quietly returning every row combined with every other row:

Person.where(Article.title == "Hello")             # ImplicitCrossJoinError
Person.where(Person.id == Article.author)          # fine: the comparison links both tables
Person.cross_join(Color)                           # fine: explicit

Filtering, ordering or grouping on a table that is joined through a relationship now targets that join. In v5 it hit a second, unjoined copy of the table. The filter applies to the joined rows as well, wherever where() appears relative to join():

Person.join("articles", method="inner").where(Article.published == True)  # persons with only their published articles
Person.join("articles").where(Article.id == None)                          # persons without any article

When a plain table name can't be resolved to a single join, AliasedTableMismatchError (a subclass of ImplicitCrossJoinError) is raised. That happens when the same table is joined more than once, or when a joined table is compared with another table (Article.reviewer == Author.id). Use a lambda argument named after the relationship to pick the join, or cross_join() for an independent copy:

builder = Article.join("writer").join("reviewer")  # both relationships point to Author
builder.where(Author.name == "ann")                 # AliasedTableMismatchError
builder.where(lambda article, reviewer: reviewer.name == "ann")

Affected-ID update hooks

After-update hooks now receive an AffectedSet that selects exactly the updated rows by primary key, rather than a Set holding the original update query. Hooks can therefore find rows even when the update changed the columns the query filtered on. rows.affected_ids lists the primary keys.

PostgreSQL and SQLite 3.35+ get these IDs from UPDATE ... RETURNING. MySQL and older SQLite select the IDs first (MySQL with FOR UPDATE) and then update exactly those rows, which costs one extra SELECT per update when an after-hook exists. TypeDAL's cache invalidation counts as one. QueryBuilder.update() now returns the IDs it actually updated, not the IDs that matched just before the update ran.

Breaking changes and how to migrate

1. Implicit cross joins raise. Run your test suite and look for ImplicitCrossJoinError. Each hit is either a bug (a filter on a table you forgot to join) or an intentional cross join. For the first, add the missing join condition. For the second, make it explicit:

# before
Person.where(Person.id > 0).select(Person.ALL, Color.ALL)
# after
Person.cross_join(Color).select(Person.ALL, Color.ALL)

Filters on joined tables now filter the join. A query such as Person.join("articles").where(Article.published == True) used to compare against a separate, unjoined copy of article. It now filters the joined articles, which can change the results of existing queries. Check every query that both joins a relationship and filters on that table by its plain name. If AliasedTableMismatchError is raised, choose the join with a lambda (.where(lambda person, articles: ...), using parent__child for nested joins). If you really want a separate copy, use cross_join(Article). delete() and update() ignore joins, so they reject these lambdas.

2. After-update hooks receive an AffectedSet. It's still a PyDAL Set subclass, so hooks that call .select(), .count() or similar keep working, and now see the updated rows rather than whatever the original query matches afterwards. Hooks that inspect set.query itself and expect the original condition need rewriting: that query is now an ID filter. Use rows.affected_ids if you need the keys. Reverse-reference sets (row.articles.update(...)) and db.smart_query(...) still pass a plain Set.

3. Cache maintenance functions take the database. Every database with caching enabled now gets its own cache models, so the helpers in typedal.caching need to know which one to act on:

# before
clear_cache()
clear_expired()
remove_cache_for_table("user")
# after
clear_cache(db)
clear_expired(db)
remove_cache_for_table(db, User)  # a table class or a table name

Code that queried the private _TypedalCache / _TypedalCacheDependency classes directly should use Cache, CacheDependency = cache_models(db). The table names (typedal_cache, typedal_cache_dependency) are unchanged, so no database migration is needed. The CLI commands already pass the database.

4. Minimum pydal is 20260520.0. UpdateSet relies on Set._apply_update, which first shipped in that release. pydal's newer 3.YYYYMMDD versions sort below it under PEP 440 and are excluded for now.

5. Decimal fields reject non-numeric values. pydal renders decimal values into SQL unquoted. TypeDAL now converts every value for a decimal field to Decimal first (inserts, updates, queries and upserts) and raises ValueError for anything that isn't a finite number. Valid numeric strings such as "12.50" keep working; "abc", NaN and Infinity no longer reach the database.

6. SlugMixin and TimestampsMixin block upsert(). Their before-hooks are registered with upsert="error", because skipping them would insert rows without a slug or leave updated_at stale. Use update_or_insert() on those tables.

Fixes

  • Relationship condition_and is now applied consistently, including when the join names the relationship as a string and when counting a joined query.
  • Multiple TypeDAL instances with caching enabled no longer share (and unbind) each other's cache models.
  • Async connections are closed properly when no connection pool is available.

Other notable changes since 5.1

  • Python 3.15 is now listed as supported, and TypeDAL is marked Production/Stable.
  • Typing aligned with pydal-stubs 0.2.0, including Rows JSON methods.
  • count(distinct=...) works for distinct field values.
  • No-op query builder calls now emit NoopQueryWarning.
  • New exception hierarchy: typedal.TypeDALError is the base class, and typedal.exceptions adds TypeDALQueryError, ImplicitCrossJoinError, AliasedTableMismatchError, UpsertKeyError, UpsertAmbiguityError and UpsertHookError. UpsertHooksWarning is in typedal.warnings.

Documentation

  • Documented upserts, upsert hook policies and the native/fallback paths in Advanced APIs.
  • Documented cross_join() and the implicit cross join errors in Building Queries.
  • Updated the hook examples in Defining Tables for AffectedSet.
  • Updated the cache maintenance examples in Memoization.

Changelog

For all details and previous releases, see: CHANGELOG.md

Full Changelog: v5.1.5...v6.0.0

Comment thread src/typedal/query_builder.py Outdated
"""Normalize join/left: PyDAL accepts a bare expression as well as a list of them."""
if value is None:
return []
if isinstance(value, (Expression, Table)):

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

elif

Comment thread src/typedal/query_builder.py Outdated
return []
if isinstance(value, (Expression, Table)):
return [value]
return list(value)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

else

Comment thread src/typedal/query_builder.py Outdated
return f"{key}_{hash(relation)}"


class _JoinedTable(t.NamedTuple):

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

don't start classes with a _

Comment thread src/typedal/query_builder.py Outdated
for item in node:
found |= _walk_tables(item, tables, parents)
return found
if isinstance(node, Field):

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

elif

Comment thread src/typedal/query_builder.py Outdated


# (id of the parent record, relationship path, related row id) -> the related instance attached to that parent
type SeenRelations = dict[tuple[int, str, t.Any], t.Any]

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

place types at the top of the file

Comment thread src/typedal/query_builder.py Outdated
_permissions: Permissions
cross_joins: list[t.Type[TypedTable]]
# where() calls with a lambda asking for joined tables; resolved at collect time, ANDed with `query`:
deferred_queries: list[tuple[t.Any, ...]]

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is it Any?

Comment thread src/typedal/query_builder.py Outdated

return []
# a TypedTable's primary key is always its integer id
return t.cast(list[int], t.cast(UpdateSet, db(self._mutation_query())).update_ids(**fields))

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this line is confusing, split it

Comment thread src/typedal/types.py
Template: t.TypeAlias = TemplateAlias # explicit export for mypy, NOT a `type` because it's used at runtime
type AnyCallable = t.Callable[..., t.Any]
type AnyDict = dict[str, t.Any]
type UpsertKeyValue = str | int | float | bool | bytes | Decimal | uuid.UUID | dt.date | dt.time

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is our minimum support python version high enough for the type keyword? Not all use it, we should pick one consistently

@robinvandernoord
robinvandernoord merged commit 8855a65 into master Oct 5, 2026
2 checks passed
@robinvandernoord
robinvandernoord deleted the release/typedal-v6 branch October 5, 2026 09:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Cache models are module-global: a second TypeDAL rebinds them, and close() unbinds them for everyone

1 participant