elasticsearch_record 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (57) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.ruby-version +1 -0
  4. data/Gemfile +6 -0
  5. data/Gemfile.lock +74 -0
  6. data/README.md +216 -0
  7. data/Rakefile +8 -0
  8. data/docs/CHANGELOG.md +44 -0
  9. data/docs/CODE_OF_CONDUCT.md +84 -0
  10. data/docs/LICENSE.txt +21 -0
  11. data/lib/active_record/connection_adapters/elasticsearch/column.rb +32 -0
  12. data/lib/active_record/connection_adapters/elasticsearch/database_statements.rb +149 -0
  13. data/lib/active_record/connection_adapters/elasticsearch/quoting.rb +38 -0
  14. data/lib/active_record/connection_adapters/elasticsearch/schema_statements.rb +134 -0
  15. data/lib/active_record/connection_adapters/elasticsearch/type/format_string.rb +28 -0
  16. data/lib/active_record/connection_adapters/elasticsearch/type/multicast_value.rb +52 -0
  17. data/lib/active_record/connection_adapters/elasticsearch/type/object.rb +44 -0
  18. data/lib/active_record/connection_adapters/elasticsearch/type/range.rb +42 -0
  19. data/lib/active_record/connection_adapters/elasticsearch/type.rb +16 -0
  20. data/lib/active_record/connection_adapters/elasticsearch_adapter.rb +197 -0
  21. data/lib/arel/collectors/elasticsearch_query.rb +112 -0
  22. data/lib/arel/nodes/select_agg.rb +22 -0
  23. data/lib/arel/nodes/select_configure.rb +9 -0
  24. data/lib/arel/nodes/select_kind.rb +9 -0
  25. data/lib/arel/nodes/select_query.rb +20 -0
  26. data/lib/arel/visitors/elasticsearch.rb +589 -0
  27. data/lib/elasticsearch_record/base.rb +14 -0
  28. data/lib/elasticsearch_record/core.rb +59 -0
  29. data/lib/elasticsearch_record/extensions/relation.rb +15 -0
  30. data/lib/elasticsearch_record/gem_version.rb +17 -0
  31. data/lib/elasticsearch_record/instrumentation/controller_runtime.rb +39 -0
  32. data/lib/elasticsearch_record/instrumentation/log_subscriber.rb +70 -0
  33. data/lib/elasticsearch_record/instrumentation/railtie.rb +16 -0
  34. data/lib/elasticsearch_record/instrumentation.rb +17 -0
  35. data/lib/elasticsearch_record/model_schema.rb +43 -0
  36. data/lib/elasticsearch_record/patches/active_record/relation_merger_patch.rb +85 -0
  37. data/lib/elasticsearch_record/patches/arel/select_core_patch.rb +64 -0
  38. data/lib/elasticsearch_record/patches/arel/select_manager_patch.rb +91 -0
  39. data/lib/elasticsearch_record/patches/arel/select_statement_patch.rb +41 -0
  40. data/lib/elasticsearch_record/patches/arel/update_manager_patch.rb +46 -0
  41. data/lib/elasticsearch_record/patches/arel/update_statement_patch.rb +60 -0
  42. data/lib/elasticsearch_record/persistence.rb +80 -0
  43. data/lib/elasticsearch_record/query.rb +129 -0
  44. data/lib/elasticsearch_record/querying.rb +90 -0
  45. data/lib/elasticsearch_record/relation/calculation_methods.rb +155 -0
  46. data/lib/elasticsearch_record/relation/core_methods.rb +64 -0
  47. data/lib/elasticsearch_record/relation/query_clause.rb +43 -0
  48. data/lib/elasticsearch_record/relation/query_clause_tree.rb +94 -0
  49. data/lib/elasticsearch_record/relation/query_methods.rb +276 -0
  50. data/lib/elasticsearch_record/relation/result_methods.rb +222 -0
  51. data/lib/elasticsearch_record/relation/value_methods.rb +54 -0
  52. data/lib/elasticsearch_record/result.rb +236 -0
  53. data/lib/elasticsearch_record/statement_cache.rb +87 -0
  54. data/lib/elasticsearch_record/version.rb +10 -0
  55. data/lib/elasticsearch_record.rb +60 -0
  56. data/sig/elasticsearch_record.rbs +4 -0
  57. metadata +175 -0
@@ -0,0 +1,129 @@
1
+ module ElasticsearchRecord
2
+ class Query
3
+ # STATUS CONSTANTS
4
+ STATUS_VALID = :valid
5
+ STATUS_FAILED = :failed
6
+
7
+ # TYPE CONSTANTS
8
+ TYPE_UNDEFINED = :undefined
9
+ TYPE_COUNT = :count
10
+ TYPE_SEARCH = :search
11
+ TYPE_MSEARCH = :msearch
12
+ TYPE_SQL = :sql
13
+ TYPE_CREATE = :create
14
+ TYPE_UPDATE = :update
15
+ TYPE_UPDATE_BY_QUERY = :update_by_query
16
+ TYPE_DELETE = :delete
17
+ TYPE_DELETE_BY_QUERY = :delete_by_query
18
+
19
+ # includes valid types only
20
+ TYPES = [TYPE_COUNT, TYPE_SEARCH, TYPE_MSEARCH, TYPE_SQL, TYPE_CREATE, TYPE_UPDATE, TYPE_UPDATE_BY_QUERY, TYPE_DELETE, TYPE_DELETE_BY_QUERY].freeze
21
+
22
+ # includes reading types only
23
+ READ_TYPES = [TYPE_COUNT, TYPE_SEARCH, TYPE_MSEARCH, TYPE_SQL].freeze
24
+
25
+ # defines a query to be executed if the query fails - +(none)+ queries
26
+ # acts like the SQL-query "where('1=0')"
27
+ FAILED_SEARCH_BODY = { size: 0, query: { bool: { filter: [{ term: { _id: '_' } }] } } }.freeze
28
+
29
+ # defines special api gates to be used per type.
30
+ # if not defined it simply uses +[:core,self.type]+
31
+ GATES = {
32
+ TYPE_SQL => [:sql, :query]
33
+ }
34
+
35
+ # defines the index the query should be executed on
36
+ # @!attribute String
37
+ attr_reader :index
38
+
39
+ # defines the query type.
40
+ # @see TYPES
41
+ # @!attribute Symbol
42
+ attr_reader :type
43
+
44
+ # defines the query status.
45
+ # @see STATUSES
46
+ # @!attribute Symbol
47
+ attr_reader :status
48
+
49
+ # defines if the affected shards gets refreshed to make this operation visible to search
50
+ # @!attribute Boolean
51
+ attr_reader :refresh
52
+
53
+ # defines the query body - in most cases this is a hash
54
+ # @!attribute Hash
55
+ attr_reader :body
56
+
57
+ # defines the query arguments to be passed to the API
58
+ # @!attribute Hash
59
+ attr_reader :arguments
60
+
61
+ # defines the columns to assign from the query
62
+ # @!attribute Array
63
+ attr_reader :columns
64
+
65
+ def initialize(index: nil, type: TYPE_UNDEFINED, status: STATUS_VALID, body: nil, refresh: nil, arguments: {}, columns: [])
66
+ @index = index
67
+ @type = type
68
+ @status = status
69
+ @refresh = refresh
70
+ @body = body
71
+ @arguments = arguments
72
+ @columns = columns
73
+ end
74
+
75
+ # sets the failed status for this query.
76
+ # returns self
77
+ # @return [ElasticsearchRecord::Query]
78
+ def failed!
79
+ @status = STATUS_FAILED
80
+
81
+ self
82
+ end
83
+
84
+ # returns true, if the query is valid (e.g. index & type defined)
85
+ # @return [Boolean]
86
+ def valid?
87
+ # type mus be valid + index must be present (not required for SQL)
88
+ TYPES.include?(self.type) #&& (index.present? || self.type == TYPE_SQL)
89
+ end
90
+
91
+ # returns the API gate to be called to execute the query.
92
+ # each query type needs a different endpoint.
93
+ # @see Elasticsearch::API
94
+ # @return [Array<Symbol, Symbol>] - API gate [<namespace>,<action>]
95
+ def gate
96
+ GATES[self.type].presence || [:core, self.type]
97
+ end
98
+
99
+ # returns true if this is a write query
100
+ # @return [Boolean]
101
+ def write?
102
+ !READ_TYPES.include?(self.type)
103
+ end
104
+
105
+ # builds the final query arguments.
106
+ # Depends on the query status, index, body & refresh attributes.
107
+ # Also used possible PRE-defined arguments to be merged with those mentioned attributes.
108
+ # @return [Hash]
109
+ def query_arguments
110
+ # check for failed status
111
+ return { index: self.index, body: FAILED_SEARCH_BODY } if self.status == STATUS_FAILED
112
+
113
+ args = @arguments.deep_dup
114
+
115
+ # set index, if present
116
+ args[:index] = self.index if self.index.present?
117
+
118
+ # set body, if present
119
+ args[:body] = self.body if self.body.present?
120
+
121
+ # set refresh, if defined (also includes false value)
122
+ args[:refresh] = self.refresh unless self.refresh.nil?
123
+
124
+ args
125
+ end
126
+
127
+ alias :to_query :query_arguments
128
+ end
129
+ end
@@ -0,0 +1,90 @@
1
+ module ElasticsearchRecord
2
+ module Querying
3
+ extend ActiveSupport::Concern
4
+
5
+ module ClassMethods
6
+ # define additional METHODS to be delegated to the Relation
7
+ # @see ::ActiveRecord::Querying::QUERYING_METHODS
8
+ ES_QUERYING_METHODS = [
9
+ :query,
10
+ :filter,
11
+ :must,
12
+ :must_not,
13
+ :should,
14
+ :aggregate,
15
+ :msearch
16
+ ].freeze # :nodoc:
17
+ delegate(*ES_QUERYING_METHODS, to: :all)
18
+
19
+ # finds records by sql, query-arguments or query-object.
20
+ #
21
+ # PLEASE NOTE: This method is used by different other methods:
22
+ # - ActiveRecord::Relation#exec_queries
23
+ # - ActiveRecord::StatementCache#execute
24
+ # - <directly on demand>
25
+ #
26
+ # We cannot rewrite all call-sources since this will mess up the whole logic end will end in other problems.
27
+ # So we check here what kind of query is provided and decide what to do.
28
+ #
29
+ # PLEASE NOTE: since ths is also used by +ActiveRecord::StatementCache#execute+ we cannot remove
30
+ # the unused params +preparable+.
31
+ # see @ ActiveRecord::Querying#find_by_sql
32
+ #
33
+ # @param [String, Hash, ElasticsearchRecord::Query] sql
34
+ # @param [Array] binds
35
+ # @param [nil] preparable
36
+ # @param [Proc] block
37
+ def find_by_sql(sql, binds = [], preparable: nil, &block)
38
+ query = case sql
39
+ when String # really find by SQL
40
+ ElasticsearchRecord::Query.new(
41
+ type: ElasticsearchRecord::Query::TYPE_SQL,
42
+ body: { query: query_or_sql },
43
+ # IMPORTANT: Always provide all columns
44
+ columns: source_column_names)
45
+ when Hash
46
+ ElasticsearchRecord::Query.new(
47
+ type: ElasticsearchRecord::Query::TYPE_SEARCH,
48
+ arguments: sql,
49
+ # IMPORTANT: Always provide all columns
50
+ columns: source_column_names)
51
+ else
52
+ sql
53
+ end
54
+
55
+ _load_from_sql(_query_by_sql(query, binds), &block)
56
+ end
57
+
58
+ # finds records by query arguments
59
+ def find_by_query(arguments, &block)
60
+ # build new query
61
+ query = ElasticsearchRecord::Query.new(
62
+ index: table_name,
63
+ type: ElasticsearchRecord::Query::TYPE_SEARCH,
64
+ arguments: arguments,
65
+ # IMPORTANT: Always provide all columns to prevent unknown attributes that should be nil ...
66
+ columns: source_column_names)
67
+
68
+ _load_from_sql(_query_by_sql(query), &block)
69
+ end
70
+
71
+ # executes a msearch by provided +RAW+ queries
72
+ def msearch(queries, async: false)
73
+ # build new msearch query
74
+ query = ElasticsearchRecord::Query.new(
75
+ index: table_name,
76
+ type: ElasticsearchRecord::Query::TYPE_MSEARCH,
77
+ body: queries.map { |q| { search: q } },
78
+ # IMPORTANT: Always provide all columns
79
+ columns: source_column_names)
80
+
81
+ connection.exec_query(query, "#{name} Msearch", async: async)
82
+ end
83
+
84
+ # execute query by msearch
85
+ def _query_by_msearch(queries, async: false)
86
+ connection.select_multiple(queries, "#{name} Msearch", async: async)
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,155 @@
1
+ module ElasticsearchRecord
2
+ module Relation
3
+ module CalculationMethods
4
+ # Count the records.
5
+ #
6
+ # Person.count
7
+ # => the total count of all people
8
+ #
9
+ # Person.count(:age)
10
+ # => returns the total count of all people whose age is present in database
11
+ def count(column_name = nil)
12
+ # fallback to default
13
+ return super() if block_given?
14
+
15
+ # reset column_name, if +:all+ was provided ...
16
+ column_name = nil if column_name == :all
17
+
18
+ # check for combined cases
19
+ if self.distinct_value && column_name
20
+ self.cardinality(column_name)
21
+ elsif column_name
22
+ where(:filter, { exists: { field: column_name } }).count
23
+ elsif self.group_values.any?
24
+ self.composite(*self.group_values)
25
+ elsif self.select_values.any?
26
+ self.composite(*self.select_values)
27
+ elsif limit_value == 0 # Shortcut when limit is zero.
28
+ return 0
29
+ elsif limit_value
30
+ # since total will be limited to 10000 results, we need to resolve the real values by a custom query.
31
+ # This query is called through +#select_count+.
32
+ #
33
+ # HINT: :__claim__ directly interacts with the query-object and sets a 'terminate_after' argument
34
+ # (see @ Arel::Collectors::ElasticsearchQuery#assign)
35
+ arel = spawn.unscope!(:offset, :limit, :order, :configure, :aggs).configure!(:__claim__, argument: { terminate_after: limit_value }).arel
36
+ klass.connection.select_count(arel, "#{klass.name} Count")
37
+ else
38
+ # since total will be limited to 10000 results, we need to resolve the real values by a custom query.
39
+ # This query is called through +#select_count+.
40
+ arel = spawn.unscope!(:offset, :limit, :order, :configure, :aggs)
41
+ klass.connection.select_count(arel, "#{klass.name} Count")
42
+ end
43
+ end
44
+
45
+ # A multi-value metrics aggregation that calculates one or more
46
+ # percentiles over numeric values extracted from the aggregated documents.
47
+ # Returns a hash with empty values (but keys still exists) if there is no row.
48
+ #
49
+ # Person.percentiles(:year)
50
+ # > {
51
+ # "1.0" => 2016.0,
52
+ # "5.0" => 2016.0,
53
+ # "25.0" => 2016.0,
54
+ # "50.0" => 2017.0,
55
+ # "75.0" => 2017.0,
56
+ # "95.0" => 2021.0,
57
+ # "99.0" => 2022.0
58
+ # }
59
+ # @param [Symbol, String] column_name
60
+ def percentiles(column_name)
61
+ calculate(:percentiles, column_name, node: :values)
62
+ end
63
+
64
+ # A multi-value metrics aggregation that calculates one or more
65
+ # percentile ranks over numeric values extracted from the aggregated documents.
66
+ #
67
+ # Percentile rank show the percentage of observed values which are below certain value.
68
+ # For example, if a value is greater than or equal to 95% of the observed values it is
69
+ # said to be at the 95th percentile rank.
70
+ #
71
+ # Person.percentile_ranks(:year, [500,600])
72
+ # > {
73
+ # "1.0" => 2016.0,
74
+ # "5.0" => 2016.0,
75
+ # "25.0" => 2016.0,
76
+ # "50.0" => 2017.0,
77
+ # "75.0" => 2017.0,
78
+ # "95.0" => 2021.0,
79
+ # "99.0" => 2022.0
80
+ # }
81
+ # @param [Symbol, String] column_name
82
+ # @param [Array] values
83
+ def percentile_ranks(column_name, values)
84
+ calculate(:percentiles, column_name, opts: { values: values }, node: :values)
85
+ end
86
+
87
+ # Calculates the cardinality on a given column. Returns +0+ if there's no row.
88
+ #
89
+ # Person.cardinality(:age)
90
+ # > 12
91
+ #
92
+ # @param [Symbol, String] column_name
93
+ def cardinality(column_name)
94
+ calculate(:cardinality, column_name)
95
+ end
96
+
97
+ # Calculates the average value on a given column. Returns +nil+ if there's no row. See #calculate for examples with options.
98
+ #
99
+ # Person.average(:age) # => 35.8
100
+ #
101
+ # @param [Symbol, String] column_name
102
+ def average(column_name)
103
+ calculate(:avg, column_name)
104
+ end
105
+
106
+ # Calculates the minimum value on a given column. The value is returned
107
+ # with the same data type of the column, or +nil+ if there's no row.
108
+ #
109
+ # Person.minimum(:age)
110
+ # > 7
111
+ #
112
+ # @param [Symbol, String] column_name
113
+ def minimum(column_name)
114
+ calculate(:min, column_name)
115
+ end
116
+
117
+ # Calculates the maximum value on a given column. The value is returned
118
+ # with the same data type of the column, or +nil+ if there's no row. See
119
+ # #calculate for examples with options.
120
+ #
121
+ # Person.maximum(:age) # => 93
122
+ #
123
+ # @param [Symbol, String] column_name
124
+ def maximum(column_name)
125
+ calculate(:max, column_name)
126
+ end
127
+
128
+ # Calculates the sum of values on a given column. The value is returned
129
+ # with the same data type of the column, +0+ if there's no row. See
130
+ # #calculate for examples with options.
131
+ #
132
+ # Person.sum(:age) # => 4562
133
+ #
134
+ # @param [Symbol, String] column_name (optional)
135
+ def sum(column_name)
136
+ calculate(:sum, column_name)
137
+ end
138
+
139
+ # creates a aggregation with the provided metric (e.g. :sum) and column.
140
+ # returns the metric node (default: :value) from the aggregations result.
141
+ # @param [Symbol, String] metric
142
+ # @param [Symbol, String] column
143
+ # @param [Hash] opts - additional arguments that get merged with the metric definition
144
+ # @param [Symbol] node (default :value)
145
+ def calculate(metric, column, opts: {}, node: :value)
146
+ metric_key = "#{column}_#{metric}"
147
+
148
+ # spawn a new aggregation and return the aggs
149
+ response = aggregate(metric_key, { metric => { field: column }.merge(opts) }).aggregations
150
+
151
+ response[metric_key][node]
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,64 @@
1
+ module ElasticsearchRecord
2
+ module Relation
3
+ module CoreMethods
4
+ def instantiate_records(rows, &block)
5
+ # slurp the total value from the rows (rows = ElasticsearchRecord::Result)
6
+ @total = rows.is_a?(::ElasticsearchRecord::Result) ? rows.total : rows.length
7
+ super
8
+ end
9
+
10
+ # transforms the current relation into arel, compiles it to query and executes the query.
11
+ # returns the result object.
12
+ #
13
+ # PLEASE NOTE: This makes the query +immutable+ and raises a +ActiveRecord::ImmutableRelation+
14
+ # if you try to change it's values.
15
+ #
16
+ # PLEASE NOTE: resolving records _(instantiate)_ is never possible after calling this method!
17
+ #
18
+ # @return [ElasticsearchRecord::Result]
19
+ # @param [String] name - custom instrumentation name (default: 'Load')
20
+ def resolve(name = 'Load')
21
+ # this acts the same like +#_query_by_sql+ but we can customize the instrumentation name and
22
+ # do not store the records.
23
+ klass.connection.select_all(arel, "#{klass.name} #{name}")
24
+ end
25
+
26
+ # returns the query hash for the current relation
27
+ # @return [Hash]
28
+ def to_query
29
+ to_sql.query_arguments
30
+ end
31
+
32
+ # executes the elasticsearch msearch on the related klass
33
+ #
34
+ # @example
35
+ # msearch([2020, 2019, 2018]).each{ |q, year| q.where!(year: year) }
36
+ #
37
+ # @param [Array] values - values to be yielded
38
+ # @param [nil, Symbol] response_type - optional type of search response (:took, :total , :hits , :aggregations , :length , :results, :each)
39
+ def msearch(values = nil, response_type = nil)
40
+ if values.nil?
41
+ arels = [arel]
42
+ else
43
+ arels = values.map { |value|
44
+ # spawn a new relation and return the arel-object
45
+ yield(spawn, value).arel
46
+ }
47
+ end
48
+
49
+ # returns a response object with multiple single responses
50
+ responses = klass._query_by_msearch(arels)
51
+
52
+ if response_type
53
+ responses.map(&response_type.to_sym)
54
+ else
55
+ responses
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+
62
+
63
+
64
+
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ElasticsearchRecord # :nodoc:
4
+ module Relation
5
+ class QueryClause
6
+ delegate :any?, :empty?, to: :predicates
7
+
8
+ attr_reader :key
9
+
10
+ def initialize(key, predicates, opts = {})
11
+ @key = key
12
+ @predicates = predicates
13
+ @opts = opts
14
+ end
15
+
16
+ def hash
17
+ [self.class, key, predicates, opts].hash
18
+ end
19
+
20
+ def ast
21
+ [key, (predicates.one? ? predicates[0] : predicates), opts]
22
+ end
23
+
24
+ def +(other)
25
+ ::ElasticsearchRecord::Relation::QueryClause.new(key, predicates + other.predicates, opts.merge(other.opts))
26
+ end
27
+
28
+ def -(other)
29
+ ::ElasticsearchRecord::Relation::QueryClause.new(key, predicates - other.predicates, Hash[opts.to_a - other.opts.to_a])
30
+ end
31
+
32
+ def |(other)
33
+ ::ElasticsearchRecord::Relation::QueryClause.new(key, predicates | other.predicates, Hash[opts.to_a | other.opts.to_a])
34
+ end
35
+
36
+ protected
37
+
38
+ attr_reader :predicates
39
+ attr_reader :opts
40
+
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ElasticsearchRecord # :nodoc:
4
+ module Relation
5
+ class QueryClauseTree
6
+ delegate :any?, :empty?, :key?, :each, to: :predicates
7
+
8
+ def self.empty
9
+ @empty ||= new({}).freeze
10
+ end
11
+
12
+ def initialize(predicates)
13
+ @predicates = predicates
14
+ end
15
+
16
+ def key
17
+ :tree
18
+ end
19
+
20
+ def hash
21
+ [self.class, predicates].hash
22
+ end
23
+
24
+ def ast
25
+ predicates.values.map(&:ast)
26
+ end
27
+
28
+ def merge(other)
29
+ dups = dupredicates
30
+
31
+ other.each do |key, values|
32
+ if dups.key?(key)
33
+ dups[key] = (dups[key] + values).uniq
34
+ else
35
+ dups[key] = values
36
+ end
37
+ end
38
+
39
+ QueryClauseTree.new(dups)
40
+ end
41
+
42
+ def +(other)
43
+ dups = dupredicates
44
+
45
+ if key?(other.key)
46
+ dups[other.key] += other
47
+ else
48
+ dups[other.key] = other
49
+ end
50
+
51
+ QueryClauseTree.new(dups)
52
+ end
53
+
54
+ def -(other)
55
+ dups = dupredicates
56
+
57
+ if key?(other.key)
58
+ dups[other.key] -= other
59
+ dups.delete(other.key) if dups[other.key].blank?
60
+ end
61
+
62
+ QueryClauseTree.new(dups)
63
+ end
64
+
65
+ def |(other)
66
+ dups = dupredicates
67
+
68
+ if key?(other.key)
69
+ dups[other.key] |= other
70
+ else
71
+ dups[other.key] = other
72
+ end
73
+
74
+ QueryClauseTree.new(dups)
75
+ end
76
+
77
+ def ==(other)
78
+ other.is_a?(::ElasticsearchRecord::Relation::QueryClauseTree) &&
79
+ predicates == other.predicates
80
+ end
81
+
82
+ protected
83
+
84
+ attr_reader :predicates
85
+
86
+ private
87
+
88
+ def dupredicates
89
+ # we only dup the hash - no need to dup the lower elements
90
+ predicates.dup
91
+ end
92
+ end
93
+ end
94
+ end