search_flip 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,545 @@
1
+
2
+ module SearchFlip
3
+ # The SearchFlip::Index mixin makes your class correspond to an
4
+ # ElasticSearch index. Your class can then create or delete the index, modify
5
+ # the mapping, import records, delete records and query the index. This gem
6
+ # uses an individual ElasticSearch index for each index class, because
7
+ # ElasticSearch requires to have the same mapping for the same field name,
8
+ # even if the field is living in different types of the same index.
9
+ #
10
+ # @example Simple index class
11
+ # class CommentIndex
12
+ # include SearchFlip::Index
13
+ #
14
+ # def self.model
15
+ # Comment
16
+ # end
17
+ #
18
+ # def self.type_name
19
+ # "comments"
20
+ # end
21
+ #
22
+ # def self.serialize(comment)
23
+ # {
24
+ # id: comment.id,
25
+ # user_id: comment.user_id,
26
+ # message: comment.message,
27
+ # created_at: comment.created_at
28
+ # }
29
+ # end
30
+ # end
31
+ #
32
+ # @example Create/delete the index
33
+ # CommentIndex.create_index
34
+ # CommentIndex.delete_index if CommentIndex.index_exists?
35
+ #
36
+ # @example Import records
37
+ # CommentIndex.import(Comment.all)
38
+ #
39
+ # @example Query the index
40
+ # CommentIndex.search("hello world")
41
+ # CommentIndex.where(user_id: 1)
42
+ # CommentIndex.range(:created_at, gt: Time.now - 7.days)
43
+
44
+ module Index
45
+ def self.included(base)
46
+ base.extend ClassMethods
47
+ end
48
+
49
+ module ClassMethods
50
+ extend Forwardable
51
+
52
+ # Override this method to automatically pass index options for a record
53
+ # at index-time, like routing or versioning.
54
+ #
55
+ # @example
56
+ # def self.index_options(comment)
57
+ # {
58
+ # routing: comment.user_id,
59
+ # version: comment.version,
60
+ # version_type: "external_gte"
61
+ # }
62
+ # end
63
+ #
64
+ # @param record The record that gets indexed
65
+ # @return [Hash] The index options
66
+
67
+ def index_options(record)
68
+ {}
69
+ end
70
+
71
+ # @abstract
72
+ #
73
+ # Override this method to generate a hash representation of a record,
74
+ # used to generate the JSON representation of it.
75
+ #
76
+ # @example
77
+ # def self.serialize(comment)
78
+ # {
79
+ # id: comment.id,
80
+ # user_id: comment.user_id,
81
+ # message: comment.message,
82
+ # created_at: comment.created_at,
83
+ # updated_at: comment.updated_at
84
+ # }
85
+ # end
86
+ #
87
+ # @param record The record that gets serialized
88
+ # @return [Hash] The hash-representation of the record
89
+
90
+ def serialize(record)
91
+ raise NotImplementedError
92
+ end
93
+
94
+ # Adds a named scope to the index.
95
+ #
96
+ # @example
97
+ # scope(:active) { where(active: true) }
98
+ #
99
+ # UserIndex.active
100
+ #
101
+ # @example
102
+ # scope(:active) { |value| where(active: value) }
103
+ #
104
+ # UserIndex.active(true)
105
+ # UserIndex.active(false)
106
+ #
107
+ # @param name [Symbol] The name of the scope
108
+ # @param block The scope definition. Add filters, etc.
109
+
110
+ def scope(name, &block)
111
+ define_singleton_method(name, &block)
112
+ end
113
+
114
+ # @api private
115
+ #
116
+ # Used to iterate a record set. Here, a record set may be a) an
117
+ # ActiveRecord::Relation or anything responding to #find_each, b) an
118
+ # Array of records or anything responding to #each or c) a single record.
119
+ #
120
+ # @param scope The record set that gets iterated
121
+ # @param index_scope [Boolean] Set to true if you want the the index
122
+ # scope to be applied to the scope
123
+
124
+ def each_record(scope, index_scope: false)
125
+ return enum_for(:each_record, scope) unless block_given?
126
+
127
+ if scope.respond_to?(:find_each)
128
+ (index_scope ? self.index_scope(scope) : scope).find_each do |record|
129
+ yield record
130
+ end
131
+ else
132
+ (scope.respond_to?(:each) ? scope : Array(scope)).each do |record|
133
+ yield record
134
+ end
135
+ end
136
+ end
137
+
138
+ # Returns the record's id, ie the unique identifier or primary key of a
139
+ # record. Override this method for custom primary keys, but return a
140
+ # String or Fixnum.
141
+ #
142
+ # @example Default implementation
143
+ # def self.record_id(record)
144
+ # record.id
145
+ # end
146
+ #
147
+ # @example Custom primary key
148
+ # def self.record_id(user)
149
+ # user.username
150
+ # end
151
+ #
152
+ # @param record The record to get the primary key for
153
+ # @return [String, Fixnum] The record's primary key
154
+
155
+ def record_id(record)
156
+ record.id
157
+ end
158
+
159
+ # Returns a record set, usually an ActiveRecord::Relation, for the
160
+ # specified ids, ie primary keys. Override this method for custom primary
161
+ # keys and/or ORMs.
162
+ #
163
+ # @param ids [Array] The array of ids to fetch the records for
164
+ # @return The record set or an array of records
165
+
166
+ def fetch_records(ids)
167
+ model.where(id: ids)
168
+ end
169
+
170
+ # Override this method to specify an index scope, which will
171
+ # automatically be applied to scopes, eg. ActiveRecord::Relation objects,
172
+ # passed to #import or #index. This can be used to preload associations
173
+ # that are used when serializing records or to restrict the records you
174
+ # want to index.
175
+ #
176
+ # @example Preloading an association
177
+ # class CommentIndex
178
+ # # ...
179
+ #
180
+ # def self.index_scope(scope)
181
+ # scope.preload(:user)
182
+ # end
183
+ # end
184
+ #
185
+ # CommentIndex.import(Comment.all) # => CommentIndex.import(Comment.preload(:user))
186
+ #
187
+ # @example Restricting records
188
+ # class CommentIndex
189
+ # # ...
190
+ #
191
+ # def self.index_scope(scope)
192
+ # scope.where(public: true)
193
+ # end
194
+ # end
195
+ #
196
+ # CommentIndex.import(Comment.all) # => CommentIndex.import(Comment.where(public: true))
197
+ #
198
+ # @param scope The supplied scope to extend
199
+ #
200
+ # @return The extended scope
201
+
202
+ def index_scope(scope)
203
+ scope
204
+ end
205
+
206
+ # @api private
207
+ #
208
+ # Creates an SearchFlip::Criteria for the current index, which is used
209
+ # as a base for chaining criteria methods.
210
+ #
211
+ # @return [SearchFlip::Criteria] The base for chaining criteria methods
212
+
213
+ def criteria
214
+ SearchFlip::Criteria.new(target: self)
215
+ end
216
+
217
+ def_delegators :criteria, :profile, :where, :where_not, :filter, :range, :match_all, :exists, :exists_not, :post_where, :post_where_not, :post_filter, :post_range,
218
+ :post_exists, :post_exists_not, :aggregate, :scroll, :source, :includes, :eager_load, :preload, :sort, :resort, :order, :reorder, :offset, :limit, :paginate,
219
+ :page, :per, :search, :highlight, :suggest, :custom, :find_in_batches, :find_each, :failsafe, :total_entries, :total_count, :timeout, :terminate_after, :records
220
+
221
+ # Override to specify the type name used within ElasticSearch. Recap,
222
+ # this gem uses an individual index for each index class, because
223
+ # ElasticSearch requires to have the same mapping for the same field
224
+ # name, even if the field is living in different types of the same index.
225
+ #
226
+ # @return [String] The name used for the type within the index
227
+
228
+ def type_name
229
+ raise NotImplementedError
230
+ end
231
+
232
+ # Returns the base name of the index within ElasticSearch, ie the index
233
+ # name without prefix. Equals #type_name by default.
234
+ #
235
+ # @return [String] The base name of the index, ie without prefix
236
+
237
+ def index_name
238
+ type_name
239
+ end
240
+
241
+ # @api private
242
+ #
243
+ # Returns the full name of the index within ElasticSearch, ie with prefix
244
+ # specified via SearchFlip::Config[:index_prefix].
245
+ #
246
+ # @return [String] The full index name
247
+
248
+ def index_name_with_prefix
249
+ "#{SearchFlip::Config[:index_prefix]}#{index_name}"
250
+ end
251
+
252
+ # Override to specify index settings like number of shards, analyzers,
253
+ # refresh interval, etc.
254
+ #
255
+ # @example
256
+ # def self.index_settings
257
+ # {
258
+ # settings: {
259
+ # number_of_shards: 10,
260
+ # number_of_replicas: 2
261
+ # }
262
+ # }
263
+ # end
264
+ #
265
+ # @return [Hash] The index settings
266
+
267
+ def index_settings
268
+ {}
269
+ end
270
+
271
+ # Returns whether or not the associated ElasticSearch index already
272
+ # exists.
273
+ #
274
+ # @return [Boolean] Whether or not the index exists
275
+
276
+ def index_exists?
277
+ SearchFlip::HTTPClient.headers(accept: "application/json").head(index_url)
278
+
279
+ true
280
+ rescue SearchFlip::ResponseError => e
281
+ return false if e.code == 404
282
+
283
+ raise e
284
+ end
285
+
286
+ # Fetches the index settings from ElasticSearch. Sends a GET request to
287
+ # index_url/_settings. Raises SearchFlip::ResponseError in case any
288
+ # errors occur.
289
+ #
290
+ # @return [Hash] The index settings
291
+
292
+ def get_index_settings
293
+ SearchFlip::HTTPClient.headers(accept: "application/json").get("#{index_url}/_settings").parse
294
+ end
295
+
296
+ # Creates the index within ElasticSearch and applies index settings, if
297
+ # specified. Raises SearchFlip::ResponseError in case any errors
298
+ # occur.
299
+
300
+ def create_index
301
+ SearchFlip::HTTPClient.put(index_url, json: index_settings)
302
+
303
+ true
304
+ end
305
+
306
+ # Updates the index settings within ElasticSearch according to the index
307
+ # settings specified. Raises SearchFlip::ResponseError in case any
308
+ # errors occur.
309
+
310
+ def update_index_settings
311
+ SearchFlip::HTTPClient.put("#{index_url}/_settings", json: index_settings)
312
+
313
+ true
314
+ end
315
+
316
+ # Deletes the index from ElasticSearch. Raises SearchFlip::ResponseError
317
+ # in case any errors occur.
318
+
319
+ def delete_index
320
+ SearchFlip::HTTPClient.delete(index_url)
321
+
322
+ true
323
+ end
324
+
325
+ # Specifies a type mapping. Override to specify a custom mapping.
326
+ #
327
+ # @example
328
+ # def self.mapping
329
+ # {
330
+ # comments: {
331
+ # _all: {
332
+ # enabled: false
333
+ # },
334
+ # properties: {
335
+ # email: { type: "string", analyzer: "custom_analyzer" }
336
+ # }
337
+ # }
338
+ # }
339
+ # end
340
+
341
+ def mapping
342
+ { type_name => {} }
343
+ end
344
+
345
+ # Updates the type mapping within ElasticSearch according to the mapping
346
+ # currently specified. Raises SearchFlip::ResponseError in case any
347
+ # errors occur.
348
+
349
+ def update_mapping
350
+ SearchFlip::HTTPClient.put("#{type_url}/_mapping", json: mapping)
351
+
352
+ true
353
+ end
354
+
355
+ # Retrieves the current type mapping from ElasticSearch. Raises
356
+ # SearchFlip::ResponseError in case any errors occur.
357
+ #
358
+ # @return [Hash] The current type mapping
359
+
360
+ def get_mapping
361
+ SearchFlip::HTTPClient.headers(accept: "application/json").get("#{type_url}/_mapping").parse
362
+ end
363
+
364
+ # Retrieves the document specified by id from ElasticSearch. Raises
365
+ # SearchFlip::ResponseError specific exceptions in case any errors
366
+ # occur.
367
+ #
368
+ # @return [Hash] The specified document
369
+
370
+ def get(id, params = {})
371
+ SearchFlip::HTTPClient.headers(accept: "application/json").get("#{type_url}/#{id}", params: params).parse
372
+ end
373
+
374
+ # Sends a index refresh request to ElasticSearch. Raises
375
+ # SearchFlip::ResponseError in case any errors occur.
376
+
377
+ def refresh
378
+ SearchFlip::HTTPClient.post("#{index_url}/_refresh", json: {})
379
+
380
+ true
381
+ end
382
+
383
+ # Indexes the given record set, array of records or individual record.
384
+ # Alias for #index.
385
+ #
386
+ # @see #index See #index for more details
387
+
388
+ def import(*args)
389
+ index(*args)
390
+ end
391
+
392
+ # Indexes the given record set, array of records or individual record. A
393
+ # record set usually is an ActiveRecord::Relation, but can be any other
394
+ # ORM as well. Uses the ElasticSearch bulk API no matter what is
395
+ # provided. Refreshes the index if auto_refresh is enabled. Raises
396
+ # SearchFlip::ResponseError in case any errors occur.
397
+ #
398
+ # @see #fetch_records See #fetch_records for other/custom ORMs
399
+ # @see #record_id See #record_id for other/custom ORMs
400
+ # @see SearchFlip::Config See SearchFlip::Config for auto_refresh
401
+ #
402
+ # @example
403
+ # CommentIndex.import Comment.all
404
+ # CommentIndex.import [comment1, comment2]
405
+ # CommentIndex.import Comment.first
406
+ # CommentIndex.import Comment.all, ignore_errors: [409]
407
+ # CommentIndex.import Comment.all, raise: false
408
+ #
409
+ # @param scope A record set, array of records or individual record to index
410
+ # @param options [Hash] Specifies options regarding the bulk indexing
411
+ # @option options ignore_errors [Array] Specifies an array of http status
412
+ # codes that shouldn't raise any exceptions, like eg 409 for conflicts,
413
+ # ie when optimistic concurrency control is used.
414
+ # @option options raise [Boolean] Prevents any exceptions from being
415
+ # raised. Please note that this only applies to the bulk response, not to
416
+ # the request in general, such that connection errors, etc will still
417
+ # raise.
418
+ # @param _index_options [Hash] Provides custom index options for eg
419
+ # routing, versioning, etc
420
+
421
+ def index(scope, options = {}, _index_options = {})
422
+ bulk options do |indexer|
423
+ each_record(scope, index_scope: true) do |object|
424
+ indexer.index record_id(object), serialize(object), index_options(object).merge(_index_options)
425
+ end
426
+ end
427
+
428
+ scope
429
+ end
430
+
431
+ # Indexes the given record set, array of records or individual record
432
+ # using ElasticSearch's create operation via the Bulk API, such that the
433
+ # request will fail if a record with a particular primary key already
434
+ # exists in ElasticSearch.
435
+ #
436
+ # @see #index See #index for more details regarding available
437
+ # params and return values
438
+
439
+ def create(scope, options = {}, _index_options = {})
440
+ bulk options do |indexer|
441
+ each_record(scope, index_scope: true) do |object|
442
+ indexer.create record_id(object), serialize(object), index_options(object).merge(_index_options)
443
+ end
444
+ end
445
+
446
+ scope
447
+ end
448
+
449
+ # Indexes the given record set, array of records or individual record
450
+ # using ElasticSearch's update operation via the Bulk API, such that the
451
+ # request will fail if a record you want to update does not already exist
452
+ # in ElasticSearch.
453
+ #
454
+ # @see #index See #index for more details regarding available
455
+ # params and return values
456
+
457
+ def update(scope, options = {}, _index_options = {})
458
+ bulk options do |indexer|
459
+ each_record(scope, index_scope: true) do |object|
460
+ indexer.update record_id(object), { doc: serialize(object) }, index_options(object).merge(_index_options)
461
+ end
462
+ end
463
+
464
+ scope
465
+ end
466
+
467
+ # Deletes the given record set, array of records or individual record
468
+ # from ElasticSearch using the Bulk API.
469
+ #
470
+ # @see #index See #index for more details regarding available
471
+ # params and return values
472
+
473
+ def delete(scope, options = {}, _index_options = {})
474
+ bulk options do |indexer|
475
+ each_record(scope) do |object|
476
+ indexer.delete record_id(object), index_options(object).merge(_index_options)
477
+ end
478
+ end
479
+
480
+ scope
481
+ end
482
+
483
+ # Initiates and yields the bulk object, such that index, import, create,
484
+ # update and delete requests can be appended to the bulk request. Sends a
485
+ # refresh request afterwards if auto_refresh is enabled.
486
+ #
487
+ # @see SearchFlip::Config See SearchFlip::Config for auto_refresh
488
+ #
489
+ # @example
490
+ # CommentIndex.bulk ignore_errors: [409] do |bulk|
491
+ # bulk.create comment.id, CommentIndex.serialize(comment),
492
+ # version: comment.version, version_type: "external_gte"
493
+ #
494
+ # bulk.delete comment.id, routing: comment.user_id
495
+ #
496
+ # # ...
497
+ # end
498
+ #
499
+ # @param options [Hash] Specifies options regarding the bulk indexing
500
+ # @option options ignore_errors [Array] Specifies an array of http status
501
+ # codes that shouldn't raise any exceptions, like eg 409 for conflicts,
502
+ # ie when optimistic concurrency control is used.
503
+ # @option options raise [Boolean] Prevents any exceptions from being
504
+ # raised. Please note that this only applies to the bulk response, not to
505
+ # the request in general, such that connection errors, etc will still
506
+ # raise.
507
+
508
+ def bulk(options = {})
509
+ SearchFlip::Bulk.new("#{type_url}/_bulk", SearchFlip::Config[:bulk_limit], options) do |indexer|
510
+ yield indexer
511
+ end
512
+
513
+ refresh if SearchFlip::Config[:auto_refresh]
514
+ end
515
+
516
+ # Returns the full ElasticSearch type URL, ie base URL, index name with
517
+ # prefix and type name.
518
+ #
519
+ # @return [String] The ElasticSearch type URL
520
+
521
+ def type_url(base_url: self.base_url)
522
+ "#{index_url(base_url: base_url)}/#{type_name}"
523
+ end
524
+
525
+ # Returns the ElasticSearch index URL, ie base URL and index name with
526
+ # prefix.
527
+ #
528
+ # @return [String] The ElasticSearch index URL
529
+
530
+ def index_url(base_url: self.base_url)
531
+ "#{base_url}/#{index_name_with_prefix}"
532
+ end
533
+
534
+ # Returns the ElasticSearch base URL, ie protcol and host with port.
535
+ # Override to specify an index specific ElasticSearch cluster.
536
+ #
537
+ # @return [String] The ElasticSearch base URL
538
+
539
+ def base_url
540
+ SearchFlip::Config[:base_url]
541
+ end
542
+ end
543
+ end
544
+ end
545
+
@@ -0,0 +1,18 @@
1
+
2
+ module SearchFlip
3
+ class JSON
4
+ @default_options = {
5
+ mode: :custom,
6
+ use_to_json: true
7
+ }
8
+
9
+ def self.default_options
10
+ @default_options
11
+ end
12
+
13
+ def self.generate(obj)
14
+ Oj.dump(obj, default_options)
15
+ end
16
+ end
17
+ end
18
+
@@ -0,0 +1,21 @@
1
+
2
+ module SearchFlip
3
+ module Model
4
+ def self.included(base)
5
+ base.extend(ClassMethods)
6
+ end
7
+
8
+ module ClassMethods
9
+ def notifies_index(index)
10
+ if respond_to?(:after_commit)
11
+ after_commit { |record| record.destroyed? ? index.delete(record) : index.import(record)}
12
+ else
13
+ after_save { |record| index.import(record) }
14
+ after_touch { |record| index.import(record) } if respond_to?(:after_touch)
15
+ after_destroy { |record| index.delete(record) }
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+