Skip to content

feat(orm): add async Eloquent-style upsert()#128

Closed
bedus-creation wants to merge 1 commit into
mainfrom
feature/orm-async-upsert
Closed

feat(orm): add async Eloquent-style upsert()#128
bedus-creation wants to merge 1 commit into
mainfrom
feature/orm-async-upsert

Conversation

@bedus-creation

Copy link
Copy Markdown
Contributor

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.

  • postgres / sqlite: INSERT ... ON CONFLICT (unique_by) DO UPDATE SET col = excluded.col
  • mysql: INSERT ... ON DUPLICATE KEY UPDATE col = VALUES(col)

API

await User.upsert(values, unique_by, update=None)          # Model async classmethod
await User.query().upsert(values, unique_by, update=None)  # QueryBuilder
  • values: dict | list[dict]
  • unique_by: str | list[str] (conflict target)
  • update: columns to write on conflict — defaults to every provided column except the unique_by keys
  • returns the affected rowcount

Behavior

  • Single statement: built with sqlalchemy.dialects.{postgresql,sqlite,mysql}.insert().values([...]) so multiple rows compile to one multi-row VALUES + conflict clause.
  • Casts: each row is run through the model Caster.set (same set-cast behavior as the insert path).
  • Timestamps: kept consistent with the current insert() path per PM ruling — 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: new Connection.upsert(statement) runs the prepared Core construct and returns rowcount.

Files

Change File
M masoniteorm/models/builder.pyupsert() + _build_upsert_statement()
M masoniteorm/models/model.pyModel.upsert() classmethod
M masoniteorm/connections/connection.pyConnection.upsert()
A tests/masoniteorm/sqlite/builder/test_sqlite_builder_upsert.py (live)
A tests/masoniteorm/mysql/builder/test_mysql_builder_upsert.py (SQL-compile)
A tests/masoniteorm/postgres/models/test_upsert.py (live, skippable)

Tests

  • uv run pytest --ignore=tests/masoniteorm/postgres --cov1501 passed, 7 skipped, coverage 66.79% (above fail_under).
  • sqlite upsert + mysql compile suites: 8 passed.
  • Postgres suite mirrors existing postgres tests (needs a live server; CI provides one — locally skipped via --ignore).
  • ruff check / ruff format --check: clean.

Note for follow-up

While grounding this work I found the model save path never fires the creating event, so created_at/updated_at are not auto-injected on insert today (confirmed by test_observers.py). This PR intentionally does not fix that latent bug — it stays consistent with the existing insert() path. Flagging for a separate follow-up task.

🤖 Generated with Claude Code

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>
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.

1 participant