elasticsearch_record 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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