sferik/rails_admin

View on GitHub
lib/rails_admin/adapters/mongoid.rb

Summary

Maintainability
C
7 hrs
Test Coverage
# frozen_string_literal: true

require 'mongoid'
require 'rails_admin/config/sections/list'
require 'rails_admin/adapters/mongoid/association'
require 'rails_admin/adapters/mongoid/object_extension'
require 'rails_admin/adapters/mongoid/property'
require 'rails_admin/adapters/mongoid/bson'

module RailsAdmin
  module Adapters
    module Mongoid
      DISABLED_COLUMN_TYPES = %w[Range Moped::BSON::Binary BSON::Binary Mongoid::Geospatial::Point].freeze

      def parse_object_id(value)
        Bson.parse_object_id(value)
      end

      def new(params = {})
        model.new(params).extend(ObjectExtension)
      end

      def get(id, scope = scoped)
        object = scope.find(id)
        return nil unless object

        object.extend(ObjectExtension)
      rescue StandardError => e
        raise e if %w[
          Mongoid::Errors::DocumentNotFound
          Mongoid::Errors::InvalidFind
          Moped::Errors::InvalidObjectId
          BSON::InvalidObjectId
          BSON::Error::InvalidObjectId
        ].exclude?(e.class.to_s)
      end

      def scoped
        model.scoped
      end

      def first(options = {}, scope = nil)
        all(options, scope).first
      end

      def all(options = {}, scope = nil)
        scope ||= scoped
        scope = scope.includes(*options[:include]) if options[:include]
        scope = scope.limit(options[:limit]) if options[:limit]
        scope = scope.any_in(_id: options[:bulk_ids]) if options[:bulk_ids]
        scope = query_scope(scope, options[:query]) if options[:query]
        scope = filter_scope(scope, options[:filters]) if options[:filters]
        scope = scope.send(Kaminari.config.page_method_name, options[:page]).per(options[:per]) if options[:page] && options[:per]
        scope = sort_by(options, scope) if options[:sort]
        scope
      rescue NoMethodError => e
        if /page/.match?(e.message)
          e = e.exception <<~ERROR
            #{e.message}
            If you don't have kaminari-mongoid installed, add `gem 'kaminari-mongoid'` to your Gemfile.
          ERROR
        end
        raise e
      end

      def count(options = {}, scope = nil)
        all(options.merge(limit: false, page: false), scope).count
      end

      def destroy(objects)
        Array.wrap(objects).each(&:destroy)
      end

      def primary_key
        '_id'
      end

      def associations
        model.relations.values.collect do |association|
          Association.new(association, model)
        end
      end

      def properties
        fields = model.fields.reject { |_name, field| DISABLED_COLUMN_TYPES.include?(field.type.to_s) }
        fields.collect { |_name, field| Property.new(field, model) }
      end

      def base_class
        klass = model
        klass = klass.superclass while klass.hereditary?
        klass
      end

      def table_name
        model.collection_name.to_s
      end

      def encoding
        Encoding::UTF_8
      end

      def embedded?
        associations.detect { |a| a.macro == :embedded_in }
      end

      def cyclic?
        model.cyclic?
      end

      def adapter_supports_joins?
        false
      end

    private

      def build_statement(column, type, value, operator)
        StatementBuilder.new(column, type, value, operator).to_statement
      end

      def make_field_conditions(field, value, operator)
        conditions_per_collection = {}
        field.searchable_columns.each do |column_infos|
          collection_name, column_name = parse_collection_name(column_infos[:column])
          statement = build_statement(column_name, column_infos[:type], value, operator)
          next unless statement

          conditions_per_collection[collection_name] ||= []
          conditions_per_collection[collection_name] << statement
        end
        conditions_per_collection
      end

      def query_scope(scope, query, fields = config.list.fields.select(&:queryable?))
        if config.list.search_by
          scope.send(config.list.search_by, query)
        else
          statements = []

          fields.each do |field|
            value = parse_field_value(field, query)
            conditions_per_collection = make_field_conditions(field, value, field.search_operator)
            statements.concat make_condition_for_current_collection(field, conditions_per_collection)
          end

          scope.where(statements.any? ? {'$or' => statements} : {})
        end
      end

      # filters example => {"string_field"=>{"0055"=>{"o"=>"like", "v"=>"test_value"}}, ...}
      # "0055" is the filter index, no use here. o is the operator, v the value
      def filter_scope(scope, filters, fields = config.list.fields.select(&:filterable?))
        statements = []

        filters.each_pair do |field_name, filters_dump|
          filters_dump.each_value do |filter_dump|
            field = fields.detect { |f| f.name.to_s == field_name }
            next unless field

            value = parse_field_value(field, filter_dump[:v])
            conditions_per_collection = make_field_conditions(field, value, (filter_dump[:o] || 'default'))
            field_statements = make_condition_for_current_collection(field, conditions_per_collection)
            if field_statements.many?
              statements << {'$or' => field_statements}
            elsif field_statements.any?
              statements << field_statements.first
            end
          end
        end

        scope.where(statements.any? ? {'$and' => statements} : {})
      end

      def parse_collection_name(column)
        collection_name, column_name = column.split('.')
        if associations.detect { |a| a.name == collection_name.to_sym }.try(:embeds?)
          [table_name, column]
        else
          [collection_name, column_name]
        end
      end

      def make_condition_for_current_collection(target_field, conditions_per_collection)
        result = []
        conditions_per_collection.each do |collection_name, conditions|
          if collection_name == table_name
            # conditions referring current model column are passed directly
            result.concat conditions
          else
            # otherwise, collect ids of documents that satisfy search condition
            result.concat perform_search_on_associated_collection(target_field.name, conditions)
          end
        end
        result
      end

      def perform_search_on_associated_collection(field_name, conditions)
        target_association = associations.detect { |a| a.name == field_name }
        return [] unless target_association

        model = target_association.klass
        case target_association.type
        when :belongs_to, :has_and_belongs_to_many
          [{target_association.foreign_key.to_s => {'$in' => model.where('$or' => conditions).all.collect { |r| r.send(target_association.primary_key) }}}]
        when :has_many, :has_one
          [{target_association.primary_key.to_s => {'$in' => model.where('$or' => conditions).all.collect { |r| r.send(target_association.foreign_key) }}}]
        end
      end

      def sort_by(options, scope)
        return scope unless options[:sort]

        case options[:sort]
        when String
          field_name, collection_name = options[:sort].split('.').reverse
          raise 'sorting by associated model column is not supported in Non-Relational databases' if collection_name && collection_name != table_name
        when Symbol
          field_name = options[:sort].to_s
        end
        if options[:sort_reverse]
          scope.asc field_name
        else
          scope.desc field_name
        end
      end

      class StatementBuilder < RailsAdmin::AbstractModel::StatementBuilder
      protected

        def unary_operators
          {
            '_blank' => {@column => {'$in' => [nil, '']}},
            '_present' => {@column => {'$nin' => [nil, '']}},
            '_null' => {@column => nil},
            '_not_null' => {@column => {'$ne' => nil}},
            '_empty' => {@column => ''},
            '_not_empty' => {@column => {'$ne' => ''}},
          }
        end

      private

        def build_statement_for_type
          case @type
          when :boolean                   then build_statement_for_boolean
          when :integer, :decimal, :float then build_statement_for_integer_decimal_or_float
          when :string, :text             then build_statement_for_string_or_text
          when :enum                      then build_statement_for_enum
          when :belongs_to_association, :bson_object_id then build_statement_for_belongs_to_association_or_bson_object_id
          end
        end

        def build_statement_for_boolean
          case @value
          when 'false', 'f', '0'
            {@column => false}
          when 'true', 't', '1'
            {@column => true}
          end
        end

        def column_for_value(value)
          {@column => value}
        end

        def build_statement_for_string_or_text
          return if @value.blank?

          @value =
            case @operator
            when 'not_like'
              Regexp.compile("^((?!#{Regexp.escape(@value)}).)*$", Regexp::IGNORECASE)
            when 'default', 'like'
              Regexp.compile(Regexp.escape(@value), Regexp::IGNORECASE)
            when 'starts_with'
              Regexp.compile("^#{Regexp.escape(@value)}", Regexp::IGNORECASE)
            when 'ends_with'
              Regexp.compile("#{Regexp.escape(@value)}$", Regexp::IGNORECASE)
            when 'is', '='
              @value.to_s
            else
              return
            end

          {@column => @value}
        end

        def build_statement_for_enum
          return if @value.blank?

          {@column => {'$in' => Array.wrap(@value)}}
        end

        def build_statement_for_belongs_to_association_or_bson_object_id
          {@column => @value} if @value
        end

        def range_filter(min, max)
          if min && max && min == max
            {@column => min}
          elsif min && max
            {@column => {'$gte' => min, '$lte' => max}}
          elsif min
            {@column => {'$gte' => min}}
          elsif max
            {@column => {'$lte' => max}}
          end
        end
      end
    end
  end
end