search_flip 2.0.0.beta3 → 2.0.0.beta4
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/UPDATING.md +45 -0
- data/lib/search_flip/aggregation.rb +47 -0
- data/lib/search_flip/connection.rb +11 -0
- data/lib/search_flip/criteria.rb +5 -3
- data/lib/search_flip/index.rb +7 -1
- data/lib/search_flip/version.rb +1 -1
- data/spec/search_flip/aggregation_spec.rb +118 -0
- data/spec/search_flip/criteria_spec.rb +4 -5
- data/spec/search_flip/index_spec.rb +10 -0
- data/spec/spec_helper.rb +12 -0
- metadata +2 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: c626b4c449c70b071fee204d164b226271d2f098ae48f4c54568e07b6ad8d9d8
|
4
|
+
data.tar.gz: f35599e1cd57a27fe3c008fa20538be08fa9f7251a5c421d25fd98113d2a6cc7
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 95df5e882633308f0dcbcac35d155901db7bfee39f0f37d1ee84c055fdde821a22b697fd0df5ea4ab82204896c3adb483769cae85b1489ec2eca28c4fc0e2de5
|
7
|
+
data.tar.gz: e97fceeacf0186b99829fb70d9a8eb396455c2f2cf3dec58e2f357dd5b8bd542025e7ee33201757af02c45b90752da70e6d28e6cbff2605fc36d0fd7ac9638d2
|
data/UPDATING.md
CHANGED
@@ -127,3 +127,48 @@ query = CommentIndex.highlight(:title).search("hello")
|
|
127
127
|
query.results[0]._hit.highlight.title # => "<em>hello</em> world"
|
128
128
|
```
|
129
129
|
|
130
|
+
* **[BREAKING]** `index_name` no longer defaults to `type_name`
|
131
|
+
|
132
|
+
1.x:
|
133
|
+
|
134
|
+
```ruby
|
135
|
+
class CommentIndex
|
136
|
+
include SearchFlip::Index
|
137
|
+
|
138
|
+
def self.type_name
|
139
|
+
"comments"
|
140
|
+
end
|
141
|
+
|
142
|
+
# CommentIndex.index_name defaults to CommentIndex.type_name
|
143
|
+
end
|
144
|
+
```
|
145
|
+
|
146
|
+
2.x:
|
147
|
+
|
148
|
+
```ruby
|
149
|
+
class CommentIndex
|
150
|
+
include SearchFlip::Index
|
151
|
+
|
152
|
+
def self.type_name
|
153
|
+
"comments"
|
154
|
+
end
|
155
|
+
|
156
|
+
def self.index_name
|
157
|
+
"comments"
|
158
|
+
end
|
159
|
+
end
|
160
|
+
```
|
161
|
+
|
162
|
+
* **[BREAKING]** Multiple calls to `source` no longer concatenate
|
163
|
+
|
164
|
+
1.x:
|
165
|
+
|
166
|
+
```ruby
|
167
|
+
CommentIndex.source([:id]).source([:description]) # => CommentIndex.source([:id, :description])
|
168
|
+
```
|
169
|
+
|
170
|
+
2.x:
|
171
|
+
|
172
|
+
```ruby
|
173
|
+
CommentIndex.source([:id]).source([:description]) # => CommentIndex.source([:description])
|
174
|
+
```
|
@@ -49,6 +49,53 @@ module SearchFlip
|
|
49
49
|
res
|
50
50
|
end
|
51
51
|
|
52
|
+
# @api private
|
53
|
+
#
|
54
|
+
# Merges a criteria into the aggregation.
|
55
|
+
#
|
56
|
+
# @param other [SearchFlip::Criteria] The criteria to merge in
|
57
|
+
#
|
58
|
+
# @return [SearchFlip::Aggregation] A fresh aggregation including the merged criteria
|
59
|
+
|
60
|
+
def merge(other)
|
61
|
+
other = other.criteria
|
62
|
+
|
63
|
+
fresh.tap do |aggregation|
|
64
|
+
unsupported_methods = [
|
65
|
+
:profile_value, :failsafe_value, :terminate_after_value, :timeout_value, :offset_value, :limit_value,
|
66
|
+
:scroll_args, :highlight_values, :suggest_values, :custom_value, :source_value, :sort_values,
|
67
|
+
:includes_values, :preload_values, :eager_load_values, :post_search_values, :post_must_values,
|
68
|
+
:post_must_not_values, :post_should_values, :post_filter_values
|
69
|
+
]
|
70
|
+
|
71
|
+
unsupported_methods.each do |unsupported_method|
|
72
|
+
unless other.send(unsupported_method).nil?
|
73
|
+
raise(SearchFlip::NotSupportedError, "Using #{unsupported_method} within aggregations is not supported")
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
aggregation.search_values = (aggregation.search_values || []) + other.search_values if other.search_values
|
78
|
+
aggregation.must_values = (aggregation.must_values || []) + other.must_values if other.must_values
|
79
|
+
aggregation.must_not_values = (aggregation.must_not_values || []) + other.must_not_values if other.must_not_values
|
80
|
+
aggregation.should_values = (aggregation.should_values || []) + other.should_values if other.should_values
|
81
|
+
aggregation.filter_values = (aggregation.filter_values || []) + other.filter_values if other.filter_values
|
82
|
+
|
83
|
+
aggregation.aggregation_values = (aggregation.aggregation_values || {}).merge(other.aggregation_values) if other.aggregation_values
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
def respond_to_missing?(name, *args)
|
88
|
+
target.respond_to?(name, *args)
|
89
|
+
end
|
90
|
+
|
91
|
+
def method_missing(name, *args, &block)
|
92
|
+
if target.respond_to?(name)
|
93
|
+
merge(target.send(name, *args, &block))
|
94
|
+
else
|
95
|
+
super
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
52
99
|
# @api private
|
53
100
|
#
|
54
101
|
# Simply dups the object for api compatability.
|
@@ -137,12 +137,15 @@ module SearchFlip
|
|
137
137
|
.parse
|
138
138
|
end
|
139
139
|
|
140
|
+
alias_method :cat_indices, :get_indices
|
141
|
+
|
140
142
|
# Creates the specified index within ElasticSearch and applies index
|
141
143
|
# settings, if specified. Raises SearchFlip::ResponseError in case any
|
142
144
|
# errors occur.
|
143
145
|
#
|
144
146
|
# @param index_name [String] The index name
|
145
147
|
# @param index_settings [Hash] The index settings
|
148
|
+
#
|
146
149
|
# @return [Boolean] Returns true or raises SearchFlip::ResponseError
|
147
150
|
|
148
151
|
def create_index(index_name, index_settings = {})
|
@@ -157,6 +160,7 @@ module SearchFlip
|
|
157
160
|
#
|
158
161
|
# @param index_name [String] The index name to update the settings for
|
159
162
|
# @param index_settings [Hash] The index settings
|
163
|
+
#
|
160
164
|
# @return [Boolean] Returns true or raises SearchFlip::ResponseError
|
161
165
|
|
162
166
|
def update_index_settings(index_name, index_settings)
|
@@ -170,6 +174,7 @@ module SearchFlip
|
|
170
174
|
# SearchFlip::ResponseError in case any errors occur.
|
171
175
|
#
|
172
176
|
# @param index_name [String] The index name
|
177
|
+
#
|
173
178
|
# @return [Hash] The index settings
|
174
179
|
|
175
180
|
def get_index_settings(index_name)
|
@@ -195,6 +200,7 @@ module SearchFlip
|
|
195
200
|
# @param index_name [String] The index name
|
196
201
|
# @param type_name [String] The type name
|
197
202
|
# @param mapping [Hash] The mapping
|
203
|
+
#
|
198
204
|
# @return [Boolean] Returns true or raises SearchFlip::ResponseError
|
199
205
|
|
200
206
|
def update_mapping(index_name, type_name, mapping)
|
@@ -208,6 +214,7 @@ module SearchFlip
|
|
208
214
|
#
|
209
215
|
# @param index_name [String] The index name
|
210
216
|
# @param type_name [String] The type name
|
217
|
+
#
|
211
218
|
# @return [Hash] The current type mapping
|
212
219
|
|
213
220
|
def get_mapping(index_name, type_name)
|
@@ -218,6 +225,7 @@ module SearchFlip
|
|
218
225
|
# SearchFlip::ResponseError in case any errors occur.
|
219
226
|
#
|
220
227
|
# @param index_name [String] The index name
|
228
|
+
#
|
221
229
|
# @return [Boolean] Returns true or raises SearchFlip::ResponseError
|
222
230
|
|
223
231
|
def delete_index(index_name)
|
@@ -229,6 +237,7 @@ module SearchFlip
|
|
229
237
|
# Returns whether or not the specified index already exists.
|
230
238
|
#
|
231
239
|
# @param index_name [String] The index name
|
240
|
+
#
|
232
241
|
# @return [Boolean] Whether or not the index exists
|
233
242
|
|
234
243
|
def index_exists?(index_name)
|
@@ -246,6 +255,7 @@ module SearchFlip
|
|
246
255
|
#
|
247
256
|
# @param index_name [String] The index name
|
248
257
|
# @param type_name [String] The type name
|
258
|
+
#
|
249
259
|
# @return [String] The ElasticSearch type URL
|
250
260
|
|
251
261
|
def type_url(index_name, type_name)
|
@@ -256,6 +266,7 @@ module SearchFlip
|
|
256
266
|
# URL and index name with prefix.
|
257
267
|
#
|
258
268
|
# @param index_name [String] The index name
|
269
|
+
#
|
259
270
|
# @return [String] The ElasticSearch index URL
|
260
271
|
|
261
272
|
def index_url(index_name)
|
data/lib/search_flip/criteria.rb
CHANGED
@@ -45,8 +45,8 @@ module SearchFlip
|
|
45
45
|
criteria.offset_value = other.offset_value if other.offset_value
|
46
46
|
criteria.limit_value = other.limit_value if other.limit_value
|
47
47
|
criteria.scroll_args = other.scroll_args if other.scroll_args
|
48
|
+
criteria.source_value = other.source_value if other.source_value
|
48
49
|
|
49
|
-
criteria.source_value = (criteria.source_value || []) + other.source_value if other.source_value
|
50
50
|
criteria.sort_values = (criteria.sort_values || []) + other.sort_values if other.sort_values
|
51
51
|
criteria.includes_values = (criteria.includes_values || []) + other.includes_values if other.includes_values
|
52
52
|
criteria.preload_values = (criteria.preload_values || []) + other.preload_values if other.preload_values
|
@@ -122,7 +122,7 @@ module SearchFlip
|
|
122
122
|
|
123
123
|
fresh.tap do |criteria|
|
124
124
|
criteria.search_values = nil if scopes.include?(:search)
|
125
|
-
criteria.post_search_values = nil if scopes.include?(:
|
125
|
+
criteria.post_search_values = nil if scopes.include?(:post_search)
|
126
126
|
criteria.sort_values = nil if scopes.include?(:sort)
|
127
127
|
criteria.hightlight_values = nil if scopes.include?(:highlight)
|
128
128
|
criteria.suggest_values = nil if scopes.include?(:suggest)
|
@@ -375,8 +375,10 @@ module SearchFlip
|
|
375
375
|
#
|
376
376
|
# @example
|
377
377
|
# CommentIndex.source([:id, :message]).search("hello world")
|
378
|
+
# CommentIndex.source(exclude: "description")
|
379
|
+
# CommentIndex.source(false)
|
378
380
|
#
|
379
|
-
# @param value
|
381
|
+
# @param value Pass any allowed value to restrict the returned source
|
380
382
|
#
|
381
383
|
# @return [SearchFlip::Criteria] A newly created extended criteria
|
382
384
|
|
data/lib/search_flip/index.rb
CHANGED
@@ -87,6 +87,7 @@ module SearchFlip
|
|
87
87
|
#
|
88
88
|
# @param index_name [String] A custom index_name
|
89
89
|
# @param connection [SearchFlip::Connection] A custom connection
|
90
|
+
#
|
90
91
|
# @return [Class] An anonymous class
|
91
92
|
|
92
93
|
def with_settings(index_name: nil, connection: nil)
|
@@ -113,6 +114,7 @@ module SearchFlip
|
|
113
114
|
# end
|
114
115
|
#
|
115
116
|
# @param record The record that gets serialized
|
117
|
+
#
|
116
118
|
# @return [Hash] The hash-representation of the record
|
117
119
|
|
118
120
|
def serialize(record)
|
@@ -178,6 +180,7 @@ module SearchFlip
|
|
178
180
|
# end
|
179
181
|
#
|
180
182
|
# @param record The record to get the primary key for
|
183
|
+
#
|
181
184
|
# @return [String, Fixnum] The record's primary key
|
182
185
|
|
183
186
|
def record_id(record)
|
@@ -189,6 +192,7 @@ module SearchFlip
|
|
189
192
|
# keys and/or ORMs.
|
190
193
|
#
|
191
194
|
# @param ids [Array] The array of ids to fetch the records for
|
195
|
+
#
|
192
196
|
# @return The record set or an array of records
|
193
197
|
|
194
198
|
def fetch_records(ids)
|
@@ -267,7 +271,7 @@ module SearchFlip
|
|
267
271
|
# @return [String] The base name of the index, ie without prefix
|
268
272
|
|
269
273
|
def index_name
|
270
|
-
|
274
|
+
raise SearchFlip::MethodNotImplemented, "You must implement #{name}::index_name"
|
271
275
|
end
|
272
276
|
|
273
277
|
# @api private
|
@@ -324,6 +328,7 @@ module SearchFlip
|
|
324
328
|
# occur.
|
325
329
|
#
|
326
330
|
# @param include_mapping [Boolean] Whether or not to include the mapping
|
331
|
+
#
|
327
332
|
# @return [Boolean] Returns true or false
|
328
333
|
|
329
334
|
def create_index(include_mapping: false)
|
@@ -393,6 +398,7 @@ module SearchFlip
|
|
393
398
|
#
|
394
399
|
# @param id [String, Fixnum] The id to get
|
395
400
|
# @param params [Hash] Optional params for the request
|
401
|
+
#
|
396
402
|
# @return [Hash] The specified document
|
397
403
|
|
398
404
|
def get(id, params = {})
|
data/lib/search_flip/version.rb
CHANGED
@@ -262,4 +262,122 @@ RSpec.describe SearchFlip::Aggregation do
|
|
262
262
|
expect(query.aggregations(:category)["category2"].title.buckets.detect { |bucket| bucket[:key] == "title2" }.price.value).to eq(60)
|
263
263
|
end
|
264
264
|
end
|
265
|
+
|
266
|
+
describe "#merge" do
|
267
|
+
it "merges a criteria into the aggregation" do
|
268
|
+
product1 = create(:product, price: 100, category: "category1")
|
269
|
+
product2 = create(:product, price: 150, category: "category1")
|
270
|
+
product3 = create(:product, price: 200, category: "category2")
|
271
|
+
product4 = create(:product, price: 300, category: "category1")
|
272
|
+
|
273
|
+
ProductIndex.import [product1, product2, product3, product4]
|
274
|
+
|
275
|
+
query = ProductIndex.aggregate(categories: {}) do |agg|
|
276
|
+
agg.merge(ProductIndex.where(price: 100..200)).aggregate(:category)
|
277
|
+
end
|
278
|
+
|
279
|
+
result = query.aggregations(:categories).category.buckets.each_with_object({}) do |bucket, hash|
|
280
|
+
hash[bucket["key"]] = bucket.doc_count
|
281
|
+
end
|
282
|
+
|
283
|
+
expect(result).to eq("category1" => 2, "category2" => 1)
|
284
|
+
end
|
285
|
+
|
286
|
+
describe "unsupported methods" do
|
287
|
+
unsupported_methods = [
|
288
|
+
:profile_value, :failsafe_value, :terminate_after_value, :timeout_value, :offset_value, :limit_value,
|
289
|
+
:scroll_args, :highlight_values, :suggest_values, :custom_value, :source_value, :sort_values,
|
290
|
+
:includes_values, :preload_values, :eager_load_values, :post_search_values, :post_must_values,
|
291
|
+
:post_must_not_values, :post_should_values, :post_filter_values
|
292
|
+
]
|
293
|
+
|
294
|
+
unsupported_methods.each do |unsupported_method|
|
295
|
+
it "raises a NotSupportedError #{unsupported_method}" do
|
296
|
+
block = lambda do
|
297
|
+
TestIndex.aggregate(field: {}) do |agg|
|
298
|
+
criteria = SearchFlip::Criteria.new(target: TestIndex)
|
299
|
+
criteria.send("#{unsupported_method}=", "value")
|
300
|
+
|
301
|
+
agg.merge(criteria)
|
302
|
+
end
|
303
|
+
end
|
304
|
+
|
305
|
+
expect(&block).to raise_error(SearchFlip::NotSupportedError)
|
306
|
+
end
|
307
|
+
end
|
308
|
+
end
|
309
|
+
|
310
|
+
describe "array concatenations" do
|
311
|
+
methods = [:search_values, :must_values, :must_not_values, :should_values, :filter_values]
|
312
|
+
|
313
|
+
methods.each do |method|
|
314
|
+
it "concatenates the values for #{method}" do
|
315
|
+
aggregation = SearchFlip::Aggregation.new(target: TestIndex)
|
316
|
+
aggregation.send("#{method}=", ["value1"])
|
317
|
+
|
318
|
+
criteria = SearchFlip::Criteria.new(target: TestIndex)
|
319
|
+
criteria.send("#{method}=", ["value2"])
|
320
|
+
|
321
|
+
result = aggregation.merge(criteria)
|
322
|
+
|
323
|
+
expect(result.send(method)).to eq(["value1", "value2"])
|
324
|
+
end
|
325
|
+
end
|
326
|
+
end
|
327
|
+
|
328
|
+
describe "hash merges" do
|
329
|
+
methods = [:aggregation_values]
|
330
|
+
|
331
|
+
methods.each do |method|
|
332
|
+
it "merges the values for #{method}" do
|
333
|
+
aggregation = SearchFlip::Aggregation.new(target: TestIndex)
|
334
|
+
aggregation.send("#{method}=", key1: "value1")
|
335
|
+
|
336
|
+
criteria = SearchFlip::Criteria.new(target: TestIndex)
|
337
|
+
criteria.send("#{method}=", key2: "value2")
|
338
|
+
|
339
|
+
result = aggregation.merge(criteria)
|
340
|
+
|
341
|
+
expect(result.send(method)).to eq(key1: "value1", key2: "value2")
|
342
|
+
end
|
343
|
+
end
|
344
|
+
end
|
345
|
+
end
|
346
|
+
|
347
|
+
describe "#respond_to?" do
|
348
|
+
it "checks whether or not the index class responds to the method" do
|
349
|
+
temp_index = Class.new(ProductIndex)
|
350
|
+
aggregation = SearchFlip::Aggregation.new(target: temp_index)
|
351
|
+
|
352
|
+
expect(aggregation.respond_to?(:test_scope)).to eq(false)
|
353
|
+
|
354
|
+
temp_index.scope(:test_scope) { match_all }
|
355
|
+
|
356
|
+
expect(aggregation.respond_to?(:test_scope)).to eq(true)
|
357
|
+
end
|
358
|
+
end
|
359
|
+
|
360
|
+
describe "#method_missing" do
|
361
|
+
it "delegates to the index class" do
|
362
|
+
temp_index = Class.new(ProductIndex)
|
363
|
+
temp_index.scope(:with_price_range) { |range| where(price: range) }
|
364
|
+
|
365
|
+
product1 = create(:product, price: 100, category: "category1")
|
366
|
+
product2 = create(:product, price: 150, category: "category1")
|
367
|
+
product3 = create(:product, price: 200, category: "category2")
|
368
|
+
product4 = create(:product, price: 300, category: "category1")
|
369
|
+
|
370
|
+
temp_index.import [product1, product2, product3, product4]
|
371
|
+
|
372
|
+
query = temp_index.aggregate(categories: {}) do |agg|
|
373
|
+
agg.merge(temp_index.with_price_range(100..200)).aggregate(:category)
|
374
|
+
end
|
375
|
+
|
376
|
+
result = query.aggregations(:categories).category.buckets.each_with_object({}) do |bucket, hash|
|
377
|
+
hash[bucket["key"]] = bucket.doc_count
|
378
|
+
end
|
379
|
+
|
380
|
+
expect(result).to eq("category1" => 2, "category2" => 1)
|
381
|
+
end
|
382
|
+
end
|
265
383
|
end
|
@@ -35,7 +35,7 @@ RSpec.describe SearchFlip::Criteria do
|
|
35
35
|
describe "assignments" do
|
36
36
|
methods = [
|
37
37
|
:profile_value, :failsafe_value, :terminate_after_value, :timeout_value,
|
38
|
-
:offset_value, :limit_value, :scroll_args
|
38
|
+
:offset_value, :limit_value, :scroll_args, :source_value
|
39
39
|
]
|
40
40
|
|
41
41
|
methods.each do |method|
|
@@ -53,10 +53,9 @@ RSpec.describe SearchFlip::Criteria do
|
|
53
53
|
|
54
54
|
describe "array concatenations" do
|
55
55
|
methods = [
|
56
|
-
:
|
57
|
-
:
|
58
|
-
:
|
59
|
-
:post_filter_values
|
56
|
+
:sort_values, :includes_values, :preload_values, :eager_load_values, :search_values,
|
57
|
+
:must_values, :must_not_values, :should_values, :filter_values, :post_search_values,
|
58
|
+
:post_must_values, :post_must_not_values, :post_should_values, :post_filter_values
|
60
59
|
]
|
61
60
|
|
62
61
|
methods.each do |method|
|
@@ -42,6 +42,16 @@ RSpec.describe SearchFlip::Index do
|
|
42
42
|
end
|
43
43
|
end
|
44
44
|
|
45
|
+
describe ".type_name" do
|
46
|
+
it "raises a SearchFlip::MethodNotImplemented by default" do
|
47
|
+
klass = Class.new do
|
48
|
+
include SearchFlip::Index
|
49
|
+
end
|
50
|
+
|
51
|
+
expect { klass.index_name }.to raise_error(SearchFlip::MethodNotImplemented)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
45
55
|
describe ".create_index" do
|
46
56
|
it "delegates to connection" do
|
47
57
|
allow(TestIndex.connection).to receive(:create_index).and_call_original
|
data/spec/spec_helper.rb
CHANGED
@@ -88,6 +88,10 @@ class CommentIndex
|
|
88
88
|
"comments"
|
89
89
|
end
|
90
90
|
|
91
|
+
def self.index_name
|
92
|
+
"comments"
|
93
|
+
end
|
94
|
+
|
91
95
|
def self.model
|
92
96
|
Comment
|
93
97
|
end
|
@@ -136,6 +140,10 @@ class ProductIndex
|
|
136
140
|
"products"
|
137
141
|
end
|
138
142
|
|
143
|
+
def self.index_name
|
144
|
+
"products"
|
145
|
+
end
|
146
|
+
|
139
147
|
def self.model
|
140
148
|
Product
|
141
149
|
end
|
@@ -174,6 +182,10 @@ class TestIndex
|
|
174
182
|
def self.type_name
|
175
183
|
"test"
|
176
184
|
end
|
185
|
+
|
186
|
+
def self.index_name
|
187
|
+
"test"
|
188
|
+
end
|
177
189
|
end
|
178
190
|
|
179
191
|
TestIndex.delete_index if TestIndex.index_exists?
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: search_flip
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 2.0.0.
|
4
|
+
version: 2.0.0.beta4
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Benjamin Vetter
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2019-03-
|
11
|
+
date: 2019-03-15 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: activerecord
|