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
    
        data/lib/view_model/callbacks.rb
    CHANGED
    
    | @@ -79,7 +79,7 @@ module ViewModel::Callbacks | |
| 79 79 | 
             
                        end
         | 
| 80 80 | 
             
                      SRC
         | 
| 81 81 | 
             
                    else
         | 
| 82 | 
            -
                      def self.create(callbacks, view, context)
         | 
| 82 | 
            +
                      def self.create(callbacks, view, context) # rubocop:disable Lint/NestedMethodDefinition
         | 
| 83 83 | 
             
                        self.new(callbacks, view, context)
         | 
| 84 84 | 
             
                      end
         | 
| 85 85 | 
             
                    end
         | 
| @@ -105,6 +105,7 @@ module ViewModel::Callbacks | |
| 105 105 | 
             
                define_singleton_method(:class_callbacks) { base_callbacks }
         | 
| 106 106 | 
             
                define_singleton_method(:all_callbacks) do |&block|
         | 
| 107 107 | 
             
                  return to_enum(__method__) unless block
         | 
| 108 | 
            +
             | 
| 108 109 | 
             
                  block.call(base_callbacks)
         | 
| 109 110 | 
             
                end
         | 
| 110 111 | 
             
              end
         | 
| @@ -115,6 +116,7 @@ module ViewModel::Callbacks | |
| 115 116 | 
             
                  subclass.define_singleton_method(:class_callbacks) { subclass_callbacks }
         | 
| 116 117 | 
             
                  subclass.define_singleton_method(:all_callbacks) do |&block|
         | 
| 117 118 | 
             
                    return to_enum(__method__) unless block
         | 
| 119 | 
            +
             | 
| 118 120 | 
             
                    super(&block)
         | 
| 119 121 | 
             
                    block.call(subclass_callbacks)
         | 
| 120 122 | 
             
                  end
         | 
| @@ -88,6 +88,7 @@ module ViewModel::Controller | |
| 88 88 | 
             
                  if data.blank?
         | 
| 89 89 | 
             
                    raise ViewModel::Error.new(status: 400, detail: "No data submitted: #{data.inspect}")
         | 
| 90 90 | 
             
                  end
         | 
| 91 | 
            +
             | 
| 91 92 | 
             
                  data.map { |el| _extract_param_hash(el) }
         | 
| 92 93 | 
             
                else
         | 
| 93 94 | 
             
                  _extract_param_hash(data)
         | 
| @@ -129,9 +130,18 @@ module ViewModel::Controller | |
| 129 130 | 
             
              # untouched. Requires a MultiJson adapter other than ActiveSupport's
         | 
| 130 131 | 
             
              # (modified) JsonGem.
         | 
| 131 132 | 
             
              class CompiledJson
         | 
| 132 | 
            -
                def initialize(s) | 
| 133 | 
            -
             | 
| 134 | 
            -
                 | 
| 133 | 
            +
                def initialize(s)
         | 
| 134 | 
            +
                  @s = s
         | 
| 135 | 
            +
                end
         | 
| 136 | 
            +
             | 
| 137 | 
            +
                def to_json(*_args)
         | 
| 138 | 
            +
                  @s
         | 
| 139 | 
            +
                end
         | 
| 140 | 
            +
             | 
| 141 | 
            +
                def to_s
         | 
| 142 | 
            +
                  @s
         | 
| 143 | 
            +
                end
         | 
| 144 | 
            +
             | 
| 135 145 | 
             
                undef_method :as_json
         | 
| 136 146 | 
             
              end
         | 
| 137 147 |  | 
| @@ -15,13 +15,14 @@ class ViewModel | |
| 15 15 | 
             
                  unless nodes.all? { |n| n.viewmodel_class == first }
         | 
| 16 16 | 
             
                    raise ArgumentError.new("All nodes must be of the same type for #{self.class.name}")
         | 
| 17 17 | 
             
                  end
         | 
| 18 | 
            +
             | 
| 18 19 | 
             
                  first
         | 
| 19 20 | 
             
                end
         | 
| 20 21 |  | 
| 21 22 | 
             
                # A collection of DeserializationErrors
         | 
| 22 23 | 
             
                class Collection < ViewModel::AbstractErrorCollection
         | 
| 23 | 
            -
                  title  | 
| 24 | 
            -
                  code   | 
| 24 | 
            +
                  title 'Error(s) occurred during deserialization'
         | 
| 25 | 
            +
                  code  'DeserializationError.Collection'
         | 
| 25 26 |  | 
| 26 27 | 
             
                  def detail
         | 
| 27 28 | 
             
                    "Error(s) occurred during deserialization: #{cause_details}"
         | 
| @@ -33,7 +34,7 @@ class ViewModel | |
| 33 34 | 
             
                class InvalidRequest < DeserializationError
         | 
| 34 35 | 
             
                  # Abstract
         | 
| 35 36 | 
             
                  status 400
         | 
| 36 | 
            -
                  title  | 
| 37 | 
            +
                  title 'Invalid request'
         | 
| 37 38 | 
             
                end
         | 
| 38 39 |  | 
| 39 40 | 
             
                # There has been an unexpected internal failure of the ViewModel library.
         | 
| @@ -145,6 +146,7 @@ class ViewModel | |
| 145 146 | 
             
                # association.
         | 
| 146 147 | 
             
                class InvalidAssociationType < InvalidRequest
         | 
| 147 148 | 
             
                  attr_reader :association, :target_type
         | 
| 149 | 
            +
             | 
| 148 150 | 
             
                  def initialize(association, target_type, node)
         | 
| 149 151 | 
             
                    @association = association
         | 
| 150 152 | 
             
                    @target_type = target_type
         | 
| @@ -198,7 +200,7 @@ class ViewModel | |
| 198 200 | 
             
                  end
         | 
| 199 201 |  | 
| 200 202 | 
             
                  def detail
         | 
| 201 | 
            -
                    errors = missing_nodes.map(&:to_s).join( | 
| 203 | 
            +
                    errors = missing_nodes.map(&:to_s).join(', ')
         | 
| 202 204 | 
             
                    "Couldn't find requested member node(s) in association '#{association}': "\
         | 
| 203 205 | 
             
                    "#{errors}"
         | 
| 204 206 | 
             
                  end
         | 
| @@ -218,7 +220,7 @@ class ViewModel | |
| 218 220 | 
             
                  end
         | 
| 219 221 |  | 
| 220 222 | 
             
                  def detail
         | 
| 221 | 
            -
                    "Duplicate views for the same '#{type}' specified: "+ nodes.map(&:to_s).join( | 
| 223 | 
            +
                    "Duplicate views for the same '#{type}' specified: " + nodes.map(&:to_s).join(', ')
         | 
| 222 224 | 
             
                  end
         | 
| 223 225 |  | 
| 224 226 | 
             
                  def meta
         | 
| @@ -235,14 +237,14 @@ class ViewModel | |
| 235 237 | 
             
                  end
         | 
| 236 238 |  | 
| 237 239 | 
             
                  def detail
         | 
| 238 | 
            -
                    "Multiple parents attempted to claim the same owned '#{association_name}' reference: " + nodes.map(&:to_s).join( | 
| 240 | 
            +
                    "Multiple parents attempted to claim the same owned '#{association_name}' reference: " + nodes.map(&:to_s).join(', ')
         | 
| 239 241 | 
             
                  end
         | 
| 240 242 | 
             
                end
         | 
| 241 243 |  | 
| 242 244 | 
             
                class ParentNotFound < NotFound
         | 
| 243 245 | 
             
                  def detail
         | 
| 244 | 
            -
                     | 
| 245 | 
            -
                      nodes.map(&:to_s).join( | 
| 246 | 
            +
                    'Could not resolve release from previous parent for the following owned viewmodel(s): ' +
         | 
| 247 | 
            +
                      nodes.map(&:to_s).join(', ')
         | 
| 246 248 | 
             
                  end
         | 
| 247 249 | 
             
                end
         | 
| 248 250 |  | 
| @@ -284,7 +286,7 @@ class ViewModel | |
| 284 286 |  | 
| 285 287 | 
             
                class ReadOnlyType < DeserializationError
         | 
| 286 288 | 
             
                  status 400
         | 
| 287 | 
            -
                  detail  | 
| 289 | 
            +
                  detail 'Deserialization not defined for view type'
         | 
| 288 290 | 
             
                end
         | 
| 289 291 |  | 
| 290 292 | 
             
                class InvalidAttributeType < InvalidRequest
         | 
| @@ -326,7 +328,7 @@ class ViewModel | |
| 326 328 | 
             
                  status 400
         | 
| 327 329 |  | 
| 328 330 | 
             
                  def detail
         | 
| 329 | 
            -
                    errors = nodes.map(&:to_s).join( | 
| 331 | 
            +
                    errors = nodes.map(&:to_s).join(', ')
         | 
| 330 332 | 
             
                    "Optimistic lock failure updating nodes: #{errors}"
         | 
| 331 333 | 
             
                  end
         | 
| 332 334 | 
             
                end
         | 
| @@ -408,8 +410,9 @@ class ViewModel | |
| 408 410 |  | 
| 409 411 | 
             
                    private
         | 
| 410 412 |  | 
| 411 | 
            -
                    QUOTED_IDENTIFIER   = /\A"(?:[^"]|"")+" | 
| 412 | 
            -
                    UNQUOTED_IDENTIFIER = /\A(?:\p{Alpha}|_)(?:\p{Alnum}|_) | 
| 413 | 
            +
                    QUOTED_IDENTIFIER   = /\A"(?:[^"]|"")+"/.freeze
         | 
| 414 | 
            +
                    UNQUOTED_IDENTIFIER = /\A(?:\p{Alpha}|_)(?:\p{Alnum}|_)*/.freeze
         | 
| 415 | 
            +
             | 
| 413 416 | 
             
                    def parse_identifier(stream)
         | 
| 414 417 | 
             
                      if (identifier = stream.slice!(UNQUOTED_IDENTIFIER))
         | 
| 415 418 | 
             
                        identifier
         | 
    
        data/lib/view_model/error.rb
    CHANGED
    
    | @@ -1,3 +1,5 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 1 3 | 
             
            # Abstract base for renderable errors in ViewModel-based APIs. Errors of this
         | 
| 2 4 | 
             
            # type will be caught by ViewModel controllers and rendered in a standard format
         | 
| 3 5 | 
             
            # by ViewModel::ErrorView, which loosely follows errors in JSON-API.
         | 
| @@ -6,7 +8,7 @@ class ViewModel::AbstractError < StandardError | |
| 6 8 | 
             
                # Brief DSL for quickly defining constant attribute values in subclasses
         | 
| 7 9 | 
             
                [:detail, :status, :title, :code].each do |attribute|
         | 
| 8 10 | 
             
                  define_method(attribute) do |x|
         | 
| 9 | 
            -
                    define_method(attribute){ x }
         | 
| 11 | 
            +
                    define_method(attribute) { x }
         | 
| 10 12 | 
             
                  end
         | 
| 11 13 | 
             
                end
         | 
| 12 14 | 
             
              end
         | 
| @@ -25,7 +27,7 @@ class ViewModel::AbstractError < StandardError | |
| 25 27 |  | 
| 26 28 | 
             
              # Human-readable reason for use displaying this error.
         | 
| 27 29 | 
             
              def detail
         | 
| 28 | 
            -
                 | 
| 30 | 
            +
                'ViewModel::AbstractError'
         | 
| 29 31 | 
             
              end
         | 
| 30 32 |  | 
| 31 33 | 
             
              # HTTP status code most appropriate for this error
         | 
| @@ -40,7 +42,7 @@ class ViewModel::AbstractError < StandardError | |
| 40 42 |  | 
| 41 43 | 
             
              # Unique symbol identifying this error type
         | 
| 42 44 | 
             
              def code
         | 
| 43 | 
            -
                 | 
| 45 | 
            +
                'ViewModel.AbstractError'
         | 
| 44 46 | 
             
              end
         | 
| 45 47 |  | 
| 46 48 | 
             
              # Additional information specific to this error type.
         | 
| @@ -74,8 +76,6 @@ class ViewModel::AbstractError < StandardError | |
| 74 76 |  | 
| 75 77 | 
             
              protected
         | 
| 76 78 |  | 
| 77 | 
            -
             | 
| 78 | 
            -
             | 
| 79 79 | 
             
              def format_references(viewmodel_refs)
         | 
| 80 80 | 
             
                viewmodel_refs.map do |viewmodel_ref|
         | 
| 81 81 | 
             
                  format_reference(viewmodel_ref)
         | 
| @@ -85,7 +85,7 @@ class ViewModel::AbstractError < StandardError | |
| 85 85 | 
             
              def format_reference(viewmodel_ref)
         | 
| 86 86 | 
             
                {
         | 
| 87 87 | 
             
                  ViewModel::TYPE_ATTRIBUTE => viewmodel_ref.viewmodel_class.view_name,
         | 
| 88 | 
            -
                  ViewModel::ID_ATTRIBUTE   => viewmodel_ref.model_id
         | 
| 88 | 
            +
                  ViewModel::ID_ATTRIBUTE   => viewmodel_ref.model_id,
         | 
| 89 89 | 
             
                }
         | 
| 90 90 | 
             
              end
         | 
| 91 91 | 
             
            end
         | 
| @@ -100,12 +100,13 @@ class ViewModel::AbstractErrorWithBlame < ViewModel::AbstractError | |
| 100 100 | 
             
                unless @nodes.all? { |n| n.is_a?(ViewModel::Reference) }
         | 
| 101 101 | 
             
                  raise ArgumentError.new("#{self.class.name}: 'blame_nodes' must all be of type ViewModel::Reference")
         | 
| 102 102 | 
             
                end
         | 
| 103 | 
            +
             | 
| 103 104 | 
             
                super()
         | 
| 104 105 | 
             
              end
         | 
| 105 106 |  | 
| 106 107 | 
             
              def meta
         | 
| 107 108 | 
             
                {
         | 
| 108 | 
            -
                  nodes: format_references(nodes)
         | 
| 109 | 
            +
                  nodes: format_references(nodes),
         | 
| 109 110 | 
             
                }
         | 
| 110 111 | 
             
              end
         | 
| 111 112 | 
             
            end
         | 
| @@ -117,8 +118,9 @@ class ViewModel::AbstractErrorCollection < ViewModel::AbstractError | |
| 117 118 | 
             
              def initialize(causes)
         | 
| 118 119 | 
             
                @causes = Array.wrap(causes)
         | 
| 119 120 | 
             
                unless @causes.present?
         | 
| 120 | 
            -
                  raise ArgumentError.new( | 
| 121 | 
            +
                  raise ArgumentError.new('A collection must have at least one cause')
         | 
| 121 122 | 
             
                end
         | 
| 123 | 
            +
             | 
| 122 124 | 
             
                super()
         | 
| 123 125 | 
             
              end
         | 
| 124 126 |  | 
| @@ -151,7 +153,7 @@ class ViewModel::AbstractErrorCollection < ViewModel::AbstractError | |
| 151 153 | 
             
              protected
         | 
| 152 154 |  | 
| 153 155 | 
             
              def cause_details
         | 
| 154 | 
            -
                causes.map(&:detail).join( | 
| 156 | 
            +
                causes.map(&:detail).join('; ')
         | 
| 155 157 | 
             
              end
         | 
| 156 158 | 
             
            end
         | 
| 157 159 |  | 
| @@ -180,7 +182,7 @@ end | |
| 180 182 | 
             
            class ViewModel::Error < ViewModel::AbstractError
         | 
| 181 183 | 
             
              attr_reader :detail, :status, :title, :code, :meta
         | 
| 182 184 |  | 
| 183 | 
            -
              def initialize(status: 400, detail:  | 
| 185 | 
            +
              def initialize(status: 400, detail: 'ViewModel Error', title: nil, code: nil, meta: {})
         | 
| 184 186 | 
             
                @detail = detail
         | 
| 185 187 | 
             
                @status = status
         | 
| 186 188 | 
             
                @title  = title
         | 
| @@ -1,3 +1,5 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 1 3 | 
             
            require 'view_model/record'
         | 
| 2 4 |  | 
| 3 5 | 
             
            # ViewModel for rendering ViewModel::AbstractErrors
         | 
| @@ -10,7 +12,7 @@ class ViewModel::ErrorView < ViewModel::Record | |
| 10 12 | 
             
                def serialize_view(json, serialize_context: nil)
         | 
| 11 13 | 
             
                  json.set! :class, exception.class.name
         | 
| 12 14 | 
             
                  json.backtrace exception.backtrace
         | 
| 13 | 
            -
                  if cause = exception.cause
         | 
| 15 | 
            +
                  if (cause = exception.cause)
         | 
| 14 16 | 
             
                    json.cause do
         | 
| 15 17 | 
             
                      json.set! :class, cause.class.name
         | 
| 16 18 | 
             
                      json.backtrace cause.backtrace
         | 
| @@ -0,0 +1,78 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            require 'view_model/migration'
         | 
| 4 | 
            +
            require 'view_model/migrator'
         | 
| 5 | 
            +
             | 
| 6 | 
            +
            require 'rgl/adjacency'
         | 
| 7 | 
            +
            require 'rgl/dijkstra'
         | 
| 8 | 
            +
             | 
| 9 | 
            +
            module ViewModel::MigratableView
         | 
| 10 | 
            +
              extend ActiveSupport::Concern
         | 
| 11 | 
            +
             | 
| 12 | 
            +
              class_methods do
         | 
| 13 | 
            +
                def inherited(base)
         | 
| 14 | 
            +
                  super
         | 
| 15 | 
            +
                  base.initialize_as_migratable_view
         | 
| 16 | 
            +
                end
         | 
| 17 | 
            +
             | 
| 18 | 
            +
                def initialize_as_migratable_view
         | 
| 19 | 
            +
                  @migrations_lock   = Monitor.new
         | 
| 20 | 
            +
                  @migration_classes = {}
         | 
| 21 | 
            +
                  @migration_paths   = {}
         | 
| 22 | 
            +
                  @realized_migration_paths = true
         | 
| 23 | 
            +
                end
         | 
| 24 | 
            +
             | 
| 25 | 
            +
                def migration_path(from:, to:)
         | 
| 26 | 
            +
                  @migrations_lock.synchronize do
         | 
| 27 | 
            +
                    realize_paths! unless @realized_migration_paths
         | 
| 28 | 
            +
             | 
| 29 | 
            +
                    migrations = @migration_paths.fetch([from, to]) do
         | 
| 30 | 
            +
                      raise ViewModel::Migration::NoPathError.new(self, from, to)
         | 
| 31 | 
            +
                    end
         | 
| 32 | 
            +
             | 
| 33 | 
            +
                    migrations
         | 
| 34 | 
            +
                  end
         | 
| 35 | 
            +
                end
         | 
| 36 | 
            +
             | 
| 37 | 
            +
                private
         | 
| 38 | 
            +
             | 
| 39 | 
            +
                # Define a migration on this viewmodel
         | 
| 40 | 
            +
                def migrates(from:, to:, &block)
         | 
| 41 | 
            +
                  @migrations_lock.synchronize do
         | 
| 42 | 
            +
                    builder = ViewModel::Migration::Builder.new
         | 
| 43 | 
            +
                    builder.instance_exec(&block)
         | 
| 44 | 
            +
                    @migration_classes[[from, to]] = builder.build!
         | 
| 45 | 
            +
                    @realized_migration_paths = false
         | 
| 46 | 
            +
                  end
         | 
| 47 | 
            +
                end
         | 
| 48 | 
            +
             | 
| 49 | 
            +
                # Internal: find and record possible paths to the current schema version.
         | 
| 50 | 
            +
                def realize_paths!
         | 
| 51 | 
            +
                  @migration_paths.clear
         | 
| 52 | 
            +
             | 
| 53 | 
            +
                  graph = RGL::DirectedAdjacencyGraph.new
         | 
| 54 | 
            +
             | 
| 55 | 
            +
                  # Add edges backwards, as we care about paths from the latest version
         | 
| 56 | 
            +
                  @migration_classes.each_key do |from, to|
         | 
| 57 | 
            +
                    graph.add_edge(to, from)
         | 
| 58 | 
            +
                  end
         | 
| 59 | 
            +
             | 
| 60 | 
            +
                  paths = graph.dijkstra_shortest_paths(Hash.new { 1 }, self.schema_version)
         | 
| 61 | 
            +
             | 
| 62 | 
            +
                  paths.each do |target_version, path|
         | 
| 63 | 
            +
                    next if path.length == 1
         | 
| 64 | 
            +
             | 
| 65 | 
            +
                    # Store the path forwards rather than backwards
         | 
| 66 | 
            +
                    path_migration_classes = path.reverse.each_cons(2).map do |from, to|
         | 
| 67 | 
            +
                      @migration_classes.fetch([from, to])
         | 
| 68 | 
            +
                    end
         | 
| 69 | 
            +
             | 
| 70 | 
            +
                    key = [target_version, schema_version]
         | 
| 71 | 
            +
             | 
| 72 | 
            +
                    @migration_paths[key] = path_migration_classes.map(&:new)
         | 
| 73 | 
            +
                  end
         | 
| 74 | 
            +
             | 
| 75 | 
            +
                  @realized_paths = true
         | 
| 76 | 
            +
                end
         | 
| 77 | 
            +
              end
         | 
| 78 | 
            +
            end
         | 
| @@ -0,0 +1,48 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            class ViewModel::Migration
         | 
| 4 | 
            +
              require 'view_model/migration/no_path_error'
         | 
| 5 | 
            +
              require 'view_model/migration/one_way_error'
         | 
| 6 | 
            +
              require 'view_model/migration/unspecified_version_error'
         | 
| 7 | 
            +
             | 
| 8 | 
            +
              def up(view, _references)
         | 
| 9 | 
            +
                raise ViewModel::Migration::OneWayError.new(view[ViewModel::TYPE_ATTRIBUTE], :up)
         | 
| 10 | 
            +
              end
         | 
| 11 | 
            +
             | 
| 12 | 
            +
              def down(view, _references)
         | 
| 13 | 
            +
                raise ViewModel::Migration::OneWayError.new(view[ViewModel::TYPE_ATTRIBUTE], :down)
         | 
| 14 | 
            +
              end
         | 
| 15 | 
            +
             | 
| 16 | 
            +
              # Tiny DSL for defining migration classes
         | 
| 17 | 
            +
              class Builder
         | 
| 18 | 
            +
                def initialize
         | 
| 19 | 
            +
                  @up_block = nil
         | 
| 20 | 
            +
                  @down_block = nil
         | 
| 21 | 
            +
                end
         | 
| 22 | 
            +
             | 
| 23 | 
            +
                def build!
         | 
| 24 | 
            +
                  migration = Class.new(ViewModel::Migration)
         | 
| 25 | 
            +
                  migration.define_method(:up, &@up_block) if @up_block
         | 
| 26 | 
            +
                  migration.define_method(:down, &@down_block) if @down_block
         | 
| 27 | 
            +
                  migration
         | 
| 28 | 
            +
                end
         | 
| 29 | 
            +
             | 
| 30 | 
            +
                private
         | 
| 31 | 
            +
             | 
| 32 | 
            +
                def up(&block)
         | 
| 33 | 
            +
                  check_signature!(block)
         | 
| 34 | 
            +
                  @up_block = block
         | 
| 35 | 
            +
                end
         | 
| 36 | 
            +
             | 
| 37 | 
            +
                def down(&block)
         | 
| 38 | 
            +
                  check_signature!(block)
         | 
| 39 | 
            +
                  @down_block = block
         | 
| 40 | 
            +
                end
         | 
| 41 | 
            +
             | 
| 42 | 
            +
                def check_signature!(block)
         | 
| 43 | 
            +
                  unless block.arity == 2
         | 
| 44 | 
            +
                    raise RuntimeError.new('Illegal signature for migration method, must be (view, references)')
         | 
| 45 | 
            +
                  end
         | 
| 46 | 
            +
                end
         | 
| 47 | 
            +
              end
         | 
| 48 | 
            +
            end
         | 
| @@ -0,0 +1,26 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            class ViewModel::Migration::NoPathError < ViewModel::AbstractError
         | 
| 4 | 
            +
              attr_reader :vm_name, :from, :to
         | 
| 5 | 
            +
             | 
| 6 | 
            +
              status 400
         | 
| 7 | 
            +
             | 
| 8 | 
            +
              def initialize(viewmodel, from, to)
         | 
| 9 | 
            +
                @vm_name = viewmodel.view_name
         | 
| 10 | 
            +
                @from = from
         | 
| 11 | 
            +
                @to = to
         | 
| 12 | 
            +
                super()
         | 
| 13 | 
            +
              end
         | 
| 14 | 
            +
             | 
| 15 | 
            +
              def detail
         | 
| 16 | 
            +
                "No migration path for #{vm_name} from #{from} to #{to}"
         | 
| 17 | 
            +
              end
         | 
| 18 | 
            +
             | 
| 19 | 
            +
              def meta
         | 
| 20 | 
            +
                {
         | 
| 21 | 
            +
                  viewmodel: vm_name,
         | 
| 22 | 
            +
                  from: from,
         | 
| 23 | 
            +
                  to: to,
         | 
| 24 | 
            +
                }
         | 
| 25 | 
            +
              end
         | 
| 26 | 
            +
            end
         | 
| @@ -0,0 +1,24 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            class ViewModel::Migration::OneWayError < ViewModel::AbstractError
         | 
| 4 | 
            +
              attr_reader :vm_name, :direction
         | 
| 5 | 
            +
             | 
| 6 | 
            +
              status 400
         | 
| 7 | 
            +
             | 
| 8 | 
            +
              def initialize(vm_name, direction)
         | 
| 9 | 
            +
                @vm_name = vm_name
         | 
| 10 | 
            +
                @direction = direction
         | 
| 11 | 
            +
                super()
         | 
| 12 | 
            +
              end
         | 
| 13 | 
            +
             | 
| 14 | 
            +
              def detail
         | 
| 15 | 
            +
                "One way migration for #{vm_name} cannot be migrated #{direction}"
         | 
| 16 | 
            +
              end
         | 
| 17 | 
            +
             | 
| 18 | 
            +
              def meta
         | 
| 19 | 
            +
                {
         | 
| 20 | 
            +
                  viewmodel: vm_name,
         | 
| 21 | 
            +
                  direction: direction,
         | 
| 22 | 
            +
                }
         | 
| 23 | 
            +
              end
         | 
| 24 | 
            +
            end
         |