cuprum-collections 0.1.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 (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