hanami-rethinkdb 0.2.3

Sign up to get free protection for your applications and to get access to all the features.
@@ -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