Memoized class variable returning inconsistent values

944 Views Asked by At

I'm really sorry in advance if this question is going to look messy; I'll do my best to make it concise.

I am building a class that emulates an ActiveRecord model, but gets its data from a service called Airtable, not a database. Airtable is like a cross between Excel and a database - it lets you create a spreadsheet of data but supports "foreign keys" between different tables so you can link data between tables. This works out really nicely for an application I'm working on.

In order to make it extensible and flexible, I created a parent class, AirtableModel, which defines common methods and attributes that will be filled out when classes inherit from it. The inheriting class's name will help the parent methods access the data from the correct Airtable table and retrieve the correct attributes. The relevant bits are below (ones that aren't mentioned are self-explanatory or are inconsequential to the problem):

class AirtableModel
  def initialize(hash)
    hash.each do |attribute_name, attribute_value|
      attribute_value = self.class.first_value_from_arrays_with_singular_key_name(attribute_name, attribute_value)
      # ^^^ Airtable always returns references as Arrays. If the relationship is a belongs_to, we pull out the value from the Array.

      begin
        attribute_name_as_class = attribute_name.to_s.singularize.camelize.constantize
        # ^^^ Converts the attribute's name to a class constant. Used to make the generated method retrieve class records instead of ids. If the class doesn't exist, its NameError is caught below.
        instance_variable_set("@#{attribute_name}_airtable_ids", attribute_value)

        self.class.send(:define_method, attribute_name.to_sym) do
          result = attribute_name_as_class.find_all_by_airtable_id(instance_variable_get("@#{attribute_name}_airtable_ids"))
          result.length <= 1 ? result.first : result
        end
      rescue NameError
        # Triggered if `attribute_name_as_class` doesn't match an existing class
        instance_variable_set("@#{attribute_name}", attribute_value)
        self.class.send(:define_method, attribute_name.to_sym) do
          instance_variable_get("@#{attribute_name}")
        end
      end
    end
  end

  # Reaches out to Airtable to get all records for this class's table (the Airtable table matches the class name). Collects the resulting data into an array of Hashes.
  # One such hash might look like this:
  #   {
  #     'id' => <unique string ID assigned by Airtable>,
  #     'fields' => {
  #       'db_id' => <Unique integer ID. I added this to emulate a database record>,
  #       ...
  #     }
  #   }
  def self.airtable
    @airtable_records ||= AirtableService.records_from_table(table_name: "#{self}s").each.map do |raw|
      object_properties = raw['fields']
      object_properties['airtable_id'] = raw['id']
      object_properties['id'] = object_properties['db_id']

      Hash[object_properties.collect { |k, v| [k.snakecase.parameterize.underscore.to_sym, v] }]
      # ^^^ Converts parameter name to snake-case symbol, i.e. :db_id
    end
  end

  def self.all
    @all_records ||= airtable.map { |b| new(b) }
  end

  def self.find_by_airtable_id(airtable_id)
    objects = all.select { |b| b.airtable_id == airtable_id }
    raise "non unique airtable_id found" if objects.size > 1
    objects.first
  end

  def self.find_all_by_airtable_id(airtable_ids)
    [airtable_ids].flatten.map { |aid| find_by_airtable_id(aid) }
    # ^^^ Accomodates airtable_ids as an Array or a single value
  end

  def self.first
    all.first
  end

  def self.last
    all.last
  end
end

If anything above doesn't make sense, let me know and I'll be happy to update.

This has worked perfectly for the majority of my classes that inherit from AirtableModel, but I'm having an issue with a particular table (FooBar) which is supposed to act like a join table between two other tables. That would look something like this:

[Table Foo]                   [Table FooBar]                  [Table Bar]
fooBars <==========---------> foo     bar <---------========> fooBars

Their class definitions are very simple:

class Foo < AirtableModel
end

class FooBar < AirtableModel
end

class Bar < AirtableModel
end

Thanks to the constructors above, I can make a call like Foo.first.foo_bars and get back an Array of all FooBar instances related to this Foo. This works without issue in the console, but I am running into an issue trying the above snippet in my Rails application.

foo_bars gets called twice in a single controller create action. This happens to call self.all twice. The first time, I get the expected result back - @all_records equals the number of records I have in Airtable, with the proper attribute values, including foreign key relationships. However, the second time the method is entered, the value of @all_records changes to an empty Array. The object that invokes foo_bars hasn't changed, and still includes the correct airtable_ids which are used to look up the associated FooBar instances. @airtable_records - the return value from the self.airtable method - still has the same values too.

I'm not sure what's causing the memoized @all_records variable to change value. I've been banging my head against it, using a debugger to follow the function calls step by step, but I can't see what's causing the value to change. Can anyone provide any advice on how to debug this further? I'd greatly appreciate it.

1

There are 1 best solutions below

2
On

It turns out the answer is really stupid.

all is returning an Array of objects. Elsewhere in the class, we have this method:

def self.where(filter = {})
    filtered_objects = all

    filter.each do |filter_property, filter_value|
      # filter_value = filter_value.airtable_id if filter_value.respond_to?(:airtable_id)
      filtered_objects.select! do |object|
        object_value = object.send(filter_property)

        match_check = lambda do |value|
          if object_value.is_a?(Array)
            object_value.include?(value)
          else
            object_value == value
          end
        end

        filter_value.is_a?(Array) ? filter_value.any? { |v| match_check.call(v) } : match_check.call(filter_value)
      end
    end

    filtered_objects
  end

If filtered_objects == all, and we call select! on filtered_objects, what would happen?

Yup. It modified the object references directly. Making all return a .dup'd version of the Array solves the problem.