search_flip 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.
@@ -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
+