Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix keyword expansion and add configuration for auto keyword expansion #101

Merged
merged 4 commits into from
Mar 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 17 additions & 10 deletions lib/stretchy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,32 +41,39 @@ class QueryOptionMissing < StandardError; end

class Configuration

attr_accessor :client
attr_accessor :opensearch
attr_accessor :options

delegate_missing_to :@options

def initialize
@client = Elasticsearch::Client.new url: 'http://localhost:9200'
@opensearch = false
@options = Hashie::Mash.new(
default_keyword_field: :keyword,
add_keyword_field_to_text_attributes: true,
auto_target_keywords: true,
opensearch: false,
client: Elasticsearch::Client.new(url: 'http://localhost:9200')
)
end

def client=(client)
@client = client
self.opensearch = true if @client.class.name =~ /OpenSearch/
def client=(klient)
@options[:client] = klient
self.opensearch = true if klient.class.name =~ /OpenSearch/
end

def search_backend_const
@opensearch ? OpenSearch : Elasticsearch
self.opensearch? ? OpenSearch : Elasticsearch
end

def opensearch=(bool)
@opensearch = bool
@options[:opensearch] = bool
OpenSearchCompatibility.opensearch_patch! if bool
end

def opensearch?
@opensearch
@options[:opensearch]
end


end

class << self
Expand Down
56 changes: 23 additions & 33 deletions lib/stretchy/attributes/transformers/keyword_transformer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -38,60 +38,50 @@ def cast_value_keys
end
end

def keyword?(arg)
attr = @attribute_types[arg.to_s]
return false unless attr
attr.is_a?(Stretchy::Attributes::Type::Keyword)
def keyword_available?(arg)
attrib = @attribute_types[arg.to_s.split(".").first]
return false unless attrib
attrib.respond_to?(:keyword_field?) && attrib.keyword_field?
end

def keyword_field_for(arg)
attrib = @attribute_types[arg.to_s.split(".").first]
keyword_field = attrib.respond_to?(:fields) ? attrib.fields.find { |k,d| d[:type].to_sym == :keyword }&.first : nil
keyword_field || Stretchy.configuration.default_keyword_field
end

def protected?(arg)
return false if arg.nil?
Stretchy::Relations::AggregationMethods.registry.include?(arg.to_sym)
end

# Add `.keyword` to attributes that have a keyword subfield but aren't `:keywords`
# this is for text fields that have a keyword subfield
# `:text` and `:string` fields add a `:keyword` subfield to the attribute mapping automatically
def transform(item, *ignore)
item.each_with_object({}) do |(k, v), new_item|
if ignore && ignore.include?(k)
new_item[k] = v
return unless Stretchy.configuration.auto_target_keywords
item.each_with_object({}) do |(key, value), new_item|
if ignore && ignore.include?(key)
new_item[key] = value
next
end
new_key = (!protected?(k) && keyword?(k)) ? "#{k}.keyword" : k

new_value = v
new_key = (!protected?(key) && keyword_available?(key)) ? "#{key}.#{keyword_field_for(key)}" : key

new_value = value

if new_value.is_a?(Hash)
new_value = transform(new_value)
new_value = transform(new_value, *ignore)
elsif new_value.is_a?(Array)
new_value = new_value.map { |i| i.is_a?(Hash) ? transform(i) : i }
new_value = new_value.map { |i| i.is_a?(Hash) ? transform(i, *ignore) : i }
elsif new_value.is_a?(String) || new_value.is_a?(Symbol)
new_value = "#{new_value}.keyword" if keyword?(new_value)
new_value = "#{new_value}.#{keyword_field_for(new_value)}" if keyword_available?(new_value) && new_value.to_s !~ Regexp.new("\.#{keyword_field_for(new_value)}$")
end

new_item[new_key] = new_value
end
end

# If terms are used, we assume that the field is a keyword field
# and append .keyword to the field name
# {terms: {field: 'gender'}}
# or nested aggs
# {terms: {field: 'gender'}, aggs: {name: {terms: {field: 'position.name'}}}}
# should be converted to
# {terms: {field: 'gender.keyword'}, aggs: {name: {terms: {field: 'position.name.keyword'}}}}
# {date_histogram: {field: 'created_at', interval: 'day'}}
def assume_keyword_field(args={}, parent_match=false)
if args.is_a?(Hash)
args.each do |k, v|
if v.is_a?(Hash)
assume_keyword_field(v, KEYWORD_AGGREGATION_FIELDS.include?(k))
else
next unless v.is_a?(String) || v.is_a?(Symbol)
args[k] = ([:field, :fields].include?(k.to_sym) && v !~ /\.keyword$/ && parent_match) ? "#{v}.keyword" : v.to_s
end
end
end
end

end
end
end
Expand Down
8 changes: 6 additions & 2 deletions lib/stretchy/attributes/type/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,23 @@ class Base < ActiveModel::Type::Value

OPTIONS = []


def initialize(**args)

define_option_methods!

args.each do |k, v|
if self.class::OPTIONS.include?(k)
instance_variable_set("@#{k}", v)
args.delete(k)
end
args.delete(k)
end
super
end

def keyword_field?
return false unless respond_to? :fields
fields.present? && fields.include?(:keyword)
end

def mappings(name)
options = {type: type_for_database}
Expand Down
2 changes: 1 addition & 1 deletion lib/stretchy/attributes/type/binary.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ module Type
# end
# ```
#
class Binary < ActiveModel::Type::Value
class Binary < Stretchy::Attributes::Type::Base
OPTIONS = [:doc_values, :store]
attr_reader *OPTIONS

Expand Down
2 changes: 1 addition & 1 deletion lib/stretchy/attributes/type/date_time.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ module Stretchy::Attributes::Type
#
class DateTime < Stretchy::Attributes::Type::Base
OPTIONS = [:doc_values, :format, :locale, :ignore_malformed, :index, :null_value, :on_script_error, :script, :store, :meta]
attr_reader *OPTIONS
attr_reader *OPTIONS + self.superclass::OPTIONS
include ActiveModel::Type::Helpers::Timezone
include ActiveModel::Type::Helpers::AcceptsMultiparameterTime.new(
defaults: { 4 => 0, 5 => 0 }
Expand Down
6 changes: 5 additions & 1 deletion lib/stretchy/attributes/type/hash.rb
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,15 @@ def type_for_database
end

def mappings(name)
options = {}
options = {type: type_for_database}
OPTIONS.each { |option| options[option] = send(option) unless send(option).nil? }
{ name => options }.as_json
end

def keyword_field?
true
end

private

def cast_value(value)
Expand Down
1 change: 1 addition & 0 deletions lib/stretchy/attributes/type/keyword.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,6 @@ class Keyword < Stretchy::Attributes::Type::Base
def type
:keyword
end

end
end
2 changes: 1 addition & 1 deletion lib/stretchy/attributes/type/numeric/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ module Stretchy::Attributes::Type::Numeric
# ```
#
class Base < Stretchy::Attributes::Type::Base #:nodoc:
OPTIONS = [:coerce, :doc_values, :ignore_malformed, :index, :meta, :null_value, :on_script_error, :script, :store, :time_series_dimension, :time_series_metric]
OPTIONS = [:coerce, :doc_values, :ignore_malformed, :index, :meta, :null_value, :on_script_error, :script, :store, :time_series_dimension, :time_series_metric] + self.superclass::OPTIONS

def type
raise NotImplementedError, "You must use one of the numeric types: integer, long, short, byte, double, float, half_float, scaled_float."
Expand Down
19 changes: 18 additions & 1 deletion lib/stretchy/attributes/type/text.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ module Stretchy::Attributes::Type
#
# This class is used to define a text attribute for a model. It provides support for the Elasticsearch text data type, which is a type of data type that can hold text strings.
#
# >[!NOTE]
# >
# > The default for the `:text` type is to have a keyword multified if `field:` is not specified and `fields:` is not explicitly false.
# > This can be disabled by setting `Stretchy.configuration.add_keyword_field_to_text_attributes` to false.
# > The default keyword field name is `:keyword`, but this can be changed by setting `Stretchy.configuration.default_keyword_field`.
#
# ### Parameters
#
# - `type:` `:text`.
Expand Down Expand Up @@ -39,7 +45,13 @@ module Stretchy::Attributes::Type
#
class Text < Stretchy::Attributes::Type::Base
OPTIONS = [:analyzer, :eager_global_ordinals, :fielddata, :fielddata_frequency_filter, :fields, :index, :index_options, :index_prefixes, :index_phrases, :norms, :position_increment_gap, :store, :search_analyzer, :search_quote_analyzer, :similarity, :term_vector, :meta]


def initialize(**args)
# Add a keyword field by default if no fields are specified
args.reverse_merge!(fields: {keyword: {type: :keyword, ignore_above: 256}}) if args[:fields].nil? && Stretchy.configuration.add_keyword_field_to_text_attributes
super
end

def type
:text
end
Expand All @@ -48,6 +60,11 @@ def type_for_database
:text
end

# The default for the `:text` type is to have a keyword field if no fields are specified.
def keyword_field?
fields.find { |k,d| d[:type].to_sym == :keyword}.present?
end

def mappings(name)
options = {type: type_for_database}
OPTIONS.each { |option| options[option] = send(option) unless send(option).nil? }
Expand Down
1 change: 1 addition & 0 deletions lib/stretchy/model/callbacks.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ module Callbacks
included do
mattr_accessor :_circuit_breaker_callbacks, default: []

define_model_callbacks :initialize, only: :after
define_model_callbacks :create, :save, :update, :destroy
define_model_callbacks :find, :touch, only: :after
end
Expand Down
1 change: 1 addition & 0 deletions lib/stretchy/record.rb
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ def self.inherited(base)
def initialize(attributes = {})
@highlights = attributes.delete(:_highlights)
super(attributes)
run_callbacks :initialize
end

end
Expand Down
2 changes: 1 addition & 1 deletion lib/stretchy/relations/aggregation_methods/aggregation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ def aggregation(name, options = {}, &block)
end

def aggregation!(name, options = {}, &block) # :nodoc:
self.aggregation_values += [{name: name, args: assume_keyword_field(options)}]
self.aggregation_values += [{name: name, args: options}]
self
end

Expand Down
23 changes: 13 additions & 10 deletions lib/stretchy/relations/query_builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ def to_elastic
build_highlights unless highlights.blank?
build_fields unless fields.blank?
build_source unless source.blank?
build_aggregations unless aggregations.blank?
build_aggregations(aggregations, structure) unless aggregations.blank?
structure.attributes!.with_indifferent_access
end

Expand Down Expand Up @@ -264,10 +264,10 @@ def build_highlights
end
end

def build_aggregations
structure.aggregations do
aggregations.each do |agg|
structure.set! agg[:name], aggregation(agg[:name], keyword_transformer.transform(agg[:args], :name))
def build_aggregations(aggregation_args, aggregation_structure)
aggregation_structure.aggregations do
aggregation_args.each do |agg|
aggregation_structure.set! agg[:name], aggregation(agg[:name], keyword_transformer.transform(agg[:args], :aggs, :aggregations))
end
end
end
Expand Down Expand Up @@ -335,8 +335,6 @@ def as_query_string(q)
_and.join(" AND ")
end



def extract_highlighter(highlighter)
Jbuilder.new do |highlight|
highlight.extract! highlighter
Expand All @@ -357,12 +355,17 @@ def extract_filters(name,opts = {})
end

def aggregation(name, opts = {})
Jbuilder.new do |agg|
Jbuilder.new do |agg_structure|
case
when opts.is_a?(Hash)
agg.extract! opts, *opts.keys
nested_agg = opts.delete(:aggs) || opts.delete(:aggregations)

agg_structure.extract! opts, *opts.keys

build_aggregations(nested_agg.map {|d| {:name => d.first, :args => d.last } }, agg_structure) if nested_agg

when opts.is_a?(Array)
extract_filter_arguments_from_array(agg, opts)
extract_filter_arguments_from_array(agg_structure, opts)
else
raise "#aggregation only accepts Hash or Array"
end
Expand Down
15 changes: 0 additions & 15 deletions lib/stretchy/relations/query_methods.rb
Original file line number Diff line number Diff line change
Expand Up @@ -103,21 +103,6 @@ def build_where(opts, other = [])

private

KEYWORD_AGGREGATION_FIELDS = [:terms, :rare_terms, :significant_terms, :cardinality, :string_stats]

def assume_keyword_field(args={}, parent_match=false)
if args.is_a?(Hash)
args.each do |k, v|
if v.is_a?(Hash)
assume_keyword_field(v, KEYWORD_AGGREGATION_FIELDS.include?(k))
else
next unless v.is_a?(String) || v.is_a?(Symbol)
args[k] = ([:field, :fields].include?(k.to_sym) && v !~ /\.keyword$/ && parent_match) ? "#{v}.keyword" : v.to_s
end
end
end
end

def check_if_method_has_arguments!(method_name, args)
if args.blank?
raise ArgumentError, "The method .#{method_name}() must contain arguments."
Expand Down
2 changes: 1 addition & 1 deletion spec/models/post.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
class Post < Stretchy::Record


attribute :title, :string
attribute :title, :keyword
attribute :body, :string
attribute :flagged, :boolean, default: false
attribute :actor, :hash
Expand Down
4 changes: 2 additions & 2 deletions spec/models/resource.rb
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
class Resource < Stretchy::Record
index_name "resource_test"

attribute :name, :keyword
attribute :name, :string
attribute :email, :keyword
attribute :phone, :string
attribute :position, :hash
attribute :gender, :keyword
attribute :gender, :string
attribute :age, :integer
attribute :income, :integer
attribute :income_after_raise, :integer
Expand Down
2 changes: 2 additions & 0 deletions spec/models/test_model.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,7 @@ class TestModel < StretchyModel
attribute :data, :hash
attribute :published_at, :datetime
attribute :agreed, :boolean
attribute :color, :keyword
attribute :text_with_keyword, :text, fields: {slug: {type: :keyword}}

end
Loading
Loading