reactrb/reactive-record

View on GitHub
lib/reactive_record/active_record/reactive_record/base.rb

Summary

Maintainability
F
4 days
Test Coverage
module ReactiveRecord
  class Base

    # Its all about lazy loading. This prevents us from grabbing enormous association collections, or large attributes
    # unless they are explicitly requested.

    # During prerendering we get each attribute as its requested and fill it in both on the javascript side, as well as
    # remember that the attribute needs to be part of the download to client.

    # On the client we fill in the record data with empty values (nil, or one element collections) but only as the attribute
    # is requested.  Each request queues up a request to get the real data from the server.

    # The ReactiveRecord class serves two purposes.  First it is the unique data corresponding to the last known state of a
    # database record.  This means All records matching a specific database record are unique.  This is unlike AR but is
    # important both for the lazy loading and also so that when values change react can be informed of the change.

    # Secondly it serves as name space for all the ReactiveRecord specific methods, so every AR Instance has a ReactiveRecord

    # Because there is no point in generating a new ar_instance everytime a search is made we cache the first ar_instance created.
    # Its possible however during loading to create a new ar_instances that will in the end point to the same record.

    # VECTORS... are an important concept.  They are the substitute for a primary key before a record is loaded.
    # Vectors have the form [ModelClass, method_call, method_call, method_call...]

    # Each method call is either a simple method name or an array in the form [method_name, param, param ...]
    # Example [User, [find, 123], todos, active, [due, "1/1/2016"], title]
    # Roughly corresponds to this query: User.find(123).todos.active.due("1/1/2016").select(:title)

    attr_accessor :ar_instance
    attr_accessor :vector
    attr_accessor :model
    attr_accessor :changed_attributes
    attr_accessor :aggregate_owner
    attr_accessor :aggregate_attribute
    attr_accessor :destroyed
    attr_accessor :updated_during
    attr_accessor :synced_attributes
    attr_accessor :virgin

    # While data is being loaded from the server certain internal behaviors need to change
    # for example records all record changes are synced as they happen.
    # This is implemented this way so that the ServerDataCache class can use pure active
    # record methods in its implementation

    def self.data_loading?
      @data_loading
    end

    def data_loading?
      self.class.data_loading?
    end

    def self.load_data(&block)
      current_data_loading, @data_loading = [@data_loading, true]
      yield
    ensure
      @data_loading = current_data_loading
    end

    def self.load_from_json(json, target = nil)
      load_data { ServerDataCache.load_from_json(json, target) }
    end

    def self.class_scopes(model)
      @class_scopes[model.base_class]
    end

    def self.sync_blocks
      # @sync_blocks[watch_model][sync_model][scope_name][...array of blocks...]
      @sync_blocks ||= Hash.new { |hash, key| hash[key] = Hash.new { |hash, key| hash[key] = Hash.new { |hash, key| hash[key] = [] } } }
    end

    def sync_scopes
      self.class.sync_blocks[self.model].each do |watching_class, scopes|
        scopes.each do |scope_name, blocks|
          blocks.each do |block|
            if block.arity > 0
              block.call watching_class.send(scope_name), @ar_instance
            elsif @ar_instance.instance_eval &block
              watching_class.send(scope_name) << @ar_instance
            else
              watching_class.send(scope_name).delete(@ar_instance)
            end
          end
        end
      end
      model.all << @ar_instance if ReactiveRecord::Base.class_scopes(model)[:all] # add this only if model.all has been fetched already
    end

    def self.find(model, attribute, value)
      # will return the unique record with this attribute-value pair
      # value cannot be an association or aggregation

      model = model.base_class
      # already have a record with this attribute-value pair?
      record = @records[model].detect { |record| record.attributes[attribute] == value}
      unless record
        # if not, and then the record may be loaded, but not have this attribute set yet,
        # so find the id of of record with the attribute-value pair, and see if that is loaded.
        # find_in_db returns nil if we are not prerendering which will force us to create a new record
        # because there is no way of knowing the id.
        if attribute != model.primary_key and id = find_in_db(model, attribute, value)
          record = @records[model].detect { |record| record.id == id}
        end
        # if we don't have a record then create one
        (record = new(model)).vector = [model, ["find_by_#{attribute}", value]] unless record
        # and set the value
        record.sync_attribute(attribute, value)
        # and set the primary if we have one
        record.sync_attribute(model.primary_key, id) if id
      end
      # finally initialize and return the ar_instance
      record.ar_instance ||= infer_type_from_hash(model, record.attributes).new(record)
    end

    def self.find_by_object_id(model, object_id)
      @records[model].detect { |record| record.object_id == object_id }.ar_instance
    end

    def self.new_from_vector(model, aggregate_owner, *vector)
      # this is the equivilent of find but for associations and aggregations
      # because we are not fetching a specific attribute yet, there is NO communication with the
      # server.  That only happens during find.
      model = model.base_class

      # do we already have a record with this vector?  If so return it, otherwise make a new one.

      record = @records[model].detect { |record| record.vector == vector }
      unless record
        record = new model
        record.vector = vector
      end

      record.ar_instance ||= infer_type_from_hash(model, record.attributes).new(record)

      if aggregate_owner
        record.aggregate_owner = aggregate_owner
        record.aggregate_attribute = vector.last
        aggregate_owner.attributes[vector.last] = record.ar_instance
      end

      record.ar_instance

    end

    def initialize(model, hash = {}, ar_instance = nil)
      @model = model
      @ar_instance = ar_instance
      @synced_attributes = {}
      @attributes = {}
      @changed_attributes = []
      @virgin = true
      records[model] << self
    end

    def find(*args)
      self.class.find(*args)
    end

    def new_from_vector(*args)
      self.class.new_from_vector(*args)
    end

    def primary_key
      @model.primary_key
    end

    def id
      attributes[primary_key]
    end

    def id=(value)
      # value can be nil if we are loading an aggregate otherwise check if it already exists
      if !(value and existing_record = records[@model].detect { |record| record.attributes[primary_key] == value})
        attributes[primary_key] = value
      else
        @ar_instance.instance_variable_set(:@backing_record, existing_record)
        existing_record.attributes.merge!(attributes) { |key, v1, v2| v1 }
      end
      value
    end

    def attributes
      @last_access_at = Time.now
      @attributes
    end

    def reactive_get!(attribute, reload = nil)
      @virgin = false unless data_loading?
      unless @destroyed
        if @attributes.has_key? attribute
          attributes[attribute].notify if @attributes[attribute].is_a? DummyValue
          apply_method(attribute) if reload
        else
          apply_method(attribute)
        end
        React::State.get_state(self, attribute) unless data_loading?
        attributes[attribute]
      end
    end

    def reactive_set!(attribute, value)
      @virgin = false unless data_loading?
      unless @destroyed or (!(attributes[attribute].is_a? DummyValue) and attributes.has_key?(attribute) and attributes[attribute] == value)
        if association = @model.reflect_on_association(attribute)
          if association.collection?
            collection = Collection.new(association.klass, @ar_instance, association)
            collection.replace(value || [])
            value = collection
          else
            inverse_of = association.inverse_of
            inverse_association = association.klass.reflect_on_association(inverse_of)
            if inverse_association.collection?
              if value.nil?
                attributes[attribute].attributes[inverse_of].delete(@ar_instance) unless attributes[attribute].nil?
              elsif value.attributes[inverse_of]
                value.attributes[inverse_of] << @ar_instance
              else
                value.attributes[inverse_of] = Collection.new(@model, value, inverse_association)
                value.attributes[inverse_of].replace [@ar_instance]
              end
            elsif !value.nil?
              attributes[attribute].attributes[inverse_of] = nil unless attributes[attribute].nil?
              value.attributes[inverse_of] = @ar_instance
              React::State.set_state(value.backing_record, inverse_of, @ar_instance) unless data_loading?
            elsif attributes[attribute]
              attributes[attribute].attributes[inverse_of] = nil
            end
          end
        elsif aggregation = @model.reflect_on_aggregation(attribute) and (aggregation.klass < ActiveRecord::Base)

          if new?
            attributes[attribute] ||= aggregation.klass.new
          elsif !attributes[attribute]
            raise "uninitialized aggregate attribute - should never happen"
          end

          aggregate_record = attributes[attribute].backing_record
          aggregate_record.virgin = false

          if value
            value_attributes = value.backing_record.attributes
            aggregation.mapped_attributes.each { |mapped_attribute| aggregate_record.update_attribute(mapped_attribute, value_attributes[mapped_attribute])}
          else
            aggregation.mapped_attributes.each { |mapped_attribute| aggregate_record.update_attribute(mapped_attribute, nil) }
          end

          return attributes[attribute]

        end
        update_attribute(attribute, value)
      end
      value
    end

    def update_attribute(attribute, *args)
      value = args[0]
      if args.count != 0 and data_loading?
        if (aggregation = model.reflect_on_aggregation(attribute)) and !(aggregation.klass < ActiveRecord::Base)
          @synced_attributes[attribute] = aggregation.deserialize(aggregation.serialize(value))
        else
          @synced_attributes[attribute] = value
        end
      end
      if @virgin
        attributes[attribute] = value if args.count != 0
        return
      end
      changed = if args.count == 0
        if (association = @model.reflect_on_association(attribute)) and association.collection?
          attributes[attribute] != @synced_attributes[attribute]
        else
          !attributes[attribute].backing_record.changed_attributes.empty?
        end
      elsif (association = @model.reflect_on_association(attribute)) and association.collection?
        value != @synced_attributes[attribute]
      else
        !@synced_attributes.has_key?(attribute) or @synced_attributes[attribute] != value
      end
      empty_before = changed_attributes.empty?
      if !changed
        changed_attributes.delete(attribute)
      elsif !changed_attributes.include?(attribute)
        changed_attributes << attribute
      end
      had_key = attributes.has_key? attribute
      current_value = attributes[attribute]
      attributes[attribute] = value if args.count != 0
      if !data_loading?
        React::State.set_state(self, attribute, value)
      elsif on_opal_client? and had_key and current_value.loaded? and current_value != value and args.count > 0  # this is to handle changes in already loaded server side methods
        React::State.set_state(self, attribute, value, true)
      end
      if empty_before != changed_attributes.empty?
        React::State.set_state(self, "!CHANGED!", !changed_attributes.empty?, true) unless on_opal_server? or data_loading?
        aggregate_owner.update_attribute(aggregate_attribute) if aggregate_owner
      end
    end

    def changed?(*args)
      if args.count == 0
        React::State.get_state(self, "!CHANGED!")
        !changed_attributes.empty?
      else
        React::State.get_state(self, args[0])
        changed_attributes.include? args[0]
      end
    end

    def errors
      @errors ||= ActiveModel::Error.new
    end

    def sync!(hash = {})  # does NOT notify (see saved! for notification)
      @attributes.merge! hash
      @synced_attributes = {}
      @synced_attributes.each { |attribute, value| sync_attribute(key, value) }
      @changed_attributes = []
      @saving = false
      @errors = nil
      # set the vector - this only happens when a new record is saved
      @vector = [@model, ["find_by_#{@model.primary_key}", id]] if (!vector or vector.empty?) and id and id != ""
      self
    end

    def sync_attribute(attribute, value)

      @synced_attributes[attribute] = attributes[attribute] = value

      #@synced_attributes[attribute] = value.dup if value.is_a? ReactiveRecord::Collection

      if value.is_a? Collection
        @synced_attributes[attribute] = value.dup_for_sync
      elsif aggregation = model.reflect_on_aggregation(attribute) and (aggregation.klass < ActiveRecord::Base)
        value.backing_record.sync!
      elsif aggregation
        @synced_attributes[attribute] = aggregation.deserialize(aggregation.serialize(value))
      elsif !model.reflect_on_association(attribute)
        @synced_attributes[attribute] = JSON.parse(value.to_json)
      end

      @changed_attributes.delete(attribute)
      value
    end

    def revert
      @changed_attributes.each do |attribute|
        @ar_instance.send("#{attribute}=", @synced_attributes[attribute])
        @attributes.delete(attribute) unless @synced_attributes.has_key?(attribute)
      end
      @changed_attributes = []
      @errors = nil
    end

    def saving!
      React::State.set_state(self, self, :saving) unless data_loading?
      @saving = true
    end

    def errors!(errors)
      @saving = false
      @errors = errors and ActiveModel::Error.new(errors)
    end

    def saved!  # sets saving to false AND notifies
      @saving = false
      if !@errors or @errors.empty?
        React::State.set_state(self, self, :saved)
      elsif !data_loading?
        React::State.set_state(self, self, :error)
      end
      self
    end

    def saving?
      React::State.get_state(self, self)
      @saving
    end

    def new?
      !id and !vector
    end

    def find_association(association, id)
      inverse_of = association.inverse_of
      instance = if id
        find(association.klass, association.klass.primary_key, id)
      else
        new_from_vector(association.klass, nil, *vector, association.attribute)
      end
      instance_backing_record_attributes = instance.backing_record.attributes
      inverse_association = association.klass.reflect_on_association(inverse_of)
      if inverse_association.collection?
        instance_backing_record_attributes[inverse_of] = if id and id != ""
          Collection.new(@model, instance, inverse_association, association.klass, ["find", id], inverse_of)
        else
          Collection.new(@model, instance, inverse_association, *vector, association.attribute, inverse_of)
        end unless instance_backing_record_attributes[inverse_of]
        instance_backing_record_attributes[inverse_of].replace [@ar_instance]
      else
        instance_backing_record_attributes[inverse_of] = @ar_instance
      end if inverse_of and !instance_backing_record_attributes.has_key?(inverse_of)
      instance
    end

    def apply_method(method)
      # Fills in the value returned by sending "method" to the corresponding server side db instance
      if on_opal_server? and changed?
        log("Warning fetching virtual attributes (#{model.name}.#{method}) during prerendering on a changed or new model is not implemented.", :warning)
        # to implement this we would have to sync up any changes during prererendering with a set the cached models (see server_data_cache)
        # right now server_data cache is read only, BUT we could change this.  However it seems like a tails case.  Why would we create or update
        # a model during prerendering???
      end
      if !new?
        new_value = if association = @model.reflect_on_association(method)
          if association.collection?
            Collection.new(association.klass, @ar_instance, association, *vector, method)
          else
            find_association(association, (id and id != "" and self.class.fetch_from_db([@model, [:find, id], method, @model.primary_key])))
          end
        elsif aggregation = @model.reflect_on_aggregation(method) and (aggregation.klass < ActiveRecord::Base)
          new_from_vector(aggregation.klass, self, *vector, method)
        elsif id and id != ""
          self.class.fetch_from_db([@model, [:find, id], *method]) || self.class.load_from_db(self, *(vector ? vector : [nil]), method)
        else  # its a attribute in an aggregate or we are on the client and don't know the id
          self.class.fetch_from_db([*vector, *method]) || self.class.load_from_db(self, *(vector ? vector : [nil]), method)
        end
        new_value = @attributes[method] if new_value.is_a? DummyValue and @attributes.has_key?(method)
        sync_attribute(method, new_value)
      elsif association = @model.reflect_on_association(method) and association.collection?
        @attributes[method] = Collection.new(association.klass, @ar_instance, association)
      elsif aggregation = @model.reflect_on_aggregation(method) and (aggregation.klass < ActiveRecord::Base)
        @attributes[method] = aggregation.klass.new.tap do |aggregate|
          backing_record = aggregate.backing_record
          backing_record.aggregate_owner = self
          backing_record.aggregate_attribute = method
        end
      elsif !aggregation and method != model.primary_key
        unless @attributes.has_key?(method)
          log("Warning: reading from new #{model.name}.#{method} before assignment.  Will fetch value from server.  This may not be what you expected!!", :warning)
        end
        new_value = self.class.load_from_db(self, *(vector ? vector : [nil]), method)
        new_value = @attributes[method] if new_value.is_a? DummyValue and @attributes.has_key?(method)
        sync_attribute(method, new_value)
      end
    end

    def self.infer_type_from_hash(klass, hash)
      klass = klass.base_class
      return klass unless hash
      type = hash[klass.inheritance_column]
      begin
        return Object.const_get(type)
      rescue Exception => e
        message = "Could not subclass #{@model_klass.model_name} as #{type}.  Perhaps #{type} class has not been required. Exception: #{e}"
        `console.error(#{message})`
      end if type
      klass
    end

  end
end