iknow_view_models 3.1.4 → 3.2.0

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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/.circleci/config.yml +6 -6
  3. data/.rubocop.yml +5 -0
  4. data/Appraisals +3 -3
  5. data/Gemfile +6 -2
  6. data/gemfiles/{rails_6_0_beta.gemfile → rails_6_0.gemfile} +2 -2
  7. data/iknow_view_models.gemspec +2 -1
  8. data/lib/iknow_view_models/version.rb +1 -1
  9. data/lib/view_model.rb +15 -4
  10. data/lib/view_model/active_record.rb +1 -1
  11. data/lib/view_model/active_record/association_data.rb +1 -1
  12. data/lib/view_model/active_record/cache.rb +110 -32
  13. data/lib/view_model/active_record/cache/cacheable_view.rb +2 -2
  14. data/lib/view_model/active_record/controller.rb +53 -1
  15. data/lib/view_model/after_transaction_runner.rb +2 -1
  16. data/lib/view_model/migratable_view.rb +78 -0
  17. data/lib/view_model/migration.rb +48 -0
  18. data/lib/view_model/migration/no_path_error.rb +25 -0
  19. data/lib/view_model/migration/one_way_error.rb +23 -0
  20. data/lib/view_model/migration/unspecified_version_error.rb +23 -0
  21. data/lib/view_model/migrator.rb +108 -0
  22. data/lib/view_model/record.rb +4 -1
  23. data/lib/view_model/test_helpers/arvm_builder.rb +2 -1
  24. data/lib/view_model/traversal_context.rb +6 -4
  25. data/nix/dependencies.nix +5 -0
  26. data/nix/gem/generate.rb +2 -1
  27. data/shell.nix +8 -3
  28. data/test/helpers/controller_test_helpers.rb +14 -0
  29. data/test/helpers/viewmodel_spec_helpers.rb +67 -2
  30. data/test/unit/view_model/active_record/cache_test.rb +41 -5
  31. data/test/unit/view_model/active_record/controller_test.rb +34 -0
  32. data/test/unit/view_model/active_record/migration_test.rb +161 -0
  33. metadata +31 -9
  34. data/.travis.yml +0 -31
  35. data/appveyor.yml +0 -22
@@ -33,7 +33,8 @@ module ViewModel::AfterTransactionRunner
33
33
  if connection.transaction_open?
34
34
  connection.add_transaction_record(self)
35
35
  else
36
- after_transaction
36
+ before_commit
37
+ after_commit
37
38
  end
38
39
  end
39
40
 
@@ -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
@@ -93,13 +93,15 @@ class ViewModel::TraversalContext
93
93
  end
94
94
 
95
95
  def run_callback(hook, view, **args)
96
- callbacks.each do |callback|
97
- callback.run_callback(hook, view, self, **args)
98
- end
99
-
96
+ # Run in-viewmodel callback hooks before context hooks, as they are
97
+ # permitted to alter the model.
100
98
  if view.respond_to?(hook.dsl_viewmodel_callback_method)
101
99
  view.public_send(hook.dsl_viewmodel_callback_method, hook.context_name => self, **args)
102
100
  end
101
+
102
+ callbacks.each do |callback|
103
+ callback.run_callback(hook, view, self, **args)
104
+ end
103
105
  end
104
106
 
105
107
  def root?
@@ -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