1

In my data, one of the fields is an array and I want to return only a section of that array, and only that, no more fields. But when I'm using the $slice in projection, it will return all the fields.

My data:

{
  "_id": {
    "$oid": "641eca0f5687d0937c1041e2"
  },
  "email": "example@gmail.com",
  "password": "$2b$10$MBuDAi3k7jTvckTsqQliQeWeg18JebLaDdltzMJwJ92bwP7i84Ee2",
  "emailVerified": true,
  "transactions": [
    {
      "category": "t2",
      "subCategory": "bread",
      "type": "expense",
      "date": "2023-03-25T16:53:59.779Z",
      "amount": "1000",
      "id": {
        "$oid": "641f2727dc59db57eac9fa29"
      }
    },
    {
      "category": "t3",
      "subCategory": "bread",
      "type": "expense",
      "date": "2023-03-25T16:54:04.243Z",
      "amount": "1000",
      "id": {
        "$oid": "641f272cdc59db57eac9fa2a"
      }
    },
    {
      "category": "t4",
      "subCategory": "bread",
      "type": "expense",
      "date": "2023-03-25T16:54:08.780Z",
      "amount": "1000",
      "id": {
        "$oid": "641f2730dc59db57eac9fa2b"
      }
    }
  ]
}

And my query in node.js is like this:

const result = await users.findOne(
  { _id: new ObjectId(id) },
  { projection: { transactions: { $slice: [1, 2] } }}
);

With this projection, I'm getting all the fields in the result. If I change the projection to something like { transactions: { $slice: [1, 2] }, _id: 1 }, it will return only the transactions and _id. But I only want the transactions.

Update:

After some research, I found that this is possible with aggregate method. What aggregate is doing, is basically recreating the projection based on previous stage on the pipeline. But still, I don't know about the projection on findOne method.

const aggCursor = users.aggregate<TransactionsPagRes>([
  {
    $match: { _id: new ObjectId(id) },
  },
  {
    $project: {
      _id: 0,
      transactions: { $slice: ['$transactions', 1, 2] },
    },
  },
]);

const result = await aggCursor.next();
Amir
  • 996
  • 7
  • 17

3 Answers3

1

If you are fine with transactions being returned, you need to blacklist other properties you don't need like this:

db.collection.find({},
{
  transactions: {
    $slice: [
      1,
      2
    ]
  },
  _id: 0,
  email: 0,
  password: 0,
  emailVerified: 0
})

It will return info as:

[
  {
    "transactions": [
      {
        "amount": "1000",
        "category": "t3",
        "date": "2023-03-25T16:54:04.243Z",
        "id": ObjectId("641f272cdc59db57eac9fa2a"),
        "subCategory": "bread",
        "type": "expense"
      },
      {
        "amount": "1000",
        "category": "t4",
        "date": "2023-03-25T16:54:08.780Z",
        "id": ObjectId("641f2730dc59db57eac9fa2b"),
        "subCategory": "bread",
        "type": "expense"
      }
    ]
  }
]

If you want to have a flat array of user's transactions, aggregation with $unwind is a way to go:

db.collection.aggregate([
  {
    $match: {
      _id: "641eca0f5687d0937c1041e2"
    },
    
  },
  {
    "$project": {
      transactions: {
        $slice: [
          "$transactions",
          1,
          2
        ]
      }
    }
  },
  {
    "$unwind": "$transactions"
  }
])

Returns:

[
  {
    "_id": "641eca0f5687d0937c1041e2",
    "transactions": {
      "amount": "1000",
      "category": "t3",
      "date": "2023-03-25T16:54:04.243Z",
      "id": ObjectId("641f272cdc59db57eac9fa2a"),
      "subCategory": "bread",
      "type": "expense"
    }
  },
  {
    "_id": "641eca0f5687d0937c1041e2",
    "transactions": {
      "amount": "1000",
      "category": "t4",
      "date": "2023-03-25T16:54:08.780Z",
      "id": ObjectId("641f2730dc59db57eac9fa2b"),
      "subCategory": "bread",
      "type": "expense"
    }
  }
]

MongoDB Playground.

Vlad Holubiev
  • 4,876
  • 7
  • 44
  • 59
  • The first method seems unintuitive, it's possible obviously, but I don't think that's the answer. I have also done this with `aggregate` function without the `$unwind`, it actually works as expected (unlike the `findOne` `projection`). I'll share it in the original post. I was hoping if there is a way to do it with `findOne`. But I guess I go with `aggregate` for now. Thanks for the answer. – Amir Mar 27 '23 at 16:52
1

Using the $slice operator does not appear to exclude other fields like a projection normally would, this seems to be unexpected behavior in MongoDB.

A couple of choices to get the behavior you want:

  1. modify the projection passed to the find
    Since an projection will never implicitly exclude _id, you will need to modify this anyway. To exclude all of the other fields, either explicitly exclude every field, or include a field that does not actually exist.
const result = await users.findOne(
  { _id: new ObjectId(id) },
  { projection: { 
     _id:0,
     _not_a_real_field: 1,
     transactions: { $slice: [1, 2] } 
  }}
);

Playground

  1. use an aggregation pipeline You will still need to explicitly exclude the _id, but would not need to reference a non-existent field to exclude the others
[
  {$match: {_id: ObjectId("641eca0f5687d0937c1041e2")}}},
  {$project: {
      _id: 0,
      transactions: {$slice: ["$transactions",1,2]}
  }}
]

Playground

Joe
  • 25,000
  • 3
  • 22
  • 44
0

Counterintuitive MongoDB

I've accidentally found seemingly unrelated bit in MongoDB documentation, with just one important sentence:

The $slice projection by itself is considered an exclusion.

It seems like Mongo considers $slice to be an exclusion of fields, not inclusion. That is, this projection:

{ transactions: { $slice: [1, 2] } }

does not return just transaction field with two elements, it returns all the fields in the document, with transaction modified to contain only two elements

If you include any other fields together with $slice, the projection will return that field and sliced array, as expected:

{ _id: true, transactions: { $slice: [1, 2] } }

I've observed this behavior not just in Mongoose, but in mongosh as well

Example

Let's say you have the following model:

const User = model('User', new Schema({
  firstName: String,
  lastName: String
  favoriteColors: [String]
}))

And a single document in the DB (object id is replaced with number for brevity)

{ 
  _id: 0, 
  firstName: 'Bob', 
  lastName: 'Bobski', 
  favoriteColors: ['blue', 'orange'] 
} 

Here is unexpected result of projection with single $slice:

const results = await User.find({}, { 
  favoriteColors: { $slice: [1, 1] } 
})

// results is [{ _id: 0, firstName: 'Bob', lastName: 'Bobski', favoriteColors: ['blue'] }]
// Note that `favoriteColors` is sliced, but all others fields are returned too

Once you add the other field, result is expected:

const results = await User.find({}, { 
  _id: true, 
  favoriteColors: { $slice: [1, 1] } 
})

// results is [{ _id: 0, favoriteColors: ['blue'] }]

As suggested by the other answer, excluding non-existent field works too:

const results = await User.find({}, { 
  _id: false, 
  notARealField: true, 
  favoriteColors: { $slice: [1, 1] } 
})

// results is [{ favoriteColors: ['blue'] }]
Azerum
  • 73
  • 1
  • 2
  • 5