3

I'm trying to create a Mongoid query for records that match a certain date range, or have nil values. Here is my ruby code that performs the function that I would like to turn into a Mongoid query:

class MyModel
  include Mongoid::Document
  field :name
  field :enabled, type: Boolean, default: false
  field :start_date, type: DateTime
  field :end_date, type: DateTime

  def self.active
    documents = where(enabled: true)
    documents = documents.keep_if {|doc| doc.start_date.nil? || doc.start_date <= Date.today}
    documents = documents.keep_if {|doc| doc.end_date.nil? || doc.end_date >= Date.tomorrow}
    documents
  end
end

How can I improve performance by turning this method into a Mongoid query?

Update:

Here is the RSpec test that I'm using to verify the correct behavior:

describe '.active' do
  let!(:disabled){ Fabricate(:model, enabled: false, name: 'disabled') }
  let!(:enabled_without_date){ Fabricate(:active_model, name: 'enabled_without_date') }
  let!(:past){ Fabricate(:active_model, start_date: 1.week.ago, end_date: Date.yesterday, name: 'past') }
  let!(:current){ Fabricate(:active_model, start_date: Date.today, end_date: Date.tomorrow, name: 'current') }
  let!(:future){ Fabricate(:active_model, start_date: Date.tomorrow, end_date: 1.week.from_now, name: 'future') }
  
  it 'returns only enabled and within the current time' do
    MyModel.count.should == 5
    models = MyModel.active.to_a
    models.should_not be_empty
    models.should_not include disabled
    models.should_not include past
    models.should_not include future
    models.should include enabled_without_date
    models.should include current
  end
end
Community
  • 1
  • 1
Andrew
  • 227,796
  • 193
  • 515
  • 708
  • possible duplicate of [Mongoid OR query syntax](http://stackoverflow.com/questions/12999368/mongoid-or-query-syntax) – Hauleth Jul 15 '13 at 20:45
  • 1
    I've updated my question with a working code sample that I would like to turn into a query. – Andrew Jul 15 '13 at 22:52
  • Your specs look for models *in* the date range, whereas your `active` method searches for documents *outside* the date range. Which is the desired behavior? – Chris Heald Jul 22 '13 at 19:16
  • @ChrisHeald I think you need to take a second look. In order for a document to be "active" it must have a start date that is in the past or today (or nil) and an end date that is after today (or nil). – Andrew Jul 23 '13 at 03:13
  • Aha, I see. Not sure what I was thinking earlier! I'll amend my answer to cover that. – Chris Heald Jul 23 '13 at 03:15

6 Answers6

4

If you convert the criteria:

(start <= X OR start.nil?) AND (end >= Y OR end.nil?)

into the disjunctive form, you get:

(start <= X AND end >= Y) OR (start <= X and end.nil?) OR (start.nil? and end >= Y) or (start.nil? and end.nil?)

You can then express this via a single $or clause:

$or: [
  {:start_date.lte => start_date, :end_date.gte => end_date},
  {:start_date => nil, :end_date.gte => end_date},
  {:start_date.lte => start_date, :end_date => nil},
  {:start_date => nil, :end_date => nil},
]

If both values must be either set or nil (that is, you can't have one set and one nil), this becomes even simpler:

$or: [
  {:start_date.lte => start_date, :end_date.gte => end_date},
  {:start_date => nil},
]

To meet your spec, the full query would be:

Model.where({
  :enabled => true,
  :$or => [
    {:start_date.lte => Date.today.to_time, :end_date.gte => Date.tomorrow.to_time},
    {:start_date => nil},
  ]
})
Chris Heald
  • 61,439
  • 10
  • 123
  • 137
  • sorry, it's still only returning the "enabled_without_date" record. It should also include the "current" record. – Andrew Jul 24 '13 at 21:01
  • You may be running into date resolution issues; Mongo only stores Javascript Date types, which are full-resolution down to the millisecond. Can you compare the query generated with the actual data in the DB? – Chris Heald Jul 24 '13 at 21:08
1

Maybe this will do the trick:

where(
  enabled: true,
  {:$or => [{start_date: nil}, {:start_date.lte => Date.today.to_time   }]},
  {:$or => [{end_date:   nil}, {:end_date.gte   => Date.tomorrow.to_time}]}
)
Patrick Oscity
  • 53,604
  • 17
  • 144
  • 168
1

Use below code:

where(:enabled => true, :$or => [{:start_date => nil}, {:start_date =>['$lte' => Date.today]}], :$or => [{:end_date => nil}, {:end_date =>['$gte' =>Date.tomorrow]}])

Hope this help you.

Andrew
  • 227,796
  • 193
  • 515
  • 708
harsh4u
  • 2,550
  • 4
  • 24
  • 39
0

Using

Document.where(
  enabled: true,
  '$or' => [
    { start_date: nil },
    { :start_date.lte => Date.today.to_time }
  ],
  '$or' => [
    { end_date: nil },
    { :end_date.gte => Date.tomorrow.to_time }
  ]
).each do |d|
  puts d.inspect
end

I was able to get

[2] pry(main)> load './documents.rb'
#<Document _id: 51e89c690e21d8ab0d9cf012, enabled: true, start_date: nil, end_date: nil>
#<Document _id: 51e8a2e62147b4bfb5f12c65, enabled: true, start_date: 2012-02-01 05:00:00 UTC, end_date: 2014-02-01 05:00:00 UTC>
#<Document _id: 51e8a4797372723f449765bd, enabled: true, start_date: nil, end_date: 2014-02-01 05:00:00 UTC>
=> true

Update

You are right. My answer was completely bogus. The second $or key would have overridden the first. However, even wrapping the entire thing with $and (thus using an array of hashes) doesn't help - mongoid could be preventing multiple $or conditions.

On a brighter note, Chris Heald's solution works. Here is a complete proof. Run it with rspec mongoid_query.rb.

start_date = Date.today
end_date = Date.tomorrow
where(
  enabled: true,
  :$or => [
    {:start_date.lte => start_date, :end_date.gte => end_date},
    {:start_date => nil, :end_date.gte => end_date},
    {:start_date.lte => start_date, :end_date => nil},
    {:start_date => nil, :end_date => nil},
  ]
)
James Lim
  • 12,915
  • 4
  • 40
  • 65
  • Interesting...I'm surprised to see that using the same `$or` hash key does not overwrite the previous `$or`. Not sure if it makes a difference, but the `start_date/end_date` fields are specified as `DateTime` – Andrew Jul 22 '13 at 18:06
  • Also, your answer is a different solution than the solution that @padde provided. Your answer passes a single hash, while padde passes three hashes. – Andrew Jul 22 '13 at 18:09
  • Unfortunately, this still isn't passing my tests. I updated my question to include the test I'm using to verify the solution. – Andrew Jul 22 '13 at 18:23
0

All answers given so far seem alright to me. However I'll add another syntax variant that has worked for me in previous projects, which also takes care of the redundant $or key scenario, which seems a little fishy to me (but maybe doing that is totally ok anyway).

Document.where({
   '$and' => [
       :enabled => true,
       '$or' => [
           :start_date => nil,
           :start_date => { '$lte' => Date.today.to_time }
       ],
       '$or' => [
           :end_date => nil,
           :end_date => { '$gte' => Date.tomorrow.to_time }
       ]
    ]
})

In terms of further advice: Have you reviewed your spec as well? (You never know ... ;)). Also make sure to debug and test only parts of the problem, e.g. get the :enabled filter working on it's own, get the date filters working on their own, get the nil filters working on their own, and then try to combine them again - Maybe that will lead you to the core of the problem. Also I'm seeing different variants of specifying the Date that $lte and $gte are compared against here. I myself had success with providing a Time class to compare against, make sure to experiment with that as well!

Simon Repp
  • 528
  • 3
  • 10
0

It appears to be a limitation in the mongodb driver itself! Just using the mongodb java shell I created three records:

db.so.insert({s:0,e:10})
db.so.insert({s:10,e:20})
db.so.insert({s:20,e:30})

then tried the following queries:

db.so.find({s:{$lte:15}, e:{$gte:15}})  
   ==> finds just the middle record - correct
db.so.find({s:{$lte:15}, $or:[{e:{$exists:false}},{e:{$gte:15}}]})  
   ==> finds just the middle record - correct
db.so.find({$or:[{s:{$exists:false}},{s:{$lte:15}}], $or:[{e:{$exists:false}},{e:{$gte:15}}]})
   ==> finds both the middle and the last record - OOPS
db.so.find({$or:[{s:{$exists:false}},{$and:[{s:{$lte:15}},{e:{$gte:15}}]}]})
   ==> finds just the middle record - correct

I presume this means you cannot use more than one $or at the same level (as it says you can nest them). I will post this as a question on the mongodb site and see whether they agree. In the meantime, in your case the fourth query points the way to a possible work round, though Chris Heald has already suggested almost the same:

Model.where({
    :enabled => true,
    :$or => [
        {:start_date => nil},
        :$and => [
            {:start_date.lte => Date.today.to_time}, 
            {:end_date.gte => Date.tomorrow.to_time}
        ]
    ]
})
Mark Farmiloe
  • 396
  • 3
  • 8