openjaf/cenit

View on GitHub
app/models/setup/json_data_type.rb

Summary

Maintainability
F
3 days
Test Coverage
module Setup
  class JsonDataType < DataType
    include Setup::SnippetCode

    origins Setup::CrossOriginShared::DEFAULT_ORIGINS

    validates_presence_of :namespace

    legacy_code_attribute :schema

    trace_include :schema

    build_in_data_type.referenced_by(:namespace, :name).with(
      :namespace,
      :name,
      :title,
      :_type,
      :snippet,
      :discard_additional_properties,
      :before_save_callbacks,
      :after_save_callbacks,
      :records_methods,
      :data_type_methods
    )
    build_in_data_type.and(
      properties: {
        schema: {
          edi: {
            discard: true
          }
        }
      }
    )

    DEFAULT_SCHEMA = {
      type: 'object',
      properties: {
        name: {
          type: 'string'
        }
      }
    }.deep_stringify_keys

    field :discard_additional_properties, type: Mongoid::Boolean, default: true

    after_initialize do
      self.schema = DEFAULT_SCHEMA if new_record? && @schema.nil?
    end

    def save_self_before_refs
      true
    end

    def validates_configuration
      super
      remove_attribute(:schema) if validate_model && check_indices
      abort_if_has_errors
    end

    def additional_properties?
      !discard_additional_properties
    end

    def code=(code)
      @schema = nil
      super
    end

    def set_relation(name, relation)
      r = super
      if name == :snippet
        @schema = nil
      end
      r
    end

    def schema_code
      schema!
    rescue
      code
    end

    def schema_code=(sch)
      self.schema = sch
    end

    def schema!
      @schema ||= JSON.parse(code)
    end

    def schema
      schema!
    rescue
      { ERROR: 'Invalid JSON syntax', schema: code }
    end

    def schema=(sch)
      old_schema = schema
      sch = JSON.parse(sch.to_s) unless sch.is_a?(Hash)
      sch = sch.deep_stringify_keys
      self.code = JSON.pretty_generate(sch)
      @schema = sch
    rescue
      @schema = nil
      self.code = sch
    ensure
      unless Cenit::Utility.eql_content?(old_schema, @schema)
        changed_attributes['schema'] = old_schema
      end
    end

    def code_extension
      '.json'
    end

    def check_indices
      build_indices if schema_changed?
      errors.blank?
    end

    def unique_properties
      records_model.unique_properties
    end

    def build_indices
      unique_properties = self.unique_properties
      indexed_properties = []
      begin
        records_model.collection.indexes.each do |index|
          indexed_property = index['key'].keys.first
          if unique_properties.detect { |p| p == indexed_property }
            indexed_properties << indexed_property
          else
            begin
              records_model.collection.indexes.drop_one(index['name'])
            rescue Exception => ex
              errors.add(:schema, "with error dropping index #{indexed_property}: #{ex.message}")
            end
          end
        end
      rescue
        # Mongo driver raises an exception if the collection does not exists, nothing to worry about
      end
      unique_properties.reject { |p| indexed_properties.include?(p) }.each do |p|
        next if p == '_id'
        begin
          records_model.collection.indexes.create_one({ p => 1 }, unique: true)
        rescue Exception => ex
          errors.add(:schema, "with error when creating index for unique property '#{p}': #{ex.message}")
        end
      end
      errors.blank?
    end

    def schema_changed?
      changed_attributes.key?('schema')
    end

    def validate_model
      if schema_code.is_a?(Hash)
        if schema_changed?
          begin
            json_schema, _ = validate_schema
            fail Exception, 'defines invalid property name: _type' if object_schema?(json_schema) && json_schema['properties'].key?('_type')
            self.schema = check_properties(JSON.parse(json_schema.to_json), skip_id_refactoring: true)
          rescue Exception => ex
            errors.add(:schema, ex.message)
          end
          @collection_data_type = nil
        end
        json_schema ||= schema
        if title.blank?
          self.title = json_schema['title'] || self.name
        end
      else
        errors.add(:schema_code, 'is not a valid JSON value')
        errors.add(:schema, 'is not a valid JSON value')
      end
      errors.blank?
    end

    def subtype?
      collection_data_type != self
    end

    def collection_data_type
      @collection_data_type ||=
        ((base = schema['extends']) && base.is_a?(String) && (base = find_data_type(base)) && base.collection_data_type) || self
    end

    def data_type_collection_name
      Account.tenant_collection_name(collection_data_type.data_type_name)
    end

    def each_ref(params = {}, &block)
      params[:visited] ||= Set.new
      params[:not_found] ||= Set.new
      for_each_ref(params[:visited], params, &block)
    end

    protected

    def for_each_ref(visited = Set.new, params = {}, &block)
      schema = params[:schema] || self.schema
      not_found = params[:not_found]
      refs = []
      if (ref = schema['$ref'])
        refs << ref
      end
      if (ref = schema['extends']).is_a?(String)
        refs << ref
      end
      refs.flatten.each do |ref|
        if (data_type = find_data_type(ref))
          if visited.exclude?(data_type)
            visited << data_type
            block.call(data_type)
            data_type.for_each_ref(visited, not_found: not_found, &block) if data_type.is_a?(Setup::JsonDataType)
          end
        else
          not_found << ref
        end
      end
      schema.each do |key, value|
        next unless value.is_a?(Hash) && %w(extends $ref).exclude?(key)
        for_each_ref(visited, schema: value, not_found: not_found, &block)
      end
    end

    def validate_schema
      # check_type_name(self.name)
      self.schema = JSON.parse(schema) unless schema.is_a?(Hash)
      ::Mongoff::Validator.validate(schema)
      embedded_refs = {}
      if schema['type'] == 'object'
        check_schema(schema, self.name, defined_types = [], embedded_refs, schema)
      end
      [schema, embedded_refs]
    end

    def check_schema(json, name, defined_types, embedded_refs, root_schema)
      if (refs = json['$ref'])
        refs = [refs] unless refs.is_a?(Array)
        refs.each { |ref| embedded_refs[ref] = check_embedded_ref(ref, root_schema) if ref.is_a?(String) && ref.start_with?('#') }
      elsif json['type'].nil? || json['type'].eql?('object')
        defined_types << name
        check_definitions(json, name, defined_types, embedded_refs, root_schema)
        if (properties = json['properties'])
          raise Exception.new('properties specification is invalid') unless properties.is_a?(Hash)
          properties.each do |property_name, property_spec|
            unless property_name == '$ref'
              check_property_name(property_name)
              raise Exception.new("specification of property '#{property_name}' is not valid") unless property_spec.is_a?(Hash)
              camelized_property_name = "#{name}::#{property_name.camelize}"
              if defined_types.include?(camelized_property_name) && !(property_spec['$ref'] || 'object'.eql?(property_spec['type']))
                raise Exception.new("'#{name.underscore}' already defines #{property_name} (use #/[definitions|properties]/#{property_name} instead)")
              end
              check_schema(property_spec, camelized_property_name, defined_types, embedded_refs, root_schema)
            end
          end
        end
        check_requires(json)
      end
    end

    def check_requires(json)
      properties = json['properties']
      if (required = json['required'])
        if required.is_a?(Array)
          required.each do |property|
            if property.is_a?(String)
              raise Exception.new("requires undefined property '#{property.to_s}'") unless properties && properties[property]
            else
              raise Exception.new("required item \'#{property.to_s}\' is not a property name (string)")
            end
          end
        else
          raise Exception.new('required clause is not an array')
        end
      end
    end

    def check_definitions(json, parent, defined_types, embedded_refs, root_schema)
      if (defs = json['definitions'])
        raise Exception.new('definitions format is invalid') unless defs.is_a?(Hash)
        defs.each do |def_name, def_spec|
          raise Exception.new("type definition '#{def_name}' is not an object type") unless def_spec.is_a?(Hash) && (def_spec['type'].nil? || def_spec['type'].eql?('object'))
          check_definition_name(def_name)
          raise Exception.new("'#{parent.underscore}/#{def_name}' definition is declared as a reference (use the reference instead)") if def_spec['$ref']
          camelized_def_name = "#{parent}::#{def_name.camelize}"
          raise Exception.new("'#{parent.underscore}' already defines #{def_name}") if defined_types.include?(camelized_def_name)
          check_schema(def_spec, camelized_def_name, defined_types, embedded_refs, root_schema)
        end
      end
    end

    def check_definition_name(def_name)
      #raise Exception.new("definition name '#{def_name}' is not valid") unless def_name =~ /\A([A-Z]|[a-z])+(_|([0-9]|[a-z]|[A-Z])+)*\Z/
      raise Exception.new("definition name '#{def_name}' is not valid") unless def_name =~ /\A[a-z]+(_|([0-9]|[a-z])+)*\Z/
    end

    def check_property_name(property_name)
      #TODO Check for a valid ruby method name
      #raise Exception.new("property name '#{property_name}' is invalid") unless property_name =~ /\A[a-z]+(_|([0-9]|[a-z])+)*\Z/
    end

  end
end