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
| @@ -40,10 +40,10 @@ module ViewModel::ActiveRecord::Cache::CacheableView | |
| 40 40 | 
             
                  @viewmodel_cache
         | 
| 41 41 | 
             
                end
         | 
| 42 42 |  | 
| 43 | 
            -
                def serialize_from_cache(views, serialize_context:)
         | 
| 43 | 
            +
                def serialize_from_cache(views, migration_versions: {}, locked: false, serialize_context:)
         | 
| 44 44 | 
             
                  plural = views.is_a?(Array)
         | 
| 45 45 | 
             
                  views = Array.wrap(views)
         | 
| 46 | 
            -
                  json_views, json_refs = viewmodel_cache.fetch_by_viewmodel(views, serialize_context: serialize_context)
         | 
| 46 | 
            +
                  json_views, json_refs = viewmodel_cache.fetch_by_viewmodel(views, locked: locked, migration_versions: migration_versions, serialize_context: serialize_context)
         | 
| 47 47 | 
             
                  json_views = json_views.first unless plural
         | 
| 48 48 | 
             
                  return json_views, json_refs
         | 
| 49 49 | 
             
                end
         | 
| @@ -47,7 +47,7 @@ module ViewModel::ActiveRecord::CollectionNestedController | |
| 47 47 | 
             
                  after  = parse_relative_position(:after)
         | 
| 48 48 |  | 
| 49 49 | 
             
                  if before && after
         | 
| 50 | 
            -
                    raise ViewModel::DeserializationError::InvalidSyntax.new( | 
| 50 | 
            +
                    raise ViewModel::DeserializationError::InvalidSyntax.new('Can not provide both `before` and `after` anchors for a collection append')
         | 
| 51 51 | 
             
                  end
         | 
| 52 52 |  | 
| 53 53 | 
             
                  owner_view = owner_viewmodel.find(owner_viewmodel_id, eager_include: false, serialize_context: serialize_context)
         | 
| @@ -55,8 +55,8 @@ module ViewModel::ActiveRecord::CollectionNestedController | |
| 55 55 | 
             
                  assoc_view = owner_view.append_associated(association_name,
         | 
| 56 56 | 
             
                                                            update_hash,
         | 
| 57 57 | 
             
                                                            references: refs,
         | 
| 58 | 
            -
                                                            before: | 
| 59 | 
            -
                                                            after: | 
| 58 | 
            +
                                                            before: before,
         | 
| 59 | 
            +
                                                            after: after,
         | 
| 60 60 | 
             
                                                            deserialize_context: deserialize_context)
         | 
| 61 61 | 
             
                  ViewModel::Callbacks.wrap_serialize(owner_view, context: serialize_context) do
         | 
| 62 62 | 
             
                    child_context = owner_view.context_for_child(association_name, context: serialize_context)
         | 
| @@ -17,6 +17,8 @@ module ViewModel::ActiveRecord::Controller | |
| 17 17 | 
             
              include ViewModel::ActiveRecord::CollectionNestedController
         | 
| 18 18 | 
             
              include ViewModel::ActiveRecord::SingularNestedController
         | 
| 19 19 |  | 
| 20 | 
            +
              MIGRATION_VERSION_HEADER = 'X-ViewModel-Versions'
         | 
| 21 | 
            +
             | 
| 20 22 | 
             
              def show(scope: nil, viewmodel_class: self.viewmodel_class, serialize_context: new_serialize_context(viewmodel_class: viewmodel_class))
         | 
| 21 23 | 
             
                view = nil
         | 
| 22 24 | 
             
                pre_rendered = viewmodel_class.transaction do
         | 
| @@ -62,7 +64,29 @@ module ViewModel::ActiveRecord::Controller | |
| 62 64 | 
             
              end
         | 
| 63 65 |  | 
| 64 66 | 
             
              included do
         | 
| 65 | 
            -
                etag {  | 
| 67 | 
            +
                etag { migrated_deep_schema_version }
         | 
| 68 | 
            +
              end
         | 
| 69 | 
            +
             | 
| 70 | 
            +
              def parse_viewmodel_updates
         | 
| 71 | 
            +
                super.tap do |update_hash, refs|
         | 
| 72 | 
            +
                  if migration_versions.present?
         | 
| 73 | 
            +
                    migrator = ViewModel::UpMigrator.new(migration_versions)
         | 
| 74 | 
            +
                    migrator.migrate!([update_hash, refs], references: refs)
         | 
| 75 | 
            +
                  end
         | 
| 76 | 
            +
                end
         | 
| 77 | 
            +
              end
         | 
| 78 | 
            +
             | 
| 79 | 
            +
              def prerender_viewmodel(*)
         | 
| 80 | 
            +
                super do |jbuilder|
         | 
| 81 | 
            +
                  yield(jbuilder) if block_given?
         | 
| 82 | 
            +
             | 
| 83 | 
            +
                  # migrate the resulting structure before it's serialized to a json string
         | 
| 84 | 
            +
                  if migration_versions.present?
         | 
| 85 | 
            +
                    tree = jbuilder.attributes!
         | 
| 86 | 
            +
                    migrator = ViewModel::DownMigrator.new(migration_versions)
         | 
| 87 | 
            +
                    migrator.migrate!(tree, references: tree['references'])
         | 
| 88 | 
            +
                  end
         | 
| 89 | 
            +
                end
         | 
| 66 90 | 
             
              end
         | 
| 67 91 |  | 
| 68 92 | 
             
              private
         | 
| @@ -70,4 +94,47 @@ module ViewModel::ActiveRecord::Controller | |
| 70 94 | 
             
              def viewmodel_id
         | 
| 71 95 | 
             
                parse_param(:id)
         | 
| 72 96 | 
             
              end
         | 
| 97 | 
            +
             | 
| 98 | 
            +
              def migration_versions
         | 
| 99 | 
            +
                @migration_versions ||=
         | 
| 100 | 
            +
                  begin
         | 
| 101 | 
            +
                    version_spec =
         | 
| 102 | 
            +
                      if params.include?(:versions)
         | 
| 103 | 
            +
                        params[:versions]
         | 
| 104 | 
            +
                      elsif request.headers.include?(MIGRATION_VERSION_HEADER)
         | 
| 105 | 
            +
                        begin
         | 
| 106 | 
            +
                          JSON.parse(request.headers[MIGRATION_VERSION_HEADER])
         | 
| 107 | 
            +
                        rescue JSON::ParserError
         | 
| 108 | 
            +
                          raise ViewModel::Error.new(status: 400, detail: "Invalid JSON in #{MIGRATION_VERSION_HEADER}")
         | 
| 109 | 
            +
                        end
         | 
| 110 | 
            +
                      else
         | 
| 111 | 
            +
                        {}
         | 
| 112 | 
            +
                      end
         | 
| 113 | 
            +
             | 
| 114 | 
            +
                    versions =
         | 
| 115 | 
            +
                      IknowParams::Parser.parse_value(
         | 
| 116 | 
            +
                        version_spec,
         | 
| 117 | 
            +
                        with: IknowParams::Serializer::HashOf.new(
         | 
| 118 | 
            +
                          IknowParams::Serializer::String, IknowParams::Serializer::Integer))
         | 
| 119 | 
            +
             | 
| 120 | 
            +
                    migration_versions = {}
         | 
| 121 | 
            +
             | 
| 122 | 
            +
                    versions.each do |view_name, required_version|
         | 
| 123 | 
            +
                      viewmodel_class = ViewModel::Registry.for_view_name(view_name)
         | 
| 124 | 
            +
             | 
| 125 | 
            +
                      if viewmodel_class.schema_version != required_version
         | 
| 126 | 
            +
                        migration_versions[viewmodel_class] = required_version
         | 
| 127 | 
            +
                      end
         | 
| 128 | 
            +
                    rescue ViewModel::DeserializationError::UnknownView
         | 
| 129 | 
            +
                      # Ignore requests to migrate types that no longer exist
         | 
| 130 | 
            +
                      next
         | 
| 131 | 
            +
                    end
         | 
| 132 | 
            +
             | 
| 133 | 
            +
                    migration_versions.freeze
         | 
| 134 | 
            +
                  end
         | 
| 135 | 
            +
              end
         | 
| 136 | 
            +
             | 
| 137 | 
            +
              def migrated_deep_schema_version
         | 
| 138 | 
            +
                ViewModel::Migrator.migrated_deep_schema_version(viewmodel_class, migration_versions, include_referenced: true)
         | 
| 139 | 
            +
              end
         | 
| 73 140 | 
             
            end
         | 
| @@ -33,7 +33,7 @@ module ViewModel::ActiveRecord::ControllerBase | |
| 33 33 | 
             
                    if (match = /(.*)Controller$/.match(self.name))
         | 
| 34 34 | 
             
                      self.viewmodel_name = match[1].singularize
         | 
| 35 35 | 
             
                    else
         | 
| 36 | 
            -
                      raise ArgumentError.new("Could not auto-determine ViewModel from Controller name '#{self.name}'") | 
| 36 | 
            +
                      raise ArgumentError.new("Could not auto-determine ViewModel from Controller name '#{self.name}'")
         | 
| 37 37 | 
             
                    end
         | 
| 38 38 | 
             
                  end
         | 
| 39 39 | 
             
                  @viewmodel_class
         | 
| @@ -43,6 +43,7 @@ module ViewModel::ActiveRecord::ControllerBase | |
| 43 43 | 
             
                  unless instance_variable_defined?(:@access_control)
         | 
| 44 44 | 
             
                    raise ArgumentError.new("AccessControl instance not set for Controller '#{self.name}'")
         | 
| 45 45 | 
             
                  end
         | 
| 46 | 
            +
             | 
| 46 47 | 
             
                  @access_control
         | 
| 47 48 | 
             
                end
         | 
| 48 49 |  | 
| @@ -64,6 +65,7 @@ module ViewModel::ActiveRecord::ControllerBase | |
| 64 65 | 
             
                  unless type < ViewModel
         | 
| 65 66 | 
             
                    raise ArgumentError.new("'#{type.inspect}' is not a valid ViewModel")
         | 
| 66 67 | 
             
                  end
         | 
| 68 | 
            +
             | 
| 67 69 | 
             
                  @viewmodel_class = type
         | 
| 68 70 | 
             
                end
         | 
| 69 71 |  | 
| @@ -75,6 +77,7 @@ module ViewModel::ActiveRecord::ControllerBase | |
| 75 77 | 
             
                  unless access_control.is_a?(Class) && access_control < ViewModel::AccessControl
         | 
| 76 78 | 
             
                    raise ArgumentError.new("'#{access_control.inspect}' is not a valid AccessControl")
         | 
| 77 79 | 
             
                  end
         | 
| 80 | 
            +
             | 
| 78 81 | 
             
                  @access_control = access_control
         | 
| 79 82 | 
             
                end
         | 
| 80 83 | 
             
              end
         | 
| @@ -1,3 +1,5 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 1 3 | 
             
            # Assembles an update operation tree from user input. Handles the interlinking
         | 
| 2 4 | 
             
            # and model of update operations, but does not handle the actual user data nor
         | 
| 3 5 | 
             
            # the mechanism by which it is applied to models.
         | 
| @@ -66,7 +68,7 @@ class ViewModel::ActiveRecord | |
| 66 68 | 
             
                    .assemble_update_tree
         | 
| 67 69 | 
             
                end
         | 
| 68 70 |  | 
| 69 | 
            -
                # TODO an unfortunate abstraction violation. The `append` case constructs an
         | 
| 71 | 
            +
                # TODO: an unfortunate abstraction violation. The `append` case constructs an
         | 
| 70 72 | 
             
                # update tree and later injects the context of parent and position.
         | 
| 71 73 | 
             
                def root_updates
         | 
| 72 74 | 
             
                  @root_update_operations
         | 
| @@ -143,7 +145,7 @@ class ViewModel::ActiveRecord | |
| 143 145 | 
             
                      if ref.nil?
         | 
| 144 146 | 
             
                        @root_update_operations << update_op
         | 
| 145 147 | 
             
                      else
         | 
| 146 | 
            -
                        # TODO make sure that referenced subtree hashes are unique and provide a decent error message
         | 
| 148 | 
            +
                        # TODO: make sure that referenced subtree hashes are unique and provide a decent error message
         | 
| 147 149 | 
             
                        # not strictly necessary, but will save confusion
         | 
| 148 150 | 
             
                        @referenced_update_operations[ref] = update_op
         | 
| 149 151 | 
             
                      end
         | 
| @@ -200,9 +202,9 @@ class ViewModel::ActiveRecord | |
| 200 202 | 
             
                    deferred_update.build!(self)
         | 
| 201 203 | 
             
                  end
         | 
| 202 204 |  | 
| 203 | 
            -
                  dangling_references = @referenced_update_operations.reject { | | 
| 205 | 
            +
                  dangling_references = @referenced_update_operations.reject { |_ref, upd| upd.built? }.map { |_ref, upd| upd.viewmodel.to_reference }
         | 
| 204 206 | 
             
                  if dangling_references.present?
         | 
| 205 | 
            -
                    raise ViewModel::DeserializationError::InvalidStructure.new( | 
| 207 | 
            +
                    raise ViewModel::DeserializationError::InvalidStructure.new('References not referred to from roots', dangling_references)
         | 
| 206 208 | 
             
                  end
         | 
| 207 209 |  | 
| 208 210 | 
             
                  self
         | 
| @@ -265,8 +267,8 @@ class ViewModel::ActiveRecord | |
| 265 267 | 
             
                # individual node that caused it, without attempting to parse Postgres'
         | 
| 266 268 | 
             
                # human-readable error details.
         | 
| 267 269 | 
             
                def check_deferred_constraints!(model_class)
         | 
| 268 | 
            -
                  if model_class.connection.adapter_name ==  | 
| 269 | 
            -
                    model_class.connection.execute( | 
| 270 | 
            +
                  if model_class.connection.adapter_name == 'PostgreSQL'
         | 
| 271 | 
            +
                    model_class.connection.execute('SET CONSTRAINTS ALL IMMEDIATE')
         | 
| 270 272 | 
             
                  end
         | 
| 271 273 | 
             
                rescue ::ActiveRecord::StatementInvalid => ex
         | 
| 272 274 | 
             
                  raise ViewModel::DeserializationError::DatabaseConstraint.from_exception(ex)
         | 
| @@ -24,6 +24,7 @@ class ViewModel::ActiveRecord | |
| 24 24 | 
             
                  NAME = 'append'
         | 
| 25 25 | 
             
                  attr_accessor :before, :after
         | 
| 26 26 | 
             
                  attr_reader :contents
         | 
| 27 | 
            +
             | 
| 27 28 | 
             
                  def initialize(contents)
         | 
| 28 29 | 
             
                    @contents = contents
         | 
| 29 30 | 
             
                  end
         | 
| @@ -32,6 +33,7 @@ class ViewModel::ActiveRecord | |
| 32 33 | 
             
                class Update
         | 
| 33 34 | 
             
                  NAME = 'update'
         | 
| 34 35 | 
             
                  attr_reader :contents
         | 
| 36 | 
            +
             | 
| 35 37 | 
             
                  def initialize(contents)
         | 
| 36 38 | 
             
                    @contents = contents
         | 
| 37 39 | 
             
                  end
         | 
| @@ -56,6 +58,7 @@ class ViewModel::ActiveRecord | |
| 56 58 | 
             
                # associations or reference strings for root.
         | 
| 57 59 | 
             
                class Replace
         | 
| 58 60 | 
             
                  attr_reader :contents
         | 
| 61 | 
            +
             | 
| 59 62 | 
             
                  def initialize(contents)
         | 
| 60 63 | 
             
                    @contents = contents
         | 
| 61 64 | 
             
                  end
         | 
| @@ -66,6 +69,7 @@ class ViewModel::ActiveRecord | |
| 66 69 | 
             
                # associations.
         | 
| 67 70 | 
             
                class Functional
         | 
| 68 71 | 
             
                  attr_reader :actions
         | 
| 72 | 
            +
             | 
| 69 73 | 
             
                  def initialize(actions)
         | 
| 70 74 | 
             
                    @actions = actions
         | 
| 71 75 | 
             
                  end
         | 
| @@ -215,23 +219,23 @@ class ViewModel::ActiveRecord | |
| 215 219 | 
             
                  # The contents of the actions are determined by the subclasses
         | 
| 216 220 |  | 
| 217 221 | 
             
                  def functional_update_schema # abstract
         | 
| 218 | 
            -
                    raise  | 
| 222 | 
            +
                    raise 'abstract'
         | 
| 219 223 | 
             
                  end
         | 
| 220 224 |  | 
| 221 225 | 
             
                  def append_action_schema # abstract
         | 
| 222 | 
            -
                    raise  | 
| 226 | 
            +
                    raise 'abstract'
         | 
| 223 227 | 
             
                  end
         | 
| 224 228 |  | 
| 225 229 | 
             
                  def remove_action_schema # abstract
         | 
| 226 | 
            -
                    raise  | 
| 230 | 
            +
                    raise 'abstract'
         | 
| 227 231 | 
             
                  end
         | 
| 228 232 |  | 
| 229 233 | 
             
                  def update_action_schema # abstract
         | 
| 230 | 
            -
                    raise  | 
| 234 | 
            +
                    raise 'abstract'
         | 
| 231 235 | 
             
                  end
         | 
| 232 236 |  | 
| 233 | 
            -
                  def parse_contents( | 
| 234 | 
            -
                    raise  | 
| 237 | 
            +
                  def parse_contents(_values) # abstract
         | 
| 238 | 
            +
                    raise 'abstract'
         | 
| 235 239 | 
             
                  end
         | 
| 236 240 |  | 
| 237 241 | 
             
                  # Remove values are always anchors
         | 
| @@ -255,11 +259,11 @@ class ViewModel::ActiveRecord | |
| 255 259 | 
             
                  # behaviour, so we parameterise the result type as well.
         | 
| 256 260 |  | 
| 257 261 | 
             
                  def replace_update_type # abstract
         | 
| 258 | 
            -
                    raise  | 
| 262 | 
            +
                    raise 'abstract'
         | 
| 259 263 | 
             
                  end
         | 
| 260 264 |  | 
| 261 265 | 
             
                  def functional_update_type # abstract
         | 
| 262 | 
            -
                    raise  | 
| 266 | 
            +
                    raise 'abstract'
         | 
| 263 267 | 
             
                  end
         | 
| 264 268 | 
             
                end
         | 
| 265 269 | 
             
              end
         | 
| @@ -390,6 +394,7 @@ class ViewModel::ActiveRecord | |
| 390 394 |  | 
| 391 395 | 
             
              class UpdateData
         | 
| 392 396 | 
             
                attr_accessor :viewmodel_class, :metadata, :attributes, :associations, :referenced_associations
         | 
| 397 | 
            +
             | 
| 393 398 | 
             
                delegate :id, :view_name, :schema_version, to: :metadata
         | 
| 394 399 |  | 
| 395 400 | 
             
                module Schemas
         | 
| @@ -400,7 +405,7 @@ class ViewModel::ActiveRecord | |
| 400 405 | 
             
                      'properties'           => { ViewModel::TYPE_ATTRIBUTE => { 'type' => 'string' },
         | 
| 401 406 | 
             
                                                  ViewModel::ID_ATTRIBUTE   => ViewModel::Schemas::ID_SCHEMA },
         | 
| 402 407 | 
             
                      'additionalProperties' => false,
         | 
| 403 | 
            -
                      'required'             => [ViewModel::TYPE_ATTRIBUTE, ViewModel::ID_ATTRIBUTE]
         | 
| 408 | 
            +
                      'required'             => [ViewModel::TYPE_ATTRIBUTE, ViewModel::ID_ATTRIBUTE],
         | 
| 404 409 | 
             
                    }
         | 
| 405 410 |  | 
| 406 411 | 
             
                  VIEWMODEL_REFERENCE_ONLY = JsonSchema.parse!(viewmodel_reference_only)
         | 
| @@ -409,14 +414,14 @@ class ViewModel::ActiveRecord | |
| 409 414 | 
             
                    {
         | 
| 410 415 | 
             
                      'description' => 'functional update',
         | 
| 411 416 | 
             
                      'type'        => 'object',
         | 
| 417 | 
            +
                      'required'    => [ViewModel::TYPE_ATTRIBUTE, VALUES_ATTRIBUTE],
         | 
| 412 418 | 
             
                      'properties'  => {
         | 
| 413 419 | 
             
                        ViewModel::TYPE_ATTRIBUTE => { 'enum' => [FunctionalUpdate::Append::NAME,
         | 
| 414 420 | 
             
                                                                  FunctionalUpdate::Update::NAME,
         | 
| 415 | 
            -
                                                                  FunctionalUpdate::Remove::NAME] },
         | 
| 421 | 
            +
                                                                  FunctionalUpdate::Remove::NAME,] },
         | 
| 416 422 | 
             
                        VALUES_ATTRIBUTE => { 'type'  => 'array',
         | 
| 417 | 
            -
                                              'items' => value_schema }
         | 
| 423 | 
            +
                                              'items' => value_schema },
         | 
| 418 424 | 
             
                      },
         | 
| 419 | 
            -
                      'required' => [ViewModel::TYPE_ATTRIBUTE, VALUES_ATTRIBUTE]
         | 
| 420 425 | 
             
                    }
         | 
| 421 426 | 
             
                  end
         | 
| 422 427 |  | 
| @@ -426,7 +431,7 @@ class ViewModel::ActiveRecord | |
| 426 431 | 
             
                    'properties'           => {
         | 
| 427 432 | 
             
                      ViewModel::TYPE_ATTRIBUTE => { 'enum' => [FunctionalUpdate::Append::NAME] },
         | 
| 428 433 | 
             
                      BEFORE_ATTRIBUTE          => viewmodel_reference_only,
         | 
| 429 | 
            -
                      AFTER_ATTRIBUTE           => viewmodel_reference_only
         | 
| 434 | 
            +
                      AFTER_ATTRIBUTE           => viewmodel_reference_only,
         | 
| 430 435 | 
             
                    },
         | 
| 431 436 | 
             
                  }
         | 
| 432 437 |  | 
| @@ -435,7 +440,7 @@ class ViewModel::ActiveRecord | |
| 435 440 |  | 
| 436 441 | 
             
                  fupdate_shared =
         | 
| 437 442 | 
             
                    fupdate_base.({ 'oneOf' => [ViewModel::Schemas::VIEWMODEL_REFERENCE_SCHEMA,
         | 
| 438 | 
            -
                                                viewmodel_reference_only] })
         | 
| 443 | 
            +
                                                viewmodel_reference_only,] })
         | 
| 439 444 |  | 
| 440 445 | 
             
                  # Referenced updates are special:
         | 
| 441 446 | 
             
                  #  - Append requires `_ref` hashes
         | 
| @@ -450,7 +455,7 @@ class ViewModel::ActiveRecord | |
| 450 455 | 
             
                    'description'          => 'collection update',
         | 
| 451 456 | 
             
                    'additionalProperties' => false,
         | 
| 452 457 | 
             
                    'properties'           => {
         | 
| 453 | 
            -
                      ViewModel::TYPE_ATTRIBUTE => { 'enum' => [FunctionalUpdate::Update::NAME] }
         | 
| 458 | 
            +
                      ViewModel::TYPE_ATTRIBUTE => { 'enum' => [FunctionalUpdate::Update::NAME] },
         | 
| 454 459 | 
             
                    },
         | 
| 455 460 | 
             
                  }
         | 
| 456 461 |  | 
| @@ -471,7 +476,6 @@ class ViewModel::ActiveRecord | |
| 471 476 | 
             
                  REMOVE_ACTION            = JsonSchema.parse!(fupdate_owned.deep_merge(remove_mixin))
         | 
| 472 477 | 
             
                  REFERENCED_REMOVE_ACTION = JsonSchema.parse!(fupdate_shared.deep_merge(remove_mixin))
         | 
| 473 478 |  | 
| 474 | 
            -
             | 
| 475 479 | 
             
                  collection_update = ->(base_schema) do
         | 
| 476 480 | 
             
                    {
         | 
| 477 481 | 
             
                      'type'                 => 'object',
         | 
| @@ -480,7 +484,7 @@ class ViewModel::ActiveRecord | |
| 480 484 | 
             
                      'required'             => [ViewModel::TYPE_ATTRIBUTE, ACTIONS_ATTRIBUTE],
         | 
| 481 485 | 
             
                      'properties'           => {
         | 
| 482 486 | 
             
                        ViewModel::TYPE_ATTRIBUTE => { 'enum' => [FUNCTIONAL_UPDATE_TYPE] },
         | 
| 483 | 
            -
                        ACTIONS_ATTRIBUTE => { 'type' => 'array', 'items' => base_schema }
         | 
| 487 | 
            +
                        ACTIONS_ATTRIBUTE => { 'type' => 'array', 'items' => base_schema },
         | 
| 484 488 | 
             
                        # The ACTIONS_ATTRIBUTE could be accurately expressed as
         | 
| 485 489 | 
             
                        #
         | 
| 486 490 | 
             
                        #   { 'oneOf' => [append, update, remove] }
         | 
| @@ -488,7 +492,7 @@ class ViewModel::ActiveRecord | |
| 488 492 | 
             
                        # but this produces completely unusable error messages.  Instead we
         | 
| 489 493 | 
             
                        # specify it must be an array, and defer checking to the code that
         | 
| 490 494 | 
             
                        # can determine the schema by inspecting the type field.
         | 
| 491 | 
            -
                      }
         | 
| 495 | 
            +
                      },
         | 
| 492 496 | 
             
                    }
         | 
| 493 497 | 
             
                  end
         | 
| 494 498 |  | 
| @@ -503,7 +507,7 @@ class ViewModel::ActiveRecord | |
| 503 507 | 
             
                  when :_type
         | 
| 504 508 | 
             
                    viewmodel_class.view_name
         | 
| 505 509 | 
             
                  else
         | 
| 506 | 
            -
                    attributes.fetch(name) { associations.fetch(name) { referenced_associations.fetch(name) }}
         | 
| 510 | 
            +
                    attributes.fetch(name) { associations.fetch(name) { referenced_associations.fetch(name) } }
         | 
| 507 511 | 
             
                  end
         | 
| 508 512 | 
             
                end
         | 
| 509 513 |  | 
| @@ -538,7 +542,7 @@ class ViewModel::ActiveRecord | |
| 538 542 | 
             
                  end
         | 
| 539 543 |  | 
| 540 544 | 
             
                  # Ensure that no root is referred to more than once
         | 
| 541 | 
            -
                  check_duplicate_updates(root_updates, type:  | 
| 545 | 
            +
                  check_duplicate_updates(root_updates, type: 'root')
         | 
| 542 546 |  | 
| 543 547 | 
             
                  # Construct reference UpdateData
         | 
| 544 548 | 
             
                  referenced_updates = referenced_subtree_hashes.transform_values do |subtree_hash|
         | 
| @@ -549,7 +553,7 @@ class ViewModel::ActiveRecord | |
| 549 553 | 
             
                    UpdateData.new(viewmodel_class, metadata, subtree_hash, valid_reference_keys)
         | 
| 550 554 | 
             
                  end
         | 
| 551 555 |  | 
| 552 | 
            -
                  check_duplicate_updates(referenced_updates.values, type:  | 
| 556 | 
            +
                  check_duplicate_updates(referenced_updates.values, type: 'reference')
         | 
| 553 557 |  | 
| 554 558 | 
             
                  return root_updates, referenced_updates
         | 
| 555 559 | 
             
                end
         | 
| @@ -614,7 +618,7 @@ class ViewModel::ActiveRecord | |
| 614 618 | 
             
                def preload_dependencies
         | 
| 615 619 | 
             
                  deps = {}
         | 
| 616 620 |  | 
| 617 | 
            -
                   | 
| 621 | 
            +
                  associations.merge(referenced_associations).each do |assoc_name, reference|
         | 
| 618 622 | 
             
                    association_data = self.viewmodel_class._association_data(assoc_name)
         | 
| 619 623 |  | 
| 620 624 | 
             
                    preload_specs = build_preload_specs(association_data,
         | 
| @@ -665,7 +669,7 @@ class ViewModel::ActiveRecord | |
| 665 669 |  | 
| 666 670 | 
             
                  # handle view pre-parsing if defined
         | 
| 667 671 | 
             
                  self.viewmodel_class.pre_parse(viewmodel_reference, metadata, hash_data) if self.viewmodel_class.respond_to?(:pre_parse)
         | 
| 668 | 
            -
                  hash_data.keys.each do |key|
         | 
| 672 | 
            +
                  hash_data.keys.each do |key| # rubocop:disable Style/HashEachMethods
         | 
| 669 673 | 
             
                    if self.viewmodel_class.respond_to?(:"pre_parse_#{key}")
         | 
| 670 674 | 
             
                      self.viewmodel_class.public_send("pre_parse_#{key}", viewmodel_reference, metadata, hash_data, hash_data.delete(key))
         | 
| 671 675 | 
             
                    end
         | 
| @@ -710,19 +714,18 @@ class ViewModel::ActiveRecord | |
| 710 714 | 
             
                          referenced_associations[name] = ref
         | 
| 711 715 | 
             
                        end
         | 
| 712 716 | 
             
                      else
         | 
| 713 | 
            -
                         | 
| 714 | 
            -
                           | 
| 717 | 
            +
                        associations[name] =
         | 
| 718 | 
            +
                          if association_data.collection?
         | 
| 715 719 | 
             
                            OwnedCollectionUpdate::Parser
         | 
| 716 720 | 
             
                              .new(association_data, blame_reference, valid_reference_keys)
         | 
| 717 721 | 
             
                              .parse(value)
         | 
| 718 | 
            -
             | 
| 719 | 
            -
             | 
| 720 | 
            -
                            if value.nil?
         | 
| 722 | 
            +
                          else # not a collection
         | 
| 723 | 
            +
                            if value.nil? # rubocop:disable Style/IfInsideElse
         | 
| 721 724 | 
             
                              nil
         | 
| 722 725 | 
             
                            else
         | 
| 723 726 | 
             
                              self.class.parse_associated(association_data, blame_reference, valid_reference_keys, value)
         | 
| 724 727 | 
             
                            end
         | 
| 725 | 
            -
             | 
| 728 | 
            +
                          end
         | 
| 726 729 | 
             
                      end
         | 
| 727 730 | 
             
                    else
         | 
| 728 731 | 
             
                      raise ViewModel::DeserializationError::UnknownAttribute.new(name, blame_reference)
         | 
| @@ -730,7 +733,6 @@ class ViewModel::ActiveRecord | |
| 730 733 | 
             
                  end
         | 
| 731 734 | 
             
                end
         | 
| 732 735 |  | 
| 733 | 
            -
             | 
| 734 736 | 
             
                def blame_reference
         | 
| 735 737 | 
             
                  ViewModel::Reference.new(self.viewmodel_class, self.id)
         | 
| 736 738 | 
             
                end
         | 
| @@ -1,4 +1,6 @@ | |
| 1 | 
            -
             | 
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            require 'renum'
         | 
| 2 4 |  | 
| 3 5 | 
             
            # Partially parsed tree of user-specified update hashes, created during deserialization.
         | 
| 4 6 | 
             
            class ViewModel::ActiveRecord
         | 
| @@ -14,7 +16,7 @@ class ViewModel::ActiveRecord | |
| 14 16 | 
             
                              :update_data,
         | 
| 15 17 | 
             
                              :points_to,  # AssociationData => UpdateOperation (returns single new viewmodel to update fkey)
         | 
| 16 18 | 
             
                              :pointed_to, # AssociationData => UpdateOperation(s) (returns viewmodel(s) with which to update assoc cache)
         | 
| 17 | 
            -
                              :reparent_to, | 
| 19 | 
            +
                              :reparent_to, # If node needs to update its pointer to a new parent, ParentData for the parent
         | 
| 18 20 | 
             
                              :reposition_to, # if this node participates in a list under its parent, what should its position be?
         | 
| 19 21 | 
             
                              :released_children # Set of children that have been released
         | 
| 20 22 |  | 
| @@ -46,11 +48,11 @@ class ViewModel::ActiveRecord | |
| 46 48 |  | 
| 47 49 | 
             
                # Evaluate a built update tree, applying and saving changes to the models.
         | 
| 48 50 | 
             
                def run!(deserialize_context:)
         | 
| 49 | 
            -
                  raise ViewModel::DeserializationError::Internal.new( | 
| 51 | 
            +
                  raise ViewModel::DeserializationError::Internal.new('Internal error: UpdateOperation run before build') unless built?
         | 
| 50 52 |  | 
| 51 53 | 
             
                  case @run_state
         | 
| 52 54 | 
             
                  when RunState::Running
         | 
| 53 | 
            -
                    raise ViewModel::DeserializationError::Internal.new( | 
| 55 | 
            +
                    raise ViewModel::DeserializationError::Internal.new('Internal error: Cycle found in running UpdateOperation')
         | 
| 54 56 | 
             
                  when RunState::Run
         | 
| 55 57 | 
             
                    return viewmodel
         | 
| 56 58 | 
             
                  end
         | 
| @@ -217,7 +219,7 @@ class ViewModel::ActiveRecord | |
| 217 219 |  | 
| 218 220 | 
             
                # Recursively builds UpdateOperations for the associations in our UpdateData
         | 
| 219 221 | 
             
                def build!(update_context)
         | 
| 220 | 
            -
                  raise ViewModel::DeserializationError::Internal.new( | 
| 222 | 
            +
                  raise ViewModel::DeserializationError::Internal.new('Internal error: UpdateOperation cannot build a deferred update') if viewmodel.nil?
         | 
| 221 223 | 
             
                  return self if built?
         | 
| 222 224 |  | 
| 223 225 | 
             
                  update_data.associations.each do |association_name, association_update_data|
         | 
| @@ -254,8 +256,8 @@ class ViewModel::ActiveRecord | |
| 254 256 | 
             
                def add_update(association_data, update)
         | 
| 255 257 | 
             
                  target =
         | 
| 256 258 | 
             
                    case association_data.pointer_location
         | 
| 257 | 
            -
                    when :remote | 
| 258 | 
            -
                    when :local | 
| 259 | 
            +
                    when :remote then pointed_to
         | 
| 260 | 
            +
                    when :local then  points_to
         | 
| 259 261 | 
             
                    end
         | 
| 260 262 |  | 
| 261 263 | 
             
                  target[association_data] = update
         | 
| @@ -658,7 +660,7 @@ class ViewModel::ActiveRecord | |
| 658 660 | 
             
                      other.indirect_viewmodel_reference == self.indirect_viewmodel_reference
         | 
| 659 661 | 
             
                  end
         | 
| 660 662 |  | 
| 661 | 
            -
                  alias  | 
| 663 | 
            +
                  alias eql? ==
         | 
| 662 664 | 
             
                end
         | 
| 663 665 |  | 
| 664 666 | 
             
                # Helper class to wrap the previous members of a referenced collection and
         | 
| @@ -767,6 +769,7 @@ class ViewModel::ActiveRecord | |
| 767 769 | 
             
                    member.ref_string = ref_string if ref_string
         | 
| 768 770 | 
             
                    member
         | 
| 769 771 | 
             
                  end
         | 
| 772 | 
            +
             | 
| 770 773 | 
             
                  def remove_from_members(removed_members)
         | 
| 771 774 | 
             
                    s = removed_members.to_set
         | 
| 772 775 | 
             
                    members.reject! { |m| s.include?(m) }
         | 
| @@ -900,11 +903,12 @@ class ViewModel::ActiveRecord | |
| 900 903 |  | 
| 901 904 | 
             
                def clear_association_cache(model, reflection)
         | 
| 902 905 | 
             
                  association = model.association(reflection.name)
         | 
| 903 | 
            -
                   | 
| 904 | 
            -
                     | 
| 905 | 
            -
             | 
| 906 | 
            -
                     | 
| 907 | 
            -
             | 
| 906 | 
            +
                  association.target =
         | 
| 907 | 
            +
                    if reflection.collection?
         | 
| 908 | 
            +
                      []
         | 
| 909 | 
            +
                    else
         | 
| 910 | 
            +
                      nil
         | 
| 911 | 
            +
                    end
         | 
| 908 912 | 
             
                end
         | 
| 909 913 |  | 
| 910 914 | 
             
                def blame_reference
         |