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.
It turns out the answer is really stupid.
all
is returning an Array of objects. Elsewhere in the class, we have this method:If
filtered_objects
==all
, and we callselect!
onfiltered_objects
, what would happen?Yup. It modified the object references directly. Making
all
return a.dup
'd version of the Array solves the problem.