feat(query-mikro-orm): implement missing QueryService methods#453
feat(query-mikro-orm): implement missing QueryService methods#453jtomaszewski wants to merge 1 commit into
Conversation
📝 WalkthroughWalkthroughThis change introduces Jest configuration for the Mikro-ORM package and adds comprehensive CRUD (create, update, delete) and aggregation operations to the MikroOrmQueryService class, alongside extensive test coverage validating persistence, filtering, error handling, and in-memory aggregations. Changes
Sequence Diagram(s)sequenceDiagram
participant Client
participant MikroOrmQueryService
participant EntityManager
participant Database
rect rgba(100, 150, 200, 0.5)
Note over Client,Database: Create Operation
Client->>MikroOrmQueryService: createOne(record)
MikroOrmQueryService->>EntityManager: create(entityData)
MikroOrmQueryService->>EntityManager: persistAndFlush()
EntityManager->>Database: INSERT
Database-->>EntityManager: persisted entity
MikroOrmQueryService->>MikroOrmQueryService: convert to DTO (assembler)
MikroOrmQueryService-->>Client: DTO
end
sequenceDiagram
participant Client
participant MikroOrmQueryService
participant Repository
participant EntityManager
participant Database
rect rgba(150, 100, 200, 0.5)
Note over Client,Database: Update Operation
Client->>MikroOrmQueryService: updateOne(id, update)
MikroOrmQueryService->>Repository: findOne(id)
Repository->>Database: SELECT
Database-->>Repository: entity
MikroOrmQueryService->>EntityManager: wrap(entity).assign(update)
MikroOrmQueryService->>EntityManager: flush()
EntityManager->>Database: UPDATE
Database-->>EntityManager: updated entity
MikroOrmQueryService->>MikroOrmQueryService: convert to DTO
MikroOrmQueryService-->>Client: DTO
end
sequenceDiagram
participant Client
participant MikroOrmQueryService
participant Repository
participant Database
rect rgba(200, 150, 100, 0.5)
Note over Client,Database: Aggregate Operation
Client->>MikroOrmQueryService: aggregate(filter, aggregateQuery)
MikroOrmQueryService->>MikroOrmQueryService: validate (reject withDeleted)
MikroOrmQueryService->>Repository: findAll(where)
Repository->>Database: SELECT
Database-->>Repository: matching entities
MikroOrmQueryService->>MikroOrmQueryService: computeAggregateInMemory()
MikroOrmQueryService->>MikroOrmQueryService: compute count/sum/avg/max/min/groupBy
MikroOrmQueryService-->>Client: AggregateResponse[]
end
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~28 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
Add implementations for core CRUD and aggregate operations: - createOne/createMany - updateOne/updateMany - deleteOne/deleteMany - aggregate with groupBy support - count method for totalCount in GraphQL connections Cherry-picked from tournee branch, excluding cursor+offset pagination changes. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
9ec64e2 to
f9fbaae
Compare
|
| Command | Status | Duration | Result |
|---|---|---|---|
nx run-many --target=lint --all |
❌ Failed | 8s | View ↗ |
nx run-many --target=test --all |
✅ Succeeded | 2m 36s | View ↗ |
nx run-many --target=e2e --all |
✅ Succeeded | 2m 1s | View ↗ |
nx run-many --target=build --all |
✅ Succeeded | <1s | View ↗ |
nx run workspace:version |
✅ Succeeded | <1s | View ↗ |
☁️ Nx Cloud last updated this comment at 2026-03-21 23:39:39 UTC
@ptc-org/nestjs-query-core
@ptc-org/nestjs-query-graphql
@ptc-org/nestjs-query-mikro-orm
@ptc-org/nestjs-query-mongoose
@ptc-org/nestjs-query-sequelize
@ptc-org/nestjs-query-typegoose
@ptc-org/nestjs-query-typeorm
commit: |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (4)
packages/query-mikro-orm/src/services/mikro-orm-query.service.ts (3)
99-109: Consider using assembler for input conversion.When
DTOandEntitydiffer and an assembler is provided, the inputrecord(typed asDeepPartial<DTO>) is passed directly toem.createwithout conversion to Entity shape. If the DTO/Entity schemas differ significantly, this could cause runtime issues.Proposed fix to convert input
async createOne(record: DeepPartial<DTO>): Promise<DTO> { const em = this.repo.getEntityManager() + const entityData = this.assembler + ? await this.assembler.convertToEntity(record as unknown as DTO) + : record // eslint-disable-next-line `@typescript-eslint/no-explicit-any` - const entity = em.create(this.repo.getEntityName(), record as any) + const entity = em.create(this.repo.getEntityName(), entityData as any) await em.persistAndFlush(entity) if (this.assembler) { return this.assembler.convertToDTO(entity as Entity) } return entity as unknown as DTO }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/query-mikro-orm/src/services/mikro-orm-query.service.ts` around lines 99 - 109, The createOne method currently passes the incoming DeepPartial<DTO> record straight into em.create; when DTO and Entity differ and an assembler exists you should first convert the DTO-shaped input into an Entity-shaped object using the assembler (e.g. call assembler.convertToEntity(record) or similar) and pass that converted entity-data into em.create (then persistAndFlush and continue returning assembler.convertToDTO(entity)); update createOne to branch: if (this.assembler) use the assembler to convert input before em.create and to convert the persisted entity back to DTO afterwards.
267-272: COUNT semantics differ from SQL.The implementation returns
entities.lengthfor all count fields, meaningCOUNT(fieldA)equalsCOUNT(fieldB)regardless of NULL values. In standard SQL,COUNT(field)counts non-NULL values for that specific field.If SQL-compatible behavior is needed:
SQL-compatible COUNT implementation
if (aggregateQuery.count) { response.count = {} for (const { field } of aggregateQuery.count) { - ;(response.count as Record<string, number>)[String(field)] = entities.length + const count = entities.filter((e) => { + const val = (e as Record<string, unknown>)[String(field)] + return val !== undefined && val !== null + }).length + ;(response.count as Record<string, number>)[String(field)] = count } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/query-mikro-orm/src/services/mikro-orm-query.service.ts` around lines 267 - 272, The current COUNT handling in mikro-orm-query.service.ts always sets each COUNT(field) to entities.length; change it to compute SQL-compatible per-field non-NULL counts by iterating entities and incrementing a counter only when the entity's value for that field is not null/undefined. In the block that processes aggregateQuery.count (where response.count is set), replace the constant assignment with per-field counters keyed by String(field) and for each entity check the field value (support nested paths if your codebase uses them) and increment only when value !== null && value !== undefined; finally assign those counters into response.count so COUNT(field) reflects non-NULL occurrences for that specific field.
123-141: Same input conversion concern applies to update methods.Both
updateOneandupdateManypassupdate: DeepPartial<DTO>directly to MikroORM operations without assembler conversion. If using an assembler with differing DTO/Entity shapes, consider converting the update payload as well.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/query-mikro-orm/src/services/mikro-orm-query.service.ts` around lines 123 - 141, updateOne and updateMany currently pass the DTO-shaped update payload straight to MikroORM, which will break if an assembler maps between DTO and Entity shapes; before calling wrap(entity).assign(...) in updateOne and before em.nativeUpdate(...) in updateMany, convert the incoming update payload using the assembler (e.g. call the assembler's convertToEntity/convertToModel method or a mapping helper) to produce an Entity-shaped partial, then pass that converted object to assign/nativeUpdate, keeping existing casts only where necessary; reference updateOne, updateMany, assembler, wrap(...).assign and em.nativeUpdate when making the change.packages/query-mikro-orm/__tests__/services/mikro-orm-query.service.spec.ts (1)
554-557: Consider using strict equality for clearer intent.Using
Boolean()conversion works here but can be fragile. IfboolTypewere0or an empty string, it would incorrectly match the "false" group. Direct comparison is clearer:Suggested improvement
- const trueGroup = result.find((r) => Boolean(r.groupBy?.boolType) === true) - const falseGroup = result.find((r) => Boolean(r.groupBy?.boolType) === false) + const trueGroup = result.find((r) => r.groupBy?.boolType === true) + const falseGroup = result.find((r) => r.groupBy?.boolType === false)🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/query-mikro-orm/__tests__/services/mikro-orm-query.service.spec.ts` around lines 554 - 557, The test is using Boolean(result.groupBy?.boolType) which can misclassify values like 0 or ''—update the two finds to compare the property directly using strict equality against true and false (e.g., use result.find(r => r.groupBy?.boolType === true) and result.find(r => r.groupBy?.boolType === false)) so intent is explicit; adjust references to trueGroup and falseGroup accordingly.
🤖 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/query-mikro-orm/src/services/mikro-orm-query.service.ts`:
- Around line 174-225: The code builds SQL select clauses and groupByFields
(using em.getMetadata(), tableName, selects, groupByFields) but never executes
them — instead it loads all entities with this.repo.findAll() and calls
this.computeAggregateInMemory; either remove that dead build-up or execute a
DB-level aggregate: replace the this.repo.findAll()/computeAggregateInMemory
path by creating a MikroORM query using this.repo.getEntityManager() or
this.repo.createQueryBuilder(), apply the converted where from
convertFilter(filter), add the constructed selects as projections and apply
groupBy for groupByFields, execute the query and return the DB aggregation
results; if you choose to remove the dead code, delete the unused variables and
loops (tableName, selects, groupByFields and all aggregate building) and keep
the in-memory path (this.repo.findAll + computeAggregateInMemory).
---
Nitpick comments:
In `@packages/query-mikro-orm/__tests__/services/mikro-orm-query.service.spec.ts`:
- Around line 554-557: The test is using Boolean(result.groupBy?.boolType) which
can misclassify values like 0 or ''—update the two finds to compare the property
directly using strict equality against true and false (e.g., use result.find(r
=> r.groupBy?.boolType === true) and result.find(r => r.groupBy?.boolType ===
false)) so intent is explicit; adjust references to trueGroup and falseGroup
accordingly.
In `@packages/query-mikro-orm/src/services/mikro-orm-query.service.ts`:
- Around line 99-109: The createOne method currently passes the incoming
DeepPartial<DTO> record straight into em.create; when DTO and Entity differ and
an assembler exists you should first convert the DTO-shaped input into an
Entity-shaped object using the assembler (e.g. call
assembler.convertToEntity(record) or similar) and pass that converted
entity-data into em.create (then persistAndFlush and continue returning
assembler.convertToDTO(entity)); update createOne to branch: if (this.assembler)
use the assembler to convert input before em.create and to convert the persisted
entity back to DTO afterwards.
- Around line 267-272: The current COUNT handling in mikro-orm-query.service.ts
always sets each COUNT(field) to entities.length; change it to compute
SQL-compatible per-field non-NULL counts by iterating entities and incrementing
a counter only when the entity's value for that field is not null/undefined. In
the block that processes aggregateQuery.count (where response.count is set),
replace the constant assignment with per-field counters keyed by String(field)
and for each entity check the field value (support nested paths if your codebase
uses them) and increment only when value !== null && value !== undefined;
finally assign those counters into response.count so COUNT(field) reflects
non-NULL occurrences for that specific field.
- Around line 123-141: updateOne and updateMany currently pass the DTO-shaped
update payload straight to MikroORM, which will break if an assembler maps
between DTO and Entity shapes; before calling wrap(entity).assign(...) in
updateOne and before em.nativeUpdate(...) in updateMany, convert the incoming
update payload using the assembler (e.g. call the assembler's
convertToEntity/convertToModel method or a mapping helper) to produce an
Entity-shaped partial, then pass that converted object to assign/nativeUpdate,
keeping existing casts only where necessary; reference updateOne, updateMany,
assembler, wrap(...).assign and em.nativeUpdate when making the change.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 126cd06e-1a1d-4c68-99c3-e5b46ccd09c5
📒 Files selected for processing (4)
jest.config.tsjest.preset.jspackages/query-mikro-orm/__tests__/services/mikro-orm-query.service.spec.tspackages/query-mikro-orm/src/services/mikro-orm-query.service.ts
| const em = this.repo.getEntityManager() | ||
| const meta = em.getMetadata().get(this.repo.getEntityName()) | ||
| const tableName = meta.tableName | ||
| const where = this.convertFilter(filter) | ||
|
|
||
| const selects: string[] = [] | ||
| const groupByFields: string[] = [] | ||
|
|
||
| if (aggregateQuery.count) { | ||
| for (const { field } of aggregateQuery.count) { | ||
| selects.push(`COUNT(${String(field)}) as count_${String(field)}`) | ||
| } | ||
| } | ||
|
|
||
| if (aggregateQuery.sum) { | ||
| for (const { field } of aggregateQuery.sum) { | ||
| selects.push(`SUM(${String(field)}) as sum_${String(field)}`) | ||
| } | ||
| } | ||
|
|
||
| if (aggregateQuery.avg) { | ||
| for (const { field } of aggregateQuery.avg) { | ||
| selects.push(`AVG(${String(field)}) as avg_${String(field)}`) | ||
| } | ||
| } | ||
|
|
||
| if (aggregateQuery.max) { | ||
| for (const { field } of aggregateQuery.max) { | ||
| selects.push(`MAX(${String(field)}) as max_${String(field)}`) | ||
| } | ||
| } | ||
|
|
||
| if (aggregateQuery.min) { | ||
| for (const { field } of aggregateQuery.min) { | ||
| selects.push(`MIN(${String(field)}) as min_${String(field)}`) | ||
| } | ||
| } | ||
|
|
||
| if (aggregateQuery.groupBy) { | ||
| for (const { field } of aggregateQuery.groupBy) { | ||
| const fieldName = String(field) | ||
| selects.push(`${fieldName} as groupBy_${fieldName}`) | ||
| groupByFields.push(fieldName) | ||
| } | ||
| } | ||
|
|
||
| if (selects.length === 0) { | ||
| return [] | ||
| } | ||
|
|
||
| const entities = await this.repo.findAll({ where }) | ||
| return this.computeAggregateInMemory(entities, aggregateQuery) |
There was a problem hiding this comment.
Dead code: SQL select statements are built but never executed.
Lines 175-218 construct selects, groupByFields, and extract tableName, but these variables are never used. Instead, line 224 fetches all entities and line 225 computes aggregates in-memory.
Either remove the dead code or implement database-level aggregation using the constructed query.
Remove unused variables
async aggregate(
filter: Filter<DTO>,
aggregateQuery: AggregateQuery<DTO>,
opts?: AggregateOptions
): Promise<AggregateResponse<DTO>[]> {
if (opts?.withDeleted) {
throw new Error('MikroOrmQueryService does not support withDeleted on aggregate')
}
- const em = this.repo.getEntityManager()
- const meta = em.getMetadata().get(this.repo.getEntityName())
- const tableName = meta.tableName
const where = this.convertFilter(filter)
-
- const selects: string[] = []
- const groupByFields: string[] = []
-
- if (aggregateQuery.count) {
- for (const { field } of aggregateQuery.count) {
- selects.push(`COUNT(${String(field)}) as count_${String(field)}`)
- }
- }
-
- if (aggregateQuery.sum) {
- for (const { field } of aggregateQuery.sum) {
- selects.push(`SUM(${String(field)}) as sum_${String(field)}`)
- }
- }
-
- if (aggregateQuery.avg) {
- for (const { field } of aggregateQuery.avg) {
- selects.push(`AVG(${String(field)}) as avg_${String(field)}`)
- }
- }
-
- if (aggregateQuery.max) {
- for (const { field } of aggregateQuery.max) {
- selects.push(`MAX(${String(field)}) as max_${String(field)}`)
- }
- }
-
- if (aggregateQuery.min) {
- for (const { field } of aggregateQuery.min) {
- selects.push(`MIN(${String(field)}) as min_${String(field)}`)
- }
- }
-
- if (aggregateQuery.groupBy) {
- for (const { field } of aggregateQuery.groupBy) {
- const fieldName = String(field)
- selects.push(`${fieldName} as groupBy_${fieldName}`)
- groupByFields.push(fieldName)
- }
- }
if (!aggregateQuery.count && !aggregateQuery.sum && !aggregateQuery.avg &&
!aggregateQuery.max && !aggregateQuery.min && !aggregateQuery.groupBy) {
return []
}
const entities = await this.repo.findAll({ where })
return this.computeAggregateInMemory(entities, aggregateQuery)
}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@packages/query-mikro-orm/src/services/mikro-orm-query.service.ts` around
lines 174 - 225, The code builds SQL select clauses and groupByFields (using
em.getMetadata(), tableName, selects, groupByFields) but never executes them —
instead it loads all entities with this.repo.findAll() and calls
this.computeAggregateInMemory; either remove that dead build-up or execute a
DB-level aggregate: replace the this.repo.findAll()/computeAggregateInMemory
path by creating a MikroORM query using this.repo.getEntityManager() or
this.repo.createQueryBuilder(), apply the converted where from
convertFilter(filter), add the constructed selects as projections and apply
groupBy for groupByFields, execute the query and return the DB aggregation
results; if you choose to remove the dead code, delete the unused variables and
loops (tableName, selects, groupByFields and all aggregate building) and keep
the in-memory path (this.repo.findAll + computeAggregateInMemory).
| const entities = await this.repo.findAll({ where }) | ||
| return this.computeAggregateInMemory(entities, aggregateQuery) |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
MikroORM QueryBuilder aggregate functions COUNT SUM AVG
💡 Result:
MikroORM QueryBuilder supports aggregate functions COUNT, SUM, and AVG primarily through raw SQL expressions in the select clause and dedicated methods for COUNT. For COUNT: - Use qb.count to create a count query: const count = await em.createQueryBuilder(User).count.where({ isActive: true }).execute('get'); // returns { count: number } - Or qb.getCount on a cloned QB without limit/offset. - Raw: qb.select([raw('count() as count'), 'status']).groupBy('status') For SUM and AVG (and other aggregates like MIN/MAX): - Use raw SQL expressions with raw or sql... helpers in select: qb.select([raw('sum(t.amount) as total'), 'u.id as id']).leftJoin('u.jncSales', 't') - Or newer sql template: qb.select(['u.id as id', sqlsum(t.amount).as('total')]) - Combine with groupBy('status') and having({ total: { $gt: 10000 } }) for filtering aggregates. - Execute with .execute('get') for single object or 'all' for array. Examples: const total = await em.createQueryBuilder(Order).select('sum(amount) as total').where({ status: 'completed' }).execute('get'); const stats = await em.createQueryBuilder(Order).select(['status', 'count() as count', 'sum(amount) as total', 'avg(amount) as avg']).groupBy('status').execute; No dedicated qb.sum or qb.avg methods exist; use raw SQL for these. Always wrap aggregate functions in raw or sql to avoid quoting issues. Works with joins, where, groupBy, having, orderBy, limit.
Citations:
- 1: https://mikro-orm.io/api/sql/interface/CountQueryBuilder
- 2: https://mikro-orm.io/docs/query-builder
- 3: SUM function in query builder has incorrect syntax. mikro-orm/mikro-orm#5546
- 4: https://app.studyraid.com/en/read/11519/361871/aggregate-functions-usage
- 5: QueryBuilder in 6.2.x mikro-orm/mikro-orm#5490
- 6: https://mikro-orm.io/api/sql/class/QueryBuilder
🏁 Script executed:
cd packages/query-mikro-orm && find . -name "*.ts" | head -20Repository: TriPSs/nestjs-query
Length of output: 575
🏁 Script executed:
cat -n packages/query-mikro-orm/src/services/mikro-orm-query.service.ts | sed -n '200,250p'Repository: TriPSs/nestjs-query
Length of output: 2051
🏁 Script executed:
grep -n "computeAggregateInMemory" packages/query-mikro-orm/src/services/mikro-orm-query.service.ts -A 30 | head -50Repository: TriPSs/nestjs-query
Length of output: 1515
🏁 Script executed:
rg "class MikroOrmQueryService|aggregateQuery" packages/query-mikro-orm/src/services/ -B 2 -A 2Repository: TriPSs/nestjs-query
Length of output: 11076
🏁 Script executed:
cat packages/query-mikro-orm/__tests__/services/mikro-orm-query.service.spec.ts | grep -A 20 "aggregate"Repository: TriPSs/nestjs-query
Length of output: 2808
🏁 Script executed:
grep -n "aggregate" packages/query-mikro-orm/__tests__/services/mikro-orm-query.service.spec.ts | head -30Repository: TriPSs/nestjs-query
Length of output: 1108
Replace in-memory aggregation with MikroORM QueryBuilder for better scalability.
The current implementation fetches all matching entities into memory and computes aggregates in JavaScript. For large result sets, this causes:
- Excessive memory consumption (loading millions of rows)
- Slower performance compared to database-level aggregation
- Risk of OOM errors
Use MikroORM's QueryBuilder with select() for raw aggregate functions (COUNT, SUM, AVG, MAX, MIN), groupBy(), and having() for filtering. Example:
qb.select('status', 'count(*) as count', 'sum(amount) as total')
.groupBy('status')
.execute('all')Note: Lines 191-218 build SQL aggregate selections that are never used; they can be removed after migrating to QueryBuilder.

Summary
createOne,createMany,updateOne,updateMany,deleteOne,deleteManyaggregate()method with groupBy supportcount()method for totalCount in GraphQL connectionsTest plan
🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Tests