mongoid / origin

A Ruby DSL for building MongoDB queries
http://mongoid.org/en/origin/index.html
MIT License
62 stars 29 forks source link

Add capability for custom types to specify MongoDB query portions #47

Closed nessche closed 11 years ago

nessche commented 11 years ago

Hi,

while working with money-mongoid project, I realized there is a need for custom types to generate portions of the MongoDB query directly (and not just serialize the type itself). E.g. when using a complex type like money (which consists of "cents", an Integer, and "currency_iso" a String) with comparison operators, the more natural way to express the query on a Product object having a field price of type Money is something along the lines of

Product.where(:price.gt => Money.new(2000,"USD"))

when only serializing the money object, the resulting query compares the cents value, ignoring the currency code, resulting in wrong results if the values in the DB are not all stored using the same currency.

In order to correctly perform the query the custom type should be able to actually generate the query subportion related to the price field such that it would look like

`{"price.cents" => {"$gt" => 2000}, "price.currency_iso" => "USD"}

I managed to implement such functionality by modifying the implementation of expr_querymethod of Selectable as a proof-of-concept (pls see custom_criteria_expansion branch of my fork of the origin project), but as an implementation it is a hack because it uses in Selectable the concept of serializers (defined in Queryable and some properties that "happen to be there" because the serializer is of type Mongoid::Field::Standard which is defined in the Mongoid gem.

So the changes should be possibly split between origin (either in Queryable or Selectableor both) and in mongoid (maybe introducing a Mongoid::Field::Custom class), but I leave to the more expert people the decision on where actually the changes should go, although I offer my help to implement such changes once there is an agreement on their correct place.

An example of how the Money type handles the query generation can be found here.

marco

kristianmandrup commented 11 years ago

:)

kristianmandrup commented 11 years ago

Hi Marco,

I have been looking into your code today, both your additions to origin and money.rb for mongoid 3 in money-mongoid. I made the following improvements in `money_spec.rb``

  before :all do
    Mongoid.logger = Logger.new($stdout)
    Moped.logger   = Logger.new($stdout)

    Money.add_rate("USD","EUR", 0.5)
    Money.add_rate("EUR","USD", 2)
  end

  def create_money iso_code, count = 6, options = {step: 500}
    step = options[:step]
    count.times do |n|
      Product.create :price => Money.new(n * step, iso_code)
    end
  end

  it "should be searchable by price using gte and a money value of different currency" do
    create_money 'USD'
    ...

  it "should respect the currency information when using comparison operators" do
    create_money 'USD'
    create_money 'EUR'
    ...

With logging output to STDOUT you can clearly track the way the query is being built with an $or for each currency supported :)

I like your solution so far. I just wish that your origin additions would be added to origin soon in some form in order to support this kind of scenario, also for other custom field types.

For now, I think it would make sense to monkey-patch origin from with money-mongoid instead of referencing your specific fork of origin.

Good job!

Kris

kristianmandrup commented 11 years ago

My suggestion:

# money/mongoid/3x/origin/selectable.rb

# encoding: utf-8
module Origin

  # An origin selectable is selectable, in that it has the ability to select
  # document from the database. The selectable module brings all functionality
  # to the selectable that has to do with building MongoDB selectors.
  module Selectable

    private

    # Create the standard expression query.
    #
    # @api private
    #
    # @example Create the selection.
    #   selectable.expr_query(age: 50)
    #
    # @param [ Hash ] criterion The field/value pairs.
    #
    # @return [ Selectable ] The cloned selectable.
    #
    # @since 1.0.0
    def expr_query(criterion)
      selection(criterion) do |selector, field, value|
        if (field.is_a? Key) && custom_serialization?(field.name, field.operator)
          specified = custom_specify(field.name, field.operator, value)
        else
          specified = field.specify(value.__expand_complex__, negating?)
        end
        selector.merge!(specified)
      end
    end

    def custom_serialization?(name, operator)
      serializer = @serializers[name.to_s]
      serializer && serializer.type.respond_to?(:custom_serialization?) && serializer.type.custom_serialization?(operator)
    end

    def custom_specify(name, operator, value)
      serializer = @serializers[name.to_s]
      raise RuntimeError, "No Serializer found for field #{name}" unless serializer
      serializer.type.custom_specify(name, operator, value, serializer.options)
    end
  end
end
# money/mongoid/3x/money.rb

...

Mongoid::Fields.option :compare_using do |model, field, value|
  value.each do |iso_code|
    unless Money::Currency.find(iso_code)
      raise ArgumentError, "Invalid ISO currency code: #{value}" 
    end
  end
end

require 'money/mongoid/3x/origin/selectable'

Sweet :)

kristianmandrup commented 11 years ago
gem 'money'
gem 'mongoid',  "~> 3.0.0"
gem 'origin'
gem 'moped'

group :development do
  gem "rspec",    ">= 2.10"
  gem "rdoc",      ">= 3.12"
  gem "bundler",  ">= 1.1.0"
  gem "jeweler",  ">= 1.8.3"
  gem "simplecov",">= 0.5"
end
kristianmandrup commented 11 years ago

For now I've created a gem: https://github.com/kristianmandrup/origin-selectable_ext (also pushed it to rubygems)

durran commented 11 years ago

@kristianmandrup Thanks for creating that. As far as origin goes, it's a bit too use case specific to implement anything here that handles all cases at the moment.

Exoth commented 11 years ago

Multi-currency queries are very useful. Other custom types could use the feature as well, I think. So it would be great if this feature is implemented in some way.