require 'set' module ReactiveRecord # requested cache items I think is there just so prerendering with multiple components works. # because we have to dump the cache after each component render (during prererender) but # we want to keep the larger cache alive (is this important???) we keep track of what got added # to the cache during this cycle # 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 = [] @cache_reps = {} @requested_cache_items = Set.new @preloaded_records = preloaded_records end attr_reader :cache attr_reader :cache_reps attr_reader :requested_cache_items def add_item_to_cache(item) cache << item cache_reps[item.vector] = item requested_cache_items << item end if RUBY_ENGINE != 'opal' def self.get_model(str) # We don't want to open a security hole by allowing some client side string to # autoload a class, which would happen if we did a simple str.constantize. # # Because all AR models are loaded at boot time on the server to define the # ActiveRecord::Base.public_columns_hash method any model which the client has # access to should already be loaded. # # If str is not already loaded then we have an access violation. unless const_defined? str Hyperstack::InternalPolicy.raise_operation_access_violation(:undefined_const, "#{str} is not a loaded constant") end str.constantize end def [](*vector) timing('building cache_items') do root = CacheItem.new(self, @acting_user, vector[0], @preloaded_records) vector[1..-1].inject(root) { |cache_item, method| cache_item.apply_method method if cache_item } final = vector[1..-1].inject(root) { |cache_item, method| cache_item.apply_method method if cache_item } next final unless final && final.value.respond_to?(:superclass) && final.value.superclass <= ActiveRecord::Base Hyperstack::InternalPolicy.raise_operation_access_violation(:invalid_vector, "attempt to insecurely access relationship #{vector.last}.") end end def start_timing(&block) ServerDataCache.start_timing(&block) end def timing(tag, &block) ServerDataCache.timing(tag, &block) end def self.start_timing(&block) @timings = Hash.new { |h, k| h[k] = 0 } start_time = Time.now yield.tap do ::Rails.logger.debug "********* Total Time #{total = Time.now - start_time} ***********************" sum = 0 @timings.sort_by(&:last).reverse.each do |tag, time| ::Rails.logger.debug " #{tag}: #{time} (#{(time/total*100).to_i})%" sum += time end ::Rails.logger.debug "********* Other Time ***********************" end end def self.timing(tag, &block) start_time = Time.now tag = tag.to_sym yield.tap { @timings[tag] += (Time.now - start_time) if @timings } end def self.[](models, associations, vectors, acting_user) start_timing do timing(:public_columns_hash) { ActiveRecord::Base.public_columns_hash } result = nil ActiveRecord::Base.transaction do cache = new(acting_user, timing(:save_records) { ReactiveRecord::Base.save_records(models, associations, acting_user, false, false) }) timing(:process_vectors) { vectors.each { |vector| cache[*vector] } } timing(:as_json) { result = cache.as_json } raise ActiveRecord::Rollback, "This Rollback is intentional!" end result end end def clear_requests @requested_cache_items = Set.new 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(&block); end def detect(&block); @cache.detect(&block); end def inject(initial, &block); @cache.inject(initial) █ end class CacheItem attr_reader :vector attr_reader :absolute_vector attr_reader :root attr_reader :acting_user def value @value # which is a ActiveRecord object end def method @vector.last end def self.new(db_cache, acting_user, klass, preloaded_records) klass = ServerDataCache.get_model(klass) if existing = ServerDataCache.timing(:root_lookup) { db_cache.cache.detect { |cached_item| cached_item.vector == [klass] } } return existing end super end def initialize(db_cache, acting_user, klass, preloaded_records) @db_cache = db_cache @acting_user = acting_user @vector = @absolute_vector = [klass] @value = klass @parent = nil @root = self @preloaded_records = preloaded_records @db_cache.add_item_to_cache self end def to_s acting_user_string = if acting_user " - with acting user: <#{acting_user.class.name} id: #{acting_user.id}>" else ' - with no acting user' end vector.collect do |e| if e.is_a? String e elsif e.is_a? Array e.length > 1 ? "#{e.first}(#{e[1..-1].join(', ')})" : e.first else e.name end end.join('.') + acting_user_string rescue vector.to_s + acting_user_string end def start_timing(&block) ServerDataCache.class.start_timing(&block) end def timing(tag, &block) ServerDataCache.timing(tag, &block) end def apply_method_to_cache(method) @db_cache.cache.inject(nil) do |representative, cache_item| if cache_item.vector == vector begin # error_recovery_method holds the current method that we are attempting to apply # in case we throw an exception, and need to give the developer a meaningful message. if method == "*" # apply_star does the security check if value is present cache_item.apply_star || representative elsif method == "*all" # if we secure the collection then we assume its okay to read the ids error_recovery_method = [:all] secured_value = cache_item.value.__secure_collection_check(cache_item) cache_item.build_new_cache_item(timing(:active_record) { secured_value.collect { |record| record.id } }, method, method) elsif method == "*count" error_recovery_method = [:count] secured_value = cache_item.value.__secure_collection_check(cache_item) cache_item.build_new_cache_item(timing(:active_record) { cache_item.value.__secure_collection_check(cache_item).count }, method, method) elsif preloaded_value = @preloaded_records[cache_item.absolute_vector + [method]] # no security check needed since we already evaluated this cache_item.build_new_cache_item(preloaded_value, method, method) elsif aggregation = cache_item.aggregation?(method) # aggregations are not protected error_recovery_method = [method, :mapping, :all] cache_item.build_new_cache_item(aggregation.mapping.collect { |attribute, accessor| cache_item.value[attribute] }, method, method) else if !cache_item.value || cache_item.value.is_a?(Array) # seeing as we just returning representative, no check is needed (its already checked) representative elsif method == 'model_name' error_recovery_method = [:model_name] cache_item.build_new_cache_item(timing(:active_record) { cache_item.value.model_name }, method, method) else begin secured_method = "__secure_remote_access_to_#{[*method].first}" error_recovery_method = [*method] # order is important. This check must be first since scopes can have same name as attributes! if cache_item.value.respond_to? secured_method cache_item.build_new_cache_item(timing(:active_record) { cache_item.value.send(secured_method, cache_item.value, @acting_user, *([*method][1..-1])) }, method, method) elsif (cache_item.value.class < ActiveRecord::Base) && cache_item.value.attributes.has_key?(method) # TODO: second check is not needed, its built into check_permmissions, check should be does class respond to check_permissions... cache_item.value.check_permission_with_acting_user(@acting_user, :view_permitted?, method) cache_item.build_new_cache_item(timing(:active_record) { cache_item.value.send(*method) }, method, method) else raise "Method missing while fetching data: \`#{[*method].first}\` "\ 'was expected to be an attribute or a method defined using the server_method of finder_method macros.' end end end end rescue StandardError => e raise e.class, form_error_message(e, cache_item.vector + error_recovery_method), e.backtrace end else representative end end end def form_error_message(original_error, vector) expression = vector.collect do |exp| next exp unless exp.is_a? Array next exp.first if exp.length == 1 "#{exp.first}(#{exp[1..-1].join(', ')})" end.join('.') "raised when evaluating #{expression}\n#{original_error}" 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.__secure_collection_check(self) && @value.length > 0 i = -1 @value.inject(nil) do |representative, current_value| i += 1 if preloaded_value = @preloaded_records[@absolute_vector + ["*#{i}"]] build_new_cache_item(preloaded_value, "*", "*#{i}") else build_new_cache_item(current_value, "*", "*#{i}") end end else build_new_cache_item([], "*", "*") end end # TODO replace instance_eval with a method like clone_new_child(....) def build_new_cache_item(new_value, 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] @value = new_value @db_cache.add_item_to_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] timing('apply_method lookup') { @db_cache.cache_reps[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 merge_inheritance_column(children) if @value.attributes.key? @value.class.inheritance_column children[@value.class.inheritance_column] = [@value[@value.class.inheritance_column]] end children end def as_hash(children = nil) unless children return {} if @value.is_a?(Class) && (@value < ActiveRecord::Base) children = [@value.is_a?(BigDecimal) ? @value.to_f : @value] end if @parent if method == "*" if @value.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? elsif (@value.class < ActiveRecord::Base) && children.is_a?(Hash) @parent.as_hash({@value.id => merge_inheritance_column(children)}) else @parent.as_hash({@value.id => children}) end elsif (@value.class < ActiveRecord::Base) && children.is_a?(Hash) id = method.is_a?(Array) && method.first == "new" ? [nil] : [@value.id] # c = children.merge(id: id) # if @value.attributes.key? @value.class.inheritance_column # c[@value.class.inheritance_column] = [@value[@value.class.inheritance_column]] # end @parent.as_hash(jsonize(method) => merge_inheritance_column(children.merge(id: id))) elsif method == '*all' @parent.as_hash('*all' => children.first) else @parent.as_hash(jsonize(method) => children) end else { method.name => children } end end def to_json @value.to_json end end end =begin tree is a hash, target is the object that will be filled in with the data hanging off the key. first time around target == nil, so for each key, value pair we do this: load_from_json(value, Object.const_get(JSON.parse(key))) keys: ':*all': target.replace tree["*all"].collect { |id| target.proxy_association.klass.find(id) } Example: {'*all': [1, 7, 19, 23]} target is a collection and will now have 4 records: 1, 7, 19, 23 'id': if value is an array then target.id = value.first Example: {'id': [17]} Example the target is a record, and its id is now set to 17 '*count': target.set_count_state(value.first) note: set_count_state sets the count of a collection and updates the associated state variable integer-like-string-or-number: target.push_and_update_belongs_to(key) note: collection will be a has_many association, so we are doing a target << find(key), and updating both ends of the relationship [:new, nnn] do a ReactiveRecord::Base.find_by_object_id(target.base_class, method[1]) and that becomes the new target, with val being passed allow_change [...] and current target is NOT an ActiveRecord Model (??? a collection ???) then send key to target, and that becomes new target but note if value is an array then the scope returned nil, so we destroy the bogus record, and set new target back to nil new_target.destroy and new_target = nil if value.is_a? Array [...] and current target IS AN ActiveRecord Model (not a collection) then target.backing_record.update_attribute([method], target.backing_record.convert(method, value.first)) aggregation: 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) other-string-method-name: if value is a an array then value.first is the new value and we do target.send "{key}=", value.first if value is a hash =end def self.load_from_json(tree, target = 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"] loaded_collection = sorted_collection.collect do |id| ReactiveRecord::Base.find_by_id(target.proxy_association.klass, id) end if loaded_collection[0] && target.scope_description&.name == '___hyperstack_internal_scoped_find_by' primary_key = target.proxy_association.klass.primary_key attrs = target.vector[-1][1].reject { |key, _| key == primary_key } loaded_collection[0].backing_record.sync_attributes(attrs) end target.replace loaded_collection # we need to notify any observers of the collection. collection#replace # will not notify if we are data_loading (which we are) so we will do it # here. BUT we want the notification to occur after the current event # completes so we wrap it a bulk_update Hyperstack::Internal::State::Mapper.bulk_update do Hyperstack::Internal::State::Variable.set(target, :collection, target.collection) end end if (id_value = tree[target.class.try(:primary_key)] || tree[:id]) && 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.count_state = value.first elsif method.is_a? Integer or method =~ /^[0-9]+$/ new_target = target.push_and_update_belongs_to(method) elsif method.is_a? Array if method[0] == "new" new_target = ReactiveRecord::Base.lookup_by_object_id(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 && (new_target = nil) if value.is_a? Array else target.backing_record.update_simple_attribute([method], target.backing_record.convert(method, value.first)) end elsif target.class.respond_to?(:reflect_on_aggregation) && (aggregation = target.class.reflect_on_aggregation(method)) && !(aggregation.klass < ActiveRecord::Base) value = [aggregation.deserialize(value.first)] unless value.first.is_a?(aggregation.klass) target.send "#{method}=", value.first elsif value.is_a? Array target.send("_hyperstack_internal_setter_#{method}", value.first) unless [target.class.primary_key, :id].include? method elsif value.is_a?(Hash) && value[:id] && value[:id].first && (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? klass = value[:model_name] ? Object.const_get(value[:model_name].first) : association.klass new_target = ReactiveRecord::Base.find_by_id(klass, 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 rescue Exception => e raise e end end end