iknow_view_models 2.8.4

Sign up to get free protection for your applications and to get access to all the features.
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