lib/ransack/nodes/condition.rb



require 'ransack/invalid_search_error'

module Ransack
  module Nodes
    class Condition < Node
      i18n_word :attribute, :predicate, :combinator, :value
      i18n_alias a: :attribute, p: :predicate,
                 m: :combinator, v: :value

      attr_accessor :predicate

      # The effective null sentinel, captured when the condition is built. A
      # Context can be shared by several searches, each with its own
      # per-search override; reading the context lazily at SQL generation or
      # form rendering time would let a later search's setting rewrite an
      # earlier search's conditions.
      attr_reader :null_sentinel

      def initialize(context)
        super
        @null_sentinel = context&.null_sentinel
      end

      class << self
        def extract(context, key, values)
          attributes, predicate, combinator =
            extract_values_for_condition(key, context)

          if attributes.size > 0 && predicate
            condition = self.new(context)
            condition.build(
              a: attributes,
              p: predicate.name,
              m: combinator,
              v: predicate.wants_array ? Array(values) : [values]
            )
            # TODO: Figure out what to do with multiple types of attributes,
            # if anything. Tempted to go with "garbage in, garbage out" here.
            if predicate.validate(condition.values, condition.default_type, condition.null_sentinel)
              condition
            else
              nil
            end
          end
        end

        private

          def extract_values_for_condition(key, context = nil)
            str = key.dup
            name = Predicate.detect_and_strip_from_string!(str)
            predicate = Predicate.named(name)

            unless predicate || Ransack.options[:ignore_unknown_conditions]
              raise InvalidSearchError, "No valid predicate for #{key}"
            end

            if context.present?
              str = context.resolve_aliases(str)
            end

            # A name that is an attribute in its own right is never split,
            # whatever it contains.
            if context.present? && context.attribute_method?(str)
              attributes = [str]
              combinator = nil
            else
              combinator = combinator_in(str, context)
              attributes = str.split(/_and_|_or_/)
            end

            [attributes, predicate, combinator]
          end

          # A condition name carries one combinator: `a_or_b_or_c` or
          # `a_and_b`. There is no grammar for precedence between the two,
          # so a name using both is rejected under a strict search rather
          # than silently applying whichever came first to every attribute
          # (#1019). Mixed logic is what groupings are for.
          def combinator_in(str, context)
            combinators = str.scan(/_(or|and)_/).flatten.uniq
            if combinators.size > 1 && context&.strict_conditions?
              raise InvalidSearchError,
                "Mixed combinators in #{str}: a condition name may use " \
                "_and_ or _or_ but not both; use groupings for mixed logic"
            end
            combinators.first
          end
      end

      def valid?
        attributes.detect(&:valid?) && predicate && valid_arity? &&
          predicate.validate(values, default_type, null_sentinel) && valid_combinator?
      end

      def valid_arity?
        values.size <= 1 || predicate.wants_array
      end

      def attributes
        @attributes ||= []
      end
      alias :a :attributes

      def attributes=(args)
        case args
        when Array
          args.each do |attr|
            if attr.is_a?(Hash) && (attr.key?(:name) || attr.key?("name"))
              attr = attr.with_indifferent_access
              build_attributes(attr[:name], attr[:ransacker_args])
            else
              build_attributes(attr)
            end
          end
        when Hash
          args.each do |index, attrs|
            build_attributes(attrs[:name], attrs[:ransacker_args])
          end
        else
          raise ArgumentError,
            "Invalid argument (#{args.class}) supplied to attributes="
        end
      end
      alias :a= :attributes=

      def values
        @values ||= []
      end
      alias :v :values

      def values=(args)
        case args
        when Array
          args.each do |val|
            if val.is_a?(Hash) && (val.key?(:value) || val.key?("value"))
              val = val.with_indifferent_access[:value]
            end
            self.values << Value.new(@context, val)
          end
        when Hash
          args.each do |index, attrs|
            val = Value.new(@context, attrs[:value])
            self.values << val
          end
        else
          raise ArgumentError,
            "Invalid argument (#{args.class}) supplied to values="
        end
      end
      alias :v= :values=

      # A supplied combinator wins; the one an expanded alias carried is the
      # fallback, so `a: { '0' => { name: 'term' } }` with `term` aliasing
      # `name_or_email` ORs its two attributes without an `m` in the params.
      # The fallback covers a missing or blank `m` only: an unknown one that
      # `Node#combinator=` normalised to nil still fails `valid_combinator?`,
      # as it does for any other multi-attribute condition.
      def combinator
        return nil unless @attributes.size > 1

        @combinator || (@alias_combinator unless @combinator_supplied)
      end

      def combinator=(val)
        super
        @combinator_supplied = !val.to_s.strip.empty?
      end

      alias :m= :combinator=
      alias :m :combinator

      # == build_attribute
      #
      #  This method was originally called from Nodes::Grouping#new_condition
      #  only, without arguments, without #valid? checking, to build a new
      #  grouping condition.
      #
      #  After refactoring in 235eae3, it is now called from 2 places:
      #
      #  1. Nodes::Condition#attributes=, with +name+ argument passed or +name+
      #     and +ransacker_args+. Attributes are included only if #valid?.
      #
      #  2. Nodes::Grouping#new_condition without arguments. In this case, the
      #     #valid? conditional needs to be bypassed, otherwise nothing is
      #     built. The `name.nil?` conditional below currently does this.
      #
      #  TODO: Add test coverage for this behavior and ensure that `name.nil?`
      #  isn't fixing issue #701 by introducing untested regressions.
      #
      # An attribute named in advanced-search params (`a: { '0' => { name:
      # 'term' } }`) arrives raw, unlike a simple key, which `Condition.extract`
      # resolves before it gets here. So a `ransack_alias` is expanded here the
      # same way: an alias to one attribute is built under that name, and an
      # alias to a compound becomes one attribute per segment, joined by the
      # compound's combinator (#1728). Left alone, the alias passed `valid?`
      # by being allowlisted and bound a column that does not exist.
      def build_attributes(name, ransacker_args = [])
        return build_attribute(name, ransacker_args) if name.blank? || @context.nil?

        resolved = @context.resolve_aliases(name.to_s)
        if resolved == name.to_s || @context.attribute_method?(resolved)
          build_attribute(resolved, ransacker_args)
        else
          @alias_combinator = resolved[/_(or|and)_/, 1]
          resolved.split(/_and_|_or_/).each { |segment| build_attribute(segment, ransacker_args) }
        end
      end

      def build_attribute(name = nil, ransacker_args = [])
        Attribute.new(@context, name, ransacker_args).tap do |attribute|
          @context.bind(attribute, attribute.name)
          self.attributes << attribute if name.nil? || attribute.valid?
          if predicate && !negative?
            @context.lock_association(attribute.parent)
          end
        end
      end

      def build_value(val = nil)
        Value.new(@context, val).tap do |value|
          self.values << value
        end
      end

      def value
        if predicate.wants_array
          values.map { |v| cast_unless_null_sentinel(v) }
        else
          cast_unless_null_sentinel(values.first)
        end
      end

      # Casting the sentinel here would turn it into a real value on a
      # typed column (e.g. an integer column casts it to 0), so a form
      # re-rendering this condition's current value would resubmit that
      # literal instead of the sentinel.
      def cast_unless_null_sentinel(v)
        if Constants.null_sentinel_predicate?(predicate.name, null_sentinel) &&
           Constants.null_sentinel_value?(v.value, null_sentinel)
          v.value
        else
          v.cast(default_type)
        end
      end

      # The long spellings route through the short setters: `p=` resolves a
      # Predicate object, whereas the `predicate=` attribute writer would store
      # the bare name. `predicate_name` is the spelling the ransacker docs
      # used for years, so it is accepted too (#1009).
      LONG_KEYS = {
        'attributes' => 'a', 'values' => 'v', 'predicate' => 'p',
        'predicate_name' => 'p', 'combinator' => 'm'
      }.freeze

      def build(params)
        params.with_indifferent_access.each do |key, value|
          if key.match(/^(a|v|p|m|attributes|values|predicate|predicate_name|combinator)$/)
            self.send("#{LONG_KEYS.fetch(key, key)}=", value)
          end
        end

        self
      end

      def persisted?
        false
      end

      def key
        @key ||= attributes.map(&:name).join("_#{combinator}_") +
          "_#{predicate.name}"
      end

      def eql?(other)
        self.class == other.class &&
        self.attributes == other.attributes &&
        self.predicate == other.predicate &&
        self.values == other.values &&
        self.combinator == other.combinator
      end
      alias :== :eql?

      def hash
        [attributes, predicate, values, combinator].hash
      end

      def predicate_name=(name)
        self.predicate = Predicate.named(name)
        if predicate && !negative?
          attributes.each { |a| context.lock_association(a.parent) }
        end
        @predicate
      end
      alias :p= :predicate_name=

      def predicate_name
        predicate.name if predicate
      end
      alias :p :predicate_name

      def validated_values
        selected = values.select { |v| predicate.validator.call(v.value) }
        return selected unless null_sentinel_requested?

        # The sentinel is a marker to detect, never a value to search for
        # or to cast to the column's type, so it is excluded from the
        # values #format_predicate builds the ordinary IN/EQ node from.
        selected.reject { |v| Constants.null_sentinel_value?(v.value, null_sentinel) }
      end

      def null_sentinel_requested?
        Constants.null_sentinel_requested?(predicate.name, values, null_sentinel)
      end

      def casted_values_for_attribute(attr)
        validated_values.map { |v| v.cast(predicate.type || attr.type) }
      end

      def formatted_values_for_attribute(attr)
        formatted = casted_values_for_attribute(attr).map do |val|
          if attr.ransacker && attr.ransacker.formatter
            val = attr.ransacker.formatter.call(val)
          end
          val = predicate.format(val)
          if val.is_a?(String) && val.include?('%')
            val = Arel::Nodes::Quoted.new(val)
          end
          val
        end
        if predicate.wants_array
          formatted
        else
          formatted.first
        end
      end

      def arel_predicate_for_attribute(attr)
        if predicate.arel_predicate === Proc
          values = casted_values_for_attribute(attr)
          unless predicate.wants_array
            values = values.first
          end
          predicate.arel_predicate.call(values)
        else
          predicate.arel_predicate
        end
      end

      # The column (or ransacker expression) a predicate compares against.
      # A case-insensitive predicate lowers it, unless the dialect has a
      # case-insensitive LIKE of its own (ILIKE), in which case the lowering
      # is left to the database. LOWER() is applied as a function rather than
      # through Arel's Attribute#lower so that a ransacker returning any Arel
      # node — a SQL literal, an infix operation — is lowered too (#1357).
      def attr_value_for_attribute(attr)
        if predicate.case_insensitive && !context.dialect.case_insensitive_like?
          Arel::Nodes::NamedFunction.new('LOWER'.freeze, [attr.attr])
        else
          attr.attr
        end
      end

      def default_type
        predicate.type || (attributes.first && attributes.first.type)
      end

      def inspect
        data = [
          ['attributes'.freeze, a.try(:map, &:name)],
          ['predicate'.freeze, p],
          [Constants::COMBINATOR, m],
          ['values'.freeze, v.try(:map, &:value)]
        ]
        .reject { |e| e[1].blank? }
        .map { |v| "#{v[0]}: #{v[1]}" }
        .join(', '.freeze)
        "Condition <#{data}>"
      end

      def negative?
        predicate.negative?
      end

      def arel_predicate
        attributes.map { |attribute|
          association = attribute.parent
          parent_table = association.table

          predicate = if negative? && attribute.associated_collection? && not_nested_condition(attribute, parent_table)
            query = context.build_correlated_subquery(association)
            context.remove_association(association)

            case self.predicate_name
            when 'not_null'
              if self.value
                query.where(format_predicate(attribute))
                Arel::Nodes::In.new(context.primary_key, Arel.sql(query.to_sql))
              else
                query.where(format_predicate(attribute).not)
                Arel::Nodes::NotIn.new(context.primary_key, Arel.sql(query.to_sql))
              end
            when 'not_cont'
              query.where(like_predicate(attribute, 'matches'.freeze))
              Arel::Nodes::NotIn.new(context.primary_key, Arel.sql(query.to_sql))
            else
              query.where(format_predicate(attribute).not)
              Arel::Nodes::NotIn.new(context.primary_key, Arel.sql(query.to_sql))
            end
          else
            format_predicate(attribute)
          end

          predicate
        }.reduce(combinator_method)
      end

      def not_nested_condition(attribute, parent_table)
        parent_table.class != Arel::Nodes::TableAlias && attribute.name.starts_with?(parent_table.name)
      end

      private

      def combinator_method
        combinator === Constants::OR ? :or : :and
      end

      def format_predicate(attribute)
        arel_pred = arel_predicate_for_attribute(attribute)
        arel_values = formatted_values_for_attribute(attribute)

        # The `present` / `blank` predicate formatters produce `[nil, '']` regardless
        # of column type. The empty-string half has no meaning on non-string columns:
        # ActiveRecord casts `''` to NULL for those types, producing the
        # always-UNKNOWN `column != NULL` (or `column = NULL`) clause. Strip the
        # empty string when the attribute is not a string-like column.
        if arel_values.is_a?(Array) && arel_values.include?(''.freeze) && !string_like_attribute?(attribute)
          arel_values = arel_values.reject { |v| v == ''.freeze }
        end

        # A length predicate compares LENGTH(column) rather than the column.
        attr_value = length_predicate? ? length_function_for_attribute(attribute) : attr_value_for_attribute(attribute)

        if like_predicate?(arel_pred)
          predicate = like_predicate(attribute, arel_pred, arel_values)
        else
          predicate = attr_value.public_send(arel_pred, arel_values)
        end

        if in_predicate?(predicate)
          predicate.right = predicate.right.map do |pr|
            casted_array?(pr) ? format_values_for(pr) : pr
          end
        end

        predicate = null_sentinel_requested? ? apply_null_sentinel(predicate, attr_value) : predicate

        # Replace an oversized Casted integer value with the plain integer,
        # in order to avoid ActiveModel::RangeError from Arel::Node::Casted.
        # The error can be ignored here because RDBMSs accept large numbers
        # in condition clauses. Applied recursively rather than only to the
        # top-level node, because the Casted value can sit inside an In /
        # NotIn node's `right` Array, or inside the Grouping / And / Or
        # that Arel's own `_any` / `_all` predicates build. Done here rather
        # than in #arel_predicate because the negated collection branch there
        # renders this node to SQL for its correlated subquery straight away,
        # which is where the Casted value would raise. Runs last so it also
        # covers the OR grouping the null sentinel may have added.
        unwrap_oversized_integers(predicate)

        predicate
      end

      # `validated_values` has already excluded the sentinel above, so
      # `predicate` here is built from the real values alone (or, when
      # the sentinel was the only value submitted, from an empty
      # array). An empty IN or eq_any/in_any is not `IS NULL` on its
      # own terms (an empty `IN` is a literal false, and an empty
      # `eq_any` builds a broken Grouping), so that case is replaced
      # outright rather than OR'd.
      def apply_null_sentinel(predicate, attr_value)
        if validated_values.empty?
          attr_value.eq(nil)
        else
          predicate.or(attr_value.eq(nil))
        end
      end

      def in_predicate?(predicate)
        return unless defined?(Arel::Nodes::Casted)
        predicate.class == Arel::Nodes::In || predicate.class == Arel::Nodes::NotIn
      end

      LIKE_PREDICATES = %w[
        matches matches_any matches_all
        does_not_match does_not_match_any does_not_match_all
      ].freeze

      def like_predicate?(arel_predicate)
        LIKE_PREDICATES.include?(arel_predicate)
      end

      # Builds a LIKE / NOT LIKE node (or the _any / _all forms).
      #
      # The value is wrapped in Arel::Nodes.build_quoted so that Active
      # Record's `normalizes` cannot rewrite the wildcards, and the escape
      # character is passed so the escaping done by `escape_wildcards` is
      # honoured: without an explicit ESCAPE clause SQLite treats a backslash
      # as a literal character (#1581). The compound forms iterate over an
      # Array of patterns, so each element is quoted on its own.
      #
      # Only the `i_` predicates ask for a case-insensitive match. Arel's
      # PostgreSQL visitor renders a case-sensitive `matches` as LIKE and the
      # other kind as ILIKE; before 6.0 the flag was never passed and every
      # LIKE on PostgreSQL became ILIKE (#1421).
      def like_predicate(attribute, arel_predicate, values = formatted_values_for_attribute(attribute))
        attr_value = attr_value_for_attribute(attribute)
        escape = Constants::LIKE_ESCAPE_CHARACTER
        case_sensitive = !predicate.case_insensitive

        case arel_predicate
        # Arel's does_not_match_any / _all take no case_sensitive argument,
        # unlike the other four, so they are assembled here the same way Arel
        # assembles them.
        when 'does_not_match_any'.freeze
          nodes = values.map { |v| attr_value.does_not_match(Arel::Nodes.build_quoted(v), escape, case_sensitive) }
          Arel::Nodes::Grouping.new(nodes.inject(&:or))
        when 'does_not_match_all'.freeze
          nodes = values.map { |v| attr_value.does_not_match(Arel::Nodes.build_quoted(v), escape, case_sensitive) }
          Arel::Nodes::Grouping.new(Arel::Nodes::And.new(nodes))
        else
          quoted = if values.is_a?(Array)
            values.map { |v| Arel::Nodes.build_quoted(v) }
          else
            Arel::Nodes.build_quoted(values)
          end
          attr_value.public_send(arel_predicate, quoted, escape, case_sensitive)
        end
      end

      STRING_LIKE_TYPES = %i[string text citext].freeze

      def string_like_attribute?(attribute)
        type = attribute.type
        # Treat unknown types as string-like (conservative: keep the existing
        # empty-string comparison rather than silently dropping it).
        type.nil? || STRING_LIKE_TYPES.include?(type.to_sym)
      end

      def casted_array?(predicate)
        predicate.is_a?(Arel::Nodes::Casted) && predicate.value.is_a?(Array)
      end

      def format_values_for(predicate)
        predicate.value.map do |val|
          val.is_a?(String) ? Arel::Nodes.build_quoted(val) : val
        end
      end

      # Walks a predicate node for the shapes `#arel_predicate` can produce
      # (a single Binary, an In/NotIn holding an Array, or several of these
      # combined with Grouping/And/Or) and replaces each integer Casted
      # value it finds with the plain integer, in place.
      def unwrap_oversized_integers(node)
        case node
        when Arel::Nodes::Grouping
          unwrap_oversized_integers(node.expr)
        when Arel::Nodes::And, Arel::Nodes::Or
          # Both are Nary: `grouping_any` (behind every `_any` predicate)
          # builds a single Or with one child per value, not a pairwise
          # chain, so `left`/`right` alone would miss the third value on.
          node.children.each { |child| unwrap_oversized_integers(child) }
        when Arel::Nodes::In, Arel::Nodes::NotIn
          if node.right.is_a?(Array)
            node.right = node.right.map { |v| plain_integer(v) || v }
          else
            # In / NotIn are Binary subclasses and match before that arm, so a
            # bare Casted right (not an Array) needs the same handling as one.
            unwrap_binary_right(node)
          end
        when Arel::Nodes::Binary
          unwrap_binary_right(node)
        end
      end

      def unwrap_binary_right(node)
        plain_value = plain_integer(node.right)
        node.right = plain_value if plain_value
      end

      def plain_integer(arel_node)
        return nil unless arel_node.is_a?(Arel::Nodes::Casted)

        relation, name = arel_node.attribute.values
        attribute_type = relation.type_for_attribute(name).type
        return nil unless attribute_type == :integer && arel_node.value.is_a?(Integer)

        arel_node.value
      end

      def length_predicate?
        predicate_name.to_s.start_with?('length_')
      end

      def length_function_for_attribute(attribute)
        Arel::Nodes::NamedFunction.new(
          context.dialect.length_function,
          [attr_value_for_attribute(attribute)]
        )
      end

      def valid_combinator?
        attributes.size < 2 || Constants::AND_OR.include?(combinator)
      end
    end
  end
end