module SearchFlip # The SearchFlip::Filterable mixin provides chainable methods like # #where, #exists, #range, etc to add search filters to a criteria. # # @example # CommentIndex.where(public: true) # CommentIndex.exists(:user_id) # CommentIndex.range(:created_at, gt: Date.today - 7) module Filterable def self.included(base) base.class_eval do attr_accessor :must_values, :must_not_values, :filter_values end end # Adds a query string query to the criteria while using AND as the default # operator unless otherwise specified. Check out the Elasticsearch docs # for further details. # # @example # CommentIndex.search("message:hello OR message:worl*") # # @param q [String] The query string query # # @param options [Hash] Additional options for the query string query, like # eg default_operator, default_field, etc. # # @return [SearchFlip::Criteria] A newly created extended criteria def search(q, options = {}) return self if q.to_s.strip.length.zero? must(query_string: { query: q, default_operator: :AND }.merge(options)) end # Adds filters to your criteria for the supplied hash composed of # field-to-filter mappings which specify terms, term or range filters, # depending on the type of the respective hash value, namely array, range # or scalar type like Fixnum, String, etc. # # @example # CommentIndex.where(id: [1, 2, 3], state: ["approved", "declined"]) # CommentIndex.where(id: 1 .. 100) # CommentIndex.where(created_at: Time.parse("2016-01-01") .. Time.parse("2017-01-01")) # CommentIndex.where(id: 1, message: "hello") # CommentIndex.where(state: nil) # # @param hash [Hash] A field-to-filter mapping specifying filter values for # the respective fields # # @return [SearchFlip::Criteria] A newly created extended criteria def where(hash) hash.inject(fresh) do |memo, (key, value)| if value.is_a?(Array) memo.filter(terms: { key => value }) elsif value.is_a?(Range) memo.filter(range: { key => { gte: value.min, lte: value.max } }) elsif value.nil? memo.must_not(exists: { field: key }) else memo.filter(term: { key => value }) end end end # Adds filters to exclude documents in accordance to the supplied hash # composed of field-to-filter mappings. Check out #where for further # details. # # @see #where See #where for further details # # @example # CommentIndex.where_not(state: "approved") # CommentIndex.where_not(created_at: Time.parse("2016-01-01") .. Time.parse("2017-01-01")) # CommentIndex.where_not(id: [1, 2, 3], state: "new") # CommentIndex.where_not(state: nil) # # @param hash [Hash] A field-to-filter mapping specifying filter values for the # respective fields # # @return [SearchFlip::Criteria] A newly created extended criteria def where_not(hash) hash.inject(fresh) do |memo, (key, value)| if value.is_a?(Array) memo.must_not(terms: { key => value }) elsif value.is_a?(Range) memo.must_not(range: { key => { gte: value.min, lte: value.max } }) elsif value.nil? memo.filter(exists: { field: key }) else memo.must_not(term: { key => value }) end end end # Adds raw filter queries to the criteria. # # @example # CommentIndex.filter(term: { state: "new" }) # CommentIndex.filter(range: { created_at: { gte: Time.parse("2016-01-01") }}) # # @param args [Array, Hash] The raw filter query arguments # # @return [SearchFlip::Criteria] A newly created extended criteria def filter(clause) fresh.tap do |criteria| criteria.filter_values = (filter_values || []) + Helper.wrap_array(clause) end end # Adds raw must queries to the criteria. # # @example # CommentIndex.must(term: { state: "new" }) # CommentIndex.must(range: { created_at: { gt: Time.parse("2016-01-01") }}) # # @param args [Array, Hash] The raw must query arguments # # @return [SearchFlip::Criteria] A newly created extended criteria def must(clause, bool_options = {}) fresh.tap do |criteria| criteria.must_values = (must_values || []) + Helper.wrap_array(clause) end end # Adds raw must_not queries to the criteria. # # @example # CommentIndex.must_not(term: { state: "new" }) # CommentIndex.must_not(range: { created_at: { gt: Time.parse"2016-01-01") }}) # # @param args [Array, Hash] The raw must_not query arguments # # @return [SearchFlip::Criteria] A newly created extended criteria def must_not(clause) fresh.tap do |criteria| criteria.must_not_values = (must_not_values || []) + Helper.wrap_array(clause) end end # Returns all added queries and filters, including post filters, as a raw # query. # # @example # CommentIndex.where(state: "new").search("text").to_query # # => {:bool=>{:filter=>[{:term=>{:state=>"new"}}], :must=>[{:query_string=>{:query=>"text", ...}}]}} # # CommentIndex.must(term: { state: "new" }).to_query # # => {:term=>{:state=>"new"}} # # @return [Hash] The raw query def to_query must_clauses = must_values.to_a must_not_clauses = must_not_values.to_a + post_must_not_values.to_a filter_clauses = post_must_values.to_a + filter_values.to_a + post_filter_values.to_a return must_clauses.first if must_clauses.size == 1 && must_not_clauses.empty? && filter_clauses.empty? { bool: { must: must_clauses, must_not: must_not_clauses, filter: filter_clauses }.reject { |_, value| value.empty? } } end # Adds a raw should query to the criteria. # # @example # CommentIndex.should([ # { term: { state: "new" } }, # { term: { state: "reviewed" } } # ]) # # @param args [Array] The raw should query arguments # # @return [SearchFlip::Criteria] A newly created extended criteria def should(clause) must(bool: { should: clause }) end # Adds a range filter to the criteria without being forced to specify the # left and right end of the range, such that you can eg simply specify lt, # lte, gt and gte. For fully specified ranges, you can as well use #where, # etc. Check out the Elasticsearch docs for further details regarding the # range filter. # # @example # CommentIndex.range(:created_at, gte: Time.parse("2016-01-01")) # CommentIndex.range(:likes_count, gt: 10, lt: 100) # # @param field [Symbol, String] The field name to specify the range for # @param options [Hash] The range filter specification, like lt, lte, etc # # @return [SearchFlip::Criteria] A newly created extended criteria def range(field, options = {}) filter(range: { field => options }) end # Adds a match all filter to the criteria, which simply matches all # documents. This can be eg be used within filter aggregations or for # filter chaining. Check out the Elasticsearch docs for further details. # # @example Basic usage # CommentIndex.match_all # # @example Filter chaining # query = CommentIndex.match_all # query = query.where(public: true) unless current_user.admin? # # @example Filter aggregation # query = CommentIndex.aggregate(filtered_tags: {}) do |aggregation| # aggregation = aggregation.match_all # aggregation = aggregation.where(user_id: current_user.id) if current_user # aggregation = aggregation.aggregate(:tags) # end # # query.aggregations(:filtered_tags).tags.buckets.each { ... } # # @param options [Hash] Options for the match_all filter, like eg boost # # @return [SearchFlip::Criteria] A newly created extended criteria def match_all(options = {}) filter(match_all: options) end # Adds a match none filter to the criteria, which simply matches none # documents at all. Check out the Elasticsearch docs for further details. # # @example Basic usage # CommentIndex.match_none # # @example Filter chaining # query = CommentIndex.search("...") # query = query.match_none unless current_user.admin? # # @return [SearchFlip::Criteria] A newly created extended criteria def match_none filter(match_none: {}) end # Adds an exists filter to the criteria, which selects all documents for # which the specified field has a non-null value. # # @example # CommentIndex.exists(:notified_at) # # @param field [Symbol, String] The field that should have a non-null value # # @return [SearchFlip::Criteria] A newly created extended criteria def exists(field) filter(exists: { field: field }) end # Adds an exists not query to the criteria, which selects all documents # for which the specified field's value is null. # # @example # CommentIndex.exists_not(:notified_at) # # @param field [Symbol, String] The field that should have a null value # # @return [SearchFlip::Criteria] A newly created extended criteria def exists_not(field) must_not(exists: { field: field }) end end end