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,3 +1,5 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 1 3 | 
             
            ## Defines an access control discipline for a given action against a tree of
         | 
| 2 4 | 
             
            ## viewmodels.
         | 
| 3 5 | 
             
            ##
         | 
| @@ -78,7 +80,7 @@ class ViewModel::AccessControl::Tree < ViewModel::AccessControl | |
| 78 80 | 
             
              def initialize
         | 
| 79 81 | 
             
                super()
         | 
| 80 82 | 
             
                @always_policy_instance = self.class::AlwaysPolicy.new(self)
         | 
| 81 | 
            -
                @view_policy_instances  = self.class.view_policies. | 
| 83 | 
            +
                @view_policy_instances  = self.class.view_policies.transform_values { |policy| policy.new(self) }
         | 
| 82 84 | 
             
                @root_visibility_store  = {}
         | 
| 83 85 | 
             
                @root_editability_store = {}
         | 
| 84 86 | 
             
              end
         | 
| @@ -98,27 +100,29 @@ class ViewModel::AccessControl::Tree < ViewModel::AccessControl | |
| 98 100 |  | 
| 99 101 | 
             
              def store_descendent_editability(view, descendent_editability)
         | 
| 100 102 | 
             
                if @root_editability_store.has_key?(view.object_id)
         | 
| 101 | 
            -
                  raise RuntimeError.new( | 
| 103 | 
            +
                  raise RuntimeError.new('Root access control data already saved for root')
         | 
| 102 104 | 
             
                end
         | 
| 105 | 
            +
             | 
| 103 106 | 
             
                @root_editability_store[view.object_id] = descendent_editability
         | 
| 104 107 | 
             
              end
         | 
| 105 108 |  | 
| 106 109 | 
             
              def fetch_descendent_editability(view)
         | 
| 107 110 | 
             
                @root_editability_store.fetch(view.object_id) do
         | 
| 108 | 
            -
                  raise RuntimeError.new( | 
| 111 | 
            +
                  raise RuntimeError.new('No root access control data recorded for root')
         | 
| 109 112 | 
             
                end
         | 
| 110 113 | 
             
              end
         | 
| 111 114 |  | 
| 112 115 | 
             
              def store_descendent_visibility(view, descendent_visibility)
         | 
| 113 116 | 
             
                if @root_visibility_store.has_key?(view.object_id)
         | 
| 114 | 
            -
                  raise RuntimeError.new( | 
| 117 | 
            +
                  raise RuntimeError.new('Root access control data already saved for root')
         | 
| 115 118 | 
             
                end
         | 
| 119 | 
            +
             | 
| 116 120 | 
             
                @root_visibility_store[view.object_id] = descendent_visibility
         | 
| 117 121 | 
             
              end
         | 
| 118 122 |  | 
| 119 123 | 
             
              def fetch_descendent_visibility(view)
         | 
| 120 124 | 
             
                @root_visibility_store.fetch(view.object_id) do
         | 
| 121 | 
            -
                  raise RuntimeError.new( | 
| 125 | 
            +
                  raise RuntimeError.new('No root access control data recorded for root')
         | 
| 122 126 | 
             
                end
         | 
| 123 127 | 
             
              end
         | 
| 124 128 |  | 
| @@ -152,6 +156,7 @@ class ViewModel::AccessControl::Tree < ViewModel::AccessControl | |
| 152 156 |  | 
| 153 157 | 
             
                  def initialize_as_node
         | 
| 154 158 | 
             
                    @root = false
         | 
| 159 | 
            +
             | 
| 155 160 | 
             
                    @root_children_editable_ifs      = []
         | 
| 156 161 | 
             
                    @root_children_editable_unlesses = []
         | 
| 157 162 | 
             
                    @root_children_visible_ifs       = []
         | 
| @@ -187,7 +192,7 @@ class ViewModel::AccessControl::Tree < ViewModel::AccessControl | |
| 187 192 | 
             
                  def inspect_checks
         | 
| 188 193 | 
             
                    checks = super
         | 
| 189 194 | 
             
                    if root?
         | 
| 190 | 
            -
                      checks <<  | 
| 195 | 
            +
                      checks << 'no root checks'
         | 
| 191 196 | 
             
                    else
         | 
| 192 197 | 
             
                      checks << "root_children_visible_if: #{root_children_visible_ifs.map(&:reason)}"            if root_children_visible_ifs.present?
         | 
| 193 198 | 
             
                      checks << "root_children_visible_unless: #{root_children_visible_unlesses.map(&:reason)}"   if root_children_visible_unlesses.present?
         | 
| @@ -1,7 +1,10 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 1 3 | 
             
            class ViewModel::AccessControlError < ViewModel::AbstractErrorWithBlame
         | 
| 2 4 | 
             
              attr_reader :detail
         | 
| 5 | 
            +
             | 
| 3 6 | 
             
              status 403
         | 
| 4 | 
            -
              code  | 
| 7 | 
            +
              code 'AccessControl.Forbidden'
         | 
| 5 8 |  | 
| 6 9 | 
             
              def initialize(detail, nodes = [])
         | 
| 7 10 | 
             
                @detail = detail
         | 
| @@ -1,23 +1,23 @@ | |
| 1 1 | 
             
            # frozen_string_literal: true
         | 
| 2 2 |  | 
| 3 | 
            -
            require  | 
| 4 | 
            -
            require  | 
| 3 | 
            +
            require 'active_support'
         | 
| 4 | 
            +
            require 'active_record'
         | 
| 5 5 |  | 
| 6 | 
            -
            require  | 
| 7 | 
            -
            require  | 
| 6 | 
            +
            require 'view_model'
         | 
| 7 | 
            +
            require 'view_model/record'
         | 
| 8 8 |  | 
| 9 | 
            -
            require  | 
| 10 | 
            -
            require  | 
| 9 | 
            +
            require 'lazily'
         | 
| 10 | 
            +
            require 'concurrent'
         | 
| 11 11 |  | 
| 12 12 | 
             
            class ViewModel::ActiveRecord < ViewModel::Record
         | 
| 13 13 | 
             
              # Defined before requiring components so components can refer to them at parse time
         | 
| 14 14 |  | 
| 15 15 | 
             
              # for functional updates
         | 
| 16 | 
            -
              FUNCTIONAL_UPDATE_TYPE =  | 
| 17 | 
            -
              ACTIONS_ATTRIBUTE      =  | 
| 18 | 
            -
              VALUES_ATTRIBUTE       =  | 
| 19 | 
            -
              BEFORE_ATTRIBUTE       =  | 
| 20 | 
            -
              AFTER_ATTRIBUTE        =  | 
| 16 | 
            +
              FUNCTIONAL_UPDATE_TYPE = '_update'
         | 
| 17 | 
            +
              ACTIONS_ATTRIBUTE      = 'actions'
         | 
| 18 | 
            +
              VALUES_ATTRIBUTE       = 'values'
         | 
| 19 | 
            +
              BEFORE_ATTRIBUTE       = 'before'
         | 
| 20 | 
            +
              AFTER_ATTRIBUTE        = 'after'
         | 
| 21 21 |  | 
| 22 22 | 
             
              require 'view_model/utils/collections'
         | 
| 23 23 | 
             
              require 'view_model/active_record/association_data'
         | 
| @@ -224,7 +224,7 @@ class ViewModel::ActiveRecord < ViewModel::Record | |
| 224 224 | 
             
                  DeepPreloader::Spec.new(association_specs)
         | 
| 225 225 | 
             
                end
         | 
| 226 226 |  | 
| 227 | 
            -
                def dependent_viewmodels(seen = Set.new, include_referenced: true)
         | 
| 227 | 
            +
                def dependent_viewmodels(seen = Set.new, include_referenced: true, include_external: true)
         | 
| 228 228 | 
             
                  return if seen.include?(self)
         | 
| 229 229 |  | 
| 230 230 | 
             
                  seen << self
         | 
| @@ -232,6 +232,8 @@ class ViewModel::ActiveRecord < ViewModel::Record | |
| 232 232 | 
             
                  _members.each_value do |data|
         | 
| 233 233 | 
             
                    next unless data.is_a?(AssociationData)
         | 
| 234 234 | 
             
                    next unless include_referenced || !data.referenced?
         | 
| 235 | 
            +
                    next unless include_external   || !data.external?
         | 
| 236 | 
            +
             | 
| 235 237 | 
             
                    data.viewmodel_classes.each do |vm|
         | 
| 236 238 | 
             
                      vm.dependent_viewmodels(seen, include_referenced: include_referenced)
         | 
| 237 239 | 
             
                    end
         | 
| @@ -240,12 +242,12 @@ class ViewModel::ActiveRecord < ViewModel::Record | |
| 240 242 | 
             
                  seen
         | 
| 241 243 | 
             
                end
         | 
| 242 244 |  | 
| 243 | 
            -
                def deep_schema_version(include_referenced: true)
         | 
| 245 | 
            +
                def deep_schema_version(include_referenced: true, include_external: true)
         | 
| 244 246 | 
             
                  (@deep_schema_version ||= {})[include_referenced] ||=
         | 
| 245 247 | 
             
                    begin
         | 
| 246 | 
            -
                      dependent_viewmodels(include_referenced: include_referenced).each_with_object({}) do |view, h|
         | 
| 248 | 
            +
                      dependent_viewmodels(include_referenced: include_referenced, include_external: include_external).each_with_object({}) do |view, h|
         | 
| 247 249 | 
             
                        h[view.view_name] = view.schema_version
         | 
| 248 | 
            -
                      end
         | 
| 250 | 
            +
                      end.freeze
         | 
| 249 251 | 
             
                    end
         | 
| 250 252 | 
             
                end
         | 
| 251 253 |  | 
| @@ -42,7 +42,7 @@ class ViewModel::ActiveRecord::AssociationData | |
| 42 42 | 
             
                    @indirect_reflection = load_indirect_reflection(intermediate_model, @indirect_association_name)
         | 
| 43 43 | 
             
                    target_reflection    = @indirect_reflection
         | 
| 44 44 | 
             
                  else
         | 
| 45 | 
            -
                    target_reflection | 
| 45 | 
            +
                    target_reflection = @direct_reflection
         | 
| 46 46 | 
             
                  end
         | 
| 47 47 |  | 
| 48 48 | 
             
                  @viewmodel_classes =
         | 
| @@ -202,6 +202,7 @@ class ViewModel::ActiveRecord::AssociationData | |
| 202 202 |  | 
| 203 203 | 
             
              def direct_viewmodel
         | 
| 204 204 | 
             
                raise ArgumentError.new('not a through association') unless through?
         | 
| 205 | 
            +
             | 
| 205 206 | 
             
                lazy_initialize! unless @initialized
         | 
| 206 207 | 
             
                @direct_viewmodel
         | 
| 207 208 | 
             
              end
         | 
| @@ -11,7 +11,8 @@ module ViewModel::ActiveRecord::AssociationManipulation | |
| 11 11 | 
             
                association_scope = association.scope
         | 
| 12 12 |  | 
| 13 13 | 
             
                if association_data.through?
         | 
| 14 | 
            -
                  raise ArgumentError.new( | 
| 14 | 
            +
                  raise ArgumentError.new('Polymorphic through relationships not supported yet') if association_data.polymorphic?
         | 
| 15 | 
            +
             | 
| 15 16 | 
             
                  associated_viewmodel = association_data.viewmodel_class
         | 
| 16 17 | 
             
                  direct_viewmodel     = association_data.direct_viewmodel
         | 
| 17 18 | 
             
                else
         | 
| @@ -44,6 +45,7 @@ module ViewModel::ActiveRecord::AssociationManipulation | |
| 44 45 | 
             
                  if vms.size > 1
         | 
| 45 46 | 
             
                    raise ViewModel::DeserializationError::Internal.new("Internal error: encountered multiple records for single association #{association_name}", self.blame_reference)
         | 
| 46 47 | 
             
                  end
         | 
| 48 | 
            +
             | 
| 47 49 | 
             
                  vms.first
         | 
| 48 50 | 
             
                end
         | 
| 49 51 | 
             
              end
         | 
| @@ -112,7 +114,7 @@ module ViewModel::ActiveRecord::AssociationManipulation | |
| 112 114 | 
             
                      deserialize_context.run_callback(ViewModel::Callbacks::Hook::BeforeValidate, self)
         | 
| 113 115 |  | 
| 114 116 | 
             
                      if association_data.through?
         | 
| 115 | 
            -
                        raise ArgumentError.new( | 
| 117 | 
            +
                        raise ArgumentError.new('Polymorphic through relationships not supported yet') if association_data.polymorphic?
         | 
| 116 118 |  | 
| 117 119 | 
             
                        direct_viewmodel_class = association_data.direct_viewmodel
         | 
| 118 120 | 
             
                        root_update_data, referenced_update_data = construct_indirect_append_updates(association_data, subtree_hashes, references)
         | 
| @@ -317,7 +319,7 @@ module ViewModel::ActiveRecord::AssociationManipulation | |
| 317 319 | 
             
                # TODO: this won't handle polymorphic associations! In the case of polymorphism,
         | 
| 318 320 | 
             
                #       need to join on (type, id) pairs instead.
         | 
| 319 321 | 
             
                if association_data.polymorphic?
         | 
| 320 | 
            -
                  raise ArgumentError.new( | 
| 322 | 
            +
                  raise ArgumentError.new('Internal error: append_association is not yet supported for polymorphic indirect associations')
         | 
| 321 323 | 
             
                end
         | 
| 322 324 |  | 
| 323 325 | 
             
                existing_indirect_associates = indirect_update_data.map { |upd| upd.id unless upd.new? }.compact
         | 
| @@ -340,7 +342,7 @@ module ViewModel::ActiveRecord::AssociationManipulation | |
| 340 342 | 
             
                  ViewModel::ActiveRecord::UpdateData.new(
         | 
| 341 343 | 
             
                    direct_viewmodel_class,
         | 
| 342 344 | 
             
                    metadata,
         | 
| 343 | 
            -
                    { indirect_reflection.name.to_s => { ViewModel::REFERENCE_ATTRIBUTE => ref_name }},
         | 
| 345 | 
            +
                    { indirect_reflection.name.to_s => { ViewModel::REFERENCE_ATTRIBUTE => ref_name } },
         | 
| 344 346 | 
             
                    [ref_name])
         | 
| 345 347 | 
             
                end
         | 
| 346 348 |  | 
| @@ -5,6 +5,7 @@ require 'iknow_cache' | |
| 5 5 | 
             
            # Cache for ViewModels that wrap ActiveRecord models.
         | 
| 6 6 | 
             
            class ViewModel::ActiveRecord::Cache
         | 
| 7 7 | 
             
              require 'view_model/active_record/cache/cacheable_view'
         | 
| 8 | 
            +
              require 'view_model/migrator'
         | 
| 8 9 |  | 
| 9 10 | 
             
              class UncacheableViewModelError < RuntimeError; end
         | 
| 10 11 |  | 
| @@ -13,13 +14,20 @@ class ViewModel::ActiveRecord::Cache | |
| 13 14 | 
             
              # If cache_group: is specified, it must be a group of a single key: `:id`
         | 
| 14 15 | 
             
              def initialize(viewmodel_class, cache_group: nil)
         | 
| 15 16 | 
             
                @viewmodel_class = viewmodel_class
         | 
| 16 | 
            -
             | 
| 17 | 
            +
             | 
| 18 | 
            +
                @cache_group = cache_group || create_default_cache_group
         | 
| 19 | 
            +
                @migrated_cache_group = @cache_group.register_child_group(:migrated, :version)
         | 
| 20 | 
            +
             | 
| 21 | 
            +
                # /viewname/:id/viewname-currentversion
         | 
| 17 22 | 
             
                @cache = @cache_group.register_cache(cache_name)
         | 
| 23 | 
            +
             | 
| 24 | 
            +
                # /viewname/:id/migrated/:oldversion/viewname-currentversion
         | 
| 25 | 
            +
                @migrated_cache = @migrated_cache_group.register_cache(cache_name)
         | 
| 18 26 | 
             
              end
         | 
| 19 27 |  | 
| 20 28 | 
             
              def delete(*ids)
         | 
| 21 29 | 
             
                ids.each do |id|
         | 
| 22 | 
            -
                  @cache_group.delete_all( | 
| 30 | 
            +
                  @cache_group.delete_all(@cache.key.new(id))
         | 
| 23 31 | 
             
                end
         | 
| 24 32 | 
             
              end
         | 
| 25 33 |  | 
| @@ -27,14 +35,14 @@ class ViewModel::ActiveRecord::Cache | |
| 27 35 | 
             
                @cache_group.invalidate_cache_group
         | 
| 28 36 | 
             
              end
         | 
| 29 37 |  | 
| 30 | 
            -
              def fetch_by_viewmodel(viewmodels, locked: false, serialize_context: @viewmodel_class.new_serialize_context)
         | 
| 38 | 
            +
              def fetch_by_viewmodel(viewmodels, migration_versions: {}, locked: false, serialize_context: @viewmodel_class.new_serialize_context)
         | 
| 31 39 | 
             
                ids = viewmodels.map(&:id)
         | 
| 32 | 
            -
                fetch(ids, initial_viewmodels: viewmodels, locked:  | 
| 40 | 
            +
                fetch(ids, initial_viewmodels: viewmodels, migration_versions: migration_versions, locked: locked, serialize_context: serialize_context)
         | 
| 33 41 | 
             
              end
         | 
| 34 42 |  | 
| 35 | 
            -
              def fetch(ids, initial_viewmodels: nil, locked: false, serialize_context: @viewmodel_class.new_serialize_context)
         | 
| 43 | 
            +
              def fetch(ids, initial_viewmodels: nil, migration_versions: {}, locked: false, serialize_context: @viewmodel_class.new_serialize_context)
         | 
| 36 44 | 
             
                data_serializations = Array.new(ids.size)
         | 
| 37 | 
            -
                worker = CacheWorker.new(serialize_context: serialize_context)
         | 
| 45 | 
            +
                worker = CacheWorker.new(migration_versions: migration_versions, serialize_context: serialize_context)
         | 
| 38 46 |  | 
| 39 47 | 
             
                # If initial root viewmodels were provided, visit them to ensure that they
         | 
| 40 48 | 
             
                # are visible. Other than this, no traversal callbacks are performed, as a
         | 
| @@ -98,12 +106,14 @@ class ViewModel::ActiveRecord::Cache | |
| 98 106 | 
             
                SENTINEL = Object.new
         | 
| 99 107 | 
             
                WorklistEntry = Struct.new(:ref_name, :viewmodel)
         | 
| 100 108 |  | 
| 101 | 
            -
                attr_reader :serialize_context, :resolved_references
         | 
| 109 | 
            +
                attr_reader :migration_versions, :serialize_context, :resolved_references
         | 
| 102 110 |  | 
| 103 | 
            -
                def initialize(serialize_context:)
         | 
| 104 | 
            -
                  @worklist | 
| 105 | 
            -
                  @resolved_references | 
| 106 | 
            -
                  @ | 
| 111 | 
            +
                def initialize(migration_versions:, serialize_context:)
         | 
| 112 | 
            +
                  @worklist                = {}
         | 
| 113 | 
            +
                  @resolved_references     = {}
         | 
| 114 | 
            +
                  @migration_versions      = migration_versions
         | 
| 115 | 
            +
                  @migrated_cache_versions = {}
         | 
| 116 | 
            +
                  @serialize_context       = serialize_context
         | 
| 107 117 | 
             
                end
         | 
| 108 118 |  | 
| 109 119 | 
             
                def resolve_references!
         | 
| @@ -141,11 +151,15 @@ class ViewModel::ActiveRecord::Cache | |
| 141 151 | 
             
                  end
         | 
| 142 152 | 
             
                end
         | 
| 143 153 |  | 
| 154 | 
            +
                def migrated_cache_version(viewmodel_cache)
         | 
| 155 | 
            +
                  @migrated_cache_versions[viewmodel_cache] ||= viewmodel_cache.migrated_cache_version(migration_versions)
         | 
| 156 | 
            +
                end
         | 
| 157 | 
            +
             | 
| 144 158 | 
             
                # Loads the specified entities from the cache and returns a hash of
         | 
| 145 159 | 
             
                # {id=>serialized_view}. Any references encountered are added to the
         | 
| 146 160 | 
             
                # worklist.
         | 
| 147 161 | 
             
                def load_from_cache(viewmodel_cache, ids)
         | 
| 148 | 
            -
                  cached_serializations = viewmodel_cache.load(ids, serialize_context: serialize_context)
         | 
| 162 | 
            +
                  cached_serializations = viewmodel_cache.load(ids, migrated_cache_version(viewmodel_cache), serialize_context: serialize_context)
         | 
| 149 163 |  | 
| 150 164 | 
             
                  cached_serializations.each_with_object({}) do |(id, cached_serialization), result|
         | 
| 151 165 | 
             
                    add_refs_to_worklist(cached_serialization[:ref_cache])
         | 
| @@ -159,17 +173,54 @@ class ViewModel::ActiveRecord::Cache | |
| 159 173 | 
             
                # added to the worklist.
         | 
| 160 174 | 
             
                def serialize_and_cache(viewmodels)
         | 
| 161 175 | 
             
                  viewmodels.each_with_object({}) do |viewmodel, result|
         | 
| 162 | 
            -
                     | 
| 176 | 
            +
                    builder = Jbuilder.new do |json|
         | 
| 163 177 | 
             
                      ViewModel.serialize(viewmodel, json, serialize_context: serialize_context)
         | 
| 164 178 | 
             
                    end
         | 
| 165 179 |  | 
| 166 180 | 
             
                    referenced_viewmodels = serialize_context.extract_referenced_views!
         | 
| 181 | 
            +
             | 
| 182 | 
            +
                    if migration_versions.present?
         | 
| 183 | 
            +
                      migrator = ViewModel::DownMigrator.new(migration_versions)
         | 
| 184 | 
            +
             | 
| 185 | 
            +
                      # This migration isn't given the chance to inspect/alter the contents
         | 
| 186 | 
            +
                      # of referenced views, only their presence: it's strictly less
         | 
| 187 | 
            +
                      # powerful than migrations on a fully serialized tree, as the only
         | 
| 188 | 
            +
                      # possible action on a referenced child is to delete it. The effect of
         | 
| 189 | 
            +
                      # this is that for sufficiently complex migrations where a parent view
         | 
| 190 | 
            +
                      # must introduce children or alter the contents of its referenced
         | 
| 191 | 
            +
                      # children, we may have to avoid caching while the migration is in
         | 
| 192 | 
            +
                      # use.
         | 
| 193 | 
            +
                      dummy_references = referenced_viewmodels.transform_values do |ref_vm|
         | 
| 194 | 
            +
                        {
         | 
| 195 | 
            +
                          ViewModel::TYPE_ATTRIBUTE    => ref_vm.class.view_name,
         | 
| 196 | 
            +
                          ViewModel::VERSION_ATTRIBUTE => ref_vm.class.schema_version,
         | 
| 197 | 
            +
                          ViewModel::ID_ATTRIBUTE      => ref_vm.id,
         | 
| 198 | 
            +
                        }.freeze
         | 
| 199 | 
            +
                      end
         | 
| 200 | 
            +
             | 
| 201 | 
            +
                      migrator.migrate!(builder.attributes!, references: dummy_references)
         | 
| 202 | 
            +
             | 
| 203 | 
            +
                      # Removed dummy references can be removed from referenced_viewmodels.
         | 
| 204 | 
            +
                      referenced_viewmodels.keep_if { |k, _| dummy_references.has_key?(k) }
         | 
| 205 | 
            +
             | 
| 206 | 
            +
                      # Introduced dummy references cannot be handled.
         | 
| 207 | 
            +
                      if dummy_references.keys != referenced_viewmodels.keys
         | 
| 208 | 
            +
                        version = migration_versions[viewmodel.class]
         | 
| 209 | 
            +
                        raise ViewModel::Error.new(
         | 
| 210 | 
            +
                                status: 500,
         | 
| 211 | 
            +
                                detail: "Down-migration for cacheable view #{viewmodel.class} to v#{version} must not introduce new shared references")
         | 
| 212 | 
            +
                      end
         | 
| 213 | 
            +
                    end
         | 
| 214 | 
            +
             | 
| 215 | 
            +
                    data_serialization = builder.target!
         | 
| 216 | 
            +
             | 
| 167 217 | 
             
                    add_viewmodels_to_worklist(referenced_viewmodels)
         | 
| 168 218 |  | 
| 169 219 | 
             
                    if viewmodel.class < CacheableView
         | 
| 170 220 | 
             
                      cacheable_references = referenced_viewmodels.transform_values { |vm| cacheable_reference(vm) }
         | 
| 171 | 
            -
                      viewmodel.class.viewmodel_cache | 
| 172 | 
            -
             | 
| 221 | 
            +
                      target_cache = viewmodel.class.viewmodel_cache
         | 
| 222 | 
            +
                      target_cache.store(viewmodel.id, migrated_cache_version(target_cache), data_serialization, cacheable_references,
         | 
| 223 | 
            +
                                         serialize_context: serialize_context)
         | 
| 173 224 | 
             
                    end
         | 
| 174 225 |  | 
| 175 226 | 
             
                    result[viewmodel.id] = data_serialization
         | 
| @@ -192,14 +243,14 @@ class ViewModel::ActiveRecord::Cache | |
| 192 243 | 
             
                  if ids.present?
         | 
| 193 244 | 
             
                    found = viewmodel_class.find(ids,
         | 
| 194 245 | 
             
                                                 eager_include: false,
         | 
| 195 | 
            -
                                                 lock:  | 
| 246 | 
            +
                                                 lock: 'FOR SHARE',
         | 
| 196 247 | 
             
                                                 serialize_context: serialize_context)
         | 
| 197 248 | 
             
                    viewmodels.concat(found)
         | 
| 198 249 | 
             
                  end
         | 
| 199 250 |  | 
| 200 251 | 
             
                  ViewModel.preload_for_serialization(viewmodels,
         | 
| 201 252 | 
             
                                                      include_referenced: false,
         | 
| 202 | 
            -
                                                      lock:  | 
| 253 | 
            +
                                                      lock: 'FOR SHARE',
         | 
| 203 254 | 
             
                                                      serialize_context: serialize_context)
         | 
| 204 255 |  | 
| 205 256 | 
             
                  viewmodels
         | 
| @@ -213,6 +264,7 @@ class ViewModel::ActiveRecord::Cache | |
| 213 264 | 
             
                def add_refs_to_worklist(cacheable_references)
         | 
| 214 265 | 
             
                  cacheable_references.each do |ref_name, (type, id)|
         | 
| 215 266 | 
             
                    next if resolved_references.has_key?(ref_name)
         | 
| 267 | 
            +
             | 
| 216 268 | 
             
                    (@worklist[type] ||= {})[id] = WorklistEntry.new(ref_name, nil)
         | 
| 217 269 | 
             
                  end
         | 
| 218 270 | 
             
                end
         | 
| @@ -220,13 +272,26 @@ class ViewModel::ActiveRecord::Cache | |
| 220 272 | 
             
                def add_viewmodels_to_worklist(referenced_viewmodels)
         | 
| 221 273 | 
             
                  referenced_viewmodels.each do |ref_name, viewmodel|
         | 
| 222 274 | 
             
                    next if resolved_references.has_key?(ref_name)
         | 
| 275 | 
            +
             | 
| 223 276 | 
             
                    (@worklist[viewmodel.class.view_name] ||= {})[viewmodel.id] = WorklistEntry.new(ref_name, viewmodel)
         | 
| 224 277 | 
             
                  end
         | 
| 225 278 | 
             
                end
         | 
| 226 279 | 
             
              end
         | 
| 227 280 |  | 
| 228 | 
            -
              def  | 
| 229 | 
            -
                 | 
| 281 | 
            +
              def cache_for(migration_version)
         | 
| 282 | 
            +
                if migration_version
         | 
| 283 | 
            +
                  @migrated_cache
         | 
| 284 | 
            +
                else
         | 
| 285 | 
            +
                  @cache
         | 
| 286 | 
            +
                end
         | 
| 287 | 
            +
              end
         | 
| 288 | 
            +
             | 
| 289 | 
            +
              def key_for(id, migration_version)
         | 
| 290 | 
            +
                if migration_version
         | 
| 291 | 
            +
                  @migrated_cache.key.new(id, migration_version)
         | 
| 292 | 
            +
                else
         | 
| 293 | 
            +
                  @cache.key.new(id)
         | 
| 294 | 
            +
                end
         | 
| 230 295 | 
             
              end
         | 
| 231 296 |  | 
| 232 297 | 
             
              def id_for(key)
         | 
| @@ -234,32 +299,47 @@ class ViewModel::ActiveRecord::Cache | |
| 234 299 | 
             
              end
         | 
| 235 300 |  | 
| 236 301 | 
             
              # Save the provided serialization and reference data in the cache
         | 
| 237 | 
            -
              def store(id, data_serialization, ref_cache, serialize_context:)
         | 
| 238 | 
            -
                 | 
| 302 | 
            +
              def store(id, migration_version, data_serialization, ref_cache, serialize_context:)
         | 
| 303 | 
            +
                key = key_for(id, migration_version)
         | 
| 304 | 
            +
                cache_for(migration_version).write(key, { data: data_serialization, ref_cache: ref_cache })
         | 
| 239 305 | 
             
              end
         | 
| 240 306 |  | 
| 241 | 
            -
              def load(ids, serialize_context:)
         | 
| 242 | 
            -
                keys = ids.map { |id| key_for(id) }
         | 
| 243 | 
            -
                results =  | 
| 307 | 
            +
              def load(ids, migration_version, serialize_context:)
         | 
| 308 | 
            +
                keys = ids.map { |id| key_for(id, migration_version) }
         | 
| 309 | 
            +
                results = cache_for(migration_version).read_multi(keys)
         | 
| 244 310 | 
             
                results.transform_keys! { |key| id_for(key) }
         | 
| 245 311 | 
             
              end
         | 
| 246 312 |  | 
| 247 | 
            -
               | 
| 313 | 
            +
              def cache_version
         | 
| 314 | 
            +
                @cache_version ||=
         | 
| 315 | 
            +
                  begin
         | 
| 316 | 
            +
                    versions = @viewmodel_class.deep_schema_version(include_referenced: false)
         | 
| 317 | 
            +
                    ViewModel.schema_hash(versions)
         | 
| 318 | 
            +
                  end
         | 
| 319 | 
            +
              end
         | 
| 248 320 |  | 
| 249 | 
            -
               | 
| 321 | 
            +
              def migrated_cache_version(migration_versions)
         | 
| 322 | 
            +
                versions = ViewModel::Migrator.migrated_deep_schema_version(viewmodel_class, migration_versions, include_referenced: false)
         | 
| 323 | 
            +
                version_hash = ViewModel.schema_hash(versions)
         | 
| 324 | 
            +
             | 
| 325 | 
            +
                if version_hash == cache_version
         | 
| 326 | 
            +
                  # no migrations affect this view
         | 
| 327 | 
            +
                  nil
         | 
| 328 | 
            +
                else
         | 
| 329 | 
            +
                  version_hash
         | 
| 330 | 
            +
                end
         | 
| 331 | 
            +
              end
         | 
| 332 | 
            +
             | 
| 333 | 
            +
              private
         | 
| 250 334 |  | 
| 251 335 | 
             
              def create_default_cache_group
         | 
| 252 336 | 
             
                IknowCache.register_group(@viewmodel_class.name, :id)
         | 
| 253 337 | 
             
              end
         | 
| 254 338 |  | 
| 255 | 
            -
              # Statically version the  | 
| 256 | 
            -
              # the constituent viewmodels, so that viewmodel changes force | 
| 339 | 
            +
              # Statically version the cache name based on the (current) deep schema
         | 
| 340 | 
            +
              # versions of the constituent viewmodels, so that viewmodel changes force
         | 
| 341 | 
            +
              # invalidation.
         | 
| 257 342 | 
             
              def cache_name
         | 
| 258 | 
            -
                "#{ | 
| 259 | 
            -
              end
         | 
| 260 | 
            -
             | 
| 261 | 
            -
              def cache_version
         | 
| 262 | 
            -
                version_string = @viewmodel_class.deep_schema_version(include_referenced: false).to_a.sort.join(',')
         | 
| 263 | 
            -
                Base64.urlsafe_encode64(Digest::MD5.digest(version_string))
         | 
| 343 | 
            +
                "vmcache_#{cache_version}"
         | 
| 264 344 | 
             
              end
         | 
| 265 345 | 
             
            end
         |