mongoid-rspec 4.2.0 → 4.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.
- checksums.yaml +4 -4
- data/README.md +132 -3
- data/Rakefile +4 -2
- data/lib/matchers/accept_nested_attributes.rb +2 -0
- data/lib/matchers/allow_mass_assignment.rb +2 -0
- data/lib/matchers/associations.rb +27 -9
- data/lib/matchers/be_covered_query.rb +75 -0
- data/lib/matchers/be_dynamic_document.rb +2 -0
- data/lib/matchers/be_mongoid_document.rb +2 -0
- data/lib/matchers/be_stored_in.rb +2 -0
- data/lib/matchers/execute_mongodb_command.rb +127 -0
- data/lib/matchers/have_field.rb +15 -3
- data/lib/matchers/have_timestamps.rb +5 -3
- data/lib/matchers/indexes/have_index_for.rb +2 -0
- data/lib/matchers/indexes/v3/have_index_for.rb +3 -1
- data/lib/matchers/indexes/v4/have_index_for.rb +3 -1
- data/lib/matchers/validations/absence_of.rb +2 -0
- data/lib/matchers/validations/acceptance_of.rb +2 -0
- data/lib/matchers/validations/associated.rb +2 -0
- data/lib/matchers/validations/confirmation_of.rb +2 -0
- data/lib/matchers/validations/custom_validation_of.rb +14 -12
- data/lib/matchers/validations/exclusion_of.rb +15 -11
- data/lib/matchers/validations/format_of.rb +19 -15
- data/lib/matchers/validations/inclusion_of.rb +13 -9
- data/lib/matchers/validations/length_of.rb +77 -36
- data/lib/matchers/validations/numericality_of.rb +13 -11
- data/lib/matchers/validations/presence_of.rb +2 -0
- data/lib/matchers/validations/uniqueness_of.rb +19 -17
- data/lib/matchers/validations.rb +41 -22
- data/lib/mongoid/rspec/version.rb +3 -1
- data/lib/mongoid/rspec.rb +4 -0
- data/lib/mongoid-rspec.rb +2 -0
- metadata +10 -8
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2ac3e47b3e606516f4ca239dad1405872db4cd012443a11a9e3c332d47555c27
|
|
4
|
+
data.tar.gz: 8b4d8baff373f7b2c6bc7e194a5a21192c08bdfda17e40693e300f9fd48a111b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 51157b3c68734d119c2880e7d071f52f244322bf517bfd8cab697d0e2f2e0662877b455acf704923dbed3f24034ab341c4c84ad6a9443230370052f805c6db0e
|
|
7
|
+
data.tar.gz: 0ed7c718a2862d4a1bb5abfd03e430dff67b1589612abbf50b2f449dd0584ecaff99b42b8e204e6281eab65f1b404b1bbf710da65550bd4fb369ef157395b241
|
data/README.md
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
# [mongoid-rspec](https://github.com/mongoid/mongoid-rspec "A collection of RSpec-compatible matchers that help to test Mongoid documents.")
|
|
2
2
|
|
|
3
3
|
[](https://badge.fury.io/rb/mongoid-rspec)
|
|
4
|
-
[](https://github.com/mongoid/mongoid-rspec/actions/workflows/rspec.yml?query=branch%3Amaster)
|
|
5
|
+
[](https://github.com/mongoid/mongoid-rspec/actions/workflows/rubocop.yml?query=branch%3Amaster)
|
|
6
|
+
[](https://coveralls.io/github/mongoid/mongoid-rspec?branch=master)
|
|
7
|
+
|
|
8
|
+
CI collects SimpleCov coverage on Ruby 3.4 with Mongoid 9 and MongoDB 8.0 and submits one report to Coveralls using GitHub's workflow token. To generate a local report, run `COVERAGE=true BUNDLE_GEMFILE=gemfiles/mongoid_9.gemfile bundle install` followed by `COVERAGE=true BUNDLE_GEMFILE=gemfiles/mongoid_9.gemfile bundle exec rake spec`. Reports are written to `coverage/`.
|
|
6
9
|
|
|
7
10
|
The mongoid-rspec library provides a collection of RSpec-compatible matchers that help to test Mongoid documents.
|
|
8
11
|
|
|
9
|
-
[Tested](https://github.com/mongoid/mongoid-
|
|
12
|
+
[Tested](https://github.com/mongoid/mongoid-rspec/actions/workflows/rspec.yml) against:
|
|
10
13
|
- MRI: `2.6.x`, `2.7.x`, `3.0.x`, `3.1.x`, `3.2.x`
|
|
11
14
|
- Mongoid: `4`, `5`, `6`, `7`, `8`, `9`
|
|
12
15
|
|
|
@@ -41,6 +44,33 @@ RSpec.configure do |config|
|
|
|
41
44
|
end
|
|
42
45
|
```
|
|
43
46
|
|
|
47
|
+
### Using Mongoid and ActiveRecord with shoulda-matchers
|
|
48
|
+
|
|
49
|
+
Both libraries define matcher names such as `validate_length_of` and `belong_to`, but their chain methods and implementations differ. In an application with both Mongoid and ActiveRecord models, include Mongoid matchers only for a custom `:mongoid_model` type instead of all `:model` examples. Keep Shoulda's ActiveModel and ActiveRecord matchers scoped to `:model`:
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
require 'mongoid-rspec'
|
|
53
|
+
require 'shoulda/matchers'
|
|
54
|
+
|
|
55
|
+
RSpec.configure do |config|
|
|
56
|
+
config.include Mongoid::Matchers, type: :mongoid_model
|
|
57
|
+
config.include Shoulda::Matchers::ActiveModel, type: :model
|
|
58
|
+
config.include Shoulda::Matchers::ActiveRecord, type: :model
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
RSpec.describe MongoidAddress, type: :mongoid_model do
|
|
62
|
+
it { is_expected.to validate_length_of(:name).with_maximum(255) }
|
|
63
|
+
it { is_expected.to belong_to(:city).as_inverse_of(:addresses) }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
RSpec.describe ActiveRecordAddress, type: :model do
|
|
67
|
+
it { is_expected.to validate_length_of(:name).is_at_most(255) }
|
|
68
|
+
it { is_expected.to belong_to(:city).inverse_of(:addresses) }
|
|
69
|
+
end
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Replace the existing unscoped or `type: :model` inclusion of `Mongoid::Matchers`; do not keep it alongside the scoped configuration. Explicitly tag Mongoid example groups with `type: :mongoid_model`, including those under `spec/models`, where rspec-rails would otherwise infer `:model`. Do not include Shoulda's model matcher modules globally or directly in those Mongoid groups. This avoids depending on module inclusion order and preserves each library's existing matcher API.
|
|
73
|
+
|
|
44
74
|
### Other
|
|
45
75
|
|
|
46
76
|
Add to your `spec_helper.rb` file.
|
|
@@ -204,8 +234,74 @@ RSpec.describe Article, type: :model do
|
|
|
204
234
|
end
|
|
205
235
|
```
|
|
206
236
|
|
|
237
|
+
### execute_mongodb_command
|
|
238
|
+
|
|
239
|
+
Use `execute_mongodb_command` with a block expectation to verify that code issues a MongoDB command. Without an argument, the matcher passes when any command is issued on the block's thread. An expected command matches the entire application command document exactly, not just a subset or query selector; nested String and Symbol keys are normalized to Strings. Driver-added top-level `$db`, `lsid`, and `$clusterTime` fields are excluded from comparison, while all other command fields and values must match. The matcher ignores unrelated background-thread activity and raises `NotImplementedError` when the driver does not expose command monitoring, including the legacy driver used with Mongoid 4.
|
|
240
|
+
|
|
241
|
+
```ruby
|
|
242
|
+
RSpec.describe FooService do
|
|
243
|
+
it 'uses the cached result without querying MongoDB' do
|
|
244
|
+
expect { FooService.recent_foos }.not_to execute_mongodb_command
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
RSpec.describe Article do
|
|
249
|
+
it 'executes the expected MongoDB command' do
|
|
250
|
+
expected_command = {
|
|
251
|
+
find: 'articles',
|
|
252
|
+
filter: { 'title.en' => 'cached' },
|
|
253
|
+
limit: 10,
|
|
254
|
+
sort: { created_at: -1 }
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
expect do
|
|
258
|
+
Article.where(title: 'cached').desc(:created_at).limit(10).to_a
|
|
259
|
+
end.to execute_mongodb_command(expected_command)
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
### be_covered_query
|
|
265
|
+
|
|
266
|
+
Check that MongoDB's winning plan uses an index without fetching documents:
|
|
267
|
+
|
|
268
|
+
```ruby
|
|
269
|
+
class Article
|
|
270
|
+
index({ title: 1, _id: 1 })
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
RSpec.describe Article do
|
|
274
|
+
it { expect(Article.where(title: 'Example').only(:title)).to be_covered_query }
|
|
275
|
+
end
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
Mongoid includes `_id` in an inclusive projection by default, so the index must cover it as well. The matcher examines all stages in the winning plan, including nested shard plans, and rejects plans containing `FETCH` or `COLLSCAN`. A covered plan does not guarantee that a query is fast; this matcher checks plan coverage, not execution time.
|
|
279
|
+
|
|
280
|
+
Use `.using_index('title_1__id_1')` or `.using_indexes('first_index', 'second_index')` to additionally require the exact set of index names used by the winning plan. Index names accept nonempty strings or symbols; order and repeated uses across shards do not matter. Rejected plans are ignored, and the query must still be covered.
|
|
281
|
+
|
|
282
|
+
```ruby
|
|
283
|
+
expect(Article.where(title: 'Example').only(:title)).to be_covered_query.using_index('title_1__id_1')
|
|
284
|
+
```
|
|
285
|
+
|
|
207
286
|
### Field Matchers
|
|
208
287
|
|
|
288
|
+
Chain `.readonly` to check fields declared with Mongoid's `attr_readonly`. This checks the declaration, not whether a new document currently allows writes. Like other field matcher options, it applies to every requested field and accepts class or instance subjects.
|
|
289
|
+
|
|
290
|
+
```ruby
|
|
291
|
+
class Article
|
|
292
|
+
include Mongoid::Document
|
|
293
|
+
|
|
294
|
+
field :slug, type: String
|
|
295
|
+
field :external_id, type: String
|
|
296
|
+
attr_readonly :slug, :external_id
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
RSpec.describe Article do
|
|
300
|
+
it { is_expected.to have_field(:slug).of_type(String).readonly }
|
|
301
|
+
it { is_expected.to have_fields(:slug, :external_id).readonly }
|
|
302
|
+
end
|
|
303
|
+
```
|
|
304
|
+
|
|
209
305
|
```ruby
|
|
210
306
|
RSpec.describe Article do
|
|
211
307
|
it { is_expected.to have_field(:published).of_type(Mongoid::Boolean).with_default_value_of(false) }
|
|
@@ -223,6 +319,31 @@ end
|
|
|
223
319
|
|
|
224
320
|
### Association Matchers
|
|
225
321
|
|
|
322
|
+
Use `with_primary_key` to check an association's primary key, including the default `_id`. Like `with_foreign_key`, it accepts a symbol or string and can be chained with other association options:
|
|
323
|
+
|
|
324
|
+
```ruby
|
|
325
|
+
class Author
|
|
326
|
+
include Mongoid::Document
|
|
327
|
+
|
|
328
|
+
field :code, type: String
|
|
329
|
+
has_many :posts, primary_key: :code, foreign_key: :author_code
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
class Post
|
|
333
|
+
include Mongoid::Document
|
|
334
|
+
|
|
335
|
+
belongs_to :author, primary_key: :code, foreign_key: :author_code
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
RSpec.describe Author do
|
|
339
|
+
it { is_expected.to have_many(:posts).with_primary_key(:code).with_foreign_key(:author_code) }
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
RSpec.describe Post do
|
|
343
|
+
it { is_expected.to belong_to(:author).with_primary_key('code') }
|
|
344
|
+
end
|
|
345
|
+
```
|
|
346
|
+
|
|
226
347
|
```ruby
|
|
227
348
|
RSpec.describe User do
|
|
228
349
|
it { is_expected.to have_many(:articles).with_foreign_key(:author_id).ordered_by(:title) }
|
|
@@ -244,6 +365,8 @@ RSpec.describe User do
|
|
|
244
365
|
|
|
245
366
|
it { is_expected.to have_and_belong_to_many(:children) }
|
|
246
367
|
it { is_expected.to have_and_belong_to_many(:children).of_type(User) }
|
|
368
|
+
# nonconflicting alternative when using shoulda-matchers
|
|
369
|
+
it { is_expected.to have_and_belong_to_many_related(:children).of_type(User) }
|
|
247
370
|
end
|
|
248
371
|
|
|
249
372
|
RSpec.describe Profile do
|
|
@@ -297,6 +420,10 @@ end
|
|
|
297
420
|
|
|
298
421
|
### Validation Matchers
|
|
299
422
|
|
|
423
|
+
`validate_length_of` searches all applicable length validators for one matching the requested options. For example, a maximum-length validator added by `has_secure_password` does not hide a separately declared minimum-length validator. Chained length, message, and `to_allow` options must match a single validator; options from separate validators are not combined.
|
|
424
|
+
|
|
425
|
+
Use `validate_length_of(:name).within(1..100).to_allow(nil: true)` to check `allow_nil: true`, or `.to_allow(blank: true)` to check `allow_blank: true`. Both options can be combined, and the full keys `allow_nil` and `allow_blank` are also accepted. Values must be `true` or `false`; `false` requires an explicitly configured `false` (an omitted option does not match). Omitting `to_allow` leaves these options unchecked. Empty hashes, unknown keys, non-boolean values, and duplicate aliases are rejected with `ArgumentError`.
|
|
426
|
+
|
|
300
427
|
```ruby
|
|
301
428
|
RSpec.describe Site do
|
|
302
429
|
it { is_expected.to validate_presence_of(:name) }
|
|
@@ -393,6 +520,8 @@ end
|
|
|
393
520
|
|
|
394
521
|
You're encouraged to contribute to this library. See [CONTRIBUTING](CONTRIBUTING.md) for details.
|
|
395
522
|
|
|
523
|
+
`bundle exec rake` runs the unit suite without third-party integration dependencies. Each integration has its own Appraisal bundle and directory under `spec/integration/`. For example, run `BUNDLE_GEMFILE=gemfiles/shoulda_matchers.gemfile bundle install`, then `BUNDLE_GEMFILE=gemfiles/shoulda_matchers.gemfile bundle exec rspec spec/integration/shoulda_matchers`. To add an integration, define its dependencies in Appraisals, generate its gemfile, add its specs under a matching directory, and add its name to the integration matrix in `.github/workflows/rspec.yml`. CI runs each listed integration in a separate process and bundle.
|
|
524
|
+
|
|
396
525
|
## Copyright and License
|
|
397
526
|
|
|
398
527
|
Copyright (c) 2009-2018 Evan Sagge and Contributors.
|
data/Rakefile
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
$LOAD_PATH.push File.expand_path('../lib', __FILE__)
|
|
2
4
|
|
|
3
5
|
require 'bundler'
|
|
@@ -6,11 +8,11 @@ Bundler::GemHelper.install_tasks
|
|
|
6
8
|
require 'rspec/core/rake_task'
|
|
7
9
|
|
|
8
10
|
RSpec::Core::RakeTask.new(:spec) do |spec|
|
|
9
|
-
spec.pattern = './spec/**/*_spec.rb'
|
|
11
|
+
spec.pattern = './spec/unit/**/*_spec.rb'
|
|
10
12
|
end
|
|
11
13
|
|
|
12
14
|
RSpec::Core::RakeTask.new(:rcov) do |spec|
|
|
13
|
-
spec.pattern = './spec/**/*_spec.rb'
|
|
15
|
+
spec.pattern = './spec/unit/**/*_spec.rb'
|
|
14
16
|
spec.rcov = true
|
|
15
17
|
end
|
|
16
18
|
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
if Mongoid::Compatibility::Version.mongoid7_or_newer?
|
|
2
4
|
require 'mongoid/association'
|
|
3
5
|
else
|
|
@@ -30,7 +32,7 @@ module Mongoid
|
|
|
30
32
|
@association = {}
|
|
31
33
|
@association[:name] = name.to_s
|
|
32
34
|
@association[:type] = association_type
|
|
33
|
-
@expectation_message = "#{type_description} #{@association[:name].inspect}"
|
|
35
|
+
@expectation_message = +"#{type_description} #{@association[:name].inspect}"
|
|
34
36
|
@expectation_message << " of type #{@association[:class].inspect}" unless @association[:class].nil?
|
|
35
37
|
end
|
|
36
38
|
|
|
@@ -113,6 +115,12 @@ module Mongoid
|
|
|
113
115
|
self
|
|
114
116
|
end
|
|
115
117
|
|
|
118
|
+
def with_primary_key(primary_key)
|
|
119
|
+
@association[:primary_key] = primary_key.to_s
|
|
120
|
+
@expectation_message << " using primary key #{@association[:primary_key].inspect}"
|
|
121
|
+
self
|
|
122
|
+
end
|
|
123
|
+
|
|
116
124
|
def with_counter_cache
|
|
117
125
|
@association[:counter_cache] = true
|
|
118
126
|
@expectation_message << " which specifies counter_cache as #{@association[:counter_cache]}"
|
|
@@ -269,6 +277,15 @@ module Mongoid
|
|
|
269
277
|
end
|
|
270
278
|
end
|
|
271
279
|
|
|
280
|
+
if @association[:primary_key]
|
|
281
|
+
if metadata.primary_key != @association[:primary_key]
|
|
282
|
+
@negative_result_message = "#{@positive_result_message} with primary key #{metadata.primary_key.inspect}"
|
|
283
|
+
return false
|
|
284
|
+
else
|
|
285
|
+
@positive_result_message = "#{@positive_result_message} with primary key #{metadata.primary_key.inspect}"
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
|
|
272
289
|
if @association[:counter_cache]
|
|
273
290
|
if metadata.counter_cached? != true
|
|
274
291
|
@negative_result_message = "#{@positive_result_message} which did not set counter_cache"
|
|
@@ -318,19 +335,19 @@ module Mongoid
|
|
|
318
335
|
type ||= @association[:type]
|
|
319
336
|
case type.name
|
|
320
337
|
when EMBEDS_ONE.name
|
|
321
|
-
(passive ? 'embed' : 'embeds') << ' one'
|
|
338
|
+
+(passive ? 'embed' : 'embeds') << ' one'
|
|
322
339
|
when EMBEDS_MANY.name
|
|
323
|
-
(passive ? 'embed' : 'embeds') << ' many'
|
|
340
|
+
+(passive ? 'embed' : 'embeds') << ' many'
|
|
324
341
|
when EMBEDDED_IN.name
|
|
325
|
-
(passive ? 'be' : 'is') << ' embedded in'
|
|
342
|
+
+(passive ? 'be' : 'is') << ' embedded in'
|
|
326
343
|
when HAS_ONE.name
|
|
327
|
-
(passive ? 'reference' : 'references') << ' one'
|
|
344
|
+
+(passive ? 'reference' : 'references') << ' one'
|
|
328
345
|
when HAS_MANY.name
|
|
329
|
-
(passive ? 'reference' : 'references') << ' many'
|
|
346
|
+
+(passive ? 'reference' : 'references') << ' many'
|
|
330
347
|
when HAS_AND_BELONGS_TO_MANY.name
|
|
331
|
-
(passive ? 'reference' : 'references') << ' and referenced in many'
|
|
348
|
+
+(passive ? 'reference' : 'references') << ' and referenced in many'
|
|
332
349
|
when BELONGS_TO.name
|
|
333
|
-
(passive ? 'be referenced in' : 'referenced in')
|
|
350
|
+
+(passive ? 'be referenced in' : 'referenced in')
|
|
334
351
|
else
|
|
335
352
|
raise format("Unknown association type '%s'", type)
|
|
336
353
|
end
|
|
@@ -369,9 +386,10 @@ module Mongoid
|
|
|
369
386
|
end
|
|
370
387
|
alias have_many have_many_related
|
|
371
388
|
|
|
372
|
-
def
|
|
389
|
+
def have_and_belong_to_many_related(association_name)
|
|
373
390
|
HaveAssociationMatcher.new(association_name, HAS_AND_BELONGS_TO_MANY)
|
|
374
391
|
end
|
|
392
|
+
alias have_and_belong_to_many have_and_belong_to_many_related
|
|
375
393
|
|
|
376
394
|
def belong_to_related(association_name)
|
|
377
395
|
HaveAssociationMatcher.new(association_name, BELONGS_TO)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mongoid
|
|
4
|
+
module Matchers
|
|
5
|
+
def be_covered_query
|
|
6
|
+
BeCoveredQuery.new
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
class BeCoveredQuery
|
|
10
|
+
PLAN_CHILD_KEYS = %w[inputStage inputStages outerStage innerStage queryPlan winningPlan shards].freeze
|
|
11
|
+
|
|
12
|
+
def using_index(name)
|
|
13
|
+
using_indexes(name)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def using_indexes(*names)
|
|
17
|
+
raise ArgumentError, 'index names must be nonempty strings or symbols' unless names.any? && names.all? { |name| (name.is_a?(String) || name.is_a?(Symbol)) && !name.to_s.empty? }
|
|
18
|
+
|
|
19
|
+
@expected_indexes = names.map(&:to_s).uniq.sort
|
|
20
|
+
self
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def matches?(actual)
|
|
24
|
+
@stages = []
|
|
25
|
+
@indexes = []
|
|
26
|
+
return false unless actual.respond_to?(:explain)
|
|
27
|
+
|
|
28
|
+
explain = actual.explain
|
|
29
|
+
planner = value_for(explain, :queryPlanner)
|
|
30
|
+
winning_plan = value_for(planner, :winningPlan)
|
|
31
|
+
return false unless winning_plan
|
|
32
|
+
|
|
33
|
+
collect_plan(winning_plan)
|
|
34
|
+
covered = @stages.include?('IXSCAN') && (@stages & %w[FETCH COLLSCAN]).empty?
|
|
35
|
+
covered && (!@expected_indexes || ((@indexes.uniq - @expected_indexes).empty? && (@expected_indexes - @indexes).empty?))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def description
|
|
39
|
+
desc = +'be a covered query'
|
|
40
|
+
desc << " using indexes #{@expected_indexes.inspect}" if @expected_indexes
|
|
41
|
+
desc
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def failure_message
|
|
45
|
+
detail = @stages.empty? ? 'no winning plan was found' : "winning plan stages were #{@stages.inspect}"
|
|
46
|
+
detail += " and indexes were #{@indexes.uniq.inspect}" if @expected_indexes && @stages.any?
|
|
47
|
+
"Expected query to #{description}, but #{detail}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def failure_message_when_negated
|
|
51
|
+
"Expected query not to #{description}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def value_for(hash, key)
|
|
57
|
+
return unless hash.respond_to?(:[])
|
|
58
|
+
|
|
59
|
+
hash[key.to_s] || hash[key.to_sym]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def collect_plan(plan)
|
|
63
|
+
case plan
|
|
64
|
+
when Hash
|
|
65
|
+
stage = value_for(plan, :stage)
|
|
66
|
+
@stages << stage.to_s if stage
|
|
67
|
+
@indexes << value_for(plan, :indexName) if stage.to_s == 'IXSCAN'
|
|
68
|
+
PLAN_CHILD_KEYS.each { |key| collect_plan(value_for(plan, key)) }
|
|
69
|
+
when Array
|
|
70
|
+
plan.each { |value| collect_plan(value) }
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mongoid
|
|
4
|
+
module Matchers
|
|
5
|
+
def execute_mongodb_command(*expected_commands)
|
|
6
|
+
raise ArgumentError, 'execute_mongodb_command accepts at most one command' if expected_commands.length > 1
|
|
7
|
+
|
|
8
|
+
if expected_commands.empty?
|
|
9
|
+
ExecuteMongodbCommand.new
|
|
10
|
+
else
|
|
11
|
+
ExecuteMongodbCommand.new(expected_commands.first)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
class ExecuteMongodbCommand
|
|
16
|
+
DRIVER_METADATA_KEYS = %w[$db lsid $clusterTime].freeze
|
|
17
|
+
UNSPECIFIED = Object.new.freeze
|
|
18
|
+
|
|
19
|
+
def initialize(expected_command = UNSPECIFIED)
|
|
20
|
+
@expected_command = expected_command.equal?(UNSPECIFIED) ? nil : normalize_command(expected_command)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def supports_block_expectations?
|
|
24
|
+
true
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def matches?(block)
|
|
28
|
+
raise ArgumentError, 'execute_mongodb_command expects a block' unless block
|
|
29
|
+
raise NotImplementedError, 'execute_mongodb_command requires MongoDB driver command monitoring' unless
|
|
30
|
+
defined?(Mongo::Monitoring::Global) && defined?(Mongo::Monitoring::COMMAND)
|
|
31
|
+
raise NotImplementedError, 'execute_mongodb_command requires the Mongoid client registry' unless
|
|
32
|
+
defined?(Mongoid::Clients) && Mongoid::Clients.respond_to?(:clients)
|
|
33
|
+
|
|
34
|
+
@command_executed = false
|
|
35
|
+
topic = Mongo::Monitoring::COMMAND
|
|
36
|
+
global_monitor = Mongo::Monitoring::Global
|
|
37
|
+
subscriber = CommandSubscriber.new(Thread.current, @expected_command, method(:normalize_command)) do
|
|
38
|
+
@command_executed = true
|
|
39
|
+
end
|
|
40
|
+
clients = mongoid_clients
|
|
41
|
+
subscribed = false
|
|
42
|
+
|
|
43
|
+
begin
|
|
44
|
+
clients.each { |client| client.subscribe(topic, subscriber) }
|
|
45
|
+
global_monitor.subscribe(topic, subscriber)
|
|
46
|
+
subscribed = true
|
|
47
|
+
block.call
|
|
48
|
+
ensure
|
|
49
|
+
current_clients = mongoid_clients
|
|
50
|
+
global_monitor.unsubscribe(topic, subscriber) if subscribed
|
|
51
|
+
(clients + current_clients).uniq.each { |client| client.unsubscribe(topic, subscriber) }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
@command_executed
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def failure_message
|
|
58
|
+
message = +'Expected block to execute a MongoDB command'
|
|
59
|
+
message << " matching #{@expected_command.inspect}" if @expected_command
|
|
60
|
+
message
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def failure_message_when_negated
|
|
64
|
+
message = +'Expected block not to execute a MongoDB command'
|
|
65
|
+
message << " matching #{@expected_command.inspect}" if @expected_command
|
|
66
|
+
message
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def normalize_command(command)
|
|
72
|
+
raise ArgumentError, 'expected MongoDB command must be a non-empty Hash' unless command.is_a?(Hash) && !command.empty?
|
|
73
|
+
|
|
74
|
+
normalize_document(command, top_level: true)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def normalize_document(document, top_level: false)
|
|
78
|
+
document.each_with_object({}) do |(key, value), normalized|
|
|
79
|
+
normalized_key = normalize_key(key)
|
|
80
|
+
next if top_level && DRIVER_METADATA_KEYS.include?(normalized_key)
|
|
81
|
+
raise ArgumentError, "duplicate MongoDB command key after normalization: #{normalized_key.inspect}" if normalized.key?(normalized_key)
|
|
82
|
+
|
|
83
|
+
normalized[normalized_key] = normalize_value(value)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def normalize_key(key)
|
|
88
|
+
return key if key.is_a?(String)
|
|
89
|
+
return key.to_s if key.is_a?(Symbol)
|
|
90
|
+
|
|
91
|
+
raise ArgumentError, "MongoDB command keys must be Strings or Symbols, got #{key.class}"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def normalize_value(value)
|
|
95
|
+
case value
|
|
96
|
+
when Hash then normalize_document(value)
|
|
97
|
+
when Array then value.map { |item| normalize_value(item) }
|
|
98
|
+
else value
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def mongoid_clients
|
|
103
|
+
Mongoid::Clients.clients.values
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
class CommandSubscriber
|
|
107
|
+
def initialize(thread, expected_command, normalizer, &on_command)
|
|
108
|
+
@thread = thread
|
|
109
|
+
@expected_command = expected_command
|
|
110
|
+
@normalizer = normalizer
|
|
111
|
+
@on_command = on_command
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def started(event)
|
|
115
|
+
return unless Thread.current.equal?(@thread)
|
|
116
|
+
|
|
117
|
+
command = @normalizer.call(event.command)
|
|
118
|
+
@on_command.call if @expected_command.nil? || command == @expected_command
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def succeeded(_event); end
|
|
122
|
+
|
|
123
|
+
def failed(_event); end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
data/lib/matchers/have_field.rb
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module Mongoid
|
|
2
4
|
module Matchers
|
|
3
5
|
class HaveField # :nodoc:
|
|
@@ -10,6 +12,11 @@ module Mongoid
|
|
|
10
12
|
self
|
|
11
13
|
end
|
|
12
14
|
|
|
15
|
+
def readonly
|
|
16
|
+
@readonly = true
|
|
17
|
+
self
|
|
18
|
+
end
|
|
19
|
+
|
|
13
20
|
def of_type(type)
|
|
14
21
|
@type = type
|
|
15
22
|
self
|
|
@@ -30,7 +37,7 @@ module Mongoid
|
|
|
30
37
|
@errors = []
|
|
31
38
|
@attributes.each do |attr|
|
|
32
39
|
if @klass.fields.include?(attr)
|
|
33
|
-
error = ''
|
|
40
|
+
error = +''
|
|
34
41
|
if @type && (@klass.fields[attr].type != @type)
|
|
35
42
|
error << " of type #{@klass.fields[attr].type}"
|
|
36
43
|
end
|
|
@@ -47,7 +54,11 @@ module Mongoid
|
|
|
47
54
|
error << " with alias #{@klass.fields[attr].options[:as]}"
|
|
48
55
|
end
|
|
49
56
|
|
|
50
|
-
@
|
|
57
|
+
if @readonly && !@klass.readonly_attributes.include?(attr)
|
|
58
|
+
error << ' that is not readonly'
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
@errors.push("field #{attr.inspect}#{error}") unless error.blank?
|
|
51
62
|
|
|
52
63
|
if @localized
|
|
53
64
|
unless @klass.fields[attr].localized?
|
|
@@ -74,10 +85,11 @@ module Mongoid
|
|
|
74
85
|
alias failure_message_when_negated failure_message_for_should_not
|
|
75
86
|
|
|
76
87
|
def description
|
|
77
|
-
desc = "have #{@attributes.size > 1 ? 'fields' : 'field'} named #{@attributes.collect(&:inspect).to_sentence}"
|
|
88
|
+
desc = +"have #{@attributes.size > 1 ? 'fields' : 'field'} named #{@attributes.collect(&:inspect).to_sentence}"
|
|
78
89
|
desc << " of type #{@type.inspect}" if @type
|
|
79
90
|
desc << " with alias #{@field_alias}" if @field_alias
|
|
80
91
|
desc << " with default value of #{@default.inspect}" unless @default.nil?
|
|
92
|
+
desc << ' marked readonly' if @readonly
|
|
81
93
|
desc
|
|
82
94
|
end
|
|
83
95
|
end
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module Mongoid
|
|
2
4
|
module Matchers
|
|
3
5
|
def have_timestamps
|
|
@@ -6,7 +8,7 @@ module Mongoid
|
|
|
6
8
|
|
|
7
9
|
class HaveTimestamps
|
|
8
10
|
def initialize
|
|
9
|
-
@root_module = 'Mongoid::Timestamps'
|
|
11
|
+
@root_module = +'Mongoid::Timestamps'
|
|
10
12
|
end
|
|
11
13
|
|
|
12
14
|
def matches?(actual)
|
|
@@ -15,7 +17,7 @@ module Mongoid
|
|
|
15
17
|
end
|
|
16
18
|
|
|
17
19
|
def for(phase)
|
|
18
|
-
raise('You\'ve already declared
|
|
20
|
+
raise('You\'ve already declared timestamp\'s sub-module via "for" clause') if @submodule
|
|
19
21
|
|
|
20
22
|
case @phase = phase.to_sym
|
|
21
23
|
when :creating then @submodule = 'Created'
|
|
@@ -33,7 +35,7 @@ module Mongoid
|
|
|
33
35
|
end
|
|
34
36
|
|
|
35
37
|
def description
|
|
36
|
-
desc = 'be a Mongoid document with'
|
|
38
|
+
desc = +'be a Mongoid document with'
|
|
37
39
|
desc << ' shorted' if @shortened
|
|
38
40
|
desc << " #{@phase}" if @phase
|
|
39
41
|
desc << ' timestamps'
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module Mongoid
|
|
2
4
|
module Matchers
|
|
3
5
|
def have_index_for(index_key)
|
|
@@ -36,7 +38,7 @@ module Mongoid
|
|
|
36
38
|
alias failure_message_when_negated failure_message_for_should_not
|
|
37
39
|
|
|
38
40
|
def description
|
|
39
|
-
desc = "have an index for #{index_key.inspect}"
|
|
41
|
+
desc = +"have an index for #{index_key.inspect}"
|
|
40
42
|
desc << " with options of #{index_options.inspect}" if index_options
|
|
41
43
|
desc
|
|
42
44
|
end
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module Mongoid
|
|
2
4
|
module Matchers
|
|
3
5
|
def have_index_for(index_key)
|
|
@@ -15,7 +17,7 @@ module Mongoid
|
|
|
15
17
|
end
|
|
16
18
|
|
|
17
19
|
def failure_message
|
|
18
|
-
message = "Expected #{model.inspect} to #{description},"
|
|
20
|
+
message = +"Expected #{model.inspect} to #{description},"
|
|
19
21
|
message << if actual_index.nil?
|
|
20
22
|
' found no index'
|
|
21
23
|
else
|