iknow_view_models 3.1.8 → 3.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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,25 @@
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
+ end
13
+
14
+ def detail
15
+ "No migration path for #{vm_name} from #{from} to #{to}"
16
+ end
17
+
18
+ def meta
19
+ {
20
+ viewmodel: vm_name,
21
+ from: from,
22
+ to: to,
23
+ }
24
+ end
25
+ end
@@ -0,0 +1,23 @@
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
+ end
12
+
13
+ def detail
14
+ "One way migration for #{vm_name} cannot be migrated #{direction}"
15
+ end
16
+
17
+ def meta
18
+ {
19
+ viewmodel: vm_name,
20
+ direction: direction,
21
+ }
22
+ end
23
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ViewModel::Migration::UnspecifiedVersionError < ViewModel::AbstractError
4
+ attr_reader :vm_name, :version
5
+
6
+ status 400
7
+
8
+ def initialize(vm_name, version)
9
+ @vm_name = vm_name
10
+ @version = version
11
+ end
12
+
13
+ def detail
14
+ "Provided view for #{vm_name} at version #{version} does not match request"
15
+ end
16
+
17
+ def meta
18
+ {
19
+ viewmodel: vm_name,
20
+ version: version,
21
+ }
22
+ end
23
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ViewModel
4
+ class Migrator
5
+ class << self
6
+ def migrated_deep_schema_version(viewmodel_class, required_versions, include_referenced: true)
7
+ deep_schema_version = viewmodel_class.deep_schema_version(include_referenced: include_referenced)
8
+
9
+ if required_versions.present?
10
+ deep_schema_version = deep_schema_version.dup
11
+
12
+ required_versions.each do |required_vm_class, required_version|
13
+ name = required_vm_class.view_name
14
+ if deep_schema_version.has_key?(name)
15
+ deep_schema_version[name] = required_version
16
+ end
17
+ end
18
+ end
19
+
20
+ deep_schema_version
21
+ end
22
+ end
23
+
24
+ def initialize(required_versions)
25
+ @paths = required_versions.each_with_object({}) do |(viewmodel_class, required_version), h|
26
+ if required_version != viewmodel_class.schema_version
27
+ path = viewmodel_class.migration_path(from: required_version, to: viewmodel_class.schema_version)
28
+ h[viewmodel_class.view_name] = path
29
+ end
30
+ end
31
+
32
+ @versions = required_versions.each_with_object({}) do |(viewmodel_class, required_version), h|
33
+ h[viewmodel_class.view_name] = [required_version, viewmodel_class.schema_version]
34
+ end
35
+ end
36
+
37
+ def migrate!(node, references:)
38
+ case node
39
+ when Hash
40
+ if (type = node[ViewModel::TYPE_ATTRIBUTE])
41
+ version = node[ViewModel::VERSION_ATTRIBUTE]
42
+
43
+ if migrate_viewmodel!(type, version, node, references)
44
+ node[ViewModel::MIGRATED_ATTRIBUTE] = true
45
+ end
46
+ end
47
+
48
+ node.each_value do |child|
49
+ migrate!(child, references: references)
50
+ end
51
+ when Array
52
+ node.each { |child| migrate!(child, references: references) }
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ def migrate_viewmodel!(_view_name, _version, _view_hash, _references)
59
+ raise RuntimeError.new('abstract method')
60
+ end
61
+ end
62
+
63
+ class UpMigrator < Migrator
64
+ private
65
+
66
+ def migrate_viewmodel!(view_name, source_version, view_hash, references)
67
+ path = @paths[view_name]
68
+ return false unless path
69
+
70
+ # We assume that an unspecified source version is the same as the required
71
+ # version.
72
+ required_version, current_version = @versions[view_name]
73
+
74
+ unless source_version.nil? || source_version == required_version
75
+ raise ViewModel::Migration::UnspecifiedVersionError.new(view_name, source_version)
76
+ end
77
+
78
+ path.each do |migration|
79
+ migration.up(view_hash, references)
80
+ end
81
+
82
+ view_hash[ViewModel::VERSION_ATTRIBUTE] = current_version
83
+
84
+ true
85
+ end
86
+ end
87
+
88
+ # down migrations find a reverse path from the current schema version to the
89
+ # specific version requested by the client.
90
+ class DownMigrator < Migrator
91
+ private
92
+
93
+ def migrate_viewmodel!(view_name, _, view_hash, references)
94
+ path = @paths[view_name]
95
+ return false unless path
96
+
97
+ required_version, _current_version = @versions[view_name]
98
+
99
+ path.reverse_each do |migration|
100
+ migration.down(view_hash, references)
101
+ end
102
+
103
+ view_hash[ViewModel::VERSION_ATTRIBUTE] = required_version
104
+
105
+ true
106
+ end
107
+ end
108
+ end
@@ -10,6 +10,9 @@ class ViewModel::Record < ViewModel
10
10
  attr_accessor :model
11
11
 
12
12
  require 'view_model/record/attribute_data'
13
+ require 'view_model/migratable_view'
14
+
15
+ include ViewModel::MigratableView
13
16
 
14
17
  class << self
15
18
  attr_reader :_members
@@ -207,7 +210,7 @@ class ViewModel::Record < ViewModel
207
210
  end
208
211
 
209
212
  def serialize_view(json, serialize_context: self.class.new_serialize_context)
210
- json.set!(ViewModel::ID_ATTRIBUTE, model.id) if model.respond_to?(:id)
213
+ json.set!(ViewModel::ID_ATTRIBUTE, self.id) if stable_id?
211
214
  json.set!(ViewModel::TYPE_ATTRIBUTE, self.view_name)
212
215
  json.set!(ViewModel::VERSION_ATTRIBUTE, self.class.schema_version)
213
216
 
@@ -4,7 +4,8 @@ class ViewModel::TestHelpers::ARVMBuilder
4
4
  # Building an ARVM requires three blocks, to define schema, model and
5
5
  # viewmodel. Support providing these either in an spec argument or as a
6
6
  # dsl-style builder.
7
- Spec = Struct.new(:schema, :model, :viewmodel) do
7
+ Spec = Struct.new(:schema, :model, :viewmodel)
8
+ class Spec
8
9
  def initialize(schema:, model:, viewmodel:)
9
10
  super(schema, model, viewmodel)
10
11
  end
@@ -0,0 +1,5 @@
1
+ {pkgs}:
2
+ {
3
+ ruby = pkgs.ruby_2_7;
4
+ postgresql = pkgs.postgresql_12;
5
+ }
@@ -7,6 +7,7 @@
7
7
  # This workaround is from https://github.com/manveru/bundix/issues/10#issuecomment-405879379
8
8
 
9
9
  require 'shellwords'
10
+ require 'uri'
10
11
 
11
12
  def sh(*args)
12
13
  warn args.shelljoin
@@ -20,7 +21,7 @@ require 'bundler'
20
21
 
21
22
  lockfile = Bundler::LockfileParser.new(File.read('Gemfile.lock'))
22
23
  gems = lockfile.specs.select { |spec| spec.source.is_a?(Bundler::Source::Rubygems) }
23
- sources = [URI('https://rubygems.org/')] | gems.map(&:source).flat_map(&:remotes)
24
+ sources = gems.map(&:source).flat_map(&:remotes).uniq
24
25
 
25
26
  FileUtils.mkdir_p 'nix/gem'
26
27
  Dir.chdir 'nix/gem' do
data/shell.nix CHANGED
@@ -1,10 +1,15 @@
1
- with import <nixpkgs> {};
1
+ { pkgs ? import <nixpkgs> {} }:
2
2
 
3
+ with pkgs;
4
+
5
+ let
6
+ dependencies = import ./nix/dependencies.nix { inherit pkgs; };
7
+ in
3
8
  (bundlerEnv {
4
9
  name = "iknow-view-models-shell";
5
10
  gemdir = ./nix/gem;
6
11
 
7
- gemConfig = (defaultGemConfig.override { postgresql = postgresql_11; });
12
+ gemConfig = (defaultGemConfig.override { inherit (dependencies) postgresql; });
8
13
 
9
- ruby = ruby_2_6;
14
+ inherit (dependencies) ruby;
10
15
  }).env
@@ -79,11 +79,25 @@ module ControllerTestModels
79
79
  end
80
80
  define_viewmodel do
81
81
  root!
82
+ self.schema_version = 2
83
+
82
84
  attributes :name
83
85
  associations :label, :target
84
86
  association :children
85
87
  association :poly, viewmodels: [:PolyOne, :PolyTwo]
86
88
  association :category, external: true
89
+
90
+ migrates from: 1, to: 2 do
91
+ up do |view, _refs|
92
+ if view.has_key?('old_name')
93
+ view['name'] = view.delete('old_name')
94
+ end
95
+ end
96
+
97
+ down do |view, refs|
98
+ view['old_name'] = view.delete('name')
99
+ end
100
+ end
87
101
  end
88
102
  end
89
103
 
@@ -120,8 +120,8 @@ module ViewModelSpecHelpers
120
120
  def model_attributes
121
121
  f = subject_association_features
122
122
  super.merge(schema: ->(t) { t.references :child, foreign_key: true },
123
- model: ->(m) { belongs_to :child, inverse_of: :model, dependent: :destroy },
124
- viewmodel: ->(v) { association :child, **f })
123
+ model: ->(_m) { belongs_to :child, inverse_of: :model, dependent: :destroy },
124
+ viewmodel: ->(_v) { association :child, **f })
125
125
  end
126
126
 
127
127
  def child_attributes
@@ -139,6 +139,71 @@ module ViewModelSpecHelpers
139
139
  end
140
140
  end
141
141
 
142
+ module ParentAndBelongsToChildWithMigration
143
+ extend ActiveSupport::Concern
144
+ include ViewModelSpecHelpers::ParentAndBelongsToChild
145
+ def model_attributes
146
+ super.merge(
147
+ schema: ->(t) { t.integer :new_field, default: 1, null: false },
148
+ viewmodel: ->(_v) {
149
+ self.schema_version = 4
150
+
151
+ attribute :new_field
152
+
153
+ # add: old_field (one-way)
154
+ migrates from: 1, to: 2 do
155
+ down do |view, _refs|
156
+ view.delete('old_field')
157
+ end
158
+ end
159
+
160
+ # rename: old_field -> mid_field
161
+ migrates from: 2, to: 3 do
162
+ up do |view, _refs|
163
+ if view.has_key?('old_field')
164
+ view['mid_field'] = view.delete('old_field') + 1
165
+ end
166
+ end
167
+
168
+ down do |view, _refs|
169
+ view['old_field'] = view.delete('mid_field') - 1
170
+ end
171
+ end
172
+
173
+ # rename: mid_field -> new_field
174
+ migrates from: 3, to: 4 do
175
+ up do |view, _refs|
176
+ if view.has_key?('mid_field')
177
+ view['new_field'] = view.delete('mid_field') + 1
178
+ end
179
+ end
180
+
181
+ down do |view, _refs|
182
+ view['mid_field'] = view.delete('new_field') - 1
183
+ end
184
+ end
185
+ })
186
+ end
187
+
188
+ def child_attributes
189
+ super.merge(
190
+ viewmodel: ->(_v) {
191
+ self.schema_version = 3
192
+
193
+ # delete: former_field
194
+ migrates from: 2, to: 3 do
195
+ up do |view, _refs|
196
+ view.delete('former_field')
197
+ end
198
+
199
+ down do |view, _refs|
200
+ view['former_field'] = 'reconstructed'
201
+ end
202
+ end
203
+ })
204
+ end
205
+ end
206
+
142
207
  module ParentAndSharedBelongsToChild
143
208
  extend ActiveSupport::Concern
144
209
  include ViewModelSpecHelpers::ParentAndBelongsToChild