Search code examples
ruby-on-railsrubyoracle-databasescopes

Rails model with conditional scope depending on database


I'm implementing a scope for a model based on this railscast. The condition in the scope uses the binary AND operator &, like this:

scope :with_role, lambda { |role| 
  {:conditions => "roles_mask & #{2**ROLES.index(role.to_s)} > 0"} 
}

Since the database I'm currently targetting is Oracle, which uses the BITAND function instead of the & operator, I've rewritten the condition like this:

{:conditions => "BITAND(roles_mask, #{2**ROLES.index(role.to_s)}) > 0"}

My problem is, I would like to keep my code as database agnostic as possible, since in the future we are planning on targetting other databases. My current solution has been to check whether I'm using Oracle and define the scope accordingly, like this (using_oracle is a boolean that I calculate elsewhere):

if using_oracle 
  scope :with_role, lambda { |role| 
    {:conditions => "BITAND(roles_mask, #{2**ROLES.index(role.to_s)}) > 0"}
  }
else
  scope :with_role, lambda { |role| 
    {:conditions => "roles_mask & #{2**ROLES.index(role.to_s)} > 0"}
  }
end

This works, but doesn't strike me as particularly elegant or ruby/rails-like. Could anyone kindly suggest better alternatives?


Solution

  • I think there should be a better way to extend Arel: I struggled to reach this result.

    Anyway; this solution uses Model#extending:

    module BitOperations
      def bitwise_and_sql
        @bitwise_and_sql ||=
          case connection.adapter_name
          when 'Oracle' # probably wrong!
            "BITAND(%s, %s)"
          else
            "%s & %s"
          end
      end
      def bitwise_and(i, j)
        where(bitwise_and_sql % [i, j])
      end
      def bitmask(i, j)
        where('%s > 0' % scoped.bitwise_and(i, j).wheres.to_a.last.to_sql)
      end
    end
    
    p User.scoped.extending(BitOperations).bitwise_and(1, 2).bitmask(3, 4).to_sql
    #=> "SELECT \"users\".* FROM \"users\"  WHERE (1 & 2) AND ((3 & 4) > 0)"
    

    .wheres contains the Arel relations; it includes Enumerable, so we can retrieve the last relation converting it to array and taking the last element. I used it in order to get the sql of bitwise_and(i, j) in order to use it in bitmask(i, j). I wonder if there is a better way to get the sql from a where...

    .wheres raises a warning about wheres deprecation, which can be ignored at the moment (It works on Rails 4 beta too).

    You can define class methods for User:

    class User
      def self.scope_with_bit_operations
        @scope_with_bit_operations ||= scoped.extending(BitOperations)    
      end
      def self.bitwise_and(i, j)
        scope_with_bit_operations.bitwise_and(i, j)
      end
      def self.bitmask(i, j)
        scope_with_bit_operations.bitmask(i, j)
      end
    end
    
    p User.bitwise_and(1, 2).bitmask(3, 4).to_sql
    #=> "SELECT \"users\".* FROM \"users\"  WHERE (1 & 2) AND ((3 & 4) > 0)"
    

    or for all your models:

    class ActiveRecord::Base
      def self.scope_with_bit_operations
        @scope_with_bit_operations ||= scoped.extending(BitOperations)    
      end
      def self.bitwise_and(i, j)
        scope_with_bit_operations.bitwise_and(i, j)
      end
      def self.bitmask(i, j)
        scope_with_bit_operations.bitmask(i, j)
      end
    end
    
    p Post.bitwise_and(1, 2).bitmask(3, 4).to_sql
    #=> "SELECT \"posts\".* FROM \"posts\"  WHERE (1 & 2) AND ((3 & 4) > 0)"
    

    And finally you can implement a slightly more elegant with_role scope:

    class User < ActiveRecord::Base
      ROLES = %w[admin moderator author]
    
      scope :with_role, ->(role) do
        # I'm a fan of quoting everything :-P
        bitmask connection.quote_column_name(:roles_mask),
                connection.quote(2**ROLES.index(role.to_s))
      end
    end
    
    p User.with_role('admin').to_sql
    #=> "SELECT \"users\".* FROM \"users\"  WHERE ((\"roles_mask\" & 1) > 0)"
    

    I must say that IMO this is more a proof-of-concept: if you don't plan to reuse bitwise_and and bitmask in other models you don't need to abstract them, so probably you're good to go with something similar to your scope, f.e. something like this:

    class User < ActiveRecord::Base
      ROLES = %w[admin moderator author]
    
      BITMASK_SQL =
        case connection.adapter_name
        when 'Oracle' # probably wrong!
          "BITAND(%s, %s) > 0"
        else
          "%s & %s > 0"
        end
    
      scope :with_role, ->(role) do
        where BITMASK_SQL % [ connection.quote_column_name(:roles_mask), 
                              connection.quote(2**ROLES.index(role.to_s)) ]
      end
    end
    
    p User.with_role('admin').to_sql
    #=> "SELECT \"users\".* FROM \"users\"  WHERE (\"roles_mask\" & 1 > 0)"
    

    I think the rule is add abstractions when you need of them, do not when you don't need of (I don't know if the english of this phrase is correct :-) )

    I want to say another thing: since you are new to Ruby/Rails, I suggest you to read a lot of Rails & c. code; IMO it is the best way to learn how Rails works (this is the reason I spent my time to answer to your question: because I was curious about Rails management of Arel relations :-) ).