lotus-dynamodb 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,559 @@
1
+ require 'forwardable'
2
+ require 'lotus/utils/kernel'
3
+
4
+ # Lotus namespace
5
+ #
6
+ # @since 0.1.0
7
+ module Lotus
8
+ # Lotus::Model namespace
9
+ #
10
+ # @since 0.1.0
11
+ module Model
12
+ # Lotus::Adapters namespace
13
+ #
14
+ # @since 0.1.0
15
+ module Adapters
16
+ # Lotus::Adapters::Dynamodb namespace
17
+ #
18
+ # @since 0.1.0
19
+ module Dynamodb
20
+ # Query DynamoDB table with a powerful API.
21
+ #
22
+ # All the methods are chainable, it allows advanced composition of
23
+ # options.
24
+ #
25
+ # This works as a lazy filtering mechanism: the records are fetched from
26
+ # the DynamoDB only when needed.
27
+ #
28
+ # @example
29
+ #
30
+ # query.where(language: 'ruby')
31
+ # .and(framework: 'lotus')
32
+ # .all
33
+ #
34
+ # # the records are fetched only when we invoke #all
35
+ #
36
+ # It implements Ruby's `Enumerable` and borrows some methods from `Array`.
37
+ # Expect a query to act like them.
38
+ #
39
+ # @since 0.1.0
40
+ class Query
41
+ include Enumerable
42
+ extend Forwardable
43
+
44
+ def_delegators :all, :each, :to_s, :empty?
45
+
46
+ # @attr_reader operation [Symbol] operation to perform
47
+ #
48
+ # @since 0.1.0
49
+ # @api private
50
+ attr_reader :operation
51
+
52
+ # @attr_reader options [Hash] an accumulator for the query options
53
+ #
54
+ # @since 0.1.0
55
+ # @api private
56
+ attr_reader :options
57
+
58
+ # Initialize a query
59
+ #
60
+ # @param dataset [Lotus::Model::Adapters::Dynamodb::Collection]
61
+ # @param collection [Lotus::Model::Mapping::Collection]
62
+ # @param blk [Proc] an optional block that gets yielded in the
63
+ # context of the current query
64
+ #
65
+ # @since 0.1.0
66
+ # @api private
67
+ def initialize(dataset, collection, &blk)
68
+ @dataset = dataset
69
+ @collection = collection
70
+
71
+ @operation = :scan
72
+ @options = {}
73
+
74
+ instance_eval(&blk) if block_given?
75
+ end
76
+
77
+ # Resolves the query by fetching records from the database and
78
+ # translating them into entities.
79
+ #
80
+ # @return [Array] a collection of entities
81
+ #
82
+ # @since 0.1.0
83
+ def all
84
+ @collection.deserialize(run.entities)
85
+ end
86
+
87
+ # Set operation to be query instead of scan.
88
+ #
89
+ # @return self
90
+ #
91
+ # @since 0.1.0
92
+ def query
93
+ @operation = :query
94
+ self
95
+ end
96
+
97
+ # Adds a condition that behaves like SQL `WHERE`.
98
+ #
99
+ # It accepts a `Hash` with only one pair.
100
+ # The key must be the name of the column expressed as a `Symbol`.
101
+ # The value is the one used by the internal filtering logic.
102
+ #
103
+ # @param condition [Hash]
104
+ #
105
+ # @return self
106
+ #
107
+ # @since 0.1.0
108
+ #
109
+ # @example Fixed value
110
+ #
111
+ # query.where(language: 'ruby')
112
+ #
113
+ # @example Array
114
+ #
115
+ # query.where(id: [1, 3])
116
+ #
117
+ # @example Range
118
+ #
119
+ # query.where(year: 1900..1982)
120
+ #
121
+ # @example Multiple conditions
122
+ #
123
+ # query.where(language: 'ruby')
124
+ # .where(framework: 'lotus')
125
+ def where(condition)
126
+ key = key_for_condition(condition)
127
+ serialized = serialize_condition(condition)
128
+
129
+ if serialized
130
+ @options[key] ||= {}
131
+ @options[key].merge!(serialized)
132
+ end
133
+
134
+ self
135
+ end
136
+
137
+ alias_method :eq, :where
138
+ alias_method :in, :where
139
+ alias_method :between, :where
140
+
141
+ # Sets DynamoDB ConditionalOperator to `OR`. This works query-wide
142
+ # so this method has no arguments.
143
+ #
144
+ # @return self
145
+ #
146
+ # @since 0.1.0
147
+ def or
148
+ @options[:conditional_operator] = "OR"
149
+ self
150
+ end
151
+
152
+ # Logical negation of a #where condition.
153
+ #
154
+ # It accepts a `Hash` with only one pair.
155
+ # The key must be the name of the column expressed as a `Symbol`.
156
+ # The value is the one used by the internal filtering logic.
157
+ #
158
+ # @param condition [Hash]
159
+ #
160
+ # @since 0.1.0
161
+ #
162
+ # @return self
163
+ #
164
+ # @example Fixed value
165
+ #
166
+ # query.exclude(language: 'java')
167
+ #
168
+ # @example Multiple conditions
169
+ #
170
+ # query.exclude(language: 'java')
171
+ # .exclude(company: 'enterprise')
172
+ def exclude(condition)
173
+ key = key_for_condition(condition)
174
+ serialized = serialize_condition(condition, negate: true)
175
+
176
+ if serialized
177
+ @options[key] ||= {}
178
+ @options[key].merge!(serialized)
179
+ end
180
+
181
+ self
182
+ end
183
+
184
+ alias_method :not, :exclude
185
+ alias_method :ne, :exclude
186
+
187
+ # Perform LE comparison.
188
+ #
189
+ # @return self
190
+ #
191
+ # @since 0.1.0
192
+ def le(condition); comparison(condition, 'LE'); end
193
+
194
+ # Perform LT comparison.
195
+ #
196
+ # @return self
197
+ #
198
+ # @since 0.1.0
199
+ def lt(condition); comparison(condition, 'LT'); end
200
+
201
+ # Perform GE comparison.
202
+ #
203
+ # @return self
204
+ #
205
+ # @since 0.1.0
206
+ def ge(condition); comparison(condition, 'GE'); end
207
+
208
+ # Perform GT comparison.
209
+ #
210
+ # @return self
211
+ #
212
+ # @since 0.1.0
213
+ def gt(condition); comparison(condition, 'GT'); end
214
+
215
+ # Perform CONTAINS comparison.
216
+ #
217
+ # @return self
218
+ #
219
+ # @since 0.1.0
220
+ def contains(condition); comparison(condition, 'CONTAINS'); end
221
+
222
+ # Perform NOT_CONTAINS comparison.
223
+ #
224
+ # @return self
225
+ #
226
+ # @since 0.1.0
227
+ def not_contains(condition); comparison(condition, 'NOT_CONTAINS'); end
228
+
229
+ # Perform BEGINS_WITH comparison.
230
+ #
231
+ # @return self
232
+ #
233
+ # @since 0.1.0
234
+ def begins_with(condition); comparison(condition, 'BEGINS_WITH'); end
235
+
236
+ # Perform NULL comparison.
237
+ #
238
+ # @return self
239
+ #
240
+ # @since 0.1.0
241
+ def null(column); comparison({ column => '' }, 'NULL'); end
242
+
243
+ # Perform NOT_NULL comparison.
244
+ #
245
+ # @return self
246
+ #
247
+ # @since 0.1.0
248
+ def not_null(column); comparison({ column => '' }, 'NOT_NULL'); end
249
+
250
+ # Perform comparison operation.
251
+ #
252
+ # @return self
253
+ #
254
+ # @api private
255
+ # @since 0.1.0
256
+ def comparison(condition, operator)
257
+ key = key_for_condition(condition)
258
+ serialized = serialize_condition(condition, operator: operator)
259
+
260
+ if serialized
261
+ @options[key] ||= {}
262
+ @options[key].merge!(serialized)
263
+ end
264
+
265
+ self
266
+ end
267
+
268
+ # Select only the specified columns.
269
+ #
270
+ # By default a query selects all the mapped columns.
271
+ #
272
+ # @param columns [Array<Symbol>]
273
+ #
274
+ # @return self
275
+ #
276
+ # @since 0.1.0
277
+ #
278
+ # @example Single column
279
+ #
280
+ # query.select(:name)
281
+ #
282
+ # @example Multiple columns
283
+ #
284
+ # query.select(:name, :year)
285
+ def select(*columns)
286
+ @options[:select] = "SPECIFIC_ATTRIBUTES"
287
+ @options[:attributes_to_get] = columns.map(&:to_s)
288
+ self
289
+ end
290
+
291
+ # Specify the ascending order of the records, sorted by the range key.
292
+ #
293
+ # @return self
294
+ #
295
+ # @since 0.1.0
296
+ #
297
+ # @see Lotus::Model::Adapters::Dynamodb::Query#desc
298
+ def order(*columns)
299
+ warn "DynamoDB only supports order by range_key" if columns.any?
300
+
301
+ query
302
+ @options[:scan_index_forward] = true
303
+ self
304
+ end
305
+
306
+ alias_method :asc, :order
307
+
308
+ # Specify the descending order of the records, sorted by the range key.
309
+ #
310
+ # @return self
311
+ #
312
+ # @since 0.1.0
313
+ #
314
+ # @see Lotus::Model::Adapters::Dynamodb::Query#asc
315
+ def desc(*columns)
316
+ warn "DynamoDB only supports order by range_key" if columns.any?
317
+
318
+ query
319
+ @options[:scan_index_forward] = false
320
+ self
321
+ end
322
+
323
+ # Limit the number of records to return.
324
+ #
325
+ # @param number [Fixnum]
326
+ #
327
+ # @return self
328
+ #
329
+ # @since 0.1.0
330
+ #
331
+ # @example
332
+ #
333
+ # query.limit(1)
334
+ def limit(number)
335
+ @options[:limit] = number
336
+ self
337
+ end
338
+
339
+ # This method is not implemented. DynamoDB does not provide offset.
340
+ #
341
+ # @param number [Fixnum]
342
+ #
343
+ # @raise [NotImplementedError]
344
+ #
345
+ # @since 0.1.0
346
+ def offset(number)
347
+ raise NotImplementedError
348
+ end
349
+
350
+ # This method is not implemented. DynamoDB does not provide sum.
351
+ #
352
+ # @param column [Symbol] the column name
353
+ #
354
+ # @raise [NotImplementedError]
355
+ #
356
+ # @since 0.1.0
357
+ def sum(column)
358
+ raise NotImplementedError
359
+ end
360
+
361
+ # This method is not implemented. DynamoDB does not provide average.
362
+ #
363
+ # @param column [Symbol] the column name
364
+ #
365
+ # @raise [NotImplementedError]
366
+ #
367
+ # @since 0.1.0
368
+ def average(column)
369
+ raise NotImplementedError
370
+ end
371
+
372
+ alias_method :avg, :average
373
+
374
+ # This method is not implemented. DynamoDB does not provide max.
375
+ #
376
+ # @param column [Symbol] the column name
377
+ #
378
+ # @raise [NotImplementedError]
379
+ #
380
+ # @since 0.1.0
381
+ def max(column)
382
+ raise NotImplementedError
383
+ end
384
+
385
+ # This method is not implemented. DynamoDB does not provide min.
386
+ #
387
+ # @param column [Symbol] the column name
388
+ #
389
+ # @raise [NotImplementedError]
390
+ #
391
+ # @since 0.1.0
392
+ def min(column)
393
+ raise NotImplementedError
394
+ end
395
+
396
+ # This method is not implemented. DynamoDB does not provide interval.
397
+ #
398
+ # @param column [Symbol] the column name
399
+ #
400
+ # @raise [NotImplementedError]
401
+ #
402
+ # @since 0.1.0
403
+ def interval(column)
404
+ raise NotImplementedError
405
+ end
406
+
407
+ # This method is not implemented. DynamoDB does not provide range.
408
+ #
409
+ # @param column [Symbol] the column name
410
+ #
411
+ # @raise [NotImplementedError]
412
+ #
413
+ # @since 0.1.0
414
+ def range(column)
415
+ raise NotImplementedError
416
+ end
417
+
418
+ # Checks if at least one record exists for the current conditions.
419
+ #
420
+ # @return [TrueClass,FalseClass]
421
+ #
422
+ # @since 0.1.0
423
+ #
424
+ # @example
425
+ #
426
+ # query.where(author_id: 23).exists? # => true
427
+ def exists?
428
+ !count.zero?
429
+ end
430
+
431
+ alias_method :exist?, :exists?
432
+
433
+ # Returns a count of the records for the current conditions.
434
+ #
435
+ # @return [Fixnum]
436
+ #
437
+ # @since 0.1.0
438
+ #
439
+ # @example
440
+ #
441
+ # query.where(author_id: 23).count # => 5
442
+ def count
443
+ @options[:select] = "COUNT"
444
+ @options.delete(:attributes_to_get)
445
+
446
+ run.count
447
+ end
448
+
449
+ # This method is not implemented.
450
+ #
451
+ # @raise [NotImplementedError]
452
+ #
453
+ # @since 0.1.0
454
+ def negate!
455
+ raise NotImplementedError
456
+ end
457
+
458
+ # Tells DynamoDB to use consistent reads.
459
+ #
460
+ # @return self
461
+ #
462
+ # @since 0.1.0
463
+ def consistent
464
+ query
465
+ @options[:consistent_read] = true
466
+ self
467
+ end
468
+
469
+ # Tells DynamoDB which index to use.
470
+ #
471
+ # @return self
472
+ #
473
+ # @since 0.1.0
474
+ def index(name)
475
+ query
476
+ @options[:index_name] = name.to_s
477
+ self
478
+ end
479
+
480
+ private
481
+ # Return proper options key for a given condition.
482
+ #
483
+ # @param condition [Hash] the condition
484
+ #
485
+ # @return [Symbol] the key
486
+ #
487
+ # @api private
488
+ # @since 0.1.0
489
+ def key_for_condition(condition)
490
+ if @dataset.key?(condition.keys.first, @options[:index_name])
491
+ query
492
+ :key_conditions
493
+ elsif operation == :scan
494
+ :scan_filter
495
+ else
496
+ :query_filter
497
+ end
498
+ end
499
+
500
+ # Serialize given condition for DynamoDB API.
501
+ #
502
+ # @param condition [Hash] the condition
503
+ # @param negate [Boolean] true when negative condition
504
+ #
505
+ # @return [Hash] the serialized condition
506
+ #
507
+ # @api private
508
+ # @since 0.1.0
509
+ def serialize_condition(condition, operator: nil, negate: false)
510
+ column, value = condition.keys.first, condition.values.first
511
+
512
+ operator ||= case
513
+ when value.is_a?(Array)
514
+ negate ? nil : "IN"
515
+ when value.is_a?(Range)
516
+ negate ? nil : "BETWEEN"
517
+ else
518
+ negate ? "NE" : "EQ"
519
+ end
520
+
521
+ # Operator is not supported
522
+ raise NotImplementedError unless operator
523
+
524
+ values ||= case
525
+ when value.is_a?(Range)
526
+ [value.first, value.last]
527
+ else
528
+ [value].flatten
529
+ end
530
+
531
+ serialized = {
532
+ column.to_s => {
533
+ comparison_operator: operator,
534
+ },
535
+ }
536
+
537
+ if !["NULL", "NOT_NULL"].include?(operator)
538
+ serialized[column.to_s][:attribute_value_list] = values.map do |v|
539
+ @dataset.format_attribute(column, v)
540
+ end
541
+ end
542
+
543
+ serialized
544
+ end
545
+
546
+ # Apply all the options and return a filtered collection.
547
+ #
548
+ # @return [Array]
549
+ #
550
+ # @api private
551
+ # @since 0.1.0
552
+ def run
553
+ @dataset.public_send(operation, @options)
554
+ end
555
+ end
556
+ end
557
+ end
558
+ end
559
+ end