lotus-dynamodb 0.1.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,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