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
data/README.md ADDED
@@ -0,0 +1,950 @@
1
+ # Cuprum::Collections
2
+
3
+ A data abstraction layer based on the Cuprum library.
4
+
5
+ Cuprum::Collections defines the following objects:
6
+
7
+ - [Collections](#collections): A standard interface for interacting with a datastore.
8
+ - [Commands](#commands): Each collection is comprised of `Cuprum` commands, which implement common collection operations such as inserting or querying data.
9
+ - [Queries](#queries): A low-level interface for performing query operations on a datastore.
10
+
11
+ ## About
12
+
13
+ Cuprum::Collections provides a standard interface for interacting with a datastore, whether the data is in a relational database, a document-based datastore, a directory of files, or simply an array of in-memory objects. It leverages the `Cuprum` and `Stannum` gems to define a set of commands with built-in parameter validation and error handling.
14
+
15
+ Currently, the Cuprum::Collections gem itself provides the `Basic` collection, which stores and queries data to and from an in-memory `Array` of `Hash`es data structure. Additional datastores are supported via other gems:
16
+
17
+ - [Cuprum::Rails](https://github.com/sleepingkingstudios/cuprum-rails/): The `Cuprum::Rails::Collection` implement the collection interface for `ActiveRecord` models.
18
+
19
+ ### Why Cuprum::Collections?
20
+
21
+ The Ruby ecosystem has a wide variety of tools and libraries for managing data and persistence - ORMs like [ActiveRecord](https://rubyonrails.org/) and [Mongoid](https://mongoid.github.io/), object mapping tools like [Ruby Object Mapper](https://rom-rb.org/), and low-level libraries like [Sequel](http://sequel.jeremyevans.net/) and [Mongo](https://docs.mongodb.com/ruby-driver/current/). Why take the time to learn and apply a new tool?
22
+
23
+ - **Flexibility:** Using a consistent interface allows an application to be flexible in how it persists and queries data. For example, an application could use the same interface to manage both a relational database and a document-based datastore, or use a fast in-memory data store to back its unit tests.
24
+ - **Command Pattern:** Leverages the [Cuprum](https://github.com/sleepingkingstudios/cuprum) gem and the [Command pattern](https://en.wikipedia.org/wiki/Command_pattern) to define encapsulated, composable, and reusable components for persisting and querying data. In addition, the [Stannum](https://github.com/sleepingkingstudios/stannum/) gem provides data and parameter validation.
25
+ - **Data Mapping:** The `Cuprum::Collections` approach to data is much closer to the [Data Mapper pattern](https://en.wikipedia.org/wiki/Data_mapper_pattern) than the [Active Record pattern](https://en.wikipedia.org/wiki/Active_record_pattern). This isolates the persistence and validation logic from how the data is defined and how it is stored.
26
+
27
+ ### Compatibility
28
+
29
+ Cuprum::Collections is tested against Ruby (MRI) 2.6 through 2.7.
30
+
31
+ ### Documentation
32
+
33
+ Documentation is generated using [YARD](https://yardoc.org/), and can be generated locally using the `yard` gem.
34
+
35
+ ### License
36
+
37
+ Copyright (c) 2020-2021 Rob Smith
38
+
39
+ Stannum is released under the [MIT License](https://opensource.org/licenses/MIT).
40
+
41
+ ### Contribute
42
+
43
+ The canonical repository for this gem is located at https://github.com/sleepingkingstudios/cuprum-collections.
44
+
45
+ To report a bug or submit a feature request, please use the [Issue Tracker](https://github.com/sleepingkingstudios/cuprum-collections/issues).
46
+
47
+ To contribute code, please fork the repository, make the desired updates, and then provide a [Pull Request](https://github.com/sleepingkingstudios/cuprum-collections/pulls). Pull requests must include appropriate tests for consideration, and all code must be properly formatted.
48
+
49
+ ### Code of Conduct
50
+
51
+ Please note that the `Cuprum::Collections` project is released with a [Contributor Code of Conduct](https://github.com/sleepingkingstudios/cuprum-collections/blob/master/CODE_OF_CONDUCT.md). By contributing to this project, you agree to abide by its terms.
52
+
53
+ <!-- ## Getting Started -->
54
+
55
+ ## Reference
56
+
57
+ <a id="collections"></a>
58
+
59
+ ### Collections
60
+
61
+ A `Cuprum::Collection` provides an interface for persisting and querying data to and from a data source.
62
+
63
+ Each collection provides three features:
64
+
65
+ - A constructor that initializes the collection with the necessary parameters.
66
+ - A set of commands that implement persistence and querying operations.
67
+ - A `#query` method to directly perform queries on the data.
68
+
69
+ ```ruby
70
+ collection = Cuprum::Collections::Basic.new(
71
+ collection_name: 'books',
72
+ data: book_data,
73
+ )
74
+
75
+ # Add an item to the collection.
76
+ steps do
77
+ # Build the book from attributes.
78
+ book = step do
79
+ collection.build_one.call(
80
+ attributes: { id: 10, title: 'Gideon the Ninth', author: 'Tammsyn Muir' }
81
+ )
82
+ end
83
+
84
+ # Validate the book using its default validations.
85
+ step { collection.validate_one.call(entity: book) }
86
+
87
+ # Insert the validated book to the collection.
88
+ step { collection.insert_one.call(entity: book) }
89
+ end
90
+
91
+ # Find an item by primary key.
92
+ book = step { collection.find_one.call(primary_key: 10) }
93
+
94
+ # Find items matching a filter.
95
+ books = step do
96
+ collection.find_matching.call(
97
+ limit: 10,
98
+ order: [:author, { title: :descending }],
99
+ where: lambda do
100
+ published_at: greater_than('1950-01-01')
101
+ end
102
+ )
103
+ end
104
+ ```
105
+
106
+ Because a collection can represent any sort of data, from a raw Ruby Hash to an ORM record, the term used to indicate "one item in the collection" is an *entity*. Likewise, the class of the items in the collection is the *entity_class*. In our example above, our entities are books, and the entity class is Hash.
107
+
108
+ <a id="commands"></a>
109
+
110
+ #### Commands
111
+
112
+ Structurally, a collection is a set of commands, which are instances of `Cuprum::Command` that implement a persistence or querying operation and wrap that operation with parameter validation and error handling. For more information on `Cuprum` commands, see the [Cuprum gem](github.com/sleepingkingstudios/cuprum).
113
+
114
+ ##### Assign One
115
+
116
+ The `AssignOne` command takes an attributes hash and an entity, and returns an instance of the entity class whose attributes are equal to the attributes hash merged into original entities attributes. Depending on the collection, `#assign_one` may or may not modify or return the original entity.
117
+
118
+ ```ruby
119
+ book = { 'id' => 10, 'title' => 'Gideon the Ninth', 'author' => 'Tammsyn Muir' }
120
+ attributes = { 'title' => 'Harrow the Ninth', 'published_at' => '2020-08-04' }
121
+ result = collection.assign_one.call(attributes: attributes, entity: entity)
122
+
123
+ result.value
124
+ #=> {
125
+ # 'id' => 10,
126
+ # 'title' => 'Harrow the Ninth',
127
+ # 'author' => 'Tammsyn Muir',
128
+ # 'published_at' => '2020-08-04'
129
+ # }
130
+ ```
131
+
132
+ If the entity class specifies a set of attributes (such as the defined columns in a relational table), the `#assign_one` command can return a failing result with an `ExtraAttributes` error (see [Errors](#errors), below) if the attributes hash includes one or more attributes that are not defined for that entity class.
133
+
134
+ ##### Build One
135
+
136
+ The `BuildOne` command takes an attributes hash and returns a new instance of the entity class whose attributes are equal to the given attributes. This does not validate or persist the entity; it is equivalent to calling `entity_class.new` with the attributes.
137
+
138
+ ```ruby
139
+ attributes = { 'id' => 10, 'title' => 'Gideon the Ninth', 'author' => 'Tammsyn Muir' }
140
+ result = collection.build_one.call(attributes: attributes, entity: entity)
141
+
142
+ result.value
143
+ #=> {
144
+ # 'id' => 10,
145
+ # 'title' => 'Gideon the Ninth',
146
+ # 'author' => 'Tammsyn Muir'
147
+ # }
148
+ ```
149
+
150
+ If the entity class specifies a set of attributes (such as the defined columns in a relational table), the `#build_one` command can return a failing result with an `ExtraAttributes` error (see [Errors](#errors), below) if the attributes hash includes one or more attributes that are not defined for that entity class.
151
+
152
+ ##### Destroy One
153
+
154
+ The `DestroyOne` command takes a primary key value and removes the entity with the specified primary key from the collection.
155
+
156
+ ```ruby
157
+ result = collection.destroy_one.call(primary_key: 0)
158
+
159
+ collection.query.where(id: 0).exists?
160
+ #=> false
161
+ ```
162
+
163
+ If the collection does not include an entity with the specified primary key, the `#destroy_one` command will return a failing result with a `NotFound` error (see [Errors](#errors), below).
164
+
165
+ ##### Find Many
166
+
167
+ The `FindMany` command takes an array of primary key values and returns the entities with the specified primary keys. The entities are returned in the order of the specified primary keys.
168
+
169
+ ```ruby
170
+ result = collection.find_many.call(primary_keys: [0, 1, 2])
171
+ result.value
172
+ #=> [
173
+ # {
174
+ # 'id' => 0,
175
+ # 'title' => 'The Hobbit',
176
+ # 'author' => 'J.R.R. Tolkien',
177
+ # 'series' => nil,
178
+ # 'category' => 'Science Fiction and Fantasy',
179
+ # 'published_at' => '1937-09-21'
180
+ # },
181
+ # {
182
+ # 'id' => 1,
183
+ # 'title' => 'The Silmarillion',
184
+ # 'author' => 'J.R.R. Tolkien',
185
+ # 'series' => nil,
186
+ # 'category' => 'Science Fiction and Fantasy',
187
+ # 'published_at' => '1977-09-15'
188
+ # },
189
+ # {
190
+ # 'id' => 2,
191
+ # 'title' => 'The Fellowship of the Ring',
192
+ # 'author' => 'J.R.R. Tolkien',
193
+ # 'series' => 'The Lord of the Rings',
194
+ # 'category' => 'Science Fiction and Fantasy',
195
+ # 'published_at' => '1954-07-29'
196
+ # }
197
+ # ]
198
+ ```
199
+
200
+ The `FindMany` command has several options:
201
+
202
+ - The `:allow_partial` keyword allows the command to return a passing result if at least one of the entities is found. By default, the command will return a failing result unless an entity is found for each primary key value.
203
+ - The `:envelope` keyword wraps the result value in an envelope hash, with a key equal to the name of the collection and whose value is the returned entities array.
204
+
205
+ ```ruby
206
+ result = collection.find_many.call(primary_keys: [0, 1, 2], envelope: true)
207
+ result.value
208
+ #=> { books: [{ ... }, { ... }, { ... }] }
209
+ ```
210
+
211
+ - The `:scope` keyword allows you to pass a query to the command. Only entities that match the given scope will be found and returned by `#find_many`.
212
+
213
+ If the collection does not include an entity with each of the specified primary keys, the `#find_many` command will return a failing result with a `NotFound` error (see [Errors](#errors), below).
214
+
215
+ ##### Find Matching
216
+
217
+ The `FindMatching` command takes a set of query parameters and queries data from the collection. You can specify filters using the `:where` keyword or by passing a block, sort the results using the `:order` keyword, or return a subset of the results using the `:limit` and `:offset` keywords. For full details on performing queries, see [Queries](#queries), below.
218
+
219
+ ```ruby
220
+ result =
221
+ collection
222
+ .find_matching
223
+ .call(order: :published_at, where: { series: 'Earthsea' })
224
+ result.value
225
+ #=> [
226
+ # {
227
+ # 'id' => 7,
228
+ # 'title' => 'A Wizard of Earthsea',
229
+ # 'author' => 'Ursula K. LeGuin',
230
+ # 'series' => 'Earthsea',
231
+ # 'category' => 'Science Fiction and Fantasy',
232
+ # 'published_at' => '1968-11-01'
233
+ # },
234
+ # {
235
+ # 'id' => 8,
236
+ # 'title' => 'The Tombs of Atuan',
237
+ # 'author' => 'Ursula K. LeGuin',
238
+ # 'series' => 'Earthsea',
239
+ # 'category' => 'Science Fiction and Fantasy',
240
+ # 'published_at' => '1970-12-01'
241
+ # },
242
+ # {
243
+ # 'id' => 9,
244
+ # 'title' => 'The Farthest Shore',
245
+ # 'author' => 'Ursula K. LeGuin',
246
+ # 'series' => 'Earthsea',
247
+ # 'category' => 'Science Fiction and Fantasy',
248
+ # 'published_at' => '1972-09-01'
249
+ # }
250
+ # ]
251
+ ```
252
+
253
+ The `FindMatching` command has several options:
254
+
255
+ - The `:envelope` keyword wraps the result value in an envelope hash, with a key equal to the name of the collection and whose value is the returned entities array.
256
+
257
+ ```ruby
258
+ result = collection.find_matching.call(where: { series: 'Earthsea' }, envelope: true)
259
+ result.value
260
+ #=> { books: [{ ... }, { ... }, { ... }] }
261
+ ```
262
+
263
+ - The `:scope` keyword allows you to pass a query to the command. Only entities that match the given scope will be found and returned by `#find_matching`.
264
+
265
+ ##### Find One
266
+
267
+ The `FindOne` command takes a primary key value and returns the entity with the specified primary key.
268
+
269
+ ```ruby
270
+ result = collection.find_one.call(primary_key: 1)
271
+ result.value
272
+ #=> {
273
+ # 'id' => 1,
274
+ # 'title' => 'The Silmarillion',
275
+ # 'author' => 'J.R.R. Tolkien',
276
+ # 'series' => nil,
277
+ # 'category' => 'Science Fiction and Fantasy',
278
+ # 'published_at' => '1977-09-15'
279
+ # }
280
+ ```
281
+
282
+ The `FindOne` command has several options:
283
+
284
+ - The `:envelope` keyword wraps the result value in an envelope hash, with a key equal to the singular name of the collection and whose value is the returned entity.
285
+
286
+ ```ruby
287
+ result = collection.find_one.call(primary_key: 1, envelope: true)
288
+ result.value
289
+ #=> { book: {} }
290
+ ```
291
+
292
+ - The `:scope` keyword allows you to pass a query to the command. Only an entity that match the given scope will be found and returned by `#find_one`.
293
+
294
+ If the collection does not include an entity with the specified primary key, the `#find_one` command will return a failing result with a `NotFound` error (see [Errors](#errors), below).
295
+
296
+ ##### Insert One
297
+
298
+ The `InsertOne` command takes an entity and inserts that entity into the collection.
299
+
300
+ ```ruby
301
+ book = { 'id' => 10, 'title' => 'Gideon the Ninth', 'author' => 'Tammsyn Muir' }
302
+ result = collection.insert_one.call(entity: entity)
303
+
304
+ result.value
305
+ #=> {
306
+ # 'id' => 10,
307
+ # 'title' => 'Gideon the Ninth',
308
+ # 'author' => 'Tammsyn Muir'
309
+ # }
310
+
311
+ collection.query.where(id: 10).exists?
312
+ #=> true
313
+ ```
314
+
315
+ If the collection already includes an entity with the specified primary key, the `#insert_one` command will return a failing result with an `AlreadyExists` error (see [Errors](#errors), below).
316
+
317
+ ##### Update One
318
+
319
+ The `UpdateOne` command takes an entity and updates the corresponding entity in the collection.
320
+
321
+ ```ruby
322
+ book = collection.find_one.call(1).value
323
+ book = book.merge('author' => 'John Ronald Reuel Tolkien')
324
+ result = collection.update_one(entity: book)
325
+
326
+ result.value
327
+ #=> {
328
+ # 'id' => 1,
329
+ # 'title' => 'The Silmarillion',
330
+ # 'author' => 'J.R.R. Tolkien',
331
+ # 'series' => nil,
332
+ # 'category' => 'Science Fiction and Fantasy',
333
+ # 'published_at' => '1977-09-15'
334
+ # }
335
+
336
+ collection
337
+ .query
338
+ .where(title: 'The Silmarillion', author: 'John Ronald Reuel Tolkien')
339
+ .exists?
340
+ #=> true
341
+ ```
342
+
343
+ If the collection does not include an entity with the specified entity's primary key, the `#update_one` command will return a failing result with a `NotFound` error (see [Errors](#errors), below).
344
+
345
+ ##### Validate One
346
+
347
+ The `ValidateOne` command takes an entity and a `Stannum` contract and matches the entity to the contract. Some implementations allow specifying a default contract, either as a parameter on the collection or as a class property on the entity class; if the collection has a default contract, then the `:contract` keyword is optional.
348
+
349
+ ```ruby
350
+ contract = Stannum::Contract.new do
351
+ property :title, Stannum::Constraints::Presence.new
352
+ end
353
+
354
+ book = { 'id' => 10, 'title' => 'Gideon the Ninth', 'author' => 'Tammsyn Muir' }
355
+ result = collection.validate_one.call(contract: contract, entity: book)
356
+ result.success?
357
+ #=> true
358
+ ```
359
+
360
+ If the contract does not match the entity, the `#validate_one` command will return a failing result with a `ValidationFailed` error (see [Errors](#errors), below).
361
+
362
+ If the collection does not specify a default contract and no `:contract` keyword is provided, the `#validate_one` command will return a failing result with a `MissingDefaultContract` error.
363
+
364
+ #### Basic Collection
365
+
366
+ ```
367
+ require 'cuprum/collections/basic'
368
+ ```
369
+
370
+ The `Cuprum::Basic::Collection` provides a reference implementation of a collection. It uses an in-memory `Array` to store `Hash`es with `String` keys. All of the command examples above use a basic collection as an example.
371
+
372
+ ```ruby
373
+ collection = Cuprum::Collections::Basic.new(
374
+ collection_name: 'books',
375
+ data: book_data,
376
+ )
377
+ ```
378
+
379
+ Initializing a basic collection requires, at a minumum, the following keywords:
380
+
381
+ - The `:collection_name` parameter sets the name of the collection. It is used to create an envelope for query commands, such as the `FindMany`, `FindMatching` and `FindOne` commands.
382
+ - The `:data` parameter initializes the collection with existing data. The data must be either an empty array or an `Array` of `Hash`es with `String` keys.
383
+
384
+ You can also specify some optional keywords:
385
+
386
+ - The `:default_contract` parameter sets a default contract for validating collection entities. If no `:contract` keyword is passed to the `ValidateOne` command, it will use the default contract to validate the entity.
387
+ - The `:member_name` parameter is used to create an envelope for singular query commands such as the `FindOne` command. If not given, the member name will be generated automatically as a singular form of the collection name.
388
+ - The `:primary_key_name` parameter specifies the attribute that serves as the primary key for the collection entities. The default value is `:id`.
389
+ - The `:primary_key_type` parameter specifies the type of the primary key attribute. The default value is `Integer`.
390
+
391
+ <a id="constraints"></a>
392
+
393
+ ### Constraints
394
+
395
+ `Cuprum::Collections` defines a small number of `Stannum` constraints for validating command parameters.
396
+
397
+ **Attribute Name**
398
+
399
+ A `Cuprum::Collections::Constraints::AttributeName` constraint validates that the object is a valid attribute name. Specifically, that the object either a `String` or a `Symbol` and that it is not `#empty?`.
400
+
401
+ **Ordering**
402
+
403
+ A `Cuprum::Collections::Constraints::Ordering` constraint validates that the object is a valid sort ordering. An ordering must be one of the following:
404
+
405
+ - `nil`
406
+ - A valid attribute name, e.g. `title` or `:author`
407
+ - An array of valid attribute names, e.g. `['title', 'author']` or `[:series, :publisher]`
408
+ - A hash of valid attribute names and sort directions, e.g. `{ title: :descending }`
409
+ - An array of valid attribute names, with the last item of the array a hash of valid attribute names and sort directions, e.g. `[:author, :series, { published_at: :ascending }]`
410
+
411
+ **Sort Direction**
412
+
413
+ A `Cuprum::Collections::Constraints::Order::SortDirection` constraint validates that the object is a valid sort direction. Specifically, that the object is either a `String` or a `Symbol` and that is has a value of `'asc'`, `'ascending'`, `'desc'`, or `'descending'`.
414
+
415
+ <a id="errors"></a>
416
+
417
+ ### Errors
418
+
419
+ `Cuprum::Collections` defines a set of errors to be used in failed command results.
420
+
421
+ **AlreadyExists**
422
+
423
+ A `Cuprum::Collections::Errors::AlreadyExists` error is used when an entity already exists in the collection with the given primary key, e.g. in an `InsertOne` command.
424
+
425
+ It has the following properties:
426
+
427
+ - `#collection_name`: The name of the collection used in the command.
428
+ - `#primary_key_name`: The name of the primary key attribute, e.g. `'id'`.
429
+ - `#primary_key_values`: The values of the duplicate primary keys, e.g. `[1]`.
430
+
431
+ **Extra Attributes**
432
+
433
+ A `Cuprum::Collections::Errors::ExtraAttributes` error is used when attempting to set attributes on an entity that are not defined for that entity class.
434
+
435
+ It has the following properties:
436
+
437
+ - `#entity_class`: The class of the entity used in the command.
438
+ - `#extra_attributes`: The names of the invalid attributes that the command attempted to set, as an `Array` of `String`s.
439
+ - `#valid_attributes`: The names of the valid attributes for the entity class, as an `Array` of `String`s.
440
+
441
+ **Failed Validation**
442
+
443
+ A `Cuprum::Collections::Errors::FailedValidation` error is used when an entity fails validation in a command.
444
+
445
+ It has the following properties:
446
+
447
+ - `#entity_class`: The class of the entity used in the command.
448
+ - `#errors`: The validation error messages, grouped by the error path.
449
+
450
+ **Invalid Parameters**
451
+
452
+ A `Cuprum::Collections::Errors::InvalidParameters` error is used when attempting to call a command with invalid parameters for that command.
453
+
454
+ It has the following properties:
455
+
456
+ - `#command`: The command that was called.
457
+ - `#errors`: The validation errors for the parameters, as an `Array` of error `Hash`es.
458
+
459
+ **Invalid Query**
460
+
461
+ A `Cuprum::Collections::Errors::InvalidQuery` error is used when attempting to call a `FindMatching` command with invalid parameters for the query filter.
462
+
463
+ It has the following properties:
464
+
465
+ - `#errors`: The validation error from the parsing strategy, as an `Array` of error `Hash`es.
466
+ - `#strategy`: The name of the attempted parsing strategy.
467
+
468
+ **Missing Default Contract**
469
+
470
+ A `Cuprum::Collections::Errors::MissingDefaultContract`error is used when attempting to call a validation command without a contract and the collection does not define a default contract.
471
+
472
+ It has the following properties:
473
+
474
+ - `#entity_class`: The class of the entity used in the command.
475
+
476
+ **Not Found**
477
+
478
+ A `Cuprum::Collections::Errors::NotFound` error is used when an entity with the requested primary key does not exist in the collection.
479
+
480
+ - `#collection_name`: The name of the collection used in the command.
481
+ - `#primary_key_name`: The name of the primary key attribute, e.g. `'id'`.
482
+ - `#primary_key_values`: The values of the missing primary keys, e.g. `[1]`.
483
+
484
+ **Unknown Operator**
485
+
486
+ A `Cuprum::Collections::Errors::UnknownOperator` error is used when attempting to perform a filter operation with an operator that is either invalid or not implemented by the collection.
487
+
488
+ It has the following properties:
489
+
490
+ - `#operator`: The name of the unrecognized operator.
491
+
492
+ <a id="queries"></a>
493
+
494
+ ### Queries
495
+
496
+ A `Cuprum::Collections::Query` provides a low-level interface for performing query operations on a collection's data.
497
+
498
+ ```ruby
499
+ collection = Cuprum::Collections::Basic.new(
500
+ collection_name: 'books',
501
+ data: book_data,
502
+ )
503
+ query = collection.query
504
+
505
+ query.class
506
+ #=> Cuprum::Collections::Basic::Query
507
+ query.count
508
+ #=> 10
509
+ query.limit(3).to_a
510
+ #=> [
511
+ # {
512
+ # 'id' => 0,
513
+ # 'title' => 'The Hobbit',
514
+ # 'author' => 'J.R.R. Tolkien',
515
+ # 'series' => nil,
516
+ # 'category' => 'Science Fiction and Fantasy',
517
+ # 'published_at' => '1937-09-21'
518
+ # },
519
+ # {
520
+ # 'id' => 1,
521
+ # 'title' => 'The Silmarillion',
522
+ # 'author' => 'J.R.R. Tolkien',
523
+ # 'series' => nil,
524
+ # 'category' => 'Science Fiction and Fantasy',
525
+ # 'published_at' => '1977-09-15'
526
+ # },
527
+ # {
528
+ # 'id' => 2,
529
+ # 'title' => 'The Fellowship of the Ring',
530
+ # 'author' => 'J.R.R. Tolkien',
531
+ # 'series' => 'The Lord of the Rings',
532
+ # 'category' => 'Science Fiction and Fantasy',
533
+ # 'published_at' => '1954-07-29'
534
+ # }
535
+ # ]
536
+ ```
537
+
538
+ Each collection defines its own `Query` implementation, but the interface should be identical except for the class of the yielded or returned entities.
539
+
540
+ #### Query Methods
541
+
542
+ Every `Cuprum::Collections::Query` implementation defines the following methods.
543
+
544
+ **#count**
545
+
546
+ The `#count` method takes no parameters and returns the number of items in the collection that match the given criteria.
547
+
548
+ ```ruby
549
+ query.count
550
+ #=> 10
551
+ ```
552
+
553
+ **#each**
554
+
555
+ The `#each` method takes a block and yields to the block each item in the collection that matches the given criteria, in the given order.
556
+
557
+ ```ruby
558
+ query.each do |book|
559
+ puts book.title if book.series == 'Earthsea'
560
+ end
561
+ #=> prints "A Wizard of Earthsea", "The Tombs of Atuan", "The Farthest Shore"
562
+ ```
563
+
564
+ **#exists**
565
+
566
+ The `#exists?` method takes no parameters and returns `true` if there are any items in the collection that match the given criteria, or `false` if there are no matching items.
567
+
568
+ ```ruby
569
+ query.exists?
570
+ #=> true
571
+ query.where({ series: 'The Wheel of Time' }).exists?
572
+ #=> false
573
+ ```
574
+
575
+ **#limit**
576
+
577
+ The `#limit` method takes a count of items and returns a copy of the query. The copied query has a limit constraint, and will yield or return up to the requested number of items when called with `#each` or `#to_a`.
578
+
579
+ ```ruby
580
+ query.limit(3).to_a
581
+ #=> [
582
+ # {
583
+ # 'id' => 0,
584
+ # 'title' => 'The Hobbit',
585
+ # 'author' => 'J.R.R. Tolkien',
586
+ # 'series' => nil,
587
+ # 'category' => 'Science Fiction and Fantasy',
588
+ # 'published_at' => '1937-09-21'
589
+ # },
590
+ # {
591
+ # 'id' => 1,
592
+ # 'title' => 'The Silmarillion',
593
+ # 'author' => 'J.R.R. Tolkien',
594
+ # 'series' => nil,
595
+ # 'category' => 'Science Fiction and Fantasy',
596
+ # 'published_at' => '1977-09-15'
597
+ # },
598
+ # {
599
+ # 'id' => 2,
600
+ # 'title' => 'The Fellowship of the Ring',
601
+ # 'author' => 'J.R.R. Tolkien',
602
+ # 'series' => 'The Lord of the Rings',
603
+ # 'category' => 'Science Fiction and Fantasy',
604
+ # 'published_at' => '1954-07-29'
605
+ # }
606
+ # ]
607
+ ```
608
+
609
+ *Note:* Not all collections provide a guarantee of a default ordering - for consistent results using `#limit` and `#offset`, specify an explicit order for the query.
610
+
611
+ **#offset**
612
+
613
+ The `#offset` method takes a count of items and returns a copy of the query. The copied query has an offset constraint, and will skip the requested number of items when called with `#each` or `#to_a`.
614
+
615
+ ```ruby
616
+ query.offset(7)
617
+ #=> [
618
+ # {
619
+ # 'id' => 7,
620
+ # 'title' => 'A Wizard of Earthsea',
621
+ # 'author' => 'Ursula K. LeGuin',
622
+ # 'series' => 'Earthsea',
623
+ # 'category' => 'Science Fiction and Fantasy',
624
+ # 'published_at' => '1968-11-01'
625
+ # },
626
+ # {
627
+ # 'id' => 8,
628
+ # 'title' => 'The Tombs of Atuan',
629
+ # 'author' => 'Ursula K. LeGuin',
630
+ # 'series' => 'Earthsea',
631
+ # 'category' => 'Science Fiction and Fantasy',
632
+ # 'published_at' => '1970-12-01'
633
+ # },
634
+ # {
635
+ # 'id' => 9,
636
+ # 'title' => 'The Farthest Shore',
637
+ # 'author' => 'Ursula K. LeGuin',
638
+ # 'series' => 'Earthsea',
639
+ # 'category' => 'Science Fiction and Fantasy',
640
+ # 'published_at' => '1972-09-01'
641
+ # }
642
+ # ]
643
+ ```
644
+
645
+ *Note:* Not all collections provide a guarantee of a default ordering - for consistent results using `#limit` and `#offset`, specify an explicit order for the query.
646
+
647
+ **#order**
648
+
649
+ The `#order` method takes a valid sort ordering and returns a copy of the query. The copied query uses the specified order, and will yield or return items in that order when called with `#each` or `#to_a`. For details on specifying a sort order, see [Query Ordering](#queries-ordering), below.
650
+
651
+ ```ruby
652
+ query.where(series: 'The Lord of the Rings').order({ title: 'desc' })
653
+ #=> [
654
+ # {
655
+ # 'id' => 3,
656
+ # 'title' => 'The Two Towers',
657
+ # 'author' => 'J.R.R. Tolkien',
658
+ # 'series' => 'The Lord of the Rings',
659
+ # 'category' => 'Science Fiction and Fantasy',
660
+ # 'published_at' => '1954-11-11'
661
+ # },
662
+ # {
663
+ # 'id' => 4,
664
+ # 'title' => 'The Return of the King',
665
+ # 'author' => 'J.R.R. Tolkien',
666
+ # 'series' => 'The Lord of the Rings',
667
+ # 'category' => 'Science Fiction and Fantasy',
668
+ # 'published_at' => '1955-10-20'
669
+ # },
670
+ # {
671
+ # 'id' => 2,
672
+ # 'title' => 'The Fellowship of the Ring',
673
+ # 'author' => 'J.R.R. Tolkien',
674
+ # 'series' => 'The Lord of the Rings',
675
+ # 'category' => 'Science Fiction and Fantasy',
676
+ # 'published_at' => '1954-07-29'
677
+ # }
678
+ # ]
679
+ ```
680
+
681
+ **#reset**
682
+
683
+ The `#reset` method takes no parameters and returns the query. By default, a `Query` will cache the results when calling `#each` or `#to_a`. The `#reset` method clears this cache and forces the query to perform another query on the underlying data.
684
+
685
+ ```ruby
686
+ query.count
687
+ #=> 10
688
+
689
+ book = { id: 10, title: 'Gideon the Ninth', author: 'Tammsyn Muir' }
690
+ collection.insert_one.call(entity: book)
691
+
692
+ query.count
693
+ #=> 10
694
+ query.reset.count
695
+ #=> 11
696
+ ```
697
+
698
+ **#to_a**
699
+
700
+ The `#to_a` method takes no parameters and returns an `Array` containing the itmes in the collection that match the given criteria, in the given order.
701
+
702
+ ```ruby
703
+ query.to_a.map { |book| book['title'] }
704
+ #=> [
705
+ # 'The Hobbit',
706
+ # 'The Silmarillion',
707
+ # 'The Fellowship of the Ring',
708
+ # 'The Two Towers',
709
+ # 'The Return of the King',
710
+ # 'The Word for World is Forest',
711
+ # 'The Ones Who Walk Away From Omelas',
712
+ # 'A Wizard of Earthsea',
713
+ # 'The Tombs of Atuan',
714
+ # 'The Farthest Shore'
715
+ # ]
716
+ ```
717
+
718
+ **#where**
719
+
720
+ The `#where` method takes a Hash argument or a block and returns a copy of the query. The copied query applies the given filters, and will yield or return only items that match the given criteria when called with `#each` or `#to_a`.
721
+
722
+ ```ruby
723
+ query.where(series: 'Earthsea').to_a
724
+ #=> [
725
+ # {
726
+ # 'id' => 7,
727
+ # 'title' => 'A Wizard of Earthsea',
728
+ # 'author' => 'Ursula K. LeGuin',
729
+ # 'series' => 'Earthsea',
730
+ # 'category' => 'Science Fiction and Fantasy',
731
+ # 'published_at' => '1968-11-01'
732
+ # },
733
+ # {
734
+ # 'id' => 8,
735
+ # 'title' => 'The Tombs of Atuan',
736
+ # 'author' => 'Ursula K. LeGuin',
737
+ # 'series' => 'Earthsea',
738
+ # 'category' => 'Science Fiction and Fantasy',
739
+ # 'published_at' => '1970-12-01'
740
+ # },
741
+ # {
742
+ # 'id' => 9,
743
+ # 'title' => 'The Farthest Shore',
744
+ # 'author' => 'Ursula K. LeGuin',
745
+ # 'series' => 'Earthsea',
746
+ # 'category' => 'Science Fiction and Fantasy',
747
+ # 'published_at' => '1972-09-01'
748
+ # }
749
+ # ]
750
+ ```
751
+
752
+ <a id="queries-ordering"></a>
753
+
754
+ #### Query Ordering
755
+
756
+ You can set the sort order of returned or yielded query results by passing a valid ordering to the query. For a `FindMatching` command, pass an `:order` keyword to `#call`. When using a query directly, use the `#order` method.
757
+
758
+ Any of the following is a valid ordering:
759
+
760
+ - `nil`
761
+ - A valid attribute name, e.g. `title` or `:author`
762
+ - An array of valid attribute names, e.g. `['title', 'author']` or `[:series, :publisher]`
763
+ - A hash of valid attribute names and sort directions, e.g. `{ title: :descending }`
764
+ - An array of valid attribute names, with the last item of the array a hash of valid attribute names and sort directions, e.g. `[:author, :series, { published_at: :ascending }]`
765
+
766
+ Internally, the sort order is converted to an ordered `Hash` with attribute name keys and sort direction values. The query results will be sorted by the given attributes in the specified order.
767
+
768
+ For example, a order of `{ author: :asc, title: :descending }` will sort the results by `:author` in ascending order. For each author, the results are then sorted by `:title` in descending order.
769
+
770
+ <a id="queries-filtering"></a>
771
+
772
+ #### Query Filtering
773
+
774
+ You can filter the results returned or yielded by a query by passing a valid criteria object to the query. For a `FindMatching` command, pass a `:where` keyword to `#call`, or use the block form to use the query builder to apply advanced operators. When using a query directly, use the `#where` method.
775
+
776
+ ```ruby
777
+ query = collection.query.where({ author: 'Ursula K. LeGuin' })
778
+ query.count
779
+ #=> 5
780
+ query.each.map(&:author).uniq
781
+ #=> ['Ursula K. LeGuin']
782
+ ```
783
+
784
+ The simplest way to filter results is by passing a `Hash` to `#where`. The keys of the Hash should be the names of the attributes to filter by, and the values the expected value of that attribute. However, passing a Hash directly only supports equality comparisons. To use advanced operators, use the block form:
785
+
786
+ ```ruby
787
+ query = collection.query.where do
788
+ {
789
+ author: 'Ursula K. LeGuin',
790
+ series: equal('Earthsea'),
791
+ published_at: greater_than('1970-01-01')
792
+ }
793
+ end
794
+ query.count
795
+ #=> 2
796
+ query.each.map(&:title)
797
+ #=> [
798
+ # 'The Tombs of Atuan',
799
+ # 'The Farthest Shore'
800
+ # ]
801
+ ```
802
+
803
+ Instead of passing a `Hash` directly, we pass a block to the `#where` method (or `#call` for a command) that *returns* a `Hash`. This allows us to use a Domain-Specific Language to generate our criteria. In the example above, we are using an exact value for the author - this is automatically converted to an `#equal` criterion, just as it is when passing a Hash. We are also using the `#greater_than` operator to filter our results.
804
+
805
+ ##### Operators
806
+
807
+ Each query implementation defines the following operators:
808
+
809
+ **#equal**
810
+
811
+ The `#equal` operator asserts that the attribute value is equal to the expected value.
812
+
813
+ ```ruby
814
+ query = collection.query.where do
815
+ { title: equal('The Hobbit') }
816
+ end
817
+ query.count
818
+ #=> 1
819
+ query.each.map(&:title)
820
+ #=> ['The Hobbit']
821
+ ```
822
+
823
+ **#greater_than**
824
+
825
+ The `#greater_than` operator asserts that the attribute value is strictly greater than the expected value. It is primarily used with numeric or date/time attributes.
826
+
827
+ ```ruby
828
+ query = collection.query.where do
829
+ {
830
+ series: 'The Lord of the Rings',
831
+ published_at: greater_than('1954-11-11')
832
+ }
833
+ end
834
+ query.count
835
+ #=> 1
836
+ query.each.map(&:title)
837
+ #=> ['The Return of the King']
838
+ ```
839
+
840
+ **#greater_than_or_equal_to**
841
+
842
+ The `#greater_than_or_equal_to` operator asserts that the attribute value is greater than or equal to the expected value. It is primarily used with numeric or date/time attributes.
843
+
844
+ ```ruby
845
+ query = collection.query.where do
846
+ {
847
+ series: 'The Lord of the Rings',
848
+ published_at: greater_than_or_equal_to('1954-11-11')
849
+ }
850
+ end
851
+ query.count
852
+ #=> 2
853
+ query.each.map(&:title)
854
+ #=> ['The Two Towers', 'The Return of the King']
855
+ ```
856
+
857
+ **#less_than**
858
+
859
+ The `#less_than` operator asserts that the attribute value is strictly greater than the expected value. It is primarily used with numeric or date/time attributes.
860
+
861
+ ```ruby
862
+ query = collection.query.where do
863
+ {
864
+ series: 'The Lord of the Rings',
865
+ published_at: less_than('1954-11-11')
866
+ }
867
+ end
868
+ query.count
869
+ #=> 1
870
+ query.each.map(&:title)
871
+ #=> ['The Fellowship of the Ring']
872
+ ```
873
+
874
+ **#less_than_or_equal_to**
875
+
876
+ The `#less_than_or_equal_to` operator asserts that the attribute value is strictly greater than the expected value. It is primarily used with numeric or date/time attributes.
877
+
878
+ ```ruby
879
+ query = collection.query.where do
880
+ {
881
+ series: 'The Lord of the Rings',
882
+ published_at: less_than_or_equal_to('1954-11-11')
883
+ }
884
+ end
885
+ query.count
886
+ #=> 2
887
+ query.each.map(&:title)
888
+ #=> ['The Fellowship of the Ring', 'The Two Towers']
889
+ ```
890
+
891
+ **#not_equal**
892
+
893
+ The `#not_equal` operator asserts that the attribute value is not equal to the expected value. It is the inverse of the `#equal` operator.
894
+
895
+ ```ruby
896
+ query = collection.query.where do
897
+ {
898
+ author: 'J.R.R. Tolkien',
899
+ series: not_equal('The Lord of the Rings')
900
+ }
901
+ end
902
+ query.count
903
+ #=> 2
904
+ query.each.map(&:title)
905
+ #=> ['The Hobbit', 'The Silmarillion']
906
+ ```
907
+
908
+ **#not_one_of**
909
+
910
+ The `#one_of` operator asserts that the attribute value is not equal to any of the expected values. It is the inverse of the `#one_of` operator.
911
+
912
+ ```ruby
913
+ query = collection.query.where do
914
+ {
915
+ series: not_one_of(['Earthsea', 'The Lord of the Rings'])
916
+ }
917
+ end
918
+ query.count
919
+ #=> 4
920
+ query.each.map(&:title)
921
+ #=> [
922
+ # 'The Hobbit',
923
+ # 'The Silmarillion',
924
+ # 'The Word for World is Forest',
925
+ # 'The Ones Who Walk Away From Omelas'
926
+ # ]
927
+ ```
928
+
929
+ **#one_of**
930
+
931
+ The `#one_of` operator asserts that the attribute value is equal to one of the expected values.
932
+
933
+ ```ruby
934
+ query = collection.query.where do
935
+ {
936
+ series: one_of(['Earthsea', 'The Lord of the Rings'])
937
+ }
938
+ end
939
+ query.count
940
+ #=> 6
941
+ query.each.map(&:title)
942
+ #=> [
943
+ # 'The Fellowship of the Ring',
944
+ # 'The Two Towers',
945
+ # 'The Return of the King',
946
+ # 'A Wizard of Earthsea',
947
+ # 'The Tombs of Atuan',
948
+ # 'The Farthest Shore'
949
+ # ]
950
+ ```