1

I am trying to make my life simpler inside of a large production Rails 6.0 website. I have a bunch of data that I serve from Redis as denormalized hashes, because Rails, with all the includes and associations is very very slow.

To keep things DRY, I'd like to use a Concern (or module) that can be included within ApplicationRecord that allows me to dynamically define the collection methods for the data I want to store.

This is what I have so far:

class ApplicationRecord < ActiveRecord::Base
    include DenormalizableCollection
    # ...
end
# The model
class News < ApplicationRecord
    denormalizable_collection :most_popular
    # ...
end
# The Concern
module DenormalizableCollection
  extend ActiveSupport::Concern

  class_methods do
    def denormalizable_collection(*actions)
      actions.each do |action|

        # define News.most_popular
        define_singleton_method "#{action}" do
          collection = Redis.current.get(send("#{action}_key"))

          return [] unless collection.present?

          JSON.parse(collection).map { |h| DenormalizedHash.new(h) }
        end

        # define News.set_most_popular
        define_singleton_method "set_#{action}" do
          Redis.current.set(send("#{action}_key"), send("#{action}_data").to_json)
        end

        # define News.most_popular_data, which is a method that returns an array of hashes
        define_singleton_method "#{action}_data" do
          raise NotImplementedError, "#{action}_data is required"
        end

        # define News.most_popular_key, the index key to use inside of redis
        define_singleton_method "#{action}_key" do
          "#{name.underscore}_#{action}".to_sym
        end
      end
    end
  end
end

This works, but I doesn't seems right because I cannot also define instance methods, or ActiveRecord after_commit callbacks to update the collection inside of Redis.

I'd like to add something like the following to it:

    after_commit :set_#{action}
    after_destroy :set_#{action}

But obviously these callbacks require an instance method, and after_commit :"self.class.set_most_popular" causes an error to be thrown. So I had wanted to add an instance method like the following:

class News
   # ...
   def reset_most_popular
       self.class.send("set_most_popular")
   end
end

I have been reading as many articles as I can and going through the Rails source to see what I'm missing - as I know I'm defo missing something!

5
  • 1
    define_method will define instance methods in the same manner that define_singleton_method will define class methods. Commented Nov 14, 2019 at 15:47
  • you can use the included block to define callbacks, and defined_method to define instance methods api.rubyonrails.org/classes/ActiveSupport/Concern.html Commented Nov 14, 2019 at 16:13
  • Do you mean call define_method from the class_methods block? Commented Nov 14, 2019 at 16:23
  • class_methods really does something like ClassMethods = Module.new do ... end under the covers. So when you call define_singleton_method you are defining a singleton method of the module and not the class that is extended by the module. Its like calling def self.foo inside a module. Commented Nov 14, 2019 at 16:31
  • @max, I would prefer to not use define_singleton_method but its the only way i could get it to function in a basic way. I did try with class_eval <<-CODE, __FILE__, __LINE__ + 1 ... CODE and normal define_method and neither registered the methods when I checked News.methods.include?(:most_popular) Commented Nov 14, 2019 at 17:02

2 Answers 2

1

The key here is to use class_eval to open up the class you are calling denormalizable_collection on.

A simplified example is:

class Foo
  def self.make_method(name)
    class_eval do |klass|
      klass.define_singleton_method(name) do
        name
      end 
    end
  end

  make_method(:hello)
end

irb(main):043:0> Foo.hello
=> :hello

module DenormalizableCollection
  def self.included(base)
    base.extend ClassMethods
  end

  module ClassMethods
    def denormalizable_collection(*actions)
      actions.each do |action|
        generate_denormalized_methods(action)
        generate_instance_methods(action)
        generate_callbacks(action)
      end
    end
    private
    def generate_denormalized_methods(action)

      self.class_eval do |klass|
        # you should consider if these should be instance methods instead.
        # define News.most_popular
        define_singleton_method "#{action}" do
          collection = Redis.current.get(send("#{action}_key"))
          return [] unless collection.present?
          JSON.parse(collection).map { |h| DenormalizedHash.new(h) }
        end
        # define News.most_popular
        # define News.set_most_popular
        define_singleton_method "set_#{action}" do
          Redis.current.set(send("#{action}_key"), send("#{action}_data").to_json)
        end
        # define News.most_popular_data, which is a method that returns an array of hashes
        define_singleton_method "#{action}_data" do
          raise NotImplementedError, "#{action}_data is required"
        end
        # define News.most_popular_key, the index key to use inside of redis
        define_singleton_method "#{action}_key" do
          "#{name.underscore}_#{action}".to_sym
        end
      end
    end

    def generate_callbacks(action)
      self.class_eval do
        # Since callbacks call instance methods you have to pass a
        # block if you want to call a class method instead
        after_commit -> { self.class.send("set_#{action}") }
        after_destroy -> { self.class.send("set_#{action}") }
      end
    end

    def generate_instance_methods(action)
      class_eval do
        define_method :a_test_method do
          # ...
        end
      end
    end
  end
end

Note here that I'm not using ActiveSupport::Concern. Its not that I don't like it. But in this case it adds an additional level of metaprogramming thats enough to make my head explode.

Sign up to request clarification or add additional context in comments.

5 Comments

Thank you! This is exactly what I needed. Can I just ask, when you use define_singleton_method is that the only way to define class methods. Someone mentioned that it was like defining the method on the module, rather than on the class itself.
I cannot seem to define instance methods from within the module. Using define_method within generate_denoramalized_methods doesn't seem to work but when I manually define with def testing_method it does. :-/
There is an older alternative to define_singleton_method that I can't remember. But it works perfectly fine here if you call in with class eval as the recipient is the class you are calling the generate_denormalized_methods method in and not the module which defines it. Did you try using define_method in the block?
I updated the answer with how you would generate instance methods. There was also one problem I didn't realize earlier which is that callbacks call instance methods and not class methods when you pass a symbol. I also managed to screw up the generate_callbacks method as it should not have been nested inside generate_denormalized_methods.
Thank you @max. I actually did the same after some playing. Thank you for your help with this, its very much appreciated.
0

Have you tried something like:

  class_methods do
    def denormalizable_collection(*actions)
      actions.each do |action|
        public_send(:after_commit, "send_#{action}")
        ...
      end
    end
  end

1 Comment

This doesn't seem to work unfortunately. I get NoMethodError: undefined method 'set_most_popular'.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.