Skip to content

feat(mssql): support native upsert via MERGE WITH (HOLDLOCK)#5794

Open
MarkusLund wants to merge 2 commits intoprisma:mainfrom
MarkusLund:fix-mssql-native-upsert-holdlock
Open

feat(mssql): support native upsert via MERGE WITH (HOLDLOCK)#5794
MarkusLund wants to merge 2 commits intoprisma:mainfrom
MarkusLund:fix-mssql-native-upsert-holdlock

Conversation

@MarkusLund
Copy link
Copy Markdown

@MarkusLund MarkusLund commented Mar 10, 2026

Summary

Adds native upsert support for SQL Server by translating OnConflict::Update into MERGE ... WITH (HOLDLOCK), giving MSSQL the same atomic upsert semantics that PostgreSQL and SQLite already have via ON CONFLICT DO UPDATE.

Motivation

Currently, MSSQL upserts fall back to a transactional query graph: a SELECT to check existence, then either an INSERT or UPDATE depending on the result. While wrapped in a transaction, this multi-step approach can still surface unique constraint violations under concurrent load because the gap between the read and the write is not fully serializable.

MERGE WITH (HOLDLOCK) replaces this with a single atomic statement that holds a range lock during the match phase, eliminating the race condition entirely.

WITH (HOLDLOCK) is applied at the MSSQL MERGE visitor level, so it affects both the new OnConflict::Update path and the existing OnConflict::DoNothing path. This is intentional: both MERGE variants benefit from serializable-level locking on SQL Server.

Changes

  • Enable NativeUpsert capability for the MSSQL connector
  • Add Merge::from_insert_with_update() to convert INSERT ... ON CONFLICT UPDATE into MERGE ... WHEN MATCHED THEN UPDATE
  • Build MERGE ON predicates from explicit conflict columns (compound keys supported)
  • Preserve schema-qualified table references ([dbo].[table]) in generated ON clauses
  • Emit WITH (HOLDLOCK) on all MSSQL MERGE statements
  • Extract shared build_using_query() helper to avoid duplication between DoNothing and Update paths
  • Extend native_upsert.rs assertion helpers to detect MERGE INTO in addition to ON CONFLICT

Test plan

  • cargo test -p quaint test_native_upsert --lib — unit tests for rendered SQL (single unique, compound, schema-qualified, multi-row, conditions, returning, error cases)
  • cargo test -p quaint upsert -- --nocapture — SQLite integration passing locally
  • CI: MSSQL connector integration tests (query-engine-tests native upsert suite)
  • CI: PostgreSQL and SQLite native upsert tests unaffected
  • Manual verification against downstream SQL Server app with concurrent upsert race scenario

Summary by CodeRabbit

  • New Features

    • SQL Server native upsert support marked available; MERGE emits WITH (HOLDLOCK) and can include optional WHEN MATCHED updates.
  • Documentation

    • Upsert docs updated to show SQL Server MERGE alongside PostgreSQL/SQLite and include HOLDLOCK example.
  • Tests

    • Tests expanded to cover SQL Server native upsert; assertions accept MERGE or ON CONFLICT and new MSSQL cases added.
  • Refactor

    • Query compatibility hooks now surface errors during transformations; MERGE/upsert paths handle fallible conversions.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 10, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds MSSQL native upsert support: enables NativeUpsert capability, extends the Merge AST with WHEN MATCHED and builders to construct MERGE from INSERT ... ON CONFLICT UPDATE, updates the Visitor API and MSSQL visitor to emit MERGE INTO ... WITH (HOLDLOCK) and propagate errors, and adjusts tests/logging to recognize MERGE.

Changes

Cohort / File(s) Summary
Connector capability
psl/psl-core/src/builtin_connectors/mssql_datamodel_connector.rs
Enabled the NativeUpsert capability flag for the MsSql datamodel connector.
AST: Insert / Merge
quaint/src/ast/insert.rs, quaint/src/ast/merge.rs
Doc updates in insert.rs. Merge gains pub(crate) when_matched: Option<Update<'a>>, a when_matched setter, from_insert_with_update constructor, and helper functions build_using_query and build_on_conditions_from_constraints.
Visitor trait & MSSQL visitor
quaint/src/visitor.rs, quaint/src/visitor/mssql.rs
Visitor::compatibility_modifications now returns crate::Result<Query<'a>>. MSSQL visitor updated to produce fallible compatibility modifications, convert INSERT ... ON CONFLICT into Merge (including optional WHEN MATCHED conditions), and emit MERGE INTO ... WITH (HOLDLOCK) (with optional target alias).
Tests / native upsert detection
quaint/src/tests/upsert.rs, query-engine/connector-test-kit-rs/.../native_upsert.rs
Updated tests to include the mssql connector. Native upsert log/assert checks now accept either ON CONFLICT or MERGE INTO as indicators of native upsert.
Misc / Manifests
(various small edits)
Minor manifest and ancillary edits referenced by the changeset (non-API changes).
🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title clearly describes the main change: adding native upsert support for MSSQL via MERGE WITH (HOLDLOCK), which accurately reflects the primary objective of enabling atomic upsert semantics for SQL Server.
Docstring Coverage ✅ Passed Docstring coverage is 90.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Tip

CodeRabbit can generate a title for your PR based on the changes with custom instructions.

Set the reviews.auto_title_instructions setting to generate a title for your PR based on the changes in the PR with custom instructions.

@CLAassistant
Copy link
Copy Markdown

CLAassistant commented Mar 10, 2026

CLA assistant check
All committers have signed the CLA.

@MarkusLund MarkusLund force-pushed the fix-mssql-native-upsert-holdlock branch from c09666f to 4c0306b Compare March 12, 2026 14:55
@MarkusLund MarkusLund marked this pull request as ready for review March 12, 2026 15:33
Copilot AI review requested due to automatic review settings March 12, 2026 15:33
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR adds native upsert support for MSSQL by translating OnConflict::Update into MERGE ... WITH (HOLDLOCK) statements, providing atomic upsert semantics equivalent to PostgreSQL's and SQLite's ON CONFLICT DO UPDATE. This eliminates race conditions that previously existed with the transactional SELECT-then-INSERT/UPDATE fallback approach under concurrent load.

Changes:

  • Enables NativeUpsert capability for the MSSQL connector and adds Merge::from_insert_with_update() to convert INSERT ... ON CONFLICT UPDATE into MERGE ... WHEN MATCHED THEN UPDATE
  • Emits WITH (HOLDLOCK) on all MSSQL MERGE statements (both DoNothing and Update paths) and extracts a shared build_using_query() helper to eliminate duplication
  • Extends test assertion helpers and adds comprehensive unit tests for the new MERGE generation (single unique, compound, schema-qualified, multi-row, conditions, returning, error cases)

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated no comments.

Show a summary per file
File Description
psl/psl-core/src/builtin_connectors/mssql_datamodel_connector.rs Adds NativeUpsert capability flag for MSSQL
quaint/src/ast/merge.rs Adds from_insert_with_update(), build_on_conditions_from_constraints(), and shared build_using_query() helper; adds when_matched field to Merge struct
quaint/src/ast/insert.rs Updates doc comment for OnConflict to reflect MSSQL support
quaint/src/visitor/mssql.rs Handles OnConflict::Update in compatibility_modifications, emits WITH (HOLDLOCK) and WHEN MATCHED THEN UPDATE SET in MERGE visitor; updates all existing test expectations
quaint/src/tests/upsert.rs Adds mssql tag to integration upsert tests
query-engine/connector-test-kit-rs/query-engine-tests/tests/new/native_upsert.rs Extends assertion helpers to detect MERGE INTO alongside ON CONFLICT

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: c155a584-97dd-45cb-b89a-fda174b6e368

📥 Commits

Reviewing files that changed from the base of the PR and between 280c870 and 4c0306b.

📒 Files selected for processing (6)
  • psl/psl-core/src/builtin_connectors/mssql_datamodel_connector.rs
  • quaint/src/ast/insert.rs
  • quaint/src/ast/merge.rs
  • quaint/src/tests/upsert.rs
  • quaint/src/visitor/mssql.rs
  • query-engine/connector-test-kit-rs/query-engine-tests/tests/new/native_upsert.rs

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: fda4f8bd-2030-4a62-ad60-8847a1479dc5

📥 Commits

Reviewing files that changed from the base of the PR and between 4c0306b and 6cf63cd.

📒 Files selected for processing (3)
  • quaint/src/ast/merge.rs
  • quaint/src/visitor.rs
  • quaint/src/visitor/mssql.rs

Implement native upsert for MSSQL by translating INSERT ... ON CONFLICT
into MERGE ... WITH (HOLDLOCK) statements, providing atomic upsert
semantics and eliminating race conditions from the transactional
SELECT-then-INSERT/UPDATE fallback.

- Enable NativeUpsert capability for the MSSQL connector
- Add Merge::from_insert_with_update() for ON CONFLICT UPDATE → MERGE
- Emit WITH (HOLDLOCK) on all MSSQL MERGE statements
- Propagate errors via Result instead of panicking in MERGE conversion
- Change compatibility_modifications trait method to return Result
- Preserve source row order in multi-row MERGE USING clauses
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

T-SQL syntax requires table hints between the table name and alias:
MERGE INTO [table] WITH (HOLDLOCK) AS [alias]
not: MERGE INTO [table] AS [alias] WITH (HOLDLOCK)
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 11f8f6d0-9b52-4fcc-ab71-c8b88e189052

📥 Commits

Reviewing files that changed from the base of the PR and between 4a76627 and c2ce504.

📒 Files selected for processing (1)
  • quaint/src/visitor/mssql.rs

Comment on lines +664 to +670
// T-SQL requires: <target_table> WITH (<hint>) [AS <alias>]
self.visit_table(merge.table.clone(), false)?;
self.write(" WITH (HOLDLOCK)")?;
if let Some(ref alias) = merge.table.alias {
self.write(" AS ")?;
self.delimited_identifiers(&[&*alias])?;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's look at the specific code section mentioned
cat -n quaint/src/visitor/mssql.rs | sed -n '655,680p'

Repository: prisma/prisma-engines

Length of output: 1108


🏁 Script executed:

# Now let's find and examine the Merge::from_insert_with_update method
rg "from_insert_with_update" --type rust -A 20

Repository: prisma/prisma-engines

Length of output: 3038


🏁 Script executed:

# Let's look at the Merge struct definition and understand its structure
rg "impl Merge" --type rust -A 5

Repository: prisma/prisma-engines

Length of output: 1496


🏁 Script executed:

# Search for how the ON clause is generated in MERGE statements
rg "visit_merge" --type rust -B 2 -A 30

Repository: prisma/prisma-engines

Length of output: 4768


🏁 Script executed:

# Let's check for any tests related to MERGE with aliases
fd "\.rs$" | xargs grep -l "MERGE.*AS\|merge.*alias" 2>/dev/null | head -10

Repository: prisma/prisma-engines

Length of output: 183


🏁 Script executed:

# Get the complete from_insert_with_update method
rg "from_insert_with_update" --type rust -A 50 quaint/src/ast/merge.rs

Repository: prisma/prisma-engines

Length of output: 2276


🏁 Script executed:

# Find where on_conditions are built in Merge construction
rg "on_conditions" --type rust quaint/src/ast/merge.rs | head -20

Repository: prisma/prisma-engines

Length of output: 508


🏁 Script executed:

# Let's look at the Merge struct definition to understand its fields
rg "pub struct Merge" --type rust -A 10

Repository: prisma/prisma-engines

Length of output: 1374


🏁 Script executed:

# Get the full implementation of build_on_conditions_from_constraints
rg "fn build_on_conditions_from_constraints" --type rust -A 30 quaint/src/ast/merge.rs

Repository: prisma/prisma-engines

Length of output: 1260


🏁 Script executed:

# Check if there are any tests for MERGE statements with aliases
fd "\.rs$" quaint/src/tests -o quaint/tests 2>/dev/null | xargs grep -l "merge\|MERGE" 2>/dev/null | head -5

Repository: prisma/prisma-engines

Length of output: 47


🏁 Script executed:

# Search for test files related to mssql or merge
fd "test" --type f quaint/ | grep -E "mssql|merge"

Repository: prisma/prisma-engines

Length of output: 47


Fix MERGE alias handling to ensure consistent table references in ON clause.

The ON clause generation in from_insert_with_update() uses a table_ref with alias: None (quaint/src/ast/merge.rs), while the rendered merge target can include an alias. This produces invalid T-SQL: MERGE INTO table AS t ... ON table.id = dual.id (should reference t.id, not table.id). Either normalize aliases away for MERGE targets or thread the alias through ON-clause generation. Add a regression test for MERGE with aliased targets.

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.

3 participants