Skip to content

feat: per-dialect optimal upsert_many (MERGE / ON CONFLICT / INSERT OR UPDATE)#740

Open
cofin wants to merge 15 commits into
litestar-org:mainfrom
cofin:feat/optimal-upsert-many
Open

feat: per-dialect optimal upsert_many (MERGE / ON CONFLICT / INSERT OR UPDATE)#740
cofin wants to merge 15 commits into
litestar-org:mainfrom
cofin:feat/optimal-upsert-many

Conversation

@cofin

@cofin cofin commented May 24, 2026

Copy link
Copy Markdown
Member

Summary

  • Repository.upsert_many and Service.upsert_many now dispatch to the optimal native upsert primitive per dialect when match_fields maps to a PK / UniqueConstraint / unique Index — INSERT … ON CONFLICT DO UPDATE (postgresql/cockroachdb/sqlite/duckdb/mysql/mariadb), MERGE (oracle/mssql), or INSERT OR UPDATE (spanner) — compiled to one statement per chunk.
  • Dispatch is decided once per (table, match_fields, dialect) and cached via functools.lru_cache; a safety gate ensures non-unique match keys silently fall back to the historical SELECT-then-partition path.
  • New chunk_size kwarg on upsert_many mirroring add_many. no_merge=True now does what it always documented but never delivered — forces the fallback path.

Dispatch matrix

Dialect Native statement RETURNING Hydration
postgresql, cockroachdb, sqlite, duckdb INSERT … ON CONFLICT DO UPDATE .returning(model_type) + session.scalars
mysql, mariadb INSERT … ON DUPLICATE KEY UPDATE re-SELECT on match_fields
oracle MERGE INTO … USING (SELECT … FROM DUAL UNION ALL …) … re-SELECT
mssql MERGE INTO … USING (VALUES (…)) AS src(…) … OUTPUT inserted.*; re-SELECT
spanner INSERT OR UPDATE INTO … (…) VALUES (…) re-SELECT
anything else fallback: SELECT + add_many + update_many n/a ORM identity map

📚 Documentation preview: https://litestar-org.github.io/advanced-alchemy-docs-preview/740

@cofin
cofin requested review from a team as code owners May 24, 2026 23:29
@cofin cofin changed the title feat: per-dialect optimal upsert_many (MERGE / ON CONFLICT / INSERT OR UPDATE) feat: per-dialect optimal upsert_many (MERGE / ON CONFLICT / INSERT OR UPDATE) May 24, 2026
@cofin
cofin force-pushed the feat/optimal-upsert-many branch from 89c824e to e61dc85 Compare May 24, 2026 23:38
cofin added a commit to cofin/advanced-alchemy that referenced this pull request May 25, 2026
Two regressions from 116b813 (revert of mssql/spanner from
``OnConflictUpsert.supports_native_upsert``) surfaced on PR litestar-org#740:

1. ``test_supports_native_upsert_all_dialects`` (mssql_engine /
   mssql_async_engine) — the integration ``expected_support`` set still
   listed ``mssql`` and ``spanner``. The unit-test mirror was updated in
   the revert but this integration assertion was left behind. Trim it
   back to the single-row ON-CONFLICT dialects and document the split.

2. ``test_upsert_many_chunk_size_emits_multiple_chunks`` (and its sibling
   ``test_upsert_many_native_path_is_single_statement_per_chunk``) —
   the bigint guard introduced by the revert checked
   ``issubclass(author_model, BigIntBase)``, but the integration
   fixture uses ``BigIntAuditBase``, which shares only the
   ``BigIntPrimaryKey`` mixin with ``BigIntBase`` (no subclass
   relationship). The skip never triggered for ``oracle*``/``mssql``,
   so the safety-net fallback in ``Repository.upsert_many`` ran and
   produced one bulk INSERT — failing the ``>= 2 chunks`` assertion.
   The sibling test happened to pass by coincidence (``insertmanyvalues``
   produced exactly the one statement it expected). Switch the guard to
   ``BigIntPrimaryKey`` so any bigint-PK base catches it.
@codecov-commenter

codecov-commenter commented May 25, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 48.36957% with 95 lines in your changes missing coverage. Please review.
✅ Project coverage is 80.88%. Comparing base (e6c2563) to head (00e3c8a).

Files with missing lines Patch % Lines
advanced_alchemy/repository/_sync.py 14.28% 76 Missing and 2 partials ⚠️
advanced_alchemy/repository/_async.py 83.51% 9 Missing and 6 partials ⚠️
advanced_alchemy/repository/memory/_async.py 0.00% 1 Missing ⚠️
advanced_alchemy/repository/memory/_sync.py 0.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #740      +/-   ##
==========================================
- Coverage   81.56%   80.88%   -0.69%     
==========================================
  Files         103      103              
  Lines        8730     8902     +172     
  Branches     1179     1219      +40     
==========================================
+ Hits         7121     7200      +79     
- Misses       1281     1370      +89     
- Partials      328      332       +4     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

cofin added a commit to cofin/advanced-alchemy that referenced this pull request May 30, 2026
Two regressions from 116b813 (revert of mssql/spanner from
``OnConflictUpsert.supports_native_upsert``) surfaced on PR litestar-org#740:

1. ``test_supports_native_upsert_all_dialects`` (mssql_engine /
   mssql_async_engine) — the integration ``expected_support`` set still
   listed ``mssql`` and ``spanner``. The unit-test mirror was updated in
   the revert but this integration assertion was left behind. Trim it
   back to the single-row ON-CONFLICT dialects and document the split.

2. ``test_upsert_many_chunk_size_emits_multiple_chunks`` (and its sibling
   ``test_upsert_many_native_path_is_single_statement_per_chunk``) —
   the bigint guard introduced by the revert checked
   ``issubclass(author_model, BigIntBase)``, but the integration
   fixture uses ``BigIntAuditBase``, which shares only the
   ``BigIntPrimaryKey`` mixin with ``BigIntBase`` (no subclass
   relationship). The skip never triggered for ``oracle*``/``mssql``,
   so the safety-net fallback in ``Repository.upsert_many`` ran and
   produced one bulk INSERT — failing the ``>= 2 chunks`` assertion.
   The sibling test happened to pass by coincidence (``insertmanyvalues``
   produced exactly the one statement it expected). Switch the guard to
   ``BigIntPrimaryKey`` so any bigint-PK base catches it.
@cofin
cofin force-pushed the feat/optimal-upsert-many branch from c159a70 to d80bb6b Compare May 30, 2026 00:13
cofin added 13 commits May 30, 2026 16:22
Add OnConflictUpsert.create_upsert_many and create_merge_many so a single
call can compile to one INSERT...VALUES(...), (...) per chunk on the
ON CONFLICT dialects, and to a single multi-row MERGE on Oracle / PG /
MSSQL — establishing the foundation for repository.upsert_many to dispatch
to a native primitive in Ch.3.

create_upsert_many:
  - postgresql / cockroachdb / sqlite / duckdb -> pg_insert(...).values(list)
    .on_conflict_do_update(...); supports_returning=True
  - mysql / mariadb -> mysql_insert(...).values(list)
    .on_duplicate_key_update(...); supports_returning=False
  - empty list and heterogeneous-key inputs raise ValueError

create_merge_many:
  - oracle -> single MergeStatement whose source is
    select(...) FROM DUAL UNION ALL select(...) FROM DUAL, with per-row
    bindparam namespacing (row{i}_*) and per-row PK default generation
  - postgresql / cockroachdb -> single MergeStatement whose source is
    select(...) UNION ALL select(...) subquery
  - mssql -> single MergeStatement with raw "VALUES (...), (...) AS src(...)"
    source string; @compiles(MergeStatement, "mssql") body lands in Ch.4
  - other dialects -> list[MergeStatement] executemany fallback

Adds _validate_bulk_inputs helper and three private builders to keep the
public methods short. validate_identifiers=True still rejects bad column
names on the bulk path.

T1 (extending supports_native_upsert to include mssql/spanner) deferred to
Ch.4/Ch.5 where the MergeStatement compile actually lands — flipping it on
in Ch.1 would regress existing spanner Litestar integration tests because
@compiles(MergeStatement, "mssql"/"spanner") doesn't exist yet. Saga-level
acceptance gate ("Litestar single-row callers unaffected") preserved.
Add module-level resolve_upsert_strategy(table, match_fields, dialect_name)
plus the UpsertStrategy NamedTuple it returns. Caches per (id(table),
sorted-match-fields, dialect) via functools.lru_cache(maxsize=None) — single
introspection per declarative class, mirroring the existing _pk_attr_names
/ has_composite_pk pattern.

Resolution priority:
  1. match_fields superset PK -> native primitive, conflict_columns=PK
  2. match_fields == UniqueConstraint columns -> native, that constraint
  3. match_fields == unique Index columns -> native, that index
  4. otherwise -> kind="fallback" (correctness anchor — non-unique match
     keys would silently insert duplicates on the native path)

Dialect -> kind mapping:
  postgresql/cockroachdb/sqlite/duckdb -> on_conflict, returning=True
  mysql/mariadb                        -> on_conflict, returning=False
  oracle/mssql                         -> merge, returning=True
  spanner                              -> insert_or_update, returning=False
  anything else                        -> fallback, returning=False

ValueError raised early if match_fields is empty or references columns
not present on the table.
Add @compiles(MergeStatement, "mssql") emitting the T-SQL form

  MERGE INTO {table} AS tgt
  USING (VALUES (...)) AS src(...)
  ON tgt.col = src.col [AND ...]
  WHEN MATCHED THEN UPDATE SET tgt.col = src.col [, ...]
  WHEN NOT MATCHED THEN INSERT (...) VALUES (src...)
  OUTPUT inserted.*;

The trailing semicolon is mandatory for T-SQL MERGE — without it SQL
Server raises a syntax error. ``OUTPUT inserted.*`` is the RETURNING
equivalent so the Ch.6 hydration helper can rebuild instances from the
cursor result without re-SELECT on mssql.

Pairs with create_merge_many's mssql branch (Ch.1) which builds the
raw-string "VALUES (...), (...) AS src(...)" source — this compiler emits
it verbatim.
Cloud Spanner has no SQL MERGE; the closest DML form is
``INSERT OR UPDATE INTO {table} ({cols}) VALUES (...), (...)``. Model it
with a dedicated SpannerUpsert ClauseElement instead of overloading
MergeStatement, because the syntax has no USING / WHEN MATCHED shape.

Adds:
- SpannerUpsert(Executable, ClauseElement) — constructor validates
  non-empty values_list and homogeneous row keys
- @compiles(SpannerUpsert) default — raises NotImplementedError for
  non-spanner dialects
- @compiles(SpannerUpsert, "spanner") — emits
  INSERT OR UPDATE INTO {tbl} ({cols}) VALUES (...), (...)

INSERT OR UPDATE has no RETURNING/OUTPUT, so resolve_upsert_strategy
already returns supports_returning=False for "spanner"; the Ch.6
hydration helper re-SELECTs in that case.

The PK must be present in every row (Spanner does not auto-generate PKs
via DML); callers are responsible for supplying it. Mutation API path
remains out of scope for this saga.
….3 + Ch.6)

Replace the SELECT-then-partition body of Repository.upsert_many with a
resolver-driven dispatch into the dialect's optimal upsert primitive,
while preserving the old behavior as _upsert_many_fallback for no_merge=True
and for dialects/match_fields the resolver cannot prove unique.

Dispatch:
  - kind="on_conflict" + supports_returning -> OnConflictUpsert.create_upsert_many
    + .returning(model_type), session.scalars hydrates attached instances
    (postgresql, cockroachdb, sqlite, duckdb)
  - kind="on_conflict" + !supports_returning -> create_upsert_many + execute,
    hydrate via re-SELECT keyed on match_fields (mysql, mariadb)
  - kind="merge" -> create_merge_many (oracle / mssql, single-statement
    MERGE per chunk), hydrate via re-SELECT
  - kind="insert_or_update" -> SpannerUpsert, hydrate via re-SELECT
  - kind="fallback" or no_merge=True -> _upsert_many_fallback (verbatim
    extract of the previous body)

Adds:
  - chunk_size kwarg mirroring add_many; sized via
    _get_insertmanyvalues_max_parameters // len(input_dicts[0])
  - _prepare_upsert_row: model_to_dict + drop None-valued PK columns so
    autoincrement / sequence DEFAULTs fire on the DB side; Python-side
    UUID/callable defaults from __init__ are preserved
  - _execute_upsert_chunk + _reselect_upsert_chunk private helpers

Drops the unused POSTGRES_VERSION_SUPPORTING_MERGE constant — dialect
gating now lives inside operations.resolve_upsert_strategy.

Regenerates _sync.py via unasyncd.

Adds the deferred T1 from Ch.1: extend OnConflictUpsert.supports_native_upsert
to include mssql + spanner, and add mssql/spanner branches to create_upsert
that delegate to create_merge_many (single-row list -> MergeStatement) and
SpannerUpsert respectively. Now safe because Ch.4 and Ch.5 supplied the
@compiles bodies. The Litestar single-row store/session callers transparently
get native dispatch on mssql and spanner with no caller changes.

Verified on aiosqlite: 106 integration tests pass, including the 4 existing
upsert_many tests and the composite-PK upsert_many cases.
- Add ``chunk_size: Optional[int] = None`` to SQLAlchemyAsyncService.upsert_many
  and pass through to Repository.upsert_many.
- Replace the "(**reserved for future use**)" no_merge docstring with a real
  description matching the Ch.3 behavior: it now forces the SELECT+partition
  fallback path on dialects that otherwise resolve to a native primitive.
- Mirror the new kwarg in SQLAlchemyAsyncMockRepository.upsert_many to keep
  the service signature compatible with the in-memory test backend.
- Regenerate service/_sync.py + memory/_sync.py via unasyncd.
… assertions (Ch.8)

Three new integration tests using SQLAlchemy 'before_execute' event listeners:

- test_upsert_many_native_path_is_single_statement_per_chunk:
  on a native-capable dialect, upsert_many must emit exactly one
  INSERT/MERGE/INSERT-OR-UPDATE statement per chunk, never the historical
  1-SELECT + N-add/update fallback shape. Saga acceptance gate.

- test_upsert_many_no_merge_forces_fallback:
  with no_merge=True, the SELECT+INSERT shape is back — proving the
  escape hatch still routes through _upsert_many_fallback.

- test_upsert_many_chunk_size_emits_multiple_chunks:
  passing chunk_size=4 with 8 rows produces >=2 upsert statements,
  proving the chunking respects the kwarg.

Tests are dialect-aware: native-path assertions skip when the dialect
isn't native-capable; the fallback test runs everywhere. Full integration
matrix (postgres, oracle, mssql, spanner, mysql, cockroachdb, duckdb)
runs automatically in CI via pytest-databases markers.
…h.9)

- docs/usage/repositories/basics.rst: add an "Upsert Many" subsection
  with the full dispatch matrix (dialect / native statement / RETURNING /
  hydration path), the match-key safety gate rationale, the no_merge=True
  escape-hatch semantics, and the new chunk_size kwarg.

- docs/changelog.rst: add 1.11.0 entry summarising the saga — new bulk
  facade (create_upsert_many / create_merge_many), SpannerUpsert + its
  compile, MSSQL MERGE compile, resolve_upsert_strategy resolver,
  chunk_size kwarg, real no_merge behavior, mssql/spanner inclusion in
  supports_native_upsert, removal of the dead
  POSTGRES_VERSION_SUPPORTING_MERGE constant. Explicit: no breaking API
  changes.
Drop the Internals bullet list and the "No breaking API changes" closing
paragraph — keep just the one-paragraph description of the dispatch
behavior. Implementation details live in the commit history.
CI surfaced several issues with the per-dialect upsert_many rollout. All
addressed without changing the saga's public API.

MSSQL bind translation
----------------------
The Ch.1 _build_mssql_bulk_merge produced a raw-string source
``"VALUES (:row0_key, ...) AS src(...)"`` — SQLAlchemy treats the string as
opaque text, so the ``:name`` markers never get translated to pyodbc's
``?`` qmark placeholders. Result: ``[SQL Server]Incorrect syntax near ':'``
on every MSSQL upsert path. Switch the source to
``text("(VALUES (:row0_key, ...)) AS src(...)").bindparams(*bp)`` so the
binds are part of the compiler's AST and get qmark-translated at execute
time. Move the alias outside the inner parens so the wrapped output is
``USING (VALUES (?, ?, ?), (?, ?, ?)) AS src(...) ON ...`` (T-SQL-correct).

Native-path defaults (Oracle ORA-01400, MySQL hydration)
-------------------------------------------------------
_prepare_upsert_row now invokes Python-side callable defaults
(``default=uuid7``, ``default=datetime.utcnow``, etc.) for columns missing
from the instance — the native dispatch path bypasses SQLAlchemy's ORM
flush where these would normally fire, and a hand-built MergeStatement
only emits the columns we hand it. Fixes ORA-01400 on
``UserRole.assigned_at`` (NOT NULL, Python default) and lets the
``mysql/mariadb`` re-SELECT hydration find the rows it just inserted on
UUID-PK models.

create_upsert mssql/spanner branches accept list-of-dicts
---------------------------------------------------------
Some integration tests pass a list to the single-row ``create_upsert``
API (works on pg/mysql because ``Insert.values(list)`` is accepted). The
new mssql / spanner delegations forward into ``create_merge_many`` /
``SpannerUpsert`` which require a real ``list[dict]`` shape. Added a
``_coerce_values_list`` helper that normalizes the input.

Litestar session.py mock unit tests
-----------------------------------
The mock fixtures set ``dialect.name = "mssql"`` with the comment "Use
dialect that forces fallback path in tests". With T1 adding mssql to
supports_native_upsert, mssql now triggers native dispatch — bypassing the
mocked _get_session_obj / add the tests asserted. Change the mock dialect
to ``"unknown_dialect"`` so the comment's intent is preserved.

Integration test fixes
----------------------
- ``test_supports_native_upsert_all_dialects`` expected set now includes
  ``mssql`` and ``spanner``.
- The native-path / chunk_size assertion tests compile statements with
  the live connection's dialect before stringifying (``str(clauseelement)``
  triggered ``compile_merge_default`` and raised NotImplementedError on
  MergeStatement / SpannerUpsert).
- The native-path single-statement-per-chunk test now skips on mysql /
  mariadb / asyncmy: those dialects can't hydrate autoincrement-PK rows
  without RETURNING, so ``match_fields=['id']`` is a degenerate setup.
- Unit test for ``create_merge_many`` mssql branch now asserts compiled
  shape (VALUES + AS SRC) rather than raw-string structure.
…ting, zero-arg defaults

Three regressions found in the second CI run:

1. NotNullViolationError on id for bigint models (postgres/oracle/mssql)
   -----------------------------------------------------------------
   BigIntPrimaryKey uses ``mapped_column(BigIntIdentity, Sequence(...))``,
   so the column has a ``default`` attribute set to a Sequence instance
   (not a Python callable / value). The previous _prepare_upsert_row
   logic took the ``not callable(arg)`` branch, set ``prepared[id] = arg``
   (which is None for Sequence defaults), and emitted
   ``INSERT (id, …) VALUES (NULL, …)``. PostgreSQL rightly rejected it.

   Now: only invoke ``arg`` when it's callable; the non-callable branch
   only fires when ``arg is not None`` (real literal default); the
   cleanup branch pops PK / default / server_default columns whose value
   is still None so the database supplies them.

2. uuid7 default not invoked (mssql session NULL id)
   -------------------------------------------------
   SessionModelMixin uses UUIDv7Base whose ``id`` has ``default=uuid7``
   — a *zero-arg* callable. The previous code only tried
   ``arg(ExecutionContext=None)`` and TypeError'd on uuid7's
   no-arg signature, so the column was dropped instead of populated.
   MSSQL session model has id NOT NULL with no server default → 23000
   IntegrityError.

   Added _invoke_default(callable_default) module-level helper that
   tries the context-taking form first, falls back to the zero-arg
   form (matching SQLAlchemy's own ColumnDefault.invoke_default
   semantics).

3. MSSQL Incorrect syntax near 'key' (T-SQL reserved word)
   -------------------------------------------------------
   The text() VALUES source emitted ``AS src(key, namespace, value)``
   with unquoted identifiers; ``key`` is a T-SQL reserved word so
   pyodbc rejected the statement.

   _build_mssql_bulk_merge now bracket-quotes ``[key]`` in the alias
   column list, the ON clause (``tgt.[key] = src.[key]``), and the
   WHEN MATCHED / WHEN NOT MATCHED source references. The
   @compiles(MergeStatement, "mssql") body also routes every emitted
   table/column identifier through ``compiler.preparer.quote()`` so
   reserved words survive on the target/source sides too.
…ert path (MSSQL session)

The MSSQL Litestar session backend invokes
``OnConflictUpsert.create_upsert(values={session_id, data, expires_at})``
— it doesn't pass ``id`` because it expects the model's ``default=uuid7``
factory to fire at SQLAlchemy flush time. The native MERGE dispatch
bypasses flush, so without intervention the emitted INSERT omits ``id``
and SQL Server rejects it: ``Cannot insert NULL into column 'id'``.

Add module-level helpers in operations.py:

- ``invoke_python_default(arg)`` — calls the ColumnDefault.arg callable,
  trying the context-taking form first then the zero-arg form, returning
  ``DEFAULT_INVOKE_FAILED`` if both raise. Replaces the inline
  ``arg(None)`` calls in create_merge_upsert, _collect_oracle_pk_defaults,
  and the repository's _prepare_upsert_row that were silently dropping
  zero-arg defaults like ``uuid7``.

- ``augment_with_pk_defaults(table, values_list)`` — for every PK column
  not in values_list[0] with a Python-callable default, invoke it once
  per row and add the result. Wired into ``create_merge_many`` and
  ``SpannerUpsert.__init__`` so the Litestar single-row / bulk callers
  pick up the same auto-population the Repository.upsert_many path
  already got via ``_prepare_upsert_row``.

Promoted both to public (no leading underscore) since they're now
imported across modules.

Verified by simulating the Litestar session call locally: the emitted
MERGE now includes ``id`` in both the alias column list and the WHEN NOT
MATCHED THEN INSERT clause, with a freshly invoked uuid4/uuid7 bound
parameter.
…ty net

Two related fixes for the remaining CI regressions on MSSQL and Oracle:

1. Revert T1 — remove mssql/spanner from ``OnConflictUpsert.supports_native_upsert``
   ----------------------------------------------------------------------------
   The original Ch.1 spec wanted T1 to flip these on so the Litestar single-row
   ``create_upsert`` API would transparently dispatch to MERGE / INSERT OR UPDATE.
   In practice that broke two scenarios:

   - Litestar's session/store with a UUIDv7 PK model would work, but every other
     non-Repository caller (e.g. ``test_store_upsert_integration`` with BigInt PK,
     unit-test mocks set to ``dialect.name = "mssql"``) hits the
     same MERGE compile and fails because hand-built MERGE doesn't auto-invoke
     server-side Sequence/Identity defaults the way PG's ON CONFLICT does.
   - Even when the Python ``default=uuid7`` worked, the surface area exposed
     a lot of edge cases (NULL-allowed columns, BigInt+Sequence, T-SQL reserved
     words in column lists) that don't bring proportional value compared to the
     existing Litestar fallback path.

   So: pull mssql/spanner back out of ``supports_native_upsert``, drop the
   ``create_upsert`` delegations to ``MergeStatement`` / ``SpannerUpsert``, and
   restore the ``Insert`` return type. The single-row API is again limited to
   true ON-CONFLICT dialects. The bulk MERGE / INSERT OR UPDATE primitives
   remain available via ``Repository.upsert_many`` (which goes through the
   resolver) for callers that opt into them with a complete row shape.

2. Repository.upsert_many safety net for missing PKs on MERGE dialects
   -------------------------------------------------------------------
   Even via the resolver, MSSQL/Oracle/Spanner MERGE can't auto-invoke
   Sequence/Identity defaults for PK columns we omit (PG's INSERT...ON CONFLICT
   does via column DEFAULT, but custom @compiles bodies don't have the
   pre-execute hook SQLAlchemy uses to satisfy Sequence). Added
   ``_native_path_missing_required_pks`` — when the resolved strategy is
   ``merge`` or ``insert_or_update`` and any prepared row is missing a PK
   column, fall back to ``_upsert_many_fallback`` (ORM-managed inserts handle
   the Sequence properly).

   Models with Python-callable PK defaults (UUIDv7Base / UUIDAuditBase) still
   take the native MERGE path because ``_prepare_upsert_row`` populates the
   value before dispatch.

Tests touched:
   - Unit ``test_supports_native_upsert`` updated to assert mssql/spanner are
     now False (and the per-delegation tests for them are removed).
   - ``test_upsert_many_native_path_is_single_statement_per_chunk`` skips on
     ``BigIntBase`` models with oracle/mssql — the new safety net falls those
     combinations back to the ORM-managed path so the single-statement
     assertion doesn't apply.
cofin added 2 commits May 30, 2026 16:22
Two regressions from 116b813 (revert of mssql/spanner from
``OnConflictUpsert.supports_native_upsert``) surfaced on PR litestar-org#740:

1. ``test_supports_native_upsert_all_dialects`` (mssql_engine /
   mssql_async_engine) — the integration ``expected_support`` set still
   listed ``mssql`` and ``spanner``. The unit-test mirror was updated in
   the revert but this integration assertion was left behind. Trim it
   back to the single-row ON-CONFLICT dialects and document the split.

2. ``test_upsert_many_chunk_size_emits_multiple_chunks`` (and its sibling
   ``test_upsert_many_native_path_is_single_statement_per_chunk``) —
   the bigint guard introduced by the revert checked
   ``issubclass(author_model, BigIntBase)``, but the integration
   fixture uses ``BigIntAuditBase``, which shares only the
   ``BigIntPrimaryKey`` mixin with ``BigIntBase`` (no subclass
   relationship). The skip never triggered for ``oracle*``/``mssql``,
   so the safety-net fallback in ``Repository.upsert_many`` ran and
   produced one bulk INSERT — failing the ``>= 2 chunks`` assertion.
   The sibling test happened to pass by coincidence (``insertmanyvalues``
   produced exactly the one statement it expected). Switch the guard to
   ``BigIntPrimaryKey`` so any bigint-PK base catches it.
Post-revert (116b813), the docstring on
``OnConflictUpsert.supports_native_upsert`` was still the generic
"Check if the dialect supports native upsert operations." Replace it
with the precise scope: the flag is for the single-row
``create_upsert`` API on true ON-CONFLICT dialects; MERGE / INSERT
OR UPDATE go through ``Repository.upsert_many`` and aren't reported
here.

Also extend ``docs/usage/repositories/basics.rst`` with a "Server-managed
PK safety net" bullet documenting the resolver's transparent fallback
when MERGE / INSERT OR UPDATE rows are missing PK columns — the same
behavior the new ``BigIntPrimaryKey`` skip guards in the chunk-size
tests exist to side-step.
@cofin
cofin force-pushed the feat/optimal-upsert-many branch from d80bb6b to 00e3c8a Compare May 30, 2026 21:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants