lib/ransack/constants.rb



module Ransack
  module Constants
    OR                  = 'or'.freeze
    AND                 = 'and'.freeze

    CAP_SEARCH          = 'Search'.freeze
    SEARCH              = 'search'.freeze
    SEARCHES            = 'searches'.freeze

    ATTRIBUTE           = 'attribute'.freeze
    ATTRIBUTES          = 'attributes'.freeze
    COMBINATOR          = 'combinator'.freeze

    TWO_COLONS          = '::'.freeze
    UNDERSCORE          = '_'.freeze
    LEFT_PARENTHESIS    = '('.freeze
    Q                   = 'q'.freeze
    I                   = 'i'.freeze
    DOT_ASTERIX         = '.*'.freeze

    STRING_JOIN         = 'string_join'.freeze
    ASSOCIATION_JOIN    = 'association_join'.freeze
    STASHED_JOIN        = 'stashed_join'.freeze
    JOIN_NODE           = 'join_node'.freeze

    TRUE_VALUES = [true, 1, '1', 't', 'T', 'true', 'TRUE'].to_set
    FALSE_VALUES = [false, 0, '0', 'f', 'F', 'false', 'FALSE'].to_set
    BOOLEAN_VALUES = (TRUE_VALUES + FALSE_VALUES).freeze

    AND_OR              = ['and'.freeze, 'or'.freeze].freeze
    IN_NOT_IN           = ['in'.freeze, 'not_in'.freeze].freeze
    SUFFIXES            = ['_any'.freeze, '_all'.freeze].freeze
    AREL_PREDICATES     = [
      'eq'.freeze, 'not_eq'.freeze,
      'matches'.freeze, 'does_not_match'.freeze,
      'lt'.freeze, 'lteq'.freeze,
      'gt'.freeze, 'gteq'.freeze,
      'in'.freeze, 'not_in'.freeze
    ].freeze
    A_S_I               = ['a'.freeze, 's'.freeze, 'i'.freeze].freeze

    # The predicates `Ransack.options[:null_sentinel]` applies to. Limited to
    # the positive equality family (`eq`, `in`, and their `_any` compounds)
    # because these are the only predicates where "or is null" is
    # unambiguous. The `_all` compounds are excluded because they are
    # conjunctive: `eq_all`/`in_all` require a single column to equal every
    # supplied value at once, so ORing in `IS NULL` would widen a query that
    # already matches nothing (or almost nothing) rather than narrow it. A
    # negative predicate treats the sentinel as a literal value for the same
    # reason of unambiguity: `not_eq` matching the sentinel would otherwise
    # mean "is not unassigned", which reads as the opposite of what a caller
    # submitting it alone almost certainly wants.
    NULL_SENTINEL_PREDICATES = [
      'eq'.freeze, 'in'.freeze, 'eq_any'.freeze, 'in_any'.freeze
    ].freeze

    EQ                  = 'eq'.freeze
    NOT_EQ              = 'not_eq'.freeze
    EQ_ANY              = 'eq_any'.freeze
    NOT_EQ_ALL          = 'not_eq_all'.freeze
    CONT                = 'cont'.freeze

    RANSACK_SLASH_SEARCHES = 'ransack/searches'.freeze
    RANSACK_SLASH_SEARCHES_SLASH_SEARCH = 'ransack/searches/search'.freeze

    DISTINCT = 'DISTINCT '.freeze

    # The most `_`-separated segments a single condition or sort key may have
    # before Ransack rejects it as invalid instead of parsing it. Working out
    # whether a key is an attribute, an `_and_`/`_or_` compound or a path
    # through associations is superlinear in this count, and the key comes
    # straight from the query string, so an unbounded one is a CPU-exhaustion
    # denial of service (GHSA-j3f8-w227-4hh8). No real search key is this
    # long: OR-ing every column of a very wide table stays well under it.
    MAX_KEY_DEPTH = 200

    DERIVED_PREDICATES = [
      [CONT, {
        arel_predicate: 'matches'.freeze,
        formatter: proc { |v| "%#{escape_wildcards(v)}%" }
        }
      ],
      ['not_cont'.freeze, {
        arel_predicate: 'does_not_match'.freeze,
        formatter: proc { |v| "%#{escape_wildcards(v)}%" }
        }
      ],
      ['i_cont'.freeze, {
        arel_predicate: 'matches'.freeze,
        formatter: proc { |v| "%#{escape_wildcards(v.downcase)}%" },
        case_insensitive: true
        }
      ],
      ['not_i_cont'.freeze, {
        arel_predicate: 'does_not_match'.freeze,
        formatter: proc { |v| "%#{escape_wildcards(v.downcase)}%" },
        case_insensitive: true
        }
      ],
      ['start'.freeze, {
        arel_predicate: 'matches'.freeze,
        formatter: proc { |v| "#{escape_wildcards(v)}%" }
        }
      ],
      ['not_start'.freeze, {
        arel_predicate: 'does_not_match'.freeze,
        formatter: proc { |v| "#{escape_wildcards(v)}%" }
        }
      ],
      ['i_start'.freeze, {
        arel_predicate: 'matches'.freeze,
        formatter: proc { |v| "#{escape_wildcards(v.downcase)}%" },
        case_insensitive: true
        }
      ],
      ['not_i_start'.freeze, {
        arel_predicate: 'does_not_match'.freeze,
        formatter: proc { |v| "#{escape_wildcards(v.downcase)}%" },
        case_insensitive: true
        }
      ],
      ['end'.freeze, {
        arel_predicate: 'matches'.freeze,
        formatter: proc { |v| "%#{escape_wildcards(v)}" }
        }
      ],
      ['not_end'.freeze, {
        arel_predicate: 'does_not_match'.freeze,
        formatter: proc { |v| "%#{escape_wildcards(v)}" }
        }
      ],
      ['i_end'.freeze, {
        arel_predicate: 'matches'.freeze,
        formatter: proc { |v| "%#{escape_wildcards(v.downcase)}" },
        case_insensitive: true
        }
      ],
      ['not_i_end'.freeze, {
        arel_predicate: 'does_not_match'.freeze,
        formatter: proc { |v| "%#{escape_wildcards(v.downcase)}" },
        case_insensitive: true
        }
      ],
      ['true'.freeze, {
        arel_predicate: proc { |v| v ? EQ : NOT_EQ },
        compounds: false,
        type: :boolean,
        validator: proc { |v| BOOLEAN_VALUES.include?(v) },
        formatter: proc { |v| true }
        }
      ],
      ['not_true'.freeze, {
        arel_predicate: proc { |v| v ? NOT_EQ : EQ },
        compounds: false,
        type: :boolean,
        validator: proc { |v| BOOLEAN_VALUES.include?(v) },
        formatter: proc { |v| true }
        }
      ],
      ['false'.freeze, {
        arel_predicate: proc { |v| v ? EQ : NOT_EQ },
        compounds: false,
        type: :boolean,
        validator: proc { |v| BOOLEAN_VALUES.include?(v) },
        formatter: proc { |v| false }
        }
      ],
      ['not_false'.freeze, {
        arel_predicate: proc { |v| v ? NOT_EQ : EQ },
        compounds: false,
        type: :boolean,
        validator: proc { |v| BOOLEAN_VALUES.include?(v) },
        formatter: proc { |v| false }
        }
      ],
      ['present'.freeze, {
        arel_predicate: proc { |v| v ? NOT_EQ_ALL : EQ_ANY },
        compounds: false,
        type: :boolean,
        validator: proc { |v| BOOLEAN_VALUES.include?(v) },
        formatter: proc { |v| [nil, ''.freeze].freeze }
        }
      ],
      ['blank'.freeze, {
        arel_predicate: proc { |v| v ? EQ_ANY : NOT_EQ_ALL },
        compounds: false,
        type: :boolean,
        validator: proc { |v| BOOLEAN_VALUES.include?(v) },
        formatter: proc { |v| [nil, ''.freeze].freeze }
        }
      ],
      ['null'.freeze, {
        arel_predicate: proc { |v| v ? EQ : NOT_EQ },
        compounds: false,
        type: :boolean,
        validator: proc { |v| BOOLEAN_VALUES.include?(v) },
        formatter: proc { |v| nil }
        }
      ],
      ['not_null'.freeze, {
        arel_predicate: proc { |v| v ? NOT_EQ : EQ },
        compounds: false,
        type: :boolean,
        validator: proc { |v| BOOLEAN_VALUES.include?(v) },
        formatter: proc { |v| nil } }
      ],
      ['length_eq'.freeze, {
        arel_predicate: 'eq'.freeze,
        type: :integer
        }
      ],
      ['length_lt'.freeze, {
        arel_predicate: 'lt'.freeze,
        type: :integer
        }
      ],
      ['length_lteq'.freeze, {
        arel_predicate: 'lteq'.freeze,
        type: :integer
        }
      ],
      ['length_gt'.freeze, {
        arel_predicate: 'gt'.freeze,
        type: :integer
        }
      ],
      ['length_gteq'.freeze, {
        arel_predicate: 'gteq'.freeze,
        type: :integer
        }
      ]
    ].freeze

    # The character used to escape LIKE wildcards. Emitted as an explicit
    # `ESCAPE` clause alongside every LIKE / NOT LIKE predicate, so the
    # behaviour does not depend on a backend's default escape character —
    # MySQL and PostgreSQL default to backslash, SQLite has no default at all.
    LIKE_ESCAPE_CHARACTER = '\\'.freeze

  module_function
    # Escapes the LIKE wildcards `%` and `_`, and the escape character itself,
    # so they are matched literally. Paired with the `ESCAPE` clause added in
    # `Condition#format_predicate`; escaping without it silently does nothing
    # on SQLite. See https://github.com/activerecord-hackery/ransack/issues/1581
    def escape_wildcards(unescaped)
      unescaped.to_s.gsub(/([\\%_])/) { "#{LIKE_ESCAPE_CHARACTER}#{$1}" }
    end

    def null_sentinel_predicate?(predicate_name, sentinel)
      sentinel && NULL_SENTINEL_PREDICATES.include?(predicate_name)
    end

    def null_sentinel_value?(value, sentinel)
      sentinel && value == sentinel
    end

    def null_sentinel_requested?(predicate_name, values, sentinel)
      null_sentinel_predicate?(predicate_name, sentinel) &&
        values.any? { |v| null_sentinel_value?(v.value, sentinel) }
    end
  end
end