mongoid-rspec 4.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0ff9e91940ac839c482f01b93f825a4f30ed64dcf3f4210053a5cac6a490cbbc
4
- data.tar.gz: 73b94988e7950e2097427412e1c4af87a1413cf891085a53a71924bbae9466ca
3
+ metadata.gz: 2ac3e47b3e606516f4ca239dad1405872db4cd012443a11a9e3c332d47555c27
4
+ data.tar.gz: 8b4d8baff373f7b2c6bc7e194a5a21192c08bdfda17e40693e300f9fd48a111b
5
5
  SHA512:
6
- metadata.gz: 14504bfe14678c3c4df1eb85438438ab5b705f22ad36bc209bfd15d1bbeb4cc59ab09c0e8227682f8960f8db6cadc7c8731e8ab617d7f1d075e98f06a1085bdc
7
- data.tar.gz: 4ae265834c188a9236e6c697345f70c5e16fc27c250076c72486030ae3ebc3cb62f1da00df5fefbf5bfd8dabb4e8dd29c26d80d26a1638e9a7e58d8824c2aa03
6
+ metadata.gz: 51157b3c68734d119c2880e7d071f52f244322bf517bfd8cab697d0e2f2e0662877b455acf704923dbed3f24034ab341c4c84ad6a9443230370052f805c6db0e
7
+ data.tar.gz: 0ed7c718a2862d4a1bb5abfd03e430dff67b1589612abbf50b2f449dd0584ecaff99b42b8e204e6281eab65f1b404b1bbf710da65550bd4fb369ef157395b241
data/README.md CHANGED
@@ -44,6 +44,33 @@ RSpec.configure do |config|
44
44
  end
45
45
  ```
46
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
+
47
74
  ### Other
48
75
 
49
76
  Add to your `spec_helper.rb` file.
@@ -207,8 +234,74 @@ RSpec.describe Article, type: :model do
207
234
  end
208
235
  ```
209
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
+
210
286
  ### Field Matchers
211
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
+
212
305
  ```ruby
213
306
  RSpec.describe Article do
214
307
  it { is_expected.to have_field(:published).of_type(Mongoid::Boolean).with_default_value_of(false) }
@@ -226,6 +319,31 @@ end
226
319
 
227
320
  ### Association Matchers
228
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
+
229
347
  ```ruby
230
348
  RSpec.describe User do
231
349
  it { is_expected.to have_many(:articles).with_foreign_key(:author_id).ordered_by(:title) }
@@ -247,6 +365,8 @@ RSpec.describe User do
247
365
 
248
366
  it { is_expected.to have_and_belong_to_many(:children) }
249
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) }
250
370
  end
251
371
 
252
372
  RSpec.describe Profile do
@@ -300,6 +420,10 @@ end
300
420
 
301
421
  ### Validation Matchers
302
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
+
303
427
  ```ruby
304
428
  RSpec.describe Site do
305
429
  it { is_expected.to validate_presence_of(:name) }
@@ -396,6 +520,8 @@ end
396
520
 
397
521
  You're encouraged to contribute to this library. See [CONTRIBUTING](CONTRIBUTING.md) for details.
398
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
+
399
525
  ## Copyright and License
400
526
 
401
527
  Copyright (c) 2009-2018 Evan Sagge and Contributors.
data/Rakefile CHANGED
@@ -8,11 +8,11 @@ Bundler::GemHelper.install_tasks
8
8
  require 'rspec/core/rake_task'
9
9
 
10
10
  RSpec::Core::RakeTask.new(:spec) do |spec|
11
- spec.pattern = './spec/**/*_spec.rb'
11
+ spec.pattern = './spec/unit/**/*_spec.rb'
12
12
  end
13
13
 
14
14
  RSpec::Core::RakeTask.new(:rcov) do |spec|
15
- spec.pattern = './spec/**/*_spec.rb'
15
+ spec.pattern = './spec/unit/**/*_spec.rb'
16
16
  spec.rcov = true
17
17
  end
18
18
 
@@ -115,6 +115,12 @@ module Mongoid
115
115
  self
116
116
  end
117
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
+
118
124
  def with_counter_cache
119
125
  @association[:counter_cache] = true
120
126
  @expectation_message << " which specifies counter_cache as #{@association[:counter_cache]}"
@@ -271,6 +277,15 @@ module Mongoid
271
277
  end
272
278
  end
273
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
+
274
289
  if @association[:counter_cache]
275
290
  if metadata.counter_cached? != true
276
291
  @negative_result_message = "#{@positive_result_message} which did not set counter_cache"
@@ -371,9 +386,10 @@ module Mongoid
371
386
  end
372
387
  alias have_many have_many_related
373
388
 
374
- def have_and_belong_to_many(association_name)
389
+ def have_and_belong_to_many_related(association_name)
375
390
  HaveAssociationMatcher.new(association_name, HAS_AND_BELONGS_TO_MANY)
376
391
  end
392
+ alias have_and_belong_to_many have_and_belong_to_many_related
377
393
 
378
394
  def belong_to_related(association_name)
379
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
@@ -12,6 +12,11 @@ module Mongoid
12
12
  self
13
13
  end
14
14
 
15
+ def readonly
16
+ @readonly = true
17
+ self
18
+ end
19
+
15
20
  def of_type(type)
16
21
  @type = type
17
22
  self
@@ -49,6 +54,10 @@ module Mongoid
49
54
  error << " with alias #{@klass.fields[attr].options[:as]}"
50
55
  end
51
56
 
57
+ if @readonly && !@klass.readonly_attributes.include?(attr)
58
+ error << ' that is not readonly'
59
+ end
60
+
52
61
  @errors.push("field #{attr.inspect}#{error}") unless error.blank?
53
62
 
54
63
  if @localized
@@ -80,6 +89,7 @@ module Mongoid
80
89
  desc << " of type #{@type.inspect}" if @type
81
90
  desc << " with alias #{@field_alias}" if @field_alias
82
91
  desc << " with default value of #{@default.inspect}" unless @default.nil?
92
+ desc << ' marked readonly' if @readonly
83
93
  desc
84
94
  end
85
95
  end
@@ -13,23 +13,23 @@ module Mongoid
13
13
  self
14
14
  end
15
15
 
16
- def matches?(actual)
17
- return false unless (@result = super(actual))
18
- check_custom_validator if @custom_validator
19
-
20
- @result
21
- end
22
-
23
16
  def description
24
17
  options_desc = +"validate field #{@field.inspect}"
25
- options_desc << " with custom validator #{@custom_validator.name}" if @validator
18
+ options_desc << " with custom validator #{@custom_validator.name}" if @custom_validator
26
19
  options_desc
27
20
  end
28
21
 
29
22
  private
30
23
 
31
- def check_custom_validator
32
- if @validator.is_a? @custom_validator
24
+ def check_validator(actual, validator)
25
+ return false unless (@result = super)
26
+ check_custom_validator(validator) if @custom_validator
27
+
28
+ @result
29
+ end
30
+
31
+ def check_custom_validator(validator)
32
+ if validator.is_a? @custom_validator
33
33
  @positive_result_message << " with custom validator of type #{@custom_validator.name}"
34
34
  else
35
35
  @negative_result_message << " with custom validator not of type #{@custom_validator.name}"
@@ -13,11 +13,19 @@ module Mongoid
13
13
  self
14
14
  end
15
15
 
16
- def matches?(actual)
17
- return false unless (result = super(actual))
16
+ def description
17
+ options_desc = []
18
+ options_desc << " not allowing the ff. values: #{@not_allowed_values}" if @not_allowed_values
19
+ super << options_desc.to_sentence
20
+ end
21
+
22
+ private
23
+
24
+ def check_validator(actual, validator)
25
+ return false unless (result = super)
18
26
 
19
27
  if @not_allowed_values
20
- raw_validator_not_allowed_values = @validator.options[:in]
28
+ raw_validator_not_allowed_values = validator.options[:in]
21
29
 
22
30
  validator_not_allowed_values = case raw_validator_not_allowed_values
23
31
  when Range then raw_validator_not_allowed_values.to_a
@@ -35,12 +43,6 @@ module Mongoid
35
43
 
36
44
  result
37
45
  end
38
-
39
- def description
40
- options_desc = []
41
- options_desc << " not allowing the ff. values: #{@not_allowed_values}" if @not_allowed_values
42
- super << options_desc.to_sentence
43
- end
44
46
  end
45
47
 
46
48
  def validate_exclusion_of(field)
@@ -23,20 +23,30 @@ module Mongoid
23
23
  self
24
24
  end
25
25
 
26
- def matches?(actual)
27
- return false unless result = super(actual)
26
+ def description
27
+ options_desc = []
28
+ options_desc << " with format #{@format.inspect}" if @format
29
+ options_desc << " allowing the value #{@valid_value.inspect}" if @valid_value
30
+ options_desc << " not allowing the value #{@invalid_value.inspect}" if @invalid_value
31
+ super << options_desc.to_sentence
32
+ end
33
+
34
+ private
35
+
36
+ def check_validator(actual, validator)
37
+ return false unless result = super
28
38
 
29
39
  if @format
30
- if @validator.options[:with] == @format
31
- @positive_result_message = @positive_result_message << " with format #{@validator.options[:format].inspect}"
40
+ if validator.options[:with] == @format
41
+ @positive_result_message = @positive_result_message << " with format #{validator.options[:format].inspect}"
32
42
  else
33
- @negative_result_message = @negative_result_message << " with format #{@validator.options[:format].inspect}"
43
+ @negative_result_message = @negative_result_message << " with format #{validator.options[:format].inspect}"
34
44
  result = false
35
45
  end
36
46
  end
37
47
 
38
48
  if @valid_value
39
- if @validator.options[:with] =~ @valid_value
49
+ if validator.options[:with] =~ @valid_value
40
50
  @positive_result_message = @positive_result_message << " with #{@valid_value.inspect} as a valid value"
41
51
  else
42
52
  @negative_result_message = @negative_result_message << " with #{@valid_value.inspect} as an invalid value"
@@ -45,7 +55,7 @@ module Mongoid
45
55
  end
46
56
 
47
57
  if @invalid_value
48
- if @invalid_value !~ @validator.options[:with]
58
+ if @invalid_value !~ validator.options[:with]
49
59
  @positive_result_message = @positive_result_message << " with #{@invalid_value.inspect} as an invalid value"
50
60
  else
51
61
  @negative_result_message = @negative_result_message << " with #{@invalid_value.inspect} as a valid value"
@@ -55,14 +65,6 @@ module Mongoid
55
65
 
56
66
  result
57
67
  end
58
-
59
- def description
60
- options_desc = []
61
- options_desc << " with format #{@format.inspect}" if @format
62
- options_desc << " allowing the value #{@valid_value.inspect}" if @valid_value
63
- options_desc << " not allowing the value #{@invalid_value.inspect}" if @invalid_value
64
- super << options_desc.to_sentence
65
- end
66
68
  end
67
69
 
68
70
  def validate_format_of(field)
@@ -13,11 +13,19 @@ module Mongoid
13
13
  self
14
14
  end
15
15
 
16
- def matches?(actual)
17
- return false unless result = super(actual)
16
+ def description
17
+ options_desc = []
18
+ options_desc << " allowing these values: #{@allowed_values}" if @allowed_values
19
+ super << options_desc.to_sentence
20
+ end
21
+
22
+ private
23
+
24
+ def check_validator(actual, validator)
25
+ return false unless result = super
18
26
 
19
27
  if @allowed_values
20
- raw_validator_allowed_values = @validator.options[:in]
28
+ raw_validator_allowed_values = validator.options[:in]
21
29
 
22
30
  validator_allowed_values = case raw_validator_allowed_values
23
31
  when Range then raw_validator_allowed_values.to_a
@@ -35,12 +43,6 @@ module Mongoid
35
43
 
36
44
  result
37
45
  end
38
-
39
- def description
40
- options_desc = []
41
- options_desc << " allowing these values: #{@allowed_values}" if @allowed_values
42
- super << options_desc.to_sentence
43
- end
44
46
  end
45
47
 
46
48
  def validate_inclusion_of(field)
@@ -6,6 +6,19 @@ module Mongoid
6
6
  class ValidateLengthOfMatcher < HaveValidationMatcher
7
7
  def initialize(name)
8
8
  super(name, :length)
9
+ @allowed_options = {}
10
+ end
11
+
12
+ def to_allow(options)
13
+ aliases = { nil: :allow_nil, blank: :allow_blank, allow_nil: :allow_nil, allow_blank: :allow_blank }
14
+ unless options.is_a?(Hash) && options.any? &&
15
+ options.all? { |key, value| aliases.key?(key) && (value.equal?(true) || value.equal?(false)) } &&
16
+ options.keys.map { |key| aliases[key] }.uniq.size == options.size
17
+ raise ArgumentError, 'validate_length_of#to_allow requires a non-empty Hash of nil, blank, allow_nil, or allow_blank keys with boolean values and no duplicate aliases'
18
+ end
19
+
20
+ options.each { |key, value| @allowed_options[aliases[key]] = value }
21
+ self
9
22
  end
10
23
 
11
24
  def with_maximum(value)
@@ -32,72 +45,102 @@ module Mongoid
32
45
  end
33
46
  alias is as_exactly
34
47
 
35
- def matches?(actual)
36
- return false unless @result = super(actual)
37
-
38
- check_maximum if @maximum
39
- check_minimum if @minimum
40
- check_range if @within
41
- check_exact if @is
42
-
43
- @result
44
- end
45
-
46
48
  def description
47
49
  options_desc = []
48
50
  options_desc << "with minimum of #{@minimum}" if @minimum
49
51
  options_desc << "with maximum of #{@maximum}" if @maximum
50
52
  options_desc << "within the range of #{@within}" if @within
51
53
  options_desc << "as exactly #{@is}" if @is
54
+ @allowed_options.each { |key, value| options_desc << "with #{key} #{value.inspect}" }
52
55
  super << " #{options_desc.to_sentence}"
53
56
  end
54
57
 
55
58
  private
56
59
 
57
- def check_maximum
58
- if actual_max.nil?
60
+ def check_validator(actual, validator)
61
+ return false unless @result = super
62
+
63
+ check_maximum(validator) if @maximum
64
+ check_minimum(validator) if @minimum
65
+ check_range(validator) if @within
66
+ check_exact(validator) if @is
67
+ check_allowed_options(validator)
68
+
69
+ @result
70
+ end
71
+
72
+ def select_validator(validators)
73
+ validators.detect do |validator|
74
+ (!@minimum || actual_min(validator) == @minimum) &&
75
+ (!@maximum || actual_max(validator) == @maximum) &&
76
+ (!@within || @within.minmax == [actual_min(validator), actual_max(validator)]) &&
77
+ (!@is || validator.options[:is] == @is) &&
78
+ (!@expected_message || validator.options[:message] == @expected_message) &&
79
+ @allowed_options.all? { |key, value| validator.options[key] == value }
80
+ end || super
81
+ end
82
+
83
+ def check_allowed_options(validator)
84
+ @allowed_options.each do |key, value|
85
+ actual_value = validator.options[key]
86
+ if actual_value == value
87
+ @positive_result_message << " with #{key} #{value.inspect}"
88
+ else
89
+ @negative_result_message << " with #{key} #{actual_value.inspect}"
90
+ @result = false
91
+ end
92
+ end
93
+ end
94
+
95
+ def check_maximum(validator)
96
+ maximum = actual_max(validator)
97
+ if maximum.nil?
59
98
  @negative_result_message << ' with no maximum'
60
99
  @result = false
61
- elsif actual_max == @maximum
100
+ elsif maximum == @maximum
62
101
  @positive_result_message << " with maximum of #{@maximum}"
63
102
  else
64
- @negative_result_message << " with maximum of #{actual_max}"
103
+ @negative_result_message << " with maximum of #{maximum}"
65
104
  @result = false
66
105
  end
67
106
  end
68
107
 
69
- def check_minimum
70
- if actual_min.nil?
108
+ def check_minimum(validator)
109
+ minimum = actual_min(validator)
110
+ if minimum.nil?
71
111
  @negative_result_message << ' with no minimum'
72
112
  @result = false
73
- elsif actual_min == @minimum
113
+ elsif minimum == @minimum
74
114
  @positive_result_message << " with minimum of #{@minimum}"
75
115
  else
76
- @negative_result_message << " with minimum of #{actual_min}"
116
+ @negative_result_message << " with minimum of #{minimum}"
77
117
  @result = false
78
118
  end
79
119
  end
80
120
 
81
- def check_range
121
+ def check_range(validator)
82
122
  min, max = @within.minmax
83
- if !actual_min.nil? && actual_max.nil?
84
- @negative_result_message << " with no minimum but with maximum of #{actual_max}"
123
+ minimum = actual_min(validator)
124
+ maximum = actual_max(validator)
125
+ if !minimum.nil? && maximum.nil?
126
+ @negative_result_message << " with no minimum but with maximum of #{maximum}"
85
127
  @result = false
86
- elsif actual_min.nil? && !actual_max.nil?
87
- @negative_result_message << " with minimum_of #{actual_min} but no maximum"
128
+ elsif minimum.nil? && !maximum.nil?
129
+ @negative_result_message << " with minimum_of #{minimum} but no maximum"
88
130
  @result = false
89
- elsif actual_min.nil? && actual_max.nil?
131
+ elsif minimum.nil? && maximum.nil?
90
132
  @negative_result_message << ' with no minimum and maximum'
91
133
  @result = false
92
- elsif actual_min == min && actual_max == max
134
+ elsif minimum == min && maximum == max
93
135
  @positive_result_message << " within the range of #{@within.inspect}"
94
136
  else
95
- @negative_result_message << " within the range of #{(actual_min..actual_max).inspect}"
137
+ @negative_result_message << " within the range of #{(minimum..maximum).inspect}"
96
138
  @result = false
97
139
  end
98
140
  end
99
141
 
100
- def check_exact
142
+ def check_exact(validator)
143
+ actual_is = validator.options[:is]
101
144
  if actual_is == @is
102
145
  @positive_result_message << " as exactly #{@is}"
103
146
  else
@@ -106,16 +149,12 @@ module Mongoid
106
149
  end
107
150
  end
108
151
 
109
- def actual_is
110
- actual_is = @validator.options[:is]
111
- end
112
-
113
- def actual_min
114
- @validator.options[:minimum] || (@validator.options[:in] || @validator.options[:within]).try(&:min)
152
+ def actual_min(validator)
153
+ validator.options[:minimum] || (validator.options[:in] || validator.options[:within]).try(&:min)
115
154
  end
116
155
 
117
- def actual_max
118
- @validator.options[:maximum] || (@validator.options[:in] || @validator.options[:within]).try(&:max)
156
+ def actual_max(validator)
157
+ validator.options[:maximum] || (validator.options[:in] || validator.options[:within]).try(&:max)
119
158
  end
120
159
  end
121
160
 
@@ -38,24 +38,24 @@ module Mongoid
38
38
  self
39
39
  end
40
40
 
41
- def matches?(actual)
42
- return false unless result = super(actual)
41
+ def description
42
+ super << options_message(@options)
43
+ end
44
+
45
+ protected
46
+
47
+ def check_validator(actual, validator)
48
+ return false unless result = super
43
49
 
44
50
  @options.each do |comparator, expected_value|
45
- result &= (@validator.options[comparator] == expected_value)
51
+ result &= (validator.options[comparator] == expected_value)
46
52
  end
47
53
 
48
- @positive_result_message <<= options_message(@validator.options)
49
- @negative_result_message <<= options_message(@validator.options)
54
+ @positive_result_message <<= options_message(validator.options)
55
+ @negative_result_message <<= options_message(validator.options)
50
56
  result
51
57
  end
52
58
 
53
- def description
54
- super << options_message(@options)
55
- end
56
-
57
- protected
58
-
59
59
  def options_message(options)
60
60
  type_msg = []
61
61
  comp_msg = []
@@ -24,16 +24,6 @@ module Mongoid
24
24
  self
25
25
  end
26
26
 
27
- def matches?(actual)
28
- return false unless @result = super(actual)
29
-
30
- check_scope if @scope
31
- check_allow_blank if @allow_blank
32
- check_case_sensitivity if @case_insensitive
33
-
34
- @result
35
- end
36
-
37
27
  def description
38
28
  options_desc = []
39
29
  options_desc << " scoped to #{@scope.inspect}" if @scope
@@ -44,8 +34,18 @@ module Mongoid
44
34
 
45
35
  private
46
36
 
47
- def check_allow_blank
48
- if @validator.options[:allow_blank] == @allow_blank
37
+ def check_validator(actual, validator)
38
+ return false unless @result = super
39
+
40
+ check_scope(validator) if @scope
41
+ check_allow_blank(validator) if @allow_blank
42
+ check_case_sensitivity(validator) if @case_insensitive
43
+
44
+ @result
45
+ end
46
+
47
+ def check_allow_blank(validator)
48
+ if validator.options[:allow_blank] == @allow_blank
49
49
  @positive_result_message << ' with blank values allowed'
50
50
  else
51
51
  @negative_result_message << ' with no blank values allowed'
@@ -53,9 +53,9 @@ module Mongoid
53
53
  end
54
54
  end
55
55
 
56
- def check_scope
57
- message = " scope to #{@validator.options[:scope]}"
58
- if [@validator.options[:scope]].flatten.map(&:to_sym) == @scope
56
+ def check_scope(validator)
57
+ message = " scope to #{validator.options[:scope]}"
58
+ if [validator.options[:scope]].flatten.map(&:to_sym) == @scope
59
59
  @positive_result_message << message
60
60
  else
61
61
  @negative_result_message << message
@@ -63,8 +63,8 @@ module Mongoid
63
63
  end
64
64
  end
65
65
 
66
- def check_case_sensitivity
67
- if @validator.options[:case_sensitive] == false
66
+ def check_case_sensitivity(validator)
67
+ if validator.options[:case_sensitive] == false
68
68
  @positive_result_message << ' with case insensitive values'
69
69
  else
70
70
  @negative_result_message << ' without case insensitive values'
@@ -13,22 +13,20 @@ module Mongoid
13
13
  def matches?(actual)
14
14
  @klass = actual.is_a?(Class) ? actual : actual.class
15
15
 
16
- @validator = @klass.validators_on(@field).detect do |v|
16
+ validators = @klass.validators_on(@field).lazy.select do |v|
17
17
  (v.kind.to_s == @type) && (!v.options[:on] || on_options_matches?(v)) &&
18
18
  if_condition_matches?(actual, v) && unless_condition_matches?(actual, v)
19
19
  end
20
+ validator = select_validator(validators)
20
21
 
21
- if @validator
22
+ if validator
22
23
  @negative_result_message = +"#{@type.inspect} validator on #{@field.inspect}"
23
24
  @positive_result_message = +"#{@type.inspect} validator on #{@field.inspect}"
24
25
  else
25
26
  @negative_result_message = +"no #{@type.inspect} validator on #{@field.inspect}"
26
27
  return false
27
28
  end
28
- @result = true
29
- check_on if @options[:on]
30
- check_expected_message if @expected_message
31
- @result
29
+ check_validator(actual, validator)
32
30
  end
33
31
 
34
32
  def failure_message_for_should
@@ -66,6 +64,17 @@ module Mongoid
66
64
 
67
65
  private
68
66
 
67
+ def check_validator(_actual, validator)
68
+ @result = true
69
+ check_on(validator) if @options[:on]
70
+ check_expected_message(validator) if @expected_message
71
+ @result
72
+ end
73
+
74
+ def select_validator(validators)
75
+ validators.first
76
+ end
77
+
69
78
  def if_condition_matches?(actual, validator)
70
79
  return true unless @check_conditions
71
80
  return true unless validator.options[:if]
@@ -95,13 +104,13 @@ module Mongoid
95
104
  end
96
105
  end
97
106
 
98
- def check_on
99
- validator_on_methods = [@validator.options[:on]].flatten
107
+ def check_on(validator)
108
+ validator_on_methods = [validator.options[:on]].flatten
100
109
 
101
110
  if validator_on_methods.any?
102
111
  message = +" on methods: #{validator_on_methods}"
103
112
 
104
- if on_options_covered_by?(@validator)
113
+ if on_options_covered_by?(validator)
105
114
  @positive_result_message << message
106
115
  else
107
116
  @negative_result_message << message
@@ -118,8 +127,8 @@ module Mongoid
118
127
  ([@options[:on]].flatten - [validator.options[:on]].flatten).empty?
119
128
  end
120
129
 
121
- def check_expected_message
122
- actual_message = @validator.options[:message]
130
+ def check_expected_message(validator)
131
+ actual_message = validator.options[:message]
123
132
  if actual_message.nil?
124
133
  @negative_result_message << ' with no custom message'
125
134
  @result = false
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Mongoid
4
4
  module RSpec
5
- VERSION = '4.3.0'
5
+ VERSION = '4.4.0'
6
6
  end
7
7
  end
data/lib/mongoid/rspec.rb CHANGED
@@ -31,7 +31,9 @@ require 'matchers/validations/absence_of'
31
31
  require 'matchers/be_mongoid_document'
32
32
  require 'matchers/be_dynamic_document'
33
33
  require 'matchers/be_stored_in'
34
+ require 'matchers/be_covered_query'
34
35
  require 'matchers/have_field'
36
+ require 'matchers/execute_mongodb_command'
35
37
  require 'matchers/indexes/have_index_for'
36
38
  if Mongoid::Compatibility::Version.mongoid3?
37
39
  require 'matchers/indexes/v3/have_index_for'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mongoid-rspec
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.3.0
4
+ version: 4.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Evan Sagge
@@ -142,9 +142,11 @@ files:
142
142
  - lib/matchers/accept_nested_attributes.rb
143
143
  - lib/matchers/allow_mass_assignment.rb
144
144
  - lib/matchers/associations.rb
145
+ - lib/matchers/be_covered_query.rb
145
146
  - lib/matchers/be_dynamic_document.rb
146
147
  - lib/matchers/be_mongoid_document.rb
147
148
  - lib/matchers/be_stored_in.rb
149
+ - lib/matchers/execute_mongodb_command.rb
148
150
  - lib/matchers/have_field.rb
149
151
  - lib/matchers/have_timestamps.rb
150
152
  - lib/matchers/indexes/have_index_for.rb