cuprum-collections 0.3.0 → 0.4.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.
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