Table Order Aggregation Documentation
Overview
This document explains the MongoDB aggregation pipeline used in BaseController.aggregateDefault() to retrieve and sort items according to their position in a tableOrder document.
Problem Statement
When displaying items from a collection, we need to:
- Sort items by their position in a custom order array stored in a separate
tableorderscollection - Support search/filtering while maintaining the custom order
- Handle pagination correctly
- Extract metadata (like
pinnedstatus) from the order array
Solution Architecture
Data Structure
Items Collection (e.g., products, articles, etc.)
{
_id: ObjectId("..."),
module_id: ObjectId("..."),
title: "Item Title",
// ... other fields
}
TableOrders Collection
{
_id: ObjectId("..."),
ref: ObjectId("..."), // References module_id
items: [
{
item_id: ObjectId("..."), // References item._id
pinned: Boolean
},
// ... more items in desired order
]
}
Aggregation Pipeline Breakdown
Stage 1: Match Items by Module
{
$match: { module_id: ObjectId("...") }
}
Filters items to only those belonging to the specified module.
Stage 2: Filter by Translation Reference (Optional)
{
$match: {
"translation.ref_item": ObjectId("...")
}
}
If a translation reference is provided, further filter items to a specific translation group.
Stage 3: Lookup Table Order
{
$lookup: {
from: "tableorders",
localField: "module_id",
foreignField: "ref",
as: "tableOrder"
}
}
Joins the tableorders collection to get the custom ordering for this module.
Stage 4: Unwind Table Order
{
$unwind: {
path: "$tableOrder",
preserveNullAndEmptyArrays: true
}
}
Converts the tableOrder array (containing one document) into a single object. Uses preserveNullAndEmptyArrays to handle cases where no table order exists.
Stage 5: Apply Search Filters (Optional)
{
$match: { $or: [/* search conditions */] }
}
If search criteria are provided, filter items based on regex matches or exact values.
Stage 6: Calculate Order Index and Pinned Status
{
$addFields: {
orderInfo: {
$arrayElemAt: [
{
$map: {
input: {
$filter: {
input: { $range: [0, { $size: "$tableOrder.items" }] },
as: "idx",
cond: {
$eq: [
{ $arrayElemAt: ["$tableOrder.items.item_id", "$$idx"] },
"$_id"
]
}
}
},
as: "matchedIdx",
in: {
index: "$$matchedIdx",
pinned: { $arrayElemAt: ["$tableOrder.items.pinned", "$$matchedIdx"] }
}
}
},
0
]
}
}
}
This is the critical stage. Here's how it works:
$range: [0, { $size: "$tableOrder.items" }]- Creates an array of indices:[0, 1, 2, ..., n-1]$filter- Filters this array to find which index has anitem_idmatching the current document's_id$map- For the matched index, creates an object containing:index: The position in the arraypinned: The pinned status at that position
$arrayElemAt: [..., 0]- Extracts the first (and only) match
Why this approach?
- Avoids double
$unwindwhich loses array position information - Directly calculates the index position without unwinding the items array
- Preserves the original order defined in
tableOrder.items
Stage 7: Extract Order Fields
{
$addFields: {
orderIndex: { $ifNull: ["$orderInfo.index", 999999] },
pinned: { $ifNull: ["$orderInfo.pinned", false] }
}
}
Extracts the index and pinned values from orderInfo. Uses $ifNull to handle items not found in the table order (assigns them a high index so they appear last).
Stage 8: Sort by Order Index
{
$sort: {
// Optional: sort by pinned first if requested
pinned: -1, // (if "pinned:desc" in sort params)
orderIndex: 1 // Always sort by index ascending
}
}
Sorts items by:
pinnedstatus (if requested) - pinned items firstorderIndex- maintains the custom order fromtableOrder.items
Stage 9: Pagination
{ $skip: (page - 1) * limit }
{ $limit: limit }
Applies pagination after sorting to get the correct page of results.
Stage 10: Project Output Fields
{
$project: {
// Include requested output fields
title: 1,
description: 1,
// ...
pinned: 1
}
}
Selects only the fields requested by the client, plus the pinned field.
Usage Example
const aggregation = await this.aggregateDefault(
moduleId, // "507f1f77bcf86cd799439011"
page, // 1
limit, // 20
sort, // ["pinned:desc"]
outputFields, // ["title", "description", "createdAt"]
search, // [{ title: "search term" }]
translationRef // Optional: "507f1f77bcf86cd799439012"
);
Performance Considerations
-
Index Requirements
module_idshould be indexed on the items collectionrefshould be indexed on the tableorders collection- Consider compound index on
(module_id, translation.ref_item)if using translations
-
Array Size
- The
$rangeoperation scales with the size oftableOrder.items - For very large arrays (>10,000 items), consider alternative approaches
- The
-
Search Performance
- Search filters are applied before order calculation
- Regex searches can be slow without proper indexes
Edge Cases Handled
- Item not in table order - Assigned index 999999, appears last
- No table order exists - Returns empty array
- Missing pinned field - Defaults to
false - Empty search array - Skips search filtering
- No translation reference - Includes all items for the module
Alternative Approaches Considered
Double $unwind (Original Approach)
{ $unwind: "$tableOrder" }
{ $unwind: "$tableOrder.items" }
Problem: Loses array index information, making it impossible to sort by original position.
$indexOfArray with $literal
{
$addFields: {
orderIndex: {
$indexOfArray: [{ $literal: orderItemIds }, "$_id"]
}
}
}
Problem: Cannot pass JavaScript arrays into aggregation pipeline; requires pre-fetching data.
Current Approach: $range + $filter
Advantages:
- Preserves array index
- Pure aggregation pipeline (no pre-fetching)
- Handles all edge cases
- Efficient for moderate array sizes
Troubleshooting
Items appear in wrong order
- Verify
tableOrder.itemsarray has correct order - Check that
item_idvalues match document_idvalues - Ensure
orderIndexis being calculated (not all 999999)
Missing items in results
- Check if items exist in
tableOrder.itemsarray - Verify
module_idmatches between collections - Check search filters aren't too restrictive
Performance issues
- Add indexes on
module_idandreffields - Consider caching table order data
- Reduce
tableOrder.itemsarray size if possible
Related Files
/src/controllers/base.controller.ts- Contains the aggregation implementation/src/controllers/tableOrder.controller.ts- Manages table order updates/src/models/tableOrder.model.ts- TableOrder schema definition