Improve an activerecord wrapper
Context
We have a gem wraps around activerecord, which provide some function like.
class Attribute
belongs_to :attribute_type
def as_json(**options)
super(options.merge(except: %i[id created_at updated_at])).tap do |h|
h['id'] = fancize(id)
end
end
end
class AttributeType
def type_info
'info_ae_different_betweens_types'
end
end
class Query
def initialize(tenant_id)
@tenant_id = tenant_id
end
def business1
query = root_scope
query = query.scope1.scope2
# Add some more magic and complexities to make problem more serious
query.as_json
end
private
def root_scope
Attribute.where(tenant_id: @tenant_id)
end
end
Then use Query.new(1).business1.
Today, I want to have type_info optionally in Query.new(1).business1 result. It first may quite straightforward, add to the business1 method.
class Query
...
def business1(includes: :type_info)
query = root_scope
query = query.scope1.scope2
# Add some more magic and complexities to make problem more serious
query.as_json(include: :type_info)
end
...
end
Well, type_info is a method, not attribute. So we continue patch the as_json
class Attribute
...
def as_json(**options)
# Exclude `type_info` in `options`
super(options.merge(except: %i[id created_at updated_at])).tap do |h|
h['id'] = fancize(id)
h['type_info'] = attribute_type.type_info if options[:include] == :type_info
end
end
end
It works, but with 2 drawbacks.
business2has a ton of params,optionswill be pushed to the tail.- N+1, as we load
attribute_typeeverytime.
So, we need one more includes. And why not adding a little more magic, render type_info if it’s ready, no matter what as_json options.
class Query
def initialize(tenant_id, includes = [])
@tenant_id = tenant_id
@includes = includes
end
def include(includes)
new(@tenant_id, @includes | Array(includes))
end
def business1
...
query = query.includes(:attribute_type) if @includes.include?(:type_info)
...
query.as_json
end
...
end
class Attribute
...
def as_json(**options)
super(options.merge(except: %i[id created_at updated_at])).tap do |h|
h['id'] = fancize(id)
# TIL
if association(:attribute_type).loaded?
h['type_info'] = attribute_type.type_info
end
end
end
end
Using association(:attribute_type).loaded? to check if it’s loaded by includes. We could use attribute_type.loaded? but it like tricking my mind, if we called attribute_type, mean we had it already. Now, if we don’t use type_info, just call Query.new(1).business1 like normal, if we need type_info, just includes like rails syntax Query.new(1).includes(:type_info).business1.
Bonus
Along with association, I found another hidden hem alias_attribute :new_attribute, :exist_attribute, which could helpful if we want to render attribute_type in Attribute#as_json with a different name.
class Attribute
belongs_to :attribute_type
alias_attribute :type_info, :attribute_type
end
attribute.as_json(include: :type_info)
attribute.as_json(include: {type_info: {...}})
But this approach doesn’t provide much customization, just exclude, only or nested some fields. Another disadvantage, we couldn’t use it with includes to preload ;(
Note
To enable rails sql log on console
To make sure includes works
ActiveRecord::Base.logger = Logger.new(STDOUT)
Rails 6
ActiveRecord::Base.verbose_query_logs = true
Sometime, need lower log level
Rails.logger.level = 0