Search code examples
mongodbmongoosemongodb-queryaggregation-framework

MongoDB not-any operator


Is there something like a not-any operator for mongodb-queries?

I have the following setup:

const ConstructQuestion = new Schema({
  answerType: String,
  text: String,
});

const Construct = new Schema({
  name: String,
  description: String,
  questions: [ConstructQuestion],
});

I also have an array of ConstructQuestion-IDs. I now try to query all Constructs, that contain any questions whose _id is not in my ConstructQuestionID-Array.

In other words: I want all Constructs that have questions whose _id is not any of the IDs in my array

I can achieve the opposite of what I want using $in, which queries all Constructs, that have any questions whose _id is any of the IDs in my ID-array. sadly $nin wasn't the answer, as it doesn't match a Construct that contains all the questions in my ID-Array and more


edit:

To be very precise: I'm looking for a query that will give me all Constructs that contain at least one question whose id is not in my ID-Array


result:

With the help of Bertrands answer my mongoose-solution now looks like this:

return this.construct.aggregate()
  .group({
    _id: '$_id',
    questions_id: {$push: '$questions._id'},
    construct: {$first: '$$ROOT'},
  })
  .unwind('$questions_id')
  .project({
    construct: true,
    questions_id: true,
    check: {
      $cmp: [{
        $size: {$setIntersection: ['$questions_id', usedQuestionIds]},
      }, {
        $size: '$questions_id',
      }],
    },
  })
  .match({check: {$lt: 0}})
  .exec()
  .then((results) => {
    const constructs = results.map((result) => {
      return result.construct;
    });
  })

improvement:

After adding the questionIds as array directly to the Construct on every create/update, I could remove the group and unwind stages, so my code now looks like this:

return this.construct.aggregate({
  $addFields: {
    check: {
      $cmp: [{
        $size: {$setIntersection: ['$questionIds', usedQuestionIds]},
      }, {
        $size: '$questionIds',
      }],
    },
  }})
  .match({check: {$lt: 0}})
  .exec()
  .then((constructs) => {
    
  });

Thanks again to Bertrand for this hint


improvement 2

Again, as a hint from Bertrand, $project and $match can (in this case) be combined to $redact, with which the code now looks like this:

return this.construct.aggregate({
  $redact: {
    $cond: {
      if: {
        $lt: [
          {$size: {$setIntersection: ['$questionIds', usedQuestionIds]}},
          {$size: '$questionIds'}
        ]
      },
      then: '$$KEEP',
      else: '$$PRUNE'
    }
  }})
  .exec()
  .then((constructs) => {
    
  });

improvement 3

I found out, that I could use $setDifference and a single $size instead of $setInsersectionand two $size. The code now looks like this:

return this.construct.aggregate({
  $redact: {
    $cond: {
      if: {
        $gt: [{$size: {$setDifference: ['$questionIds', usedQuestionIds]}}, 0]
      },
      then: "$$KEEP",
      else: "$$PRUNE"
    }
  }})
  .exec()
  .then((constructs) => {
    
  });

Solution

  • I think what you are looking for is $not + $all :

    var input = ["58c20f6d95b16e3046ddd7e9", "58c20f6d95b16e3046ddd7eb", "58c20f6d95b16e3046ddd7ec"];
    
    Data.find({
        "questions._id": {
            "$not": {
                "$all": input
            }
        }
    }, function(err, res) {
        console.log(res);
    });
    

    It will return all document that haven't got all questions specified in input input array. If the document is selected, it means at least one question in the input array is not in the document.

    Edit

    As you want the intersection to be exact match for the questions document, you can use setIntersection available in aggregation to determine elements which are common to both array and take decision according to that :

    var array = [
        new mongoose.mongo.ObjectId("58c20f6d95b16e3046ddd7e9"),
        new mongoose.mongo.ObjectId("58c20f6d95b16e3046ddd7ea"),
        new mongoose.mongo.ObjectId("58c20f6d95b16e3046ddd7eb"),
        new mongoose.mongo.ObjectId("58c20f6d95b16e3046ddd7ec")
    ];
    
    Const.aggregate([{
        $group: {
            "_id": "$_id",
            "questions_id": {
                $push: "$questions._id"
            },
            "document": { $first: "$$ROOT" }
        }
    }, {
        $unwind: "$questions_id"
    }, {
        $project: {
            document: 1,
            questions_id: 1,
            check: {
                $cmp: [{
                    $size: {
                        $setIntersection: ["$questions_id", array]
                    }
                }, {
                    $size: "$questions_id"
                }]
            }
        }
    }, {
        $match: {
            check: {
                "$lt": 0
            }
        }
    }], function(err, res) {
        console.log(res);
    });
    

    The first $group & $unwind take care of building an array of questions._id. The comparison is performed in the next $project with the intersection size and the size of questions._id.

    Edit 2

    You can simplify $project + $match with a $redact that will keep dcouments matching the condition. With your newly added questionIds field, it's much simpler :

    var array = [
        new mongoose.mongo.ObjectId("58c20f6d95b16e3046ddd7e9"),
        new mongoose.mongo.ObjectId("58c20f6d95b16e3046ddd7ea"),
        new mongoose.mongo.ObjectId("58c20f6d95b16e3046ddd7eb"),
        new mongoose.mongo.ObjectId("58c20f6d95b16e3046ddd7ec")
    ];
    
    Const.aggregate([{
        $redact: {
            $cond: {
                if: {
                    $lt: [{
                        $size: {
                            $setIntersection: ["$questionIds", array]
                        }
                    }, {
                        $size: "$questionIds"
                    }]
                },
                then: "$$KEEP",
                else: "$$PRUNE"
            }
        }
    }], function(err, res) {
        console.log(res);
    });