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.
- checksums.yaml +4 -4
- data/.circleci/config.yml +6 -6
- data/.rubocop.yml +5 -0
- data/Appraisals +3 -3
- data/Gemfile +6 -2
- data/gemfiles/{rails_6_0_beta.gemfile → rails_6_0.gemfile} +2 -2
- data/iknow_view_models.gemspec +2 -1
- data/lib/iknow_view_models/version.rb +1 -1
- data/lib/view_model.rb +15 -4
- data/lib/view_model/active_record.rb +1 -1
- data/lib/view_model/active_record/association_data.rb +1 -1
- data/lib/view_model/active_record/cache.rb +110 -32
- data/lib/view_model/active_record/cache/cacheable_view.rb +2 -2
- data/lib/view_model/active_record/controller.rb +53 -1
- data/lib/view_model/after_transaction_runner.rb +2 -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 +25 -0
- data/lib/view_model/migration/one_way_error.rb +23 -0
- data/lib/view_model/migration/unspecified_version_error.rb +23 -0
- data/lib/view_model/migrator.rb +108 -0
- data/lib/view_model/record.rb +4 -1
- data/lib/view_model/test_helpers/arvm_builder.rb +2 -1
- data/lib/view_model/traversal_context.rb +6 -4
- data/nix/dependencies.nix +5 -0
- data/nix/gem/generate.rb +2 -1
- data/shell.nix +8 -3
- data/test/helpers/controller_test_helpers.rb +14 -0
- data/test/helpers/viewmodel_spec_helpers.rb +67 -2
- data/test/unit/view_model/active_record/cache_test.rb +41 -5
- data/test/unit/view_model/active_record/controller_test.rb +34 -0
- data/test/unit/view_model/active_record/migration_test.rb +161 -0
- metadata +31 -9
- data/.travis.yml +0 -31
- data/appveyor.yml +0 -22
@@ -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
|
data/lib/view_model/record.rb
CHANGED
@@ -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,
|
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)
|
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
|
-
|
97
|
-
|
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?
|
data/nix/gem/generate.rb
CHANGED
@@ -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 =
|
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
|
-
|
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 {
|
12
|
+
gemConfig = (defaultGemConfig.override { inherit (dependencies) postgresql; });
|
8
13
|
|
9
|
-
|
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: ->(
|
124
|
-
viewmodel: ->(
|
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
|