diff --git a/lib/stretchy/querying.rb b/lib/stretchy/querying.rb index a91666d..405467c 100644 --- a/lib/stretchy/querying.rb +++ b/lib/stretchy/querying.rb @@ -1,13 +1,13 @@ module Stretchy module Querying - delegate :first, :first!, :last, :last!, :exists?, :has_field, :any?, :many?, to: :all - delegate :order, :limit, :size, :sort, :rewhere, :eager_load, :includes, :create_with, :none, :unscope, to: :all - delegate :or_filter, :fields, :source, :highlight, to: :all - delegate :neural_sparse, :neural, :hybrid, to: :all + delegate :first, :first!, :last, :last!, to: :all + delegate :exists?, :any?, :many?, :includes, to: :all + delegate :rewhere, :eager_load, :create_with, :none, :unscope, to: :all + delegate :routing, :search_options, to: :all + + delegate *Stretchy::Relations::QueryMethods.registry, to: :all delegate *Stretchy::Relations::AggregationMethods::AGGREGATION_METHODS, to: :all - delegate :skip_callbacks, :routing, :search_options, to: :all - delegate :must, :must_not, :should, :where_not, :where, :filter_query, :query_string, :regexp, to: :all def fetch_results(es) if es.count? @@ -17,6 +17,5 @@ def fetch_results(es) end end - end end diff --git a/lib/stretchy/relation.rb b/lib/stretchy/relation.rb index ad3254b..44b21d8 100644 --- a/lib/stretchy/relation.rb +++ b/lib/stretchy/relation.rb @@ -3,6 +3,7 @@ module Stretchy # It provides methods for querying and manipulating the documents. class Relation + # These methods cannot be used with the `delete_all` method. INVALID_METHODS_FOR_DELETE_ALL = [:limit, :offset] @@ -146,7 +147,7 @@ def inspect message.unshift entries.join(', ') unless entries.size.zero? "#<#{self.class.name} #{message.join(', ')}>" rescue StandardError => e - e + Stretchy.logger.error e.message raise e end end diff --git a/lib/stretchy/relations/query_builder.rb b/lib/stretchy/relations/query_builder.rb index bdfb0a1..f0e6b26 100644 --- a/lib/stretchy/relations/query_builder.rb +++ b/lib/stretchy/relations/query_builder.rb @@ -51,6 +51,10 @@ def shoulds @shoulds ||= compact_where(values[:should]) end + def ids + @ids ||= values[:ids] + end + def regexes @regexes ||= values[:regexp] end @@ -105,7 +109,7 @@ def to_elastic private def missing_bool_query? - query.nil? && must_nots.nil? && shoulds.nil? && regexes.nil? + query.blank? && must_nots.nil? && shoulds.nil? && regexes.nil? end def missing_query_string? @@ -121,12 +125,15 @@ def missing_neural? end def no_query? - missing_bool_query? && missing_query_string? && missing_query_filter? && missing_neural? + missing_bool_query? && missing_query_string? && missing_query_filter? && missing_neural? && ids.nil? end def build_query return if no_query? structure.query do + structure.ids do + structure.values ids.flatten.compact.uniq + end unless ids.nil? structure.hybrid do structure.queries do diff --git a/lib/stretchy/relations/query_methods.rb b/lib/stretchy/relations/query_methods.rb index a626e72..7293bc4 100644 --- a/lib/stretchy/relations/query_methods.rb +++ b/lib/stretchy/relations/query_methods.rb @@ -3,8 +3,28 @@ module Relations module QueryMethods extend ActiveSupport::Concern + @_registry = [] - MULTI_VALUE_METHODS = [ + class << self + # Define the register! method + def register!(*methods) + @_registry += methods + end + + # Define a method to access the registry + def registry + @_registry.flatten.compact.uniq + end + end + # Load all the query methods + Dir["#{File.dirname(__FILE__)}/query_methods/*.rb"].each do |file| + basename = File.basename(file, '.rb') + module_name = basename.split('_').collect(&:capitalize).join + mod = const_get(module_name) + include mod + end + + MULTI_VALUE_METHODS = [ :where, :order, :field, @@ -22,18 +42,10 @@ module QueryMethods :neural_sparse, :neural, :hybrid, - :regexp + :regexp, + :ids ] - SINGLE_VALUE_METHODS = [:size] - - class WhereChain - def initialize(scope) - @scope = scope - end - end - - MULTI_VALUE_METHODS.each do |name| class_eval <<-CODE, __FILE__, __LINE__ + 1 def #{name}_values # def select_values @@ -47,6 +59,8 @@ def #{name}_values=(values) # def select_values=(values) CODE end + SINGLE_VALUE_METHODS = [:size] + SINGLE_VALUE_METHODS.each do |name| class_eval <<-CODE, __FILE__, __LINE__ + 1 def #{name}_value # def readonly_value @@ -65,510 +79,6 @@ def #{name}_value=(value) # def readonly_value=(value) end - # Allows you to add one or more sorts on specified fields. - # - # @overload order(attribute: direction, ...) - # @param attribute [Symbol] the attribute to sort by - # @param direction [Symbol] the direction to sort in (:asc or :desc) - # - # @overload order(attribute: {order: direction, mode: mode, ...}, ...) - # @param params [Hash] attributes to sort by - # @param params [Symbol] :attribute the attribute name as key to sort by - # @param options [Hash] a hash containing possible sorting options - # @option options [Symbol] :order the direction to sort in (:asc or :desc) - # @option options [Symbol] :mode the mode to use for sorting (:avg, :min, :max, :sum, :median) - # @option options [Symbol] :numeric_type the numeric type to use for sorting (:double, :long, :date, :date_nanos) - # @option options [Symbol] :missing the value to use for documents without the field - # @option options [Hash] :nested the nested sorting options - # @option nested [String] :path the path to the nested object - # @option nested [Hash] :filter the filter to apply to the nested object - # @option nested [Hash] :max_children the maximum number of children to consider per root document when picking the sort value. Defaults to unlimited - # - # @example - # Model.order(created_at: :asc) - # # Elasticsearch equivalent - # #=> "sort" : [{"created_at" : "asc"}] - # - # Model.order(age: :desc, name: :asc, price: {order: :desc, mode: :avg}) - # - # # Elasticsearch equivalent - # #=> "sort" : [ - # { "price" : {"order" : "desc", "mode": "avg"}}, - # { "name" : "asc" }, - # { "age" : "desc" } - # ] - # - # @return [Stretchy::Relation] a new relation with the specified order - # @see #sort - def order(*args) - check_if_method_has_arguments!(:order, args) - spawn.order!(*args) - end - - def order!(*args) # :nodoc: - self.order_values += args.first.zip.map(&:to_h) - self - end - - # Alias for {#order} - # @see #order - alias :sort :order - - - # Allows you to skip callbacks for the specified fields that are added by query_must_have for - # the current query. - # - # @example - # Model.skip_callbacks(:routing) - def skip_callbacks(*args) - spawn.skip_callbacks!(*args) - end - - def skip_callbacks!(*args) # :nodoc: - self.skip_callbacks_values += args - self - end - - alias :sort :order - - - # Sets the maximum number of records to be retrieved. - # - # @param args [Integer] the maximum number of records to retrieve - # - # @example - # Model.size(10) - # - # @return [ActiveRecord::Relation] a new relation, which reflects the limit - # @see #limit - def size(args) - spawn.size!(args) - end - - def size!(args) # :nodoc: - self.size_value = args - self - end - - # Alias for {#size} - # @see #size - alias :limit :size - - - - - # Adds conditions to the query. - # - # Each argument is a hash where the key is the attribute to filter by and the value is the value to match. - # - # @overload where(*rest) - # @param rest [Array] keywords containing attribute-value pairs to match - # - # @example - # Model.where(price: 10, color: :green) - # - # # Elasticsearch equivalent - # # => "query" : { - # "bool" : { - # "must" : [ - # { "term" : { "price" : 10 } }, - # { "term" : { "color" : "green" } } - # ] - # } - # } - # - # .where acts as a convienence method for adding conditions to the query. It can also be used to add - # range , regex, terms, and id queries through shorthand parameters. - # - # @example - # Model.where(price: {gte: 10, lte: 20}) - # Model.where(age: 19..33) - # Model.where(color: /gr(a|e)y/) - # Model.where(id: [10, 22, 18]) - # Model.where(names: ['John', 'Jane']) - # - # @return [ActiveRecord::Relation, WhereChain] a new relation, which reflects the conditions, or a WhereChain if opts is :chain - # @see #must - def where(opts = :chain, *rest) - if opts == :chain - WhereChain.new(spawn) - elsif opts.blank? - self - else - opts.each do |key, value| - case value - when Range - between(value, key) - when Hash - filter_query(:range, key => value) if value.keys.any? { |k| [:gte, :lte, :gt, :lt].include?(k) } - when Regexp - regexp(Hash[key, value]) - when Array - # handle ID queries - # if [:id, :_id].include?(key) - - # else - spawn.where!(opts, *rest) - # end - else - spawn.where!(opts, *rest) - end - end - - self - - end - end - - - def where!(opts, *rest) # :nodoc: - if opts == :chain - WhereChain.new(self) - else - self.where_values += build_where(opts, rest) - self - end - end - - # Alias for {#where} - # @see #where - alias :must :where - - - concerning :Neural do - def neural_sparse(opts) - spawn.neural_sparse!(opts) - end - - def neural_sparse!(opts) # :nodoc: - self.neural_sparse_values += [opts] - self - end - - def neural(opts) - spawn.neural!(opts) - end - - def neural!(opts) # :nodoc: - self.neural_values += [opts] - self - end - - def hybrid(opts) - spawn.hybrid!(opts) - end - - def hybrid!(opts) # :nodoc: - self.hybrid_values += [opts] - self - end - end - # Adds a regexp condition to the query. - # - # @param field [Hash] the field to filter by and the Regexp to match - # @param opts [Hash] additional options for the regexp query - # - :flags [String] the flags to use for the regexp query (e.g. 'ALL') - # - :use_keyword [Boolean] whether to use the .keyword field for the regexp query. Default: true - # - :case_insensitive [Boolean] whether to use case insensitive matching. If the regexp has ignore case flag `/regex/i`, this is automatically set to true - # - :max_determinized_states [Integer] the maximum number of states that the regexp query can produce - # - :rewrite [String] the rewrite method to use for the regexp query - # - # - # @example - # Model.regexp(:name, /john|jane/) - # Model.regexp(:name, /john|jane/i) - # Model.regexp(:name, /john|jane/i, flags: 'ALL') - # - # @return [Stretchy::Relation] a new relation, which reflects the regexp condition - # @see #where - def regexp(args) - spawn.regexp!(args) - end - - def regexp!(args) # :nodoc: - args = args.to_a - target_field, regex = args.shift - opts = args.to_h - opts.reverse_merge!(use_keyword: true) - target_field = "#{target_field}.keyword" if opts.delete(:use_keyword) - opts.merge!(case_insensitive: true) if regex.casefold? - self.regexp_values += [[Hash[target_field, regex.source], opts]] - self - end - - - - # Adds a query string to the search. - # - # The query string uses Elasticsearch's Query String Query syntax, which includes a series of terms and operators. - # Terms can be single words or phrases. Operators include AND, OR, and NOT, among others. - # Field names can be included in the query string to search for specific values in specific fields. (e.g. "eye_color: green") - # The default operator between terms are treated as OR operators. - # - # @param query [String] the query string - # @param rest [Array] additional arguments (not normally used) - # - # @example - # Model.query_string("((big cat) OR (domestic cat)) AND NOT panther eye_color: green") - # - # @return [Stretchy::Relation] a new relation, which reflects the query string - def query_string(opts = :chain, *rest) - if opts == :chain - WhereChain.new(spawn) - elsif opts.blank? - self - else - spawn.query_string!(opts, *rest) - end - end - - def query_string!(opts, *rest) # :nodoc: - if opts == :chain - WhereChain.new(self) - else - self.query_string_values += build_where(opts, rest) - self - end - end - - - - # Adds negated conditions to the query. - # - # Each argument is a hash where the key is the attribute to filter by and the value is the value to exclude. - # - # @overload must_not(*rest) - # @param rest [Array] a hash containing attribute-value pairs to exclude - # - # @example - # Model.must_not(color: 'blue', size: :large) - # - # @return [Stretchy::Relation] a new relation, which reflects the negated conditions - # @see #where_not - def must_not(opts = :chain, *rest) - if opts == :chain - WhereChain.new(spawn) - elsif opts.blank? - self - else - spawn.must_not!(opts, *rest) - end - end - - - def must_not!(opts, *rest) # :nodoc: - if opts == :chain - WhereChain.new(self) - else - self.must_not_values += build_where(opts, rest) - self - end - end - - # Alias for {#must_not} - # @see #must_not - alias :where_not :must_not - - - - # Adds optional conditions to the query. - # - # Each argument is a hash where the key is the attribute to filter by and the value is the value to match optionally. - # - # @overload should(*rest) - # @param rest [Array] additional keywords containing attribute-value pairs to match optionally - # - # @example - # Model.should(color: :pink, size: :medium) - # - # @return [Stretchy::Relation] a new relation, which reflects the optional conditions - def should(opts = :chain, *rest) - if opts == :chain - WhereChain.new(spawn) - elsif opts.blank? - self - else - spawn.should!(opts, *rest) - end - end - - def should!(opts, *rest) # :nodoc: - if opts == :chain - WhereChain.new(self) - else - self.should_values += build_where(opts, rest) - self - end - end - - - - - # @deprecated in elasticsearch 7.x+ use {#filter_query} instead - def or_filter(name, options = {}, &block) - spawn.or_filter!(name, options, &block) - end - - def or_filter!(name, options = {}, &block) # :nodoc: - self.or_filter_values += [{name: name, args: options}] - self - end - - # Adds a filter to the query. - # - # This method supports all filters supported by Elasticsearch. - # - # @overload filter_query(type, opts) - # @param type [Symbol] the type of filter to add (:range, :term, etc.) - # @param opts [Hash] a hash containing the attribute and value to filter by - # - # @example - # Model.filter_query(:range, age: {gte: 30}) - # Model.filter_query(:term, color: :blue) - # - # @return [Stretchy::Relation] a new relation, which reflects the filter - def filter_query(name, options = {}, &block) - spawn.filter_query!(name, options, &block) - end - - def filter_query!(name, options = {}, &block) # :nodoc: - self.filter_query_values += [{name: name, args: options}] - self - end - - - - - - - - - def field(*args) - spawn.field!(*args) - end - alias :fields :field - - def field!(*args) # :nodoc: - self.field_values += args - self - end - - - - # Controls which fields of the source are returned. - # - # This method supports source filtering, which allows you to include or exclude fields from the source. - # You can specify fields directly, use wildcard patterns, or use an object containing arrays - # of includes and excludes patterns. - # - # If the includes property is specified, only source fields that match one of its patterns are returned. - # You can exclude fields from this subset using the excludes property. - # - # If the includes property is not specified, the entire document source is returned, excluding any - # fields that match a pattern in the excludes property. - # - # @overload source(opts) - # @param opts [Hash, Boolean] a hash containing :includes and/or :excludes arrays, or a boolean indicating whether - # to include the source - # - # @example - # Model.source(includes: [:name, :email]) - # Model.source(excludes: [:name, :email]) - # Model.source(false) # don't include source - # - # @return [Stretchy::Relation] a new relation, which reflects the source filtering - def source(*args) - spawn.source!(*args) - end - - def source!(*args) # :nodoc: - self.source_values += args - self - end - - - - # Checks if a field exists in the documents. - # - # This is a helper for the exists filter in Elasticsearch, which returns documents - # that have at least one non-null value in the specified field. - # - # @param field [Symbol, String] the field to check for existence - # - # @example - # Model.has_field(:name) - # - # @return [ActiveRecord::Relation] a new relation, which reflects the exists filter - def has_field(field) - spawn.filter_query(:exists, {field: field}) - end - - - - - def bind(value) - spawn.bind!(value) - end - - def bind!(value) # :nodoc: - self.bind_values += [value] - self - end - - - - - - # Highlights the specified fields in the search results. - # - # @example - # Model.where(body: "turkey").highlight(:body) - # - # @param [Hash] args The fields to highlight. Each field is a key in the hash, - # and the value is another hash specifying the type of highlighting. - # For example, `{body: {type: :plain}}` will highlight the 'body' field - # with plain type highlighting. - # - # @return [Stretchy::Relation] Returns a Stretchy::Relation object, which can be used - # for chaining further query methods. - def highlight(*args) - spawn.highlight!(*args) - end - - def highlight!(*args) # :nodoc: - self.highlight_values += args - self - end - - - # Returns a chainable relation with zero records. - def none - extending(NullRelation) - end - - def none! # :nodoc: - extending!(NullRelation) - end - - - def extending(*modules, &block) - if modules.any? || block - spawn.extending!(*modules, &block) - else - self - end - end - - def extending!(*modules, &block) # :nodoc: - modules << Module.new(&block) if block - modules.flatten! - - self.extending_values += modules - extend(*extending_values) if extending_values.any? - - self - end - def build_where(opts, other = []) case opts when String, Array @@ -618,8 +128,7 @@ def check_if_method_has_arguments!(method_name, args) end end - VALID_DIRECTIONS = [:asc, :desc, :ASC, :DESC, - 'asc', 'desc', 'ASC', 'DESC'] # :nodoc: + VALID_DIRECTIONS = [:asc, :desc, :ASC, :DESC] def validate_order_args(args) args.each do |arg| @@ -631,18 +140,6 @@ def validate_order_args(args) end end - def add_relations_to_bind_values(attributes) - if attributes.is_a?(Hash) - attributes.each_value do |value| - if value.is_a?(ActiveRecord::Relation) - self.bind_values += value.bind_values - else - add_relations_to_bind_values(value) - end - end - end - end - end - end + end end diff --git a/lib/stretchy/relations/query_methods/bind.rb b/lib/stretchy/relations/query_methods/bind.rb new file mode 100644 index 0000000..5fd34a9 --- /dev/null +++ b/lib/stretchy/relations/query_methods/bind.rb @@ -0,0 +1,19 @@ +module Stretchy + module Relations + module QueryMethods + module Bind + extend ActiveSupport::Concern + def bind(value) + spawn.bind!(value) + end + + def bind!(value) # :nodoc: + self.bind_values += [value] + self + end + + QueryMethods.register!(:bind) + end + end + end +end \ No newline at end of file diff --git a/lib/stretchy/relations/query_methods/extending.rb b/lib/stretchy/relations/query_methods/extending.rb new file mode 100644 index 0000000..584a1cc --- /dev/null +++ b/lib/stretchy/relations/query_methods/extending.rb @@ -0,0 +1,29 @@ +module Stretchy + module Relations + module QueryMethods + module Extending + extend ActiveSupport::Concern + def extending(*modules, &block) + if modules.any? || block + spawn.extending!(*modules, &block) + else + self + end + end + + def extending!(*modules, &block) # :nodoc: + modules << Module.new(&block) if block + modules.flatten! + + self.extending_values += modules + extend(*extending_values) if extending_values.any? + + self + end + + QueryMethods.register!(:extending) + + end + end + end +end diff --git a/lib/stretchy/relations/query_methods/field.rb b/lib/stretchy/relations/query_methods/field.rb new file mode 100644 index 0000000..33e8aeb --- /dev/null +++ b/lib/stretchy/relations/query_methods/field.rb @@ -0,0 +1,46 @@ +module Stretchy + module Relations + module QueryMethods + module Field + extend ActiveSupport::Concern + + # Public: Specify the fields to be returned by the Elasticsearch query. + # + # This method accepts a variable number of arguments, each of which is the name of a field to be returned. + # If no arguments are provided, all fields are returned. + # + # To retrieve specific fields in the search response, use the fields parameter. + # Because it consults the index mappings, the fields parameter provides several advantages over referencing + # the `_source` directly. Specifically, the fields parameter: + # Returns each value in a standardized way that matches its mapping type + # Accepts multi-fields and field aliases + # Formats dates and spatial data types + # Retrieves runtime field values + # Returns fields calculated by a script at index time + # Returns fields from related indices using lookup runtime fields + # + # args - The Array of field names to be returned by the query (default: []). + # + # Examples + # + # Model.fields(:title, :author) + # Model.field('author.name', 'author.age') + # Model.fields('books.*') + # + # Returns a new relation with the specified fields to be returned. + def field(*args) + spawn.field!(*args) + end + alias :fields :field + + def field!(*args) # :nodoc: + self.field_values += args + self + end + + QueryMethods.register!(:field, :fields) + + end + end + end +end diff --git a/lib/stretchy/relations/query_methods/filter_query.rb b/lib/stretchy/relations/query_methods/filter_query.rb new file mode 100644 index 0000000..400fdad --- /dev/null +++ b/lib/stretchy/relations/query_methods/filter_query.rb @@ -0,0 +1,33 @@ +module Stretchy + module Relations + module QueryMethods + module FilterQuery + extend ActiveSupport::Concern + # Adds a filter to the query. + # + # This method supports all filters supported by Elasticsearch. + # + # @overload filter_query(type, opts) + # @param type [Symbol] the type of filter to add (:range, :term, etc.) + # @param opts [Hash] a hash containing the attribute and value to filter by + # + # @example + # Model.filter_query(:range, age: {gte: 30}) + # Model.filter_query(:term, color: :blue) + # + # @return [Stretchy::Relation] a new relation, which reflects the filter + def filter_query(name, options = {}, &block) + spawn.filter_query!(name, options, &block) + end + + def filter_query!(name, options = {}, &block) # :nodoc: + self.filter_query_values += [{name: name, args: options}] + self + end + + QueryMethods.register!(:filter_query) + + end + end + end +end diff --git a/lib/stretchy/relations/query_methods/has_field.rb b/lib/stretchy/relations/query_methods/has_field.rb new file mode 100644 index 0000000..b88952a --- /dev/null +++ b/lib/stretchy/relations/query_methods/has_field.rb @@ -0,0 +1,25 @@ +module Stretchy + module Relations + module QueryMethods + module HasField + extend ActiveSupport::Concern + # Checks if a field exists in the documents. + # + # This is a helper for the exists filter in Elasticsearch, which returns documents + # that have at least one non-null value in the specified field. + # + # @param field [Symbol, String] the field to check for existence + # + # @example + # Model.has_field(:name) + # + # @return [ActiveRecord::Relation] a new relation, which reflects the exists filter + def has_field(field) + spawn.filter_query(:exists, {field: field}) + end + + QueryMethods.register!(:has_field) + end + end + end +end diff --git a/lib/stretchy/relations/query_methods/highlight.rb b/lib/stretchy/relations/query_methods/highlight.rb new file mode 100644 index 0000000..e906314 --- /dev/null +++ b/lib/stretchy/relations/query_methods/highlight.rb @@ -0,0 +1,31 @@ +module Stretchy + module Relations + module QueryMethods + module Highlight + extend ActiveSupport::Concern + # Highlights the specified fields in the search results. + # + # @example + # Model.where(body: "turkey").highlight(:body) + # + # @param [Hash] args The fields to highlight. Each field is a key in the hash, + # and the value is another hash specifying the type of highlighting. + # For example, `{body: {type: :plain}}` will highlight the 'body' field + # with plain type highlighting. + # + # @return [Stretchy::Relation] Returns a Stretchy::Relation object, which can be used + # for chaining further query methods. + def highlight(*args) + spawn.highlight!(*args) + end + + def highlight!(*args) # :nodoc: + self.highlight_values += args + self + end + + QueryMethods.register!(:highlight) + end + end + end +end diff --git a/lib/stretchy/relations/query_methods/hybrid.rb b/lib/stretchy/relations/query_methods/hybrid.rb new file mode 100644 index 0000000..61ebad6 --- /dev/null +++ b/lib/stretchy/relations/query_methods/hybrid.rb @@ -0,0 +1,50 @@ +module Stretchy + module Relations + module QueryMethods + module Hybrid + extend ActiveSupport::Concern + # Public: Perform a hybrid search using both neural and traditional queries. + # + # The `hybrid` method accepts two parameters: `neural` and `query`, both of which are arrays. + # The `neural` array should contain hashes representing neural queries, with each hash containing + # The `query` array should contain hashes representing traditional queries. + # + # opts - The Hash options used to refine the selection (default: {}): + # :neural - The Array of neural queries (default: []). + # :query - The Array of traditional queries (default: []). + # Each element is a Hash representing a traditional query. + # + # Examples + # + # Model.hybrid( + # neural: [ + # { + # passage_embedding: 'hello world', + # model_id: '1234', + # k: 2 + # } + # ], + # query: [ + # { + # term: { + # status: :active + # } + # } + # ] + # ) + # + # Returns a new relation with the hybrid search applied. + def hybrid(opts) + spawn.hybrid!(opts) + end + + def hybrid!(opts) # :nodoc: + self.hybrid_values += [opts] + self + end + + QueryMethods.register!(:hybrid) + end + end + end +end \ No newline at end of file diff --git a/lib/stretchy/relations/query_methods/ids.rb b/lib/stretchy/relations/query_methods/ids.rb new file mode 100644 index 0000000..2629a9c --- /dev/null +++ b/lib/stretchy/relations/query_methods/ids.rb @@ -0,0 +1,18 @@ +module Stretchy + module Relations + module QueryMethods + module Ids + def ids(*args) + spawn.ids!(*args) + end + + def ids!(*args) # :nodoc: + self.ids_values += args + self + end + + QueryMethods.register!(:ids) + end + end + end +end \ No newline at end of file diff --git a/lib/stretchy/relations/query_methods/must_not.rb b/lib/stretchy/relations/query_methods/must_not.rb new file mode 100644 index 0000000..5da1c5c --- /dev/null +++ b/lib/stretchy/relations/query_methods/must_not.rb @@ -0,0 +1,45 @@ +module Stretchy + module Relations + module QueryMethods + module MustNot + extend ActiveSupport::Concern + # Adds negated conditions to the query. + # + # Each argument is a hash where the key is the attribute to filter by and the value is the value to exclude. + # + # @overload must_not(*rest) + # @param rest [Array] a hash containing attribute-value pairs to exclude + # + # @example + # Model.must_not(color: 'blue', size: :large) + # + # @return [Stretchy::Relation] a new relation, which reflects the negated conditions + # @see #where_not + def must_not(opts = :chain, *rest) + if opts == :chain + WhereChain.new(spawn) + elsif opts.blank? + self + else + spawn.must_not!(opts, *rest) + end + end + + + def must_not!(opts, *rest) # :nodoc: + if opts == :chain + WhereChain.new(self) + else + self.must_not_values += build_where(opts, rest) + self + end + end + + # Alias for {#must_not} + # @see #must_not + alias :where_not :must_not + QueryMethods.register!(:where_not, :must_not) + end + end + end +end diff --git a/lib/stretchy/relations/query_methods/neural.rb b/lib/stretchy/relations/query_methods/neural.rb new file mode 100644 index 0000000..ab43fca --- /dev/null +++ b/lib/stretchy/relations/query_methods/neural.rb @@ -0,0 +1,41 @@ +module Stretchy + module Relations + module QueryMethods + module Neural + extend ActiveSupport::Concern + # Public: Perform a neural search on a specific field. + # + # The `neural` method accepts a Hash with the field name as the key and the query text as the value. + # It can also accept a Hash with `query_text` and `query_image` keys for multimodal neural search. + # + # field - The Symbol or String representing the field name. + # opts - The Hash options used to refine the selection (default: {}): + # :query_text - The String representing the query text (optional). + # :query_image - The String representing the base-64 encoded query image (optional). + # :model_id - The String representing the ID of the model to be used (required if default model ID is not set). + # :k - The Integer representing the number of results to return (optional, default: 10). + # :filter - The Object representing a query to reduce the number of documents considered (optional). + # + # Examples + # + # Model.neural(body_embeddings: 'hello world', model_id: '1234') + # Model.neural(body_embeddings: { + # query_text: 'hello world', + # query_image: 'base64encodedimage' + # }, model_id: '1234') + # + # Returns a new relation with the neural search applied. + def neural(opts) + spawn.neural!(opts) + end + + def neural!(opts) # :nodoc: + self.neural_values += [opts] + self + end + + QueryMethods.register!(:neural) + end + end + end +end diff --git a/lib/stretchy/relations/query_methods/neural_sparse.rb b/lib/stretchy/relations/query_methods/neural_sparse.rb new file mode 100644 index 0000000..fac5ccf --- /dev/null +++ b/lib/stretchy/relations/query_methods/neural_sparse.rb @@ -0,0 +1,33 @@ +module Stretchy + module Relations + module QueryMethods + module NeuralSparse + extend ActiveSupport::Concern + # Public: Perform a neural sparse search on a specific field. + # + # The `neural_sparse` method accepts a Hash with the `field_name`, `model_id`, and `max_token_score` keys. + # + # opts - The Hash options used to refine the selection (default: {}): + # :field_name - The keyword argument representing the passage to be embedded and the value to be searched. + # :model_id - The String representing the ID of the model to be used. + # :max_token_score - The Integer representing the maximum token score to consider. + # + # Examples + # + # Model.neural_sparse(passage_embedding: 'hello world', model_id: '1234', max_token_score: 2) + # + # Returns a new relation with the neural sparse search applied. + def neural_sparse(opts) + spawn.neural_sparse!(opts) + end + + def neural_sparse!(opts) # :nodoc: + self.neural_sparse_values += [opts] + self + end + + QueryMethods.register!(:neural_sparse) + end + end + end +end \ No newline at end of file diff --git a/lib/stretchy/relations/query_methods/none.rb b/lib/stretchy/relations/query_methods/none.rb new file mode 100644 index 0000000..986a0c1 --- /dev/null +++ b/lib/stretchy/relations/query_methods/none.rb @@ -0,0 +1,21 @@ +module Stretchy + module Relations + module QueryMethods + module None + extend ActiveSupport::Concern + + # Returns a chainable relation with zero records. + def none + extending(NullRelation) + end + + def none! # :nodoc: + extending!(NullRelation) + end + + QueryMethods.register!(:none) + + end + end + end +end diff --git a/lib/stretchy/relations/query_methods/or_filter.rb b/lib/stretchy/relations/query_methods/or_filter.rb new file mode 100644 index 0000000..7d06a21 --- /dev/null +++ b/lib/stretchy/relations/query_methods/or_filter.rb @@ -0,0 +1,21 @@ +module Stretchy + module Relations + module QueryMethods + module OrFilter + extend ActiveSupport::Concern + # @deprecated in elasticsearch 7.x+ use {#filter_query} instead + def or_filter(name, options = {}, &block) + spawn.or_filter!(name, options, &block) + end + + def or_filter!(name, options = {}, &block) # :nodoc: + self.or_filter_values += [{name: name, args: options}] + self + end + + QueryMethods.register!(:or_filter) + + end + end + end +end diff --git a/lib/stretchy/relations/query_methods/order.rb b/lib/stretchy/relations/query_methods/order.rb new file mode 100644 index 0000000..b961d9a --- /dev/null +++ b/lib/stretchy/relations/query_methods/order.rb @@ -0,0 +1,59 @@ +module Stretchy + module Relations + module QueryMethods + module Order + extend ActiveSupport::Concern + # Allows you to add one or more sorts on specified fields. + # + # @overload order(attribute: direction, ...) + # @param attribute [Symbol] the attribute to sort by + # @param direction [Symbol] the direction to sort in (:asc or :desc) + # + # @overload order(attribute: {order: direction, mode: mode, ...}, ...) + # @param params [Hash] attributes to sort by + # @param params [Symbol] :attribute the attribute name as key to sort by + # @param options [Hash] a hash containing possible sorting options + # @option options [Symbol] :order the direction to sort in (:asc or :desc) + # @option options [Symbol] :mode the mode to use for sorting (:avg, :min, :max, :sum, :median) + # @option options [Symbol] :numeric_type the numeric type to use for sorting (:double, :long, :date, :date_nanos) + # @option options [Symbol] :missing the value to use for documents without the field + # @option options [Hash] :nested the nested sorting options + # @option nested [String] :path the path to the nested object + # @option nested [Hash] :filter the filter to apply to the nested object + # @option nested [Hash] :max_children the maximum number of children to consider per root document when picking the sort value. Defaults to unlimited + # + # @example + # Model.order(created_at: :asc) + # # Elasticsearch equivalent + # #=> "sort" : [{"created_at" : "asc"}] + # + # Model.order(age: :desc, name: :asc, price: {order: :desc, mode: :avg}) + # + # # Elasticsearch equivalent + # #=> "sort" : [ + # { "price" : {"order" : "desc", "mode": "avg"}}, + # { "name" : "asc" }, + # { "age" : "desc" } + # ] + # + # @return [Stretchy::Relation] a new relation with the specified order + # @see #sort + def order(*args) + check_if_method_has_arguments!(:order, args) + spawn.order!(*args) + end + + def order!(*args) # :nodoc: + self.order_values += args.first.zip.map(&:to_h) + self + end + + # Alias for {#order} + # @see #order + alias :sort :order + QueryMethods.register!(:sort, :order) + + end + end + end +end diff --git a/lib/stretchy/relations/query_methods/query_string.rb b/lib/stretchy/relations/query_methods/query_string.rb new file mode 100644 index 0000000..9e32c11 --- /dev/null +++ b/lib/stretchy/relations/query_methods/query_string.rb @@ -0,0 +1,44 @@ +module Stretchy + module Relations + module QueryMethods + module QueryString + extend ActiveSupport::Concern + # Adds a query string to the search. + # + # The query string uses Elasticsearch's Query String Query syntax, which includes a series of terms and operators. + # Terms can be single words or phrases. Operators include AND, OR, and NOT, among others. + # Field names can be included in the query string to search for specific values in specific fields. (e.g. "eye_color: green") + # The default operator between terms are treated as OR operators. + # + # @param query [String] the query string + # @param rest [Array] additional arguments (not normally used) + # + # @example + # Model.query_string("((big cat) OR (domestic cat)) AND NOT panther eye_color: green") + # + # @return [Stretchy::Relation] a new relation, which reflects the query string + def query_string(opts = :chain, *rest) + if opts == :chain + WhereChain.new(spawn) + elsif opts.blank? + self + else + spawn.query_string!(opts, *rest) + end + end + + def query_string!(opts, *rest) # :nodoc: + if opts == :chain + WhereChain.new(self) + else + self.query_string_values += build_where(opts, rest) + self + end + end + + QueryMethods.register!(:query_string) + + end + end + end +end diff --git a/lib/stretchy/relations/query_methods/regexp.rb b/lib/stretchy/relations/query_methods/regexp.rb new file mode 100644 index 0000000..65a9901 --- /dev/null +++ b/lib/stretchy/relations/query_methods/regexp.rb @@ -0,0 +1,44 @@ +module Stretchy + module Relations + module QueryMethods + module Regexp + extend ActiveSupport::Concern + # Adds a regexp condition to the query. + # + # @param field [Hash] the field to filter by and the Regexp to match + # @param opts [Hash] additional options for the regexp query + # - :flags [String] the flags to use for the regexp query (e.g. 'ALL') + # - :use_keyword [Boolean] whether to use the .keyword field for the regexp query. Default: true + # - :case_insensitive [Boolean] whether to use case insensitive matching. If the regexp has ignore case flag `/regex/i`, this is automatically set to true + # - :max_determinized_states [Integer] the maximum number of states that the regexp query can produce + # - :rewrite [String] the rewrite method to use for the regexp query + # + # + # @example + # Model.regexp(:name, /john|jane/) + # Model.regexp(:name, /john|jane/i) + # Model.regexp(:name, /john|jane/i, flags: 'ALL') + # + # @return [Stretchy::Relation] a new relation, which reflects the regexp condition + # @see #where + def regexp(args) + spawn.regexp!(args) + end + + def regexp!(args) # :nodoc: + args = args.to_a + target_field, regex = args.shift + opts = args.to_h + opts.reverse_merge!(use_keyword: true) + target_field = "#{target_field}.keyword" if opts.delete(:use_keyword) + opts.merge!(case_insensitive: true) if regex.casefold? + self.regexp_values += [[Hash[target_field, regex.source], opts]] + self + end + + QueryMethods.register!(:regexp) + + end + end + end +end diff --git a/lib/stretchy/relations/query_methods/should.rb b/lib/stretchy/relations/query_methods/should.rb new file mode 100644 index 0000000..8c11be6 --- /dev/null +++ b/lib/stretchy/relations/query_methods/should.rb @@ -0,0 +1,41 @@ +module Stretchy + module Relations + module QueryMethods + module Should + extend ActiveSupport::Concern + # Adds optional conditions to the query. + # + # Each argument is a hash where the key is the attribute to filter by and the value is the value to match optionally. + # + # @overload should(*rest) + # @param rest [Array] additional keywords containing attribute-value pairs to match optionally + # + # @example + # Model.should(color: :pink, size: :medium) + # + # @return [Stretchy::Relation] a new relation, which reflects the optional conditions + def should(opts = :chain, *rest) + if opts == :chain + WhereChain.new(spawn) + elsif opts.blank? + self + else + spawn.should!(opts, *rest) + end + end + + def should!(opts, *rest) # :nodoc: + if opts == :chain + WhereChain.new(self) + else + self.should_values += build_where(opts, rest) + self + end + end + + QueryMethods.register!(:should) + + end + end + end +end \ No newline at end of file diff --git a/lib/stretchy/relations/query_methods/size.rb b/lib/stretchy/relations/query_methods/size.rb new file mode 100644 index 0000000..14d2559 --- /dev/null +++ b/lib/stretchy/relations/query_methods/size.rb @@ -0,0 +1,32 @@ +module Stretchy + module Relations + module QueryMethods + module Size + extend ActiveSupport::Concern + # Sets the maximum number of records to be retrieved. + # + # @param args [Integer] the maximum number of records to retrieve + # + # @example + # Model.size(10) + # + # @return [ActiveRecord::Relation] a new relation, which reflects the limit + # @see #limit + def size(args) + spawn.size!(args) + end + + def size!(args) # :nodoc: + self.size_value = args + self + end + + # Alias for {#size} + # @see #size + alias :limit :size + + QueryMethods.register!(:limit, :size) + end + end + end +end \ No newline at end of file diff --git a/lib/stretchy/relations/query_methods/skip_callbacks.rb b/lib/stretchy/relations/query_methods/skip_callbacks.rb new file mode 100644 index 0000000..d016b6a --- /dev/null +++ b/lib/stretchy/relations/query_methods/skip_callbacks.rb @@ -0,0 +1,25 @@ +module Stretchy + module Relations + module QueryMethods + module SkipCallbacks + extend ActiveSupport::Concern + # Allows you to skip callbacks for the specified fields that are added by query_must_have for + # the current query. + # + # @example + # Model.skip_callbacks(:routing) + def skip_callbacks(*args) + spawn.skip_callbacks!(*args) + end + + def skip_callbacks!(*args) # :nodoc: + self.skip_callbacks_values += args + self + end + + QueryMethods.register!(:skip_callbacks) + + end + end + end +end diff --git a/lib/stretchy/relations/query_methods/source.rb b/lib/stretchy/relations/query_methods/source.rb new file mode 100644 index 0000000..469bcf1 --- /dev/null +++ b/lib/stretchy/relations/query_methods/source.rb @@ -0,0 +1,41 @@ +module Stretchy + module Relations + module QueryMethods + module Source + extend ActiveSupport::Concern + # Controls which fields of the source are returned. + # + # This method supports source filtering, which allows you to include or exclude fields from the source. + # You can specify fields directly, use wildcard patterns, or use an object containing arrays + # of includes and excludes patterns. + # + # If the includes property is specified, only source fields that match one of its patterns are returned. + # You can exclude fields from this subset using the excludes property. + # + # If the includes property is not specified, the entire document source is returned, excluding any + # fields that match a pattern in the excludes property. + # + # @overload source(opts) + # @param opts [Hash, Boolean] a hash containing :includes and/or :excludes arrays, or a boolean indicating whether + # to include the source + # + # @example + # Model.source(includes: [:name, :email]) + # Model.source(excludes: [:name, :email]) + # Model.source(false) # don't include source + # + # @return [Stretchy::Relation] a new relation, which reflects the source filtering + def source(*args) + spawn.source!(*args) + end + + def source!(*args) # :nodoc: + self.source_values += args + self + end + + QueryMethods.register!(:source) + end + end + end +end diff --git a/lib/stretchy/relations/query_methods/where.rb b/lib/stretchy/relations/query_methods/where.rb new file mode 100644 index 0000000..4a36d42 --- /dev/null +++ b/lib/stretchy/relations/query_methods/where.rb @@ -0,0 +1,94 @@ +module Stretchy + module Relations + module QueryMethods + module Where + + class WhereChain + def initialize(scope) + @scope = scope + end + end + # Adds conditions to the query. + # + # Each argument is a hash where the key is the attribute to filter by and the value is the value to match. + # + # @overload where(*rest) + # @param rest [Array] keywords containing attribute-value pairs to match + # + # @example + # Model.where(price: 10, color: :green) + # + # # Elasticsearch equivalent + # # => "query" : { + # "bool" : { + # "must" : [ + # { "term" : { "price" : 10 } }, + # { "term" : { "color" : "green" } } + # ] + # } + # } + # + # .where acts as a convienence method for adding conditions to the query. It can also be used to add + # range , regex, terms, and id queries through shorthand parameters. + # + # @example + # Model.where(price: {gte: 10, lte: 20}) + # Model.where(age: 19..33) + # Model.where(color: /gr(a|e)y/) + # Model.where(id: [10, 22, 18]) + # Model.where(names: ['John', 'Jane']) + # + # @return [ActiveRecord::Relation, WhereChain] a new relation, which reflects the conditions, or a WhereChain if opts is :chain + # @see #must + def where(opts = :chain, *rest) + if opts == :chain + WhereChain.new(spawn) + elsif opts.blank? + self + else + opts.each do |key, value| + case value + when Range + opts.delete(key) + between(value, key) + when Hash + opts.delete(key) + filter_query(:range, key => value) if value.keys.any? { |k| [:gte, :lte, :gt, :lt].include?(k) } + when ::Regexp + opts.delete(key) + regexp(Hash[key, value]) + when Array + # handle ID queries + if [:id, :_id].include?(key) + opts.delete(key) + ids(value) + end + end + end + + spawn.where!(opts, *rest) unless opts.empty? + self + + end + end + + + def where!(opts, *rest) # :nodoc: + if opts == :chain + WhereChain.new(self) + else + self.where_values += build_where(opts, rest) + self + end + end + + # Alias for {#where} + # @see #where + alias :must :where + + QueryMethods.register!(:where, :must) + + end + end + end +end diff --git a/spec/stretchy/querying_spec.rb b/spec/stretchy/querying_spec.rb index 2d20753..37f3d10 100644 --- a/spec/stretchy/querying_spec.rb +++ b/spec/stretchy/querying_spec.rb @@ -124,6 +124,14 @@ it 'handles regex' do expect(described_class.where(color: /gr(a|e)y/).to_elastic).to eq({query: {regexp: {'color.keyword': { value: 'gr(a|e)y' }}}}.with_indifferent_access) end + + it 'handles regex with flags' do + expect(described_class.where(color: /gr(a|e)y/i).to_elastic).to eq({query: {regexp: {'color.keyword': { value: 'gr(a|e)y', case_insensitive: true }}}}.with_indifferent_access) + end + + it 'handles multiple conditions' do + expect(described_class.where(color: /gr(a|e)y/, age: 30).to_elastic).to eq({query: {bool: {must: {term: {age: 30}}},regexp: {'color.keyword': { value: 'gr(a|e)y' }}}}.with_indifferent_access) + end end context 'when using terms' do @@ -133,8 +141,8 @@ end context 'when using ids' do - xit 'handles ids' do - expect(Model.where(id: [12, 80, 32]).to_elastic).to eq({ids: {values: [12, 80, 32]}}) + it 'handles ids' do + expect(described_class.where(id: [12, 80, 32]).to_elastic).to eq({query: {ids: {values: [12, 80, 32]}}}.with_indifferent_access) end end