ruby-on-railsrubymoduleclass-evalruby-2.6

Ruby: How to eager load class contents before a module gets loaded in its parent class


I've got a few classes with a constant SCHEMA

class Consumable::ProbeDesign < Consumable
  SCHEMA = {
    "type": "object",
    "properties": {  },
    "required": []
  }
end

class DataModule::WaterDeprivationLog < DataModule
  SCHEMA = {
    "type": "object",
    "properties": {
      "water_amount":         {"type": "decimal"},
      "notes":                {"type": "string"}
    },
    "required": []
  }
end

which are children of a base class in an STI scheme


class Consumable < ApplicationRecord
  include SingleTableInheritable
end

class DataModule < ApplicationRecord
  include SingleTableInheritable
end

and then I have a module which needs to access that constant dynamically for all of the classes inherited from classes which include the module

module SingleTableInheritable
  extend ActiveSupport::Concern

  included do
    def self.inherited(subclass)
      subclass.class_eval do
        schema = subclass::SCHEMA # NameError: uninitialized constant #<Class:0x0000560848920be8>::SCHEMA
        # then do some validations that rely on that schema value
      end

      super
    end
  end
end

But at the time of execution and within the context of how it is called it cannot find the module and returns NameError: uninitialized constant #<Class:0x0000560848920be8>::SCHEMA

Note that the subclass.const_get("SCHEMA") also fails

edit: This is an loading order problem. Right after this runs on a class, the constant is available because the class is then loaded. But by trying to eager load this class, the module gets inherited from the parent class on eager load and the module code still runs before the constant is set.

Is there some kind of hook like inherited but that allows everything to preload?


Solution

  • The issue here is really that Module#included will always be run before the the body of the subclass is evaluated. But Module#included is not the only way to add validations or callbacks.

    Your can define your own "hook" instead:

    module SingleTableInheritance
      extend ActiveSupport::Concern
      class_methods do
        def define_schema(hash)
          class_eval do
            const_set(:SCHEMA, hash)
            if self::SCHEMA["type"] == "object" 
              validates :something ...
            end
          end
        end
      end
    end
    

    define_schema is just a plain old class method that opens up the eigenclass. This is the same pattern that's used everywhere in Rails and Ruby in general for everything from generating setters and getters to validations and even callbacks.

    Usage:

    class DataModule::WaterDeprivationLog < DataModule
      define_schema({
        type: "object",
        properties: {
          water_amount:         { type: "decimal"},
          notes:                { type: "string"}
        },
        required: []
      })
    end
    

    You should also be aware that the "short" hash syntax which you are using coerces the keys into symbols:

    irb(main):033:0> {"foo": "bar" }
    => {:foo=>"bar"}
    

    If you want to have strings as keys use hashrockets => instead. {"foo": "bar" } is regarded as bad form as the intent is very unclear.