lib/reactive_record/server_data_cache.rb
module ReactiveRecord
# the point is to collect up a all records needed, with whatever attributes were required + primary key, and inheritance column
# or get all scope arrays, with the record ids
# the incoming vector includes the terminal method
# output is a hash tree of the form
# tree ::= {method => tree | [value]} | method's value is either a nested tree or a single value which is wrapped in array
# {:id => primary_key_id_value} | if its the id method we leave the array off because we know it must be an int
# {integer => tree} for collections, each item retrieved will be represented by its id
#
# example
# {
# "User" => {
# ["find", 12] => {
# :id => 12
# "email" => ["mitch@catprint.com"]
# "todos" => {
# "active" => {
# 123 =>
# {
# id: 123,
# title: ["get fetch_records_from_db done"]
# },
# 119 =>
# {
# id: 119
# title: ["go for a swim"]
# }
# ]
# }
# }
# }
# }
# }
# }
# To build this tree we first fill values for each individual vector, saving all the intermediate data
# when all these are built we build the above hash structure
# basic
# [Todo, [find, 123], title]
# -> [[Todo, [find, 123], title], "get fetch_records_from_db done", 123]
# [User, [find_by_email, "mitch@catprint.com"], first_name]
# -> [[User, [find_by_email, "mitch@catprint.com"], first_name], "Mitch", 12]
# misses
# [User, [find_by_email, "foobar@catprint.com"], first_name]
# nothing is found so nothing is downloaded
# prerendering may do this
# [User, [find_by_email, "foobar@catprint.com"]]
# which will return a cache object whose id is nil, and value is nil
# scoped collection
# [User, [find, 12], todos, active, *, title]
# -> [[User, [find, 12], todos, active, *, title], "get fetch_records_from_db done", 12, 123]
# -> [[User, [find, 12], todos, active, *, title], "go for a swim", 12, 119]
# collection with nested belongs_to
# [User, [find, 12], todos, *, team]
# -> [[User, [find, 12], todos, *, team, name], "developers", 12, 123, 252]
# [[User, [find, 12], todos, *, team, name], nil, 12, 119] <- no team defined for todo<119> so list ends early
# collections that are empty will deliver nothing
# [User, [find, 13], todos, *, team, name] # no todos for user 13
# evaluation will get this far: [[User, [find, 13], todos], nil, 13]
# nothing will match [User, [find, 13], todos, team, name] so nothing will be downloaded
# aggregate
# [User, [find, 12], address, zip_code]
# -> [[User, [find, 12], address, zip_code]], "14622", 12] <- note parent id is returned
# aggregate with a belongs_to
# [User, [find, 12], address, country, country_code]
# -> [[User, [find, 12], address, country, country_code], "US", 12, 342]
# collection * (for iterators etc)
# [User, [find, 12], todos, overdue, *all]
# -> [[User, [find, 12], todos, active, *all], [119, 123], 12]
# [Todo, [find, 119], owner, todos, active, *all]
# -> [[Todo, [find, 119], owner, todos, active, *all], [119, 123], 119, 12]
class ServerDataCache
def initialize(acting_user, preloaded_records)
@acting_user = acting_user
@cache = []
@requested_cache_items = []
@preloaded_records = preloaded_records
end
if RUBY_ENGINE != 'opal'
def [](*vector)
root = CacheItem.new(@cache, @acting_user, vector[0], @preloaded_records)
vector[1..-1].inject(root) { |cache_item, method| cache_item.apply_method method if cache_item }
vector[0] = vector[0].constantize
new_items = @cache.select { | cache_item | cache_item.root == root }
@requested_cache_items += new_items
new_items.last.value if new_items.last
end
def self.[](models, associations, vectors, acting_user)
result = nil
ActiveRecord::Base.transaction do
cache = new(acting_user, ReactiveRecord::Base.save_records(models, associations, acting_user, false, false))
vectors.each { |vector| cache[*vector] }
result = cache.as_json
raise ActiveRecord::Rollback
end
result
end
def clear_requests
@requested_cache_items = []
end
def as_json
@requested_cache_items.inject({}) do | hash, cache_item|
hash.deep_merge! cache_item.as_hash
end
end
def select(&block); @cache.select █ end
def detect(&block); @cache.detect █ end
def inject(initial, &block); @cache.inject(initial) █ end
class CacheItem
attr_reader :vector
attr_reader :absolute_vector
attr_reader :record_chain
attr_reader :root
attr_reader :acting_user
def value
@ar_object
end
def method
vector.last
end
def self.new(db_cache, acting_user, klass, preloaded_records)
klass_constant = klass.constantize
if existing = db_cache.detect { |cached_item| cached_item.vector == [klass_constant] }
return existing
end
super
end
def initialize(db_cache, acting_user, klass, preloaded_records)
klass = klass.constantize
@db_cache = db_cache
@acting_user = acting_user
@vector = @absolute_vector = [klass]
@ar_object = klass
@record_chain = []
@parent = nil
@root = self
@preloaded_records = preloaded_records
db_cache << self
end
def apply_method_to_cache(method)
@db_cache.inject(nil) do | representative, cache_item |
if cache_item.vector == vector
if @ar_object.class < ActiveRecord::Base and @ar_object.attributes.has_key?(method)
@ar_object.check_permission_with_acting_user(acting_user, :view_permitted?, method)
end
if method == "*"
cache_item.apply_star || representative
elsif method == "*all"
cache_item.build_new_cache_item(cache_item.value.collect { |record| record.id }, method, method)
elsif method == "*count"
cache_item.build_new_cache_item(cache_item.value.count, method, method)
elsif preloaded_value = @preloaded_records[cache_item.absolute_vector + [method]]
cache_item.build_new_cache_item(preloaded_value, method, method)
elsif aggregation = cache_item.aggregation?(method)
cache_item.build_new_cache_item(aggregation.mapping.collect { |attribute, accessor| cache_item.value[attribute] }, method, method)
else
begin
cache_item.build_new_cache_item(cache_item.value.send(*method), method, method)
rescue Exception => e
if cache_item.value and cache_item.value != []
ReactiveRecord::Pry::rescued(e)
raise e, "ReactiveRecord exception caught when applying #{method} to db object #{cache_item.value}: #{e}", e.backtrace
else
representative
end
end
end
else
representative
end
end
end
def aggregation?(method)
if method.is_a?(String) && value.class.respond_to?(:reflect_on_aggregation)
aggregation = value.class.reflect_on_aggregation(method.to_sym)
if aggregation && !(aggregation.klass < ActiveRecord::Base) && value.send(method)
aggregation
end
end
end
def apply_star
if value && value.length > 0
i = -1
value.inject(nil) do | representative, ar_object |
i += 1
if preloaded_value = @preloaded_records[absolute_vector + ["*#{i}"]]
build_new_cache_item(preloaded_value, "*", "*#{i}")
else
build_new_cache_item(ar_object, "*", "*#{i}")
end
end
else
build_new_cache_item([], "*", "*")
end
end
def build_new_cache_item(new_ar_object, method, absolute_method)
new_parent = self
self.clone.instance_eval do
@vector = @vector + [method] # don't push it on since you need a new vector!
@absolute_vector = @absolute_vector + [absolute_method]
@ar_object = new_ar_object
@db_cache << self
@parent = new_parent
@root = new_parent.root
self
end
end
def apply_method(method)
if method.is_a? Array and method.first == "find_by_id"
method[0] = "find"
elsif method.is_a? String and method =~ /^\*[0-9]+$/
method = "*"
end
new_vector = vector + [method]
@db_cache.detect { |cached_item| cached_item.vector == new_vector} || apply_method_to_cache(method)
end
def jsonize(method)
# sadly standard json converts {[:foo, nil] => 123} to {"['foo', nil]": 123}
# luckily [:foo, nil] does convert correctly
# so we check the methods and force proper conversion
method.is_a?(Array) ? method.to_json : method
end
def as_hash(children = nil)
unless children
return {} if @ar_object.is_a?(Class) and (@ar_object < ActiveRecord::Base)
children = [@ar_object.is_a?(BigDecimal) ? @ar_object.to_f : @ar_object]
end
if @parent
if method == "*"
if @ar_object.is_a? Array # this happens when a scope is empty there is test case, but
@parent.as_hash({}) # does it work for all edge cases?
else
@parent.as_hash({@ar_object.id => children})
end
elsif @ar_object.class < ActiveRecord::Base and children.is_a? Hash
@parent.as_hash({jsonize(method) => children.merge({
:id => (method.is_a?(Array) && method.first == "new") ? [nil] : [@ar_object.id],
@ar_object.class.inheritance_column => [@ar_object[@ar_object.class.inheritance_column]],
})})
elsif method == "*all"
@parent.as_hash({jsonize(method) => children.first})
else
@parent.as_hash({jsonize(method) => children})
end
else
{method.name => children}
end
end
end
end
def self.load_from_json(tree, target = nil)
ignore_all = nil
# have to process *all before any other items
# we leave the "*all" key in just for debugging purposes, and then skip it below
if sorted_collection = tree["*all"]
target.replace sorted_collection.collect { |id| target.proxy_association.klass.find(id) }
end
if id_value = tree["id"] and id_value.is_a? Array
target.id = id_value.first
end
tree.each do |method, value|
method = JSON.parse(method) rescue method
new_target = nil
if method == "*all"
next # its already been processed above
elsif !target
load_from_json(value, Object.const_get(method))
elsif method == "*count"
target.instance_variable_set(:@count, value.first)
elsif method.is_a? Integer or method =~ /^[0-9]+$/
target << (new_target = target.proxy_association.klass.find(method))
elsif method.is_a? Array
if method[0] == "new"
new_target = ReactiveRecord::Base.find_by_object_id(target.base_class, method[1])
elsif !(target.class < ActiveRecord::Base)
new_target = target.send *method
# value is an array if scope returns nil, so we destroy the bogus record
new_target.destroy and new_target = nil if value.is_a? Array
else
target.backing_record.update_attribute([method], value.first)
end
elsif target.class.respond_to?(:reflect_on_aggregation) and aggregation = target.class.reflect_on_aggregation(method) and
!(aggregation.klass < ActiveRecord::Base)
target.send "#{method}=", aggregation.deserialize(value.first)
elsif value.is_a? Array
target.send "#{method}=", value.first unless method == "id" # we handle ids first so things sync nicely
elsif value.is_a? Hash and value[:id] and value[:id].first and association = target.class.reflect_on_association(method)
# not sure if its necessary to check the id above... is it possible to for the method to be an association but not have an id?
new_target = association.klass.find(value[:id].first)
target.send "#{method}=", new_target
elsif !(target.class < ActiveRecord::Base)
new_target = target.send *method
# value is an array if scope returns nil, so we destroy the bogus record
new_target.destroy and new_target = nil if value.is_a? Array
else
new_target = target.send("#{method}=", target.send(method))
end
load_from_json(value, new_target) if new_target
end
#target.save if target.respond_to? :save
rescue Exception => e
`debugger`
nil
end
end
end