sferik/rails_admin

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

Summary

Maintainability
B
7 hrs
Test Coverage
# frozen_string_literal: true

require 'active_record'
require 'rails_admin/adapters/active_record/association'
require 'rails_admin/adapters/active_record/object_extension'
require 'rails_admin/adapters/active_record/property'

module RailsAdmin
  module Adapters
    module ActiveRecord
      DISABLED_COLUMN_TYPES = %i[tsvector blob binary spatial hstore geometry].freeze

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

      def get(id, scope = scoped)
        object = scope.where(primary_key => id).first
        return unless object

        object.extend(ObjectExtension)
      end

      def scoped
        model.all
      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 = bulk_scope(scope, options) 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_scope(scope, options) if options[:sort]
        scope
      end

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

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

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

      def properties
        columns = model.columns.reject do |c|
          c.type.blank? ||
            DISABLED_COLUMN_TYPES.include?(c.type.to_sym) ||
            c.try(:array)
        end
        columns.collect do |property|
          Property.new(property, model)
        end
      end

      def base_class
        model.base_class
      end

      delegate :primary_key, :table_name, to: :model, prefix: false

      def quoted_table_name
        model.quoted_table_name
      end

      def quote_column_name(name)
        model.connection.quote_column_name(name)
      end

      def encoding
        adapter =
          if ::ActiveRecord::Base.respond_to?(:connection_db_config)
            ::ActiveRecord::Base.connection_db_config.configuration_hash[:adapter]
          else
            ::ActiveRecord::Base.connection_config[:adapter]
          end
        case adapter
        when 'postgresql'
          ::ActiveRecord::Base.connection.select_one("SELECT ''::text AS str;").values.first.encoding
        when 'mysql2'
          if RUBY_ENGINE == 'jruby'
            ::ActiveRecord::Base.connection.select_one("SELECT '' AS str;").values.first.encoding
          else
            ::ActiveRecord::Base.connection.raw_connection.encoding
          end
        when 'oracle_enhanced'
          ::ActiveRecord::Base.connection.select_one('SELECT dummy FROM DUAL').values.first.encoding
        else
          ::ActiveRecord::Base.connection.select_one("SELECT '' AS str;").values.first.encoding
        end
      end

      def embedded?
        false
      end

      def cyclic?
        false
      end

      def adapter_supports_joins?
        true
      end

    private

      def bulk_scope(scope, options)
        scope.where(primary_key => options[:bulk_ids])
      end

      def sort_scope(scope, options)
        direction = options[:sort_reverse] ? :asc : :desc
        case options[:sort]
        when String, Symbol
          scope.reorder("#{options[:sort]} #{direction}")
        when Array
          scope.reorder(options[:sort].zip(Array.new(options[:sort].size) { direction }).to_h)
        when Hash
          scope.reorder(options[:sort].map { |table_name, column| "#{table_name}.#{column}" }.
            zip(Array.new(options[:sort].size) { direction }).to_h)
        else
          raise ArgumentError.new("Unsupported sort value: #{options[:sort]}")
        end
      end

      class WhereBuilder
        def initialize(scope)
          @statements = []
          @values = []
          @tables = []
          @scope = scope
        end

        def add(field, value, operator)
          field.searchable_columns.flatten.each do |column_infos|
            statement, value1, value2 = StatementBuilder.new(column_infos[:column], column_infos[:type], value, operator, @scope.connection.adapter_name).to_statement
            @statements << statement if statement.present?
            @values << value1 unless value1.nil?
            @values << value2 unless value2.nil?
            table, column = column_infos[:column].split('.')
            @tables.push(table) if column
          end
        end

        def build
          scope = @scope.where(@statements.join(' OR '), *@values)
          scope = scope.references(*@tables.uniq) if @tables.any?
          scope
        end
      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
          wb = WhereBuilder.new(scope)
          fields.each do |field|
            value = parse_field_value(field, query)
            wb.add(field, value, field.search_operator)
          end
          # OR all query statements
          wb.build
        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?))
        filters.each_pair do |field_name, filters_dump|
          filters_dump.each_value do |filter_dump|
            wb = WhereBuilder.new(scope)
            field = fields.detect { |f| f.name.to_s == field_name }
            value = parse_field_value(field, filter_dump[:v])

            wb.add(field, value, (filter_dump[:o] || RailsAdmin::Config.default_search_operator))
            # AND current filter statements to other filter statements
            scope = wb.build
          end
        end
        scope
      end

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

      class StatementBuilder < RailsAdmin::AbstractModel::StatementBuilder
        def initialize(column, type, value, operator, adapter_name)
          super column, type, value, operator
          @adapter_name = adapter_name
        end

      protected

        def unary_operators
          case @type
          when :boolean
            boolean_unary_operators
          when :uuid
            uuid_unary_operators
          when :integer, :decimal, :float
            numeric_unary_operators
          else
            generic_unary_operators
          end
        end

      private

        def generic_unary_operators
          {
            '_blank' => ["(#{@column} IS NULL OR #{@column} = '')"],
            '_present' => ["(#{@column} IS NOT NULL AND #{@column} != '')"],
            '_null' => ["(#{@column} IS NULL)"],
            '_not_null' => ["(#{@column} IS NOT NULL)"],
            '_empty' => ["(#{@column} = '')"],
            '_not_empty' => ["(#{@column} != '')"],
          }
        end

        def boolean_unary_operators
          generic_unary_operators.merge(
            '_blank' => ["(#{@column} IS NULL)"],
            '_empty' => ["(#{@column} IS NULL)"],
            '_present' => ["(#{@column} IS NOT NULL)"],
            '_not_empty' => ["(#{@column} IS NOT NULL)"],
          )
        end
        alias_method :numeric_unary_operators, :boolean_unary_operators
        alias_method :uuid_unary_operators, :boolean_unary_operators

        def range_filter(min, max)
          if min && max && min == max
            ["(#{@column} = ?)", min]
          elsif min && max
            ["(#{@column} BETWEEN ? AND ?)", min, max]
          elsif min
            ["(#{@column} >= ?)", min]
          elsif max
            ["(#{@column} <= ?)", max]
          end
        end

        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, :citext    then build_statement_for_string_or_text
          when :enum                      then build_statement_for_enum
          when :belongs_to_association    then build_statement_for_belongs_to_association
          when :uuid                      then build_statement_for_uuid
          end
        end

        def build_statement_for_boolean
          case @value
          when 'false', 'f', '0'
            ["(#{@column} IS NULL OR #{@column} = ?)", false]
          when 'true', 't', '1'
            ["(#{@column} = ?)", true]
          end
        end

        def column_for_value(value)
          ["(#{@column} = ?)", value]
        end

        def build_statement_for_belongs_to_association
          return if @value.blank?

          ["(#{@column} = ?)", @value.to_i] if @value.to_i.to_s == @value
        end

        def build_statement_for_string_or_text
          return if @value.blank?

          return ["(#{@column} = ?)", @value] if ['is', '='].include?(@operator)

          @value = @value.mb_chars.downcase unless %w[postgresql postgis].include? ar_adapter

          @value =
            case @operator
            when 'default', 'like', 'not_like'
              "%#{@value}%"
            when 'starts_with'
              "#{@value}%"
            when 'ends_with'
              "%#{@value}"
            else
              return
            end

          if %w[postgresql postgis].include? ar_adapter
            if @operator == 'not_like'
              ["(#{@column} NOT ILIKE ?)", @value]
            else
              ["(#{@column} ILIKE ?)", @value]
            end
          elsif @operator == 'not_like'
            ["(LOWER(#{@column}) NOT LIKE ?)", @value]
          else
            ["(LOWER(#{@column}) LIKE ?)", @value]
          end
        end

        def build_statement_for_enum
          return if @value.blank?

          ["(#{@column} IN (?))", Array.wrap(@value)]
        end

        def build_statement_for_uuid
          column_for_value(@value) if /\A[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}\z/.match?(@value.to_s)
        end

        def ar_adapter
          @adapter_name.downcase
        end
      end
    end
  end
end