Repository navigation
Handle nil elements in array and hash validation - #247
JacobNWolf wants to merge 3 commits into
Conversation
DmitryTsepelev
left a comment
There was a problem hiding this comment.
Thanks for the fix! The direction is right, but nil elements can still crash on two paths, and the nil element's own error often gets lost. Details are in the inline comments. Two points are about code outside the diff, so they are here.
Polymorphic arrays still crash before validation runs. ManyPolymorphic#ensure_model_class (many_polymorphic.rb) and ManyBase#decode_and_initialize (many_base.rb) pass nil to the user's wrapper block. A wrapper that reads its input, like StoreModel.one_of { |json| json["kind"] == "x" ? A : B }, raises NoMethodError: undefined method '[]' for nil on [nil], and also on "[null]" loaded from the database. StoreModel.union(...) returns nil from its wrapper and raises the extract-wrapper error. The hash types already skip nil with next object if object.nil? (hash_polymorphic.rb), so the array types could do the same.
The docs don't say how nil is handled. The docs say a custom array strategy receives store_models (validations.md). After this PR that list can contain nil, and neither validations.md nor hash_of_stored_models.md mentions it. People write custom strategies from these docs.
A suggestion for the overall shape. It would help to handle nil in one place. One option is for the validator to pass strategies only real models, keeping their indexes, and for the merge strategies to report [i] is invalid for each nil slot. Another option is to skip nil when casting, the way the hash types do. Either way, the custom-strategy crash, the lost errors and the duplicate errors go away together.
| def call_array_strategy(record, attribute, value) | ||
| any_invalid = value.select { |v| v.invalid?(record.validation_context) }.present? | ||
| any_invalid = value.select { |v| v&.invalid?(record.validation_context) }.present? | ||
| array_strategy.call(attribute, record.errors, value) if any_invalid |
There was a problem hiding this comment.
Custom array strategies still receive the nil elements here, because value is passed through unfiltered. Only the built-in MergeArrayErrorStrategy got a guard. A custom strategy written the way the docs describe still crashes:
validates :configurations, store_model: {
merge_array_errors: ->(attr, errors, models) {
models.each_with_index { |m, i| m.errors.full_messages.each { |msg| errors.add(attr, "[#{i}] #{msg}") } }
}
}With configurations: [nil, Configuration.new] this raises NoMethodError: undefined method 'errors' for nil. That is the same crash as #245.
The default strategy has a milder version of the problem. MarkInvalidErrorStrategy stores store_models in the error details, so errors.details[:configurations].first[:errors] becomes [nil, #<Configuration>]. Code that walks those details, such as a serializer doing .flat_map { |m| m.errors.to_a }, crashes on the nil.
| any_invalid = value.select { |v| v.invalid?(record.validation_context) }.present? | ||
| any_invalid = value.select { |v| v&.invalid?(record.validation_context) }.present? | ||
| array_strategy.call(attribute, record.errors, value) if any_invalid | ||
| add_nil_element_error(record, attribute) if value.include?(nil) |
There was a problem hiding this comment.
This error is added outside the configured strategy, so it doesn't follow the strategy's format.
- With
merge_array_errors: trueand[nil], the message isConfigurations is invalid, with no[0]index. - With a custom strategy like
FhtagnArrayErrorStrategyfrom the docs and[nil, Configuration.new], the errors are["cthulhu fhtagn", "is invalid"]. The dedup on line 66 only knows about:invalid, so the user gets both. - With
[nil]alone, the custom strategy is never called, and the user getsis invalidinstead of their own message.
| end | ||
|
|
||
| def add_nil_element_error(record, attribute) | ||
| record.errors.add(attribute, :invalid) unless record.errors.of_kind?(attribute, :invalid) |
There was a problem hiding this comment.
of_kind?(attribute, :invalid) matches any :invalid error on the attribute, no matter what added it. So the nil element goes unreported whenever something else has already added :invalid.
- With
merge_array_errors: trueand[nil, Configuration.new], the only message is[1] Color can't be blank. Nothing says that index 0 is nil. - With
merge_hash_errors: trueand{ "a" => nil, "b" => Configuration.new }, keyais not reported. - A custom validation that runs first, like
validate { errors.add(:configurations, :invalid, message: "too many") }, hides the nil error too.
Also, the error added here has no :errors key in its details, unlike every other :invalid error this validator adds.
|
|
||
| def call_array_strategy(record, attribute, value) | ||
| any_invalid = value.select { |v| v.invalid?(record.validation_context) }.present? | ||
| any_invalid = value.select { |v| v&.invalid?(record.validation_context) }.present? |
There was a problem hiding this comment.
Minor: each collection is walked twice. select { ... }.present? builds an intermediate array, and then include?(nil) on line 44 walks the whole collection again, calling == on every model. One pass that notes whether it saw a nil would do both jobs.
| @@ -12,6 +12,8 @@ class MergeArrayErrorStrategy | |||
| # @param store_models [Array] an array or store_models that have been validated | |||
| def call(attribute, base_errors, store_models) | |||
| store_models.each_with_index do |store_model, index| | |||
There was a problem hiding this comment.
This guard is one of three places that now deal with nil. The other two are the check after the strategy call in the validator and the of_kind? dedup. Custom strategies don't get this guard, which is why they still crash. If the validator handled nil in one place, strategies wouldn't need to know about nil at all. The review summary has two ways to do that.
| context "when nil sits next to an invalid element" do | ||
| let(:attributes) { { configurations: [nil, Configuration.new] } } | ||
|
|
||
| it "keeps the index of the invalid element" do |
There was a problem hiding this comment.
This expectation, and the one on line 903 ("skips the nil one"), make dropping the nil element's error part of the contract. With merge_array_errors: true I'd expect something like ["[0] is invalid", "[1] Color can't be blank"] here, so the user can tell which slot is nil. As written, fixing that later would break this test.
| context "with a polymorphic array type" do | ||
| let(:custom_product_class) do | ||
| build_custom_product_class do | ||
| attribute :configurations, StoreModel.one_of { |_json| Configuration }.to_array_type |
There was a problem hiding this comment.
This wrapper ignores its argument, so nil never reaches real discriminator logic. The test passes only because of that. A wrapper that reads its input, like { |json| json["kind"] == "x" ? A : Configuration }, fails on [nil] while casting, before validation runs. The review summary has more on this: the polymorphic array types need the same next object if object.nil? that the hash types have.
| end | ||
| end | ||
|
|
||
| context "when a collection contains nil elements" do |
There was a problem hiding this comment.
A few paths that are likely to carry nil aren't covered:
- A value loaded from the database as a string, such as
"[null]"or'{"a":null}'. That goes throughdecode_and_initialize. - A custom strategy, either a lambda or a class, that receives the nils.
- The polymorphic hash type with nil values.
A test with a custom strategy would have caught the crash on line 43. A polymorphic test with a wrapper that reads its input would have caught the crash while casting.
|
|
||
| ## master | ||
|
|
||
| - [PR #247](https://github.com/DmitryTsepelev/store_model/pull/247) Handle nil elements in array and hash validation |
There was a problem hiding this comment.
The entry is missing the author credit that the other entries have, like ([@JacobNWolf]) at the end. Without it, the link definition added on line 317 isn't used.
| @@ -0,0 +1,40 @@ | |||
| { | |||
There was a problem hiding this comment.
Could you drop the .zed/ files from this PR? The repo doesn't track editor settings. These would turn on format-on-save with RuboCop autofix for everyone who opens the repo in Zed. If you want to keep them locally, a global gitignore entry keeps them out of commits.
Fixes #245.
Collections containing
nilnow report:invalidon the parent attribute instead of raisingNoMethodError. Other elements are still validated, and merged errors preserve the original array indexes and hash keys without adding duplicate invalid messages.Additionally adds settings for the Zed IDE so that Rubocop fixes autofixable linting/formatting errors on save and RSpec tests can be ran directly within the IDE.
Adds 23 regression examples covering arrays, hashes, polymorphic arrays, and merged errors.
Validation:
bundle exec rspec— 572 examples, 0 failures