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!
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.
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
Related
I wish to use a method in the controller:
class Hash
def sort_by_array a; Hash[sort_by{|k, _| a.index(k) || length}] end
end
But after placing the code in the controller, I receive an error: class definition in method body
I tried removing the class Hash, and second end, and have also tried
class Hash
def self.class.sort_by_array a; Hash[sort_by{|k, _| a.index(k) || length}] end
end
But I still can't get it to stop erroring
For reference, here is the controller:
class StaticPagesController < ApplicationController
def main
class Hash
def self.class.sort_by_array a; Hash[sort_by{|k, _| a.index(k) || length}] end
end
#languages = Listing.group_by(&:language)
#languages.sort_by_array(#languages)
end
end
That error occurs when you define a class inside the method of another class. i.e. you were probably doing something like below:
class SomeClass
def some_method
class Hash
def sort_by_array(a)
end
end
end
end
Assuming you want to extend the functionality of Hash objects by adding a method sort_by_array, then you can do monkey-patching like below:
Solution (Simple):
you can only define "instance" methods. If you want to define also "class" methods, see "Advanced" solution below.
lib/extensions/hash.rb
module Extensions
module Hash
def sort_by_array(a)
sort_by do |k, _|
a.index(k) || length
end
end
end
end
i.e. let's say another class you want to extend functionality:
lib/extensions/active_record/base.rb
module Extensions
module ActiveRecord
module Base
def say_hello_world
puts 'Hello World!'
end
end
end
end
config/initializers/extensions.rb
Hash.include Extensions::Hash
ActiveRecord::Base.include Extensions::ActiveRecord::Base
Usage:
# rails console
some_array = [:a, :c, :b]
some_hash = { a: 1, b: 2, c: 3 }
some_hash.sort_by_array(some_array)
# => [[:a, 1], [:c, 3], [:b, 2]]
user = User.find(1)
user.say_hello_world
# => 'Hello World!'
Solution (Advanced):
now allows both "class" and "instance" methods to be defined:
lib/extensions/hash.rb
module Extensions
module Hash
def self.included(base)
base.extend ClassMethods
base.include InstanceMethods
end
# define your Hash "class methods" here inside ClassMethods
module ClassMethods
# commented out because not yet fully working (check update later)
# # feel free to remove this part (see P.S. for details)
# def self.extended(base)
# instance_methods.each do |method_name|
# raise NameError, "#{method_name} method already defined!" if (base.singleton_methods - instance_methods).include? method_name
# end
# end
end
# define your Hash "instance methods" here inside InstanceMethods
module InstanceMethods
# commented out because not yet fully working (check update later)
# # feel free to remove this part (see P.S. for details)
# def self.included(base)
# instance_methods.each do |method_name|
# raise NameError, "#{method_name} method already defined!" if (base.instance_methods - instance_methods).include? method_name
# end
# end
def sort_by_array(a)
sort_by do |k, _|
a.index(k) || length
end
end
end
end
end
i.e. let's say another class you want to extend functionality:
lib/extensions/active_record/base.rb
module Extensions
module ActiveRecord
module Base
def self.included(base)
base.extend ClassMethods
base.include InstanceMethods
end
module ClassMethods
# commented out because not yet fully working (check update later)
# # feel free to remove this part (see P.S. for details)
# def self.extended(base)
# instance_methods.each do |method_name|
# raise NameError, "#{method_name} method already defined!" if (base.singleton_methods - instance_methods).include? method_name
# end
# end
def say_hello_mars
puts 'Hello Mars!'
end
end
module InstanceMethods
# commented out because not yet fully working (check update later)
# # feel free to remove this part (see P.S. for details)
# def self.included(base)
# instance_methods.each do |method_name|
# raise NameError, "#{method_name} method already defined!" if (base.instance_methods - instance_methods).include? method_name
# end
# end
def say_hello_world
puts 'Hello World!'
end
end
end
end
end
config/initializers/extensions.rb
Hash.include Extensions::Hash
ActiveRecord::Base.include Extensions::ActiveRecord::Base
Usage:
# rails console
some_array = [:a, :c, :b]
some_hash = { a: 1, b: 2, c: 3 }
some_hash.sort_by_array(some_array)
# => [[:a, 1], [:c, 3], [:b, 2]]
user = User.find(1)
user.say_hello_world
# => 'Hello World!'
ActiveRecord::Base.say_hello_mars
# => 'Hello Mars!'
P.S, arguably you won't need to raise an error if a method is already defined, but this is just my personal taste to prevent "bugs" (i.e. if for example some "gems" you used also defined same exact methods name but having different functionality, of which you have no control of). Feel free to remove them though.
Place it in a separate file. This would extend the base class Hash and would allow you to use in the whole application.
The easiest would be putting the code into config/initializers/hash.rb and restarting the server.
Or, better, similarly to how Rails does it (e.g. https://github.com/rails/rails/tree/master/activesupport/lib/active_support/core_ext):
Put your code here: lib/core_ext/hash/sort_by_array.rb
And then either add this path to autoload, or require it manually from where you'd like to use it, like this:
require "core_ext/hash/sort_by_array".
currently I have a module like this:
module MyModule
def A
end
.....
end
and I have a model that I want to use that method A as a class method. However, the thing is I only need that A method. If I extend it, I am gonna extend the other unnecessary class methods into my model. Therefore, is there a way for me to do sth like MyModule.A without rewriting the module like this:
module MyModule
def A
...
end
def self.A
...
end
.....
end
It is kind of repeating myself if I do it that way. I still feel there is a better way to do it in Rails.
Use Module#module_function to make a single function to be a module function:
module M
def m1; puts "m1"; end
def m2; puts "m2"; end
module_function :m2
end
or:
module M
def m1; puts "m1"; end
module_function # from now on all functions are defined as module_functions
def m2; puts "m2"; end
end
M.m1 #⇒ NoMethodError: undefined method `m1' for M:Module
M.m2 #⇒ "m2"
Yes, you can define it as a module_function, then you should be able to access it using module name.
Ex:
module Mod
def my_method
100
end
def self.my_method_1
200
end
module_function :my_method
end
Mod.my_method
# => 100
Mod.my_method_1
# => 200
Note: No need to add the self defined methods in module_function, they are accessible directly. But it's needed for methods defined without self
I'm trying to understand some Ruby metaprogramming concepts.
I think I understand classes, objects, and metaclasses. Unfortunately, I'm very unclear on exactly what happens with included Modules with respect to their instance/'class' variables.
Here's a contrived question whose solution will answer my questions:
Suppose I'm writing my own crappy Rails "validates" method, but I want it to come from a mixed-in module, not a base class:
module MyMixin
# Somehow validates_wordiness_of() is defined/injected here.
def valid?
# Run through all of the fields enumerated in a class that uses
# "validate_wordiness_of" and make sure they .match(/\A\w+\z/)
end
end
class MyClass
include MyMixin
# Now I can call this method in my class definition and it will
# validate the word-ness of my string fields.
validate_wordiness_of :string_field1, :string_field2, :string_field3
# Insert rest of class here...
end
# This should work.
MyMixin.new.valid?
Ok, so how would you store that list of fields from the validate_wordiness_of invocation (in MyClass) in such a way that it can be used in the valid? method (from MyMixin)?
Or am I coming at this all wrong? Any info would be super appreciated!
So here are two alternative ways of doing it:
With "direct" access
module MyMixin
def self.included(base)
base.extend(ClassMethods)
end
def wordy?(value)
value.length > 2
end
module ClassMethods
def validates_wordiness_of(*attrs)
define_method(:valid?) do
attrs.all? do |attr|
wordy?(send(attr))
end
end
end
end
end
class MyClass
include MyMixin
validates_wordiness_of :foo, :bar
def foo
"a"
end
def bar
"asrtioenarst"
end
end
puts MyClass.new.valid?
The downside to this approach is that several consecutive calls to validates_wordiness_of will overwrite each other.
So you can't do this:
validates_wordiness_of :foo
validates_wordiness_of :bar
Saving validated attribute names in the class
You could also do this:
require 'set'
module MyMixin
def self.included(base)
base.extend(ClassMethods)
end
module Validation
def valid?
self.class.wordy_attributes.all? do |attr|
wordy?(self.send(attr))
end
end
def wordy?(value)
value.length > 2
end
end
module ClassMethods
def wordy_attributes
#wordy_attributes ||= Set.new
end
def validates_wordiness_of(*attrs)
include(Validation) unless validation_included?
wordy_attributes.merge(attrs)
end
def validation_included?
ancestors.include?(Validation)
end
end
end
class MyClass
include MyMixin
validates_wordiness_of :foo, :bar
def foo
"aastrarst"
end
def bar
"asrtioenarst"
end
end
MyClass.new.valid?
# => true
I chose to make the valid? method unavailable until you actually add a validation. This may be unwise. You could probably just have it return true if there are no validations.
This solution will quickly become unwieldy if you introduce other kinds of validations. In that case I would start wrapping validations in validator objects.
In my rails projects, I often use this sort of behavior in my classes and models:
class Whatever
class WhateverError < StandardError; end
def initialize(params={})
raise WhateverError.new("Bad params: #{params}") if condition
# actual class code to follow
end
end
The trouble is, this is both hugely repetitive and fairly verbose. I'd love it if I could just do this whenever I need to raise a class-specific error:
class ErrorRaiser
include ClassErrors
def initialize(params={})
error("Bad params: #{params}") if condition
error if other_condition # has default message
# actual class code to follow
end
def self.class_method
error if third_condition # class method, behaves identically
end
end
I'm having major trouble creating such a module. My sad early attempts have tended to look something like the below, but I'm pretty confused about what's available within the scope of the module, how to dynamically create classes (within methods?) or whether I have straightforward access to the "calling" class at all.
My basic requirements are that error be both a class method and an instance method, that it be "namespaced" to the class calling it, and that it have a default message. Any thoughts/help? Is this even possible?
module ClassErrorable
# This and the "extend" bit (theoretically) allow error to be a class method as well
module ClassMethods
def self.error(string=nil)
ClassErrorable.new(string).error
end
end
def self.included(base)
set_error_class(base)
base.extend ClassMethods
end
def self.set_error_class(base)
# I'm shaky on the scoping. Do I refer to this with # in a class method
# but ## in an instance method? Should I define it here with # then?
##error_class = "##{base.class}Error".constantize
end
def self.actual_error
# This obviously doesn't work, and in fact,
# it raises a syntax error. How can I make my
# constant a class inheriting from StandardError?
##actual_error = ##error_class < StandardError; end
end
def initialize(string)
#string = string || "There's been an error!"
end
def error(string=nil)
raise ##actual_error.new(string)
end
end
How about something like this (written in pure Ruby; it could be refactored to use some Rails-specific features like .constantize):
module ClassErrorable
module ClassMethods
def error(message = nil)
klass = Object::const_get(exception_class_name)
raise klass.new(message || "There's been an error!")
end
def exception_class_name
name + 'Error'
end
end
def self.included(base)
base.extend ClassMethods
Object::const_set(base.exception_class_name, Class.new(Exception))
end
def error(message = nil)
self.class.error(message)
end
end
I use the following method_missing implementation to give a certain model an adaptable named_scope filtering:
class Product < ActiveRecord::Base
def self.method_missing(method_id, *args)
# only respond to methods that begin with 'by_'
if method_id.to_s =~ /^(by\_){1}\w*/i
# extract column name from called method
column = method_id.to_s.split('by_').last
# if a valid column, create a dynamic named_scope
# for it. So basically, I can now run
# >>> Product.by_name('jellybeans')
# >>> Product.by_vendor('Cyberdine')
if self.respond_to?( column.to_sym )
self.send(:named_scope, method_id, lambda {|val|
if val.present?
# (this is simplified, I know about ActiveRecord::Base#find_by_..)
{ :conditions => ["#{base.table_name}.#{column} = ?", val]}
else
{}
end
})
else
super(method_id, args)
end
end
end
end
I know this is already provided by ActiveRecord::Base using find_by_<X>, but I'm trying to go a little bit beyond the example I've given and provide some custom filtering taylored to my application. I'd like to make it available to selected models w/o having to paste this snippet in every model class. I thought of using a module and then mixing it in the models of choice - I'm just a bit vague on the syntax.
I've gotten as far as this when the errors started piling up (am I doing this right?):
module GenericFilter
def self.extended(base)
base.send(:method_missing, method_id, *args, lambda { |method_id, args|
# ?..
})
end
end
Then I hope to be able to use it like so:
def Product < ActiveRecord::Base
include GenericFilter
end
def Vendor < ActiveRecord::Base
include GenericFilter
end
# etc..
Any help will be great - thanks.
Two ways of achieving this
module GenericModule
def self.included(base)
base.extend ClassMethods
end
module ClassMethods
def methods_missing
#....
end
end
end
class YourModel
include GenericModule
..
end
or
module GenericModule
def method_missing
#...
end
end
class MyModel
extend GenericModule
end
I would suggest using the first one, its seems cleaner to me. And as general advise, I'd avoid overriding method_missing :).
Hope this helps.
You need to define the scope within the context of the class that is including your mixin. Wrap your scopes in including_class.class_eval and self will be correctly set to the including_class.
module Mixin
def self.included(klass)
klass.class_eval do
scope :scope_name, lambda {|*args| ... }
end
end
end
class MyModel
include Mixin
end