iknow_view_models 2.10.1 → 3.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/.circleci/config.yml +119 -0
  3. data/.travis.yml +31 -0
  4. data/Appraisals +6 -16
  5. data/gemfiles/{rails_7_0.gemfile → rails_6_0_beta.gemfile} +2 -2
  6. data/iknow_view_models.gemspec +3 -5
  7. data/lib/iknow_view_models/version.rb +1 -1
  8. data/lib/view_model/active_record/association_data.rb +206 -92
  9. data/lib/view_model/active_record/association_manipulation.rb +22 -12
  10. data/lib/view_model/active_record/cache/cacheable_view.rb +3 -13
  11. data/lib/view_model/active_record/cache.rb +2 -2
  12. data/lib/view_model/active_record/cloner.rb +11 -11
  13. data/lib/view_model/active_record/controller.rb +0 -2
  14. data/lib/view_model/active_record/update_context.rb +21 -3
  15. data/lib/view_model/active_record/update_data.rb +43 -45
  16. data/lib/view_model/active_record/update_operation.rb +265 -153
  17. data/lib/view_model/active_record/visitor.rb +9 -6
  18. data/lib/view_model/active_record.rb +94 -74
  19. data/lib/view_model/after_transaction_runner.rb +3 -18
  20. data/lib/view_model/callbacks.rb +2 -2
  21. data/lib/view_model/changes.rb +24 -16
  22. data/lib/view_model/config.rb +6 -2
  23. data/lib/view_model/deserialization_error.rb +31 -0
  24. data/lib/view_model/deserialize_context.rb +2 -6
  25. data/lib/view_model/error_view.rb +6 -5
  26. data/lib/view_model/record/attribute_data.rb +11 -6
  27. data/lib/view_model/record.rb +44 -24
  28. data/lib/view_model/serialize_context.rb +2 -63
  29. data/lib/view_model/test_helpers/arvm_builder.rb +2 -4
  30. data/lib/view_model/traversal_context.rb +2 -2
  31. data/lib/view_model.rb +21 -13
  32. data/shell.nix +1 -1
  33. data/test/helpers/arvm_test_models.rb +4 -12
  34. data/test/helpers/arvm_test_utilities.rb +6 -0
  35. data/test/helpers/controller_test_helpers.rb +6 -6
  36. data/test/helpers/viewmodel_spec_helpers.rb +63 -52
  37. data/test/unit/view_model/access_control_test.rb +88 -37
  38. data/test/unit/view_model/active_record/belongs_to_test.rb +110 -178
  39. data/test/unit/view_model/active_record/cache_test.rb +11 -5
  40. data/test/unit/view_model/active_record/cloner_test.rb +1 -1
  41. data/test/unit/view_model/active_record/controller_test.rb +12 -20
  42. data/test/unit/view_model/active_record/has_many_test.rb +540 -316
  43. data/test/unit/view_model/active_record/has_many_through_poly_test.rb +12 -15
  44. data/test/unit/view_model/active_record/has_many_through_test.rb +15 -58
  45. data/test/unit/view_model/active_record/has_one_test.rb +288 -135
  46. data/test/unit/view_model/active_record/poly_test.rb +0 -1
  47. data/test/unit/view_model/active_record/shared_test.rb +21 -39
  48. data/test/unit/view_model/active_record/version_test.rb +3 -2
  49. data/test/unit/view_model/active_record_test.rb +5 -63
  50. data/test/unit/view_model/callbacks_test.rb +1 -0
  51. data/test/unit/view_model/record_test.rb +0 -32
  52. data/test/unit/view_model/traversal_context_test.rb +13 -12
  53. metadata +15 -25
  54. data/.github/workflows/gem-push.yml +0 -31
  55. data/.github/workflows/test.yml +0 -65
  56. data/gemfiles/rails_6_0.gemfile +0 -9
  57. data/gemfiles/rails_6_1.gemfile +0 -9
  58. data/test/unit/view_model/active_record/optional_attribute_view_test.rb +0 -58
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a9b87bb5a0377e13f42a5468d35a918f7555cad606a85a8ec20217862c9e3382
4
- data.tar.gz: 84b0c4a23ed0749dc27a029cc93958f2571e047ce327214c174386e26ce921a3
3
+ metadata.gz: a86c98f9ceda1cb4bf6980a84eb2051b5bc3904c08ab85b8546c4b16c78d26ff
4
+ data.tar.gz: 3b988a29d63e06d929c3fa0ca31b7b11f585e03263653c8f5bea64f2583c2fa3
5
5
  SHA512:
6
- metadata.gz: 0d2b3aafd5900d7cccd209c7526a39ad6099d670e4a15586e9b64b28a0211d153bef4dadca7d3ab4ed2a7203167e0022565e28bb8337669175c2d0d2257a6593
7
- data.tar.gz: b8e28fca052e4664244704d876d94242d1ca4c2b40d696095011a5d795921d31dd5a588133e22ee813540e56a9cacfc77158b50b6df70288e1e59e6a3fa87fc3
6
+ metadata.gz: f281b79eda3e83689d5f2aec0239f89d73a4c1ad0778ee017c20af65088c50dc7356d2149bce7b487766a68947c85645cddd0f6e92f0312a7c2cad1489ccb6f0
7
+ data.tar.gz: a3f4960911039bc0679c068cc67c86bee0cdad278ab7acfbea66e36cdf8e064e45eed8ea709ecd3b75ddc42a7934645f93ad9699be5c40a0595a3d2b5db10dc6
@@ -0,0 +1,119 @@
1
+ version: 2.1
2
+
3
+ executors:
4
+ ruby-pg:
5
+ parameters:
6
+ ruby-version:
7
+ type: string
8
+ default: "2.6"
9
+ pg-version:
10
+ type: string
11
+ default: "11"
12
+ gemfile:
13
+ type: string
14
+ default: "Gemfile"
15
+ environment:
16
+ PGHOST: 127.0.0.1
17
+ PGUSER: eikaiwa
18
+ docker:
19
+ - image: circleci/ruby:<< parameters.ruby-version >>
20
+ environment:
21
+ BUNDLE_JOBS: 3
22
+ BUNDLE_RETRY: 3
23
+ BUNDLE_PATH: vendor/bundle
24
+ RAILS_ENV: test
25
+ BUNDLE_GEMFILE: << parameters.gemfile >>
26
+ - image: circleci/postgres:<< parameters.pg-version >>-alpine
27
+ environment:
28
+ POSTGRES_USER: eikaiwa
29
+ POSTGRES_DB: iknow_view_models
30
+ POSTGRES_PASSWORD: ""
31
+
32
+ jobs:
33
+ test:
34
+ parameters:
35
+ ruby-version:
36
+ type: string
37
+ pg-version:
38
+ type: string
39
+ gemfile:
40
+ type: string
41
+ executor:
42
+ name: ruby-pg
43
+ ruby-version: << parameters.ruby-version >>
44
+ pg-version: << parameters.pg-version >>
45
+ gemfile: << parameters.gemfile >>
46
+ parallelism: 1
47
+ steps:
48
+ - checkout
49
+
50
+ - run:
51
+ # Remove the non-appraisal gemfile for safety: we never want to use it.
52
+ name: Prepare bundler
53
+ command: bundle -v && rm Gemfile
54
+
55
+ - run:
56
+ name: Compute a gemfile lock
57
+ command: bundle lock && cp "${BUNDLE_GEMFILE}.lock" /tmp/gem-lock
58
+
59
+ - restore_cache:
60
+ keys:
61
+ - iknow_viewmodels-<< parameters.ruby-version >>-{{ checksum "/tmp/gem-lock" }}
62
+ - iknow_viewmodels-
63
+
64
+ - run:
65
+ name: Bundle Install
66
+ command: bundle check || bundle install
67
+
68
+ - save_cache:
69
+ key: iknow_viewmodels-<< parameters.ruby-version >>-{{ checksum "/tmp/gem-lock" }}
70
+ paths:
71
+ - vendor/bundle
72
+
73
+ - run:
74
+ name: Wait for DB
75
+ command: dockerize -wait tcp://localhost:5432 -timeout 1m
76
+
77
+ - run:
78
+ name: Run minitest
79
+ command: bundle exec rake test
80
+
81
+ - store_test_results:
82
+ path: test/reports
83
+
84
+ publish:
85
+ executor: ruby-pg
86
+ steps:
87
+ - checkout
88
+ - run:
89
+ name: Setup Rubygems
90
+ command: |
91
+ mkdir ~/.gem &&
92
+ echo -e "---\r\n:rubygems_api_key: $RUBYGEMS_API_KEY" > ~/.gem/credentials &&
93
+ chmod 0600 ~/.gem/credentials
94
+ - run:
95
+ name: Publish to Rubygems
96
+ command: |
97
+ gem build iknow_view_models.gemspec
98
+ gem push iknow_view_models-*.gem
99
+
100
+ workflows:
101
+ version: 2.1
102
+ build:
103
+ jobs:
104
+ - test:
105
+ name: 'ruby 2.6 rails 5.2 pg 11'
106
+ ruby-version: "2.6"
107
+ pg-version: "11"
108
+ gemfile: gemfiles/rails_5_2.gemfile
109
+ - test:
110
+ name: 'ruby 2.6 rails 6.0 pg 11'
111
+ ruby-version: "2.6"
112
+ pg-version: "11"
113
+ gemfile: gemfiles/rails_6_0_beta.gemfile
114
+ - publish:
115
+ filters:
116
+ branches:
117
+ only: master
118
+ tags:
119
+ ignore: /.*/
data/.travis.yml ADDED
@@ -0,0 +1,31 @@
1
+ dist: trusty
2
+ sudo: false
3
+ language: ruby
4
+ cache: bundler
5
+
6
+ rvm:
7
+ - 2.5
8
+
9
+ gemfile:
10
+ - gemfiles/rails_5_2.gemfile
11
+
12
+ addons:
13
+ postgresql: "10"
14
+ apt:
15
+ packages:
16
+ - postgresql-10
17
+ - postgresql-client-10
18
+ - postgresql-server-dev-10
19
+ env:
20
+ global:
21
+ - PGPORT=5433
22
+
23
+ before_install:
24
+ - gem update --system
25
+ - gem install bundler
26
+
27
+ before_script:
28
+ - psql -c 'CREATE DATABASE iknow_view_models;'
29
+
30
+ notifications:
31
+ email: false
data/Appraisals CHANGED
@@ -1,19 +1,9 @@
1
- appraise 'rails-5-2' do
2
- gem 'activerecord', '~> 5.2.0'
3
- gem 'activesupport', '~> 5.2.0'
1
+ appraise "rails-5-2" do
2
+ gem "activerecord", "~> 5.2.0"
3
+ gem "activesupport", "~> 5.2.0"
4
4
  end
5
5
 
6
- appraise 'rails-6-0' do
7
- gem 'activerecord', '~> 6.0.0'
8
- gem 'activesupport', '~> 6.0.0'
9
- end
10
-
11
- appraise 'rails-6-1' do
12
- gem 'activerecord', '~> 6.1.0'
13
- gem 'activesupport', '~> 6.1.0'
14
- end
15
-
16
- appraise 'rails-7-0' do
17
- gem 'activerecord', '~> 7.0.0'
18
- gem 'activesupport', '~> 7.0.0'
6
+ appraise "rails-6-0-beta" do
7
+ gem "activerecord", "~> 6.0.0.beta"
8
+ gem "activesupport", "~> 6.0.0.beta"
19
9
  end
@@ -3,7 +3,7 @@
3
3
  source "https://rubygems.org"
4
4
 
5
5
  gem "minitest-ci"
6
- gem "activerecord", "~> 7.0.0"
7
- gem "activesupport", "~> 7.0.0"
6
+ gem "activerecord", "~> 6.0.0.beta"
7
+ gem "activesupport", "~> 6.0.0.beta"
8
8
 
9
9
  gemspec path: "../"
@@ -20,15 +20,13 @@ Gem::Specification.new do |spec|
20
20
  spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
21
21
  spec.require_paths = ["lib"]
22
22
 
23
- spec.required_ruby_version = '>= 2.7.3'
24
-
25
23
  spec.add_dependency "activerecord", ">= 5.0"
26
24
  spec.add_dependency "activesupport", ">= 5.0"
27
25
 
28
26
  spec.add_dependency "acts_as_manual_list"
29
- spec.add_dependency "deep_preloader"
27
+ spec.add_dependency "deep_preloader", ">= 1.0.1"
30
28
  spec.add_dependency "iknow_cache"
31
- spec.add_dependency "iknow_params", ">= 2.2.0", "< 2.4"
29
+ spec.add_dependency "iknow_params", "~> 2.2.0"
32
30
  spec.add_dependency "safe_values"
33
31
  spec.add_dependency "keyword_builder"
34
32
 
@@ -44,7 +42,7 @@ Gem::Specification.new do |spec|
44
42
  spec.add_development_dependency "byebug"
45
43
  spec.add_development_dependency "method_source"
46
44
  spec.add_development_dependency "minitest-hooks"
47
- spec.add_development_dependency "pg"
45
+ spec.add_development_dependency "pg", '~> 0.18' # As of 5.1.4, Rails runtime check excludes pg 1.x, see #31669
48
46
  spec.add_development_dependency "pry"
49
47
  spec.add_development_dependency "rake"
50
48
  spec.add_development_dependency "rspec-expectations"
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module IknowViewModels
4
- VERSION = '2.10.1'
4
+ VERSION = '3.0.0'
5
5
  end
@@ -1,95 +1,137 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # TODO consider rephrase scope for consistency
4
3
  class ViewModel::ActiveRecord::AssociationData
5
- attr_reader :direct_reflection, :association_name
6
-
7
- def initialize(association_name, direct_reflection, viewmodel_classes, shared, optional, through_to, through_order_attr)
8
- @association_name = association_name
9
- @direct_reflection = direct_reflection
10
- @shared = shared
11
- @optional = optional
12
- @through_to = through_to
13
- @through_order_attr = through_order_attr
14
-
15
- if viewmodel_classes
16
- @viewmodel_classes = Array.wrap(viewmodel_classes).map! do |v|
17
- case v
18
- when String, Symbol
19
- ViewModel::Registry.for_view_name(v.to_s)
20
- when Class
21
- v
4
+ class InvalidAssociation < RuntimeError; end
5
+
6
+ attr_reader :association_name, :direct_reflection
7
+
8
+ def initialize(owner:,
9
+ association_name:,
10
+ direct_association_name:,
11
+ indirect_association_name:,
12
+ target_viewmodels:,
13
+ external:,
14
+ through_order_attr:,
15
+ read_only:)
16
+ @association_name = association_name
17
+
18
+ @direct_reflection = owner.model_class.reflect_on_association(direct_association_name)
19
+ if @direct_reflection.nil?
20
+ raise InvalidAssociation.new("Association '#{direct_association_name}' not found in model '#{model_class.name}'")
21
+ end
22
+
23
+ @indirect_association_name = indirect_association_name
24
+
25
+ @read_only = read_only
26
+ @external = external
27
+ @through_order_attr = through_order_attr
28
+ @target_viewmodels = target_viewmodels
29
+
30
+ # Target models/reflections/viewmodels are lazily evaluated so that we can
31
+ # safely express cycles.
32
+ @initialized = false
33
+ @mutex = Mutex.new
34
+ end
35
+
36
+ def lazy_initialize!
37
+ @mutex.synchronize do
38
+ return if @initialized
39
+
40
+ if through?
41
+ intermediate_model = @direct_reflection.klass
42
+ @indirect_reflection = load_indirect_reflection(intermediate_model, @indirect_association_name)
43
+ target_reflection = @indirect_reflection
44
+ else
45
+ target_reflection = @direct_reflection
46
+ end
47
+
48
+ @viewmodel_classes =
49
+ if @target_viewmodels.present?
50
+ # Explicitly named
51
+ @target_viewmodels.map { |v| resolve_viewmodel_class(v) }
22
52
  else
23
- raise ArgumentError.new("Invalid viewmodel class: #{v.inspect}")
53
+ # Infer name from name of model
54
+ if target_reflection.polymorphic?
55
+ raise InvalidAssociation.new(
56
+ 'Cannot automatically infer target viewmodels from polymorphic association')
57
+ end
58
+ infer_viewmodel_class(target_reflection.klass)
24
59
  end
60
+
61
+ @referenced = @viewmodel_classes.first.root?
62
+
63
+ # Non-referenced viewmodels must be owned. For referenced viewmodels, we
64
+ # own it if it points to us. Through associations aren't considered
65
+ # `owned?`: while we do own the implicit direct viewmodel, we don't own
66
+ # the target of the association.
67
+ @owned = !@referenced || (target_reflection.macro != :belongs_to)
68
+
69
+ unless @viewmodel_classes.all? { |v| v.root? == @referenced }
70
+ raise InvalidAssociation.new('Invalid association target: mixed root and non-root viewmodels')
25
71
  end
26
- end
27
72
 
28
- if through?
29
- # Through associations must always be an owned direct association to a
30
- # shared indirect target. We expect the user to set shared: true to
31
- # express the ownership of the indirect target, but this direct
32
- # association to the intermediate is in fact owned. This ownership
33
- # property isn't directly used anywhere: the synthetic intermediate
34
- # viewmodel is only used in the deserialization update operations, which
35
- # directly understands the semantics of through associations.
36
- raise ArgumentError.new("Through associations must be to a shared target") unless @shared
37
- raise ArgumentError.new("Through associations must be `has_many`") unless direct_reflection.macro == :has_many
38
- end
39
- end
73
+ if external? && !@referenced
74
+ raise InvalidAssociation.new('External associations must be to root viewmodels')
75
+ end
40
76
 
41
- # reflection for the target of this association: indirect if through, direct otherwise
42
- def target_reflection
43
- if through?
44
- indirect_reflection
45
- else
46
- direct_reflection
77
+ if through?
78
+ unless @referenced
79
+ raise InvalidAssociation.new('Through associations must be to root viewmodels')
80
+ end
81
+
82
+ @direct_viewmodel = build_direct_viewmodel(@direct_reflection, @indirect_reflection,
83
+ @viewmodel_classes, @through_order_attr)
84
+ end
85
+
86
+ @initialized = true
47
87
  end
48
88
  end
49
89
 
50
- def polymorphic?
51
- target_reflection.polymorphic?
90
+ def association?
91
+ true
52
92
  end
53
93
 
54
- def viewmodel_classes
55
- # If we weren't given explicit viewmodel classes, try to work out from the
56
- # names. This should work unless the association is polymorphic.
57
- @viewmodel_classes ||=
58
- begin
59
- model_class = target_reflection.klass
60
- if model_class.nil?
61
- raise "Couldn't derive target class for association '#{target_reflection.name}"
62
- end
63
- inferred_view_name = ViewModel::Registry.default_view_name(model_class.name)
64
- viewmodel_class = ViewModel::Registry.for_view_name(inferred_view_name) # TODO: improve error message to show it's looking for default name
65
- [viewmodel_class]
66
- end
94
+ def referenced?
95
+ lazy_initialize! unless @initialized
96
+ @referenced
67
97
  end
68
98
 
69
- private def model_to_viewmodel
70
- @model_to_viewmodel ||= viewmodel_classes.each_with_object({}) do |vm, h|
71
- h[vm.model_class] = vm
72
- end
99
+ def nested?
100
+ !referenced?
73
101
  end
74
102
 
75
- private def name_to_viewmodel
76
- @name_to_viewmodel ||= viewmodel_classes.each_with_object({}) do |vm, h|
77
- h[vm.view_name] = vm
78
- vm.view_aliases.each do |view_alias|
79
- h[view_alias] = vm
80
- end
81
- end
103
+ def owned?
104
+ lazy_initialize! unless @initialized
105
+ @owned
82
106
  end
83
107
 
84
108
  def shared?
85
- @shared
109
+ !owned?
110
+ end
111
+
112
+ def external?
113
+ @external
114
+ end
115
+
116
+ def read_only?
117
+ @read_only
118
+ end
119
+
120
+ # reflection for the target of this association: indirect if through, direct otherwise
121
+ def target_reflection
122
+ if through?
123
+ indirect_reflection
124
+ else
125
+ direct_reflection
126
+ end
86
127
  end
87
128
 
88
- def optional?
89
- @optional
129
+ def polymorphic?
130
+ target_reflection.polymorphic?
90
131
  end
91
132
 
92
- def pointer_location # TODO name
133
+ # The side of the immediate association that holds the pointer.
134
+ def pointer_location
93
135
  case direct_reflection.macro
94
136
  when :belongs_to
95
137
  :local
@@ -98,6 +140,24 @@ class ViewModel::ActiveRecord::AssociationData
98
140
  end
99
141
  end
100
142
 
143
+ def indirect_reflection
144
+ lazy_initialize! unless @initialized
145
+ @indirect_reflection
146
+ end
147
+
148
+ def direct_reflection_inverse(foreign_class = nil)
149
+ if direct_reflection.polymorphic?
150
+ direct_reflection.polymorphic_inverse_of(foreign_class)
151
+ else
152
+ direct_reflection.inverse_of
153
+ end
154
+ end
155
+
156
+ def viewmodel_classes
157
+ lazy_initialize! unless @initialized
158
+ @viewmodel_classes
159
+ end
160
+
101
161
  def viewmodel_class_for_model(model_class)
102
162
  model_to_viewmodel[model_class]
103
163
  end
@@ -132,47 +192,101 @@ class ViewModel::ActiveRecord::AssociationData
132
192
  unless viewmodel_classes.size == 1
133
193
  raise ArgumentError.new("More than one possible class for association '#{target_reflection.name}'")
134
194
  end
195
+
135
196
  viewmodel_classes.first
136
197
  end
137
198
 
138
199
  def through?
139
- @through_to.present?
200
+ @indirect_association_name.present?
140
201
  end
141
202
 
142
203
  def direct_viewmodel
143
- @direct_viewmodel ||= begin
144
- raise 'not a through association' unless through?
204
+ raise ArgumentError.new('not a through association') unless through?
205
+ lazy_initialize! unless @initialized
206
+ @direct_viewmodel
207
+ end
208
+
209
+ def collection?
210
+ through? || direct_reflection.collection?
211
+ end
145
212
 
146
- # Join table viewmodel class
213
+ def indirect_association_data
214
+ direct_viewmodel._association_data(indirect_reflection.name)
215
+ end
147
216
 
148
- # For A has_many B through T; where this association is defined on A
217
+ private
149
218
 
150
- # Copy into scope for new class block
151
- direct_reflection = self.direct_reflection # A -> T
152
- indirect_reflection = self.indirect_reflection # T -> B
153
- through_order_attr = @through_order_attr
154
- viewmodel_classes = self.viewmodel_classes
219
+ # Through associations must always be to a root viewmodel, via an owned
220
+ # has_many association to an intermediate model. A synthetic viewmodel is
221
+ # created to represent this intermediate, but is used only internally by the
222
+ # deserialization update operations, which directly understands the semantics
223
+ # of through associations.
224
+ def load_indirect_reflection(intermediate_model, indirect_association_name)
225
+ indirect_reflection =
226
+ intermediate_model.reflect_on_association(ActiveSupport::Inflector.singularize(indirect_association_name))
155
227
 
156
- Class.new(ViewModel::ActiveRecord) do
157
- self.synthetic = true
158
- self.model_class = direct_reflection.klass
159
- self.view_name = direct_reflection.klass.name
160
- association indirect_reflection.name, shared: true, optional: false, viewmodels: viewmodel_classes
161
- acts_as_list through_order_attr if through_order_attr
162
- end
228
+ if indirect_reflection.nil?
229
+ raise InvalidAssociation.new(
230
+ "Indirect association '#{@indirect_association_name}' not found in "\
231
+ "intermediate model '#{intermediate_model.name}'")
232
+ end
233
+
234
+ unless direct_reflection.macro == :has_many
235
+ raise InvalidAssociation.new('Through associations must be `has_many`')
163
236
  end
237
+
238
+ indirect_reflection
164
239
  end
165
240
 
166
- def indirect_reflection
167
- @indirect_reflection ||=
168
- direct_reflection.klass.reflect_on_association(ActiveSupport::Inflector.singularize(@through_to))
241
+ def build_direct_viewmodel(direct_reflection, indirect_reflection, viewmodel_classes, through_order_attr)
242
+ # Join table viewmodel class. For A has_many B through T; where this association is defined on A
243
+ # direct_reflection = A -> T
244
+ # indirect_reflection = T -> B
245
+
246
+ Class.new(ViewModel::ActiveRecord) do
247
+ self.synthetic = true
248
+ self.model_class = direct_reflection.klass
249
+ self.view_name = direct_reflection.klass.name
250
+ association indirect_reflection.name, viewmodels: viewmodel_classes
251
+ acts_as_list through_order_attr if through_order_attr
252
+ end
169
253
  end
170
254
 
171
- def collection?
172
- through? || direct_reflection.collection?
255
+ def resolve_viewmodel_class(v)
256
+ case v
257
+ when String, Symbol
258
+ ViewModel::Registry.for_view_name(v.to_s)
259
+ when Class
260
+ v
261
+ else
262
+ raise InvalidAssociation.new("Invalid viewmodel class: #{v.inspect}")
263
+ end
173
264
  end
174
265
 
175
- def indirect_association_data
176
- direct_viewmodel._association_data(indirect_reflection.name)
266
+ def infer_viewmodel_class(model_class)
267
+ # If we weren't given explicit viewmodel classes, try to work out from the
268
+ # names. This should work unless the association is polymorphic.
269
+ if model_class.nil?
270
+ raise InvalidAssociation.new("Couldn't derive target class for model association '#{target_reflection.name}'")
271
+ end
272
+
273
+ inferred_view_name = ViewModel::Registry.default_view_name(model_class.name)
274
+ viewmodel_class = ViewModel::Registry.for_view_name(inferred_view_name) # TODO: improve error message to show it's looking for default name
275
+ [viewmodel_class]
276
+ end
277
+
278
+ def model_to_viewmodel
279
+ @model_to_viewmodel ||= viewmodel_classes.each_with_object({}) do |vm, h|
280
+ h[vm.model_class] = vm
281
+ end
282
+ end
283
+
284
+ def name_to_viewmodel
285
+ @name_to_viewmodel ||= viewmodel_classes.each_with_object({}) do |vm, h|
286
+ h[vm.view_name] = vm
287
+ vm.view_aliases.each do |view_alias|
288
+ h[view_alias] = vm
289
+ end
290
+ end
177
291
  end
178
292
  end
@@ -52,9 +52,7 @@ module ViewModel::ActiveRecord::AssociationManipulation
52
52
  def replace_associated(association_name, update_hash, references: {}, deserialize_context: self.class.new_deserialize_context)
53
53
  association_data = self.class._association_data(association_name)
54
54
 
55
- # TODO: structure checking
56
-
57
- if association_data.through? || association_data.shared?
55
+ if association_data.referenced?
58
56
  is_fupdate =
59
57
  association_data.collection? &&
60
58
  update_hash.is_a?(Hash) &&
@@ -125,10 +123,6 @@ module ViewModel::ActiveRecord::AssociationManipulation
125
123
 
126
124
  update_context = ViewModel::ActiveRecord::UpdateContext.build!(root_update_data, referenced_update_data, root_type: direct_viewmodel_class)
127
125
 
128
- # Provide information about what was updated
129
- deserialize_context.updated_associations = root_update_data.map(&:updated_associations)
130
- .inject({}) { |acc, assocs| acc.deep_merge(assocs) }
131
-
132
126
  # Set new parent
133
127
  new_parent = ViewModel::ActiveRecord::UpdateOperation::ParentData.new(direct_reflection.inverse_of, self)
134
128
  update_context.root_updates.each { |update| update.reparent_to = new_parent }
@@ -177,15 +171,26 @@ module ViewModel::ActiveRecord::AssociationManipulation
177
171
  child_context = self.context_for_child(association_name, context: deserialize_context)
178
172
  updated_viewmodels = update_context.run!(deserialize_context: child_context)
179
173
 
174
+ # Propagate changes and finalize the parent
175
+ updated_viewmodels.each do |child|
176
+ child_changes = child.previous_changes
177
+
178
+ if association_data.nested?
179
+ nested_children_changed! if child_changes.changed_nested_tree?
180
+ referenced_children_changed! if child_changes.changed_referenced_children?
181
+ elsif association_data.owned?
182
+ referenced_children_changed! if child_changes.changed_owned_tree?
183
+ end
184
+ end
185
+
186
+ final_changes = self.clear_changes!
187
+
180
188
  if association_data.through?
181
189
  updated_viewmodels.map! do |direct_vm|
182
190
  direct_vm._read_association(association_data.indirect_reflection.name)
183
191
  end
184
192
  end
185
193
 
186
- # Finalize the parent
187
- final_changes = self.clear_changes!
188
-
189
194
  # Could happen if hooks attempted to change the parent, which aren't
190
195
  # valid since we're only editing children here.
191
196
  unless final_changes.contained_to?(associations: [association_name.to_s])
@@ -269,7 +274,12 @@ module ViewModel::ActiveRecord::AssociationManipulation
269
274
  association.delete(child_vm.model)
270
275
  end
271
276
 
272
- self.children_changed!
277
+ if association_data.nested?
278
+ nested_children_changed!
279
+ elsif association_data.owned?
280
+ referenced_children_changed!
281
+ end
282
+
273
283
  final_changes = self.clear_changes!
274
284
 
275
285
  unless final_changes.contained_to?(associations: [association_name.to_s])
@@ -286,7 +296,7 @@ module ViewModel::ActiveRecord::AssociationManipulation
286
296
 
287
297
  private
288
298
 
289
- def construct_direct_append_updates(association_data, subtree_hashes, references)
299
+ def construct_direct_append_updates(_association_data, subtree_hashes, references)
290
300
  ViewModel::ActiveRecord::UpdateData.parse_hashes(subtree_hashes, references)
291
301
  end
292
302