iknow_view_models 2.10.1 → 3.0.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 (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