feat(orm): add async Eloquent-style upsert()#128
Closed
bedus-creation wants to merge 1 commit into
Closed
Conversation
Add an async upsert() that emits a SINGLE bulk statement and is dialect-correct across postgres, sqlite, and mysql using SQLAlchemy dialect insert helpers: - postgres/sqlite: INSERT ... ON CONFLICT (unique_by) DO UPDATE SET col = excluded.col - mysql: INSERT ... ON DUPLICATE KEY UPDATE col = VALUES(col) Exposed on both Model (async classmethod) and QueryBuilder. Signature mirrors Eloquent: upsert(values, unique_by, update=None) where update defaults to all provided columns except the unique_by keys. Casts are applied per-row via the model Caster (same set-cast behavior as the insert path). Timestamps stay consistent with the current insert() path — no auto-injection on INSERT; explicit created_at/updated_at in a row dict are honored; updated_at is refreshed on the DO UPDATE branch only when __timestamps__ is enabled and the model declares updated_at. Execution routes through a new Connection.upsert() that runs the prepared SQLAlchemy construct and returns the affected rowcount. Tests per dialect: live sqlite (insert/update/subset/timestamp + single ON CONFLICT statement), mysql SQL-compile assertions (no live server), and live postgres (skippable via --ignore=tests/masoniteorm/postgres). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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.
Summary
Adds an async, Eloquent-style
upsert()to the masoniteorm ORM. It emits a single bulk statement and is dialect-correct across postgres, sqlite, and mysql via SQLAlchemy dialect insert helpers.INSERT ... ON CONFLICT (unique_by) DO UPDATE SET col = excluded.colINSERT ... ON DUPLICATE KEY UPDATE col = VALUES(col)API
values:dict | list[dict]unique_by:str | list[str](conflict target)update: columns to write on conflict — defaults to every provided column except theunique_bykeysBehavior
sqlalchemy.dialects.{postgresql,sqlite,mysql}.insert().values([...])so multiple rows compile to one multi-rowVALUES+ conflict clause.Caster.set(same set-cast behavior as the insert path).insert()path per PM ruling — no auto-injection on INSERT. Explicitcreated_at/updated_atin a row dict are honored;updated_atis refreshed on theDO UPDATEbranch only when__timestamps__is enabled and the model declaresupdated_at.Connection.upsert(statement)runs the prepared Core construct and returnsrowcount.Files
masoniteorm/models/builder.py—upsert()+_build_upsert_statement()masoniteorm/models/model.py—Model.upsert()classmethodmasoniteorm/connections/connection.py—Connection.upsert()tests/masoniteorm/sqlite/builder/test_sqlite_builder_upsert.py(live)tests/masoniteorm/mysql/builder/test_mysql_builder_upsert.py(SQL-compile)tests/masoniteorm/postgres/models/test_upsert.py(live, skippable)Tests
uv run pytest --ignore=tests/masoniteorm/postgres --cov→ 1501 passed, 7 skipped, coverage 66.79% (abovefail_under).--ignore).ruff check/ruff format --check: clean.Note for follow-up
While grounding this work I found the model save path never fires the
creatingevent, socreated_at/updated_atare not auto-injected on insert today (confirmed bytest_observers.py). This PR intentionally does not fix that latent bug — it stays consistent with the existinginsert()path. Flagging for a separate follow-up task.🤖 Generated with Claude Code