iknow_view_models 2.8.4

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 (92) hide show
  1. checksums.yaml +7 -0
  2. data/.circleci/config.yml +115 -0
  3. data/.gitignore +36 -0
  4. data/.travis.yml +31 -0
  5. data/Appraisals +9 -0
  6. data/Gemfile +19 -0
  7. data/LICENSE.txt +22 -0
  8. data/README.md +19 -0
  9. data/Rakefile +21 -0
  10. data/appveyor.yml +22 -0
  11. data/gemfiles/rails_5_2.gemfile +15 -0
  12. data/gemfiles/rails_6_0_beta.gemfile +15 -0
  13. data/iknow_view_models.gemspec +49 -0
  14. data/lib/iknow_view_models.rb +12 -0
  15. data/lib/iknow_view_models/railtie.rb +8 -0
  16. data/lib/iknow_view_models/version.rb +5 -0
  17. data/lib/view_model.rb +333 -0
  18. data/lib/view_model/access_control.rb +154 -0
  19. data/lib/view_model/access_control/composed.rb +216 -0
  20. data/lib/view_model/access_control/open.rb +13 -0
  21. data/lib/view_model/access_control/read_only.rb +13 -0
  22. data/lib/view_model/access_control/tree.rb +264 -0
  23. data/lib/view_model/access_control_error.rb +10 -0
  24. data/lib/view_model/active_record.rb +383 -0
  25. data/lib/view_model/active_record/association_data.rb +178 -0
  26. data/lib/view_model/active_record/association_manipulation.rb +389 -0
  27. data/lib/view_model/active_record/cache.rb +265 -0
  28. data/lib/view_model/active_record/cache/cacheable_view.rb +51 -0
  29. data/lib/view_model/active_record/cloner.rb +113 -0
  30. data/lib/view_model/active_record/collection_nested_controller.rb +100 -0
  31. data/lib/view_model/active_record/controller.rb +77 -0
  32. data/lib/view_model/active_record/controller_base.rb +185 -0
  33. data/lib/view_model/active_record/nested_controller_base.rb +93 -0
  34. data/lib/view_model/active_record/singular_nested_controller.rb +34 -0
  35. data/lib/view_model/active_record/update_context.rb +252 -0
  36. data/lib/view_model/active_record/update_data.rb +749 -0
  37. data/lib/view_model/active_record/update_operation.rb +810 -0
  38. data/lib/view_model/active_record/visitor.rb +77 -0
  39. data/lib/view_model/after_transaction_runner.rb +29 -0
  40. data/lib/view_model/callbacks.rb +219 -0
  41. data/lib/view_model/changes.rb +62 -0
  42. data/lib/view_model/config.rb +29 -0
  43. data/lib/view_model/controller.rb +142 -0
  44. data/lib/view_model/deserialization_error.rb +437 -0
  45. data/lib/view_model/deserialize_context.rb +16 -0
  46. data/lib/view_model/error.rb +191 -0
  47. data/lib/view_model/error_view.rb +35 -0
  48. data/lib/view_model/record.rb +367 -0
  49. data/lib/view_model/record/attribute_data.rb +48 -0
  50. data/lib/view_model/reference.rb +31 -0
  51. data/lib/view_model/references.rb +48 -0
  52. data/lib/view_model/registry.rb +73 -0
  53. data/lib/view_model/schemas.rb +45 -0
  54. data/lib/view_model/serialization_error.rb +10 -0
  55. data/lib/view_model/serialize_context.rb +118 -0
  56. data/lib/view_model/test_helpers.rb +103 -0
  57. data/lib/view_model/test_helpers/arvm_builder.rb +111 -0
  58. data/lib/view_model/traversal_context.rb +126 -0
  59. data/lib/view_model/utils.rb +24 -0
  60. data/lib/view_model/utils/collections.rb +49 -0
  61. data/test/helpers/arvm_test_models.rb +59 -0
  62. data/test/helpers/arvm_test_utilities.rb +187 -0
  63. data/test/helpers/callback_tracer.rb +27 -0
  64. data/test/helpers/controller_test_helpers.rb +270 -0
  65. data/test/helpers/match_enumerator.rb +58 -0
  66. data/test/helpers/query_logging.rb +71 -0
  67. data/test/helpers/test_access_control.rb +56 -0
  68. data/test/helpers/viewmodel_spec_helpers.rb +326 -0
  69. data/test/unit/view_model/access_control_test.rb +769 -0
  70. data/test/unit/view_model/active_record/alias_test.rb +35 -0
  71. data/test/unit/view_model/active_record/belongs_to_test.rb +376 -0
  72. data/test/unit/view_model/active_record/cache_test.rb +351 -0
  73. data/test/unit/view_model/active_record/cloner_test.rb +313 -0
  74. data/test/unit/view_model/active_record/controller_test.rb +561 -0
  75. data/test/unit/view_model/active_record/counter_test.rb +80 -0
  76. data/test/unit/view_model/active_record/customization_test.rb +388 -0
  77. data/test/unit/view_model/active_record/has_many_test.rb +957 -0
  78. data/test/unit/view_model/active_record/has_many_through_poly_test.rb +269 -0
  79. data/test/unit/view_model/active_record/has_many_through_test.rb +736 -0
  80. data/test/unit/view_model/active_record/has_one_test.rb +334 -0
  81. data/test/unit/view_model/active_record/namespacing_test.rb +75 -0
  82. data/test/unit/view_model/active_record/optional_attribute_view_test.rb +58 -0
  83. data/test/unit/view_model/active_record/poly_test.rb +320 -0
  84. data/test/unit/view_model/active_record/shared_test.rb +285 -0
  85. data/test/unit/view_model/active_record/version_test.rb +121 -0
  86. data/test/unit/view_model/active_record_test.rb +542 -0
  87. data/test/unit/view_model/callbacks_test.rb +582 -0
  88. data/test/unit/view_model/deserialization_error/unique_violation_test.rb +73 -0
  89. data/test/unit/view_model/record_test.rb +524 -0
  90. data/test/unit/view_model/traversal_context_test.rb +371 -0
  91. data/test/unit/view_model_test.rb +62 -0
  92. metadata +490 -0
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ViewModel::Record::AttributeData
4
+ attr_reader :name, :model_attr_name, :attribute_viewmodel, :attribute_serializer
5
+
6
+ def initialize(name, model_attr_name, attribute_viewmodel, attribute_serializer, array, optional, read_only, write_once)
7
+ @name = name
8
+ @model_attr_name = model_attr_name
9
+ @attribute_viewmodel = attribute_viewmodel
10
+ @attribute_serializer = attribute_serializer
11
+ @array = array
12
+ @optional = optional
13
+ @read_only = read_only
14
+ @write_once = write_once
15
+ end
16
+
17
+ def array?
18
+ @array
19
+ end
20
+
21
+ def optional?
22
+ @optional
23
+ end
24
+
25
+ def read_only?
26
+ @read_only
27
+ end
28
+
29
+ def write_once?
30
+ @write_once
31
+ end
32
+
33
+ def using_serializer?
34
+ !@attribute_serializer.nil?
35
+ end
36
+
37
+ def using_viewmodel?
38
+ !@attribute_viewmodel.nil?
39
+ end
40
+
41
+ def map_value(value)
42
+ if array?
43
+ value.map { |v| yield(v) }
44
+ else
45
+ yield(value)
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,31 @@
1
+ class ViewModel
2
+ # Key to identify a viewmodel with some kind of inherent ID (e.g. an ViewModel::ActiveRecord)
3
+ class Reference
4
+ attr_accessor :viewmodel_class, :model_id
5
+
6
+ def initialize(viewmodel_class, model_id)
7
+ @viewmodel_class = viewmodel_class
8
+ @model_id = model_id
9
+ end
10
+
11
+ def to_s
12
+ "'#{viewmodel_class.view_name}(id=#{model_id})'"
13
+ end
14
+
15
+ def inspect
16
+ "<Ref:#{self}>"
17
+ end
18
+
19
+ def ==(other)
20
+ other.class == self.class &&
21
+ other.viewmodel_class == viewmodel_class &&
22
+ other.model_id == model_id
23
+ end
24
+
25
+ alias :eql? :==
26
+
27
+ def hash
28
+ [viewmodel_class, model_id].hash
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,48 @@
1
+ class ViewModel
2
+ # A bucket for configuration, used for serializing and deserializing.
3
+ class References
4
+ delegate :each, :size, :present?, to: :@value_by_ref
5
+
6
+ def initialize
7
+ @last_ref = 0
8
+ @ref_by_value = {}
9
+ @value_by_ref = {}
10
+ end
11
+
12
+ def has_references?
13
+ @ref_by_value.present?
14
+ end
15
+
16
+ # Takes a reference to a thing that is to be shared, and returns the id
17
+ # under which the data is stored. If the data is not present, will compute
18
+ # it by calling the given block.
19
+ def add_reference(value)
20
+ if (ref = @ref_by_value[value]).present?
21
+ ref
22
+ else
23
+ ref = new_ref!(value)
24
+ @ref_by_value[value] = ref
25
+ @value_by_ref[ref] = value
26
+ ref
27
+ end
28
+ end
29
+
30
+ def clear!
31
+ @ref_by_value.clear
32
+ @value_by_ref.clear
33
+ end
34
+
35
+ private
36
+
37
+ # Ensure stable reference ids for the same (persisted) viewmodels.
38
+ def new_ref!(viewmodel)
39
+ vm_ref = viewmodel.to_reference
40
+ if vm_ref.model_id
41
+ hash = Digest::SHA256.base64digest("#{vm_ref.viewmodel_class.name}.#{vm_ref.model_id}")
42
+ "ref:h:#{hash}"
43
+ else
44
+ 'ref:i:%06d' % (@last_ref += 1)
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ViewModel::Registry
4
+ include Singleton
5
+
6
+ DEFERRED_NAME = Object.new
7
+
8
+ class << self
9
+ delegate :for_view_name, :register, :default_view_name, :infer_model_class_name, :clear_removed_classes!,
10
+ to: :instance
11
+ end
12
+
13
+ def initialize
14
+ @lock = Monitor.new
15
+ @viewmodel_classes_by_name = {}
16
+ @deferred_viewmodel_classes = []
17
+ end
18
+
19
+ def for_view_name(name)
20
+ raise ViewModel::DeserializationError::InvalidSyntax.new('ViewModel name cannot be nil') if name.nil?
21
+
22
+ @lock.synchronize do
23
+ # Resolve names for any deferred viewmodel classes
24
+ resolve_deferred_classes
25
+
26
+ viewmodel_class = @viewmodel_classes_by_name[name]
27
+
28
+ if viewmodel_class.nil? || !(viewmodel_class < ViewModel)
29
+ raise ViewModel::DeserializationError::UnknownView.new(name)
30
+ end
31
+
32
+ viewmodel_class
33
+ end
34
+ end
35
+
36
+ def register(viewmodel, as: DEFERRED_NAME)
37
+ @lock.synchronize do
38
+ @deferred_viewmodel_classes << [viewmodel, as]
39
+ end
40
+ end
41
+
42
+ def default_view_name(model_class_name)
43
+ model_class_name.gsub('::', '.')
44
+ end
45
+
46
+ def infer_model_class_name(view_name)
47
+ view_name.gsub('.', '::')
48
+ end
49
+
50
+ # For Rails hot code loading: ditch any classes that are not longer present at
51
+ # their constant
52
+ def clear_removed_classes!
53
+ @lock.synchronize do
54
+ resolve_deferred_classes
55
+ @viewmodel_classes_by_name.delete_if do |_name, klass|
56
+ !Kernel.const_defined?(klass.name)
57
+ end
58
+ end
59
+ end
60
+
61
+ private
62
+
63
+ def resolve_deferred_classes
64
+ until @deferred_viewmodel_classes.empty?
65
+ vm, view_name = @deferred_viewmodel_classes.pop
66
+
67
+ if vm.should_register?
68
+ view_name = vm.view_name if view_name == DEFERRED_NAME
69
+ @viewmodel_classes_by_name[view_name] = vm
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,45 @@
1
+ require 'json'
2
+ require 'json_schema'
3
+
4
+ class ViewModel::Schemas
5
+ JsonSchema.configure do |c|
6
+ uuid_format = /\A[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}\Z/
7
+ c.register_format('uuid', ->(value) { uuid_format.match(value) })
8
+ end
9
+
10
+ ID_SCHEMA =
11
+ { 'oneOf' => [{ 'type' => 'integer' },
12
+ { 'type' => 'string', 'format' => 'uuid' }] }
13
+ ID = JsonSchema.parse!(ID_SCHEMA)
14
+
15
+ VIEWMODEL_UPDATE_SCHEMA =
16
+ {
17
+ 'type' => 'object',
18
+ 'description' => 'viewmodel update',
19
+ 'properties' => { ViewModel::TYPE_ATTRIBUTE => { 'type' => 'string' },
20
+ ViewModel::ID_ATTRIBUTE => ID_SCHEMA,
21
+ ViewModel::NEW_ATTRIBUTE => { 'type' => 'boolean' },
22
+ ViewModel::VERSION_ATTRIBUTE => { 'type' => 'integer' } },
23
+ 'required' => [ViewModel::TYPE_ATTRIBUTE]
24
+ }
25
+ VIEWMODEL_UPDATE = JsonSchema.parse!(VIEWMODEL_UPDATE_SCHEMA)
26
+
27
+ VIEWMODEL_REFERENCE_SCHEMA =
28
+ {
29
+ 'type' => 'object',
30
+ 'description' => 'viewmodel shared reference',
31
+ 'properties' => { ViewModel::REFERENCE_ATTRIBUTE => { 'type' => 'string' } },
32
+ 'additionalProperties' => false,
33
+ 'required' => [ViewModel::REFERENCE_ATTRIBUTE],
34
+ }
35
+ VIEWMODEL_REFERENCE = JsonSchema.parse!(VIEWMODEL_REFERENCE_SCHEMA)
36
+
37
+ def self.verify_schema!(schema, value)
38
+ valid, errors = schema.validate(value)
39
+ unless valid
40
+ error_list = errors.map { |e| "#{e.pointer}: #{e.message}" }.join("\n")
41
+ errors = 'Error'.pluralize(errors.length)
42
+ raise ViewModel::DeserializationError::InvalidSyntax.new("#{errors} parsing #{schema.description}:\n#{error_list}")
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,10 @@
1
+ class ViewModel::SerializationError < ViewModel::AbstractError
2
+ attr_reader :detail
3
+ status 400
4
+ code "SerializationError"
5
+
6
+ def initialize(detail)
7
+ @detail = detail
8
+ super()
9
+ end
10
+ end
@@ -0,0 +1,118 @@
1
+ require 'active_support/core_ext'
2
+ require 'view_model/traversal_context'
3
+
4
+ class ViewModel::SerializeContext < ViewModel::TraversalContext
5
+ class SharedContext < ViewModel::TraversalContext::SharedContext
6
+ attr_reader :references, :flatten_references
7
+
8
+ def initialize(flatten_references: false, **rest)
9
+ super(**rest)
10
+ @references = ViewModel::References.new
11
+ @flatten_references = flatten_references
12
+ end
13
+ end
14
+
15
+ def self.shared_context_class
16
+ SharedContext
17
+ end
18
+
19
+ delegate :references, :flatten_references, to: :shared_context
20
+
21
+ attr_reader :include, :prune
22
+
23
+ def initialize(include: nil, prune: nil, **rest)
24
+ super(**rest)
25
+ @include = self.class.normalize_includes(include)
26
+ @prune = self.class.normalize_includes(prune)
27
+ end
28
+
29
+ def initialize_as_child(include:, prune:, **rest)
30
+ super(**rest)
31
+ @include = include
32
+ @prune = prune
33
+ end
34
+
35
+ def for_child(parent_viewmodel, association_name:, **rest)
36
+ super(parent_viewmodel,
37
+ association_name: association_name,
38
+ include: @include.try { |i| i[association_name] },
39
+ prune: @prune.try { |p| p[association_name] },
40
+ **rest)
41
+ end
42
+
43
+ def includes_member?(member_name, default)
44
+ member_name = member_name.to_s
45
+
46
+ # Every node in the include tree is to be included
47
+ included = @include.try { |is| is.has_key?(member_name) }
48
+ # whereas only the leaves of the prune tree are to be removed
49
+ pruned = @prune.try { |ps| ps.fetch(member_name, :sentinel).nil? }
50
+
51
+ (default || included) && !pruned
52
+ end
53
+
54
+ def add_includes(includes)
55
+ return if includes.blank?
56
+ @include ||= {}
57
+ @include.deep_merge!(self.class.normalize_includes(includes))
58
+ end
59
+
60
+ def add_prunes(prunes)
61
+ return if prunes.blank?
62
+ @prune ||= {}
63
+ @prune.deep_merge!(self.class.normalize_includes(prunes))
64
+ end
65
+
66
+ delegate :add_reference, :has_references?, to: :references
67
+
68
+ # Return viewmodels referenced during serialization and clear @references.
69
+ def extract_referenced_views!
70
+ refs = references.each.to_h
71
+ references.clear!
72
+ refs
73
+ end
74
+
75
+ def serialize_references(json)
76
+ reference_context = self.for_references
77
+
78
+ # References should be serialized in a stable order to improve caching via
79
+ # naive response hash.
80
+
81
+ serialized_refs = {}
82
+
83
+ while references.present?
84
+ extract_referenced_views!.each do |ref, value|
85
+ unless serialized_refs.has_key?(ref)
86
+ serialized_refs[ref] = Jbuilder.new do |j|
87
+ ViewModel.serialize(value, j, serialize_context: reference_context)
88
+ end
89
+ end
90
+ end
91
+ end
92
+
93
+ serialized_refs.sort.each do |ref, value|
94
+ json.set!(ref, value)
95
+ end
96
+ end
97
+
98
+ def serialize_references_to_hash
99
+ Jbuilder.new { |json| serialize_references(json) }.attributes!
100
+ end
101
+
102
+ def self.normalize_includes(includes)
103
+ case includes
104
+ when Array
105
+ includes.each_with_object({}) do |v, new_includes|
106
+ new_includes.merge!(normalize_includes(v))
107
+ end
108
+ when Hash
109
+ includes.each_with_object({}) do |(k,v), new_includes|
110
+ new_includes[k.to_s] = normalize_includes(v)
111
+ end
112
+ when nil
113
+ nil
114
+ else
115
+ { includes.to_s => nil }
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,103 @@
1
+ ##
2
+ # Helpers useful for writing tests for viewmodel implementations
3
+ module ViewModel::TestHelpers
4
+ require 'view_model/test_helpers/arvm_builder'
5
+ extend ActiveSupport::Concern
6
+ using ViewModel::Utils::Collections
7
+
8
+ def serialize_with_references(serializable, serialize_context: ViewModel.new_serialize_context)
9
+ data = ViewModel.serialize_to_hash(serializable, serialize_context: serialize_context)
10
+ references = serialize_context.serialize_references_to_hash
11
+ return data, references
12
+ end
13
+
14
+ def serialize(serializable, serialize_context: ViewModel.new_serialize_context)
15
+ data, _ = serialize_with_references(serializable, serialize_context: serialize_context)
16
+ data
17
+ end
18
+
19
+ # Test helper: update a model by manipulating the full view hash
20
+ def alter_by_view!(viewmodel_class, model,
21
+ serialize_context: viewmodel_class.new_serialize_context,
22
+ deserialize_context: viewmodel_class.new_deserialize_context)
23
+
24
+ models = Array.wrap(model)
25
+
26
+ data, refs = serialize_with_references(models.map { |m| viewmodel_class.new(m) }, serialize_context: serialize_context)
27
+
28
+ if model.is_a?(Array)
29
+ yield(data, refs)
30
+ else
31
+ yield(data.first, refs)
32
+ end
33
+
34
+ begin
35
+ result = viewmodel_class.deserialize_from_view(
36
+ data, references: refs, deserialize_context: deserialize_context)
37
+
38
+ result.each do |vm|
39
+ assert_consistent_record(vm)
40
+ end
41
+
42
+ result = result.first unless model.is_a?(Array)
43
+
44
+ models.each { |m| m.reload }
45
+
46
+ return result, deserialize_context
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ def assert_consistent_record(viewmodel, been_there: Set.new)
53
+ return if been_there.include?(viewmodel.model)
54
+ been_there << viewmodel.model
55
+
56
+ if viewmodel.is_a?(ViewModel::ActiveRecord)
57
+ assert_model_represents_database(viewmodel.model, been_there: been_there)
58
+ elsif viewmodel.is_a?(ViewModel::Record)
59
+ viewmodel.class._members.each do |name, attribute_data|
60
+ if attribute_data.attribute_viewmodel
61
+ assert_consistent_record(viewmodel.send(name), been_there: been_there)
62
+ end
63
+ end
64
+ end
65
+ end
66
+
67
+ def assert_model_represents_database(model, been_there: Set.new)
68
+ return if been_there.include?(model)
69
+ been_there << model
70
+
71
+ refute(model.new_record?, 'model represents database entity')
72
+ refute(model.changed?, 'model is fully persisted')
73
+
74
+ database_model = model.class.find(model.id)
75
+
76
+ assert_equal(database_model.attributes,
77
+ model.attributes,
78
+ 'in memory attributes match database attributes')
79
+
80
+ model.class.reflections.each do |_, reflection|
81
+ association = model.association(reflection.name)
82
+
83
+ next unless association.loaded?
84
+
85
+ case
86
+ when association.target == nil
87
+ assert_nil(database_model.association(reflection.name).target,
88
+ 'in memory nil association matches database')
89
+ when reflection.collection?
90
+ association.target.each do |associated_model|
91
+ assert_model_represents_database(associated_model, been_there: been_there)
92
+ end
93
+ else
94
+ assert_model_represents_database(association.target, been_there: been_there)
95
+ end
96
+ end
97
+ end
98
+
99
+ def assert_contains_exactly(expected, actual, msg = nil)
100
+ msg ||= diff(expected, actual)
101
+ assert(expected.contains_exactly?(actual), msg)
102
+ end
103
+ end