Skip to main content

Aggregates

pagedAggregate​

Runs an aggregation pipeline and returns a RecordsPage plus the raw aggregation result. Internally wraps the pipeline in a $facet stage with documents (skip + limit) and totalCount facets, plus any additional facets you pass in.

fun <Doc : Any> MongoCollection<Doc>.pagedAggregate(
pipeline: List<Bson>,
page: Page,
facets: List<Facet> = emptyList(),
documentStages: List<Bson> = emptyList()
): PagedAggregateSearchResult<Doc>

class PagedAggregateSearchResult<Doc : Any>(
val recordsPage: RecordsPage<Doc>,
val rawResult: Document
)

facets and documentStages are both lists of stages and they land in different places. A Facet in facets is a sibling of the documents facet and runs over the whole matched population. documentStages run inside the documents facet, after its $skip and $limit, so they see the page's rows only - the place for a per-row $lookup that would otherwise be paid for across the whole population:

val result = collection.pagedAggregate(
pipeline = listOf(Aggregates.match(filter), Aggregates.sort(sort)),
page = Page(0, 20),
documentStages = listOf(
Aggregates.lookup("authors", Book::authorId.name, "_id", "authorLookup"),
Aggregates.addFields(Field("authorName", mongoLookupField("authorLookup", "name"))),
Aggregates.unset("authorLookup")
)
)

Because they run after $skip / $limit, a $match or a $sort in documentStages disagrees with totalCount and with the paging: the match drops rows the count still includes, and the sort only orders the rows of the page it is handed. Narrowing and ordering belong in pipeline.

The rawResult holds the full $facet response, so you can read any extra facets you passed in:

val result = collection.pagedAggregate(
pipeline = listOf(Aggregates.match(filter)),
page = Page(0, 20),
facets = listOf(
Facet("byCategory", Aggregates.group("\$category", Accumulators.sum("count", 1)))
)
)

result.recordsPage.records // List<Doc>
result.recordsPage.totalCount
result.rawResult["byCategory"] // raw aggregation output

Reading a $facet result​

Two readers over the raw $facet response. A $count stage emits no document at all when it matched nothing, so an empty branch - and a branch that is not in the result - read as 0 and as an empty list respectively.

fun Document.countFacet(name: String): Int
inline fun <reified Doc : Any> Document.facetListOf(name: String): List<Doc>
result.rawResult.countFacet("totalCount") // Int
result.rawResult.facetListOf<Product>("bestSellers") // List<Product>

aggregateCount​

Runs a pipeline and returns the number of documents it leaves, appending the $count stage itself so the caller never names the field the count lands under. A pipeline that matches nothing reads as 0.

fun MongoCollection<*>.aggregateCount(pipeline: List<Bson>): Long

countsGroupedBy​

Counts the documents whose field is one of ids, grouped by field, as a single aggregation rather than a count per id. extraFilter narrows what is counted.

fun <Doc : Any> MongoCollection<Doc>.countsGroupedBy(
field: KProperty<*>,
ids: Collection<String>,
extraFilter: Bson? = null
): Map<String, Int>
val counts = orders.countsGroupedBy(
Order::customerId,
customerIds,
Order::status.mongoEq(Order.Status.PLACED)
)
counts[customerId] ?: 0

An id with no documents is absent from the map rather than present with a zero. An empty ids returns an empty map without querying, which is correctness rather than an optimisation: mongoIn rejects an empty collection. The map is typed to the pair this counts: a String group key, so field must hold strings, and an Int count.

Lookup expressions​

mongoLookupField builds the expression that reads a scalar out of the array a $lookup leaves behind, for a lookup whose sub-pipeline resolves to at most one document. The $ifNull wrapper is added only when a default is given; without one the expression resolves to missing on an empty lookup.

fun mongoLookupField(lookupField: String, path: String? = null, default: Any? = null): Document
// { $ifNull: [ { $arrayElemAt: [ "$organisationLookup.name", 0 ] }, "Unknown organisation" ] }
Field("organisationName", mongoLookupField("organisationLookup", "name", "Unknown organisation"))

// { $arrayElemAt: [ "$allocationLookup", 0 ] }
Field("allocation", mongoLookupField("allocationLookup"))

mongoCorrelatedEq builds the { $eq: [ "$field", "$$letVariable" ] } a correlated $lookup sub-pipeline matches on. It is an expression rather than a filter, so a $match wraps it in Filters.expr:

fun mongoCorrelatedEq(field: String, letVariable: String): Document
Aggregates.lookup(
"consent-events",
listOf(Variable("consentUserId", "\$_id")),
listOf(
Aggregates.match(Filters.expr(mongoCorrelatedEq(ConsentEvent::userId.name, "consentUserId"))),
Aggregates.sort(Sorts.descending(ConsentEvent::createdAt.name)),
Aggregates.limit(1)
),
"latestEvent"
)