Repository: sequelize/sequelize
Pull request #17751: feat(core): count grouped rows
Author: login: seho0808
Created: 2025-03-09T05:23:55Z | Merged: 2025-03-22T20:33:03Z

<!--
Thanks for wanting to fix something on Sequelize - we already love you!
Please fill in the template below.
If unsure about something, just do as best as you're able.
-->

## Pull Request Checklist

<!-- Please make sure to review and check all of these items: -->

- [x] Have you added new tests to prevent regressions?
- [ ] If a documentation update is necessary, have you opened a PR to [the documentation repository](https://github.com/sequelize/website/)? <!-- Put PR link here -->
- [x] Did you update the typescript typings accordingly (if applicable)?
- [x] Does the description below contain a link to an existing issue (Closes #[issue]) or a description of the issue you are solving?
- [x] Does the name of your PR follow [our conventions](https://github.com/sequelize/sequelize/blob/main/CONTRIBUTING.md#6-commit-your-modifications)?

<!-- NOTE: these things are not required to open a PR and can be done afterwards / while the PR is open. -->

## Description of Changes

- Why this PR is opened
  - This PR resolves merge conflict from https://github.com/sequelize/sequelize/pull/14841
  - I wanted to open PR to fratzinger's repo first, but I couldn't set `fratzinger/sequelize` as base branch when I tried to open a PR. (I wanted to create PR from seho0808/sequelize/fix/counted-group-rows to fratzinger/sequelize/fix/counted-group-rows but couldn't set fratzinger/sequelize as the base branch)
  - so I just created another PR to resolve the merge conflict

- What I changed to resolve the conflict
  - simple type conflict was resolved. there were only two lines of code changed. - it might not be evident in the commit history so if you want me to separate the merge conflict commit into two parts i can do so - but I don't know how you guys do it usually?

- For documentations sake, since there is an option added, I will go through the webiste repo to see if any documentation change is necessary after this PR is merged.

- I set the title of this PR as same as the #14841 instead of something like `meta: resolves merge conflict from #14841` as the PR title convention says all the commits will be squashed to the title of the PR. BUT I also feel like this should be a feature since it adds an optional field?


Diff:
diff --git a/packages/core/src/model.d.ts b/packages/core/src/model.d.ts
index 1fa16386c6a9..27aba242f217 100644
--- a/packages/core/src/model.d.ts
+++ b/packages/core/src/model.d.ts
@@ -985,6 +985,12 @@ export interface CountOptions<TAttributes = any>
    * Column on which COUNT() should be applied
    */
   col?: string;
+
+  /**
+   * Count number of records returned by group by
+   * Used in conjunction with `group`.
+   */
+  countGroupedRows?: boolean;
 }
 
 /**
@@ -2521,7 +2527,7 @@ export abstract class Model<
    */
   static findAndCountAll<M extends Model>(
     this: ModelStatic<M>,
-    options?: Omit<FindAndCountOptions<Attributes<M>>, 'group'>,
+    options?: Omit<FindAndCountOptions<Attributes<M>>, 'group' | 'countGroupedRows'>,
   ): Promise<{ rows: M[]; count: number }>;
   static findAndCountAll<M extends Model>(
     this: ModelStatic<M>,
diff --git a/packages/core/src/model.js b/packages/core/src/model.js
index d12a54a04280..f279df34a402 100644
--- a/packages/core/src/model.js
+++ b/packages/core/src/model.js
@@ -71,6 +71,7 @@ import {
   mergeDefaults,
 } from './utils/object';
 import { isWhereEmpty } from './utils/query-builder-utils';
+import { removeTrailingSemicolon } from './utils/string.js';
 import { getComplexKeys } from './utils/where.js';
 
 // This list will quickly become dated, but failing to maintain this list just means
@@ -1675,15 +1676,6 @@ ${associationOwner._getAssociationDebugList()}`);
       await this.hooks.runAsync('beforeCount', options);
     }
 
-    let col = options.col || '*';
-    if (options.include) {
-      col = `${this.name}.${options.col || this.primaryKeyField}`;
-    }
-
-    if (options.distinct && col === '*') {
-      col = this.primaryKeyField;
-    }
-
     options.plain = !options.group;
     options.dataType = new DataTypes.INTEGER();
     options.includeIgnoreAttributes = false;
@@ -1694,6 +1686,29 @@ ${associationOwner._getAssociationDebugList()}`);
     options.offset = null;
     options.order = null;
 
+    // counting grouped rows is not possible with `this.aggregate`
+    // use a subquery to get the count
+    if (options.group && options.countGroupedRows) {
+      const query = removeTrailingSemicolon(this.queryGenerator.selectQuery(this.table, options));
+
+      const queryCountAll = `Select COUNT(*) AS count FROM (${query}) AS Z`;
+
+      const result = await this.sequelize.query(queryCountAll);
+
+      const count = Number(result[0][0].count || result[0][0].COUNT);
+
+      return count;
+    }
+
+    let col = options.col || '*';
+    if (options.include) {
+      col = `${this.name}.${options.col || this.primaryKeyField}`;
+    }
+
+    if (options.distinct && col === '*') {
+      col = this.primaryKeyField;
+    }
+
     const result = await this.aggregate(col, 'count', options);
 
     // When grouping is used, some dialects such as PG are returning the count as string
@@ -1760,7 +1775,7 @@ ${associationOwner._getAssociationDebugList()}`);
 
     const countOptions = cloneDeep(options) ?? {};
 
-    if (countOptions.attributes) {
+    if (countOptions.attributes && !options.countGroupedRows) {
       countOptions.attributes = undefined;
     }
 
diff --git a/packages/core/test/integration/model/count.test.js b/packages/core/test/integration/model/count.test.js
index 397312215800..5fdfb23d50e5 100644
--- a/packages/core/test/integration/model/count.test.js
+++ b/packages/core/test/integration/model/count.test.js
@@ -329,6 +329,25 @@ describe('Model.count', () => {
 
       expect(counts).to.deep.equal([1, 1, 1, 1, 1, 1, 1, 1]);
     });
+
+    it('can count grouped rows', async function () {
+      await this.User.bulkCreate([
+        { username: 'user1', age: 10 },
+        { username: 'user2', age: 20 },
+        { username: 'user3', age: 30 },
+        { username: 'user4', age: 10 },
+        { username: 'user5', age: 20 },
+        { username: 'user6', age: 30 },
+      ]);
+
+      const count = await this.User.count({
+        attributes: ['age'],
+        group: ['age'],
+        countGroupedRows: true,
+      });
+
+      expect(count).to.be.eql(3);
+    });
   });
 
   context('test-specific models', () => {
diff --git a/packages/core/test/integration/model/findAll.test.js b/packages/core/test/integration/model/findAll.test.js
index e3a0d4f1e914..d8c8839467af 100644
--- a/packages/core/test/integration/model/findAll.test.js
+++ b/packages/core/test/integration/model/findAll.test.js
@@ -1556,9 +1556,9 @@ The following associations are defined on "Worker": "ToDos"`);
   describe('findAndCountAll', () => {
     beforeEach(async function () {
       await this.User.bulkCreate([
-        { username: 'user', data: 'foobar' },
-        { username: 'user2', data: 'bar' },
-        { username: 'bobby', data: 'foo' },
+        { username: 'user', data: 'foobar', intVal: 5 },
+        { username: 'user2', data: 'bar', intVal: 5 },
+        { username: 'bobby', data: 'foo', intVal: 5 },
       ]);
 
       const users = await this.User.findAll();
@@ -1674,6 +1674,26 @@ The following associations are defined on "Worker": "ToDos"`);
       expect(info.rows[0].dataValues).to.not.have.property('username');
       expect(info.rows[1].dataValues).to.not.have.property('username');
     });
+
+    it('handles grouped rows', async function () {
+      const info = await this.User.findAndCountAll({
+        attributes: [
+          [Sequelize.fn('sum', Sequelize.col('intVal')), 'sum'],
+          Sequelize.col('intVal'),
+        ],
+        group: [Sequelize.col('intVal')],
+        countGroupedRows: true,
+        raw: true,
+      });
+      expect(info.count).to.equal(1);
+      expect(Array.isArray(info.rows)).to.be.ok;
+      const row = info.rows[0];
+      if (dialectName === 'mysql') {
+        row.sum = Number(row.sum);
+      }
+
+      expect(info.rows).to.deep.equal([row]);
+    });
   });
 
   describe('all', () => {
diff --git a/packages/core/test/types/model-count.ts b/packages/core/test/types/model-count.ts
index 84dd5f65f833..adfb578acf42 100644
--- a/packages/core/test/types/model-count.ts
+++ b/packages/core/test/types/model-count.ts
@@ -7,6 +7,9 @@ expectTypeOf(MyModel.count()).toEqualTypeOf<Promise<number>>();
 expectTypeOf(MyModel.count({ group: 'tag' })).toEqualTypeOf<
   Promise<Array<{ [key: string]: unknown; count: number }>>
 >();
+expectTypeOf(MyModel.count({ group: 'tag', countGroupedRows: true })).toEqualTypeOf<
+  Promise<Array<{ [key: string]: unknown; count: number }>>
+>();
 expectTypeOf(MyModel.count({ col: 'tag', distinct: true })).toEqualTypeOf<Promise<number>>();
 expectTypeOf(
   MyModel.count({
diff --git a/packages/core/test/types/model.ts b/packages/core/test/types/model.ts
index 62808ebcbf3e..800c67b5774b 100644
--- a/packages/core/test/types/model.ts
+++ b/packages/core/test/types/model.ts
@@ -69,6 +69,15 @@ MyModel.findAndCountAll({ include: OtherModel, group: ['MyModel.int'] }).then(({
   expectTypeOf(rows).toEqualTypeOf<MyModel[]>();
 });
 
+MyModel.findAndCountAll({
+  include: OtherModel,
+  group: ['MyModel.int'],
+  countGroupedRows: true,
+}).then(({ count, rows }) => {
+  expectTypeOf(count).toEqualTypeOf<Array<{ [key: string]: unknown; count: number }>>();
+  expectTypeOf(rows).toEqualTypeOf<MyModel[]>();
+});
+
 MyModel.count({ include: OtherModel }).then(count => {
   expectTypeOf(count).toEqualTypeOf<number>();
 });


Reviews:
user: login: WikiRik
body: Thanks for working on this! I've made a few suggestions, but I haven't tested them so they might not work directly
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-14T20:13:18Z
user: login: seho0808
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-17T11:45:13Z
user: login: seho0808
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-17T11:52:19Z
user: login: seho0808
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-17T11:54:43Z
user: login: seho0808
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-17T11:57:29Z
user: login: seho0808
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-17T12:01:24Z
user: login: WikiRik
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-17T12:04:16Z
user: login: WikiRik
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-17T12:04:51Z
user: login: WikiRik
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-17T12:05:38Z
user: login: WikiRik
body: I've played around with some things and got the types working. I should have added suggestions for that, otherwise you can check out; https://github.com/sequelize/sequelize/pull/17793/files
If we get this PR to be like that, I think we're good to merge this

On the `!options.countGroupedRows`, for some reason I was thinking that it was part of the `count` function and missed that it was in `findAndCountAll`. So my original comment on that was not exactly supposed to be there
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-19T21:33:59Z
user: login: seho0808
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-21T14:55:45Z
user: login: seho0808
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-21T14:56:02Z
user: login: seho0808
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-22T12:10:37Z
user: login: seho0808
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-22T12:10:59Z
user: login: WikiRik
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-22T20:30:09Z

Review comments:
diff_hunk: @@ -4,7 +4,7 @@ import { expectTypeOf } from 'expect-type';
 class MyModel extends Model {}
 
 expectTypeOf(MyModel.count()).toEqualTypeOf<Promise<number>>();
-expectTypeOf(MyModel.count({ group: 'tag' })).toEqualTypeOf<
+expectTypeOf(MyModel.count({ group: 'tag', countGroupedRows: true })).toEqualTypeOf<
   Promise<Array<{ [key: string]: unknown; count: number }>>
 >();
path: packages/core/test/types/model-count.ts
user: login: WikiRik
body: ```suggestion
expectTypeOf(MyModel.count({ group: 'tag' })).toEqualTypeOf<
  Promise<Array<{ [key: string]: unknown; count: number }>>
>();
expectTypeOf(MyModel.count({ group: 'tag', countGroupedRows: true })).toEqualTypeOf<
  Promise<Array<{ [key: string]: unknown; count: number }>>
>();
```
The old one is still valid
created_at: 2025-03-14T19:59:22Z
author_association: MEMBER
diff_hunk: @@ -1674,6 +1674,24 @@ The following associations are defined on "Worker": "ToDos"`);
       expect(info.rows[0].dataValues).to.not.have.property('username');
       expect(info.rows[1].dataValues).to.not.have.property('username');
     });
+
+    it('handles grouped rows', async function () {
+      const info = await this.User.findAndCountAll({
+        attributes: [
+          [Sequelize.fn('sum', Sequelize.col('intVal')), 'sum'],
+          Sequelize.col('intVal'),
+        ],
+        group: [Sequelize.col('intVal')],
+        countGroupedRows: true,
+        raw: true,
+      });
+      expect(info.count).to.equal(1);
+      expect(Array.isArray(info.rows)).to.be.ok;
+      const row = info.rows[0];
+      // MySQL returns sum as string, other dialects return number
+      row.sum = Number(row.sum);
path: packages/core/test/integration/model/findAll.test.js
user: login: WikiRik
body: ```suggestion
      if (dialectName === 'mysql) {
         row.sum = Number(row.sum)
      }
```
This conveys the same information as the comment, with the added benefit of not changing `row.sum` for the other dialects
created_at: 2025-03-14T20:04:46Z
author_association: MEMBER
diff_hunk: @@ -1760,7 +1775,7 @@ ${associationOwner._getAssociationDebugList()}`);
 
     const countOptions = cloneDeep(options) ?? {};
 
-    if (countOptions.attributes) {
+    if (countOptions.attributes && !options.countGroupedRows) {
path: packages/core/src/model.js
user: login: WikiRik
body: Why is this addition needed?
created_at: 2025-03-14T20:05:50Z
author_association: MEMBER
diff_hunk: @@ -1694,6 +1685,30 @@ ${associationOwner._getAssociationDebugList()}`);
     options.offset = null;
     options.order = null;
 
+    // counting grouped rows is not possible with `this.aggregate`
+    // use a subquery to get the count
+    if (options.group && options.countGroupedRows) {
+      // remove trailing semicolon for wrap query
+      const query = this.queryGenerator.selectQuery(this.getTableName(), options).slice(0, -1);
+
+      const queryCountAll = `Select COUNT(*) AS count FROM (${query}) AS Z`;
+
+      const result = await this.sequelize.query(queryCountAll);
+
+      const count = Number(result[0][0].count || result[0][0].COUNT);
+
+      return count;
+    }
+
+    let col = options.col || '*';
+    if (options.include) {
+      col = `${this.name}.${options.col || this.primaryKeyField}`;
+    }
+
+    if (options.distinct && col === '*') {
+      col = this.primaryKeyField;
+    }
path: packages/core/src/model.js
user: login: WikiRik
body: Why is this moved from it's original location?
created_at: 2025-03-14T20:06:39Z
author_association: MEMBER
diff_hunk: @@ -1694,6 +1685,30 @@ ${associationOwner._getAssociationDebugList()}`);
     options.offset = null;
     options.order = null;
 
+    // counting grouped rows is not possible with `this.aggregate`
+    // use a subquery to get the count
+    if (options.group && options.countGroupedRows) {
+      // remove trailing semicolon for wrap query
+      const query = this.queryGenerator.selectQuery(this.getTableName(), options).slice(0, -1);
path: packages/core/src/model.js
user: login: WikiRik
body: ```suggestion
      const query = removeTrailingSemicolon(this.queryGenerator.selectQuery(this.table, options));
```

You'll need to import `removeTrailingSemicolon`;
https://github.com/sequelize/sequelize/blob/89fc0ff0dec093d17cd37d2f0260e404926faeb0/packages/core/src/utils/string.ts#L109-L115

`getTableName()` is deprecated as per;
https://github.com/sequelize/sequelize/blob/89fc0ff0dec093d17cd37d2f0260e404926faeb0/packages/core/src/model-typescript.ts#L413-L433
created_at: 2025-03-14T20:12:26Z
author_association: MEMBER
diff_hunk: @@ -1760,7 +1775,7 @@ ${associationOwner._getAssociationDebugList()}`);
 
     const countOptions = cloneDeep(options) ?? {};
 
-    if (countOptions.attributes) {
+    if (countOptions.attributes && !options.countGroupedRows) {
path: packages/core/src/model.js
user: login: seho0808
body: So I went throught the codebase again, I found that this option was added by the original PR in order to bypass attributes getting removed which is used to create RAW string query within the count function. the place that creates RAW string query is as below:

```ts
// this was the place it generates RAW string query. (part of `model.js` - `count` function)
// if the options.attributes is emtpy here, it simply does not produce a correct RAW query 
static async count(options) {
  // ...
  if (options.group && options.countGroupedRows) {
    const query = this.queryGenerator.selectQuery(this.getTableName(), options).slice(0, -1);
```

BUT, if we do keep options.attributes, it seems like there is a problem with transaction. (if i simply remove below code, transaction tests fail)

```ts
// if i comment this part out, transaction tests fail.
//if (countOptions.attributes) {
//   countOptions.attributes = undefined;
//}
```

so this feels like it has to do with transactions, and hence even if we do something like below, 'group by + counting + countGroupedRows' combo will PROBABLY not support transaction:

```ts
// this will probably remove transaction support WHEN countGroupedRows is enabled.
if (countOptions.attributes && !options.countGroupedRows) {
  countOptions.attributes = undefined;
}
```

so there could possibly be massive code change from previous PR by fratzinger in order to allow transaction support for countGroupedRows.
created_at: 2025-03-17T11:45:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1694,6 +1685,30 @@ ${associationOwner._getAssociationDebugList()}`);
     options.offset = null;
     options.order = null;
 
+    // counting grouped rows is not possible with `this.aggregate`
+    // use a subquery to get the count
+    if (options.group && options.countGroupedRows) {
+      // remove trailing semicolon for wrap query
+      const query = this.queryGenerator.selectQuery(this.getTableName(), options).slice(0, -1);
+
+      const queryCountAll = `Select COUNT(*) AS count FROM (${query}) AS Z`;
+
+      const result = await this.sequelize.query(queryCountAll);
+
+      const count = Number(result[0][0].count || result[0][0].COUNT);
+
+      return count;
+    }
+
+    let col = options.col || '*';
+    if (options.include) {
+      col = `${this.name}.${options.col || this.primaryKeyField}`;
+    }
+
+    if (options.distinct && col === '*') {
+      col = this.primaryKeyField;
+    }
path: packages/core/src/model.js
user: login: seho0808
body: so the original PR's code has a premature return statement:

```ts
if (options.group && options.countGroupedRows) {
  // ...
  return count; 
} 

let col = options.col || '*';
if (options.include) {
  col = `${this.name}.${options.col || this.primaryKeyField}`;
}

if (options.distinct && col === '*') {
  col = this.primaryKeyField;
}

const result = await this.aggregate(col, 'count', options);
// ...

```
fratzinger left a comment saying this: 

>    // counting grouped rows is not possible with `this.aggregate`
    // use a subquery to get the count

hence the code snippet seemed to have moved because it is only used by aggregate function but not the raw query processing bypass for countGroupedRows option.
created_at: 2025-03-17T11:52:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1694,6 +1685,30 @@ ${associationOwner._getAssociationDebugList()}`);
     options.offset = null;
     options.order = null;
 
+    // counting grouped rows is not possible with `this.aggregate`
+    // use a subquery to get the count
+    if (options.group && options.countGroupedRows) {
+      // remove trailing semicolon for wrap query
+      const query = this.queryGenerator.selectQuery(this.getTableName(), options).slice(0, -1);
+
+      const queryCountAll = `Select COUNT(*) AS count FROM (${query}) AS Z`;
+
+      const result = await this.sequelize.query(queryCountAll);
+
+      const count = Number(result[0][0].count || result[0][0].COUNT);
+
+      return count;
+    }
+
+    let col = options.col || '*';
+    if (options.include) {
+      col = `${this.name}.${options.col || this.primaryKeyField}`;
+    }
+
+    if (options.distinct && col === '*') {
+      col = this.primaryKeyField;
+    }
path: packages/core/src/model.js
user: login: seho0808
body: so basically, it seems like a very small optimization to not run the unused code when countGroupedRows option is enabled
created_at: 2025-03-17T11:54:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1760,7 +1775,7 @@ ${associationOwner._getAssociationDebugList()}`);
 
     const countOptions = cloneDeep(options) ?? {};
 
-    if (countOptions.attributes) {
+    if (countOptions.attributes && !options.countGroupedRows) {
path: packages/core/src/model.js
user: login: seho0808
body: This part seems like the most crucial part of this PR, so I will keep focusing on this part. Please let me know if you have any suggestions.
created_at: 2025-03-17T11:57:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -4,7 +4,7 @@ import { expectTypeOf } from 'expect-type';
 class MyModel extends Model {}
 
 expectTypeOf(MyModel.count()).toEqualTypeOf<Promise<number>>();
-expectTypeOf(MyModel.count({ group: 'tag' })).toEqualTypeOf<
+expectTypeOf(MyModel.count({ group: 'tag', countGroupedRows: true })).toEqualTypeOf<
   Promise<Array<{ [key: string]: unknown; count: number }>>
 >();
path: packages/core/test/types/model-count.ts
user: login: seho0808
body: The typescript linter would say `count` is `number` because we just defined our function return types as so, but in reality, it is buggy because without `countGroupedRows: true` I believe `count` is `{ count: number}[]` as https://github.com/sequelize/sequelize/issues/6148 suggests.

I roled my commit back for now to make countGroupedRows field mandatory, but I can always revert it again. https://github.com/sequelize/sequelize/pull/17751/commits/9790683360b925ef0dfd5cdab2a11d35621fb542

We have options:

1. just keep the buggy code ignore it (i dont know how strict at TS this repo is) - probs a bad solution
2. [If we keep countGroupedRows as optional] 
    - when countGroupedRows is  false OR undefined return `{ count: number}[]`  and when true, return `number`
3. [If we change countGroupedRows as mandatory] 
    - when countGroupedRows is  false return `{ count: number}[]`  and when true, return `number`
4. [If we hide countGroupedRows and always enable it in the background]
    - always return `number`

If there is no other issue, there is a high change I will go with solution number 2 (It kind of got organized in my head while I was writing this comment)

created_at: 2025-03-17T12:01:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1694,6 +1685,30 @@ ${associationOwner._getAssociationDebugList()}`);
     options.offset = null;
     options.order = null;
 
+    // counting grouped rows is not possible with `this.aggregate`
+    // use a subquery to get the count
+    if (options.group && options.countGroupedRows) {
+      // remove trailing semicolon for wrap query
+      const query = this.queryGenerator.selectQuery(this.getTableName(), options).slice(0, -1);
+
+      const queryCountAll = `Select COUNT(*) AS count FROM (${query}) AS Z`;
+
+      const result = await this.sequelize.query(queryCountAll);
+
+      const count = Number(result[0][0].count || result[0][0].COUNT);
+
+      return count;
+    }
+
+    let col = options.col || '*';
+    if (options.include) {
+      col = `${this.name}.${options.col || this.primaryKeyField}`;
+    }
+
+    if (options.distinct && col === '*') {
+      col = this.primaryKeyField;
+    }
path: packages/core/src/model.js
user: login: WikiRik
body: Thanks for that explanation! Let's keep it in the new way you've done then.
created_at: 2025-03-17T12:04:16Z
author_association: MEMBER
diff_hunk: @@ -4,7 +4,7 @@ import { expectTypeOf } from 'expect-type';
 class MyModel extends Model {}
 
 expectTypeOf(MyModel.count()).toEqualTypeOf<Promise<number>>();
-expectTypeOf(MyModel.count({ group: 'tag' })).toEqualTypeOf<
+expectTypeOf(MyModel.count({ group: 'tag', countGroupedRows: true })).toEqualTypeOf<
   Promise<Array<{ [key: string]: unknown; count: number }>>
 >();
path: packages/core/test/types/model-count.ts
user: login: WikiRik
body: I'll play around with it myself and get back to you on this
created_at: 2025-03-17T12:04:51Z
author_association: MEMBER
diff_hunk: @@ -1760,7 +1775,7 @@ ${associationOwner._getAssociationDebugList()}`);
 
     const countOptions = cloneDeep(options) ?? {};
 
-    if (countOptions.attributes) {
+    if (countOptions.attributes && !options.countGroupedRows) {
path: packages/core/src/model.js
user: login: WikiRik
body: I'll play around with it a little bit based on your explanation and get back to you on this
created_at: 2025-03-17T12:05:38Z
author_association: MEMBER
diff_hunk: @@ -2521,11 +2527,11 @@ export abstract class Model<
    */
   static findAndCountAll<M extends Model>(
     this: ModelStatic<M>,
-    options?: Omit<FindAndCountOptions<Attributes<M>>, 'group'>,
+    options?: Omit<FindAndCountOptions<Attributes<M>>, 'group' | 'countGroupedRows'>,
   ): Promise<{ rows: M[]; count: number }>;
   static findAndCountAll<M extends Model>(
     this: ModelStatic<M>,
-    options: SetRequired<FindAndCountOptions<Attributes<M>>, 'group'>,
+    options: SetRequired<FindAndCountOptions<Attributes<M>>, 'group' | 'countGroupedRows'>,
path: packages/core/src/model.d.ts
user: login: WikiRik
body: ```suggestion
    options: SetRequired<FindAndCountOptions<Attributes<M>>, 'group'>,
```
created_at: 2025-03-19T21:02:31Z
author_association: MEMBER
diff_hunk: @@ -64,7 +64,11 @@ MyModel.findAndCountAll({ include: OtherModel }).then(({ count, rows }) => {
   expectTypeOf(rows).toEqualTypeOf<MyModel[]>();
 });
 
-MyModel.findAndCountAll({ include: OtherModel, group: ['MyModel.int'] }).then(({ count, rows }) => {
+MyModel.findAndCountAll({
+  include: OtherModel,
+  group: ['MyModel.int'],
+  countGroupedRows: true,
+}).then(({ count, rows }) => {
path: packages/core/test/types/model.ts
user: login: WikiRik
body: ```suggestion
MyModel.findAndCountAll({ include: OtherModel, group: ['MyModel.int'] }).then(({ count, rows }) => {
  expectTypeOf(count).toEqualTypeOf<Array<{ [key: string]: unknown; count: number }>>();
  expectTypeOf(rows).toEqualTypeOf<MyModel[]>();
});

MyModel.findAndCountAll({
  include: OtherModel,
  group: ['MyModel.int'],
  countGroupedRows: true,
}).then(({ count, rows }) => {
```
created_at: 2025-03-19T21:31:28Z
author_association: MEMBER
diff_hunk: @@ -1694,6 +1685,30 @@ ${associationOwner._getAssociationDebugList()}`);
     options.offset = null;
     options.order = null;
 
+    // counting grouped rows is not possible with `this.aggregate`
+    // use a subquery to get the count
+    if (options.group && options.countGroupedRows) {
+      // remove trailing semicolon for wrap query
+      const query = this.queryGenerator.selectQuery(this.getTableName(), options).slice(0, -1);
path: packages/core/src/model.js
user: login: seho0808
body: resolved at https://github.com/sequelize/sequelize/pull/17751/commits/b761e8f643f1ddf2896469d79680cec8d545e6d6
created_at: 2025-03-21T14:55:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1674,6 +1674,24 @@ The following associations are defined on "Worker": "ToDos"`);
       expect(info.rows[0].dataValues).to.not.have.property('username');
       expect(info.rows[1].dataValues).to.not.have.property('username');
     });
+
+    it('handles grouped rows', async function () {
+      const info = await this.User.findAndCountAll({
+        attributes: [
+          [Sequelize.fn('sum', Sequelize.col('intVal')), 'sum'],
+          Sequelize.col('intVal'),
+        ],
+        group: [Sequelize.col('intVal')],
+        countGroupedRows: true,
+        raw: true,
+      });
+      expect(info.count).to.equal(1);
+      expect(Array.isArray(info.rows)).to.be.ok;
+      const row = info.rows[0];
+      // MySQL returns sum as string, other dialects return number
+      row.sum = Number(row.sum);
path: packages/core/test/integration/model/findAll.test.js
user: login: seho0808
body: resolved at https://github.com/sequelize/sequelize/pull/17751/commits/ee26ed7cd2ee3b880b0ca753e51ac8838c7328d5
created_at: 2025-03-21T14:56:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2521,11 +2527,11 @@ export abstract class Model<
    */
   static findAndCountAll<M extends Model>(
     this: ModelStatic<M>,
-    options?: Omit<FindAndCountOptions<Attributes<M>>, 'group'>,
+    options?: Omit<FindAndCountOptions<Attributes<M>>, 'group' | 'countGroupedRows'>,
   ): Promise<{ rows: M[]; count: number }>;
   static findAndCountAll<M extends Model>(
     this: ModelStatic<M>,
-    options: SetRequired<FindAndCountOptions<Attributes<M>>, 'group'>,
+    options: SetRequired<FindAndCountOptions<Attributes<M>>, 'group' | 'countGroupedRows'>,
path: packages/core/src/model.d.ts
user: login: seho0808
body: applied at => https://github.com/sequelize/sequelize/pull/17751/commits/705408eebe90a188ec9355dc31c8de793176da6a
created_at: 2025-03-22T12:10:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -64,7 +64,11 @@ MyModel.findAndCountAll({ include: OtherModel }).then(({ count, rows }) => {
   expectTypeOf(rows).toEqualTypeOf<MyModel[]>();
 });
 
-MyModel.findAndCountAll({ include: OtherModel, group: ['MyModel.int'] }).then(({ count, rows }) => {
+MyModel.findAndCountAll({
+  include: OtherModel,
+  group: ['MyModel.int'],
+  countGroupedRows: true,
+}).then(({ count, rows }) => {
path: packages/core/test/types/model.ts
user: login: seho0808
body: applied at => https://github.com/sequelize/sequelize/pull/17751/commits/705408eebe90a188ec9355dc31c8de793176da6a
created_at: 2025-03-22T12:10:59Z
author_association: CONTRIBUTOR

PR comments:
user: login: seho0808
created_at: 2025-03-09T06:06:28Z
body: ah the tests are not merged properly, I will spend some time to resolve this later today
author_association: CONTRIBUTOR
user: login: WikiRik
created_at: 2025-03-09T09:00:42Z
body: If it's easier you can also just copy the changes on a new branch without keeping the original commits, that's not a problem
author_association: MEMBER
user: login: WikiRik
created_at: 2025-03-11T08:42:42Z
body: >   - simple type conflict was resolved. there were only two lines of code changed. - it might not be evident in the commit history so if you want me to separate the merge conflict commit into two parts i can do so - but I don't know how you guys do it usually?

No, this is fine. We'll review it as a new PR so it doesn't really matter what was changed as merge conflict or in the original PR.


> - I set the title of this PR as same as the #14841 instead of something like `meta: resolves merge conflict from #14841` as the PR title convention says all the commits will be squashed to the title of the PR. BUT I also feel like this should be a feature since it adds an optional field?

`feat(core)` would indeed be better, good catch!

author_association: MEMBER
user: login: seho0808
created_at: 2025-03-11T10:24:57Z
body: @WikiRik Thanks for the comment! There are still some existing issues with Type Testing. I will look into it. From next commit I will try to run tests on my local machine thoroughly to not trigger Actions too much!!
author_association: CONTRIBUTOR
user: login: WikiRik
created_at: 2025-03-11T10:27:06Z
body: > @WikiRik Thanks for the comment! There are still some existing issues with Type Testing. I will look into it. From next commit I will try to run tests on my local machine thoroughly to not trigger Actions too much!!

No worries, GitHub Actions are running for free. It only takes more time to run, but for things like type tests on older TypeScript versions or integration tests it is usually easier to just trigger Actions
author_association: MEMBER
user: login: seho0808
created_at: 2025-03-12T00:46:40Z
body: @WikiRik Hey Rik, I came to a deadlock on deciding what to do. I think I need your input on this.

There are THREE design choices currently.

I found this problem when solving the current Typescript error.

Current Status is the second choice and I think number 1 or 3 is better.

I couldn't choose between 1 and 3 because I don't know which is better on trade off: customizable API vs simple API

(2 could be also ok, since it opens up possibilities)

1. KEEP countGroupedRows field + make it OPTIONAL
  - Pros: 
    - backward compatibility
    - User can choose for optimization (since using countGroupedRows enforces wrapped select query which slows down queries)
  - Cons: 
    - Users can have hard time understanding what countGroupedRows does

2. KEEP countGroupedRows field + make it REQUIRED (This was the original PR by fratzinger)
  - Pros:
    - there could be a chance when this field might be optional some day according to fratzinger's original PR
    
      > What I'm unsure about is, if the countGroupedRows really is necessary. Is there a use case where options.group is used and you don't want to count the grouped rows? If not, we can get rid of countGroupedRows and make the wrap query the default behavior.
  
  - Cons:
    - no backward compat
    - confusing since it is required to write `countGroupedRows: true` whenever using group by => then why have countGroupedRows in the first place?

3. REMOVE countGroupedRows field
  - Pros:
    - simpler sequelize API. sequelize does auto select wrapping in the background whenever grouping is enabled
    - user can use raw query if they want performance optimization
  - Cons:
    - users have to use raw query to do performance optimization



author_association: CONTRIBUTOR
user: login: seho0808
created_at: 2025-03-12T12:07:18Z
body: nevermind on the previous comment. I think the original PR is backward compatible. (in that `countGroupedRows: false` works)  I think I fixed the test issues and will add documentation after merging
author_association: CONTRIBUTOR
user: login: seho0808
created_at: 2025-03-13T11:04:36Z
body: I believe everything is settled! please let me know if there are anything more i need to work on this PR
author_association: CONTRIBUTOR
user: login: WikiRik
created_at: 2025-03-13T16:04:46Z
body: > I believe everything is settled! please let me know if there are anything more i need to work on this PR

Great, thanks for the work! I'll try to take a look at this tomorrow
author_association: MEMBER
user: login: seho0808
created_at: 2025-03-14T23:41:52Z
body: > Thanks for working on this! I've made a few suggestions, but I haven't tested them so they might not work directly

I tried to keep everything from the previous PR.
I was also curious of some of the issues you mentioned.
I will try to go re-review all the codes!
Thank you for your review!!
author_association: CONTRIBUTOR
user: login: WikiRik
created_at: 2025-03-16T20:09:37Z
body: Just ping me (@WikiRik) when you're ready for another review or if you have any questions
author_association: MEMBER
user: login: seho0808
created_at: 2025-03-20T16:29:38Z
body: Thank you so much for your help! I wanted to check something on transactions, so give me  one or two days on it to check it out!
author_association: CONTRIBUTOR
user: login: WikiRik
created_at: 2025-03-22T20:34:05Z
body: @seho0808 Thanks for your work on this!
author_association: MEMBER
user: login: seho0808
created_at: 2025-03-23T03:42:29Z
body: @WikiRik Hope I can get a better grasp of the code base on the next PR I will work on. I wish I could be help for the upcoming v7 laucnh. Thank you for your help!
author_association: CONTRIBUTOR
user: login: ericbf
created_at: 2025-03-29T04:21:10Z
body: Hello, what version will this be included in? Thanks!
author_association: NONE
user: login: WikiRik
created_at: 2025-03-29T07:59:34Z
body: This has been released in v7 alpha 46; https://github.com/sequelize/sequelize/releases/tag/v7.0.0-alpha.46
author_association: MEMBER