iknow_view_models 3.1.8 → 3.2.4
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/.circleci/config.yml +6 -6
- data/.rubocop.yml +18 -0
- data/Appraisals +6 -6
- data/Gemfile +6 -2
- data/Rakefile +5 -5
- data/gemfiles/rails_5_2.gemfile +5 -5
- data/gemfiles/rails_6_0.gemfile +9 -0
- data/iknow_view_models.gemspec +40 -38
- data/lib/iknow_view_models.rb +9 -7
- data/lib/iknow_view_models/version.rb +1 -1
- data/lib/view_model.rb +31 -17
- data/lib/view_model/access_control.rb +5 -2
- data/lib/view_model/access_control/composed.rb +10 -9
- data/lib/view_model/access_control/open.rb +2 -0
- data/lib/view_model/access_control/read_only.rb +2 -0
- data/lib/view_model/access_control/tree.rb +11 -6
- data/lib/view_model/access_control_error.rb +4 -1
- data/lib/view_model/active_record.rb +17 -15
- data/lib/view_model/active_record/association_data.rb +2 -1
- data/lib/view_model/active_record/association_manipulation.rb +6 -4
- data/lib/view_model/active_record/cache.rb +114 -34
- data/lib/view_model/active_record/cache/cacheable_view.rb +2 -2
- data/lib/view_model/active_record/collection_nested_controller.rb +3 -3
- data/lib/view_model/active_record/controller.rb +68 -1
- data/lib/view_model/active_record/controller_base.rb +4 -1
- data/lib/view_model/active_record/nested_controller_base.rb +1 -0
- data/lib/view_model/active_record/update_context.rb +8 -6
- data/lib/view_model/active_record/update_data.rb +32 -30
- data/lib/view_model/active_record/update_operation.rb +17 -13
- data/lib/view_model/active_record/visitor.rb +0 -1
- data/lib/view_model/after_transaction_runner.rb +0 -1
- data/lib/view_model/callbacks.rb +3 -1
- data/lib/view_model/controller.rb +13 -3
- data/lib/view_model/deserialization_error.rb +15 -12
- data/lib/view_model/error.rb +12 -10
- data/lib/view_model/error_view.rb +3 -1
- data/lib/view_model/migratable_view.rb +78 -0
- data/lib/view_model/migration.rb +48 -0
- data/lib/view_model/migration/no_path_error.rb +26 -0
- data/lib/view_model/migration/one_way_error.rb +24 -0
- data/lib/view_model/migration/unspecified_version_error.rb +24 -0
- data/lib/view_model/migrator.rb +108 -0
- data/lib/view_model/record.rb +15 -14
- data/lib/view_model/reference.rb +3 -1
- data/lib/view_model/references.rb +8 -5
- data/lib/view_model/registry.rb +14 -2
- data/lib/view_model/schemas.rb +9 -4
- data/lib/view_model/serialization_error.rb +4 -1
- data/lib/view_model/serialize_context.rb +4 -4
- data/lib/view_model/test_helpers.rb +8 -3
- data/lib/view_model/test_helpers/arvm_builder.rb +21 -15
- data/lib/view_model/traversal_context.rb +2 -1
- data/nix/dependencies.nix +5 -0
- data/nix/gem/generate.rb +2 -1
- data/shell.nix +8 -3
- data/test/.rubocop.yml +14 -0
- data/test/helpers/arvm_test_models.rb +12 -9
- data/test/helpers/arvm_test_utilities.rb +5 -3
- data/test/helpers/controller_test_helpers.rb +55 -32
- data/test/helpers/match_enumerator.rb +1 -0
- data/test/helpers/query_logging.rb +2 -1
- data/test/helpers/test_access_control.rb +5 -3
- data/test/helpers/viewmodel_spec_helpers.rb +88 -22
- data/test/unit/view_model/access_control_test.rb +144 -144
- data/test/unit/view_model/active_record/alias_test.rb +15 -13
- data/test/unit/view_model/active_record/belongs_to_test.rb +40 -39
- data/test/unit/view_model/active_record/cache_test.rb +68 -31
- data/test/unit/view_model/active_record/cloner_test.rb +67 -63
- data/test/unit/view_model/active_record/controller_test.rb +113 -65
- data/test/unit/view_model/active_record/counter_test.rb +10 -9
- data/test/unit/view_model/active_record/customization_test.rb +59 -58
- data/test/unit/view_model/active_record/has_many_test.rb +112 -111
- data/test/unit/view_model/active_record/has_many_through_poly_test.rb +15 -14
- data/test/unit/view_model/active_record/has_many_through_test.rb +33 -38
- data/test/unit/view_model/active_record/has_one_test.rb +37 -36
- data/test/unit/view_model/active_record/migration_test.rb +161 -0
- data/test/unit/view_model/active_record/namespacing_test.rb +19 -17
- data/test/unit/view_model/active_record/poly_test.rb +44 -45
- data/test/unit/view_model/active_record/shared_test.rb +30 -28
- data/test/unit/view_model/active_record/version_test.rb +9 -7
- data/test/unit/view_model/active_record_test.rb +72 -72
- data/test/unit/view_model/callbacks_test.rb +19 -15
- data/test/unit/view_model/controller_test.rb +4 -2
- data/test/unit/view_model/record_test.rb +158 -145
- data/test/unit/view_model/registry_test.rb +38 -0
- data/test/unit/view_model/traversal_context_test.rb +4 -5
- data/test/unit/view_model_test.rb +18 -16
- metadata +38 -12
- data/.travis.yml +0 -31
- data/appveyor.yml +0 -22
- data/gemfiles/rails_6_0_beta.gemfile +0 -9
| @@ -1,9 +1,11 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 1 3 | 
             
            require 'minitest/autorun'
         | 
| 2 4 | 
             
            require 'minitest/unit'
         | 
| 3 5 | 
             
            require 'minitest/hooks'
         | 
| 4 6 |  | 
| 5 | 
            -
            require_relative '../../helpers/arvm_test_utilities | 
| 6 | 
            -
            require_relative '../../helpers/viewmodel_spec_helpers | 
| 7 | 
            +
            require_relative '../../helpers/arvm_test_utilities'
         | 
| 8 | 
            +
            require_relative '../../helpers/viewmodel_spec_helpers'
         | 
| 7 9 |  | 
| 8 10 | 
             
            require 'view_model'
         | 
| 9 11 | 
             
            require 'view_model/controller'
         | 
| @@ -1,12 +1,12 @@ | |
| 1 1 | 
             
            # frozen_string_literal: true
         | 
| 2 2 |  | 
| 3 | 
            -
            require_relative  | 
| 3 | 
            +
            require_relative '../../helpers/test_access_control'
         | 
| 4 4 |  | 
| 5 | 
            -
            require  | 
| 5 | 
            +
            require 'minitest/autorun'
         | 
| 6 6 | 
             
            require 'minitest/unit'
         | 
| 7 7 |  | 
| 8 | 
            -
            require  | 
| 9 | 
            -
            require  | 
| 8 | 
            +
            require 'view_model'
         | 
| 9 | 
            +
            require 'view_model/record'
         | 
| 10 10 |  | 
| 11 11 | 
             
            class ViewModel::RecordTest < ActiveSupport::TestCase
         | 
| 12 12 | 
             
              using ViewModel::Utils::Collections
         | 
| @@ -15,6 +15,7 @@ class ViewModel::RecordTest < ActiveSupport::TestCase | |
| 15 15 | 
             
              class TestDeserializeContext < ViewModel::DeserializeContext
         | 
| 16 16 | 
             
                class SharedContext < ViewModel::DeserializeContext::SharedContext
         | 
| 17 17 | 
             
                  attr_reader :targets
         | 
| 18 | 
            +
             | 
| 18 19 | 
             
                  def initialize(targets: [], **rest)
         | 
| 19 20 | 
             
                    super(**rest)
         | 
| 20 21 | 
             
                    @targets = targets
         | 
| @@ -26,16 +27,9 @@ class ViewModel::RecordTest < ActiveSupport::TestCase | |
| 26 27 | 
             
                end
         | 
| 27 28 |  | 
| 28 29 | 
             
                delegate :targets, to: :shared_context
         | 
| 29 | 
            -
             | 
| 30 | 
            -
                def initialize(**rest)
         | 
| 31 | 
            -
                  super(**rest)
         | 
| 32 | 
            -
                end
         | 
| 33 30 | 
             
              end
         | 
| 34 31 |  | 
| 35 32 | 
             
              class TestSerializeContext < ViewModel::SerializeContext
         | 
| 36 | 
            -
                def initialize(**rest)
         | 
| 37 | 
            -
                  super(**rest)
         | 
| 38 | 
            -
                end
         | 
| 39 33 | 
             
              end
         | 
| 40 34 |  | 
| 41 35 | 
             
              class TestViewModel < ViewModel::Record
         | 
| @@ -79,7 +73,7 @@ class ViewModel::RecordTest < ActiveSupport::TestCase | |
| 79 73 | 
             
                    # associations.
         | 
| 80 74 | 
             
                    self.unregistered = true
         | 
| 81 75 |  | 
| 82 | 
            -
                    self.view_name   =  | 
| 76 | 
            +
                    self.view_name   = 'Model'
         | 
| 83 77 | 
             
                    self.model_class = mc
         | 
| 84 78 |  | 
| 85 79 | 
             
                    attrs.each { |a, opts| attribute(a, **opts) }
         | 
| @@ -90,8 +84,8 @@ class ViewModel::RecordTest < ActiveSupport::TestCase | |
| 90 84 |  | 
| 91 85 | 
             
                let(:view_base) do
         | 
| 92 86 | 
             
                  {
         | 
| 93 | 
            -
                     | 
| 94 | 
            -
                     | 
| 87 | 
            +
                    '_type'      => 'Model',
         | 
| 88 | 
            +
                    '_version'   => 1,
         | 
| 95 89 | 
             
                  }
         | 
| 96 90 | 
             
                end
         | 
| 97 91 |  | 
| @@ -144,7 +138,7 @@ class ViewModel::RecordTest < ActiveSupport::TestCase | |
| 144 138 | 
             
                module CanDeserializeToNew
         | 
| 145 139 | 
             
                  def self.included(base)
         | 
| 146 140 | 
             
                    base.instance_eval do
         | 
| 147 | 
            -
                      it  | 
| 141 | 
            +
                      it 'can deserialize to a new model' do
         | 
| 148 142 | 
             
                        vm = viewmodel_class.deserialize_from_view(default_view, deserialize_context: create_context)
         | 
| 149 143 | 
             
                        assert_equal(default_model, vm.model)
         | 
| 150 144 | 
             
                        refute(default_model.equal?(vm.model))
         | 
| @@ -159,7 +153,7 @@ class ViewModel::RecordTest < ActiveSupport::TestCase | |
| 159 153 | 
             
                module CanDeserializeToExisting
         | 
| 160 154 | 
             
                  def self.included(base)
         | 
| 161 155 | 
             
                    base.instance_eval do
         | 
| 162 | 
            -
                      it  | 
| 156 | 
            +
                      it 'can deserialize to existing model with no changes' do
         | 
| 163 157 | 
             
                        vm = viewmodel_class.deserialize_from_view(default_view, deserialize_context: update_context)
         | 
| 164 158 | 
             
                        assert(default_model.equal?(vm.model))
         | 
| 165 159 |  | 
| @@ -172,7 +166,7 @@ class ViewModel::RecordTest < ActiveSupport::TestCase | |
| 172 166 | 
             
                module CanSerialize
         | 
| 173 167 | 
             
                  def self.included(base)
         | 
| 174 168 | 
             
                    base.instance_eval do
         | 
| 175 | 
            -
                      it  | 
| 169 | 
            +
                      it 'can serialize to the expected view' do
         | 
| 176 170 | 
             
                        h = viewmodel_class.new(default_model).to_hash
         | 
| 177 171 | 
             
                        assert_equal(default_view, h)
         | 
| 178 172 | 
             
                      end
         | 
| @@ -180,44 +174,44 @@ class ViewModel::RecordTest < ActiveSupport::TestCase | |
| 180 174 | 
             
                  end
         | 
| 181 175 | 
             
                end
         | 
| 182 176 |  | 
| 183 | 
            -
                describe  | 
| 177 | 
            +
                describe 'with simple attribute' do
         | 
| 184 178 | 
             
                  let(:attributes) { { simple: {} } }
         | 
| 185 179 | 
             
                  include CanSerialize
         | 
| 186 180 | 
             
                  include CanDeserializeToNew
         | 
| 187 181 | 
             
                  include CanDeserializeToExisting
         | 
| 188 182 |  | 
| 189 | 
            -
                  it  | 
| 190 | 
            -
                    new_view = default_view.merge( | 
| 183 | 
            +
                  it 'can be updated' do
         | 
| 184 | 
            +
                    new_view = default_view.merge('simple' => 'changed')
         | 
| 191 185 |  | 
| 192 186 | 
             
                    vm = viewmodel_class.deserialize_from_view(new_view, deserialize_context: update_context)
         | 
| 193 187 |  | 
| 194 | 
            -
                    assert(default_model.equal?(vm.model),  | 
| 195 | 
            -
                    assert_equal( | 
| 188 | 
            +
                    assert(default_model.equal?(vm.model), 'returned model was not the same')
         | 
| 189 | 
            +
                    assert_equal('changed', default_model.simple)
         | 
| 196 190 | 
             
                    assert_edited(vm, changed_attributes: [:simple])
         | 
| 197 191 | 
             
                  end
         | 
| 198 192 |  | 
| 199 | 
            -
                  it  | 
| 200 | 
            -
                    view = default_view.merge( | 
| 193 | 
            +
                  it 'rejects unknown attributes' do
         | 
| 194 | 
            +
                    view = default_view.merge('unknown' => 'illegal')
         | 
| 201 195 | 
             
                    ex = assert_raises(ViewModel::DeserializationError::UnknownAttribute) do
         | 
| 202 196 | 
             
                      viewmodel_class.deserialize_from_view(view, deserialize_context: create_context)
         | 
| 203 197 | 
             
                    end
         | 
| 204 | 
            -
                    assert_equal( | 
| 198 | 
            +
                    assert_equal('unknown', ex.attribute)
         | 
| 205 199 | 
             
                  end
         | 
| 206 200 |  | 
| 207 | 
            -
                  it  | 
| 201 | 
            +
                  it 'edit checks when creating empty' do
         | 
| 208 202 | 
             
                    vm = viewmodel_class.deserialize_from_view(view_base, deserialize_context: create_context)
         | 
| 209 | 
            -
                    refute(default_model.equal?(vm.model),  | 
| 203 | 
            +
                    refute(default_model.equal?(vm.model), 'returned model was the same')
         | 
| 210 204 | 
             
                    assert_edited(vm, new: true)
         | 
| 211 205 | 
             
                  end
         | 
| 212 206 | 
             
                end
         | 
| 213 207 |  | 
| 214 | 
            -
                describe  | 
| 208 | 
            +
                describe 'with validated simple attribute' do
         | 
| 215 209 | 
             
                  let(:attributes) { { validated: {} } }
         | 
| 216 210 | 
             
                  let(:viewmodel_body) do
         | 
| 217 211 | 
             
                    ->(_x) do
         | 
| 218 212 | 
             
                      def validate!
         | 
| 219 | 
            -
                        if validated ==  | 
| 220 | 
            -
                          raise ViewModel::DeserializationError::Validation.new( | 
| 213 | 
            +
                        if validated == 'naughty'
         | 
| 214 | 
            +
                          raise ViewModel::DeserializationError::Validation.new('validated', 'was naughty', nil, self.blame_reference)
         | 
| 221 215 | 
             
                        end
         | 
| 222 216 | 
             
                      end
         | 
| 223 217 | 
             
                    end
         | 
| @@ -227,34 +221,34 @@ class ViewModel::RecordTest < ActiveSupport::TestCase | |
| 227 221 | 
             
                  include CanDeserializeToNew
         | 
| 228 222 | 
             
                  include CanDeserializeToExisting
         | 
| 229 223 |  | 
| 230 | 
            -
                  it  | 
| 231 | 
            -
                    new_view = default_view.merge( | 
| 224 | 
            +
                  it 'rejects update when validation fails' do
         | 
| 225 | 
            +
                    new_view = default_view.merge('validated' => 'naughty')
         | 
| 232 226 |  | 
| 233 227 | 
             
                    ex = assert_raises(ViewModel::DeserializationError::Validation) do
         | 
| 234 228 | 
             
                      viewmodel_class.deserialize_from_view(new_view, deserialize_context: update_context)
         | 
| 235 229 | 
             
                    end
         | 
| 236 | 
            -
                    assert_equal( | 
| 237 | 
            -
                    assert_equal( | 
| 230 | 
            +
                    assert_equal('validated', ex.attribute)
         | 
| 231 | 
            +
                    assert_equal('was naughty', ex.reason)
         | 
| 238 232 | 
             
                  end
         | 
| 239 233 | 
             
                end
         | 
| 240 234 |  | 
| 241 | 
            -
                describe  | 
| 235 | 
            +
                describe 'with renamed attribute' do
         | 
| 242 236 | 
             
                  let(:attributes) { { modelname: { as: :viewname } } }
         | 
| 243 | 
            -
                  let(:default_model_values) { { modelname:  | 
| 244 | 
            -
                  let(:default_view_values)  { { viewname:  | 
| 237 | 
            +
                  let(:default_model_values) { { modelname: 'value' } }
         | 
| 238 | 
            +
                  let(:default_view_values)  { { viewname: 'value' } }
         | 
| 245 239 |  | 
| 246 240 | 
             
                  include CanSerialize
         | 
| 247 241 | 
             
                  include CanDeserializeToNew
         | 
| 248 242 | 
             
                  include CanDeserializeToExisting
         | 
| 249 243 |  | 
| 250 | 
            -
                  it  | 
| 251 | 
            -
                    value(default_model.modelname).must_equal( | 
| 244 | 
            +
                  it 'makes attributes available on their new names' do
         | 
| 245 | 
            +
                    value(default_model.modelname).must_equal('value')
         | 
| 252 246 | 
             
                    vm = viewmodel_class.new(default_model)
         | 
| 253 | 
            -
                    value(vm.viewname).must_equal( | 
| 247 | 
            +
                    value(vm.viewname).must_equal('value')
         | 
| 254 248 | 
             
                  end
         | 
| 255 249 | 
             
                end
         | 
| 256 250 |  | 
| 257 | 
            -
                describe  | 
| 251 | 
            +
                describe 'with formatted attribute' do
         | 
| 258 252 | 
             
                  let(:attributes) { { moment: { format: IknowParams::Serializer::Time } } }
         | 
| 259 253 | 
             
                  let(:moment) { 1.week.ago.change(usec: 0) }
         | 
| 260 254 | 
             
                  let(:default_model_values) { { moment: moment } }
         | 
| @@ -264,8 +258,8 @@ class ViewModel::RecordTest < ActiveSupport::TestCase | |
| 264 258 | 
             
                  include CanDeserializeToNew
         | 
| 265 259 | 
             
                  include CanDeserializeToExisting
         | 
| 266 260 |  | 
| 267 | 
            -
                  it  | 
| 268 | 
            -
                    bad_view = default_view.tap { |v| v[ | 
| 261 | 
            +
                  it 'raises correctly on an unparseable value' do
         | 
| 262 | 
            +
                    bad_view = default_view.tap { |v| v['moment'] = 'not a timestamp' }
         | 
| 269 263 | 
             
                    ex = assert_raises(ViewModel::DeserializationError::Validation) do
         | 
| 270 264 | 
             
                      viewmodel_class.deserialize_from_view(bad_view, deserialize_context: create_context)
         | 
| 271 265 | 
             
                    end
         | 
| @@ -273,7 +267,7 @@ class ViewModel::RecordTest < ActiveSupport::TestCase | |
| 273 267 | 
             
                    assert_match(/could not be deserialized because.*Time/, ex.detail)
         | 
| 274 268 | 
             
                  end
         | 
| 275 269 |  | 
| 276 | 
            -
                  it  | 
| 270 | 
            +
                  it 'raises correctly on an undeserializable value' do
         | 
| 277 271 | 
             
                    bad_model = default_model.tap { |m| m.moment = 2.7 }
         | 
| 278 272 | 
             
                    ex = assert_raises(ViewModel::SerializationError) do
         | 
| 279 273 | 
             
                      viewmodel_class.new(bad_model).to_hash
         | 
| @@ -282,40 +276,40 @@ class ViewModel::RecordTest < ActiveSupport::TestCase | |
| 282 276 | 
             
                  end
         | 
| 283 277 | 
             
                end
         | 
| 284 278 |  | 
| 285 | 
            -
                describe  | 
| 279 | 
            +
                describe 'with read-only attribute' do
         | 
| 286 280 | 
             
                  let(:attributes) { { read_only: { read_only: true } } }
         | 
| 287 281 |  | 
| 288 282 | 
             
                  include CanSerialize
         | 
| 289 283 | 
             
                  include CanDeserializeToExisting
         | 
| 290 284 |  | 
| 291 | 
            -
                  it  | 
| 292 | 
            -
                    new_view = default_view.tap { |v| v.delete( | 
| 285 | 
            +
                  it 'deserializes to new without the attribute' do
         | 
| 286 | 
            +
                    new_view = default_view.tap { |v| v.delete('read_only') }
         | 
| 293 287 | 
             
                    vm = viewmodel_class.deserialize_from_view(new_view, deserialize_context: create_context)
         | 
| 294 288 | 
             
                    refute(default_model.equal?(vm.model))
         | 
| 295 289 | 
             
                    assert_nil(vm.model.read_only)
         | 
| 296 290 | 
             
                    assert_edited(vm, new: true)
         | 
| 297 291 | 
             
                  end
         | 
| 298 292 |  | 
| 299 | 
            -
                  it  | 
| 293 | 
            +
                  it 'rejects deserialize from new' do
         | 
| 300 294 | 
             
                    ex = assert_raises(ViewModel::DeserializationError::ReadOnlyAttribute) do
         | 
| 301 295 | 
             
                      viewmodel_class.deserialize_from_view(default_view, deserialize_context: create_context)
         | 
| 302 296 | 
             
                    end
         | 
| 303 | 
            -
                    assert_equal( | 
| 297 | 
            +
                    assert_equal('read_only', ex.attribute)
         | 
| 304 298 | 
             
                  end
         | 
| 305 299 |  | 
| 306 | 
            -
                  it  | 
| 307 | 
            -
                    new_view = default_view.merge( | 
| 300 | 
            +
                  it 'rejects update if changed' do
         | 
| 301 | 
            +
                    new_view = default_view.merge('read_only' => 'written')
         | 
| 308 302 | 
             
                    ex = assert_raises(ViewModel::DeserializationError::ReadOnlyAttribute) do
         | 
| 309 303 | 
             
                      viewmodel_class.deserialize_from_view(new_view, deserialize_context: update_context)
         | 
| 310 304 | 
             
                    end
         | 
| 311 | 
            -
                    assert_equal( | 
| 305 | 
            +
                    assert_equal('read_only', ex.attribute)
         | 
| 312 306 | 
             
                  end
         | 
| 313 307 | 
             
                end
         | 
| 314 308 |  | 
| 315 | 
            -
                describe  | 
| 309 | 
            +
                describe 'with read-only write-once attribute' do
         | 
| 316 310 | 
             
                  let(:attributes) { { write_once: { read_only: true, write_once: true } } }
         | 
| 317 311 | 
             
                  let(:model_body) do
         | 
| 318 | 
            -
                    ->( | 
| 312 | 
            +
                    ->(_x) do
         | 
| 319 313 | 
             
                      # For the purposes of testing, we assume a record is new and can be
         | 
| 320 314 | 
             
                      # written once to if write_once is nil. We will never write a nil.
         | 
| 321 315 | 
             
                      def new_record?
         | 
| @@ -328,21 +322,21 @@ class ViewModel::RecordTest < ActiveSupport::TestCase | |
| 328 322 | 
             
                  include CanDeserializeToNew
         | 
| 329 323 | 
             
                  include CanDeserializeToExisting
         | 
| 330 324 |  | 
| 331 | 
            -
                  it  | 
| 332 | 
            -
                    new_view = default_view.merge( | 
| 325 | 
            +
                  it 'rejects change to attribute' do
         | 
| 326 | 
            +
                    new_view = default_view.merge('write_once' => 'written')
         | 
| 333 327 | 
             
                    ex = assert_raises(ViewModel::DeserializationError::ReadOnlyAttribute) do
         | 
| 334 328 | 
             
                      viewmodel_class.deserialize_from_view(new_view, deserialize_context: update_context)
         | 
| 335 329 | 
             
                    end
         | 
| 336 | 
            -
                    assert_equal( | 
| 330 | 
            +
                    assert_equal('write_once', ex.attribute)
         | 
| 337 331 | 
             
                  end
         | 
| 338 332 | 
             
                end
         | 
| 339 333 |  | 
| 340 | 
            -
                describe  | 
| 334 | 
            +
                describe 'with custom serialization' do
         | 
| 341 335 | 
             
                  let(:attributes)           { { overridden: {} } }
         | 
| 342 336 | 
             
                  let(:default_view_values)  { { overridden: 10 } }
         | 
| 343 337 | 
             
                  let(:default_model_values) { { overridden: 5 } }
         | 
| 344 338 | 
             
                  let(:viewmodel_body) do
         | 
| 345 | 
            -
                    ->( | 
| 339 | 
            +
                    ->(_x) do
         | 
| 346 340 | 
             
                      def serialize_overridden(json, serialize_context:)
         | 
| 347 341 | 
             
                        json.overridden model.overridden.try { |o| o * 2 }
         | 
| 348 342 | 
             
                      end
         | 
| @@ -359,134 +353,153 @@ class ViewModel::RecordTest < ActiveSupport::TestCase | |
| 359 353 | 
             
                  include CanDeserializeToNew
         | 
| 360 354 | 
             
                  include CanDeserializeToExisting
         | 
| 361 355 |  | 
| 362 | 
            -
                  it  | 
| 363 | 
            -
                    new_view = default_view.merge( | 
| 356 | 
            +
                  it 'can be updated' do
         | 
| 357 | 
            +
                    new_view = default_view.merge('overridden' => '20')
         | 
| 364 358 |  | 
| 365 359 | 
             
                    vm = viewmodel_class.deserialize_from_view(new_view, deserialize_context: update_context)
         | 
| 366 360 |  | 
| 367 | 
            -
                    assert(default_model.equal?(vm.model),  | 
| 361 | 
            +
                    assert(default_model.equal?(vm.model), 'returned model was not the same')
         | 
| 368 362 | 
             
                    assert_equal(10, default_model.overridden)
         | 
| 369 363 |  | 
| 370 364 | 
             
                    assert_edited(vm, changed_attributes: [:overridden])
         | 
| 371 365 | 
             
                  end
         | 
| 372 366 | 
             
                end
         | 
| 373 367 |  | 
| 374 | 
            -
                 | 
| 368 | 
            +
                describe 'nesting' do
         | 
| 369 | 
            +
                  let(:nested_model_class) do
         | 
| 370 | 
            +
                    klass = Struct.new(:member)
         | 
| 371 | 
            +
                    Object.const_set(:Nested, klass)
         | 
| 372 | 
            +
                    klass
         | 
| 373 | 
            +
                  end
         | 
| 375 374 |  | 
| 376 | 
            -
             | 
| 377 | 
            -
             | 
| 378 | 
            -
             | 
| 379 | 
            -
             | 
| 380 | 
            -
             | 
| 375 | 
            +
                  let(:nested_viewmodel_class) do
         | 
| 376 | 
            +
                    mc = nested_model_class
         | 
| 377 | 
            +
                    klass = Class.new(TestViewModel) do
         | 
| 378 | 
            +
                      self.view_name = 'Nested'
         | 
| 379 | 
            +
                      self.model_class = mc
         | 
| 380 | 
            +
                      attribute :member
         | 
| 381 | 
            +
                    end
         | 
| 382 | 
            +
                    Object.const_set(:NestedView, klass)
         | 
| 383 | 
            +
                    klass
         | 
| 384 | 
            +
                  end
         | 
| 381 385 |  | 
| 382 | 
            -
             | 
| 383 | 
            -
             | 
| 384 | 
            -
             | 
| 386 | 
            +
                  def teardown
         | 
| 387 | 
            +
                    Object.send(:remove_const, :Nested)
         | 
| 388 | 
            +
                    Object.send(:remove_const, :NestedView)
         | 
| 389 | 
            +
                    ActiveSupport::Dependencies::Reference.clear!
         | 
| 390 | 
            +
                    super
         | 
| 391 | 
            +
                  end
         | 
| 385 392 |  | 
| 386 | 
            -
                   | 
| 393 | 
            +
                  describe 'with nested viewmodel' do
         | 
| 394 | 
            +
                    let(:default_nested_model) { nested_model_class.new('member') }
         | 
| 395 | 
            +
                    let(:default_nested_view)  { view_base.merge('_type' => 'Nested', 'member' => 'member') }
         | 
| 387 396 |  | 
| 388 | 
            -
             | 
| 389 | 
            -
                  let(:default_model_values) { { nested: default_nested_model } }
         | 
| 397 | 
            +
                    let(:attributes) { { simple: {}, nested: { using: nested_viewmodel_class } } }
         | 
| 390 398 |  | 
| 391 | 
            -
             | 
| 392 | 
            -
             | 
| 399 | 
            +
                    let(:default_view_values)  { { nested: default_nested_view } }
         | 
| 400 | 
            +
                    let(:default_model_values) { { nested: default_nested_model } }
         | 
| 393 401 |  | 
| 394 | 
            -
             | 
| 395 | 
            -
             | 
| 396 | 
            -
             | 
| 402 | 
            +
                    let(:update_context) do
         | 
| 403 | 
            +
                      TestDeserializeContext.new(targets: [default_model, default_nested_model],
         | 
| 404 | 
            +
                                                 access_control: access_control)
         | 
| 405 | 
            +
                    end
         | 
| 397 406 |  | 
| 398 | 
            -
             | 
| 399 | 
            -
                     | 
| 407 | 
            +
                    include CanSerialize
         | 
| 408 | 
            +
                    include CanDeserializeToNew
         | 
| 409 | 
            +
                    include CanDeserializeToExisting
         | 
| 400 410 |  | 
| 401 | 
            -
                     | 
| 411 | 
            +
                    it 'can update the nested value' do
         | 
| 412 | 
            +
                      new_view = default_view.merge('nested' => default_nested_view.merge('member' => 'changed'))
         | 
| 402 413 |  | 
| 403 | 
            -
             | 
| 404 | 
            -
                    assert(default_nested_model.equal?(vm.model.nested), "returned nested model was not the same")
         | 
| 414 | 
            +
                      vm = viewmodel_class.deserialize_from_view(new_view, deserialize_context: update_context)
         | 
| 405 415 |  | 
| 406 | 
            -
             | 
| 416 | 
            +
                      assert(default_model.equal?(vm.model), 'returned model was not the same')
         | 
| 417 | 
            +
                      assert(default_nested_model.equal?(vm.model.nested), 'returned nested model was not the same')
         | 
| 407 418 |  | 
| 408 | 
            -
             | 
| 409 | 
            -
                    assert_edited(vm.nested, changed_attributes: [:member])
         | 
| 410 | 
            -
                  end
         | 
| 419 | 
            +
                      assert_equal('changed', default_model.nested.member)
         | 
| 411 420 |  | 
| 412 | 
            -
             | 
| 413 | 
            -
             | 
| 414 | 
            -
                     | 
| 421 | 
            +
                      assert_unchanged(vm)
         | 
| 422 | 
            +
                      assert_edited(vm.nested, changed_attributes: [:member])
         | 
| 423 | 
            +
                    end
         | 
| 424 | 
            +
             | 
| 425 | 
            +
                    it 'can replace the nested value' do
         | 
| 426 | 
            +
                      # The value will be unified if it is different after deserialization
         | 
| 427 | 
            +
                      new_view = default_view.merge('nested' => default_nested_view.merge('member' => 'changed'))
         | 
| 415 428 |  | 
| 416 | 
            -
             | 
| 417 | 
            -
             | 
| 429 | 
            +
                      partial_update_context = TestDeserializeContext.new(targets: [default_model],
         | 
| 430 | 
            +
                                                                          access_control: access_control)
         | 
| 418 431 |  | 
| 419 | 
            -
             | 
| 432 | 
            +
                      vm = viewmodel_class.deserialize_from_view(new_view, deserialize_context: partial_update_context)
         | 
| 420 433 |  | 
| 421 | 
            -
             | 
| 422 | 
            -
             | 
| 434 | 
            +
                      assert(default_model.equal?(vm.model), 'returned model was not the same')
         | 
| 435 | 
            +
                      refute(default_nested_model.equal?(vm.model.nested), 'returned nested model was the same')
         | 
| 423 436 |  | 
| 424 | 
            -
             | 
| 425 | 
            -
             | 
| 437 | 
            +
                      assert_edited(vm, new: false, changed_attributes: [:nested])
         | 
| 438 | 
            +
                      assert_edited(vm.nested, new: true, changed_attributes: [:member])
         | 
| 439 | 
            +
                    end
         | 
| 426 440 | 
             
                  end
         | 
| 427 | 
            -
                end
         | 
| 428 441 |  | 
| 429 | 
            -
             | 
| 430 | 
            -
             | 
| 431 | 
            -
             | 
| 442 | 
            +
                  describe 'with array of nested viewmodel' do
         | 
| 443 | 
            +
                    let(:default_nested_model_1) { nested_model_class.new('member1') }
         | 
| 444 | 
            +
                    let(:default_nested_view_1)  { view_base.merge('_type' => 'Nested', 'member' => 'member1') }
         | 
| 432 445 |  | 
| 433 | 
            -
             | 
| 434 | 
            -
             | 
| 446 | 
            +
                    let(:default_nested_model_2) { nested_model_class.new('member2') }
         | 
| 447 | 
            +
                    let(:default_nested_view_2)  { view_base.merge('_type' => 'Nested', 'member' => 'member2') }
         | 
| 435 448 |  | 
| 436 | 
            -
             | 
| 449 | 
            +
                    let(:attributes) { { simple: {}, nested: { using: nested_viewmodel_class, array: true } } }
         | 
| 437 450 |  | 
| 438 | 
            -
             | 
| 439 | 
            -
             | 
| 451 | 
            +
                    let(:default_view_values)  { { nested: [default_nested_view_1, default_nested_view_2] } }
         | 
| 452 | 
            +
                    let(:default_model_values) { { nested: [default_nested_model_1, default_nested_model_2] } }
         | 
| 440 453 |  | 
| 441 | 
            -
             | 
| 442 | 
            -
             | 
| 443 | 
            -
             | 
| 444 | 
            -
             | 
| 454 | 
            +
                    let(:update_context) {
         | 
| 455 | 
            +
                      TestDeserializeContext.new(targets: [default_model, default_nested_model_1, default_nested_model_2],
         | 
| 456 | 
            +
                                                 access_control: access_control)
         | 
| 457 | 
            +
                    }
         | 
| 445 458 |  | 
| 446 | 
            -
             | 
| 447 | 
            -
             | 
| 448 | 
            -
             | 
| 459 | 
            +
                    include CanSerialize
         | 
| 460 | 
            +
                    include CanDeserializeToNew
         | 
| 461 | 
            +
                    include CanDeserializeToExisting
         | 
| 449 462 |  | 
| 450 | 
            -
             | 
| 451 | 
            -
             | 
| 452 | 
            -
             | 
| 453 | 
            -
             | 
| 463 | 
            +
                    it 'rejects change to attribute' do
         | 
| 464 | 
            +
                      new_view = default_view.merge('nested' => 'terrible')
         | 
| 465 | 
            +
                      ex = assert_raises(ViewModel::DeserializationError::InvalidAttributeType) do
         | 
| 466 | 
            +
                        viewmodel_class.deserialize_from_view(new_view, deserialize_context: update_context)
         | 
| 467 | 
            +
                      end
         | 
| 468 | 
            +
                      assert_equal('nested', ex.attribute)
         | 
| 469 | 
            +
                      assert_equal('Array',  ex.expected_type)
         | 
| 470 | 
            +
                      assert_equal('String', ex.provided_type)
         | 
| 454 471 | 
             
                    end
         | 
| 455 | 
            -
                    assert_equal("nested", ex.attribute)
         | 
| 456 | 
            -
                    assert_equal("Array",  ex.expected_type)
         | 
| 457 | 
            -
                    assert_equal("String", ex.provided_type)
         | 
| 458 | 
            -
                  end
         | 
| 459 472 |  | 
| 460 | 
            -
             | 
| 461 | 
            -
             | 
| 462 | 
            -
             | 
| 463 | 
            -
             | 
| 464 | 
            -
             | 
| 465 | 
            -
             | 
| 466 | 
            -
             | 
| 473 | 
            +
                    it 'can edit a nested value' do
         | 
| 474 | 
            +
                      default_view['nested'][0]['member'] = 'changed'
         | 
| 475 | 
            +
                      vm = viewmodel_class.deserialize_from_view(default_view, deserialize_context: update_context)
         | 
| 476 | 
            +
                      assert(default_model.equal?(vm.model), 'returned model was not the same')
         | 
| 477 | 
            +
                      assert_equal(2, vm.model.nested.size)
         | 
| 478 | 
            +
                      assert(default_nested_model_1.equal?(vm.model.nested[0]))
         | 
| 479 | 
            +
                      assert(default_nested_model_2.equal?(vm.model.nested[1]))
         | 
| 467 480 |  | 
| 468 | 
            -
             | 
| 469 | 
            -
             | 
| 470 | 
            -
             | 
| 481 | 
            +
                      assert_unchanged(vm)
         | 
| 482 | 
            +
                      assert_edited(vm.nested[0], changed_attributes: [:member])
         | 
| 483 | 
            +
                    end
         | 
| 471 484 |  | 
| 472 | 
            -
             | 
| 473 | 
            -
             | 
| 485 | 
            +
                    it 'can append a nested value' do
         | 
| 486 | 
            +
                      default_view['nested'] << view_base.merge('_type' => 'Nested', 'member' => 'member3')
         | 
| 474 487 |  | 
| 475 | 
            -
             | 
| 488 | 
            +
                      vm = viewmodel_class.deserialize_from_view(default_view, deserialize_context: update_context)
         | 
| 476 489 |  | 
| 477 | 
            -
             | 
| 478 | 
            -
             | 
| 479 | 
            -
             | 
| 480 | 
            -
             | 
| 490 | 
            +
                      assert(default_model.equal?(vm.model), 'returned model was not the same')
         | 
| 491 | 
            +
                      assert_equal(3, vm.model.nested.size)
         | 
| 492 | 
            +
                      assert(default_nested_model_1.equal?(vm.model.nested[0]))
         | 
| 493 | 
            +
                      assert(default_nested_model_2.equal?(vm.model.nested[1]))
         | 
| 481 494 |  | 
| 482 | 
            -
             | 
| 483 | 
            -
             | 
| 484 | 
            -
             | 
| 495 | 
            +
                      vm.model.nested.each_with_index do |nvm, i|
         | 
| 496 | 
            +
                        assert_equal("member#{i + 1}", nvm.member)
         | 
| 497 | 
            +
                      end
         | 
| 485 498 |  | 
| 486 | 
            -
             | 
| 487 | 
            -
             | 
| 499 | 
            +
                      assert_edited(vm, changed_attributes: [:nested])
         | 
| 500 | 
            +
                      assert_edited(vm.nested[2], new: true, changed_attributes: [:member])
         | 
| 501 | 
            +
                    end
         | 
| 488 502 | 
             
                  end
         | 
| 489 503 | 
             
                end
         | 
| 490 504 | 
             
              end
         | 
| 491 | 
            -
             | 
| 492 505 | 
             
            end
         |