Repository navigation
release: typedal 6.0 - #18
Merged
Merged
Conversation
…rt explicit cross joins
…alandsuccess/TypeDAL into release/typedal-v6
…cess/TypeDAL into release/typedal-v6
…L into release/typedal-v6
…nto release/typedal-v6
30 of 34 tasks
robinvandernoord
commented
Oct 3, 2026
| """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)): |
| return [] | ||
| if isinstance(value, (Expression, Table)): | ||
| return [value] | ||
| return list(value) |
| return f"{key}_{hash(relation)}" | ||
|
|
||
|
|
||
| class _JoinedTable(t.NamedTuple): |
Member
Author
There was a problem hiding this comment.
don't start classes with a _
| for item in node: | ||
| found |= _walk_tables(item, tables, parents) | ||
| return found | ||
| if isinstance(node, Field): |
|
|
||
|
|
||
| # (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] |
Member
Author
There was a problem hiding this comment.
place types at the top of the file
| _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, ...]] |
|
|
||
| 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)) |
Member
Author
There was a problem hiding this comment.
this line is confusing, split it
| 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 |
Member
Author
There was a problem hiding this comment.
is our minimum support python version high enough for the type keyword? Not all use it, we should pick one consistently
…AL into release/typedal-v6
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_asynchas the same contract.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 arequest_tenantfield 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 raisesUpsertAmbiguityErrorwhen 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:An unmarked before-hook emits
UpsertHooksWarningand is skipped. Invalid keys (empty, containingidorNone, non-scalar values, overlapping with the values) raiseUpsertKeyErrorbefore 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
ImplicitCrossJoinErrorwhen it's built, instead of quietly returning every row combined with every other row: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 tojoin():When a plain table name can't be resolved to a single join,
AliasedTableMismatchError(a subclass ofImplicitCrossJoinError) 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, orcross_join()for an independent copy:Affected-ID update hooks
After-update hooks now receive an
AffectedSetthat selects exactly the updated rows by primary key, rather than aSetholding the original update query. Hooks can therefore find rows even when the update changed the columns the query filtered on.rows.affected_idslists the primary keys.PostgreSQL and SQLite 3.35+ get these IDs from
UPDATE ... RETURNING. MySQL and older SQLite select the IDs first (MySQL withFOR UPDATE) and then update exactly those rows, which costs one extraSELECTper 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: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 ofarticle. 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. IfAliasedTableMismatchErroris raised, choose the join with a lambda (.where(lambda person, articles: ...), usingparent__childfor nested joins). If you really want a separate copy, usecross_join(Article).delete()andupdate()ignore joins, so they reject these lambdas.2. After-update hooks receive an
AffectedSet. It's still a PyDALSetsubclass, 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 inspectset.queryitself and expect the original condition need rewriting: that query is now an ID filter. Userows.affected_idsif you need the keys. Reverse-reference sets (row.articles.update(...)) anddb.smart_query(...)still pass a plainSet.3. Cache maintenance functions take the database. Every database with caching enabled now gets its own cache models, so the helpers in
typedal.cachingneed to know which one to act on:Code that queried the private
_TypedalCache/_TypedalCacheDependencyclasses directly should useCache, 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.UpdateSetrelies onSet._apply_update, which first shipped in that release. pydal's newer3.YYYYMMDDversions 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
decimalfield toDecimalfirst (inserts, updates, queries and upserts) and raisesValueErrorfor anything that isn't a finite number. Valid numeric strings such as"12.50"keep working;"abc",NaNandInfinityno longer reach the database.6.
SlugMixinandTimestampsMixinblockupsert(). Their before-hooks are registered withupsert="error", because skipping them would insert rows without a slug or leaveupdated_atstale. Useupdate_or_insert()on those tables.Fixes
condition_andis now applied consistently, including when the join names the relationship as a string and when counting a joined query.TypeDALinstances with caching enabled no longer share (and unbind) each other's cache models.Other notable changes since 5.1
pydal-stubs0.2.0, includingRowsJSON methods.count(distinct=...)works for distinct field values.NoopQueryWarning.typedal.TypeDALErroris the base class, andtypedal.exceptionsaddsTypeDALQueryError,ImplicitCrossJoinError,AliasedTableMismatchError,UpsertKeyError,UpsertAmbiguityErrorandUpsertHookError.UpsertHooksWarningis intypedal.warnings.Documentation
cross_join()and the implicit cross join errors in Building Queries.AffectedSet.Changelog
For all details and previous releases, see: CHANGELOG.md
Full Changelog: v5.1.5...v6.0.0