9

I'm trying to add some custom methods to ActiveRecord. I want to add a *_after and *_before scopes for every date field of a model so I can do something like this:

User.created_at_after(DateTime.now - 3.days).created_at_before(DateTime.now)

I've followed the solution explained here Rails extending ActiveRecord::Base but when I execute the rails console and try to call the methods I get an undefined method error.

Here's my code:

# config/initializers/active_record_date_extension.rb
require "active_record_date_extension"

# lib/active_record_date_extension.rb
module ActiveRecordDateExtension
  extend ActiveSupport::Concern

  included do |base|
    base.columns_hash.each do |column_name,column|
      if ["datetime","date"].include? column.type
        base.define_method("#{column_name}_after") do |date|
          where("#{column_name} > ?", date)
        end
        base.define_method("#{column_name}_before") do |date|
          where("#{column_name} < ?", date)
        end
      end
    end
  end
end
Rails.application.eager_load!

ActiveRecord::Base.descendants.each do |model|
  model.send(:include, ActiveRecordDateExtension)
end

What am I doing wrong?

Community
  • 1
  • 1
Simon Soriano
  • 803
  • 12
  • 19
  • Do you mind posting the stack trace? As well as the result of `ActiveRecordDateExtension.instance_methods`? – Jeremy Rodi Aug 20 '15 at 18:21
  • @JeremyRodi The rails console runs normally. I get an `undefined method` error when I try to call, for example, `User.created_at_before(DateTime.now)` NoMethodError: undefined method 'created_at_before' for #.... Here is the output for `instance_methods` and `methods`. But take into account that I'm trying to define class methods. `ActiveRecordDateExtension.instance_methods => []` `ActiveRecordDateExtension.methods(false) => []` – Simon Soriano Aug 20 '15 at 19:29

2 Answers2

4

Using Rails 4.1.9 and Ruby 2.2.1, I noticed a few issues with the code above.

  1. You are comparing column.type with strings, and Rails returns symbols for that attribute.
  2. base.define_method is trying to call a private method, you can get around that with send

This is the tweaked code

module ActiveRecordDateExtension
  extend ActiveSupport::Concern

  included do |base|
    base.columns_hash.each do |column_name,column|      
      if [:datetime, :date].include? column.type              
        base.class.send(:define_method, "#{column_name}_after") do |date|
          where("#{column_name} > ?", date)
        end
        base.class.send(:define_method, "#{column_name}_before") do |date|
          where("#{column_name} < ?", date)
        end
      end
    end
  end
end
yez
  • 2,368
  • 9
  • 15
  • Why is needed `base.class`instead of just `base` ? There's a problem using `base.class`: Every class now has the methods. Is there a way to avoid that, and just define those methods for ActiveRecord::Base descendants ? – Simon Soriano Aug 20 '15 at 20:58
  • I believe you wanted `User.created_at_after`, correct? So that is why the `base.class` must be where you define the method. If you simply define it on `base`, it becomes an instance method instead of a class method. – yez Aug 20 '15 at 21:36
2

Thanks to the previous answer I realised part of the problems. Here are all the problems and the solution that I came to after some research:

  1. column.type is a symbol and I was comparing it with a String.
  2. base.define_method is a private method
  3. I had to define the methods in the singleton_class, not in the base class nor the class.
  4. Rails.application.eager_load! will cause eager load even when it is not required. This wasn't affecting the functionality but in first place the eager load should not be responsibility of this "extension" and in second place it depends in Rails, making the "extension" only Rails compatible.

Taking into account these problems I decided to implement it using the method_missing functionality of ruby and I wrote this gem (https://github.com/simon0191/date_supercharger). Here is the relevant part for this question:

module DateSupercharger
  extend ActiveSupport::Concern

  included do
    def self.method_missing(method_sym, *arguments, &block)
      return super unless descends_from_active_record? 
      matcher = Matcher.new(self,method_sym)
      # Inside matcher
      # method_sym.to_s =~ /^(.+)_(before|after)$/

      if matcher.match?
        method_definer = MethodDefiner.new(self) # self will be klass inside Matcher
        method_definer.define(attribute: matcher.attribute, suffix: matcher.suffix)
        # Inside MethodDefiner
        # new_method = "#{attribute}_#{suffix}"
        # operators = { after: ">", before: "<" }
        # klass.singleton_class.class_eval do
        #   define_method(new_method) do |date|
        #     where("#{attribute} #{operators[suffix]} ?", date)
        #   end
        # end
        send(method_sym, *arguments)
      else
        super
      end
    end

    def self.respond_to?(method_sym, include_private = false)
      return super unless descends_from_active_record?
      if Matcher.new(self,method_sym).match?
        true
      else
        super
      end
    end
  end
end
ActiveRecord::Base.send :include, DateSupercharger
Simon Soriano
  • 803
  • 12
  • 19