cuprum-collections 0.3.0 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +48 -0
  3. data/DEVELOPMENT.md +2 -2
  4. data/README.md +13 -11
  5. data/lib/cuprum/collections/association.rb +256 -0
  6. data/lib/cuprum/collections/associations/belongs_to.rb +32 -0
  7. data/lib/cuprum/collections/associations/has_many.rb +23 -0
  8. data/lib/cuprum/collections/associations/has_one.rb +23 -0
  9. data/lib/cuprum/collections/associations.rb +10 -0
  10. data/lib/cuprum/collections/basic/collection.rb +39 -74
  11. data/lib/cuprum/collections/basic/commands/find_many.rb +1 -1
  12. data/lib/cuprum/collections/basic/commands/find_matching.rb +1 -1
  13. data/lib/cuprum/collections/basic/repository.rb +9 -33
  14. data/lib/cuprum/collections/basic.rb +1 -0
  15. data/lib/cuprum/collections/collection.rb +154 -0
  16. data/lib/cuprum/collections/commands/associations/find_many.rb +161 -0
  17. data/lib/cuprum/collections/commands/associations/require_many.rb +48 -0
  18. data/lib/cuprum/collections/commands/associations.rb +13 -0
  19. data/lib/cuprum/collections/commands/find_one_matching.rb +1 -1
  20. data/lib/cuprum/collections/commands.rb +1 -0
  21. data/lib/cuprum/collections/errors/abstract_find_error.rb +1 -1
  22. data/lib/cuprum/collections/relation.rb +401 -0
  23. data/lib/cuprum/collections/repository.rb +71 -4
  24. data/lib/cuprum/collections/resource.rb +65 -0
  25. data/lib/cuprum/collections/rspec/contracts/association_contracts.rb +2137 -0
  26. data/lib/cuprum/collections/rspec/contracts/basic/command_contracts.rb +484 -0
  27. data/lib/cuprum/collections/rspec/contracts/basic.rb +11 -0
  28. data/lib/cuprum/collections/rspec/contracts/collection_contracts.rb +429 -0
  29. data/lib/cuprum/collections/rspec/contracts/command_contracts.rb +1462 -0
  30. data/lib/cuprum/collections/rspec/contracts/query_contracts.rb +1093 -0
  31. data/lib/cuprum/collections/rspec/contracts/relation_contracts.rb +1381 -0
  32. data/lib/cuprum/collections/rspec/contracts/repository_contracts.rb +605 -0
  33. data/lib/cuprum/collections/rspec/contracts.rb +23 -0
  34. data/lib/cuprum/collections/rspec/fixtures.rb +85 -82
  35. data/lib/cuprum/collections/rspec.rb +4 -1
  36. data/lib/cuprum/collections/version.rb +1 -1
  37. data/lib/cuprum/collections.rb +9 -4
  38. metadata +23 -19
  39. data/lib/cuprum/collections/base.rb +0 -11
  40. data/lib/cuprum/collections/basic/rspec/command_contract.rb +0 -392
  41. data/lib/cuprum/collections/rspec/assign_one_command_contract.rb +0 -168
  42. data/lib/cuprum/collections/rspec/build_one_command_contract.rb +0 -93
  43. data/lib/cuprum/collections/rspec/collection_contract.rb +0 -190
  44. data/lib/cuprum/collections/rspec/destroy_one_command_contract.rb +0 -108
  45. data/lib/cuprum/collections/rspec/find_many_command_contract.rb +0 -407
  46. data/lib/cuprum/collections/rspec/find_matching_command_contract.rb +0 -194
  47. data/lib/cuprum/collections/rspec/find_one_command_contract.rb +0 -157
  48. data/lib/cuprum/collections/rspec/insert_one_command_contract.rb +0 -84
  49. data/lib/cuprum/collections/rspec/query_builder_contract.rb +0 -92
  50. data/lib/cuprum/collections/rspec/query_contract.rb +0 -650
  51. data/lib/cuprum/collections/rspec/querying_contract.rb +0 -298
  52. data/lib/cuprum/collections/rspec/repository_contract.rb +0 -235
  53. data/lib/cuprum/collections/rspec/update_one_command_contract.rb +0 -80
  54. data/lib/cuprum/collections/rspec/validate_one_command_contract.rb +0 -96
@@ -0,0 +1,1093 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cuprum/collections/queries'
4
+ require 'cuprum/collections/rspec/contracts'
5
+ require 'cuprum/collections/rspec/fixtures'
6
+
7
+ module Cuprum::Collections::RSpec::Contracts
8
+ # Contracts for asserting on Query objects.
9
+ module QueryContracts
10
+ # Contract validating the behavior of a Query implementation.
11
+ module ShouldBeAQuery
12
+ extend RSpec::SleepingKingStudios::Contract
13
+
14
+ BOOKS_FIXTURES = Cuprum::Collections::RSpec::Fixtures::BOOKS_FIXTURES
15
+ private_constant :BOOKS_FIXTURES
16
+
17
+ OPERATORS = Cuprum::Collections::Queries::Operators
18
+ private_constant :OPERATORS
19
+
20
+ # @!method apply(example_group, operators:)
21
+ # Adds the contract to the example group.
22
+ #
23
+ # @param example_group [RSpec::Core::ExampleGroup] the example group to
24
+ # which the contract is applied.
25
+ # @param operators [Array<Symbol>] the expected operators.
26
+ contract do |operators: OPERATORS.values|
27
+ include Cuprum::Collections::RSpec::Contracts::QueryContracts
28
+
29
+ operators = Set.new(operators.map(&:to_sym))
30
+
31
+ include_contract 'with query contexts'
32
+
33
+ shared_context 'when the query has composed filters' do
34
+ let(:scoped_query) do
35
+ super()
36
+ .where { { author: 'Ursula K. LeGuin' } }
37
+ .where { { series: not_equal('Earthsea') } }
38
+ end
39
+ let(:matching_data) do
40
+ super()
41
+ .select { |item| item['author'] == 'Ursula K. LeGuin' }
42
+ .reject { |item| item['series'] == 'Earthsea' }
43
+ end
44
+ end
45
+
46
+ let(:scoped_query) do
47
+ # :nocov:
48
+ scoped =
49
+ if filter.is_a?(Proc)
50
+ query.where(&filter)
51
+ elsif !filter.nil?
52
+ query.where(filter)
53
+ else
54
+ query
55
+ end
56
+ # :nocov:
57
+ scoped = scoped.limit(limit) if limit
58
+ scoped = scoped.offset(offset) if offset
59
+ scoped = scoped.order(order) if order
60
+
61
+ scoped
62
+ end
63
+
64
+ it 'should be enumerable' do
65
+ expect(described_class).to be < Enumerable
66
+ end
67
+
68
+ describe '#count' do
69
+ let(:data) { [] }
70
+ let(:matching_data) { data }
71
+ let(:expected_data) do
72
+ defined?(super()) ? super() : matching_data
73
+ end
74
+
75
+ it { expect(query).to respond_to(:count).with(0).arguments }
76
+
77
+ it { expect(query.count).to be == expected_data.count }
78
+
79
+ wrap_context 'when the query has composed filters' do
80
+ it { expect(scoped_query.count).to be == expected_data.count }
81
+ end
82
+
83
+ context 'when the collection data changes' do
84
+ let(:item) { BOOKS_FIXTURES.first }
85
+
86
+ before(:example) do
87
+ query.count # Cache query results.
88
+
89
+ add_item_to_collection(item)
90
+ end
91
+
92
+ it { expect(query.count).to be == expected_data.count }
93
+ end
94
+
95
+ context 'when the collection has many items' do
96
+ let(:data) { BOOKS_FIXTURES }
97
+
98
+ it { expect(query.count).to be == expected_data.count }
99
+
100
+ wrap_context 'when the query has composed filters' do
101
+ it { expect(scoped_query.count).to be == expected_data.count }
102
+ end
103
+
104
+ context 'when the collection data changes' do
105
+ let(:data) { BOOKS_FIXTURES[0...-1] }
106
+ let(:item) { BOOKS_FIXTURES.last }
107
+
108
+ before(:example) do
109
+ query.count # Cache query results.
110
+
111
+ add_item_to_collection(item)
112
+ end
113
+
114
+ it { expect(query.count).to be == expected_data.count }
115
+ end
116
+ end
117
+ end
118
+
119
+ describe '#criteria' do
120
+ include_examples 'should have reader', :criteria, []
121
+
122
+ wrap_context 'when the query has where: a simple block filter' do
123
+ let(:expected) { [['author', :equal, 'Ursula K. LeGuin']] }
124
+
125
+ it { expect(scoped_query.criteria).to be == expected }
126
+ end
127
+
128
+ wrap_context 'when the query has where: a complex block filter' do
129
+ let(:expected) do
130
+ [
131
+ ['author', :equal, 'Ursula K. LeGuin'],
132
+ ['series', :not_equal, 'Earthsea']
133
+ ]
134
+ end
135
+
136
+ if operators.include?(OPERATORS::EQUAL) &&
137
+ operators.include?(OPERATORS::NOT_EQUAL)
138
+ it { expect(scoped_query.criteria).to be == expected }
139
+ else
140
+ # :nocov:
141
+ pending
142
+ # :nocov:
143
+ end
144
+ end
145
+
146
+ wrap_context 'when the query has composed filters' do
147
+ let(:expected) do
148
+ [
149
+ ['author', :equal, 'Ursula K. LeGuin'],
150
+ ['series', :not_equal, 'Earthsea']
151
+ ]
152
+ end
153
+
154
+ it { expect(scoped_query.criteria).to be == expected }
155
+ end
156
+
157
+ wrap_context 'when the query has where: an equal block filter' do
158
+ let(:expected) { [['author', :equal, 'Ursula K. LeGuin']] }
159
+
160
+ if operators.include?(OPERATORS::EQUAL)
161
+ it { expect(scoped_query.criteria).to be == expected }
162
+ else
163
+ # :nocov:
164
+ pending
165
+ # :nocov:
166
+ end
167
+ end
168
+
169
+ wrap_context 'when the query has where: a not_equal block filter' do
170
+ let(:expected) { [['author', :not_equal, 'Ursula K. LeGuin']] }
171
+
172
+ if operators.include?(OPERATORS::NOT_EQUAL)
173
+ it { expect(scoped_query.criteria).to be == expected }
174
+ else
175
+ # :nocov:
176
+ pending
177
+ # :nocov:
178
+ end
179
+ end
180
+ end
181
+
182
+ describe '#each' do
183
+ shared_examples 'should enumerate the matching data' do
184
+ describe 'with no arguments' do
185
+ it { expect(scoped_query.each).to be_a Enumerator }
186
+
187
+ it { expect(scoped_query.each.count).to be == matching_data.size }
188
+
189
+ it { expect(scoped_query.each.to_a).to deep_match expected_data }
190
+ end
191
+
192
+ describe 'with a block' do
193
+ it 'should yield each matching item' do
194
+ expect { |block| scoped_query.each(&block) }
195
+ .to yield_successive_args(*expected_data)
196
+ end
197
+ end
198
+ end
199
+
200
+ let(:data) { [] }
201
+ let(:matching_data) { data }
202
+ let(:expected_data) do
203
+ defined?(super()) ? super() : matching_data
204
+ end
205
+
206
+ it { expect(query).to respond_to(:each).with(0).arguments }
207
+
208
+ include_examples 'should enumerate the matching data'
209
+
210
+ include_contract 'should perform queries',
211
+ block: lambda {
212
+ include_examples 'should enumerate the matching data'
213
+ },
214
+ operators: operators
215
+
216
+ wrap_context 'when the query has composed filters' do
217
+ include_examples 'should enumerate the matching data'
218
+ end
219
+
220
+ context 'when the collection data changes' do
221
+ let(:item) { BOOKS_FIXTURES.first }
222
+
223
+ before(:example) do
224
+ query.each {} # Cache query results.
225
+
226
+ add_item_to_collection(item)
227
+ end
228
+
229
+ include_examples 'should enumerate the matching data'
230
+ end
231
+
232
+ context 'when the collection has many items' do
233
+ let(:data) { BOOKS_FIXTURES }
234
+
235
+ include_examples 'should enumerate the matching data'
236
+
237
+ include_contract 'should perform queries',
238
+ block: lambda {
239
+ include_examples 'should enumerate the matching data'
240
+ },
241
+ operators: operators
242
+
243
+ wrap_context 'when the query has composed filters' do
244
+ include_examples 'should enumerate the matching data'
245
+ end
246
+
247
+ context 'when the collection data changes' do
248
+ let(:data) { BOOKS_FIXTURES[0...-1] }
249
+ let(:item) { BOOKS_FIXTURES.last }
250
+
251
+ before(:example) do
252
+ query.each {} # Cache query results.
253
+
254
+ add_item_to_collection(item)
255
+ end
256
+
257
+ include_examples 'should enumerate the matching data'
258
+ end
259
+ end
260
+ end
261
+
262
+ describe '#exists?' do
263
+ shared_examples 'should check the existence of matching data' do
264
+ it { expect(query.exists?).to be == !matching_data.empty? }
265
+ end
266
+
267
+ let(:data) { [] }
268
+ let(:matching_data) { data }
269
+
270
+ include_examples 'should define predicate', :exists?
271
+
272
+ include_examples 'should check the existence of matching data'
273
+
274
+ include_contract 'should perform queries',
275
+ block: lambda {
276
+ include_examples 'should check the existence of matching data'
277
+ },
278
+ operators: operators
279
+
280
+ wrap_context 'when the query has composed filters' do
281
+ include_examples 'should check the existence of matching data'
282
+ end
283
+
284
+ context 'when the collection has many items' do
285
+ let(:data) { BOOKS_FIXTURES }
286
+
287
+ include_examples 'should check the existence of matching data'
288
+
289
+ include_contract 'should perform queries',
290
+ block: lambda {
291
+ include_examples 'should check the existence of matching data'
292
+ },
293
+ operators: operators
294
+
295
+ wrap_context 'when the query has composed filters' do
296
+ include_examples 'should check the existence of matching data'
297
+ end
298
+ end
299
+ end
300
+
301
+ describe '#limit' do
302
+ it { expect(query).to respond_to(:limit).with(0..1).arguments }
303
+
304
+ describe 'with no arguments' do
305
+ it { expect(query.limit).to be nil }
306
+ end
307
+
308
+ describe 'with nil' do
309
+ let(:error_message) { 'limit must be a non-negative integer' }
310
+
311
+ it 'should raise an exception' do
312
+ expect { query.limit nil }
313
+ .to raise_error ArgumentError, error_message
314
+ end
315
+ end
316
+
317
+ describe 'with an object' do
318
+ let(:error_message) { 'limit must be a non-negative integer' }
319
+
320
+ it 'should raise an exception' do
321
+ expect { query.limit Object.new.freeze }
322
+ .to raise_error ArgumentError, error_message
323
+ end
324
+ end
325
+
326
+ describe 'with a negative integer' do
327
+ let(:error_message) { 'limit must be a non-negative integer' }
328
+
329
+ it 'should raise an exception' do
330
+ expect { query.limit(-1) }
331
+ .to raise_error ArgumentError, error_message
332
+ end
333
+ end
334
+
335
+ describe 'with zero' do
336
+ it { expect(query.limit(0)).to be_a described_class }
337
+
338
+ it { expect(query.limit(0)).not_to be query }
339
+
340
+ it { expect(query.limit(0).limit).to be 0 }
341
+ end
342
+
343
+ describe 'with a positive integer' do
344
+ it { expect(query.limit(3)).to be_a described_class }
345
+
346
+ it { expect(query.limit(3)).not_to be query }
347
+
348
+ it { expect(query.limit(3).limit).to be 3 }
349
+ end
350
+ end
351
+
352
+ describe '#offset' do
353
+ it { expect(query).to respond_to(:offset).with(0..1).argument }
354
+
355
+ describe 'with no arguments' do
356
+ it { expect(query.offset).to be nil }
357
+ end
358
+
359
+ describe 'with nil' do
360
+ let(:error_message) { 'offset must be a non-negative integer' }
361
+
362
+ it 'should raise an exception' do
363
+ expect { query.offset nil }
364
+ .to raise_error ArgumentError, error_message
365
+ end
366
+ end
367
+
368
+ describe 'with an object' do
369
+ let(:error_message) { 'offset must be a non-negative integer' }
370
+
371
+ it 'should raise an exception' do
372
+ expect { query.offset Object.new.freeze }
373
+ .to raise_error ArgumentError, error_message
374
+ end
375
+ end
376
+
377
+ describe 'with a negative integer' do
378
+ let(:error_message) { 'offset must be a non-negative integer' }
379
+
380
+ it 'should raise an exception' do
381
+ expect { query.offset(-1) }
382
+ .to raise_error ArgumentError, error_message
383
+ end
384
+ end
385
+
386
+ describe 'with zero' do
387
+ it { expect(query.offset(0)).to be_a described_class }
388
+
389
+ it { expect(query.offset(0)).not_to be query }
390
+
391
+ it { expect(query.offset(0).offset).to be 0 }
392
+ end
393
+
394
+ describe 'with a positive integer' do
395
+ it { expect(query.offset(3)).to be_a described_class }
396
+
397
+ it { expect(query.offset(3)).not_to be query }
398
+
399
+ it { expect(query.offset(3).offset).to be 3 }
400
+ end
401
+ end
402
+
403
+ describe '#order' do
404
+ let(:default_order) { defined?(super()) ? super() : {} }
405
+ let(:error_message) do
406
+ 'order must be a list of attribute names and/or a hash of ' \
407
+ 'attribute names with values :asc or :desc'
408
+ end
409
+
410
+ it 'should define the method' do
411
+ expect(query)
412
+ .to respond_to(:order)
413
+ .with(0).arguments
414
+ .and_unlimited_arguments
415
+ end
416
+
417
+ it { expect(query).to have_aliased_method(:order).as(:order_by) }
418
+
419
+ describe 'with no arguments' do
420
+ it { expect(query.order).to be == default_order }
421
+ end
422
+
423
+ describe 'with a hash with invalid keys' do
424
+ it 'should raise an exception' do
425
+ expect { query.order({ nil => :asc }) }
426
+ .to raise_error ArgumentError, error_message
427
+ end
428
+ end
429
+
430
+ describe 'with a hash with empty string keys' do
431
+ it 'should raise an exception' do
432
+ expect { query.order({ '' => :asc }) }
433
+ .to raise_error ArgumentError, error_message
434
+ end
435
+ end
436
+
437
+ describe 'with a hash with empty symbol keys' do
438
+ it 'should raise an exception' do
439
+ expect { query.order({ '': :asc }) }
440
+ .to raise_error ArgumentError, error_message
441
+ end
442
+ end
443
+
444
+ describe 'with a hash with nil value' do
445
+ it 'should raise an exception' do
446
+ expect { query.order({ title: nil }) }
447
+ .to raise_error ArgumentError, error_message
448
+ end
449
+ end
450
+
451
+ describe 'with a hash with object value' do
452
+ it 'should raise an exception' do
453
+ expect { query.order({ title: Object.new.freeze }) }
454
+ .to raise_error ArgumentError, error_message
455
+ end
456
+ end
457
+
458
+ describe 'with a hash with empty value' do
459
+ it 'should raise an exception' do
460
+ expect { query.order({ title: '' }) }
461
+ .to raise_error ArgumentError, error_message
462
+ end
463
+ end
464
+
465
+ describe 'with a hash with invalid value' do
466
+ it 'should raise an exception' do
467
+ expect { query.order({ title: 'wibbly' }) }
468
+ .to raise_error ArgumentError, error_message
469
+ end
470
+ end
471
+
472
+ describe 'with a valid ordering' do
473
+ let(:expected) do
474
+ { title: :asc }
475
+ end
476
+
477
+ it { expect(query.order(:title)).to be_a described_class }
478
+
479
+ it { expect(query.order(:title)).not_to be query }
480
+
481
+ it { expect(query.order(:title).order).to be == expected }
482
+ end
483
+ end
484
+
485
+ describe '#reset' do
486
+ let(:data) { [] }
487
+ let(:matching_data) { data }
488
+ let(:expected_data) do
489
+ defined?(super()) ? super() : matching_data
490
+ end
491
+
492
+ it { expect(query).to respond_to(:reset).with(0).arguments }
493
+
494
+ it { expect(query.reset).to be_a query.class }
495
+
496
+ it { expect(query.reset).not_to be query }
497
+
498
+ it { expect(query.reset.to_a).to be == query.to_a }
499
+
500
+ context 'when the collection data changes' do
501
+ let(:item) { BOOKS_FIXTURES.first }
502
+ let(:matching_data) { [item] }
503
+
504
+ before(:example) do
505
+ query.to_a # Cache query results.
506
+
507
+ add_item_to_collection(item)
508
+ end
509
+
510
+ it { expect(query.reset.count).to be expected_data.size }
511
+
512
+ it { expect(query.reset.to_a).to deep_match expected_data }
513
+ end
514
+
515
+ context 'when the collection has many items' do
516
+ let(:data) { BOOKS_FIXTURES }
517
+
518
+ it { expect(query.reset).to be_a query.class }
519
+
520
+ it { expect(query.reset).not_to be query }
521
+
522
+ it { expect(query.reset.to_a).to be == query.to_a }
523
+
524
+ context 'when the collection data changes' do
525
+ let(:data) { BOOKS_FIXTURES[0...-1] }
526
+ let(:item) { BOOKS_FIXTURES.last }
527
+ let(:matching_data) { [*data, item] }
528
+
529
+ before(:example) do
530
+ query.to_a # Cache query results.
531
+
532
+ add_item_to_collection(item)
533
+ end
534
+
535
+ it { expect(query.reset.count).to be expected_data.size }
536
+
537
+ it { expect(query.reset.to_a).to deep_match expected_data }
538
+ end
539
+ end
540
+ end
541
+
542
+ describe '#to_a' do
543
+ let(:data) { [] }
544
+ let(:matching_data) { data }
545
+ let(:expected_data) do
546
+ defined?(super()) ? super() : matching_data
547
+ end
548
+
549
+ it { expect(query).to respond_to(:to_a).with(0).arguments }
550
+
551
+ it { expect(query.to_a).to deep_match expected_data }
552
+
553
+ include_contract 'should perform queries',
554
+ block: lambda {
555
+ it { expect(scoped_query.to_a).to deep_match expected_data }
556
+ },
557
+ operators: operators
558
+
559
+ wrap_context 'when the query has composed filters' do
560
+ it { expect(scoped_query.to_a).to deep_match expected_data }
561
+ end
562
+
563
+ context 'when the collection data changes' do
564
+ let(:item) { BOOKS_FIXTURES.first }
565
+
566
+ before(:example) do
567
+ query.to_a # Cache query results.
568
+
569
+ add_item_to_collection(item)
570
+ end
571
+
572
+ it { expect(query.to_a).to deep_match expected_data }
573
+ end
574
+
575
+ context 'when the collection has many items' do
576
+ let(:data) { BOOKS_FIXTURES }
577
+
578
+ it { expect(query.to_a).to deep_match expected_data }
579
+
580
+ include_contract 'should perform queries',
581
+ block: lambda {
582
+ it { expect(scoped_query.to_a).to deep_match expected_data }
583
+ },
584
+ operators: operators
585
+
586
+ wrap_context 'when the query has composed filters' do
587
+ it { expect(scoped_query.to_a).to deep_match expected_data }
588
+ end
589
+
590
+ context 'when the collection data changes' do
591
+ let(:data) { BOOKS_FIXTURES[0...-1] }
592
+ let(:item) { BOOKS_FIXTURES.last }
593
+
594
+ before(:example) do
595
+ query.to_a # Cache query results.
596
+
597
+ add_item_to_collection(item)
598
+ end
599
+
600
+ it { expect(query.to_a).to deep_match expected_data }
601
+ end
602
+ end
603
+ end
604
+
605
+ describe '#where' do
606
+ let(:block) { -> { { title: 'The Caves of Steel' } } }
607
+
608
+ it 'should define the method' do
609
+ expect(query)
610
+ .to respond_to(:where)
611
+ .with(0..1).arguments
612
+ .and_keywords(:strategy)
613
+ .and_a_block
614
+ end
615
+
616
+ describe 'with no arguments' do
617
+ it { expect(query.where).to be_a described_class }
618
+
619
+ it { expect(query.where).not_to be query }
620
+ end
621
+
622
+ describe 'with a block' do
623
+ it { expect(query.where(&block)).to be_a described_class }
624
+
625
+ it { expect(query.where(&block)).not_to be query }
626
+ end
627
+
628
+ describe 'with a valid strategy' do
629
+ it 'should return a query instance' do
630
+ expect(query.where(strategy: :block, &block))
631
+ .to be_a described_class
632
+ end
633
+
634
+ it { expect(query.where(strategy: :block, &block)).not_to be query }
635
+ end
636
+
637
+ describe 'with parameters that do not match a strategy' do
638
+ let(:error_class) do
639
+ Cuprum::Collections::QueryBuilder::ParseError
640
+ end
641
+ let(:error_message) { 'unable to parse query with strategy nil' }
642
+
643
+ it 'should raise an exception' do
644
+ expect { query.where(%w[ichi ni san]) }
645
+ .to raise_error error_class, error_message
646
+ end
647
+ end
648
+
649
+ describe 'with an invalid strategy' do
650
+ let(:error_class) do
651
+ Cuprum::Collections::QueryBuilder::ParseError
652
+ end
653
+ let(:error_message) do
654
+ 'unable to parse query with strategy :random'
655
+ end
656
+
657
+ it 'should raise an exception' do
658
+ expect { query.where(strategy: :random) }
659
+ .to raise_error error_class, error_message
660
+ end
661
+ end
662
+
663
+ describe 'with invalid parameters for a strategy' do
664
+ let(:error_class) do
665
+ Cuprum::Collections::QueryBuilder::ParseError
666
+ end
667
+ let(:error_message) { 'unable to parse query with strategy :block' }
668
+
669
+ it 'should raise an exception' do
670
+ expect { query.where(strategy: :block) }
671
+ .to raise_error error_class, error_message
672
+ end
673
+ end
674
+ end
675
+ end
676
+ end
677
+
678
+ # Contract validating the behavior of a QueryBuilder implementation.
679
+ module ShouldBeAQueryBuilderContract
680
+ extend RSpec::SleepingKingStudios::Contract
681
+
682
+ # @!method apply(example_group)
683
+ # Adds the contract to the example group.
684
+ #
685
+ # @param example_group [RSpec::Core::ExampleGroup] the example group to
686
+ # which the contract is applied.
687
+ contract do
688
+ describe '#base_query' do
689
+ include_examples 'should define reader',
690
+ :base_query,
691
+ -> { base_query }
692
+ end
693
+
694
+ describe '#call' do
695
+ let(:criteria) { [['title', :equal, 'The Naked Sun']] }
696
+ let(:expected) { criteria }
697
+ let(:filter) { { title: 'The Naked Sun' } }
698
+ let(:strategy) { :custom }
699
+ let(:parser) do
700
+ instance_double(
701
+ Cuprum::Collections::Queries::Parse,
702
+ call: Cuprum::Result.new(value: criteria)
703
+ )
704
+ end
705
+ let(:query) do
706
+ builder.call(strategy: strategy, where: filter)
707
+ end
708
+
709
+ before(:example) do
710
+ allow(Cuprum::Collections::Queries::Parse)
711
+ .to receive(:new)
712
+ .and_return(parser)
713
+ end
714
+
715
+ it 'should define the method' do
716
+ expect(builder).to respond_to(:call)
717
+ .with(0).arguments
718
+ .and_keywords(:strategy, :where)
719
+ end
720
+
721
+ it 'should parse the criteria' do
722
+ builder.call(strategy: strategy, where: filter)
723
+
724
+ expect(parser)
725
+ .to have_received(:call)
726
+ .with(strategy: strategy, where: filter)
727
+ end
728
+
729
+ it { expect(query).to be_a base_query.class }
730
+
731
+ it { expect(query).not_to be base_query }
732
+
733
+ it { expect(query.criteria).to be == expected }
734
+
735
+ describe 'with strategy: :unsafe' do
736
+ let(:strategy) { :unsafe }
737
+ let(:filter) { criteria }
738
+
739
+ it 'should not parse the criteria' do
740
+ builder.call(strategy: strategy, where: filter)
741
+
742
+ expect(parser).not_to have_received(:call)
743
+ end
744
+
745
+ it { expect(query.criteria).to be == expected }
746
+ end
747
+
748
+ context 'when the query has existing criteria' do
749
+ let(:old_criteria) { [['genre', :eq, 'Science Fiction']] }
750
+ let(:expected) { old_criteria + criteria }
751
+ let(:base_query) { super().send(:with_criteria, old_criteria) }
752
+
753
+ it { expect(query.criteria).to be == expected }
754
+ end
755
+
756
+ context 'when the parser is unable to parse the query' do
757
+ let(:error) { Cuprum::Error.new(message: 'Something went wrong.') }
758
+ let(:result) { Cuprum::Result.new(error: error) }
759
+
760
+ before(:example) do
761
+ allow(parser).to receive(:call).and_return(result)
762
+ end
763
+
764
+ it 'should raise an exception' do
765
+ expect do
766
+ builder.call(strategy: strategy, where: filter)
767
+ end
768
+ .to raise_error Cuprum::Collections::QueryBuilder::ParseError,
769
+ error.message
770
+ end
771
+ end
772
+ end
773
+ end
774
+ end
775
+
776
+ # Contract validating the behavior when performing queries.
777
+ module ShouldPerformQueriesContract
778
+ extend RSpec::SleepingKingStudios::Contract
779
+
780
+ OPERATORS = Cuprum::Collections::Queries::Operators
781
+ private_constant :OPERATORS
782
+
783
+ # @!method apply(example_group, block:, operators:)
784
+ # Adds the contract to the example group.
785
+ #
786
+ # @param example_group [RSpec::Core::ExampleGroup] the example group to
787
+ # which the contract is applied.
788
+ # @param block [Proc] the expectations for each query context.
789
+ # @param operators [Array<Symbol>] the expected operators.
790
+ contract do |block:, operators: OPERATORS.values|
791
+ operators = Set.new(operators.map(&:to_sym))
792
+
793
+ wrap_context 'when the query has limit: value' do
794
+ instance_exec(&block)
795
+ end
796
+
797
+ wrap_context 'when the query has offset: value' do
798
+ instance_exec(&block)
799
+ end
800
+
801
+ wrap_context 'when the query has order: a simple ordering' do
802
+ instance_exec(&block)
803
+ end
804
+
805
+ wrap_context 'when the query has order: a complex ordering' do
806
+ instance_exec(&block)
807
+ end
808
+
809
+ context 'when the query has where: a block filter' do
810
+ context 'with a simple filter' do
811
+ include_context 'when the query has where: a simple block filter'
812
+
813
+ instance_exec(&block)
814
+ end
815
+
816
+ context 'with a complex filter' do
817
+ include_context 'when the query has where: a complex block filter'
818
+
819
+ if operators.include?(OPERATORS::EQUAL) &&
820
+ operators.include?(OPERATORS::NOT_EQUAL)
821
+ instance_exec(&block)
822
+ else
823
+ # :nocov:
824
+ pending
825
+ # :nocov:
826
+ end
827
+ end
828
+
829
+ context 'with an equals filter' do
830
+ include_context 'when the query has where: an equal block filter'
831
+
832
+ if operators.include?(OPERATORS::EQUAL)
833
+ instance_exec(&block)
834
+ else
835
+ # :nocov:
836
+ pending
837
+ # :nocov:
838
+ end
839
+ end
840
+
841
+ context 'with a greater_than filter' do
842
+ include_context 'when the query has where: a greater_than filter'
843
+
844
+ if operators.include?(OPERATORS::GREATER_THAN)
845
+ instance_exec(&block)
846
+ else
847
+ # :nocov:
848
+ pending
849
+ # :nocov:
850
+ end
851
+ end
852
+
853
+ context 'with a greater_than_or_equal_to filter' do
854
+ include_context \
855
+ 'when the query has where: a greater_than_or_equal_to filter'
856
+
857
+ if operators.include?(OPERATORS::GREATER_THAN_OR_EQUAL_TO)
858
+ instance_exec(&block)
859
+ else
860
+ # :nocov:
861
+ pending
862
+ # :nocov:
863
+ end
864
+ end
865
+
866
+ context 'with a less_than filter' do
867
+ include_context 'when the query has where: a less_than filter'
868
+
869
+ if operators.include?(OPERATORS::LESS_THAN)
870
+ instance_exec(&block)
871
+ else
872
+ # :nocov:
873
+ pending
874
+ # :nocov:
875
+ end
876
+ end
877
+
878
+ context 'with a less_than_or_equal_to filter' do
879
+ include_context \
880
+ 'when the query has where: a less_than_or_equal_to filter'
881
+
882
+ if operators.include?(OPERATORS::LESS_THAN_OR_EQUAL_TO)
883
+ instance_exec(&block)
884
+ else
885
+ # :nocov:
886
+ pending
887
+ # :nocov:
888
+ end
889
+ end
890
+
891
+ context 'with a not_equal filter' do
892
+ include_context 'when the query has where: a not_equal block filter'
893
+
894
+ if operators.include?(OPERATORS::NOT_EQUAL)
895
+ instance_exec(&block)
896
+ else
897
+ # :nocov:
898
+ pending
899
+ # :nocov:
900
+ end
901
+ end
902
+
903
+ context 'with a not_one_of filter' do
904
+ include_context \
905
+ 'when the query has where: a not_one_of block filter'
906
+
907
+ if operators.include?(OPERATORS::NOT_ONE_OF)
908
+ instance_exec(&block)
909
+ else
910
+ # :nocov:
911
+ pending
912
+ # :nocov:
913
+ end
914
+ end
915
+
916
+ context 'with a one_of filter' do
917
+ include_context 'when the query has where: a one_of block filter'
918
+
919
+ if operators.include?(OPERATORS::ONE_OF)
920
+ instance_exec(&block)
921
+ else
922
+ # :nocov:
923
+ pending
924
+ # :nocov:
925
+ end
926
+ end
927
+ end
928
+
929
+ wrap_context 'when the query has multiple query options' do
930
+ instance_exec(&block)
931
+ end
932
+ end
933
+ end
934
+
935
+ # Contract defining contexts for validating query behavior.
936
+ module WithQueryContextsContract
937
+ extend RSpec::SleepingKingStudios::Contract
938
+
939
+ # @!method apply(example_group)
940
+ # Adds the contract to the example group.
941
+ #
942
+ # @param example_group [RSpec::Core::ExampleGroup] the example group to
943
+ # which the contract is applied.
944
+ contract do
945
+ let(:filter) { nil }
946
+ let(:strategy) { nil }
947
+ let(:limit) { nil }
948
+ let(:offset) { nil }
949
+ let(:order) { nil }
950
+
951
+ shared_context 'when the query has limit: value' do
952
+ let(:limit) { 3 }
953
+ let(:matching_data) { super()[0...limit] }
954
+ end
955
+
956
+ shared_context 'when the query has offset: value' do
957
+ let(:offset) { 2 }
958
+ let(:matching_data) { super()[offset..] || [] }
959
+ end
960
+
961
+ shared_context 'when the query has order: a simple ordering' do
962
+ let(:order) { :title }
963
+ let(:matching_data) { super().sort_by { |item| item['title'] } }
964
+ end
965
+
966
+ shared_context 'when the query has order: a complex ordering' do
967
+ let(:order) do
968
+ {
969
+ author: :asc,
970
+ title: :desc
971
+ }
972
+ end
973
+ let(:matching_data) do
974
+ super().sort do |u, v|
975
+ cmp = u['author'] <=> v['author']
976
+
977
+ cmp.zero? ? (v['title'] <=> u['title']) : cmp
978
+ end
979
+ end
980
+ end
981
+
982
+ shared_context 'when the query has where: a simple block filter' do
983
+ let(:filter) { -> { { author: 'Ursula K. LeGuin' } } }
984
+ let(:matching_data) do
985
+ super().select { |item| item['author'] == 'Ursula K. LeGuin' }
986
+ end
987
+ end
988
+
989
+ shared_context 'when the query has where: a complex block filter' do
990
+ let(:filter) do
991
+ lambda do
992
+ {
993
+ author: equals('Ursula K. LeGuin'),
994
+ series: not_equal('Earthsea')
995
+ }
996
+ end
997
+ end
998
+ let(:matching_data) do
999
+ super()
1000
+ .select { |item| item['author'] == 'Ursula K. LeGuin' }
1001
+ .reject { |item| item['series'] == 'Earthsea' }
1002
+ end
1003
+ end
1004
+
1005
+ shared_context 'when the query has where: a greater_than filter' do
1006
+ let(:filter) { -> { { published_at: greater_than('1970-12-01') } } }
1007
+ let(:matching_data) do
1008
+ super().select { |item| item['published_at'] > '1970-12-01' }
1009
+ end
1010
+ end
1011
+
1012
+ shared_context 'when the query has where: a greater_than_or_equal_to ' \
1013
+ 'filter' \
1014
+ do
1015
+ let(:filter) do
1016
+ -> { { published_at: greater_than_or_equal_to('1970-12-01') } }
1017
+ end
1018
+ let(:matching_data) do
1019
+ super().select { |item| item['published_at'] >= '1970-12-01' }
1020
+ end
1021
+ end
1022
+
1023
+ shared_context 'when the query has where: a less_than filter' do
1024
+ let(:filter) { -> { { published_at: less_than('1970-12-01') } } }
1025
+ let(:matching_data) do
1026
+ super().select { |item| item['published_at'] < '1970-12-01' }
1027
+ end
1028
+ end
1029
+
1030
+ shared_context 'when the query has where: a ' \
1031
+ 'less_than_or_equal_to filter' \
1032
+ do
1033
+ let(:filter) do
1034
+ -> { { published_at: less_than_or_equal_to('1970-12-01') } }
1035
+ end
1036
+ let(:matching_data) do
1037
+ super().select { |item| item['published_at'] <= '1970-12-01' }
1038
+ end
1039
+ end
1040
+
1041
+ shared_context 'when the query has where: an equal block filter' do
1042
+ let(:filter) { -> { { author: equals('Ursula K. LeGuin') } } }
1043
+ let(:matching_data) do
1044
+ super().select { |item| item['author'] == 'Ursula K. LeGuin' }
1045
+ end
1046
+ end
1047
+
1048
+ shared_context 'when the query has where: a not_equal block filter' do
1049
+ let(:filter) { -> { { author: not_equal('Ursula K. LeGuin') } } }
1050
+ let(:matching_data) do
1051
+ super().reject { |item| item['author'] == 'Ursula K. LeGuin' }
1052
+ end
1053
+ end
1054
+
1055
+ shared_context 'when the query has where: a not_one_of block filter' do
1056
+ let(:filter) do
1057
+ -> { { series: not_one_of(['Earthsea', 'The Lord of the Rings']) } }
1058
+ end
1059
+ let(:matching_data) do
1060
+ super().reject do |item|
1061
+ ['Earthsea', 'The Lord of the Rings'].include?(item['series'])
1062
+ end
1063
+ end
1064
+ end
1065
+
1066
+ shared_context 'when the query has where: a one_of block filter' do
1067
+ let(:filter) do
1068
+ -> { { series: one_of(['Earthsea', 'The Lord of the Rings']) } }
1069
+ end
1070
+ let(:matching_data) do
1071
+ super().select do |item|
1072
+ ['Earthsea', 'The Lord of the Rings'].include?(item['series'])
1073
+ end
1074
+ end
1075
+ end
1076
+
1077
+ shared_context 'when the query has multiple query options' do
1078
+ let(:filter) { -> { { author: 'Ursula K. LeGuin' } } }
1079
+ let(:strategy) { nil }
1080
+ let(:order) { { title: :desc } }
1081
+ let(:limit) { 2 }
1082
+ let(:offset) { 1 }
1083
+ let(:matching_data) do
1084
+ super()
1085
+ .select { |item| item['author'] == 'Ursula K. LeGuin' }
1086
+ .sort { |u, v| v['title'] <=> u['title'] }
1087
+ .slice(1, 2) || []
1088
+ end
1089
+ end
1090
+ end
1091
+ end
1092
+ end
1093
+ end