Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

## master

- [PR #247](https://github.com/DmitryTsepelev/store_model/pull/247) Handle nil elements in array and hash validation ([@JacobNWolf])

## 4.6.2 (2026-11-07)

- [PR #240](https://github.com/DmitryTsepelev/store_model/pull/240) Avoid repeated casts when stored models have unknown attributes ([@imacomber])
Expand Down Expand Up @@ -315,4 +317,4 @@
[@HoneyryderChuck]: https://github.com/HoneyryderChuck
[@ClayShentrup]: https://github.com/ClayShentrup
[@imacomber]: https://github.com/imacomber

[@JacobNWolf]: https://github.com/JacobNWolf
2 changes: 2 additions & 0 deletions docs/hash_of_stored_models.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,8 @@ product.errors.full_messages
# "Configurations [secondary] Color can't be blank"]
```

A `nil` value makes the hash invalid too. With `merge_hash_errors: true` it's reported as `[key] is invalid`. Custom strategies receive it as a `StoreModel::NilElement`, which responds to `errors` like any other model (see [Validations](validations.md)).

### Important Notes

> **Heads up!** The attribute is not the same as an association. It's just a hash stored as JSON. `assign_attributes` (and similar) is going to _override_ the whole hash, not merge it with a previous value.
Expand Down
10 changes: 10 additions & 0 deletions docs/validations.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,14 @@ puts product.valid? # => false
puts product.errors.messages # => { color: ["[0] Color can't be blank"] }
```

Arrays and hashes can contain `nil` elements, for example when they come from JSON with `null` values. A `nil` element makes the attribute invalid and is reported with an `is invalid` message at its index or key:

```ruby
product = Product.new(configurations: [nil, Configuration.new])
puts product.valid? # => false
puts product.errors.messages # => { configurations: ["[0] is invalid", "[1] Color can't be blank"] }
```

You can change the global behavior using `StoreModel.config`:

```ruby
Expand Down Expand Up @@ -107,6 +115,8 @@ class FhtagnArrayErrorStrategy
end
```

Array strategies receive every element of the array, and hash strategies receive the invalid values keyed by their hash keys. A `nil` element is passed as a `StoreModel::NilElement` in its original position, so every element responds to `errors`. A `StoreModel::NilElement` is always invalid and has a single `is invalid` error on `:base`.

You can provide its instance or snake-cased name when configuring global `merge_errors`:

```ruby
Expand Down
11 changes: 8 additions & 3 deletions lib/active_model/validations/store_model_validator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

require "active_record"
require "store_model/combine_errors_strategies"
require "store_model/nil_element"

module ActiveModel
module Validations
Expand Down Expand Up @@ -39,12 +40,16 @@ def call_json_strategy(record, attribute, value)
end

def call_array_strategy(record, attribute, value)
any_invalid = value.select { |v| v.invalid?(record.validation_context) }.present?
array_strategy.call(attribute, record.errors, value) if any_invalid
store_models = value.map { |v| v || StoreModel::NilElement.new }
any_invalid = store_models.count { |v| v.invalid?(record.validation_context) }.positive?
array_strategy.call(attribute, record.errors, store_models) if any_invalid
end

def call_hash_strategy(record, attribute, value)
invalid_values = value.select { |_k, v| v.invalid?(record.validation_context) }
invalid_values = value.each_with_object({}) do |(key, v), invalid|
store_model = v || StoreModel::NilElement.new
invalid[key] = store_model if store_model.invalid?(record.validation_context)
end
hash_strategy.call(attribute, record.errors, invalid_values) if invalid_values.present?
end

Expand Down
14 changes: 14 additions & 0 deletions lib/store_model/nil_element.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# frozen_string_literal: true

require "active_model"

module StoreModel
# +NilElement+ stands in for a nil element of an array or hash attribute during
# validation, so error strategies always receive objects that respond to +errors+.
# It is always invalid and carries a single +:invalid+ error on +:base+.
class NilElement
include ActiveModel::Validations

validate { errors.add(:base, :invalid) }
end
end
6 changes: 5 additions & 1 deletion lib/store_model/types/many_base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,11 @@ def cast_model_type_value(_value)
# rubocop:disable Style/RescueModifier
def decode_and_initialize(array_value)
decoded = ActiveSupport::JSON.decode(array_value) rescue []
decoded.map { |attributes| cast_model_type_value(attributes) }
decoded.map do |attributes|
next nil if attributes.nil?

cast_model_type_value(attributes)
end
end
# rubocop:enable Style/RescueModifier
end
Expand Down
1 change: 1 addition & 0 deletions lib/store_model/types/many_polymorphic.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ def type

def ensure_model_class(array)
array.map do |object|
next object if object.nil?
next object if implements_model?(object.class)

cast_model_type_value(object)
Expand Down
252 changes: 252 additions & 0 deletions spec/active_model/validations/store_model_validator_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -764,4 +764,256 @@
end
end
end

context "when a collection contains nil elements" do
Comment thread
JacobNWolf marked this conversation as resolved.
Resolved
shared_examples "nil element is invalid" do |attribute_name, messages|
it { is_expected.to be_invalid }

it "reports the nil element through the configured strategy" do
expect(subject.errors.messages).to eq(attribute_name => messages)
end
end

shared_examples "nil element is passed to the strategy as NilElement" do |attribute_name, error_messages|
it "stores models that respond to #errors in the error details" do
store_models = subject.errors.details[attribute_name].first[:errors]
store_models = store_models.values if store_models.is_a?(Hash)
expect(store_models).to all(respond_to(:errors))
expect(store_models.flat_map { |m| m.errors.to_a }).to eq(error_messages)
end
end

let(:polymorphic_wrapper) { StoreModel.one_of { |json| json["gears"] ? Bicycle : Configuration } }

context "with an array type" do
let(:custom_product_class) do
build_custom_product_class do
attribute :configurations, Configuration.to_array_type
validates :configurations, store_model: true
end
end

context "when the only element is nil" do
let(:attributes) { { configurations: [nil] } }

include_examples "nil element is invalid", :configurations, ["is invalid"]
include_examples "nil element is passed to the strategy as NilElement", :configurations, ["is invalid"]
end

context "when nil sits next to valid elements" do
let(:attributes) { { configurations: [Configuration.new(color: "red"), nil] } }

include_examples "nil element is invalid", :configurations, ["is invalid"]
end

context "when nil sits next to an invalid element" do
let(:attributes) { { configurations: [nil, Configuration.new] } }

include_examples "nil element is invalid", :configurations, ["is invalid"]
include_examples "nil element is passed to the strategy as NilElement",
:configurations, ["is invalid", "Color can't be blank"]

it "keeps nil in the attribute value" do
expect(subject.configurations.first).to be_nil
end

it "still validates the other elements" do
expect(subject.configurations.second.errors.messages).to eq(color: ["can't be blank"])
end
end

context "when the value is loaded from a JSON string" do
let(:attributes) { { configurations: "[null]" } }

include_examples "nil element is invalid", :configurations, ["is invalid"]
end

context "with merge_array_errors: true" do
let(:custom_product_class) do
build_custom_product_class do
attribute :configurations, Configuration.to_array_type
validates :configurations, store_model: { merge_array_errors: true }
end
end

context "when the only element is nil" do
let(:attributes) { { configurations: [nil] } }

include_examples "nil element is invalid", :configurations, ["[0] is invalid"]
end

context "when nil sits next to an invalid element" do
let(:attributes) { { configurations: [nil, Configuration.new] } }

include_examples "nil element is invalid", :configurations, ["[0] is invalid", "[1] Color can't be blank"]
end

context "when nil sits between invalid elements" do
let(:attributes) { { configurations: [Configuration.new, nil, Configuration.new] } }

include_examples "nil element is invalid", :configurations,
["[0] Color can't be blank", "[1] is invalid", "[2] Color can't be blank"]
end

context "when another validation already added :invalid to the attribute" do
let(:custom_product_class) do
build_custom_product_class do
attribute :configurations, Configuration.to_array_type
validate { errors.add(:configurations, :invalid, message: "too many") }
validates :configurations, store_model: { merge_array_errors: true }
end
end

let(:attributes) { { configurations: [nil] } }

include_examples "nil element is invalid", :configurations, ["too many", "[0] is invalid"]
end
end

context "with a custom lambda strategy" do
let(:custom_product_class) do
build_custom_product_class do
attribute :configurations, Configuration.to_array_type
validates :configurations, store_model: {
merge_array_errors: lambda { |attr, errors, models|
models.each_with_index do |m, i|
m.errors.full_messages.each { |msg| errors.add(attr, "[#{i}] #{msg}") }
end
}
}
end
end

let(:attributes) { { configurations: [nil, Configuration.new] } }

include_examples "nil element is invalid", :configurations, ["[0] is invalid", "[1] Color can't be blank"]
end

context "with a custom strategy class" do
let(:custom_product_class) do
build_custom_product_class do
attribute :configurations, Configuration.to_array_type
validates :configurations, store_model: { merge_array_errors: :fhtagn_error_strategy }
end
end

context "when the only element is nil" do
let(:attributes) { { configurations: [nil] } }

include_examples "nil element is invalid", :configurations, ["cthulhu fhtagn"]
end

context "when nil sits next to an invalid element" do
let(:attributes) { { configurations: [nil, Configuration.new] } }

include_examples "nil element is invalid", :configurations, ["cthulhu fhtagn"]
end
end
end

context "with a polymorphic array type" do
let(:custom_product_class) do
wrapper = polymorphic_wrapper
build_custom_product_class do
attribute :configurations, wrapper.to_array_type
validates :configurations, store_model: { merge_array_errors: true }
end
end

context "when nil sits next to an invalid element" do
let(:attributes) { { configurations: [nil, Configuration.new] } }

include_examples "nil element is invalid", :configurations, ["[0] is invalid", "[1] Color can't be blank"]
end

context "when the value is loaded from a JSON string" do
let(:attributes) { { configurations: "[null]" } }

include_examples "nil element is invalid", :configurations, ["[0] is invalid"]
end
end

context "with a hash type" do
let(:custom_product_class) do
build_custom_product_class do
attribute :configurations, Configuration.to_hash_type
validates :configurations, store_model: true
end
end

context "when the only value is nil" do
let(:attributes) { { configurations: { "primary" => nil } } }

include_examples "nil element is invalid", :configurations, ["is invalid"]
include_examples "nil element is passed to the strategy as NilElement", :configurations, ["is invalid"]
end

context "when nil sits next to a valid value" do
let(:attributes) { { configurations: { "primary" => nil, "secondary" => Configuration.new(color: "red") } } }

include_examples "nil element is invalid", :configurations, ["is invalid"]
end

context "when nil sits next to an invalid value" do
let(:attributes) { { configurations: { "primary" => nil, "secondary" => Configuration.new } } }

include_examples "nil element is invalid", :configurations, ["is invalid"]

it "still validates the other values" do
expect(subject.configurations["secondary"].errors.messages).to eq(color: ["can't be blank"])
end
end

context "when the value is loaded from a JSON string" do
let(:attributes) { { configurations: '{"primary":null}' } }

include_examples "nil element is invalid", :configurations, ["is invalid"]
end

context "with merge_hash_errors: true" do
let(:custom_product_class) do
build_custom_product_class do
attribute :configurations, Configuration.to_hash_type
validates :configurations, store_model: { merge_hash_errors: true }
end
end

context "when the only value is nil" do
let(:attributes) { { configurations: { "primary" => nil } } }

include_examples "nil element is invalid", :configurations, ["[primary] is invalid"]
end

context "when nil sits next to an invalid value" do
let(:attributes) { { configurations: { "primary" => nil, "secondary" => Configuration.new } } }

include_examples "nil element is invalid", :configurations,
["[primary] is invalid", "[secondary] Color can't be blank"]
end
end
end

context "with a polymorphic hash type" do
let(:custom_product_class) do
wrapper = polymorphic_wrapper
build_custom_product_class do
attribute :configurations, wrapper.to_hash_type
validates :configurations, store_model: { merge_hash_errors: true }
end
end

context "when nil sits next to an invalid value" do
let(:attributes) { { configurations: { "primary" => nil, "secondary" => Configuration.new } } }

include_examples "nil element is invalid", :configurations,
["[primary] is invalid", "[secondary] Color can't be blank"]
end

context "when the value is loaded from a JSON string" do
let(:attributes) { { configurations: '{"primary":null}' } }

include_examples "nil element is invalid", :configurations, ["[primary] is invalid"]
end
end
end
end
Loading
Loading