hanami-rethinkdb 0.2.3

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,66 @@
1
+ module Hanami
2
+ module Model
3
+ module Adapters
4
+ module Rethinkdb
5
+ # Execute a command for the given query.
6
+ #
7
+ # @see Hanami::Model::Adapters::Rethinkdb::Query
8
+ #
9
+ # @api private
10
+ # @since 0.1.0
11
+ class Command
12
+ # Initialize a command
13
+ #
14
+ # @param query [Hanami::Model::Adapters::Rethinkdb::Query]
15
+ #
16
+ # @api private
17
+ # @since 0.1.0
18
+ def initialize(query)
19
+ @collection = query.scoped
20
+ end
21
+
22
+ # Creates a document for the given entity.
23
+ #
24
+ # @param entity [Object] the entity to persist
25
+ #
26
+ # @see Hanami::Model::Adapters::Rethinkdb::Collection#insert
27
+ #
28
+ # @return the primary key of the just created document.
29
+ #
30
+ # @api private
31
+ # @since 0.1.0
32
+ def create(entity)
33
+ @collection.insert(entity)
34
+ end
35
+
36
+ # Updates the corresponding document for the given entity.
37
+ #
38
+ # @param entity [Object] the entity to persist
39
+ #
40
+ # @see Hanami::Model::Adapters::Rethinkdb::Collection#update
41
+ #
42
+ # @api private
43
+ # @since 0.1.0
44
+ def update(entity)
45
+ @collection.update(entity)
46
+ end
47
+
48
+ # Deletes all the documents for the current query.
49
+ #
50
+ # It's used to delete a single document or an entire database table.
51
+ #
52
+ # @see Hanami::Model::Adapters::RethinkdbAdapter#delete
53
+ # @see Hanami::Model::Adapters::RethinkdbAdapter#clear
54
+ #
55
+ # @api private
56
+ # @since 0.1.0
57
+ def delete
58
+ @collection.delete
59
+ end
60
+
61
+ alias_method :clear, :delete
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,486 @@
1
+ require 'forwardable'
2
+ require 'hanami/utils/kernel'
3
+ require 'rethinkdb'
4
+
5
+ module Hanami
6
+ module Model
7
+ module Adapters
8
+ module Rethinkdb
9
+ # Query the database with a powerful API.
10
+ #
11
+ # All the methods are chainable, it allows advanced composition of
12
+ # ReQL conditions.
13
+ #
14
+ # This works as a lazy filtering mechanism: the documents are fetched
15
+ # from the database only when needed.
16
+ #
17
+ # @example
18
+ #
19
+ # query.where(language: 'ruby')
20
+ # .and(framework: 'hanami')
21
+ # .desc(:users_count).all
22
+ #
23
+ # # the documents are fetched only when we invoke #all
24
+ #
25
+ # It implements Ruby's `Enumerable` and borrows some methods from
26
+ # `Array`. Expect a query to act like them.
27
+ #
28
+ # @since 0.1.0
29
+ class Query
30
+ include RethinkDB::Shortcuts
31
+ include Enumerable
32
+ extend Forwardable
33
+
34
+ def_delegators :all, :each, :to_s, :empty?
35
+
36
+ # @attr_reader conditions [Array] an accumulator for the called
37
+ # methods
38
+ #
39
+ # @since 0.1.0
40
+ # @api private
41
+ attr_reader :conditions
42
+
43
+ # Initialize a query
44
+ #
45
+ # @param collection [Hanami::Model::Adapters::Rethinkdb::Collection]
46
+ # the collection to query
47
+ #
48
+ # @param blk [Proc] an optional block that gets yielded in the
49
+ # context of the current query
50
+ #
51
+ # @return [Hanami::Model::Adapters::Rethinkdb::Query]
52
+ def initialize(collection, context = nil, &blk)
53
+ @collection, @context = collection, context
54
+ @conditions = []
55
+
56
+ instance_eval(&blk) if block_given?
57
+ end
58
+
59
+ # Resolves the query by fetching documents from the database and
60
+ # translating them into entities.
61
+ #
62
+ # @return [Array] a collection of entities
63
+ #
64
+ # @since 0.1.0
65
+ def all
66
+ scoped.execute
67
+ end
68
+
69
+ # Adds a condition like SQL `WHERE` using r.filter().
70
+ #
71
+ # It accepts a `Hash` with only one pair.
72
+ # The key must be the name of the field expressed as a `Symbol`.
73
+ # The value is the one used by the ReQL query
74
+ #
75
+ # @param condition [Hash]
76
+ #
77
+ # @return self
78
+ #
79
+ # @since 0.1.0
80
+ #
81
+ # @example Fixed value
82
+ #
83
+ # query.where(language: 'ruby')
84
+ #
85
+ # # => r.filter(language: 'ruby')
86
+ #
87
+ # @example Multiple conditions
88
+ #
89
+ # query.where(language: 'ruby')
90
+ # .where(framework: 'hanami')
91
+ #
92
+ # # => r.filter(language: 'ruby').filter('framework: 'hanami')
93
+ #
94
+ # @example Blocks
95
+ #
96
+ # query.where { |doc| doc['age'] > 10 }
97
+ #
98
+ # # => r.filter { |doc| doc.bracket('age').gt('10') }
99
+ def where(condition = nil, &blk)
100
+ condition = condition || blk ||
101
+ fail(ArgumentError, 'You need to specify a condition.')
102
+ conditions.push([:filter, condition])
103
+ self
104
+ end
105
+
106
+ alias_method :and, :where
107
+
108
+ # Pluck only the specified fields. Documents without the fields are
109
+ # omitted.
110
+ #
111
+ # By default a query includes all the fields of a table.
112
+ #
113
+ # @param fields [Array<Symbol>]
114
+ #
115
+ # @return self
116
+ #
117
+ # @since 0.1.0
118
+ #
119
+ # @example Single field
120
+ #
121
+ # query.pluck(:name)
122
+ #
123
+ # # => r.pluck(:name)
124
+ #
125
+ # @example Multiple fields
126
+ #
127
+ # query.pluck(:name, :year)
128
+ #
129
+ # # => r.pluck(:name, :year)
130
+ def pluck(*fields)
131
+ conditions.push([:pluck, *fields])
132
+ self
133
+ end
134
+
135
+ # Only include documents with the given fields.
136
+ #
137
+ # @param fields [Array<Symbol>]
138
+ #
139
+ # @return self
140
+ #
141
+ # @since 0.1.0
142
+ #
143
+ # @example Single column
144
+ #
145
+ # query.has_fields(:name)
146
+ #
147
+ # # => r.has_fields(:name)
148
+ #
149
+ # @example Multiple columns
150
+ #
151
+ # query.has_fields(:name, :year)
152
+ #
153
+ # # => r.has_fields(:name, :year)
154
+ def has_fields(*fields) # rubocop:disable Style/PredicateName
155
+ conditions.push([:has_fields, *fields])
156
+ self
157
+ end
158
+
159
+ # Limit the number of documents to return.
160
+ #
161
+ # This operation is performed at the database level with r.limit().
162
+ #
163
+ # @param number [Fixnum]
164
+ #
165
+ # @return self
166
+ #
167
+ # @since 0.1.0
168
+ #
169
+ # @example
170
+ #
171
+ # query.limit(1)
172
+ #
173
+ # # => r.limit(1)
174
+ def limit(number)
175
+ conditions.push([:limit, number])
176
+ self
177
+ end
178
+
179
+ # Specify the ascending order of the documents, sorted by the given
180
+ # fields or index. Identify an index using `{ index: :key }`.
181
+ #
182
+ # The last invokation of this method takes precidence. Previously
183
+ # called sorts will be overwritten by RethinkDB.
184
+ #
185
+ # @param fields [Array<Symbol, Hash>] the field names, optionally with
186
+ # an index identifier
187
+ #
188
+ # @return self
189
+ #
190
+ # @since 0.1.0
191
+ #
192
+ # @see Hanami::Model::Adapters::Rethinkdb::Query#desc
193
+ #
194
+ # @example Single field
195
+ #
196
+ # query.order(:name)
197
+ #
198
+ # # => r.order_by(:name)
199
+ #
200
+ # @example Multiple columns
201
+ #
202
+ # query.order(:name, :year)
203
+ #
204
+ # # => r.order_by(:name, :year)
205
+ #
206
+ # @example Single index
207
+ #
208
+ # query.order(index: :date)
209
+ #
210
+ # # => r.order_by(index: :date)
211
+ #
212
+ # @example Mixed fields and index
213
+ #
214
+ # query.order(:name, :year, index: :date)
215
+ #
216
+ # # => r.order_by(:name, :year, index: :date)
217
+ def order(*fields)
218
+ conditions.push([:order_by, *fields])
219
+ self
220
+ end
221
+
222
+ alias_method :asc, :order
223
+
224
+ # Specify the descending order of the documents, sorted by the given
225
+ # fields or index. Identify an index using `{ index: :key }`.
226
+ #
227
+ # The last invokation of this method takes precidence. Previously
228
+ # called sorts will be overwritten by RethinkDB.
229
+ #
230
+ # @return self
231
+ #
232
+ # @since 0.1.0
233
+ #
234
+ # @see Hanami::Model::Adapters::Rethinkdb::Query#desc
235
+ #
236
+ # @example Single field
237
+ #
238
+ # query.desc(:name)
239
+ #
240
+ # # => r.order_by(r.desc(:name))
241
+ #
242
+ # @example Multiple columns
243
+ #
244
+ # query.desc(:name, :year)
245
+ #
246
+ # # => r.order_by(r.desc(:name), r.desc(:year))
247
+ #
248
+ # @example Single index
249
+ #
250
+ # query.desc(index: :date)
251
+ #
252
+ # # => r.order_by(index: r.desc(:date))
253
+ #
254
+ # @example Mixed fields and index
255
+ #
256
+ # query.desc(:name, :year, { index: r.desc(:date) })
257
+ #
258
+ # # => r.order_by(r.desc(:name), r.desc(:year), { index:
259
+ # r.desc(:date) })
260
+ def desc(*fields)
261
+ conditions.push([:order_by, *_desc_wrapper(*fields)])
262
+ self
263
+ end
264
+
265
+ # Returns the sum of the values for the given field.
266
+ #
267
+ # @param field [Symbol] the field name
268
+ #
269
+ # @return [Numeric]
270
+ #
271
+ # @since 0.1.0
272
+ #
273
+ # @example
274
+ #
275
+ # query.sum(:comments_count)
276
+ #
277
+ # # => r.sum(:comments_count)
278
+ def sum(field)
279
+ scoped.sum(field)
280
+ end
281
+
282
+ # Returns the average of the values for the given field.
283
+ #
284
+ # @param field [Symbol] the column name
285
+ #
286
+ # @return [Numeric]
287
+ #
288
+ # @since 0.1.0
289
+ #
290
+ # @example
291
+ #
292
+ # query.average(:comments_count)
293
+ #
294
+ # # => r.avg(:comments_count)
295
+ def average(field)
296
+ scoped.avg(field)
297
+ end
298
+
299
+ alias_method :avg, :average
300
+
301
+ # Returns the maximum value for the given field.
302
+ #
303
+ # @param field [Symbol] the field name
304
+ #
305
+ # @return result
306
+ #
307
+ # @since 0.1.0
308
+ #
309
+ # @example With numeric type
310
+ #
311
+ # query.max(:comments_count)
312
+ #
313
+ # # r.max(:comments_count)
314
+ #
315
+ # @example With string type
316
+ #
317
+ # query.max(:title)
318
+ #
319
+ # # => r.max(:title)
320
+ def max(field)
321
+ has_fields(field)
322
+ scoped.max(field)
323
+ end
324
+
325
+ # Returns the minimum value for the given field.
326
+ #
327
+ # @param field [Symbol] the field name
328
+ #
329
+ # @return result
330
+ #
331
+ # @since 0.1.0
332
+ #
333
+ # @example With numeric type
334
+ #
335
+ # query.min(:comments_count)
336
+ #
337
+ # # => r.min(:comments_count)
338
+ #
339
+ # @example With string type
340
+ #
341
+ # query.min(:title)
342
+ #
343
+ # # => r.min(:title)
344
+ def min(field)
345
+ has_fields(field)
346
+ scoped.min(field)
347
+ end
348
+
349
+ # Returns a count of the records for the current conditions.
350
+ #
351
+ # @return [Fixnum]
352
+ #
353
+ # @since 0.1.0
354
+ #
355
+ # @example
356
+ #
357
+ # query.where(author_id: 23).count # => 5
358
+ def count
359
+ scoped.count
360
+ end
361
+
362
+ # Apply all the conditions and returns a filtered collection.
363
+ #
364
+ # This operation is idempotent, and the returned result didn't
365
+ # fetched the documents yet.
366
+ #
367
+ # @return [Hanami::Model::Adapters::Rethinkdb::Collection]
368
+ #
369
+ # @since 0.1.0
370
+ def scoped
371
+ scope = @collection
372
+
373
+ conditions.each do |(method, *args)|
374
+ scope = scope.public_send(method, *args)
375
+ end
376
+
377
+ scope
378
+ end
379
+
380
+ protected
381
+
382
+ # Handles missing methods for query combinations
383
+ #
384
+ # @api private
385
+ # @since 0.1.0
386
+ #
387
+ # @see Hanami::Model::Adapters:Rethinkdb::Query#apply
388
+ def method_missing(m, *args, &blk)
389
+ if @context.respond_to?(m)
390
+ apply @context.public_send(m, *args, &blk)
391
+ else
392
+ super
393
+ end
394
+ end
395
+
396
+ private
397
+
398
+ # Returns a new query that is the result of the merge of the current
399
+ # conditions with the ones of the given query.
400
+ #
401
+ # This is used to combine queries together in a Repository.
402
+ #
403
+ # @param query [Hanami::Model::Adapters::Rethinkdb::Query] the query
404
+ # to apply
405
+ #
406
+ # @return [Hanami::Model::Adapters::Rethinkdb::Query] a new query with
407
+ # the merged conditions
408
+ #
409
+ # @api private
410
+ # @since 0.1.0
411
+ #
412
+ # @example
413
+ # require 'hanami/model'
414
+ #
415
+ # class ArticleRepository
416
+ # include Hanami::Repository
417
+ #
418
+ # def self.by_author(author)
419
+ # query do
420
+ # where(author_id: author.id)
421
+ # end
422
+ # end
423
+ #
424
+ # def self.rank
425
+ # query.desc(:comments_count)
426
+ # end
427
+ #
428
+ # def self.rank_by_author(author)
429
+ # rank.by_author(author)
430
+ # end
431
+ # end
432
+ #
433
+ # # The code above combines two queries: `rank` and `by_author`.
434
+ # #
435
+ # # The first class method `rank` returns a `Rethinkdb::Query`
436
+ # # instance which doesn't respond to `by_author`. How to solve
437
+ # # this problem?
438
+ # #
439
+ # # 1. When we use `query` to fabricate a `Rethinkdb::Query` we
440
+ # # pass the current context (the repository itself) to the query
441
+ # # initializer.
442
+ # #
443
+ # # 2. When that query receives the `by_author` message, it's
444
+ # # captured by `method_missing` and dispatched to the repository.
445
+ # #
446
+ # # 3. The class method `by_author` returns a query too.
447
+ # #
448
+ # # 4. We just return a new query that is the result of the current
449
+ # # query's conditions (`rank`) and of the conditions from
450
+ # # `by_author`.
451
+ # #
452
+ # # You're welcome ;)
453
+ def apply(query)
454
+ dup.tap do |result|
455
+ result.conditions.push(*query.conditions)
456
+ end
457
+ end
458
+
459
+ # Wrap the given fields with a desc operator.
460
+ #
461
+ # @return [Array] the wrapped fields
462
+ #
463
+ # @api private
464
+ # @since 0.1.0
465
+ def _desc_wrapper(*fields)
466
+ Array(fields).map do |field|
467
+ if field.is_a?(Hash)
468
+ field.merge(field) { |_k, v| r.desc(v) }
469
+ else
470
+ r.desc(field)
471
+ end
472
+ end
473
+ end
474
+
475
+ # Run the enclosed block on the database.
476
+ #
477
+ # @api private
478
+ # @since 0.1.0
479
+ def _run
480
+ yield.run(@connection)
481
+ end
482
+ end
483
+ end
484
+ end
485
+ end
486
+ end