cuprum-collections 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (72) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +59 -0
  3. data/CODE_OF_CONDUCT.md +132 -0
  4. data/DEVELOPMENT.md +25 -0
  5. data/LICENSE +22 -0
  6. data/README.md +950 -0
  7. data/lib/cuprum/collections/base.rb +11 -0
  8. data/lib/cuprum/collections/basic/collection.rb +135 -0
  9. data/lib/cuprum/collections/basic/command.rb +112 -0
  10. data/lib/cuprum/collections/basic/commands/assign_one.rb +54 -0
  11. data/lib/cuprum/collections/basic/commands/build_one.rb +45 -0
  12. data/lib/cuprum/collections/basic/commands/destroy_one.rb +48 -0
  13. data/lib/cuprum/collections/basic/commands/find_many.rb +65 -0
  14. data/lib/cuprum/collections/basic/commands/find_matching.rb +126 -0
  15. data/lib/cuprum/collections/basic/commands/find_one.rb +49 -0
  16. data/lib/cuprum/collections/basic/commands/insert_one.rb +50 -0
  17. data/lib/cuprum/collections/basic/commands/update_one.rb +52 -0
  18. data/lib/cuprum/collections/basic/commands/validate_one.rb +69 -0
  19. data/lib/cuprum/collections/basic/commands.rb +18 -0
  20. data/lib/cuprum/collections/basic/query.rb +160 -0
  21. data/lib/cuprum/collections/basic/query_builder.rb +69 -0
  22. data/lib/cuprum/collections/basic/rspec/command_contract.rb +392 -0
  23. data/lib/cuprum/collections/basic/rspec.rb +8 -0
  24. data/lib/cuprum/collections/basic.rb +22 -0
  25. data/lib/cuprum/collections/command.rb +26 -0
  26. data/lib/cuprum/collections/commands/abstract_find_many.rb +77 -0
  27. data/lib/cuprum/collections/commands/abstract_find_matching.rb +64 -0
  28. data/lib/cuprum/collections/commands/abstract_find_one.rb +44 -0
  29. data/lib/cuprum/collections/commands.rb +8 -0
  30. data/lib/cuprum/collections/constraints/attribute_name.rb +22 -0
  31. data/lib/cuprum/collections/constraints/order/attributes_array.rb +26 -0
  32. data/lib/cuprum/collections/constraints/order/attributes_hash.rb +27 -0
  33. data/lib/cuprum/collections/constraints/order/complex_ordering.rb +46 -0
  34. data/lib/cuprum/collections/constraints/order/sort_direction.rb +32 -0
  35. data/lib/cuprum/collections/constraints/order.rb +8 -0
  36. data/lib/cuprum/collections/constraints/ordering.rb +114 -0
  37. data/lib/cuprum/collections/constraints/query_hash.rb +25 -0
  38. data/lib/cuprum/collections/constraints.rb +8 -0
  39. data/lib/cuprum/collections/errors/already_exists.rb +86 -0
  40. data/lib/cuprum/collections/errors/extra_attributes.rb +66 -0
  41. data/lib/cuprum/collections/errors/failed_validation.rb +66 -0
  42. data/lib/cuprum/collections/errors/invalid_parameters.rb +50 -0
  43. data/lib/cuprum/collections/errors/invalid_query.rb +55 -0
  44. data/lib/cuprum/collections/errors/missing_default_contract.rb +49 -0
  45. data/lib/cuprum/collections/errors/not_found.rb +81 -0
  46. data/lib/cuprum/collections/errors/unknown_operator.rb +71 -0
  47. data/lib/cuprum/collections/errors.rb +8 -0
  48. data/lib/cuprum/collections/queries/ordering.rb +74 -0
  49. data/lib/cuprum/collections/queries/parse.rb +22 -0
  50. data/lib/cuprum/collections/queries/parse_block.rb +206 -0
  51. data/lib/cuprum/collections/queries/parse_strategy.rb +91 -0
  52. data/lib/cuprum/collections/queries.rb +25 -0
  53. data/lib/cuprum/collections/query.rb +247 -0
  54. data/lib/cuprum/collections/query_builder.rb +61 -0
  55. data/lib/cuprum/collections/rspec/assign_one_command_contract.rb +168 -0
  56. data/lib/cuprum/collections/rspec/build_one_command_contract.rb +93 -0
  57. data/lib/cuprum/collections/rspec/collection_contract.rb +153 -0
  58. data/lib/cuprum/collections/rspec/destroy_one_command_contract.rb +106 -0
  59. data/lib/cuprum/collections/rspec/find_many_command_contract.rb +327 -0
  60. data/lib/cuprum/collections/rspec/find_matching_command_contract.rb +194 -0
  61. data/lib/cuprum/collections/rspec/find_one_command_contract.rb +154 -0
  62. data/lib/cuprum/collections/rspec/fixtures.rb +89 -0
  63. data/lib/cuprum/collections/rspec/insert_one_command_contract.rb +83 -0
  64. data/lib/cuprum/collections/rspec/query_builder_contract.rb +92 -0
  65. data/lib/cuprum/collections/rspec/query_contract.rb +650 -0
  66. data/lib/cuprum/collections/rspec/querying_contract.rb +298 -0
  67. data/lib/cuprum/collections/rspec/update_one_command_contract.rb +79 -0
  68. data/lib/cuprum/collections/rspec/validate_one_command_contract.rb +96 -0
  69. data/lib/cuprum/collections/rspec.rb +8 -0
  70. data/lib/cuprum/collections/version.rb +59 -0
  71. data/lib/cuprum/collections.rb +26 -0
  72. metadata +219 -0
@@ -0,0 +1,247 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cuprum/collections'
4
+ require 'cuprum/collections/queries/ordering'
5
+
6
+ module Cuprum::Collections
7
+ # Abstract base class for collection Query implementations.
8
+ class Query
9
+ UNDEFINED = Object.new.freeze
10
+ private_constant :UNDEFINED
11
+
12
+ def initialize
13
+ @criteria = []
14
+ end
15
+
16
+ # Returns a normalized representation of the query criteria.
17
+ #
18
+ # The query criteria define which data from the collection matches the
19
+ # query. Specifically, an item in the collection matches the query if and
20
+ # only if it matches each criterion. If the query has no criteria, then it
21
+ # will match all items in the collection.
22
+ #
23
+ # Each criterion is represented as an Array with three elements:
24
+ # - The name of the property or column to select by.
25
+ # - The operation to filter, such as :eq (an equality operation).
26
+ # - The expected value.
27
+ #
28
+ # For example, a query that selects all items whose :series property is
29
+ # equal to 'The Lord of the Rings' would have the following criterion:
30
+ # `[:series, :eq, 'The Lord of the Rings']`.
31
+ #
32
+ # @return [Array<Array>] the query criteria.
33
+ #
34
+ # @see #where
35
+ def criteria
36
+ @criteria.dup
37
+ end
38
+
39
+ # Sets or returns the maximum number of items returned by the query.
40
+ #
41
+ # @overload limit
42
+ # @return [Integer, nil] the current limit for the query.
43
+ #
44
+ # @overload limit(count)
45
+ # Returns a copy of the query with the specified limit.
46
+ #
47
+ # The query will return at most the specified number of items.
48
+ #
49
+ # When #limit is called on a query that already defines a limit, the old
50
+ # limit is replaced with the new.
51
+ #
52
+ # @param count [Integer] the maximum number of items to return.
53
+ #
54
+ # @return [Query] the copy of the query.
55
+ def limit(count = UNDEFINED)
56
+ return @limit if count == UNDEFINED
57
+
58
+ validate_limit(count)
59
+
60
+ dup.tap { |copy| copy.with_limit(count) }
61
+ end
62
+
63
+ # Sets or returns the number of ordered items skipped by the query.
64
+ #
65
+ # @overload offset
66
+ # @return [Integer, nil] the current offset for the query.
67
+ #
68
+ # @overload offset(count)
69
+ # Returns a copy of the query with the specified offset.
70
+ #
71
+ # The query will skip the specified number of matching items, and return
72
+ # only matching items after the given offset. If the total number of
73
+ # matching items is less than or equal to the offset, the query will not
74
+ # return any items.
75
+ #
76
+ # When #offset is called on a query that already defines an offset, the
77
+ # old offset is replaced with the new.
78
+ #
79
+ # @param count [Integer] the number of items to skip.
80
+ #
81
+ # @return [Query] the copy of the query.
82
+ def offset(count = UNDEFINED)
83
+ return @offset if count == UNDEFINED
84
+
85
+ validate_offset(count)
86
+
87
+ dup.tap { |copy| copy.with_offset(count) }
88
+ end
89
+
90
+ # Returns a copy of the query with the specified order.
91
+ #
92
+ # The query will find the matching items, sort them in the specified order,
93
+ # and then apply limit and/or offset (if applicable) to determine the final
94
+ # returned items.
95
+ #
96
+ # When #order is called on a query that already defines an ordering, the old
97
+ # ordering is replaced with the new.
98
+ #
99
+ # @return [Query] the copy of the query.
100
+ #
101
+ # @example Sorting By Attribute Names
102
+ # # This query will sort books by author (ascending), then by title
103
+ # # (ascending) within authors.
104
+ # query = query.order(:author, :title)
105
+ #
106
+ # @example Sorting With Directions
107
+ # # This query will sort books by series (ascending), then by the date of
108
+ # # publication (descending) within series.
109
+ # query = query.order({ series: :asc, published_at: :desc })
110
+ #
111
+ # @overload order
112
+ # @return [Hash{String,Symbol=>Symbol}] the current ordering for the
113
+ # query.
114
+ #
115
+ # @overload order(*attributes)
116
+ # Orders the results by the given attributes, ascending, and in the
117
+ # specified order, i.e. items with the same value of the first attribute
118
+ # will be sorted by the second (if any), and so on.
119
+ #
120
+ # @param attributes [Array<String, Symbol>] The attributes to order by.
121
+ #
122
+ # @overload order(attributes)
123
+ # Orders the results by the given attributes and sort directions, and in
124
+ # the specified order.
125
+ #
126
+ # @param attributes [Hash{String,Symbol=>Symbol}] The attributes to order
127
+ # by. The hash keys should be the names of attributes or columns, and
128
+ # the corresponding values should be the sort direction for that
129
+ # attribute, either :asc or :desc.
130
+ def order(*attributes)
131
+ return @order if attributes.empty?
132
+
133
+ normalized = Cuprum::Collections::Queries::Ordering.normalize(*attributes)
134
+
135
+ dup.tap { |copy| copy.with_order(normalized) }
136
+ end
137
+ alias order_by order
138
+
139
+ # Returns a copy of the query with no cached query results.
140
+ #
141
+ # Once the query has been called (e.g. by calling #each or #to_a), the
142
+ # matching data is cached. If the underlying collection changes, those
143
+ # changes will not be reflected in the query.
144
+ #
145
+ # Calling #reset clears the cached results. The next time the query is
146
+ # called, the results will be drawn from the current collection state.
147
+ #
148
+ # @return [Cuprum::Collections::Query] a copy of the query with a cleared
149
+ # results cache.
150
+ def reset
151
+ dup.reset!
152
+ end
153
+
154
+ # Returns a copy of the query with the specified filters.
155
+ #
156
+ # The given parameters are used to construct query criteria, which define
157
+ # which data from the collection matches the query. Specifically, an item in
158
+ # the collection matches the query if and only if it matches each criterion.
159
+ # If the query has no criteria, then it will match all items in the
160
+ # collection.
161
+ #
162
+ # When #where is called on a query that already defines criteria, then the
163
+ # new criteria are appended to the old. Any items in the collection must
164
+ # match both the old and the new criteria to be returned by the query.
165
+ #
166
+ # @example Filtering Data By Equality
167
+ # # The query will only return items whose author is 'J.R.R. Tolkien'.
168
+ # query = query.where { { author: 'J.R.R. Tolkien' } }
169
+ #
170
+ # @example Filtering Data By Operator
171
+ # # The query will only return items whose author is 'J.R.R. Tolkien',
172
+ # # and whose series is not 'The Lord of the Rings'.
173
+ # query = query.where do
174
+ # {
175
+ # author: eq('J.R.R. Tolkien'),
176
+ # series: ne('The Lord of the Rings')
177
+ # }
178
+ # end
179
+ #
180
+ # @overload where(&block)
181
+ # @yield The given block is passed to a QueryBuilder, which converts the
182
+ # block to query criteria and generates a new query using those
183
+ # criteria.
184
+ #
185
+ # @yieldreturn [Hash] The filters to apply to the query. The hash keys
186
+ # should be the names of attributes or columns, and the corresponding
187
+ # values should be either the literal value for that attribute or a
188
+ # method call for a valid operation defined for the query.
189
+ #
190
+ # @see #criteria
191
+ def where(filter = nil, strategy: nil, &block)
192
+ filter ||= block
193
+
194
+ return dup if filter.nil? && strategy.nil?
195
+
196
+ query_builder.call(strategy: strategy, where: filter)
197
+ end
198
+
199
+ protected
200
+
201
+ def reset!
202
+ # :nocov:
203
+ self
204
+ # :nocov:
205
+ end
206
+
207
+ def with_criteria(criteria)
208
+ @criteria += criteria
209
+
210
+ self
211
+ end
212
+
213
+ def with_limit(count)
214
+ @limit = count
215
+
216
+ self
217
+ end
218
+
219
+ def with_offset(count)
220
+ @offset = count
221
+
222
+ self
223
+ end
224
+
225
+ def with_order(order)
226
+ @order = order
227
+
228
+ self
229
+ end
230
+
231
+ private
232
+
233
+ def validate_limit(count)
234
+ return if count.is_a?(Integer) && !count.negative?
235
+
236
+ raise ArgumentError, 'limit must be a non-negative integer', caller(1..-1)
237
+ end
238
+
239
+ def validate_offset(count)
240
+ return if count.is_a?(Integer) && !count.negative?
241
+
242
+ raise ArgumentError,
243
+ 'offset must be a non-negative integer',
244
+ caller(1..-1)
245
+ end
246
+ end
247
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cuprum/collections'
4
+ require 'cuprum/collections/queries/parse'
5
+
6
+ module Cuprum::Collections
7
+ # Internal class that handles parsing and applying criteria to a query.
8
+ class QueryBuilder
9
+ # Exception class to be raised when the query cannot be parsed.
10
+ class ParseError < RuntimeError; end
11
+
12
+ # @param base_query [Cuprum::Collections::Query] The original query.
13
+ def initialize(base_query)
14
+ @base_query = base_query
15
+ end
16
+
17
+ # @return [Cuprum::Collections::Query] the original query.
18
+ attr_reader :base_query
19
+
20
+ # Returns a copy of the query updated with the generated criteria.
21
+ #
22
+ # Classifies the parameters to determine parsing strategy, then uses that
23
+ # strategy to parse the parameters into an array of criteria. Then, copies
24
+ # the original query and updates the copy with the parsed criteria.
25
+ #
26
+ # @param strategy [Symbol, nil] The specified strategy for parsing the given
27
+ # filter into criteria. If nil, the builder will attempt to guess the
28
+ # strategy based on the given filter.
29
+ # @param where [Object] The filter used to match items in the collection.
30
+ #
31
+ # @return [Cuprum::Collections::Query] the copied and updated query.
32
+ def call(where:, strategy: nil)
33
+ criteria =
34
+ if strategy == :unsafe
35
+ where
36
+ else
37
+ parse_criteria(strategy: strategy, where: where)
38
+ end
39
+
40
+ build_query(criteria)
41
+ end
42
+
43
+ private
44
+
45
+ def build_query(criteria)
46
+ base_query
47
+ .dup
48
+ .send(:with_criteria, criteria)
49
+ end
50
+
51
+ def parse_criteria(strategy:, where:)
52
+ result = Cuprum::Collections::Queries::Parse
53
+ .new
54
+ .call(strategy: strategy, where: where)
55
+
56
+ return result.value if result.success?
57
+
58
+ raise ParseError, result.error.message
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'stannum/constraints/presence'
4
+ require 'stannum/constraints/types/hash_with_indifferent_keys'
5
+ require 'stannum/rspec/validate_parameter'
6
+
7
+ require 'cuprum/collections/rspec'
8
+
9
+ module Cuprum::Collections::RSpec
10
+ # Contract validating the behavior of an Assign command implementation.
11
+ ASSIGN_ONE_COMMAND_CONTRACT = lambda do |allow_extra_attributes:|
12
+ describe '#call' do
13
+ shared_examples 'should assign the attributes' do
14
+ it { expect(result).to be_a_passing_result }
15
+
16
+ it { expect(result.value).to be_a entity.class }
17
+
18
+ it { expect(result.value).to be == expected_value }
19
+ end
20
+
21
+ let(:attributes) { {} }
22
+ let(:result) { command.call(attributes: attributes, entity: entity) }
23
+ let(:expected_attributes) do
24
+ initial_attributes.merge(attributes)
25
+ end
26
+ let(:expected_value) do
27
+ defined?(super()) ? super() : expected_attributes
28
+ end
29
+
30
+ it 'should validate the :attributes keyword' do
31
+ expect(command)
32
+ .to validate_parameter(:call, :attributes)
33
+ .using_constraint(
34
+ Stannum::Constraints::Types::HashWithIndifferentKeys.new
35
+ )
36
+ end
37
+
38
+ it 'should validate the :entity keyword' do
39
+ expect(command)
40
+ .to validate_parameter(:call, :entity)
41
+ .using_constraint(entity_type)
42
+ .with_parameters(attributes: {}, entity: nil)
43
+ end
44
+
45
+ describe 'with an empty attributes hash' do
46
+ let(:attributes) { {} }
47
+
48
+ include_examples 'should assign the attributes'
49
+ end
50
+
51
+ describe 'with an attributes hash with partial attributes' do
52
+ let(:attributes) { { title: 'Gideon the Ninth' } }
53
+
54
+ include_examples 'should assign the attributes'
55
+ end
56
+
57
+ describe 'with an attributes hash with full attributes' do
58
+ let(:attributes) do
59
+ {
60
+ title: 'Gideon the Ninth',
61
+ author: 'Tammsyn Muir',
62
+ series: 'The Locked Tomb',
63
+ category: 'Horror'
64
+ }
65
+ end
66
+
67
+ include_examples 'should assign the attributes'
68
+ end
69
+
70
+ describe 'with an attributes hash with extra attributes' do
71
+ let(:attributes) do
72
+ {
73
+ title: 'The Book of Lost Tales',
74
+ audiobook: true
75
+ }
76
+ end
77
+
78
+ if allow_extra_attributes
79
+ include_examples 'should assign the attributes'
80
+ else
81
+ # :nocov:
82
+ let(:valid_attributes) do
83
+ defined?(super()) ? super() : expected_attributes.keys
84
+ end
85
+ let(:expected_error) do
86
+ Cuprum::Collections::Errors::ExtraAttributes.new(
87
+ entity_class: entity.class,
88
+ extra_attributes: %w[audiobook],
89
+ valid_attributes: valid_attributes
90
+ )
91
+ end
92
+
93
+ it 'should return a failing result' do
94
+ expect(result).to be_a_failing_result.with_error(expected_error)
95
+ end
96
+ # :nocov:
97
+ end
98
+ end
99
+
100
+ context 'when the entity has existing attributes' do
101
+ let(:initial_attributes) do
102
+ # :nocov:
103
+ if defined?(super())
104
+ super().merge(fixtures_data.first)
105
+ else
106
+ fixtures_data.first
107
+ end
108
+ # :nocov:
109
+ end
110
+
111
+ describe 'with an empty attributes hash' do
112
+ let(:attributes) { {} }
113
+
114
+ include_examples 'should assign the attributes'
115
+ end
116
+
117
+ describe 'with an attributes hash with partial attributes' do
118
+ let(:attributes) { { title: 'Gideon the Ninth' } }
119
+
120
+ include_examples 'should assign the attributes'
121
+ end
122
+
123
+ describe 'with an attributes hash with full attributes' do
124
+ let(:attributes) do
125
+ {
126
+ title: 'Gideon the Ninth',
127
+ author: 'Tammsyn Muir',
128
+ series: 'The Locked Tomb',
129
+ category: 'Horror'
130
+ }
131
+ end
132
+
133
+ include_examples 'should assign the attributes'
134
+ end
135
+
136
+ describe 'with an attributes hash with extra attributes' do
137
+ let(:attributes) do
138
+ {
139
+ title: 'The Book of Lost Tales',
140
+ audiobook: true
141
+ }
142
+ end
143
+
144
+ if allow_extra_attributes
145
+ include_examples 'should assign the attributes'
146
+ else
147
+ # :nocov:
148
+ let(:valid_attributes) do
149
+ defined?(super()) ? super() : expected_attributes.keys
150
+ end
151
+ let(:expected_error) do
152
+ Cuprum::Collections::Errors::ExtraAttributes.new(
153
+ entity_class: entity.class,
154
+ extra_attributes: %w[audiobook],
155
+ valid_attributes: valid_attributes
156
+ )
157
+ end
158
+
159
+ it 'should return a failing result' do
160
+ expect(result).to be_a_failing_result.with_error(expected_error)
161
+ end
162
+ # :nocov:
163
+ end
164
+ end
165
+ end
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'stannum/constraints/types/hash_with_indifferent_keys'
4
+ require 'stannum/rspec/validate_parameter'
5
+
6
+ require 'cuprum/collections/rspec'
7
+
8
+ module Cuprum::Collections::RSpec
9
+ # Contract validating the behavior of a Build command implementation.
10
+ BUILD_ONE_COMMAND_CONTRACT = lambda do |allow_extra_attributes:|
11
+ include Stannum::RSpec::Matchers
12
+
13
+ describe '#call' do
14
+ shared_examples 'should build the entity' do
15
+ it { expect(result).to be_a_passing_result }
16
+
17
+ it { expect(result.value).to be == expected_value }
18
+ end
19
+
20
+ let(:attributes) { {} }
21
+ let(:result) { command.call(attributes: attributes) }
22
+ let(:expected_attributes) do
23
+ attributes
24
+ end
25
+ let(:expected_value) do
26
+ defined?(super()) ? super() : attributes
27
+ end
28
+
29
+ it 'should validate the :attributes keyword' do
30
+ expect(command)
31
+ .to validate_parameter(:call, :attributes)
32
+ .using_constraint(
33
+ Stannum::Constraints::Types::HashWithIndifferentKeys.new
34
+ )
35
+ end
36
+
37
+ describe 'with an empty attributes hash' do
38
+ let(:attributes) { {} }
39
+
40
+ include_examples 'should build the entity'
41
+ end
42
+
43
+ describe 'with an attributes hash with partial attributes' do
44
+ let(:attributes) { { title: 'Gideon the Ninth' } }
45
+
46
+ include_examples 'should build the entity'
47
+ end
48
+
49
+ describe 'with an attributes hash with full attributes' do
50
+ let(:attributes) do
51
+ {
52
+ title: 'Gideon the Ninth',
53
+ author: 'Tammsyn Muir',
54
+ series: 'The Locked Tomb',
55
+ category: 'Horror'
56
+ }
57
+ end
58
+
59
+ include_examples 'should build the entity'
60
+ end
61
+
62
+ describe 'with an attributes hash with extra attributes' do
63
+ let(:attributes) do
64
+ {
65
+ title: 'The Book of Lost Tales',
66
+ audiobook: true
67
+ }
68
+ end
69
+
70
+ if allow_extra_attributes
71
+ include_examples 'should build the entity'
72
+ else
73
+ # :nocov:
74
+ let(:valid_attributes) do
75
+ defined?(super()) ? super() : expected_attributes.keys
76
+ end
77
+ let(:expected_error) do
78
+ Cuprum::Collections::Errors::ExtraAttributes.new(
79
+ entity_class: entity_type,
80
+ extra_attributes: %w[audiobook],
81
+ valid_attributes: valid_attributes
82
+ )
83
+ end
84
+
85
+ it 'should return a failing result' do
86
+ expect(result).to be_a_failing_result.with_error(expected_error)
87
+ end
88
+ # :nocov:
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end