A composable query-building library for the official MongoDB Go driver.
Hand-written MongoDB queries with the Go driver mean nesting bson.D, bson.A, and bson.E values, and remembering operator strings like $eq, $gte,
or $elemMatch, usually with the MongoDB docs open in another tab. monq replaces that with plain functions named after the operators they build, so the
function signature tells you what it does and your editor's autocomplete surfaces the operators you have available.
monq is not an ODM. There are no models, no sessions, no query execution, it only builds bson.D values and hands them back. Every function's output
plugs directly into Find, Aggregate, UpdateOne, and the rest of the driver's API with no adapter layer in between.
Each function takes a field path and a value (or, for logical operators, other monq expressions) and returns a bson.D:
monq.Eq("status", "active")
// bson.D{{Key: "status", Value: bson.D{{Key: "$eq", Value: "active"}}}}Filters compose by nesting function calls, no builder object, no method chaining:
filter := monq.And(
monq.Eq("status", "active"),
monq.Or(
monq.Gte("stats.followers", 10000),
monq.Exists("verified_at", true),
),
)
cursor, err := collection.Find(ctx, filter)For anything monq doesn't have a function for yet, Raw accepts a hand-written bson.D anywhere a monq expression is expected, so it composes
with the rest of a query instead of forcing an all-or-nothing rewrite:
monq.And(
monq.Eq("status", "active"),
monq.Raw(bson.D{{Key: "$where", Value: "this.credits == this.debits"}}),
)Array conditions read the same way. ElemMatch takes the criteria a single array element has to satisfy:
filter := monq.ElemMatch("items",
monq.Eq("sku", "abc"),
monq.Gte("qty", 2),
)
// {"items": {"$elemMatch": {"sku": {"$eq": "abc"}, "qty": {"$gte": 2}}}}Query operators available today:
| Category | Functions |
|---|---|
| Comparison | Eq Ne Gt Gte Lt Lte In Nin |
| Logical | And Or Nor Not |
| Element | Exists Type |
| Array | All ElemMatch Size |
| Evaluation | Expr JSONSchema Mod Regex Text |
| Bitwise | BitsAllClear BitsAllSet BitsAnyClear BitsAnySet |
| Geospatial | GeoWithin GeoIntersects Near NearSphere |
| Escape | Raw |
Operators with optional parts take them as variadic options named after the MongoDB field they set:
monq.Text("coffee shop", monq.Language("en"), monq.CaseSensitive())
// {"$text": {"$search": "coffee shop", "$language": "en", "$caseSensitive": true}}Geospatial queries come with geometry constructors, so there is no hand-written GeoJSON. Point, Polygon, and GeoJSON build the shape, Geometry
hands it to an operator, and the legacy Box, Center, and CenterSphere shapes are there for 2d data. Positions are [longitude, latitude]:
monq.Near("loc", monq.Geometry(monq.Point(-73.97, 40.77)), monq.MaxDistance(1000))
// {"loc": {"$near": {"$geometry": {"type": "Point", "coordinates": [-73.97, 40.77]}, "$maxDistance": 1000.0}}}Update operators work the same way, one field each, and a single one is already a valid update document:
collection.UpdateOne(ctx, filter, monq.Set("status", "active"))
// {"$set": {"status": "active"}}Several of them go through Update, which merges operators sharing a key. It is to update documents what And is to filters, and it exists because two
$set documents concatenated by hand end up as a duplicate key that MongoDB does not merge:
update := monq.Update(
monq.Set("status", "active"),
monq.Inc("logins", 1),
monq.Set("name", "ada"),
)
// {"$set": {"status": "active", "name": "ada"}, "$inc": {"logins": 1}}| Category | Functions |
|---|---|
| Field update | Set SetOnInsert Unset Inc Mul Min Max Rename CurrentDate CurrentDateTimestamp |
| Array update | Push PushEach AddToSet AddToSetEach Pull PullAll PopFirst PopLast |
| Bitwise | BitAnd BitOr BitXor |
| Composition | Update |
The $each form of $push is its own function, since the $position, $slice, and $sort modifiers only exist there:
monq.PushEach("scores", []any{90, 80}, monq.PushSort(-1), monq.PushSlice(3))
// {"$push": {"scores": {"$each": [90, 80], "$sort": -1, "$slice": 3}}}Pipeline stages live in monq/stage, one function per stage. The package split is what keeps names honest: stage.Set is the $set stage while
monq.Set is the $set update operator, and the qualifier says which one is meant.
pipeline := stage.Pipeline(
stage.Match(monq.Eq("status", "active")),
stage.Sort(bson.D{{Key: "created_at", Value: -1}}),
stage.Limit(20),
)
cursor, err := collection.Aggregate(ctx, pipeline)Pipeline returns a []bson.D, which is what the driver's mongo.Pipeline is defined as, so it goes into Aggregate as is. A plain slice literal works
too.
Grouping stages take their output fields as Accumulator pieces, merged into one document by the stage:
stage.Group("$category",
stage.Accumulator("total", bson.D{{Key: "$sum", Value: "$amount"}}),
)
// {"$group": {"_id": "$category", "total": {"$sum": "$amount"}}}| Category | Functions |
|---|---|
| Filtering | Match Limit Skip Sample Count Sort |
| Grouping | Group Bucket BucketAuto SortByCount Facet Unwind |
| Joining | Lookup LookupPipeline GraphLookup UnionWith |
| Reshaping | Project AddFields Set Unset ReplaceRoot ReplaceWith |
| Output | Out Merge Documents |
| Geospatial | GeoNear |
| Building | Field Accumulator FacetPipeline Namespace |
| Assembly | Pipeline |
GeoNear takes the same geometry constructors the query operators do, which is why they return bare GeoJSON:
stage.GeoNear(monq.Point(-73.97, 40.77), "distance", stage.MaxDistance(1000), stage.Spherical())
// {"$geoNear": {"near": {"type": "Point", "coordinates": [-73.97, 40.77]}, "distanceField": "distance", ...}}monq/expr builds the expressions stages compute with. Expression operators compare values rather than naming a field, so a field goes in as a reference:
stage.Project(
stage.Field("name", 1),
stage.Field("grade", expr.Switch(
expr.Branch(expr.Gte(expr.Field("score"), 90), "A"),
expr.Branch(expr.Gte(expr.Field("score"), 80), "B"),
expr.DefaultCase("F"),
)),
)expr.Field("score") is the string "$score", which is how the aggregation framework tells a field from a constant. The rule runs both ways: any string
starting with $ is read as a reference, so a literal one goes through expr.Literal. That is also why expr.Eq(a, b) and monq.Eq(field, value) are
different functions rather than one name; expr.Eq("status", "active") compares two constants and is false everywhere.
Accumulators are the same functions, since MongoDB spells them the same way. Sum reads its argument count: one argument totals a field across a group,
several add them up inside each document.
stage.Group("$category",
stage.Accumulator("total", expr.Sum(expr.Field("amount"))),
stage.Accumulator("best", expr.Top(bson.D{{Key: "score", Value: -1}}, expr.Field("name"))),
)| Category | Functions |
|---|---|
| References | Field Literal |
| Comparison | Cmp Eq Ne Gt Gte Lt Lte |
| Boolean | And Or Not |
| Conditional | Cond IfNull Switch Branch DefaultCase |
| Arithmetic | Abs Add Ceil Divide Exp Floor Ln Log Log10 Mod Multiply Pow Round Sqrt Subtract Trunc |
| String | Concat Split SubstrBytes SubstrCP StrLenBytes StrLenCP Strcasecmp ToLower ToUpper |
| Trimming | Trim Ltrim Rtrim TrimChars |
| Searching | IndexOfBytes IndexOfCP RegexFind RegexFindAll RegexMatch ReplaceOne ReplaceAll |
| Array | ArrayElemAt ConcatArrays First Last FirstN LastN MaxN MinN In IndexOfArray IsArray Size |
| Array shape | Filter Map Reduce Range ReverseArray Slice SliceFrom SortArray Zip |
| Object | ArrayToObject ObjectToArray MergeObjects GetField SetField UnsetField |
| Set | AllElementsTrue AnyElementTrue SetDifference SetEquals SetIntersection SetIsSubset SetUnion |
| Date | DateAdd DateSubtract DateDiff DateFromParts DateToParts DateFromString DateToString |
| Date parts | Year Month DayOfMonth DayOfWeek DayOfYear Hour Minute Second Millisecond Week IsoDayOfWeek IsoWeek IsoWeekYear |
| Conversion | Convert IsNumber Type ToBool ToDate ToDecimal ToDouble ToInt ToLong ToObjectID ToString |
| Accumulator | Sum Avg Max Min Push AddToSet Count StdDevPop StdDevSamp Top TopN Bottom BottomN Median Percentile |
go get github.com/behzadsh/monq