chewy 8.0.1 → 8.3.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 36ec23aabab2b6b62d9425ad6505f5589958400f21411c90d9808113336aac35
4
- data.tar.gz: 0b082e6327fa4092477c7888714662f791a940b9a1045047ce7a183d41656f2c
3
+ metadata.gz: 34d773f10bf67d60eca51ff91a6d668186eea74515bb5b31a5fd5ce814dac3c7
4
+ data.tar.gz: 334c6e92185369170b9f4d534029b69c218bb3c0cabfe6e4c11fd79268c19587
5
5
  SHA512:
6
- metadata.gz: ad839b902eeb12a5f94547e21f0ed9b2d078be1869b0070be7698fd357edde8d6d759502301dd823eb53d8a3fa42bd6d5584cee53dd482df6c05d1b46e2d110a
7
- data.tar.gz: 3fdef90ea2c73f41274556d5f001e7931bcbbb9b2bdd02df6204fbd75c5b8139ed2cbff00cd448922e8215d2f35f6f3695bd52505db2f30cd20968b54732c401
6
+ metadata.gz: 24bde735bcfc4f1bfec8b146cdcc5024d049e1405ccfbbae5e6f57f53a89aa9a6951063b00251a2f87fafac3632e1640fddc64038c7337e9147a0b9d6740f984
7
+ data.tar.gz: d2d84b1d2d3b07bde52699dfc54ed3b9ac8b9b71c5d79ca204851abab06bf53a41f0591ded6cdab59be5bf87522374dc10ff01d180891bcf81d73d33b009e180
data/CHANGELOG.md CHANGED
@@ -1,5 +1,74 @@
1
1
  # Changelog
2
2
 
3
+ ## master (unreleased)
4
+
5
+ ### New Features
6
+
7
+ ### Bug Fixes
8
+
9
+ ### Changes
10
+
11
+ ## 8.3.0 (2026-06-03)
12
+
13
+ ### New Features
14
+
15
+ * New compiled compose path is now the default for every index. Chewy generates one `__chewy_compose__` method per index from the field tree on first import and reuses it for every object, replacing the iterative `Fields::Root#compose` loop. Typical document composition is 3-4× faster than the iterative path and matches `witchcraft!` performance without any extra dependencies. `update_fields:` partial imports are also covered via per-fields-set memoized methods.
16
+
17
+ ### Bug Fixes
18
+
19
+ ### Changes
20
+
21
+ * **Deprecation:** `Chewy::Index.witchcraft!` is deprecated and will be removed in a future major release. It now prints a deprecation warning and is no longer required for fast composition — the compiled compose path described above is the default and delivers equivalent throughput without `method_source` / `parser` / `prism` / `unparser`. The `parser` / `prism` / `unparser` requires are now lazy and only loaded when an index actually calls `witchcraft!`, eliminating ~24 MiB of boot-time allocations and ~1 MiB of retained memory for apps that don't use it ([#644](https://github.com/toptal/chewy/issues/644)).
22
+
23
+ * [#1028](https://github.com/toptal/chewy/pull/1028): Remove the obsolete `_type` field from mock response helpers (`Chewy::Minitest::Helpers`, `Chewy::Rspec::Helpers`), `EVERFIELDS`, and `Chewy::Index::Wrapper` accessors. Elasticsearch removed `_type` from search responses in 8.0 (ES 7 already returned only the placeholder `_doc`), so these references no longer matched real responses. Also removes the long-obsolete `_parent` entry from `EVERFIELDS`. ([@mattmenefee][])
24
+
25
+ ## 8.2.1 (2026-06-01)
26
+
27
+ ### New Features
28
+
29
+ ### Bug Fixes
30
+
31
+ * Fix `import_count` on relations with multiple `.select(...)` clauses so the bounded progressbar is used instead of silently falling back to the unbounded spinner. Bare `.count` produced `SELECT COUNT(col1, col2, ...)` (invalid SQL); `count(:all)` is used for relations. ([#1026](https://github.com/toptal/chewy/pull/1026))
32
+
33
+ ### Changes
34
+
35
+ ## 8.2.0 (2026-05-29)
36
+
37
+ ### New Features
38
+
39
+ * Add `progressbar:` option to `import`/`import!` and a `PROGRESS=1` rake env toggle for `chewy:reset` / `chewy:update`. The bar is opt-in (default `false`), supports a `:unbounded` spinner mode that skips the `import_count` query, and is safe in parallel mode — workers stay process-based, the bar is incremented in the parent via `Parallel`'s `finish:` callback. Reintroduces the feature originally added in [#787](https://github.com/toptal/chewy/pull/787) and reverted in [#800](https://github.com/toptal/chewy/pull/800) without the GVL regression.
40
+
41
+ ### Bug Fixes
42
+
43
+ * Fix race condition during `reset!` where the unsuffixed concrete index could be recreated by a concurrent process between the delete and the alias creation, causing the alias creation to fail with an index/alias name collision.
44
+ The delete + alias-add are now performed in a single atomic `_aliases` cluster state update via the `remove_index` action.
45
+ No public API or layout change.
46
+ * [#992](https://github.com/toptal/chewy/issues/992): `import(update_fields: [])` is now a no-op (zero fields to update) instead of triggering a full document reindex. The default of `update_fields` is now `nil` (still performs a full reindex). Behavior change: any caller passing an explicit empty array previously got a silent full reimport; they will now skip the update.
47
+
48
+ ### Changes
49
+
50
+ * [#1024](https://github.com/toptal/chewy/pull/1024): Replace deprecated `ZRANGEBYSCORE` with `ZRANGE ... BYSCORE` in the `delayed_sidekiq` worker Lua script. `ZRANGEBYSCORE` has been deprecated in Redis since 6.2.0.
51
+
52
+ ## 8.1.0 (2026-05-28)
53
+
54
+ ### New Features
55
+
56
+ * [#887](https://github.com/toptal/chewy/pull/887): Add support [runtime_mappings](https://www.elastic.co/guide/en/elasticsearch/reference/current/runtime-search-request.html). ([@TakuyaKurimoto](https://github.com/TakuyaKurimoto))
57
+ * [#996](https://github.com/toptal/chewy/pull/996): Add `context:` option to `import`/`import!` for passing custom data to crutch blocks and field value procs without redundant DB queries.
58
+
59
+ ### Bug Fixes
60
+
61
+ * [#878](https://github.com/toptal/chewy/issues/878): Flatten `Chewy::Stash::Journal.for` to use a single `terms` filter instead of a chain of `bool.should` clauses. Fixes Elasticsearch `indices.query.bool.max_nested_depth` errors when applying or cleaning the journal across many indices.
62
+
63
+ ### Changes
64
+
65
+ * [#916](https://github.com/toptal/chewy/pull/916): Raise error in #scroll_batches when search backend returns a failure. ([@tomdev][])
66
+ * [#1008](https://github.com/toptal/chewy/pull/1008): Promote Elasticsearch to a native GitHub Actions service with a health-check gate, replacing the fragile `docker compose` + `sleep 15` approach. ([@mattmenefee][])
67
+ * [#1010](https://github.com/toptal/chewy/pull/1010): Add Chewy 7/ES 7 to Chewy 8/ES 8 migration guide and fix stale `Elasticsearch::Transport` namespace references in docs. ([@mattmenefee][])
68
+ * [#1011](https://github.com/toptal/chewy/pull/1011): Replace deprecated `Sidekiq::Testing` API with new `Sidekiq 8.1+` testing API and silence Sidekiq logger during spec runs. ([@mattmenefee][], [@mjankowski][])
69
+ * [#1013](https://github.com/toptal/chewy/pull/1013): Fix `drop_indices` test helper to use `format: 'json'` for ES version portability. If you define a custom `drop_indices` helper in your test suite, update it to use `Chewy.client.cat.indices(format: 'json')` instead of parsing the text-format response. ([@mattmenefee][])
70
+ * [#1014](https://github.com/toptal/chewy/pull/1014): Improve contributing documentation with development setup instructions, PR workflow, and grammar fixes. ([@mattmenefee][])
71
+
3
72
  ## 8.0.1 (2026-03-12)
4
73
 
5
74
  ### New Features
@@ -874,10 +943,12 @@
874
943
  [@marshall]: https://github.com/marshall
875
944
  [@matchbookmac]: https://github.com/matchbookmac
876
945
  [@matthee]: https://github.com/matthee
946
+ [@mattmenefee]: https://github.com/mattmenefee
877
947
  [@mattzollinhofer]: https://github.com/mattzollinhofer
878
948
  [@menglewis]: https://github.com/menglewis
879
949
  [@mikeyhogarth]: https://github.com/mikeyhogarth
880
950
  [@milk1000cc]: https://github.com/milk1000cc
951
+ [@mjankowski]: https://github.com/mjankowski
881
952
  [@mkcode]: https://github.com/mkcode
882
953
  [@mpeychich]: https://github.com/mpeychich
883
954
  [@mrbrdo]: https://github.com/mrbrdo
@@ -901,6 +972,7 @@
901
972
  [@socialchorus]: https://github.com/socialchorus
902
973
  [@taylor-au]: https://github.com/taylor-au
903
974
  [@TikiTDO]: https://github.com/TikiTDO
975
+ [@tomdev]: https://github.com/tomdev
904
976
  [@undr]: https://github.com/undr
905
977
  [@Vitalina-Vakulchyk]: https://github.com/Vitalina-Vakulchyk
906
978
  [@webgago]: https://github.com/webgago
data/README.md CHANGED
@@ -1,7 +1,5 @@
1
1
  [![Gem Version](https://badge.fury.io/rb/chewy.svg)](http://badge.fury.io/rb/chewy)
2
2
  [![GitHub Actions](https://github.com/toptal/chewy/actions/workflows/ruby.yml/badge.svg)](https://github.com/toptal/chewy/actions/workflows/ruby.yml)
3
- [![Code Climate](https://codeclimate.com/github/toptal/chewy.svg)](https://codeclimate.com/github/toptal/chewy)
4
- [![Inline docs](http://inch-ci.org/github/toptal/chewy.svg?branch=master)](http://inch-ci.org/github/toptal/chewy)
5
3
 
6
4
  # Chewy
7
5
 
@@ -47,7 +45,7 @@ Chewy aims to support all Ruby and Rails versions that are currently maintained
47
45
 
48
46
  ### Ruby
49
47
 
50
- Chewy is compatible with MRI 3.2-3.4.
48
+ Chewy is compatible with MRI 3.2-4.0.
51
49
 
52
50
  ### Elasticsearch compatibility matrix
53
51
 
@@ -220,7 +218,6 @@ end
220
218
  },
221
219
  "_data":{
222
220
  "_index":"users",
223
- "_type":"_doc",
224
221
  "_id":"1",
225
222
  "_score":0.9808291,
226
223
  "_source":{
@@ -238,7 +235,7 @@ end
238
235
 
239
236
  - [Getting Started](docs/getting_started.md) — end-to-end tutorial building search for a media library
240
237
  - [Configuration](docs/configuration.md) — client settings, update strategies, notifications, integrations
241
- - [Indexing](docs/indexing.md) — index definition, field types, crutches, witchcraft, index manipulation
238
+ - [Indexing](docs/indexing.md) — index definition, field types, crutches, compiled compose path, index manipulation
242
239
  - [Import](docs/import.md) — import options, raw import, journaling
243
240
  - [Querying](docs/querying.md) — search requests, pagination, scopes, scroll, loading
244
241
  - [Rake Tasks](docs/rake_tasks.md) — all rake tasks and parallelization
@@ -274,19 +271,7 @@ Use the standard client settings with your Cloud credentials (API key or user/pa
274
271
 
275
272
  ## Contributing
276
273
 
277
- 1. Fork it (http://github.com/toptal/chewy/fork)
278
- 2. Create your feature branch (`git checkout -b my-new-feature`)
279
- 3. Implement your changes, cover it with specs and make sure old specs are passing
280
- 4. Commit your changes (`git commit -am 'Add some feature'`)
281
- 5. Push to the branch (`git push origin my-new-feature`)
282
- 6. Create new Pull Request
283
-
284
- Use the following Rake tasks to control the Elasticsearch cluster while developing, if you prefer native Elasticsearch installation over the dockerized one:
285
-
286
- ```bash
287
- rake elasticsearch:start # start Elasticsearch cluster on 9250 port for tests
288
- rake elasticsearch:stop # stop Elasticsearch
289
- ```
274
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup instructions, how to run tests, and the pull request workflow.
290
275
 
291
276
  ## Copyright
292
277
 
data/lib/chewy/errors.rb CHANGED
@@ -37,6 +37,9 @@ module Chewy
37
37
  end
38
38
  end
39
39
 
40
+ class MissingHitsInScrollError < Error
41
+ end
42
+
40
43
  class ImportScopeCleanupError < Error
41
44
  end
42
45
 
@@ -3,10 +3,12 @@ module Chewy
3
3
  class Root < Chewy::Fields::Base
4
4
  attr_reader :dynamic_templates, :id
5
5
 
6
+ DEFAULT_VALUE = -> { self }
7
+
6
8
  def initialize(name, **options)
7
9
  super
8
10
 
9
- @value ||= -> { self }
11
+ @value ||= DEFAULT_VALUE
10
12
  @dynamic_templates = []
11
13
  end
12
14
 
@@ -61,8 +63,8 @@ module Chewy
61
63
  # @param fields [Array<Symbol>] a list of fields to compose, every field will be composed if empty
62
64
  # @return [Hash] JSON-ready hash with stringified keys
63
65
  #
64
- def compose(object, crutches = nil, fields: [])
65
- result = evaluate([object, crutches])
66
+ def compose(object, crutches = nil, fields: [], context: {})
67
+ result = evaluate([object, crutches, context])
66
68
 
67
69
  if children.present?
68
70
  child_fields = if fields.present?
@@ -72,7 +74,7 @@ module Chewy
72
74
  end
73
75
 
74
76
  child_fields.each_with_object({}) do |field, memo|
75
- memo.merge!(field.compose(result, crutches) || {})
77
+ memo.merge!(field.compose(result, crutches, context) || {})
76
78
  end.as_json
77
79
  elsif fields.present?
78
80
  result.as_json(only: fields, root: false)
@@ -7,12 +7,13 @@ module Chewy
7
7
  extend ActiveSupport::Concern
8
8
 
9
9
  module ClassMethods
10
- # Checks index existance. Returns true or false
10
+ # Checks index existance. Supports suffixes. Returns true or false
11
11
  #
12
12
  # UsersIndex.exists? #=> true
13
+ # UsersIndex.exists?('11-2024') #=> false
13
14
  #
14
- def exists?
15
- client.indices.exists(index: index_name)
15
+ def exists?(suffix = nil)
16
+ client.indices.exists(index: index_name(suffix: suffix))
16
17
  end
17
18
 
18
19
  # Creates index and applies mappings and settings.
@@ -163,16 +164,17 @@ module Chewy
163
164
  ))
164
165
  original_index_settings suffixed_name
165
166
 
166
- delete if indexes.blank?
167
- client.indices.update_aliases body: {actions: [
168
- *indexes.map do |index|
169
- {remove: {index: index, alias: general_name}}
170
- end,
171
- {add: {index: suffixed_name, alias: general_name}}
172
- ]}
167
+ actions = indexes.map { |index| {remove: {index: index, alias: general_name}} }
168
+ actions << {add: {index: suffixed_name, alias: general_name}}
169
+ if indexes.blank? && exists?
170
+ index_names = client.indices.get_alias(index: general_name).keys
171
+ actions << {remove_index: {index: index_names.join(',')}}
172
+ end
173
+
174
+ client.indices.update_aliases body: {actions: actions}
173
175
  client.indices.delete index: indexes if indexes.present?
174
176
 
175
- self.journal.apply(start_time, **import_options) if apply_journal
177
+ self.journal.apply(start_time, **import_options.except(:progressbar)) if apply_journal
176
178
  result
177
179
  else
178
180
  purge!
@@ -146,6 +146,23 @@ module Chewy
146
146
  collection.each_slice(options[:batch_size], &block)
147
147
  end
148
148
 
149
+ # Returns the count of objects that would be imported. Used by the
150
+ # progressbar feature to set the total. Mirrors {#import_args} input
151
+ # handling but does not enumerate batches.
152
+ #
153
+ # @return [Integer]
154
+ def import_count(*args)
155
+ args = args.dup
156
+ args.extract_options!
157
+ collection = if args.empty? && @target.respond_to?(import_all_method)
158
+ @target.send(import_all_method)
159
+ else
160
+ args.flatten(1).compact
161
+ end
162
+
163
+ collection.count
164
+ end
165
+
149
166
  # This method is used internally by the request DSL when the
150
167
  # collection of ORM/ODM objects is requested.
151
168
  #
@@ -96,6 +96,26 @@ module Chewy
96
96
  end
97
97
  alias_method :import_references, :import_fields
98
98
 
99
+ # Returns the count of records that would be imported. Used by the
100
+ # progressbar feature to set the total. Accepts the same shapes as
101
+ # {#import}: nothing (uses default scope), a relation, or an array
102
+ # of ids/objects.
103
+ #
104
+ # @return [Integer]
105
+ def import_count(*args)
106
+ args = args.dup
107
+ args.extract_options!
108
+ collection = if args.empty?
109
+ default_scope
110
+ elsif args.first.is_a?(relation_class)
111
+ args.first
112
+ else
113
+ args.flatten.compact
114
+ end
115
+
116
+ collection.is_a?(relation_class) ? collection.count(:all) : collection.count
117
+ end
118
+
99
119
  def load(ids, **options)
100
120
  scope = all_scope_where_ids_in(ids)
101
121
  additional_scope = options[options[:_index].to_sym].try(:[], :scope) || options[:scope]
@@ -0,0 +1,235 @@
1
+ module Chewy
2
+ class Index
3
+ # Compiled compose path. Default for all indexes.
4
+ #
5
+ # Generates a single `__chewy_compose__` method per index from the
6
+ # field tree (no Ruby source parsing of user procs). Bakes the
7
+ # per-field arity into the call site and inlines hash construction,
8
+ # removing the per-field iteration + dispatch overhead of the legacy
9
+ # plain compose path. Proc bodies are NOT inlined — procs are called
10
+ # via `.call`. In exchange there are no parser/unparser/method_source
11
+ # deps and no Ruby/Prism version coupling.
12
+ #
13
+ # `fields:` selection is supported: a dedicated method is generated
14
+ # and memoized per unique fields set.
15
+ #
16
+ # Falls back to the plain `Chewy::Fields::Root#compose` path when the
17
+ # field tree uses features the compiler does not handle:
18
+ # ignore_blank, geo_point, custom root value.
19
+ module Compiled
20
+ extend ActiveSupport::Concern
21
+
22
+ class UNSUPPORTED < StandardError
23
+ end
24
+
25
+ COMPILE_MUTEX = Mutex.new
26
+
27
+ module ClassMethods
28
+ # Returns true when this index can use the compiled path for the
29
+ # given compose call. Builds the per-fields method if needed.
30
+ def compiled_compose_available?(fields)
31
+ return false if witchcraft?
32
+ return false unless root&.children&.present?
33
+
34
+ ensure_compiled_compose_for!(fields)
35
+ rescue UNSUPPORTED
36
+ false
37
+ end
38
+
39
+ def compiled_compose(object, crutches, context, fields)
40
+ send(compiled_method_name(fields), object, crutches, context)
41
+ end
42
+
43
+ def compiled_procs
44
+ @compiled_procs ||= []
45
+ end
46
+
47
+ # Build (and cache) the compiled compose method for the given
48
+ # fields restriction. Returns true on success, false / raises on
49
+ # unsupported. Thread-safe: compilation under a process-wide
50
+ # mutex prevents two threads from interleaving appends into
51
+ # @compiled_procs and baking duplicate indices into class_eval'd
52
+ # method bodies.
53
+ def ensure_compiled_compose_for!(fields)
54
+ key = fields_key(fields)
55
+ built = (@compiled_compose_built ||= {})
56
+ return built[key] if built.key?(key)
57
+
58
+ COMPILE_MUTEX.synchronize do
59
+ return built[key] if built.key?(key)
60
+
61
+ method_name = compiled_method_name(fields)
62
+ source = Compiler.new(self, fields: fields, method_name: method_name).build
63
+ class_eval(source, __FILE__, __LINE__)
64
+ built[key] = true
65
+ end
66
+ rescue UNSUPPORTED
67
+ (@compiled_compose_built ||= {})[fields_key(fields)] = false
68
+ raise
69
+ end
70
+
71
+ # Clears compiled-method cache and rebuilds-on-next-call. Hooked
72
+ # from `Mapping.field` so a `field` added after the first compose
73
+ # is picked up rather than absent from a stale generated method.
74
+ # Existing singleton methods remain defined (they shadow until
75
+ # rebuilt) but the cache flag is dropped so the next compose
76
+ # rebuilds and reassigns them.
77
+ def invalidate_compiled_compose!
78
+ @compiled_compose_built = nil
79
+ @compiled_procs = nil
80
+ @compiled_default_ready = false
81
+ end
82
+
83
+ def compiled_method_name(fields)
84
+ key = fields_key(fields)
85
+ return :__chewy_compose__ if key.empty?
86
+
87
+ :"__chewy_compose__#{key}"
88
+ end
89
+
90
+ private
91
+
92
+ # Distinct field sets are kept distinct by hashing the joined,
93
+ # sorted symbol names rather than joining with a separator that
94
+ # collides with `_` inside field names (e.g. [:full_name, :id]
95
+ # vs [:full, :name_id]). Hex-encoding the digest keeps the
96
+ # method name a valid Ruby identifier.
97
+ def fields_key(fields)
98
+ return ''.freeze if fields.nil? || fields.empty?
99
+
100
+ require 'digest'
101
+ Digest::SHA1.hexdigest(fields.map(&:to_sym).sort.join("\0"))[0, 12]
102
+ end
103
+ end
104
+
105
+ class Compiler
106
+ IDENTIFIER_RE = /\A[A-Za-z_][A-Za-z0-9_]*[!?=]?\z/
107
+
108
+ def initialize(index, fields: [], method_name: :__chewy_compose__)
109
+ @index = index
110
+ @procs = index.compiled_procs
111
+ @counter = 0
112
+ @fields = fields.map(&:to_sym)
113
+ @method_name = method_name
114
+ end
115
+
116
+ def build
117
+ check_root_value!(@index.root)
118
+ body = compose_children_src(@index.root, 'o0', restrict: @fields)
119
+ <<~RUBY
120
+ def self.#{@method_name}(o0, crutches, context)
121
+ #{body}.as_json
122
+ end
123
+ RUBY
124
+ end
125
+
126
+ private
127
+
128
+ def check_root_value!(root)
129
+ v = root.instance_variable_get(:@value)
130
+ return if v.equal?(Chewy::Fields::Root::DEFAULT_VALUE)
131
+
132
+ raise UNSUPPORTED, 'custom root value proc not supported by compiled path'
133
+ end
134
+
135
+ def compose_children_src(parent_field, obj_var, restrict: [])
136
+ children = parent_field.children
137
+ children = children.select { |f| restrict.include?(f.name) } if restrict.any?
138
+ # Plain Base#compose_children merges per-field hashes, so a field
139
+ # redeclared with the same name is silently last-wins. Match that
140
+ # here to avoid Ruby's "duplicate key" warning on the generated
141
+ # literal hash.
142
+ children = children.reverse.uniq(&:name).reverse
143
+
144
+ pairs = children.map do |f|
145
+ raise UNSUPPORTED, "field #{f.name}" if unsupported?(f)
146
+
147
+ # Use Ruby's literal-inspect for the key so names with quotes,
148
+ # backslashes, `#{}`, or other special characters are safely
149
+ # serialised — never raw-interpolated into source.
150
+ "#{f.name.to_s.inspect} => #{field_value_src(f, obj_var)}"
151
+ end
152
+ "{ #{pairs.join(', ')} }"
153
+ end
154
+
155
+ # Features the compiler refuses; caller falls back to plain.
156
+ # Join fields are supported via Field#value returning a proc.
157
+ def unsupported?(field)
158
+ return true if field.options.key?(:ignore_blank)
159
+ return true if field.options[:type].to_s == 'geo_point'
160
+
161
+ false
162
+ end
163
+
164
+ def field_value_src(field, parent_obj_var)
165
+ fetch = fetch_src(field, parent_obj_var)
166
+ return fetch if !field.children.present? || field.multi_field?
167
+
168
+ child_var = "o#{next_counter}"
169
+ raw_var = "#{child_var}_raw"
170
+ children_src = compose_children_src(field, child_var)
171
+ <<~RUBY.chomp
172
+ (#{raw_var} = #{fetch}
173
+ if #{raw_var}.nil?
174
+ nil
175
+ elsif #{raw_var}.respond_to?(:to_ary)
176
+ #{raw_var}.to_ary.map { |#{child_var}| #{children_src} }
177
+ else
178
+ #{child_var} = #{raw_var}
179
+ #{children_src}
180
+ end)
181
+ RUBY
182
+ end
183
+
184
+ def fetch_src(field, obj_var)
185
+ v = field.value
186
+ if v.is_a?(Proc)
187
+ idx = @procs.size
188
+ @procs << v
189
+ procs_ref = "compiled_procs[#{idx}]"
190
+ param_count = positional_param_count(v)
191
+ case v.arity
192
+ when 0
193
+ "#{obj_var}.instance_exec(&#{procs_ref})"
194
+ else
195
+ # Pass only as many of (object, crutches, context) as the
196
+ # proc actually declares. This keeps lambdas with optional
197
+ # args (negative arity like `->(o, c=nil)`) from being
198
+ # called with too many arguments. Anything beyond context
199
+ # truncates to all three.
200
+ args = [obj_var, 'crutches', 'context'].first(param_count)
201
+ "#{procs_ref}.call(#{args.join(', ')})"
202
+ end
203
+ else
204
+ method_name = v.is_a?(Symbol) || v.is_a?(String) ? v.to_s : field.name.to_s
205
+ raise UNSUPPORTED, "non-identifier field accessor: #{method_name.inspect}" unless safe_identifier?(method_name)
206
+
207
+ # Mirror Base#value_by_name_proc: hash key-or-string fallback,
208
+ # method send otherwise.
209
+ "(#{obj_var}.is_a?(Hash) ? " \
210
+ "(#{obj_var}.key?(:#{method_name}) ? #{obj_var}[:#{method_name}] : #{obj_var}['#{method_name}']) : " \
211
+ "#{obj_var}.#{method_name})"
212
+ end
213
+ end
214
+
215
+ # Number of positional parameters declared by `proc`, counting
216
+ # required + optional. Splats and keyword args contribute one
217
+ # bucket each so the caller still passes all three context args.
218
+ def positional_param_count(proc)
219
+ params = proc.parameters
220
+ required = params.count { |type, _| %i[req opt].include?(type) }
221
+ has_splat = params.any? { |type, _| type == :rest }
222
+ has_splat ? 3 : [required, 3].min
223
+ end
224
+
225
+ def safe_identifier?(name)
226
+ IDENTIFIER_RE.match?(name.to_s)
227
+ end
228
+
229
+ def next_counter
230
+ @counter += 1
231
+ end
232
+ end
233
+ end
234
+ end
235
+ end
@@ -9,9 +9,12 @@ module Chewy
9
9
  end
10
10
 
11
11
  class Crutches
12
- def initialize(index, collection)
12
+ attr_reader :context
13
+
14
+ def initialize(index, collection, context = {})
13
15
  @index = index
14
16
  @collection = collection
17
+ @context = context
15
18
  @crutches_instances = {}
16
19
  end
17
20
 
@@ -26,7 +29,14 @@ module Chewy
26
29
  end
27
30
 
28
31
  def [](name)
29
- @crutches_instances[name] ||= @index._crutches[:"#{name}"].call(@collection)
32
+ @crutches_instances[name] ||= begin
33
+ block = @index._crutches[:"#{name}"]
34
+ if block.arity > 1 || block.arity < -1
35
+ block.call(@collection, @context)
36
+ else
37
+ block.call(@collection)
38
+ end
39
+ end
30
40
  end
31
41
  end
32
42
 
@@ -13,11 +13,12 @@ module Chewy
13
13
  # @param to_index [Array<Object>] objects to index
14
14
  # @param delete [Array<Object>] objects or ids to delete
15
15
  # @param fields [Array<Symbol, String>] and array of fields for documents update
16
- def initialize(index, to_index: [], delete: [], fields: [])
16
+ def initialize(index, to_index: [], delete: [], fields: nil, context: {})
17
17
  @index = index
18
18
  @to_index = to_index
19
19
  @delete = delete
20
- @fields = fields.map!(&:to_sym)
20
+ @fields = fields&.map(&:to_sym)
21
+ @context = context
21
22
  end
22
23
 
23
24
  # Returns ES API-ready bulk requiest body.
@@ -42,10 +43,12 @@ module Chewy
42
43
  private
43
44
 
44
45
  def crutches_for_index
45
- @crutches_for_index ||= Chewy::Index::Crutch::Crutches.new @index, @to_index
46
+ @crutches_for_index ||= Chewy::Index::Crutch::Crutches.new @index, @to_index, @context
46
47
  end
47
48
 
48
49
  def index_entry(object)
50
+ return [] if @fields&.empty?
51
+
49
52
  entry = {}
50
53
  entry[:_id] = index_object_ids[object] if index_object_ids[object]
51
54
  entry[:routing] = routing(object) if join_field?
@@ -257,13 +260,13 @@ module Chewy
257
260
  end
258
261
 
259
262
  def data_for(object, fields: [], crutches: crutches_for_index)
260
- @index.compose(object, crutches, fields: fields)
263
+ @index.compose(object, crutches, fields: fields, context: @context)
261
264
  end
262
265
 
263
266
  def parent_changed?(data, old_parent)
264
267
  return false unless old_parent
265
268
  return false unless join_field?
266
- return false unless @fields.include?(join_field.to_sym)
269
+ return false unless @fields&.include?(join_field.to_sym)
267
270
  return false unless data.key?(join_field)
268
271
 
269
272
  # The join field value can be a hash, e.g.: