diff --git a/packages/runtime/src/client/crud/dialects/postgresql.ts b/packages/runtime/src/client/crud/dialects/postgresql.ts index d3e6d48f..2c8af180 100644 --- a/packages/runtime/src/client/crud/dialects/postgresql.ts +++ b/packages/runtime/src/client/crud/dialects/postgresql.ts @@ -60,7 +60,7 @@ export class PostgresCrudDialect extends BaseCrudDiale ): SelectQueryBuilder { const joinedQuery = this.buildRelationJSON(model, query, relationField, parentAlias, payload); - return joinedQuery.select(`${parentAlias}$${relationField}.$j as ${relationField}`); + return joinedQuery.select(`${parentAlias}$${relationField}.$t as ${relationField}`); } private buildRelationJSON( @@ -176,9 +176,9 @@ export class PostgresCrudDialect extends BaseCrudDiale if (relationFieldDef.array) { return eb.fn .coalesce(sql`jsonb_agg(jsonb_build_object(${sql.join(objArgs)}))`, sql`'[]'::jsonb`) - .as('$j'); + .as('$t'); } else { - return sql`jsonb_build_object(${sql.join(objArgs)})`.as('$j'); + return sql`jsonb_build_object(${sql.join(objArgs)})`.as('$t'); } }); @@ -242,7 +242,7 @@ export class PostgresCrudDialect extends BaseCrudDiale const fieldDef = requireField(this.schema, relationModel, field); const fieldValue = fieldDef.relation ? // reference the synthesized JSON field - eb.ref(`${parentAlias}$${relationField}$${field}.$j`) + eb.ref(`${parentAlias}$${relationField}$${field}.$t`) : // reference a plain field this.fieldRef(relationModel, field, eb, undefined, false); return [sql.lit(field), fieldValue]; @@ -260,7 +260,7 @@ export class PostgresCrudDialect extends BaseCrudDiale .map(([field]) => [ sql.lit(field), // reference the synthesized JSON field - eb.ref(`${parentAlias}$${relationField}$${field}.$j`), + eb.ref(`${parentAlias}$${relationField}$${field}.$t`), ]) .flatMap((v) => v), ); diff --git a/packages/runtime/src/client/crud/dialects/sqlite.ts b/packages/runtime/src/client/crud/dialects/sqlite.ts index c119c883..dd677361 100644 --- a/packages/runtime/src/client/crud/dialects/sqlite.ts +++ b/packages/runtime/src/client/crud/dialects/sqlite.ts @@ -213,9 +213,9 @@ export class SqliteCrudDialect extends BaseCrudDialect if (relationFieldDef.array) { return eb.fn .coalesce(sql`json_group_array(json_object(${sql.join(objArgs)}))`, sql`json_array()`) - .as('$j'); + .as('$t'); } else { - return sql`json_object(${sql.join(objArgs)})`.as('data'); + return sql`json_object(${sql.join(objArgs)})`.as('$t'); } }); diff --git a/packages/runtime/src/client/executor/name-mapper.ts b/packages/runtime/src/client/executor/name-mapper.ts index b2b96fda..bb057e40 100644 --- a/packages/runtime/src/client/executor/name-mapper.ts +++ b/packages/runtime/src/client/executor/name-mapper.ts @@ -6,7 +6,6 @@ import { FromNode, IdentifierNode, InsertQueryNode, - JoinNode, OperationNodeTransformer, ReferenceNode, ReturningNode, @@ -22,15 +21,15 @@ import { getModel, requireModel } from '../query-utils'; import { stripAlias } from './kysely-utils'; type Scope = { - model: string; + model?: string; alias?: string; - namesMapped?: boolean; + namesMapped?: boolean; // true means fields referring to this scope have their names already mapped }; export class QueryNameMapper extends OperationNodeTransformer { private readonly modelToTableMap = new Map(); private readonly fieldToColumnMap = new Map(); - private readonly modelScopes: Scope[] = []; + private readonly scopes: Scope[] = []; constructor(private readonly schema: SchemaDef) { super(); @@ -56,16 +55,29 @@ export class QueryNameMapper extends OperationNodeTransformer { return super.transformSelectQuery(node); } - // all table names in "from" are pushed as scopes, each "from" is expanded - // as nested query to apply column name mapping, so the scopes are marked - // "namesMapped" so no additional name mapping is applied when resolving - // columns - const scopes = this.createScopesFromFroms(node.from, true); + // process "from" clauses + const processedFroms = node.from.froms.map((from) => this.processSelectTable(from)); + + // process "join" clauses + const processedJoins = (node.joins ?? []).map((join) => this.processSelectTable(join.table)); + + // merge the scopes of froms and joins since they're all visible in the query body + const scopes = [...processedFroms.map(({ scope }) => scope), ...processedJoins.map(({ scope }) => scope)]; + return this.withScopes(scopes, () => { + // transform join clauses, "on" is transformed within the scopes + const joins = node.joins + ? node.joins.map((join, i) => ({ + ...join, + table: processedJoins[i]!.node, + on: this.transformNode(join.on), + })) + : undefined; return { ...super.transformSelectQuery(node), - // convert "from" to nested query as needed - from: this.processFrom(node.from!), + from: FromNode.create(processedFroms.map((f) => f.node)), + joins, + selections: this.processSelectQuerySelections(node), }; }); } @@ -94,32 +106,16 @@ export class QueryNameMapper extends OperationNodeTransformer { }; } - protected override transformJoin(node: JoinNode) { - const { alias, node: innerNode } = stripAlias(node.table); - if (TableNode.is(innerNode!)) { - const modelName = innerNode.table.identifier.name; - if (this.hasMappedColumns(modelName)) { - // create a nested query with all fields selected and names mapped - const select = this.createSelectAll(modelName); - return { ...super.transformJoin(node), table: this.wrapAlias(select, alias ?? modelName) }; - } - } - return super.transformJoin(node); - } - protected override transformReference(node: ReferenceNode) { if (!ColumnNode.is(node.column)) { return super.transformReference(node); } // resolve the reference to a field from outer scopes - const { fieldDef, modelDef, scope } = this.resolveFieldFromScopes( - node.column.column.name, - node.table?.table.identifier.name, - ); - if (fieldDef && !scope.namesMapped) { + const scope = this.resolveFieldFromScopes(node.column.column.name, node.table?.table.identifier.name); + if (scope && !scope.namesMapped && scope.model) { // map column name and table name as needed - const mappedFieldName = this.mapFieldName(modelDef.name, fieldDef.name); + const mappedFieldName = this.mapFieldName(scope.model, node.column.column.name); // map table name depending on how it is resolved let mappedTableName = node.table?.table.identifier.name; @@ -142,11 +138,11 @@ export class QueryNameMapper extends OperationNodeTransformer { } protected override transformColumn(node: ColumnNode) { - const { modelDef, fieldDef, scope } = this.resolveFieldFromScopes(node.column.name); - if (!fieldDef || scope.namesMapped) { + const scope = this.resolveFieldFromScopes(node.column.name); + if (!scope || scope.namesMapped || !scope.model) { return super.transformColumn(node); } - const mappedName = this.mapFieldName(modelDef.name, fieldDef.name); + const mappedName = this.mapFieldName(scope.model, node.column.name); return ColumnNode.create(mappedName); } @@ -171,7 +167,14 @@ export class QueryNameMapper extends OperationNodeTransformer { protected override transformDeleteQuery(node: DeleteQueryNode) { // all "from" nodes are pushed as scopes - const scopes = this.createScopesFromFroms(node.from, false); + const scopes: Scope[] = node.from.froms.map((node) => { + const { alias, node: innerNode } = stripAlias(node); + return { + model: this.extractModelName(innerNode), + alias, + namesMapped: false, + }; + }); // process name mapping in each "from" const froms = node.from.froms.map((from) => { @@ -196,32 +199,82 @@ export class QueryNameMapper extends OperationNodeTransformer { // #region utils + private processSelectQuerySelections(node: SelectQueryNode) { + const selections: SelectionNode[] = []; + for (const selection of node.selections ?? []) { + if (SelectAllNode.is(selection.selection)) { + // expand `selectAll` to all fields with name mapping if the + // inner-most scope is not already mapped + const scope = this.scopes[this.scopes.length - 1]; + if (scope?.model && !scope.namesMapped) { + selections.push(...this.createSelectAllFields(scope.model, scope.alias)); + } else { + selections.push(super.transformSelection(selection)); + } + } else if (ReferenceNode.is(selection.selection) || ColumnNode.is(selection.selection)) { + // map column name and add/preserve alias + const transformed = this.transformNode(selection.selection); + if (AliasNode.is(transformed)) { + // keep the alias if there's one + selections.push(SelectionNode.create(transformed)); + } else { + // otherwise use an alias to preserve the original field name + const origFieldName = this.extractFieldName(selection.selection); + const fieldName = this.extractFieldName(transformed); + if (fieldName !== origFieldName) { + selections.push(SelectionNode.create(this.wrapAlias(transformed, origFieldName))); + } else { + selections.push(SelectionNode.create(transformed)); + } + } + } else { + selections.push(super.transformSelection(selection)); + } + } + return selections; + } + private resolveFieldFromScopes(name: string, qualifier?: string) { - for (const scope of this.modelScopes.toReversed()) { + for (let i = this.scopes.length - 1; i >= 0; i--) { + const scope = this.scopes[i]!; if (qualifier) { + // if the field as a qualifier, the qualifier must match the scope's + // alias if any, or model if no alias if (scope.alias) { - if (qualifier !== scope.alias) { + if (scope.alias === qualifier) { + // scope has an alias that matches the qualifier + return scope; + } else { + // scope has an alias but it doesn't match the qualifier continue; } - } else { - if (qualifier !== scope.model) { + } else if (scope.model) { + if (scope.model === qualifier) { + // scope has a model that matches the qualifier + return scope; + } else { + // scope has a model but it doesn't match the qualifier continue; } } - } - const modelDef = getModel(this.schema, scope.model); - if (!modelDef) { - continue; - } - if (modelDef.fields[name]) { - return { modelDef, fieldDef: modelDef.fields[name], scope }; + } else { + // if the field has no qualifier, match with model name + if (scope.model) { + const modelDef = getModel(this.schema, scope.model); + if (!modelDef) { + continue; + } + if (modelDef.fields[name]) { + return scope; + } + } } } - return { modelDef: undefined, fieldDef: undefined, scope: undefined }; + return undefined; } private pushScope(scope: Scope) { - this.modelScopes.push(scope); + this.scopes.push(scope); } private withScope(scope: Scope, fn: (...args: unknown[]) => T): T { @@ -229,7 +282,7 @@ export class QueryNameMapper extends OperationNodeTransformer { try { return fn(); } finally { - this.modelScopes.pop(); + this.scopes.pop(); } } @@ -238,7 +291,7 @@ export class QueryNameMapper extends OperationNodeTransformer { try { return fn(); } finally { - scopes.forEach(() => this.modelScopes.pop()); + scopes.forEach(() => this.scopes.pop()); } } @@ -246,15 +299,6 @@ export class QueryNameMapper extends OperationNodeTransformer { return alias ? AliasNode.create(node, IdentifierNode.create(alias)) : node; } - private ensureAlias(node: OperationNode, alias: string | undefined, fallbackName: string) { - if (!node) { - return node; - } - return alias - ? AliasNode.create(node, IdentifierNode.create(alias)) - : AliasNode.create(node, IdentifierNode.create(fallbackName)); - } - private processTableRef(node: TableNode) { if (!node) { return node; @@ -298,64 +342,52 @@ export class QueryNameMapper extends OperationNodeTransformer { return [...this.fieldToColumnMap.keys()].some((key) => key.startsWith(modelName + '.')); } - private createScopesFromFroms(node: FromNode | undefined, namesMapped: boolean) { - if (!node) { - return []; - } - return node.froms - .map((from) => { - const { alias, node: innerNode } = stripAlias(from); - if (innerNode && TableNode.is(innerNode)) { - return { model: innerNode.table.identifier.name, alias, namesMapped }; - } else { - return undefined; - } - }) - .filter((s) => !!s); - } - // convert a "from" node to a nested query if there are columns with name mapping - private processFrom(node: FromNode): FromNode { - return { - ...super.transformFrom(node), - froms: node.froms.map((from) => { - const { alias, node: innerNode } = stripAlias(from); - if (!innerNode) { - return super.transformNode(from); - } - if (TableNode.is(innerNode)) { - if (this.hasMappedColumns(innerNode.table.identifier.name)) { - // create a nested query with all fields selected and names mapped - const selectAll = this.createSelectAll(innerNode.table.identifier.name); - - // use the original alias or table name as the alias for the nested query - // so its transparent to the outer scope - return this.ensureAlias(selectAll, alias, innerNode.table.identifier.name); - } - } - return this.transformNode(from); - }), - }; + private processSelectTable(node: OperationNode): { node: OperationNode; scope: Scope } { + const { alias, node: innerNode } = stripAlias(node); + if (innerNode && TableNode.is(innerNode)) { + // if the selection is a table, map its name and create alias to preserve model name, + // mark the scope as names NOT mapped if the model has field name mappings, so that + // inner transformations will map column names + const modelName = innerNode.table.identifier.name; + const mappedName = this.mapTableName(modelName); + return { + node: this.wrapAlias(TableNode.create(mappedName), alias ?? modelName), + scope: { + alias: alias ?? modelName, + model: modelName, + namesMapped: !this.hasMappedColumns(modelName), + }, + }; + } else { + // otherwise, it's an alias or a sub-query, in which case the inner field names are + // already mapped, so we just create a scope with the alias and mark names mapped + return { + node: super.transformNode(node), + scope: { + alias, + model: undefined, + namesMapped: true, + }, + }; + } } - // create a `SelectQueryNode` for the given model with all columns mapped - private createSelectAll(model: string): SelectQueryNode { + private createSelectAllFields(model: string, alias: string | undefined) { const modelDef = requireModel(this.schema, model); - const tableName = this.mapTableName(model); - return { - kind: 'SelectQueryNode', - from: FromNode.create([TableNode.create(tableName)]), - selections: this.getModelFields(modelDef).map((fieldDef) => { - const columnName = this.mapFieldName(model, fieldDef.name); - const columnRef = ReferenceNode.create(ColumnNode.create(columnName), TableNode.create(tableName)); - if (columnName !== fieldDef.name) { - const aliased = AliasNode.create(columnRef, IdentifierNode.create(fieldDef.name)); - return SelectionNode.create(aliased); - } else { - return SelectionNode.create(columnRef); - } - }), - }; + return this.getModelFields(modelDef).map((fieldDef) => { + const columnName = this.mapFieldName(model, fieldDef.name); + const columnRef = ReferenceNode.create( + ColumnNode.create(columnName), + alias ? TableNode.create(alias) : undefined, + ); + if (columnName !== fieldDef.name) { + const aliased = AliasNode.create(columnRef, IdentifierNode.create(fieldDef.name)); + return SelectionNode.create(aliased); + } else { + return SelectionNode.create(columnRef); + } + }); } private getModelFields(modelDef: ModelDef) { @@ -392,10 +424,10 @@ export class QueryNameMapper extends OperationNodeTransformer { } private processSelectAll(node: SelectAllNode) { - const scope = this.modelScopes[this.modelScopes.length - 1]; + const scope = this.scopes[this.scopes.length - 1]; invariant(scope); - if (!this.hasMappedColumns(scope.model)) { + if (!scope.model || !this.hasMappedColumns(scope.model)) { // no name mapping needed, preserve the select all return super.transformSelectAll(node); } @@ -403,12 +435,17 @@ export class QueryNameMapper extends OperationNodeTransformer { // expand select all to a list of selections with name mapping const modelDef = requireModel(this.schema, scope.model); return this.getModelFields(modelDef).map((fieldDef) => { - const columnName = this.mapFieldName(scope.model, fieldDef.name); + const columnName = this.mapFieldName(modelDef.name, fieldDef.name); const columnRef = ReferenceNode.create(ColumnNode.create(columnName)); return columnName !== fieldDef.name ? this.wrapAlias(columnRef, fieldDef.name) : columnRef; }); } + private extractModelName(node: OperationNode): string | undefined { + const { node: innerNode } = stripAlias(node); + return TableNode.is(innerNode!) ? innerNode!.table.identifier.name : undefined; + } + private extractFieldName(node: ReferenceNode | ColumnNode) { if (ReferenceNode.is(node) && ColumnNode.is(node.column)) { return node.column.column.name; diff --git a/packages/runtime/src/client/helpers/schema-db-pusher.ts b/packages/runtime/src/client/helpers/schema-db-pusher.ts index bbfa2a3e..781c131d 100644 --- a/packages/runtime/src/client/helpers/schema-db-pusher.ts +++ b/packages/runtime/src/client/helpers/schema-db-pusher.ts @@ -73,7 +73,9 @@ export class SchemaDbPusher { } private createModelTable(kysely: ToKysely, modelDef: ModelDef) { - let table: CreateTableBuilder = kysely.schema.createTable(modelDef.name).ifNotExists(); + let table: CreateTableBuilder = kysely.schema + .createTable(this.getTableName(modelDef)) + .ifNotExists(); for (const [fieldName, fieldDef] of Object.entries(modelDef.fields)) { if (fieldDef.originModel && !fieldDef.id) { @@ -106,6 +108,28 @@ export class SchemaDbPusher { return table; } + private getTableName(modelDef: ModelDef) { + const mapAttr = modelDef.attributes?.find((a) => a.name === '@@map'); + if (mapAttr && mapAttr.args?.[0]) { + const mappedName = ExpressionUtils.getLiteralValue(mapAttr.args[0].value); + if (mappedName) { + return mappedName as string; + } + } + return modelDef.name; + } + + private getColumnName(fieldDef: FieldDef) { + const mapAttr = fieldDef.attributes?.find((a) => a.name === '@map'); + if (mapAttr && mapAttr.args?.[0]) { + const mappedName = ExpressionUtils.getLiteralValue(mapAttr.args[0].value); + if (mappedName) { + return mappedName as string; + } + } + return fieldDef.name; + } + private isComputedField(fieldDef: FieldDef) { return fieldDef.attributes?.some((a) => a.name === '@computed'); } @@ -119,7 +143,10 @@ export class SchemaDbPusher { } if (modelDef.idFields.length > 0) { - table = table.addPrimaryKeyConstraint(`pk_${modelDef.name}`, modelDef.idFields); + table = table.addPrimaryKeyConstraint( + `pk_${modelDef.name}`, + modelDef.idFields.map((f) => this.getColumnName(modelDef.fields[f]!)), + ); } return table; @@ -134,17 +161,20 @@ export class SchemaDbPusher { if (fieldDef.unique) { continue; } - table = table.addUniqueConstraint(`unique_${modelDef.name}_${key}`, [key]); + table = table.addUniqueConstraint(`unique_${modelDef.name}_${key}`, [this.getColumnName(fieldDef)]); } else { // multi-field constraint - table = table.addUniqueConstraint(`unique_${modelDef.name}_${key}`, Object.keys(value)); + table = table.addUniqueConstraint( + `unique_${modelDef.name}_${key}`, + Object.keys(value).map((f) => this.getColumnName(modelDef.fields[f]!)), + ); } } return table; } private createModelField(table: CreateTableBuilder, fieldDef: FieldDef, modelDef: ModelDef) { - return table.addColumn(fieldDef.name, this.mapFieldType(fieldDef), (col) => { + return table.addColumn(this.getColumnName(fieldDef), this.mapFieldType(fieldDef), (col) => { // @id if (fieldDef.id && modelDef.idFields.length === 1) { col = col.primaryKey(); @@ -240,11 +270,14 @@ export class SchemaDbPusher { return table; } + const modelDef = requireModel(this.schema, model); + const relationModelDef = requireModel(this.schema, fieldDef.type); + table = table.addForeignKeyConstraint( `fk_${model}_${fieldName}`, - fieldDef.relation.fields, - fieldDef.type, - fieldDef.relation.references, + fieldDef.relation.fields.map((f) => this.getColumnName(modelDef.fields[f]!)), + this.getTableName(relationModelDef), + fieldDef.relation.references.map((f) => this.getColumnName(relationModelDef.fields[f]!)), (cb) => { if (fieldDef.relation?.onDelete) { cb = cb.onDelete(this.mapCascadeAction(fieldDef.relation.onDelete)); diff --git a/packages/runtime/src/schema/expression.ts b/packages/runtime/src/schema/expression.ts index 6ae1c158..a650391a 100644 --- a/packages/runtime/src/schema/expression.ts +++ b/packages/runtime/src/schema/expression.ts @@ -109,4 +109,8 @@ export const ExpressionUtils = { isField: (value: unknown): value is FieldExpression => ExpressionUtils.is(value, 'field'), isMember: (value: unknown): value is MemberExpression => ExpressionUtils.is(value, 'member'), + + getLiteralValue: (expr: Expression): string | number | boolean | undefined => { + return ExpressionUtils.isLiteral(expr) ? expr.value : undefined; + }, }; diff --git a/packages/runtime/test/client-api/name-mapping.test.ts b/packages/runtime/test/client-api/name-mapping.test.ts index 7904ee8f..41341f7c 100644 --- a/packages/runtime/test/client-api/name-mapping.test.ts +++ b/packages/runtime/test/client-api/name-mapping.test.ts @@ -235,5 +235,201 @@ describe.each([{ provider: 'sqlite' as const }, { provider: 'postgresql' as cons posts: [], }); }); + + it('works with count', async () => { + await db.user.create({ + data: { + email: 'u1@test.com', + posts: { + create: [{ title: 'Post1' }, { title: 'Post2' }], + }, + }, + }); + + await db.user.create({ + data: { + email: 'u2@test.com', + posts: { + create: [{ title: 'Post3' }], + }, + }, + }); + + // Test ORM count operations + await expect(db.user.count()).resolves.toBe(2); + await expect(db.post.count()).resolves.toBe(3); + await expect(db.user.count({ select: { email: true } })).resolves.toMatchObject({ + email: 2, + }); + + await expect(db.user.count({ where: { email: 'u1@test.com' } })).resolves.toBe(1); + await expect(db.post.count({ where: { title: { contains: 'Post1' } } })).resolves.toBe(1); + + await expect(db.post.count({ where: { author: { email: 'u1@test.com' } } })).resolves.toBe(2); + + // Test Kysely count operations + const r = await db.$qb + .selectFrom('User') + .select((eb) => eb.fn.count('email').as('count')) + .executeTakeFirst(); + await expect(Number(r?.count)).toBe(2); + }); + + it('works with aggregate', async () => { + await db.user.create({ + data: { + id: 1, + email: 'u1@test.com', + posts: { + create: [ + { id: 1, title: 'Post1' }, + { id: 2, title: 'Post2' }, + ], + }, + }, + }); + + await db.user.create({ + data: { + id: 2, + email: 'u2@test.com', + posts: { + create: [{ id: 3, title: 'Post3' }], + }, + }, + }); + + // Test ORM aggregate operations + await expect(db.user.aggregate({ _count: { id: true, email: true } })).resolves.toMatchObject({ + _count: { id: 2, email: 2 }, + }); + + await expect( + db.post.aggregate({ _count: { authorId: true }, _min: { authorId: true }, _max: { authorId: true } }), + ).resolves.toMatchObject({ + _count: { authorId: 3 }, + _min: { authorId: 1 }, + _max: { authorId: 2 }, + }); + + await expect( + db.post.aggregate({ + where: { author: { email: 'u1@test.com' } }, + _count: { authorId: true }, + _min: { authorId: true }, + _max: { authorId: true }, + }), + ).resolves.toMatchObject({ + _count: { authorId: 2 }, + _min: { authorId: 1 }, + _max: { authorId: 1 }, + }); + + // Test Kysely aggregate operations + const countResult = await db.$qb + .selectFrom('User') + .select((eb) => eb.fn.count('email').as('emailCount')) + .executeTakeFirst(); + expect(Number(countResult?.emailCount)).toBe(2); + + const postAggResult = await db.$qb + .selectFrom('Post') + .select((eb) => [eb.fn.min('authorId').as('minAuthorId'), eb.fn.max('authorId').as('maxAuthorId')]) + .executeTakeFirst(); + expect(Number(postAggResult?.minAuthorId)).toBe(1); + expect(Number(postAggResult?.maxAuthorId)).toBe(2); + }); + + it('works with groupBy', async () => { + // Create test data with multiple posts per user + await db.user.create({ + data: { + id: 1, + email: 'u1@test.com', + posts: { + create: [ + { id: 1, title: 'Post1' }, + { id: 2, title: 'Post2' }, + { id: 3, title: 'Post3' }, + ], + }, + }, + }); + + await db.user.create({ + data: { + id: 2, + email: 'u2@test.com', + posts: { + create: [ + { id: 4, title: 'Post4' }, + { id: 5, title: 'Post5' }, + ], + }, + }, + }); + + await db.user.create({ + data: { + id: 3, + email: 'u3@test.com', + posts: { + create: [{ id: 6, title: 'Post6' }], + }, + }, + }); + + // Test ORM groupBy operations + const userGroupBy = await db.user.groupBy({ + by: ['email'], + _count: { id: true }, + }); + expect(userGroupBy).toHaveLength(3); + expect(userGroupBy).toEqual( + expect.arrayContaining([ + { email: 'u1@test.com', _count: { id: 1 } }, + { email: 'u2@test.com', _count: { id: 1 } }, + { email: 'u3@test.com', _count: { id: 1 } }, + ]), + ); + + const postGroupBy = await db.post.groupBy({ + by: ['authorId'], + _count: { id: true }, + _min: { id: true }, + _max: { id: true }, + }); + expect(postGroupBy).toHaveLength(3); + expect(postGroupBy).toEqual( + expect.arrayContaining([ + { authorId: 1, _count: { id: 3 }, _min: { id: 1 }, _max: { id: 3 } }, + { authorId: 2, _count: { id: 2 }, _min: { id: 4 }, _max: { id: 5 } }, + { authorId: 3, _count: { id: 1 }, _min: { id: 6 }, _max: { id: 6 } }, + ]), + ); + + const filteredGroupBy = await db.post.groupBy({ + by: ['authorId'], + where: { title: { contains: 'Post' } }, + _count: { title: true }, + having: { title: { _count: { gte: 2 } } }, + }); + expect(filteredGroupBy).toHaveLength(2); + expect(filteredGroupBy).toEqual( + expect.arrayContaining([ + { authorId: 1, _count: { title: 3 } }, + { authorId: 2, _count: { title: 2 } }, + ]), + ); + + // Test Kysely groupBy operations + const kyselyUserGroupBy = await db.$qb + .selectFrom('User') + .select(['email', (eb) => eb.fn.count('email').as('count')]) + .groupBy('email') + .having((eb) => eb.fn.count('email'), '>=', 1) + .execute(); + expect(kyselyUserGroupBy).toHaveLength(3); + }); }, );