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.
- checksums.yaml +7 -0
- data/.gitignore +21 -0
- data/.travis.yml +9 -0
- data/.yardopts +5 -0
- data/Gemfile +16 -0
- data/LICENSE.md +22 -0
- data/Procfile +1 -0
- data/README.md +112 -0
- data/Rakefile +17 -0
- data/benchmarks/coercer.rb +76 -0
- data/examples/Gemfile +2 -0
- data/examples/purchase.rb +164 -0
- data/lib/lotus/dynamodb/config.rb +14 -0
- data/lib/lotus/dynamodb/version.rb +8 -0
- data/lib/lotus/model/adapters/dynamodb/coercer.rb +211 -0
- data/lib/lotus/model/adapters/dynamodb/collection.rb +321 -0
- data/lib/lotus/model/adapters/dynamodb/command.rb +117 -0
- data/lib/lotus/model/adapters/dynamodb/query.rb +559 -0
- data/lib/lotus/model/adapters/dynamodb_adapter.rb +190 -0
- data/lib/lotus-dynamodb.rb +3 -0
- data/lotus-dynamodb.gemspec +30 -0
- data/test/fixtures.rb +75 -0
- data/test/model/adapters/dynamodb/coercer_test.rb +269 -0
- data/test/model/adapters/dynamodb/query_test.rb +259 -0
- data/test/model/adapters/dynamodb_adapter_test.rb +940 -0
- data/test/test_helper.rb +46 -0
- data/test/version_test.rb +7 -0
- metadata +203 -0
@@ -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
|