Return first element if no match found in array - mongodb

I have the following document:
{
_id: 123,
state: "AZ",
products: [
{
product_id: 1,
desc: "P1"
},
{
product_id: 2,
desc: "P2"
}
]
}
I need to write a query to return a single element from the products array where state is "AZ" and product_id is 2. If the matching product_id is not found, then return the first (or any) element from the products array.
For example: If product_id is 2 (match found), then the result should be:
products: [
{
product_id: 2,
desc: "P2"
}
]
If the product_id is 3 (not found), then the result should be:
products: [
{
product_id: 1,
desc: "P1"
}
]
I was able to meet one condition when the match is found but not sure how to satisfy the second condition in the same query:
db.getCollection('test').find({"state": "AZ"}, {_id: 0, state: 0, products: { "$elemMatch": {"product_id": "2"}}})
I tried using the aggregation pipeline as well but could not find a working solution.
Note: This is different from the following question as I need to return a default element if the match is not found:
Retrieve only the queried element in an object array in MongoDB collection

You can try below aggregation
Basically you need to $filter the products array and check for the $condition if it doesn't contain any element or equal to [] then you have to $slice with the first element of the products array.
db.collection.aggregate([
{ "$addFields": {
"products": {
"$cond": [
{
"$eq": [
{ "$filter": {
"input": "$products",
"cond": { "$eq": ["$$this.product_id", 2] }
}},
[]
]
},
{ "$slice": ["$products", 1] },
{ "$filter": {
"input": "$products",
"cond": { "$eq": ["$$this.product_id", 2] }
}}
]
}
}}
])
or even using $let aggregation
db.collection.aggregate([
{ "$addFields": {
"products": {
"$let": {
"vars": {
"filt": {
"$filter": {
"input": "$products",
"cond": { "$eq": ["$$this.product_id", 2] }
}
}
},
"in": {
"$cond": [
{ "$eq": ["$$filt", []] },
{ "$slice": ["$products", 1] },
"$$filt"
]
}
}
}
}}
])

If you don't care which element you get back then this is the way to go (you'll get the last element in the array in case of no match since $indexOfArray will return -1):
db.getCollection('test').aggregate([{
$addFields: {
"products": {
$arrayElemAt: [ "$products", { $indexOfArray: [ "$products.product_id", 2 ] } ]
},
}
}])
If you want the first then do this instead ($max will take care of transforming -1 into index 0 which is the first element):
db.getCollection('test').aggregate([{
$addFields: {
"products": {
$arrayElemAt: [ "$products", { $max: [ 0, { $indexOfArray: [ "$products.product_id", 2 ] } ] } ]
},
}
}])
Here is a version that should work on v3.2 as well:
db.getCollection('test').aggregate([{
"$project": {
"products": {
$slice: [{
$concatArrays: [{
$filter: {
"input": "$products",
"cond": { "$eq": ["$$this.product_id", 2] }
}},
"$products" // simply append the "products" array
// alternatively, you could append only the first or a specific item like this [ { $arrayElemAt: [ "$products", 0 ] } ]
]
},
1 ] // take first element only
}
}
}])

Related

Mongodb aggregation convert array of pairs to key and list of values

Trying to condense an array with key value pairs into an array of objects with the key and all the unique values for that key.
I have a structure like:
{
fruits: [
{fruit: apple, type: gaja},
{fruit: apple, type: honey-crisp},
{fruit: apple, type: fuji},
{fruit: cherry, type: black},
{fruit: cherry, type: red},
{fruit: cherry, type: red},
]
}
How can I convert it to:
{
fruits: [
{fruit: apple, types: [gaja, honey-crisp, fuji]},
{fruit: cherry, types: [black, red]}
]
}
Using mongo aggregations I managed to get the first structure from my data using $group and $addToSet. Not sure how to map the array to new object with a key and list of values
Here's another way to do it by using "$reduce". Comments are in the aggregation pipeline.
db.collection.aggregate([
{
"$set": {
// rewrite fruits
"fruits": {
"$reduce": {
"input": "$fruits",
"initialValue": [],
"in": {
"$let": {
"vars": {
// get fruit index in $$value : will be -1 if not there
"idx": {"$indexOfArray": ["$$value.fruit", "$$this.fruit"]}
},
"in": {
"$cond": [
// is fruit not in $$value yet
{"$eq": ["$$idx", -1]},
// new fruit so put in $$value and make "type" an array
{
"$concatArrays": [
"$$value",
[{"$mergeObjects": ["$$this", {"type": ["$$this.type"]}]}]
]
},
// fruit already in $$value, so map $$value with "type" update
{
"$map": {
"input": "$$value",
"as": "val",
"in": {
"$cond": [
// is this array element not the right fruit?
{"$ne": ["$$val.fruit", "$$this.fruit"]},
// nope, leave the element as-is
"$$val",
// this element needs to be updated
{
"$mergeObjects": [
"$$val",
{
"type": {
"$cond": [
// is this "type" already in array?
{"$in": ["$$this.type", "$$val.type"]},
// yes, so leave it as-is
"$$val.type",
// this is a new "type", so add it to array
{"$concatArrays": ["$$val.type", ["$$this.type"]]}
]
}
}
]
}
]
}
}
}
]
}
}
}
}
}
}
}
])
Try it on mongoplayground.net.
Maybe something like this:
db.collection.aggregate([
{
$unwind: "$fruits"
},
{
$group: {
_id: "$fruits.fruit",
type: {
$push: "$fruits.type"
}
}
},
{
$project: {
fruit: "$_id",
type: 1,
_id: 0
}
},
{
$group: {
_id: "",
fruits: {
$push: "$$ROOT"
}
}
}
])
Explained:
Unwind the array
Group to form the type array ( you can use $push or $addToSet in case you need only unique )
Project the necessary fields
Group all documents inside single final one
Playground
Here's another, another way using a multiple "$map" and "$setUnion" to get unique array members.
db.collection.aggregate([
{
"$set": {
// rewrite fruits
"fruits": {
"$map": {
// map over unique fruits
"input": {"$setUnion": "$fruits.fruit"},
"as": "theFruit",
"in": {
// set fruit
"fruit": "$$theFruit",
// "type" are unique elements of fruits.type
// where fruits.fruit == theFruit
"type": {
"$setUnion": {
"$map": {
"input": {
"$filter": {
"input": "$fruits",
"as": "obj",
"cond": {"$eq": ["$$obj.fruit", "$$theFruit"]}
}
},
"in": "$$this.type"
}
}
}
}
}
}
}
}
])
Try it on mongoplayground.net.

MongoDB $filter nested array by date does not work

I have a document with a nested array which looks like this:
[
{
"id": 1,
data: [
[
ISODate("2000-01-01T00:00:00Z"),
2,
3
],
[
ISODate("2000-01-03T00:00:00Z"),
2,
3
],
[
ISODate("2000-01-05T00:00:00Z"),
2,
3
]
]
},
{
"id": 2,
data: []
}
]
As you can see, we have an array of arrays. For each element in the data array, the first element is a date.
I wanted to create an aggregation pipeline which filters only the elements of data where the date is larger than a given date.
db.collection.aggregate([
{
"$match": {
"id": 1
}
},
{
"$project": {
"data": {
"$filter": {
"input": "$data",
"as": "entry",
"cond": {
"$gt": [
"$$entry.0",
ISODate("2000-01-04T00:00:00Z")
]
}
}
}
}
}
])
The problem is that with $gt, this just returns an empty array for data. With $lt this returns all elements. So the filtering clearly does not work.
Expected result:
[
{
"id": 1,
"data": [
[
ISODate("2000-01-05T00:00:00Z"),
2,
3
]
]
}
]
Any ideas?
Playground
I believe the issue is that when you write $$entry.0, MongoDB is trying to evaluate entry.0 as a variable name, when in reality the variable is named entry. You could make use of the $first array operator in order to get the first element like so:
db.collection.aggregate([
{
"$match": {
"id": 1
}
},
{
"$project": {
"data": {
"$filter": {
"input": "$data",
"as": "entry",
"cond": {
"$gt": [
{
$first: "$$entry"
},
ISODate("2000-01-04T00:00:00Z")
]
}
}
}
}
}
])
Mongo playground example
Don't think $$entry.0 work to get the first element of the array. Instead, use $arrayElemAt operator.
db.collection.aggregate([
{
"$match": {
"id": 1
}
},
{
"$project": {
"data": {
"$filter": {
"input": "$data",
"as": "entry",
"cond": {
"$gt": [
{
"$arrayElemAt": [
"$$entry",
0
]
},
ISODate("2000-01-04T00:00:00Z")
]
}
}
}
}
}
])
Sample Mongo Playground
to specify which element in the array you are comparing it is better to use $arrayElemAt instead of $$ARRAY.0. you must pass 2 parameters while using $arrayElemAt, the first one is the array which in your case is $$entry, and the second one is the index which in your case is 0
this is the solution I came up with:
db.collection.aggregate([
{
"$match": {
"id": 1
}
},
{
"$project": {
"data": {
"$filter": {
"input": "$data",
"as": "entry",
"cond": {
"$gt": [
{
"$arrayElemAt": [
"$$entry",
0
]
},
ISODate("2000-01-04T00:00:00Z")
]
}
}
}
}
}
])
playground

MongoDB/PyMongo: upsert array element

I have the following document:
{'software_house': 'k1',
'client_id': '1234',
'transactions': [
{'antecedents': 12345,
'consequents': '015896018',
'antecedent support': 0.0030889166727954697},
{'antecedents': '932696735',
'consequents': '939605046',
'antecedent support': 0.0012502757961314996}
...
]}
In which key 'transactions' stores within an array 3 features, for each item.
I would like to update each item contained in the 'transactions' array, that matches with the same 'software_house', 'client_id', 'transactions.antecedents' and 'transactions.consequents'; and thus:
Overwriting the element within the array if it does exist
Appending a new value within 'transactions' if it doesn't
How could I achieve that using pymongo?
You can do this with an update with aggregation pipeline. You can first $filter the element matched. Then $setUnion with the item you want to upsert
PyMongo:
db.collection.update_many(filter = {
// the criteria you want to match outside array
"software_house": "k1",
"client_id": "1234"
},
update = [
{
"$addFields": {
"transactions": {
"$filter": {
"input": "$transactions",
"as": "t",
"cond": {
$not: {
$and: [
// the criteria you want to match in array
{
$eq: [
"$$t.antecedents",
12345
]
},
{
$eq: [
"$$t.consequents",
"015896018"
]
}
]
}
}
}
}
}
},
{
"$addFields": {
"transactions": {
"$setUnion": [
"$transactions",
[
{
"antecedents": 12345,
"consequents": "the entry you want to upsert",
"antecedent support": -1
}
]
]
}
}
}
])
Native MongoDB query:
db.collection.update({
// the criteria you want to match outside array
"software_house": "k1",
"client_id": "1234"
},
[
{
"$addFields": {
"transactions": {
"$filter": {
"input": "$transactions",
"as": "t",
"cond": {
$not: {
$and: [
// the criteria you want to match in array
{
$eq: [
"$$t.antecedents",
12345
]
},
{
$eq: [
"$$t.consequents",
"015896018"
]
}
]
}
}
}
}
}
},
{
"$addFields": {
"transactions": {
"$setUnion": [
"$transactions",
[
{
"antecedents": 12345,
"consequents": "the entry you want to upsert",
"antecedent support": -1
}
]
]
}
}
}
],
{
multi: true
})
Here is the Mongo playground for your reference.

MongoDB > extract collection from nested array

I've been trying every method I found on SO with no success. Trying
to accomplish a seemingly simple task (very easy with json/lodash for example) in MongoDB..
I have a collection:
db.users >
[
{
_id: 'userid',
profile: {
username: 'abc',
tests: [
{
_id: 'testid',
meta: {
category: 'math',
date: '9/2/2017',
...
}
questions: [
{
type: 'add',
correct: true,
},
{
type: 'subtract',
correct: true,
},
{
type: 'add',
correct: false,
},
{
type: 'multiply',
correct: false,
},
]
},
...
]
}
},
...
]
I want to end up with an array grouped by question type:
[
{
type: 'add',
correct: 5,
wrong: 3,
},
{
type: 'subtract',
correct: 4,
wrong: 9
}
...
]
I've tried different variations of aggregate, last one is:
db.users.aggregate([
{ $match: { 'profile.tests.meta.category': 'math' }},
{
$project: {
tests: {
$filter: {
input: "$profile.tests",
as: "test",
cond: { $eq: ['$$test.meta.category', 'math'] }
}
}
}
},
{
$project: {
question: "$tests.questions"
}
},
{ $unwind: "$questions"},
])
Also tried adding $group at the end of the pipeline:
{
$group:
{
_id: '$questions.type',
res: {
$addToSet: { correct: {$eq:['$questions.chosenAnswer', '$questions.answers.correct'] }
}
}
}
No variation gave me what I'm looking for, I'm sure I'm missing a core concept, I've looked over the documentation and couldn't figure it out.. what I'm basically looking for is a flatMap to extract away all the questions of all users and group them by type.
If anyone can lead me in the right direction, I'll greatly appreciate it :) thx. (Also, I'm using Meteor, so any query has to work in Meteor mongo)
You can try below aggregation in 3.4.
$filter to filter math categories with $map to project questions array in each matching category followed by $reduce and $concatArrays to get all questions into single array for all matching categories.
$unwind questions array and $group by type and $sum to compute correct and wrong count.
db.users.aggregate([
{
"$match": {
"profile.tests.meta.category": "math"
}
},
{
"$project": {
"questions": {
"$reduce": {
"input": {
"$map": {
"input": {
"$filter": {
"input": "$profile.tests",
"as": "testf",
"cond": {
"$eq": [
"$$testf.meta.category",
"math"
]
}
}
},
"as": "testm",
"in": "$$testm.questions"
}
},
"initialValue": [],
"in": {
"$concatArrays": [
"$$value",
"$$this"
]
}
}
}
}
},
{
"$unwind": "$questions"
},
{
"$group": {
"_id": "$questions.type",
"correct": {
"$sum": {
"$cond": [
{
"$eq": [
"$questions.correct",
true
]
},
1,
0
]
}
},
"wrong": {
"$sum": {
"$cond": [
{
"$eq": [
"$questions.correct",
false
]
},
1,
0
]
}
}
}
}
])

Mongo Query to Return only a subset of SubDocuments

Using the example from the Mongo docs:
{ _id: 1, results: [ { product: "abc", score: 10 }, { product: "xyz", score: 5 } ] }
{ _id: 2, results: [ { product: "abc", score: 8 }, { product: "xyz", score: 7 } ] }
{ _id: 3, results: [ { product: "abc", score: 7 }, { product: "xyz", score: 8 } ] }
db.survey.find(
{ id: 12345, results: { $elemMatch: { product: "xyz", score: { $gte: 6 } } } }
)
How do I return survey 12345 (regardless of even if it HAS surveys or not) but only return surveys with a score greater than 6? In other words I don't want the document disqualified from the results based on the subdocument, I want the document but only a subset of subdocuments.
What you are asking for is not so much a "query" but is basically just a filtering of content from the array in each document.
You do this with .aggregate() and $project:
db.survey.aggregate([
{ "$project": {
"results": {
"$setDifference": [
{ "$map": {
"input": "$results",
"as": "el",
"in": {
"$cond": [
{ "$and": [
{ "$eq": [ "$$el.product", "xyz" ] },
{ "$gte": [ "$$el.score", 6 ] }
]}
]
}
}},
[false]
]
}
}}
])
So rather than "contrain" results to documents that have an array member matching the condition, all this is doing is "filtering" the array members out that do not match the condition, but returns the document with an empty array if need be.
The fastest present way to do this is with $map to inspect all elements and $setDifference to filter out any values of false returned from that inspection. The possible downside is a "set" must contain unique elements, so this is fine as long as the elements themselves are unique.
Future releases will have a $filter method, which is similar to $map in structure, but directly removes non-matching results where as $map just returns them ( via the $cond and either the matching element or false ) and is then better suited.
Otherwise if not unique or the MongoDB server version is less than 2.6, you are doing this using $unwind, in a non performant way:
db.survey.aggregate([
{ "$unwind": "$results" },
{ "$group": {
"_id": "$_id",
"results": { "$push": "$results" },
"matched": {
"$sum": {
"$cond": [
{ "$and": [
{ "$eq": [ "$results.product", "xyz" ] },
{ "$gte": [ "$results.score", 6 ] }
]},
1,
0
]
}
}
}},
{ "$unwind": "$results" },
{ "$match": {
"$or": [
{
"results.product": "xyz",
"results.score": { "$gte": 6 }
},
{ "matched": 0 }
}},
{ "$group": {
"_id": "$_id",
"results": { "$push": "$results" },
"matched": { "$first": "$matched" }
}},
{ "$project": {
"results": {
"$cond": [
{ "$ne": [ "$matched", 0 ] },
"$results",
[]
]
}
}}
])
Which is pretty horrible in both design and perfomance. As such you are probably better off doing the filtering per document in client code instead.
You can use $filter in mongoDB 3.2
db.survey.aggregate([{
$match: {
{ id: 12345}
}
}, {
$project: {
results: {
$filter: {
input: "$results",
as: "results",
cond:{$gt: ['$$results.score', 6]}
}
}
}
}]);
It will return all the sub document that have score greater than 6. If you want to return only first matched document than you can use '$' operator.
You can use $redact in this way:
db.survey.aggregate( [
{ $match : { _id : 12345 }},
{ $redact: {
$cond: {
if: {
$or: [
{ $eq: [ "$_id", 12345 ] },
{ $and: [
{ $eq: [ "$product", "xyz" ] },
{ $gte: [ "$score", 6 ] }
]}
]
},
then: "$$DESCEND",
else: "$$PRUNE"
}
}
}
] );
It will $match by _id: 12345 first and then it will "$$PRUNE" all the subdocuments that don't have "product":"xyz" and don't have score greater or equal 6. I added the condition ($cond) { $eq: [ "$_id", 12345 ] } so that it wouldn't prune the whole document before it reaches the subdocuments.