Skip to content

feat(core): add Model.findByPks() for batch primary key lookups#18128

Open
topmonroe9 wants to merge 10 commits intosequelize:mainfrom
topmonroe9:feat/find-by-pks
Open

feat(core): add Model.findByPks() for batch primary key lookups#18128
topmonroe9 wants to merge 10 commits intosequelize:mainfrom
topmonroe9:feat/find-by-pks

Conversation

@topmonroe9
Copy link
Copy Markdown

@topmonroe9 topmonroe9 commented Feb 26, 2026

Summary

Closes #11297

  • Adds Model.findByPks(identifiers, options?) — a convenience method for fetching multiple instances by their primary keys in a single query
  • For single-column PKs, generates WHERE pk IN (...) using Op.in
  • For composite PKs, generates WHERE (pk1=v1 AND pk2=v2) OR (pk1=v3 AND pk2=v4) ...
  • Returns an empty array immediately if an empty identifiers array is passed (no DB round-trip)
  • Validates input: checks array type, requires PK on model, validates composite key objects

This mirrors conventions from other ORMs (Rails find, TypeORM findByIds) and avoids the boilerplate of manually constructing Op.in queries for a very common use case.

Changes

  • packages/core/src/model-typescript.ts — new findByPks static method
  • packages/core/src/model.d.ts — type declarations with raw overload
  • packages/core/test/unit/model/find-by-pks.test.ts — 9 unit tests covering single PK, composite PK, empty input, validation errors, where merging, and no-PK models

Test plan

  • Unit tests pass (DIALECT=postgres yarn mocha "test/unit/model/find-by-pks.test.ts" — 9/9)
  • Existing findByPk tests still pass (no regressions)
  • TypeScript compiles without errors (tsc --noEmit)

Summary by CodeRabbit

  • New Features

    • Fetch multiple records by providing an array of primary-key identifiers (supports single-attribute and composite keys). Validates input, returns an empty array for empty input, and merges with existing query options. Public API/type declarations updated to expose the new overloads.
  • Tests

    • Added comprehensive unit tests covering empty inputs, single/composite keys, validation/error cases, option merging, and query composition.

Closes sequelize#11297

Add a new static method `findByPks` that accepts an array of primary key
values and returns all matching instances. For single-column primary keys
it generates a single `WHERE pk IN (...)` clause; for composite keys it
builds `WHERE (pk1=v1 AND pk2=v2) OR ...`.

This is a convenience wrapper around `findAll` that mirrors `findByPk`
but for multiple identifiers at once, similar to Rails' `find` and
TypeORM's `findByIds`.
@topmonroe9 topmonroe9 requested a review from a team as a code owner February 26, 2026 16:53
@topmonroe9 topmonroe9 requested review from ephys and sdepold February 26, 2026 16:53
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Feb 26, 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

Adds a new static Model.findByPks method with input validation, support for simple and composite primary keys (IN for single-key, OR of ANDs for composite), a helper to build per-PK where clauses, type declarations, and unit tests; delegates to Model.findAll with merged where options.

Changes

Cohort / File(s) Summary
Core Implementation
packages/core/src/model-typescript.ts
Adds static async findByPks(...), input validation, buildPkWhereClause helper, constructs single-key Op.in or composite Op.or of Op.and, merges with provided where via Op.and, updates imports/types, delegates to findAll.
Type Declarations
packages/core/src/model.d.ts
Adds two overloads for static findByPks on Model: one for raw: true returning raw rows and one returning Model instances; both accept identifiers: readonly unknown[] and options.
Unit Tests
packages/core/test/unit/model/find-by-pks.test.ts
New test suite covering empty input, type validation, single-key (Op.in) queries, composite-key (Op.or/Op.and) handling and validation, merging with existing where, and ensuring internal Model.findAll is used (Sinon stubs).

Sequence Diagram(s)

sequenceDiagram
  participant Caller as Caller
  participant FinderMethod as Model.findByPks
  participant Finder as Model.findAll
  participant DB as Database

  Caller->>FinderMethod: call findByPks(identifiers, options)
  FinderMethod->>FinderMethod: validate identifiers, determine PK shape
  alt single PK
    FinderMethod->>Finder: findAll({ where: { pk: { [Op.in]: ids } }, ...options })
  else composite PKs
    FinderMethod->>Finder: findAll({ where: { [Op.or]: [ {pk1: v1, pk2: v2}, ... ] }, ...options })
  end
  Finder->>DB: execute query
  DB-->>Finder: rows
  Finder-->>FinderMethod: instances/raw rows
  FinderMethod-->>Caller: return array of results
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐇 I hop through rows both near and far,
I sniff out pks like guiding stars.
One key, many, or parts combined—
I fetch the lot, neatly aligned.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 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: adding a Model.findByPks() static method for batch primary key lookups, which is the primary feature in this PR.
Linked Issues check ✅ Passed The PR implements all coding requirements from #11297: provides findByPks method supporting single-column and composite PKs, returns empty array for empty input, validates input as array with explicit rejection of nullish values, and includes comprehensive unit tests.
Out of Scope Changes check ✅ Passed All changes are directly scoped to #11297: implementation in model-typescript.ts, type declarations in model.d.ts, and tests in find-by-pks.test.ts. No unrelated modifications detected.

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

🧹 Nitpick comments (2)
packages/core/test/unit/model/find-by-pks.test.ts (2)

116-127: Consider verifying the merged where clause structure.

The test could be strengthened by verifying that the PK condition and the custom where clause are properly merged with Op.and.

💡 Optional: Add merged where clause verification
       await testModel.findByPks([1, 2], { where: { active: true } });

       expect(findAllSpy.calledOnce).to.equal(true);
+      const callArgs = findAllSpy.getCall(0).args[0];
+      expect(callArgs.where).to.have.property(Op.and);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/test/unit/model/find-by-pks.test.ts` around lines 116 - 127,
Extend the test to assert that Model.findAll was called with a merged where
containing the PK condition and the provided where under an Op.and; after
stubbing Model.findAll in the test, check the spy's firstCall.args[0].where
equals an object like { [Op.and]: [ { id: [1, 2] }, { active: true } ] } (import
Sequelize.Op if needed) so testModel.findByPks and Model.findAll are verified to
merge correctly.

49-69: Consider verifying the where clause structure for composite PKs.

The test confirms findAll is called, but doesn't verify the constructed where clause. Adding an assertion would improve test robustness.

💡 Optional: Add where clause verification
       await testModel.findByPks([
         { pk1: 1, pk2: 10 },
         { pk1: 2, pk2: 20 },
       ]);

       expect(findAllSpy.calledOnce).to.equal(true);
+      const callArgs = findAllSpy.getCall(0).args[0];
+      expect(callArgs.where).to.have.property(Op.or);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/test/unit/model/find-by-pks.test.ts` around lines 49 - 69, Add
a strict assertion that the stubbed Model.findAll was invoked with the expected
where clause for composite keys: inspect findAllSpy.firstCall.args[0].where and
assert it equals an Op.or array of the provided PK objects (i.e. [{ pk1:1,
pk2:10 }, { pk1:2, pk2:20 }]); reference the tested method testModel.findByPks
and the stub findAllSpy, and import/usage of Sequelize.Op (or sequelize.Op) to
construct/compare the expected where shape so the test verifies the exact query
structure rather than only that findAll was called.
🤖 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/find-by-pks.test.ts`:
- Around line 116-127: Extend the test to assert that Model.findAll was called
with a merged where containing the PK condition and the provided where under an
Op.and; after stubbing Model.findAll in the test, check the spy's
firstCall.args[0].where equals an object like { [Op.and]: [ { id: [1, 2] }, {
active: true } ] } (import Sequelize.Op if needed) so testModel.findByPks and
Model.findAll are verified to merge correctly.
- Around line 49-69: Add a strict assertion that the stubbed Model.findAll was
invoked with the expected where clause for composite keys: inspect
findAllSpy.firstCall.args[0].where and assert it equals an Op.or array of the
provided PK objects (i.e. [{ pk1:1, pk2:10 }, { pk1:2, pk2:20 }]); reference the
tested method testModel.findByPks and the stub findAllSpy, and import/usage of
Sequelize.Op (or sequelize.Op) to construct/compare the expected where shape so
the test verifies the exact query structure rather than only that findAll was
called.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 70236a8 and 6bdf90f.

📒 Files selected for processing (3)
  • packages/core/src/model-typescript.ts
  • packages/core/src/model.d.ts
  • packages/core/test/unit/model/find-by-pks.test.ts

- Fix prettier formatting in test file
- Verify Op.or is used in composite PK where clause
- Verify Op.and is used when merging with existing where option
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

🧹 Nitpick comments (1)
packages/core/test/unit/model/find-by-pks.test.ts (1)

11-14: Add DB lifecycle hooks in this test suite.

This file only restores Sinon in afterEach; it does not follow the test lifecycle pattern for syncing and closing Sequelize instances, which can leak state between tests.

As per coding guidelines "Use beforeEach/afterEach to sync with { force: true } and close Sequelize instances in tests".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/test/unit/model/find-by-pks.test.ts` around lines 11 - 14, Add
test lifecycle hooks to ensure Sequelize is reset and closed: add a beforeEach
that calls sequelize.sync({ force: true }) (or the test DB instance named in
this file) to recreate tables before each test, and update the existing
afterEach to first call sinon.restore() and then await sequelize.close() (or the
file's DB instance close() method) to fully tear down the connection; keep the
hook names as beforeEach/afterEach and await both sync({ force: true }) and
close() so tests don't leak state between runs.
🤖 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/find-by-pks.test.ts`:
- Around line 15-22: The test should also assert that no DB call happens when
passing an empty array: stub or spy on the model's findAll (e.g.
sinon.stub(testModel, 'findAll') or sinon.spy) before calling
testModel.findByPks([]), call await testModel.findByPks([]), assert that
testModel.findAll was not called, and finally restore the stub/spy; reference
the testModel.findByPks and testModel.findAll symbols when making the change.

---

Nitpick comments:
In `@packages/core/test/unit/model/find-by-pks.test.ts`:
- Around line 11-14: Add test lifecycle hooks to ensure Sequelize is reset and
closed: add a beforeEach that calls sequelize.sync({ force: true }) (or the test
DB instance named in this file) to recreate tables before each test, and update
the existing afterEach to first call sinon.restore() and then await
sequelize.close() (or the file's DB instance close() method) to fully tear down
the connection; keep the hook names as beforeEach/afterEach and await both
sync({ force: true }) and close() so tests don't leak state between runs.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 6bdf90f and 1fa27df.

📒 Files selected for processing (1)
  • packages/core/test/unit/model/find-by-pks.test.ts

Comment thread packages/core/test/unit/model/find-by-pks.test.ts Outdated
- Use type-safe patterns to avoid 'possibly undefined' errors on
  getCall().args indexing across TS 5.5-5.8
- Assert findAll is not called for empty identifiers array
@SippieCup
Copy link
Copy Markdown
Contributor

I don’t really see a reason why this is needed above where: { Id: [1,2,3]}

@topmonroe9
Copy link
Copy Markdown
Author

I don’t really see a reason why this is needed above where: { Id: [1,2,3]}

Yeah for a single PK that's basically the same thing. The bigger win is composite primary keys though, where: { id: [1,2,3] } doesn't really work when your PK is two columns. Also you don't have to hardcode the PK field name, so findByPks([1,2,3]) works whether the PK is id, uuid, whatever. Same idea behind why findByPk exists when you could just use findOne

Honestly for the simple case it's just a convenience method, but #11297 had a bunch of people asking for it so figured it was worth adding

@SippieCup
Copy link
Copy Markdown
Contributor

Alright. I can see that use case, overall looks fine to me. ideally we would refactor the object.create(null) stuff into a function that either returns a created object or throws, so that null never gets through even with malformed models. then replace that in both findbypk and findbypks.

but yeah. nice work.

@topmonroe9
Copy link
Copy Markdown
Author

Thanks, yeah that made sense. I pulled the validation + where-clause stuff into a shared buildPkWhereClause helper, both findByPk and findByPks use it now. Malformed identifiers throw early so null can't slip through.

Pushed it just now

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-typescript.ts`:
- Around line 548-564: The findByPks implementation allows null/undefined
entries in the identifiers array which are passed into Op.in and can mask bugs;
update findByPks to mirror findByPk by rejecting nullish identifiers: in the
findByPks method add a guard after the early-empty check that iterates
identifiers (or uses identifiers.some(id => id == null)) and throws a
descriptive Error if any identifier is null or undefined, ensuring you still
reference identifiers, primaryKeyAttributeNames, and the existing Op.in logic
unchanged otherwise.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3ad066e and c883059.

📒 Files selected for processing (1)
  • packages/core/src/model-typescript.ts

Comment thread packages/core/src/model-typescript.ts
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

🧹 Nitpick comments (2)
packages/core/test/unit/model/find-by-pks.test.ts (2)

9-13: Align test lifecycle hooks with repository test isolation rules.

This suite currently restores Sinon only. Please align setup/teardown with the standard sync/close lifecycle pattern to avoid shared-state leakage across tests.

As per coding guidelines "Use beforeEach/afterEach to sync with { force: true } and close Sequelize instances in tests".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/test/unit/model/find-by-pks.test.ts` around lines 9 - 13, The
test suite for findByPks currently only calls sinon.restore in afterEach; update
the lifecycle to follow the repository test isolation pattern by adding a
beforeEach that calls sync({ force: true }) on the Sequelize/model test DB and
expanding afterEach to both restore sinon (sinon.restore()) and close the
Sequelize connection (await sequelize.close() or the test helper close method).
Locate the describe('findByPks', ...) block and modify the hooks around it (add
beforeEach to perform sync({ force: true }) and change afterEach to restore
sinon and close the Sequelize instance) so each test runs with a fresh DB and
cleans up connections.

72-76: Strengthen assertions for generated where clauses.

Both tests only assert that where exists, so regressions in Op.or / Op.and composition could still pass.

Proposed assertion tightening
-      const where = findAllSpy.firstCall.args[0] as Record<string, unknown> | undefined;
-      expect(where).to.exist;
+      findAllSpy.should.have.been.calledWithMatch({
+        where: {
+          [Op.or]: [
+            { pk1: 1, pk2: 10 },
+            { pk1: 2, pk2: 20 },
+          ],
+        },
+      });

@@
-      const where = findAllSpy.firstCall.args[0] as Record<string, unknown> | undefined;
-      expect(where).to.exist;
+      findAllSpy.should.have.been.calledWithMatch({
+        where: {
+          [Op.and]: [{ active: true }, { id: { [Op.in]: [1, 2] } }],
+        },
+      });

Also applies to: 149-152

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/test/unit/model/find-by-pks.test.ts` around lines 72 - 76, The
test currently only checks that the captured `where` object exists (from
`findAllSpy.firstCall.args[0]`), which is weak; update the assertions to verify
the expected Op composition and contents explicitly: for single-table PK lookups
assert `where[Op.or]` is an array of objects matching each primary key (check
lengths and that each element has the expected key/value pairs), and for
composite-PK cases assert `where[Op.or]` contains objects with `Op.and` arrays
where each `Op.and` element matches each column/value pair; reference and update
the assertions around `findAllSpy` / `findAllSpy.firstCall.args[0]` (the `where`
variable) in both the block at lines ~72-76 and the similar block at ~149-152 to
validate Op structure, array lengths, and exact key/value shapes rather than
only existence.
🤖 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-typescript.ts`:
- Around line 606-614: The loop that builds pkWhere in model-typescript.ts
currently only checks for undefined and allows nulls (in the block iterating
primaryKeyAttributeNames), so update the guard to reject null as well: change
the check that inspects identifier[attributeName] to treat null and undefined as
missing (e.g., identifier[attributeName] == null or explicit === null || ===
undefined) and throw the same TypeError; ensure this logic is applied in the
same loop that assigns pkWhere[attributeName] = identifier[attributeName] so
composite key parts of null are refused (matching findByPks' nullish guard).

---

Nitpick comments:
In `@packages/core/test/unit/model/find-by-pks.test.ts`:
- Around line 9-13: The test suite for findByPks currently only calls
sinon.restore in afterEach; update the lifecycle to follow the repository test
isolation pattern by adding a beforeEach that calls sync({ force: true }) on the
Sequelize/model test DB and expanding afterEach to both restore sinon
(sinon.restore()) and close the Sequelize connection (await sequelize.close() or
the test helper close method). Locate the describe('findByPks', ...) block and
modify the hooks around it (add beforeEach to perform sync({ force: true }) and
change afterEach to restore sinon and close the Sequelize instance) so each test
runs with a fresh DB and cleans up connections.
- Around line 72-76: The test currently only checks that the captured `where`
object exists (from `findAllSpy.firstCall.args[0]`), which is weak; update the
assertions to verify the expected Op composition and contents explicitly: for
single-table PK lookups assert `where[Op.or]` is an array of objects matching
each primary key (check lengths and that each element has the expected key/value
pairs), and for composite-PK cases assert `where[Op.or]` contains objects with
`Op.and` arrays where each `Op.and` element matches each column/value pair;
reference and update the assertions around `findAllSpy` /
`findAllSpy.firstCall.args[0]` (the `where` variable) in both the block at lines
~72-76 and the similar block at ~149-152 to validate Op structure, array
lengths, and exact key/value shapes rather than only existence.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c883059 and fe078b2.

📒 Files selected for processing (2)
  • packages/core/src/model-typescript.ts
  • packages/core/test/unit/model/find-by-pks.test.ts

Comment thread packages/core/src/model-typescript.ts
@SippieCup
Copy link
Copy Markdown
Contributor

@WikiRik What are you thoughts on this?

Copy link
Copy Markdown
Member

@WikiRik WikiRik left a comment

Choose a reason for hiding this comment

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

It's been a while since I looked at this code. I think in general this seems good. I have a few initial remarks and will do a better review later

Comment thread packages/core/src/model.d.ts Outdated
Comment on lines +2450 to +2470
/**
* Search for multiple instances by their primary keys.
*
* Returns the models with matching primary keys. If a given primary key has no match,
* it is simply omitted from the result (no error is thrown).
*
* @param identifiers An array of primary key values. For composite keys, each element
* should be an object containing all primary key attributes.
* @param options find options
*/
static findByPks<M extends Model, R = Attributes<M>>(
this: ModelStatic<M>,
identifiers: readonly unknown[],
options: Omit<FindOptions<Attributes<M>>, 'raw'> & { raw: true },
): Promise<R[]>;
static findByPks<M extends Model>(
this: ModelStatic<M>,
identifiers: readonly unknown[],
options?: FindOptions<Attributes<M>>,
): Promise<M[]>;

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.

Do we need these if we have defined it in the model-typescript.ts as well?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Yeah you're right, findByPk doesn't have one either so this was just unnecessary duplication. Removed it.

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.

We should add a few integration tests as well

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Added integration tests — basic lookups, composite PKs, empty input, and where merging.

Comment thread packages/core/src/model-typescript.ts Outdated
primaryKeyAttributeNames: SetView<string>,
identifier: unknown,
): Record<string, unknown> {
const pkWhere: Record<string, unknown> = Object.create(null);
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.

I'm not sure anymore but don't we have a constant in the utils that's basically a null object? I know we sure we have one for EMPTY_OBJECT

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good catch, switched it to pojo() from @sequelize/utils.

@SippieCup
Copy link
Copy Markdown
Contributor

@topmonroe9
@WikiRik

lgtm.

Didn't know about pojo(). will be useful for a couple other things!

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.

[Feature] Add findByPks convenience method

3 participants