archivesspace/archivesspace

View on GitHub
backend/app/model/ASModel_crud.rb

Summary

Maintainability
D
2 days
Test Coverage
require_relative '../lib/nested_record_resolver'

module ASModel

  # Code for converting JSONModels into DB records and back again.
  module CRUD

    def self.included(base)
      base.extend(ClassMethods)
      base.include(JSONModel)
      base.extend(JSONModel)
    end

    Sequel.extension :inflector
    Sequel.extension(:core_extensions)

    def self.set_audit_fields(json, obj)
      ['created_by', 'last_modified_by'].each do |field|
        json[field] = obj[field.intern] if obj[field.intern]
      end

      ['system_mtime', 'user_mtime', 'create_time'].each do |field|
        val = obj[field.intern]
        next if !val

        json[field] = val.getutc.iso8601
      end
    end


    def validate
      # Check uniqueness constraints
      self.class.repo_unique_constraints.each do |constraint|
        validates_unique([:repo_id, constraint[:property]],
                         :message => constraint[:message])
        map_validation_to_json_property([:repo_id, constraint[:property]],
                                         constraint[:json_property])
      end

      super
    end


    # Do whatever is necessary to eaglerly load this object from the database.
    #
    # This is designed to give mixins the options of eagerly loading an entire
    # record and its components.
    def eagerly_load!
      # Do nothing by default
    end


    # Several JSONModels consist of logical subrecords that are stored as
    # separate models in the database (in separate tables).
    #
    # When we get a JSON blob for a record with subrecords, we want to create a
    # database record for each subrecords (or, if a URI referencing an existing
    # subrecord was given, use the existing object), then associate those
    # subrecords with the main record.
    def apply_nested_records(json, new_record = false)
      self.remove_nested_records if !new_record

      self.class.nested_records.each do |nested_record|
        # Read the subrecords from our JSON blob and fetch or create
        # the corresponding subrecord from the database.
        model = Kernel.const_get(nested_record[:association][:class_name])

        if nested_record[:association][:type] === :one_to_one
          add_record_method = nested_record[:association][:name].to_s
        elsif nested_record[:association][:type] === :many_to_one
          add_record_method = "#{nested_record[:association][:name].to_s.singularize}="
        else
          add_record_method = "add_#{nested_record[:association][:name].to_s.singularize}"
        end

        records = json[nested_record[:json_property]]

        is_array = true
        if nested_record[:association][:type] === :one_to_one || nested_record[:is_array] === false
          is_array = false
          records = [records]
        end

        updated_records = []
        (records or []).each_with_index do |json_or_uri, i|
          next if json_or_uri.nil?

          db_record = nil

          begin
            needs_linking = true

            if json_or_uri.is_a? String
              # A URI.  Just grab its database ID and look it up.
              db_record = model[JSONModel(nested_record[:jsonmodel]).id_for(json_or_uri)]
              updated_records << json_or_uri
            else
              # Create a database record for the JSON blob and return its ID
              subrecord_json = JSONModel(nested_record[:jsonmodel]).from_hash(json_or_uri, true, true)

              # The value of subrecord_json can be mutated by the various
              # transformations performed by the model layer.  Make sure we
              # keep the modified version of the JSON here.
              updated_records << subrecord_json

              if model.respond_to? :ensure_exists
                # Give our classes an opportunity to provide their own logic here
                db_record = model.ensure_exists(subrecord_json, self)
              else
                extra_opts = {}

                if nested_record[:association][:key]
                  extra_opts[nested_record[:association][:key]] = self.id

                  # We'll skip the call to the .add method because this step
                  # will have already linked the nested record to this one.
                  needs_linking = false
                end

                db_record = model.create_from_json(subrecord_json, extra_opts)
              end
            end

            if db_record.system_modified?
              # If the subrecord got changed by the system, mark ourselves as
              # modified too.
              self.mark_as_system_modified
            end

            self.send(add_record_method, db_record) if (db_record && needs_linking)
          rescue Sequel::ValidationFailed => e
            # Modify the exception keys by prefixing each with the path up until this point.
            e.instance_eval do
              if @errors
                prefix = nested_record[:json_property]
                prefix = "#{prefix}/#{i}" if is_array

                new_errors = {}
                @errors.each do |k, v|
                  new_errors["#{prefix}/#{k}"] = v
                end

                @errors = new_errors
              end
            end

            raise e
          end
        end

        json[nested_record[:json_property]] = is_array ? updated_records : updated_records[0]
      end
    end


    def remove_nested_records
      self.class.nested_records.each do |nested_record_defn|
        if [:one_to_one, :one_to_many].include?(nested_record_defn[:association][:type])
          # If the current record "owns" its nested record, delete the nested record.
          model = Kernel.const_get(nested_record_defn[:association][:class_name])

          # Tell the nested record to clear its own nested records
          Array(self.send(nested_record_defn[:association][:name])).each do |nested_record|
            nested_record.delete
          end
        elsif nested_record_defn[:association][:type] === :many_to_many
          # Just remove the links
          self.send("remove_all_#{nested_record_defn[:association][:name]}".intern)
        elsif nested_record_defn[:association][:type] === :many_to_one
          # Just remove the link
          self.send("#{nested_record_defn[:association][:name].intern}=", nil)
        end
      end
    end


    def update_from_json(json, extra_values = {}, apply_nested_records = true)
      if self.values.has_key?(:suppressed)
        if self[:suppressed] == 1
          raise ReadOnlyException.new("Can't update an object that has been suppressed")
        end

        # No funny business.  If you want to set this you need to do it via the
        # dedicated controller.
        json["suppressed"] = false
      end


      schema_defined_properties = json.class.schema["properties"].map {|prop, defn|
        prop if !defn['readonly']
      }.compact

      # Start by assuming all existing properties were nil, then overlay the
      # updates plus any extra attributes.
      #
      # This has the effect of unsetting (or setting to NULL) any properties that
      # were removed by this update.
      updated = Hash[schema_defined_properties.map {|property| [property, nil]}].
        merge(json.to_hash).
        merge(ASUtils.keys_as_strings(extra_values))

      if updated.has_key?('lock_version') && !updated['lock_version']
        raise ConflictException.new("You must provide a lock_version in your request")
      end

      self.class.strict_param_setting = false

      self.update(self.class.prepare_for_db(json.class, updated).
                  merge(:user_mtime => Time.now,
                        :last_modified_by => RequestContext.get(:current_username)))

      if apply_nested_records
        self.apply_nested_records(json)
      end

      self.class.fire_update(json, self)

      self
    end


    # Delete the current record using Sequel's delete method, but clean up
    # dependencies first.
    def delete
      object_graph = self.object_graph

      deleted_uris = []

      successfully_deleted_models = []
      last_error = nil
      while true
        progressed = false
        object_graph.each do |model, ids_to_delete|
          next if successfully_deleted_models.include?(model)

          begin
            model.handle_delete(ids_to_delete)
            successfully_deleted_models << model
            progressed = true
          rescue Sequel::DatabaseError
            last_error = $!
            next
          end

          if model.my_jsonmodel(true)
            ids_to_delete.each do |id|
              deleted_model = model.my_jsonmodel(true)

              deleted_uri = deleted_model.uri_for(id, :repo_id => model.active_repository)

              if deleted_uri
                deleted_uris << deleted_uri
              end
            end
          end
        end

        break if object_graph.models.length == successfully_deleted_models.length

        unless progressed
          if last_error && DB.is_retriable_exception(last_error)
            # Give us a chance to retry after a deadlock
            raise last_error
          end

          raise ConflictException.new("Record deletion failed: #{last_error}")
        end
      end


      deleted_uris.each do |uri|
        Tombstone.create(:uri => uri)
        DB.after_commit do
          RealtimeIndexing.record_delete(uri)
        end
      end
    end


    # When reporting a Sequel validation error against the set of 'columns',
    # report it against the JSONModel 'property' instead.
    #
    # For example, an identifier that must be unique to a repository might have a
    # constraint against the columns [:repository, :identifier], but when we
    # report this to the client we just want to tell them that the value for
    # 'identifier' was incorrect.
    def map_validation_to_json_property(columns, property)
      errors = self.errors.clone

      self.errors.clear

      errors.each do |error, msg|
        if error == columns
          self.errors[property] = msg
        else
          self.errors[error] = msg
        end
      end
    end


    # True if this record has been modified by some mechanism other than a request
    # from the client.  Used to send a status back to the client to let them know
    # that they'll need to fetch the latest representation.
    #
    # For example, this flag is used when the user's data is combined with a

    # that their local copy of the record includes the system-generated data too.
    def system_modified?
      @system_modified
    end


    def mark_as_system_modified
      @system_modified = true
    end

    module ClassMethods

      # Create a new record instance from the JSONModel 'json'.  Also creates any
      # nested record instances that it contains.
      def create_from_json(json, extra_values = {})
        self.strict_param_setting = false
        values = ASUtils.keys_as_strings(extra_values)

        if model_scope == :repository && !values.has_key?("repo_id")
          values["repo_id"] = active_repository
        end

        if model_scope == :repository && values["repo_id"] == Repository.global_repo_id &&
           !allowed_in_global_repo

          raise BadParamsException.new("The global repository cannot contain archival records")
        end

        values['created_by'] = RequestContext.get(:current_username)

        obj = self.create(prepare_for_db(json.class,
                                         json.to_hash.merge(values)))

        obj.apply_nested_records(json, true)

        fire_update(json, obj)

        obj.refresh
        obj
      end


      def high_priority?
        RequestContext.get(:is_high_priority)
      end


      # (Potentially) notify the real-time indexer that an update is available.
      def fire_update(json, sequel_obj)
        if high_priority?
          model = self

          uri = sequel_obj.uri

          # We don't index records without URIs, so no point digging them out of the database either.
          return unless uri

          record_id = sequel_obj.id
          repo_id = RequestContext.get(:repo_id)

          DB.after_commit do
            RequestContext.open(:repo_id => repo_id) do
              # if the record was created in a transaction that was rolled back
              # then it won't exist after the rollback, so we make sure it's there
              # before trying to fire the update
              record = model.any_repo.filter(:id => record_id).first
              if record
                hash = model.to_jsonmodel(record).to_hash(:trusted)
                RealtimeIndexing.record_update(hash, uri)
              end
            end
          end
        end
      end


      def nested_records
        @nested_records ||= []
      end


      # Match a JSONModel object to an existing database association.
      #
      # This linkage manages records that contain subrecords:
      #
      #  - When storing a JSON blob in the database, the linkage indicates which
      #    parts of the JSON should be plucked out and stored as separate database
      #    records (with the appropriate associations)
      #
      #  - When requesting a record in JSON format, the linkage indicates which
      #    associated database records should be pulled back and included in the
      #    JSON returned.
      #
      # For example, this definition from subject.rb:
      #
      #   def_nested_record(:the_property => :terms,
      #                     :contains_records_of_type => :term,
      #                     :corresponding_to_association  => :term)
      #
      # Causes an incoming JSONModel(:subject) to have each of the objects in its
      # "terms" array to be coerced into a Sequel model (based on the :terms
      # association) and stored in the database.  The provided list of terms are
      # associated with the subject as it is stored, and these replace any
      # previous terms.
      #
      # The definition also causes Subject.to_jsonmodel(obj) to
      # automatically pull back the list of terms associated with the object and
      # include them in the response.

      def def_nested_record(opts)
        opts[:association] = self.association_reflection(opts[:corresponding_to_association])
        opts[:jsonmodel] = opts[:contains_records_of_type]
        opts[:json_property] = opts[:the_property]

        opts[:is_array] = true if !opts.has_key?(:is_array)

        # Store our association on the nested record's model so we can walk back
        # the other way.
        ArchivesSpaceService.loaded_hook do
          nested_model = Kernel.const_get(opts[:association][:class_name])
          nested_model.add_enclosing_association(opts[:association])
        end

        nested_records << opts
      end


      # Record the association of the record that encloses this one.  For
      # example, an Archival Object encloses an Instance record because an
      # Instance is a nested record of an Archival Object.
      def add_enclosing_association(association)
        @enclosing_associations ||= []
        @enclosing_associations << association
      end

      # If this is a nested record, return the list of associations that link us
      # back to our parent(s).  Top-level records just return an empty list.
      def enclosing_associations
        @enclosing_associations || []
      end


      def get_nested_graph
        Hash[nested_records.map {|nested_record|
               model = Kernel.const_get(nested_record[:association][:class_name])
               association = nested_record[:corresponding_to_association]

               [association, model.get_nested_graph]
             }]
      end


      def get_or_die(id)
        obj = if self.model_scope == :repository
                self.this_repo[:id => id]
              else
                self[id]
              end

        obj or raise NotFoundException.new("#{self} not found")
      end


      def corresponds_to(jsonmodel)
        @jsonmodel = jsonmodel

        include(DynamicEnums)

        enums = []
        @jsonmodel.schema['properties'].each do |prop, defn|
          if defn["dynamic_enum"]
            enums << {:property => prop, :uses_enum => [defn['dynamic_enum']]}
          end
        end

        uses_enums(*enums)
      end


      # Does this model have a corresponding JSONModel?
      def has_jsonmodel?
        !@jsonmodel.nil?
      end


      # Return the JSONModel class that maps to this backend model
      def my_jsonmodel(ok_if_missing = false)
        @jsonmodel or (ok_if_missing ? nil : raise("No corresponding JSONModel set for model #{self.inspect}"))
      end


      def sequel_to_jsonmodel(objs, opts = {})
        NestedRecordResolver.new(nested_records, objs).resolve
      end

      def associations_to_eagerly_load
        # Allow subclasses to force eager loading of certain associations to
        # save SQL queries.
        []
      end


      def to_jsonmodel(obj, opts = {})
        is_id_query     = obj.is_a?(Integer)
        is_string_query = obj.is_a?(String) && opts[:query]

        if is_id_query || is_string_query
          ds = if self.model_scope == :repository
                 self.this_repo
               else
                 self
               end

          # An ID.  Get the Sequel row for it.
          if is_id_query
            obj = ds.eager(get_nested_graph).filter(:id => obj).all[0]

          # If we have a string and query option, attempt to look up by querying string value against column name.
          elsif is_string_query
            obj = ds.eager(get_nested_graph).filter(opts[:query].to_sym => obj).all[0]
          end

          raise NotFoundException.new("#{self} not found") unless obj
          obj.eagerly_load!
        end

        sequel_to_jsonmodel([obj], opts)[0]
      end


      def handle_delete(ids_to_delete)
        self.filter(:id => ids_to_delete).delete
      end


      def update_mtime_for_repo_id(repo_id)
        if model_scope == :repository
          self.dataset.filter(:repo_id => repo_id).update(:system_mtime => Time.now) if self.dataset.columns.include? :repo_id
        end
      end


      def update_mtime_for_ids(ids)
        now = Time.now
        ids.each_slice(1000) do |subset|
          self.dataset.filter(:id => subset).update(:system_mtime => now)
        end
      end


      def repo_unique_constraints
        Array(@repo_unique_constraints)
      end


      def repo_unique_constraint(property, constraints)
        @repo_unique_constraints ||= []
        @repo_unique_constraints << constraints.merge(:property => property)
      end


      def is_relationship?
        false
      end

    end
  end

end