Skip to content

fix(core): apply beforeUpsert value changes to query payload#18219

Open
mccabemj wants to merge 6 commits into
sequelize:mainfrom
mccabemj:fix-before-upsert-hook-values
Open

fix(core): apply beforeUpsert value changes to query payload#18219
mccabemj wants to merge 6 commits into
sequelize:mainfrom
mccabemj:fix-before-upsert-hook-values

Conversation

@mccabemj
Copy link
Copy Markdown
Contributor

@mccabemj mccabemj commented Apr 29, 2026

Pull Request Checklist

  • Have you added new tests to prevent regressions?
  • If a documentation update is necessary, have you opened a PR to the documentation repository?
  • Did you update the typescript typings accordingly (if applicable)?
  • Does the description below contain a link to an existing issue (Closes #[issue]) or a description of the issue you are solving?
  • Does the name of your PR follow our conventions?

Description of Changes

Fixes #8829.

Model.upsert ran beforeUpsert after insertValues and updateValues had already been derived from the built instance. As a result, mutations made by beforeUpsert were visible on the original values object but were not sent to the database.

This changes upsert to run beforeUpsert before mapping the insert/update payloads, sync hook-mutated values back into the built instance, and preserve explicit fields behavior so hook changes do not widen the update payload when fields are user-specified.

List of Breaking Changes

None

Summary by CodeRabbit

  • Bug Fixes

    • Upsert now applies hook-driven modifications before determining which fields to persist; insert/update payloads honor post-hook changes and deletions, while explicit field lists are respected.
  • Tests

    • Added integration and unit tests verifying hooks run, mutated values persist as expected, and behavior with explicit upsert fields.

@mccabemj mccabemj requested a review from a team as a code owner April 29, 2026 21:44
@mccabemj mccabemj requested review from WikiRik and sdepold April 29, 2026 21:44
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 29, 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

Walkthrough

Model.upsert now invokes the beforeUpsert hook earlier: it snapshots pre-hook values, deep-clones them when hooks are enabled, runs beforeUpsert, computes changed keys by deep equality, applies only those changed keys to the instance, and recalculates primary-key/fields handling. A later duplicate beforeUpsert call was removed.

Changes

Cohort / File(s) Summary
Core implementation
packages/core/src/model.js
Reordered upsert flow so beforeUpsert runs before computing changed/updatedDataValues; snapshots/clones pre-hook values, applies only post-hook changed keys to the instance, recalculates hasPrimary and options.fields, and removed the redundant later hook call.
Integration tests
packages/core/test/integration/hooks/upsert.test.js
Expanded upsert hook tests to assert persistence: verifies beforeUpsert mutations persist on insert/update and that fields-restricted upsert runs the hook but does not persist non-listed field changes.
Unit tests
packages/core/test/unit/model/upsert.test.js
Added unit tests covering: beforeUpsert mutations reflected in insert/update payloads; virtual setter/derived value handling; behavior when options.fields is explicit; primary-key set within hook; deletion of properties inside hook omitted from payloads.

Sequence Diagram(s)

sequenceDiagram
    participant Caller
    participant Model
    participant Hook as beforeUpsert
    participant Instance
    participant DB
    Caller->>Model: upsert(values, options)
    Model->>Instance: build(instanceValues)
    Model->>Hook: beforeUpsert(values, options) 
    Hook-->>Model: mutated values
    Model->>Instance: set(only changed keys from mutated values)
    Model->>Model: compute insertValues / updateValues / options.fields / hasPrimary
    Model->>DB: execute upsert (insert or update) with computed payload
    DB-->>Model: result
    Model-->>Caller: return result
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

"A rabbit hopped into the upsert hive,
tweaked the values to make them thrive.
Hooks whispered changes, then away they went—
only the altered bits were sent.
Hooray for tidy hops and clever intent! 🐇"

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: applying beforeUpsert hook value changes to the query payload, which directly addresses the core fix.
Linked Issues check ✅ Passed The pull request successfully addresses issue #8829 by ensuring beforeUpsert hook mutations are applied to the database query payload through early hook execution and value synchronization.
Out of Scope Changes check ✅ Passed All changes are directly related to fixing the beforeUpsert hook issue: the core implementation change, integration tests, and unit tests are all focused on the stated objective.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

Copy link
Copy Markdown
Contributor

@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

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/core/src/model.js`:
- Around line 2191-2197: The current diffing uses pickBy over values so keys
removed by a beforeUpsert hook are ignored; update the logic that builds
hookChangedValues (replace the pickBy usage) to iterate the union of
Object.keys(values) and Object.keys(beforeHookValues) and include any key where
!isEqual(values[key], beforeHookValues[key]); for keys absent from values, set
hookChangedValues[key] to undefined (or the appropriate "deleted" sentinel) so
instance.set(hookChangedValues) will remove stale properties; modify the code
around hookChangedValues, pickBy, beforeHookValues, and instance.set to
implement this behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b7a3fc04-dd17-49e4-ad05-c1ab7b861388

📥 Commits

Reviewing files that changed from the base of the PR and between ab2c9ad and 24ec8f5.

📒 Files selected for processing (3)
  • packages/core/src/model.js
  • packages/core/test/integration/hooks/upsert.test.js
  • packages/core/test/unit/model/upsert.test.js

Comment thread packages/core/src/model.js Outdated
Copy link
Copy Markdown
Contributor

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

🧹 Nitpick comments (1)
packages/core/test/unit/model/upsert.test.js (1)

154-171: Consider clarifying the test title for readability.

The test title "uses beforeUpsert deleted values in insert and update values" is slightly ambiguous—it could be read as if deleted values are being used. Consider a clearer phrasing such as:

-      it('uses beforeUpsert deleted values in insert and update values', async function () {
+      it('excludes values deleted by beforeUpsert from insert and update payloads', async function () {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/test/unit/model/upsert.test.js` around lines 154 - 171, The
test title is ambiguous; update the "it" description for the test that exercises
the beforeUpsert hook to make intent clearer (e.g., indicate that deleted
properties are removed from insert/update values). Edit the string passed to the
it(...) for the test containing the beforeUpsert hook (the test that calls
this.User.hooks.addListener('beforeUpsert', ...) and then awaits
this.User.upsert(...)) so it reads something like "removes properties deleted in
beforeUpsert from insert and update values" to improve readability while leaving
the hook logic and assertions unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/core/test/unit/model/upsert.test.js`:
- Around line 154-171: The test title is ambiguous; update the "it" description
for the test that exercises the beforeUpsert hook to make intent clearer (e.g.,
indicate that deleted properties are removed from insert/update values). Edit
the string passed to the it(...) for the test containing the beforeUpsert hook
(the test that calls this.User.hooks.addListener('beforeUpsert', ...) and then
awaits this.User.upsert(...)) so it reads something like "removes properties
deleted in beforeUpsert from insert and update values" to improve readability
while leaving the hook logic and assertions unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 13f2284c-ff15-4e3d-a04e-19b2325bdb98

📥 Commits

Reviewing files that changed from the base of the PR and between 24ec8f5 and 8427ced.

📒 Files selected for processing (2)
  • packages/core/src/model.js
  • packages/core/test/unit/model/upsert.test.js

Copy link
Copy Markdown
Contributor

@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

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/core/src/model.js`:
- Around line 2202-2203: The code currently computes hasPrimary from the
original values while recomputing changed from the finalized instance after
beforeUpsert hooks, causing a mismatch when hooks or options.instance mutate the
PK; update the logic that sets hasPrimary to derive it from the post-hook
instance (the same source used to recompute changed) instead of from values so
the PK-pruning branch uses the same finalized instance state (look for symbols:
beforeUpsert, options.instance, instance.set, hookChangedValues, hasPrimary,
values and the PK-pruning code path) and ensure any subsequent checks/branches
reference this recomputed hasPrimary.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c336b35c-8631-4893-b4bd-23707aa876db

📥 Commits

Reviewing files that changed from the base of the PR and between 8a1e495 and 73b65a5.

📒 Files selected for processing (2)
  • packages/core/src/model.js
  • packages/core/test/integration/hooks/upsert.test.js
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/core/test/integration/hooks/upsert.test.js

Comment thread packages/core/src/model.js Outdated
Copy link
Copy Markdown
Contributor

@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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/core/src/model.js (1)

2178-2210: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Validate after applying beforeUpsert mutations.

Line 2178 validates the pre-hook instance, but Lines 2189-2210 now change the persisted state afterward. That means beforeUpsert can introduce an invalid value that still gets written through insertValues / updateValues, which is a new validation bypass in this flow.

Suggested fix
-    if (options.validate) {
-      await instance.validate(options);
-    }
-
     // Map conflict fields to column names
     if (options.conflictFields) {
       options.conflictFields = options.conflictFields.map(attrName => {
         return modelDefinition.getColumnName(attrName);
       });
@@
       if (!fieldsSpecified) {
         options.fields = changed;
       }
     }
+
+    if (options.validate) {
+      await instance.validate(options);
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/model.js` around lines 2178 - 2210, The pre-hook can mutate
instance after the initial validate, so re-run validation when options.validate
is truthy after running the beforeUpsert hook and applying mutations: after
this.hooks.runAsync('beforeUpsert', values, options) and after
instance.set(hookChangedValues) call await instance.validate(options) (or only
when options.validate was set), then recalc hasPrimary/changed and set
options.fields as currently done; ensure you reference instance.validate,
this.hooks.runAsync('beforeUpsert'), instance.set(hookChangedValues), hasPrimary
and changed so the second validation applies to the mutated state before
insertValues/updateValues proceed.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/core/test/unit/model/upsert.test.js`:
- Around line 163-179: The test currently creates a nested sinon stub for
current.queryInterface.upsert which conflicts with the existing stub from
beforeEach; instead, reuse and reconfigure the existing stub (e.g., this.stub or
the shared stub used in beforeEach) to resolve with [Item.build(), true] for the
Item.upsert scenario, remove the local stub.restore() call (afterEach will clean
up), keep the unhook removal via Item.hooks.addListener/unhook(), and when
asserting read the args from the reused stub (this.stub.getCall(0).args) to
verify insertValues.id === 42.

---

Outside diff comments:
In `@packages/core/src/model.js`:
- Around line 2178-2210: The pre-hook can mutate instance after the initial
validate, so re-run validation when options.validate is truthy after running the
beforeUpsert hook and applying mutations: after
this.hooks.runAsync('beforeUpsert', values, options) and after
instance.set(hookChangedValues) call await instance.validate(options) (or only
when options.validate was set), then recalc hasPrimary/changed and set
options.fields as currently done; ensure you reference instance.validate,
this.hooks.runAsync('beforeUpsert'), instance.set(hookChangedValues), hasPrimary
and changed so the second validation applies to the mutated state before
insertValues/updateValues proceed.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6cb37645-27ed-484f-b3fa-ca20c360c388

📥 Commits

Reviewing files that changed from the base of the PR and between 73b65a5 and 29330ac.

📒 Files selected for processing (2)
  • packages/core/src/model.js
  • packages/core/test/unit/model/upsert.test.js

Comment thread packages/core/test/unit/model/upsert.test.js Outdated
Copy link
Copy Markdown
Member

@ephys ephys left a comment

Choose a reason for hiding this comment

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

Thank you for your PR

The problem with changing hooks is that it's very easy to break existing workflows that depend on the current behavior

I've highlighted places where regressions are likely

options.fields = changed;
}

if (options.validate) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

this is a breaking change as beforeUpsert can now be called with invalid data

This validation should be restored. If data changes during beforeUpsert, validation should be re-run after the hook

Don't forget to add a unit test for this :)

instance._changed.has(this.primaryKeyField);
changed = [...instance._changed];
if (!fieldsSpecified) {
options.fields = changed;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

this will override the changes to the fields property that might have happened during beforeUpsert

Maybe users should have to update it themselves, this could be documented in the documentation of beforeUpsert on the website

A test should be added to cover this regression

}

if (options.hooks) {
const beforeHookValues = cloneDeep(values);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

there is no need to clone deep, a shallow clone is enough

const beforeHookValues = cloneDeep(values);
await this.hooks.runAsync('beforeUpsert', values, options);

const hookChangedValues = {};
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
const hookChangedValues = {};
const hookChangedValues = pojo();


const hookChangedValues = {};
for (const key of union(Object.keys(beforeHookValues), Object.keys(values))) {
if (!isEqual(values[key], beforeHookValues[key])) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

a deep comparison is too expensive, it should be a shallow comparison

Our philosophy is that users should not mutate nested data, but should immutably replace it. We've documented it here for save, this can be documented as well in the documentation of beforeUpsert


instance.set(hookChangedValues);

hasPrimary =
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

hasPrimary is not used before it gets re-computed. It should be computed once after the hook instead

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.

beforeUpsert hook is unable to change values sent to the database

2 participants