Thanks to visit codestin.com
Credit goes to github.com

Skip to content

feat(core): add native UNION support via sequelize.union#18110

Open
agamennone-siae wants to merge 5 commits intosequelize:mainfrom
agamennone-siae:main
Open

feat(core): add native UNION support via sequelize.union#18110
agamennone-siae wants to merge 5 commits intosequelize:mainfrom
agamennone-siae:main

Conversation

@agamennone-siae
Copy link

@agamennone-siae agamennone-siae commented Jan 26, 2026

Pull Request Checklist

Description of Changes

Implemented Sequelize#union (feat #18107) to combine results from multiple queries.
This adds support for:

  • UNION (default) and UNION ALL (via { unionAll: true } option).
  • Global ORDER BY clause for the combined result.
  • Global LIMIT and OFFSET clauses.
    The implementation relies on a new unionQuery method in AbstractQueryGenerator which handles the SQL generation across dialects, properly quoting identifiers and handling dialect-specific syntax (e.g. LIMIT/OFFSET handling).

List of Breaking Changes

  • None

Summary by CodeRabbit

  • New Features

    • Added a Sequelize-level union operation to combine queries across models.
    • Supports UNION and UNION ALL, with optional ORDER BY, LIMIT and OFFSET, and automatic deduplication for standard UNIONs.
  • Tests

    • Added integration tests covering UNION vs UNION ALL, ordering, pagination, deduplication, and per-query attribute selection.

…eries with support for `UNION ALL`, `ORDER BY`, `LIMIT`, and `OFFSET`.
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Jan 26, 2026

📝 Walkthrough

Walkthrough

Adds UNION support: new UnionOptions type, AbstractQueryGenerator.unionQuery to build combined SQL, AbstractQueryInterface.union to execute it, and Sequelize.union to assemble per-model SELECTs and run the union. Includes integration tests for behavior (dedupe, UNION ALL, ORDER/LIMIT/OFFSET, attributes).

Changes

Cohort / File(s) Summary
Types
packages/core/src/abstract-dialect/query-generator.types.ts, packages/core/src/abstract-dialect/query-generator.d.ts
Adds UnionOptions interface (extends QueryRawOptions & AddLimitOffsetOptions) and imports it into query-generator declarations.
Query Generator
packages/core/src/abstract-dialect/query-generator.js
New unionQuery(sqls, options) that strips trailing semicolons, joins queries with UNION or UNION ALL, optionally appends ORDER BY (supports strings and [column,direction] tuples) and delegates LIMIT/OFFSET formatting to internals.
Query Interface
packages/core/src/abstract-dialect/query-interface.d.ts, packages/core/src/abstract-dialect/query-interface.js
Adds union(sqls, options?) which defaults type to SELECT, delegates SQL assembly to queryGenerator.unionQuery, and executes via queryRaw with replacements removed.
Sequelize API
packages/core/src/sequelize.d.ts, packages/core/src/sequelize.js
Adds Sequelize.prototype.union(queries, options?) that validates/normalizes per-model queries (scopes, includes, attributes), ensures primary key included for deduplication, builds per-query SELECT SQLs, and calls queryInterface.union to execute the combined SQL.
Tests
packages/core/test/integration/sequelize/union.test.ts
New integration tests covering default deduplication, unionAll behavior, ORDER BY, LIMIT, OFFSET, and per-query attributes across models.

Sequence Diagram

sequenceDiagram
    participant Client as Client
    participant Sequelize as Sequelize
    participant QueryInterface as QueryInterface
    participant QueryGenerator as QueryGenerator
    participant DB as Database

    Client->>Sequelize: union(queries, options)
    activate Sequelize

    Sequelize->>Sequelize: validate & normalize queries
    loop per query
        Sequelize->>QueryGenerator: build SELECT SQL for model
        QueryGenerator-->>Sequelize: SELECT SQL
    end

    Sequelize->>QueryInterface: union(sqls, options)
    deactivate Sequelize
    activate QueryInterface

    QueryInterface->>QueryGenerator: unionQuery(sqls, options)
    activate QueryGenerator
    QueryGenerator->>QueryGenerator: join with UNION / UNION ALL
    QueryGenerator->>QueryGenerator: append ORDER BY, LIMIT, OFFSET
    QueryGenerator-->>QueryInterface: combined SQL
    deactivate QueryGenerator

    QueryInterface->>DB: execute combined SQL
    DB-->>QueryInterface: results
    QueryInterface-->>Client: results
    deactivate QueryInterface
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related issues

Poem

🐰 I stitched queries, one by one,
Hopped them together until they're one,
Deduped the carrots, kept order neat,
LIMIT tucked in—what a treat!
Hop on, union—our feast is done. 🥕🎩

🚥 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 title accurately describes the main change: adding native UNION support via a sequelize.union method as a new feature.
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 docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 OpenGrep (1.16.0)
packages/core/src/abstract-dialect/query-generator.js

┌──────────────┐
│ Opengrep CLI │
└──────────────┘

�[32m✔�[39m �[1mOpengrep OSS�[0m
�[32m✔�[39m Basic security coverage for first-party code vulnerabilities.

�[1m Loading rules from local config...�[0m

packages/core/src/abstract-dialect/query-interface.js

┌──────────────┐
│ Opengrep CLI │
└──────────────┘

�[32m✔�[39m �[1mOpengrep OSS�[0m
�[32m✔�[39m Basic security coverage for first-party code vulnerabilities.

�[1m Loading rules from local config...�[0m

packages/core/src/abstract-dialect/query-generator.types.ts

┌──────────────┐
│ Opengrep CLI │
└──────────────┘

�[32m✔�[39m �[1mOpengrep OSS�[0m
�[32m✔�[39m Basic security coverage for first-party code vulnerabilities.

�[1m Loading rules from local config...�[0m

  • 3 others

Tip

Issue Planner is now in beta. Read the docs and try it out! Share your feedback on Discord.


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
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: 8

🤖 Fix all issues with AI agents
In `@packages/core/src/abstract-dialect/query-generator.js`:
- Around line 1495-1506: The ORDER BY direction from options.order is used
directly when building orderClauses; validate the direction against
VALID_ORDER_OPTIONS before interpolating to avoid unexpected SQL. In the order
mapping logic (where orders, orderClauses, and the tuple [col, dir] are
handled), normalize dir to a canonical form (e.g., toUpperCase()), check
inclusion in VALID_ORDER_OPTIONS, and if invalid either throw a clear error or
fall back to a safe default like 'ASC'; then use the validated value when
calling quoteIdentifier(col) and building the clause. Ensure VALID_ORDER_OPTIONS
is referenced and used for validation so behavior matches the rest of the
codebase.
- Around line 1509-1514: The conditional that decides whether to call
this.#internals.addLimitAndOffset uses a truthy check (if (options.limit ||
options.offset)) — change it to use explicit null/undefined checks to match the
file's pattern by testing options.limit != null || options.offset != null so
LIMIT/OFFSET with zero values are handled consistently; update the conditional
near the query assembly where query and limitOrder are used (the block calling
this.#internals.addLimitAndOffset with options.limit/options.offset).

In `@packages/core/src/abstract-dialect/query-interface.js`:
- Around line 585-588: Before calling this.sequelize.queryRaw in the union flow,
remove options.replacements the same way select() does to avoid double
replacements: obtain or clone the options passed to unionQuery (referencing
this.queryGenerator.unionQuery and the local variable options), delete the
replacements property (options.replacements) on that cloned options object, then
pass the sanitized options into this.sequelize.queryRaw(sql, sanitizedOptions).
Ensure you do not mutate the original options object if other callers rely on
it.
- Around line 583-584: The merge currently allows callers to override the query
type; in the async union(sqls, options) function ensure QueryTypes.SELECT is
applied after merging so callers cannot override it. Modify the options
assignment to first merge user options (e.g., options = { ...options }) and then
enforce options.type = QueryTypes.SELECT (or recreate options = { ...options,
type: QueryTypes.SELECT }) so the function always uses SELECT; update references
to options in union accordingly.

In `@packages/core/src/sequelize.js`:
- Around line 247-252: In union, add upfront validation to ensure the queries
array is not empty and each entry has a valid model: check that queries is an
array with length > 0 and iterate queries to verify q && q.model is present (and
optionally that q.options exists); throw a clear TypeError or return a rejected
Promise with a descriptive message if validation fails. Place this check at the
start of the async union(queries, options = {}) method before mapping queries so
that later uses of q.model and q.options (e.g., the mapping that references
q.model and q.options) are safe and produce deterministic errors.
- Line 287: Update the UnionOptions type so it includes transaction support by
extending QueryRawOptions instead of only AddLimitOffsetOptions: find the
UnionOptions type (used by the union method) and change its extends clause to
include QueryRawOptions (which includes Transactionable) so callers can pass a
transaction through the union() call that ultimately delegates to queryRaw;
ensure any existing properties (limit, offset, replacements) remain present and
compatible with QueryRawOptions.

In `@packages/core/test/integration/sequelize/union.test.ts`:
- Line 41: The tests cast sequelize to any because the custom union method is
missing from the Sequelize type definitions; add a TypeScript declaration for
the union method (its signature used in tests) and surface it via module
augmentation or by extending the Sequelize interface so the test instance has
the correct type instead of casting. Concretely, add a declaration file (or
update the package's exported types) that augments the 'sequelize' module /
Sequelize class with union(params: Array<{ model: typeof Model }>):
Promise<any[]> (match the real return type), export that types file from the
package (or include it in the package's types entry), then remove the (sequelize
as any) casts in union.test.ts so the test uses the updated typed
sequelize.union method.
- Around line 96-109: Test currently asserts that 'age' is absent but doesn't
assert whether the primary key was injected when attributes were specified;
update the test for sequelize.union to also assert presence (or absence, as
appropriate) of the injected primary key field (id) when calling (sequelize as
any).union with options { attributes: ['name'] } and raw mode not set, e.g.,
after creating vars.User and vars.Guest check results include the primary key
property 'id' (or verify deduplication behavior relies on id) so the behavior of
attribute injection in the union implementation (union, attributes, raw, id,
vars.User, vars.Guest) is covered.
🧹 Nitpick comments (4)
packages/core/src/abstract-dialect/query-generator.types.ts (1)

15-18: Consider adding JSDoc documentation for the new public interface.

The UnionOptions interface is part of the public API but lacks documentation. Adding JSDoc comments would improve developer experience.

📝 Suggested documentation
+/**
+ * Options for union queries.
+ */
 export interface UnionOptions extends AddLimitOffsetOptions {
+  /** If true, use UNION ALL (keeps duplicates). Defaults to false (UNION with deduplication). */
   unionAll?: boolean;
+  /** Order clause for the combined result. Can be a column name, [column, direction] tuple, or array of either. */
   order?: string | [string, string] | Array<string | [string, string]>;
 }
packages/core/src/sequelize.d.ts (1)

611-617: Consider using a more specific type for the queries parameter.

The queries parameter is typed as object[], which is quite loose. Based on the implementation in sequelize.js, each query object should have model and optional options properties.

💡 Suggested type improvement
+export interface UnionQueryEntry {
+  model: ModelStatic<any>;
+  options?: FindOptions<any>;
+}
+
 /**
  * Run a union query
  *
  * `@param` queries The queries to union. Can be a set of options or model instances.
  * `@param` options The options for the union query.
  */
-union(queries: object[], options?: UnionOptions): Promise<any>;
+union(queries: UnionQueryEntry[], options?: UnionOptions): Promise<any>;
packages/core/test/integration/sequelize/union.test.ts (1)

5-5: Add dialect teaser for test description.

Per coding guidelines, use Support.getTestDialectTeaser() for dialect-specific test descriptions.

📝 Suggested fix
+import { sequelize, getTestDialectTeaser } from '../support';
-import { sequelize } from '../support';
 
-describe('Sequelize#union', () => {
+describe(getTestDialectTeaser('Sequelize#union'), () => {
packages/core/src/sequelize.js (1)

264-274: Add comments explaining the primary key injection logic.

The condition for primary key injection is complex. Adding a comment would help future maintainers understand when and why the primary key is injected.

📝 Suggested comment
+        // Inject primary key into attributes if:
+        // - Custom attributes are specified (not selecting all columns)
+        // - Not a raw query (raw queries bypass model mapping)
+        // - Model has a primary key
+        // - Primary key is not already in the attributes
+        // - Either no grouping, or has multi-association (to ensure proper deduplication)
         if (
           queryOptions.attributes &&
           !queryOptions.raw &&
           model.primaryKeyAttribute &&
           !queryOptions.attributes.includes(model.primaryKeyAttribute) &&
           (!queryOptions.group ||
             !queryOptions.hasSingleAssociation ||
             queryOptions.hasMultiAssociation)
         ) {
           queryOptions.attributes = [model.primaryKeyAttribute].concat(queryOptions.attributes);
         }

await vars.User.create({ name: 'Alice', age: 20 });
await vars.Guest.create({ name: 'Bob', age: 30 });

const results = await (sequelize as any).union([{ model: vars.User }, { model: vars.Guest }]);
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Type cast indicates missing type definition.

The (sequelize as any).union cast is used throughout the tests, indicating that the union method type isn't properly exported or the test's sequelize instance doesn't have the updated types. This should be addressed to ensure type safety in tests.

🤖 Prompt for AI Agents
In `@packages/core/test/integration/sequelize/union.test.ts` at line 41, The tests
cast sequelize to any because the custom union method is missing from the
Sequelize type definitions; add a TypeScript declaration for the union method
(its signature used in tests) and surface it via module augmentation or by
extending the Sequelize interface so the test instance has the correct type
instead of casting. Concretely, add a declaration file (or update the package's
exported types) that augments the 'sequelize' module / Sequelize class with
union(params: Array<{ model: typeof Model }>): Promise<any[]> (match the real
return type), export that types file from the package (or include it in the
package's types entry), then remove the (sequelize as any) casts in
union.test.ts so the test uses the updated typed sequelize.union method.

Copy link
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: 3

🧹 Nitpick comments (2)
packages/core/test/integration/sequelize/union.test.ts (2)

5-5: Use Support.getTestDialectTeaser() for the describe block.

Per coding guidelines, test descriptions should use the dialect teaser helper for dialect-specific context.

+import { sequelize } from '../support';
+import Support from '../support';
+
-describe('Sequelize#union', () => {
+describe(Support.getTestDialectTeaser('Sequelize#union'), () => {

As per coding guidelines: "Use Support.getTestDialectTeaser() for dialect-specific test descriptions".

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

In `@packages/core/test/integration/sequelize/union.test.ts` at line 5, Replace
the hardcoded describe title with the dialect teaser helper: change the
describe('Sequelize#union', ...) call to use
Support.getTestDialectTeaser('Sequelize#union') so the test description is
dialect-aware (locate the describe call in union.test.ts and update it to call
Support.getTestDialectTeaser).

59-67: Deduplication test relies on auto-increment IDs being identical across tables.

This test works because both User and Guest tables start auto-increment at 1, so the full row (1, 'Alice', 20) is identical in both sub-SELECTs. If the test setup or seeding order changes (e.g., a prior create call advances one table's sequence), the IDs would differ and UNION would no longer deduplicate. Consider using raw: true or explicit attributes excluding id to make the deduplication assertion independent of auto-increment state.

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

In `@packages/core/test/integration/sequelize/union.test.ts` around lines 59 - 67,
The deduplication assertion in the "supports UNION (deduplicates by default)"
test is brittle because it relies on matching auto-increment IDs from vars.User
and vars.Guest; update the test to make the UNION compare only business fields
by calling sequelize.union with sub-selects that exclude the id (e.g., provide
attributes: ['name','age'] for each entry) or pass raw: true so the result rows
lack the id, then assert deduplication on name/age; locate the call to
sequelize.union and the vars.User/vars.Guest creates in that test and change the
union invocation to use attributes or raw as described.
🤖 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/abstract-dialect/query-generator.js`:
- Around line 1487-1522: The unionQuery method should not use options.unionAll
unconditionally; update the join character selection in unionQuery to check the
dialect capability by using this.dialect.supports['UNION ALL'] &&
options.unionAll to decide between ' UNION ALL ' and ' UNION '; change the
existing joinChar assignment accordingly (referencing unionQuery,
options.unionAll, and this.dialect.supports['UNION ALL']) and ensure tests/usage
that passed options.unionAll on dialects without support still produce plain
UNION (or add a dialect-specific override for unionQuery where necessary, e.g.,
MSSQL).
- Around line 1514-1519: The code appends an extra leading space before the
LIMIT/OFFSET clause because addLimitAndOffset already returns a leading-space
string; in the block that computes limitOrder (the if checking
options.limit/options.offset) replace the template literal that prepends " "
(query += ` ${limitOrder}`) with a direct append (query += limitOrder) or trim
the leading space from limitOrder so the generated SQL has only a single space
before the clause; refer to the addLimitAndOffset call and the limitOrder
variable when making the change.

In `@packages/core/src/sequelize.js`:
- Around line 272-273: Remove the dead variable assignment: the tableNames
object (declared as tableNames and assigned tableNames[model.table] = true) is
never read later; delete the tableNames declaration and the assignment to
tableNames[model.table] so only the actual logic remains (this was likely copied
from a Model.findAll pattern and is unnecessary here), leaving all references to
model and its properties intact.

---

Duplicate comments:
In `@packages/core/src/abstract-dialect/query-generator.js`:
- Around line 1498-1509: The code silently normalizes an invalid order direction
to 'ASC' in the Array.isArray(t) branch; instead, validate the direction against
VALID_ORDER_OPTIONS and throw a descriptive error when it's not recognized. In
the Array tuple handling (where you destructure const [col, dir] and compute
normalizedDir), replace the fallback normalization with a check that if dir is a
string but not included in VALID_ORDER_OPTIONS you throw (e.g., Error or
TypeError) naming the invalid direction and listing VALID_ORDER_OPTIONS; if dir
is undefined use the default 'ASC' as before, and continue to use
this.quoteIdentifier(col) for the column in the returned string.

---

Nitpick comments:
In `@packages/core/test/integration/sequelize/union.test.ts`:
- Line 5: Replace the hardcoded describe title with the dialect teaser helper:
change the describe('Sequelize#union', ...) call to use
Support.getTestDialectTeaser('Sequelize#union') so the test description is
dialect-aware (locate the describe call in union.test.ts and update it to call
Support.getTestDialectTeaser).
- Around line 59-67: The deduplication assertion in the "supports UNION
(deduplicates by default)" test is brittle because it relies on matching
auto-increment IDs from vars.User and vars.Guest; update the test to make the
UNION compare only business fields by calling sequelize.union with sub-selects
that exclude the id (e.g., provide attributes: ['name','age'] for each entry) or
pass raw: true so the result rows lack the id, then assert deduplication on
name/age; locate the call to sequelize.union and the vars.User/vars.Guest
creates in that test and change the union invocation to use attributes or raw as
described.

Comment on lines +1487 to +1522
unionQuery(sqls, options) {
let query = '';
const joinChar = options.unionAll ? ' UNION ALL ' : ' UNION ';

const cleanedSqls = sqls.map(sql => (sql.endsWith(';') ? sql.slice(0, -1) : sql));

query += cleanedSqls.join(joinChar);

if (options.order) {
const orders = Array.isArray(options.order) ? options.order : [options.order];
const orderClauses = orders.map(t => {
if (Array.isArray(t)) {
const [col, dir] = t;

let normalizedDir = typeof dir === 'string' ? dir.toUpperCase() : 'ASC';
if (!VALID_ORDER_OPTIONS.includes(normalizedDir)) {
normalizedDir = 'ASC';
}

return `${this.quoteIdentifier(col)} ${normalizedDir}`;
}

return this.quoteIdentifier(t);
});
query += ` ORDER BY ${orderClauses.join(', ')}`;
}

if (options.limit != null || options.offset != null) {
const limitOrder = this.#internals.addLimitAndOffset(options);
if (limitOrder) {
query += ` ${limitOrder}`;
}
}

return `${query};`;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# Check which dialects extend the query generator
rg -n "class.*extends.*QueryGenerator" packages/ --type ts --type js -A 2

# Check if any dialect already has union-related support flags
rg -n "UNION" packages/core/src/abstract-dialect/dialect.ts -B 2 -A 2
rg -n "'UNION" packages/ --type ts --type js | head -20

Repository: sequelize/sequelize

Length of output: 9762


🏁 Script executed:

# Search for unionQuery method in all query generators (both base and dialect-specific)
rg -n "unionQuery" packages/ --type ts --type js -B 2 -A 2

Repository: sequelize/sequelize

Length of output: 1303


🏁 Script executed:

# Check dialect.ts files to see what support flags exist for each dialect
fd "dialect.ts" packages/ --type f | xargs cat | grep -E "(supports\.|UNION|union)"

Repository: sequelize/sequelize

Length of output: 213


🏁 Script executed:

# Search for calls to unionQuery to see if unionAll is used and where
rg -n "unionQuery" packages/ --type ts --type js | grep -v "\.d\.ts"

# Check if tests call union with unionAll option
rg -n "\.union\(" packages/core/test --type ts --type js -A 3 | head -40

# Check if there are any union tests that might test SQLite
rg -n "union.*" packages/*/test --type ts --type js -B 2 | grep -i "sqlite" -A 2 -B 2 | head -30

Repository: sequelize/sequelize

Length of output: 2303


🏁 Script executed:

# Check what addLimitAndOffset does and if it's dialect-specific
rg -n "addLimitAndOffset" packages/ --type ts --type js -B 2 -A 5 | head -60

Repository: sequelize/sequelize

Length of output: 4628


🏁 Script executed:

# Verify the exact issue: check line 1299 mentioned to see how it properly checks the support flag
sed -n '1295,1305p' packages/core/src/abstract-dialect/query-generator.js

# Also check if there's any guard elsewhere in the union flow
grep -n "unionQuery\|supports\['UNION ALL'\]" packages/core/src/abstract-dialect/query-interface.js -B 2 -A 2

Repository: sequelize/sequelize

Length of output: 675


Add validation that the dialect supports UNION ALL before using it in the query.

The unionQuery method at line 1489 uses options.unionAll directly without checking if the dialect supports it. SQLite explicitly declares 'UNION ALL': false in its dialect configuration, yet the method would generate invalid SQL if options.unionAll is true on SQLite.

This contradicts the pattern already present at line 1299 of the same file, which correctly checks this.dialect.supports['UNION ALL'] before choosing the join character. Apply the same validation in unionQuery:

Suggested fix pattern
const joinChar = this.dialect.supports['UNION ALL'] && options.unionAll ? ' UNION ALL ' : ' UNION ';

Alternatively, consider adding dialect-specific unionQuery overrides if UNION has other dialect-specific quirks (e.g., MSSQL's ORDER BY requirements with OFFSET ... FETCH).

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

In `@packages/core/src/abstract-dialect/query-generator.js` around lines 1487 -
1522, The unionQuery method should not use options.unionAll unconditionally;
update the join character selection in unionQuery to check the dialect
capability by using this.dialect.supports['UNION ALL'] && options.unionAll to
decide between ' UNION ALL ' and ' UNION '; change the existing joinChar
assignment accordingly (referencing unionQuery, options.unionAll, and
this.dialect.supports['UNION ALL']) and ensure tests/usage that passed
options.unionAll on dialects without support still produce plain UNION (or add a
dialect-specific override for unionQuery where necessary, e.g., MSSQL).

Comment on lines +1514 to +1519
if (options.limit != null || options.offset != null) {
const limitOrder = this.#internals.addLimitAndOffset(options);
if (limitOrder) {
query += ` ${limitOrder}`;
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find addLimitAndOffset implementation and its return patterns
rg -n "addLimitAndOffset" packages/core/src/abstract-dialect/query-generator.js -B 2 -A 15 | head -100

Repository: sequelize/sequelize

Length of output: 1362


🏁 Script executed:

#!/bin/bash
# Check the selectQuery method context around line 1442 to see how limitOrder is used
sed -n '1435,1460p' packages/core/src/abstract-dialect/query-generator.js | cat -n

Repository: sequelize/sequelize

Length of output: 1108


🏁 Script executed:

#!/bin/bash
# Check the unionQuery method context around line 1517 to see the exact code
sed -n '1510,1525p' packages/core/src/abstract-dialect/query-generator.js | cat -n

Repository: sequelize/sequelize

Length of output: 586


🏁 Script executed:

#!/bin/bash
# Search for dialect-specific query-generator implementations
find packages -name "query-generator.js" -o -name "query-generator.ts" | grep -v node_modules

Repository: sequelize/sequelize

Length of output: 467


🏁 Script executed:

#!/bin/bash
# Search for addLimitAndOffset method definition in the base query generator
rg -n "addLimitAndOffset\s*\(" packages/core/src/abstract-dialect/query-generator.js -B 1 -A 20 | head -80

Repository: sequelize/sequelize

Length of output: 1613


🏁 Script executed:

#!/bin/bash
# Also check if addLimitAndOffset is in the `#internals` object or elsewhere
rg -n "addLimitAndOffset" packages/core/src/abstract-dialect/query-generator.js | head -20

Repository: sequelize/sequelize

Length of output: 208


🏁 Script executed:

#!/bin/bash
# Search for `#internals` initialization or definition
rg -n "#internals\s*=" packages/core/src/abstract-dialect/query-generator.js | head -10

Repository: sequelize/sequelize

Length of output: 98


🏁 Script executed:

#!/bin/bash
# Search for where addLimitAndOffset is defined - it might be in a separate file or class
rg -n "addLimitAndOffset" packages/core/src/ -B 2 -A 15 --type js --type ts | head -150

Repository: sequelize/sequelize

Length of output: 7024


🏁 Script executed:

#!/bin/bash
# Find addLimitAndOffset implementation in a dialect (e.g., postgres)
rg -n "addLimitAndOffset" packages/postgres/src/ -B 2 -A 15

Repository: sequelize/sequelize

Length of output: 1213


🏁 Script executed:

#!/bin/bash
# Check another dialect like MySQL
rg -n "addLimitAndOffset" packages/mysql/src/ -B 2 -A 15

Repository: sequelize/sequelize

Length of output: 497


🏁 Script executed:

#!/bin/bash
# Check SQLite
rg -n "addLimitAndOffset" packages/sqlite3/src/ -B 2 -A 15

Repository: sequelize/sequelize

Length of output: 3148


🏁 Script executed:

#!/bin/bash
# Check the formatMySqlStyleLimitOffset function
rg -n "formatMySqlStyleLimitOffset" packages/mysql/src/ -B 2 -A 20

Repository: sequelize/sequelize

Length of output: 2394


🏁 Script executed:

#!/bin/bash
# Check other dialects like MSSQL
rg -n "addLimitAndOffset" packages/mssql/src/ -B 2 -A 15

Repository: sequelize/sequelize

Length of output: 1478


🏁 Script executed:

#!/bin/bash
# Find formatMySqlStyleLimitOffset implementation
rg -n "formatMySqlStyleLimitOffset" packages/core/src/ -B 2 -A 20

Repository: sequelize/sequelize

Length of output: 1471


Remove the extra space before the LIMIT/OFFSET clause.

The addLimitAndOffset method returns a string that already includes a leading space (e.g., LIMIT 10). Line 1517 prepends an additional space via the template literal, producing double spacing in the generated SQL.

🔧 Proposed fix
     if (options.limit != null || options.offset != null) {
       const limitOrder = this.#internals.addLimitAndOffset(options);
       if (limitOrder) {
-        query += ` ${limitOrder}`;
+        query += limitOrder;
       }
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/abstract-dialect/query-generator.js` around lines 1514 -
1519, The code appends an extra leading space before the LIMIT/OFFSET clause
because addLimitAndOffset already returns a leading-space string; in the block
that computes limitOrder (the if checking options.limit/options.offset) replace
the template literal that prepends " " (query += ` ${limitOrder}`) with a direct
append (query += limitOrder) or trim the leading space from limitOrder so the
generated SQL has only a single space before the clause; refer to the
addLimitAndOffset call and the limitOrder variable when making the change.

Comment on lines +272 to +273
const tableNames = {};
tableNames[model.table] = true;
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Dead code: tableNames is populated but never read.

The tableNames object is created and assigned a key but is never used in the rest of the method. This appears to be leftover from copying the Model.findAll pattern.

🧹 Proposed fix: remove dead code
-        const tableNames = {};
-        tableNames[model.table] = true;
-
         // Inject primary key if needed for deduplication
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const tableNames = {};
tableNames[model.table] = true;
// Inject primary key if needed for deduplication
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/sequelize.js` around lines 272 - 273, Remove the dead
variable assignment: the tableNames object (declared as tableNames and assigned
tableNames[model.table] = true) is never read later; delete the tableNames
declaration and the assignment to tableNames[model.table] so only the actual
logic remains (this was likely copied from a Model.findAll pattern and is
unnecessary here), leaving all references to model and its properties intact.

@SippieCup
Copy link
Contributor

SippieCup commented Feb 28, 2026

This is some nice work. However, I have a couple refactoring suggestions that I think will really help with the usability of this feature. Added as-is, it is far too brittle for merging. There is a lot of low hanging fruit that can be implemented around typing that should resolve quite a bit of this.

First, You need to just improve the typing on this quite a bit. By definition, the spec you set for yourself is very open ended, which is great for extensibility, but makes it extremely brittle and for users to know if what they built works or will even create a valid sql statement.

I think a lot of this could be easily rectified with a few typing rules which would provide users with feedback on if they have valid filters before it gets to production. But here's a list of things I think would need to be addressed after review.

  1. There is an obvioous SQL Injection in this implementation. QuoteIdentifier is not the correct type to use for sanitizing the order., since the order can also be passed in a strings. If a user was able to pass something `id; DROP SCHEMA public CASCADE --", there is no protection. There is also no validation that the columns specified in the order actually exist. I would review how we are parsing orders already and use the functions we already have to protect against this.

  2. Remove the any and obj types for real inferred types from the extensive types we already have.

  3. Ensure the same number of columns, and that each column has compatible types across all the queries rather than passing in an array of generated SQL statements and running off hopeium. Rather than pre-generating the Statements and concatenating them together, You would be better off if you generated them in parallel and cross-referenced each field to ensure it exists and is of compatible types. Doing this correctly would allow serialization & type checking to catch any misalignment and during the writing of the filter rather than at runtime. You will know that something is broken as soon as you see a UUID & a date in the same column, or if there a different number of columns being returned. Doing them in parallel also provides us with an easy place for us to branch off of building unions for SQLite, which doesn't support it.

  4. There is a fundamental issue in SQL that rears its head when implementing UNION you should be aware of. if two tables happen to have a collision where you have the same set of data being returned, it won't be deduplicated because the PKs are coming from different tables. { id: 1, foo: 'bar' } from tableA and { id: 1, foo: 'bar' } from tableB would result in duplicates. Your dedup test is broken because you have a super sparse table. add foo: 'bar' to them and it'll break.

  5. That wouldn't be that bad from a data perspective, even though it'll likely end up being slower than simply not using UNION, because our hydration engine would catch and dedup it anyway, However, this PR is just bypassing that with the any[] return type. Since it isn't returning a back a model instance, all getters, virtual fields, typing, and useful functions like toJSON() would be lost. I don't have a good answer for this, and the only one I have would be a real PITA to implement, but ideally we should be returning a model with a consolidated pick lists for each row from the UNION'd models. Doing the queries in parallel would allow you to make this a little less painful. However, that be returning a generic Model, so while we would get the coveted toJSON() back, getters would still be lost, and i'm 99% sure anyone attempting a union with virtuals would be a lost cause.

  6. This obviously needs transaction support.

  7. I can't see eager loading working with UNIONs at all, even though there seems to be an attempt at doing so. IMO the filters should be extended to have include: never, but maybe add some tests around it.

  8. This needs tests for when it should fail as well. like if there is a bad where clause or include in subqueries, If there is a wrong number of columns between SQL statements, and a few integration tests to see if it really works past just the generator.

This is a good start, but there is some work that needs to be done. If you would like some help let me know. I intentionally avoided doing more for unions (it does identify and use em for things like hasMany seperate relationships) in my last commit because of a lot of these issues. So I'm happy someone else is willing to power through it.

Good luck and nice work so far!

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.

2 participants