attribute-trackable 0.0.2.pre.rc1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ec7576bd556386c9eb3e9e21a3f6f1872dd36240cbd0f9625d7478e26dade44c
4
+ data.tar.gz: 1e88eb34d0624aa3394c80743b4ed4700831cd237a4f7bedfe668a7f1ecf0b4a
5
+ SHA512:
6
+ metadata.gz: c1ad9d08eb731530e2ebbe3adc257d397d82b1dd2ad4cccf2ef0948a645f8700e211abc407caa95fbc46201d2b5eb4886b8122bd65c7fe8a9dc11e7473e1d51a
7
+ data.tar.gz: 6c239fbd602b9ffd2b6688f660f5a51b8c49cefb115ee4f118ce88fac8102abade074e783e16aa7dab7ea40d0e4188c177ccf07df41e8da78510e444f46f44ae
@@ -0,0 +1,246 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Defines tracking logic to an internal field and/or to an external resource using a hash DSL
4
+ #
5
+ # Interface: `object.trackable_update(params.merge(unlink_fields: [:teeth]))`
6
+ # Reserved params:
7
+ # unlink_fields: Array - array of symbolized field names to unlink from tracked their fields
8
+ # unlink_child_fields: Array - array of symbolized field names to unlink child fields
9
+ #
10
+ # The modified external resources can be found in: `object.trackable_dependents`
11
+ #
12
+ # DSL Structure:
13
+ # {
14
+ # species: {
15
+ # linked_attributes: {
16
+ # coat: {
17
+ # if: ->(animal) { animal.tracking_coat },
18
+ # tracking_bool: :tracking_coat,
19
+ # track: ->(animal) { TrackableAnimal.track_coat_to_species(animal) }
20
+ # },
21
+ # teeth: {
22
+ # tracking_bool: :tracking_teeth,
23
+ # track: ->(a) { TrackableAnimal.track_teeth_to_species(animal) }
24
+ # }
25
+ # },
26
+ # linked_child_attributes: {
27
+ # species: {
28
+ # children: :children,
29
+ # tracking_bool: :tracking_species,
30
+ # track: ->(animal, child) { TrackableAnimal.track_species_to_parent(animal, child) }
31
+ # }
32
+ # }
33
+ # },
34
+ # teeth: {
35
+ # linked_attributes: {
36
+ # diet: {
37
+ # tracking_bool: :tracking_diet,
38
+ # track: ->(animal) { TrackableAnimal.track_diet_to_teeth(animal) }
39
+ # }
40
+ # }
41
+ # }
42
+ # }
43
+ #
44
+ # DSL Params:
45
+ # 1. tracking_bool: boolean value used to determine whether or not a field is linked
46
+ # 2. if: proc used to determine whether or not to track the linked field
47
+ # 3. children: symbol to send to object to obtain it's children
48
+ # 4. track: proc used to translate tracked field into its linked dependent
49
+ # - when attributes are linked to base object, param passed to proc is (object)
50
+ # - when attributes are linked to parent object, params passed to proc are (object, child)
51
+ module Trackable
52
+ require 'trackable/errors'
53
+ require 'trackable/helpers/circular_dependency'
54
+ require 'trackable/helpers/verify_options'
55
+ require 'active_support/concern'
56
+ require 'active_support/core_ext/class/attribute'
57
+ require 'active_support/core_ext/object'
58
+ extend ::ActiveSupport::Concern
59
+
60
+ included do
61
+ class_attribute :trackable_data, :trackable_defaults
62
+
63
+ attr_accessor :trackable_dependents
64
+ end
65
+
66
+ module ClassMethods
67
+ def tracking(structure)
68
+ self.trackable_data = structure
69
+ end
70
+
71
+ def set_trackable_defaults(children: nil, linked_bool: nil)
72
+ self.trackable_defaults = {
73
+ linked_bool: linked_bool,
74
+ children: children
75
+ }
76
+ end
77
+
78
+ def propagate_changes(opts)
79
+ add_trackable_dependency(opts)
80
+ end
81
+
82
+ def propagate_changes_to_children(opts)
83
+ add_trackable_dependency(opts.merge(child: true))
84
+ end
85
+
86
+ def add_trackable_dependency(opts)
87
+ self.trackable_data ||= {}
88
+ self_or_child_attributes = opts[:child] ? :linked_child_attributes : :linked_attributes
89
+ new_option = { track: opts[:use] }
90
+ new_option[:if] = opts[:if] if opts[:if]
91
+ new_option[:tracking_bool] = opts[:linked_bool] if opts[:linked_bool]
92
+ new_option[:skip_assignment] = opts[:skip_assignment] if opts[:skip_assignment]
93
+ new_option[:children] = opts[:children] if opts[:children] && opts[:child]
94
+ linked_attrs = trackable_data.dig(opts[:from], self_or_child_attributes) || {}
95
+ trackable_data[opts[:from]] = (trackable_data.dig(opts[:from]) || {})
96
+ .merge(self_or_child_attributes => linked_attrs
97
+ .merge(opts[:to] => new_option))
98
+ end
99
+
100
+ def explain_trackable_attribute(attribute)
101
+ linked_to = []
102
+ linked_to_parent = []
103
+
104
+ trackable_data.each do |key, value|
105
+ linked_to << key if value[:linked_attributes] && attribute.in?(value[:linked_attributes])
106
+ if value[:linked_child_attributes] && attribute.in?(value[:linked_child_attributes])
107
+ linked_to_parent << key
108
+ end
109
+ end
110
+
111
+ {
112
+ inherits: linked_to,
113
+ inherits_from_parent: linked_to_parent,
114
+ inherited_by: trackable_data[attribute][:linked_child_attributes],
115
+ inherited_by_to_children: trackable_data[attribute][:linked_child_attributes]
116
+ }
117
+ end
118
+
119
+ def verify_trackable_options!
120
+ Trackable::Helpers::VerifyOptions.new(trackable_data, trackable_defaults).verify!
121
+ end
122
+ end
123
+
124
+ def self.prepended(base)
125
+ class << base
126
+ prepend ClassMethods
127
+ end
128
+ end
129
+
130
+ define_method(:trackable_update) do |attrs|
131
+ unlink_fields = Array(attrs.delete(:unlink_fields))
132
+ unlink_child_fields = Array(attrs.delete(:unlink_child_fields))
133
+
134
+ attrs.each do |key, value|
135
+ # set the value using the default setter method
136
+ public_send("#{key.to_sym}=", value)
137
+ next unless trackable_data.key? key
138
+
139
+ changes_to_propagate = trackable_data[key][:linked_attributes]
140
+ changes_to_propagate_to_children = trackable_data[key][:linked_child_attributes]
141
+
142
+ changes_to_propagate&.each do |attribute, options|
143
+ next unless should_track?(linked_bool(attribute, options), options[:if])
144
+
145
+ new_value = options[:track].is_a?(Symbol) ? public_send(options[:track]) : options[:track].call(self)
146
+ trackable_update("#{attribute}": new_value) unless options[:skip_assignment]
147
+ end
148
+
149
+ self.trackable_dependents ||= {} # build hash of mutated child objects keyed by :id
150
+ changes_to_propagate_to_children&.each do |attribute, options|
151
+ unlink_child = attribute.in? unlink_child_fields
152
+ update_dependents(attribute, options, unlink_child)
153
+ end
154
+ end
155
+
156
+ # Make the dependency tree order agnostic by overriding attributes that are explicitly set in params
157
+ overriding_attrs = attrs.keys & all_linked_attributes
158
+ override(attrs.slice(*overriding_attrs), unlink_fields)
159
+ end
160
+
161
+ define_method(:trackable_link_child) do |child|
162
+ self.trackable_dependents ||= {}
163
+ all_linked_attributes(:linked_child_attributes).each do |attribute|
164
+ update_dependents(
165
+ attribute,
166
+ trackable_data.dig(attribute, :linked_child_attributes, attribute),
167
+ false,
168
+ child
169
+ )
170
+ end
171
+ end
172
+
173
+ define_method(:trackable_unlink) do |attribute|
174
+ unlinked = trackable_data.each_value.map do |options|
175
+ opts = options.dig(:linked_attributes, attribute) || options.dig(:linked_child_attributes, attribute)
176
+ linked_bool(attribute, opts) if opts
177
+ end.compact
178
+
179
+ if unlinked.empty?
180
+ raise Trackable::Errors::InvalidUnlink, "Must include tracking_bool for #{attribute} to override"
181
+ end
182
+
183
+ unlinked.each { |tracking_bool| public_send("#{tracking_bool}=", false) }
184
+ end
185
+
186
+ private
187
+
188
+ def linked_bool(attribute, options)
189
+ return false unless options
190
+
191
+ defaults = trackable_defaults&.dig(:linked_bool)
192
+ if options[:tracking_bool].is_a?(Symbol)
193
+ options[:tracking_bool]
194
+ elsif defaults && options[:tracking_bool]
195
+ [defaults[:prefix], attribute, defaults[:sufix]]
196
+ .compact.join('_').to_sym
197
+ end
198
+ end
199
+
200
+ def should_track?(bool, func, child = nil)
201
+ bool_result = bool.nil? || (child || self).send(bool.to_sym)
202
+ proc_params = func&.parameters&.map(&:last) || []
203
+ parameters = { root: self, child: child }
204
+
205
+ func.present? ? bool_result && func.call(**parameters.slice(*proc_params)) : bool_result
206
+ end
207
+
208
+ # Overrides an attribute when it is passed as a parameter to trackable_update
209
+ def override(attrs, unlink_fields)
210
+ attrs.each do |attribute, value|
211
+ unlink_field = attribute.in? unlink_fields
212
+ public_send("#{attribute}=", value)
213
+
214
+ if trackable_data.dig(attribute, :linked_child_attributes)
215
+ update_dependents(
216
+ attribute,
217
+ trackable_data.dig(attribute, :linked_child_attributes, attribute),
218
+ unlink_field
219
+ )
220
+ end
221
+
222
+ next unless unlink_field
223
+
224
+ trackable_unlink(attribute)
225
+ end
226
+ end
227
+
228
+ def update_dependents(attribute, options, unlink_field, override_child = nil)
229
+ dependents = Array(override_child || public_send((options[:children] || trackable_defaults[:children])))
230
+ dependents.each do |dependent|
231
+ dependent.trackable_unlink(attribute) if unlink_field
232
+ next unless options && should_track?(linked_bool(attribute, options), options[:if], dependent)
233
+
234
+ current_dep = trackable_dependents[dependent.id] || dependent
235
+
236
+ new_value = options[:track].is_a?(Symbol) ? public_send(options[:track]) : options[:track].call(self, current_dep)
237
+ current_dep.trackable_update("#{attribute}": new_value) unless options[:skip_assignment]
238
+
239
+ trackable_dependents[current_dep.id] = current_dep
240
+ end
241
+ end
242
+
243
+ def all_linked_attributes(from = :linked_attributes)
244
+ trackable_data.map { |_k, v| v[from]&.keys }.flatten.compact
245
+ end
246
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Trackable
4
+ module Errors
5
+ class TrackableError < StandardError; end
6
+ class CyclicDependency < TrackableError; end
7
+ class InvalidOptions < TrackableError; end
8
+
9
+ class TrackableRuntimeError < RuntimeError; end
10
+ class InvalidUnlink < TrackableRuntimeError; end
11
+ end
12
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Example graph
4
+ # {
5
+ # 'a': ['b', 'd'],
6
+ # 'b': ['c', 'e'],
7
+ # 'c': ['d', 'a']
8
+ # }
9
+ # TODO: refactor to use sets
10
+ module Trackable
11
+ module Helpers
12
+ class CircularDependency
13
+ attr_accessor :cyclic_dependencies, :graph, :traversed_verticies,
14
+ :verticies, :work_graph
15
+
16
+ def initialize(graph = {})
17
+ @graph = graph
18
+ @work_graph = @graph.dup
19
+ @verticies = @graph.keys
20
+ @traversed_verticies = []
21
+ @cyclic_dependencies = []
22
+ end
23
+
24
+ def check_graph
25
+ verticies.each do |vertex|
26
+ check_node(vertex) if work_graph[vertex]
27
+ end
28
+ cyclic_dependencies
29
+ end
30
+
31
+ def check_node(node)
32
+ traversed_verticies << node
33
+
34
+ work_graph[node].each do |child|
35
+ # There is no point in further investigating this node
36
+ # since it has been removed
37
+ if work_graph[child].nil?
38
+ # We have followed this tree to its end
39
+ self.traversed_verticies = [] if graph[child].nil?
40
+ next
41
+ end
42
+
43
+ repeated_index = repeated_index(child)
44
+ if repeated_index # Found a cycle so bust the stack and continue on
45
+ new_dep = (traversed_verticies[repeated_index..-1] +
46
+ [child, traversed_verticies[repeated_index]])
47
+ self.traversed_verticies = []
48
+ cyclic_dependencies << new_dep
49
+ next
50
+ end
51
+
52
+ check_node(child)
53
+ end
54
+
55
+ # Finished checking all cycles for this node so don't repeat it
56
+ work_graph.delete(node)
57
+ end
58
+
59
+ def repeated_index(child)
60
+ traversed_verticies.index { |v| v.in?(work_graph[child]) }
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Trackable
4
+ module Helpers
5
+ class VerifyOptions
6
+ attr_accessor :options, :defaults
7
+
8
+ def initialize(options, defaults)
9
+ self.options = options
10
+ self.defaults = defaults.to_h
11
+ end
12
+
13
+ def verify!
14
+ verify_trackable_opts(options)
15
+ verify_cyclical_dependencies(options)
16
+ end
17
+
18
+ private
19
+
20
+ def verify_trackable_opts(options)
21
+ options.each_value do |opts|
22
+ if no_attribute_dependencies?(opts)
23
+ raise Trackable::Errors::InvalidOptions,
24
+ 'Missing tracking attributes'
25
+ end
26
+
27
+ verify_attributes(opts)
28
+ verify_attributes(opts, true)
29
+ end
30
+ end
31
+
32
+ def verify_attributes(opts, child = false)
33
+ opt_key = child ? :linked_attributes : :linked_child_attributes
34
+ opts[opt_key]&.each_value do |opt_value|
35
+ verify_track_attribute(opt_value, child)
36
+ end
37
+ end
38
+
39
+ def verify_track_attribute(opt_value, child = false)
40
+ if child && opt_value[:children].blank? && defaults[:children]&.blank?
41
+ raise Trackable::Errors::InvalidOptions, 'Missing children attribute'
42
+ end
43
+
44
+ return unless opt_value[:track].blank?
45
+
46
+ raise Trackable::Errors::InvalidOptions, 'Missing track attribute'
47
+ end
48
+
49
+ def verify_cyclical_dependencies(options)
50
+ graph = build_graph(options)
51
+ cycles = Trackable::Helpers::CircularDependency.new(graph).check_graph
52
+ return if cycles.empty?
53
+
54
+ raise Trackable::Errors::CyclicDependency,
55
+ "Cyclic dependency found: #{cycles.map { |c| c.join(' -> ') }}"
56
+ end
57
+
58
+ def build_graph(options)
59
+ options.each_with_object({}) do |(k, v), h|
60
+ h[k] = v[:linked_attributes]&.keys
61
+ end
62
+ end
63
+
64
+ def no_attribute_dependencies?(opts)
65
+ opts[:linked_attributes].nil? && opts[:linked_child_attributes].nil?
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,3 @@
1
+ module Trackable
2
+ VERSION = '0.0.2-rc1'.freeze
3
+ end
metadata ADDED
@@ -0,0 +1,146 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: attribute-trackable
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.2.pre.rc1
5
+ platform: ruby
6
+ authors:
7
+ - Adam David
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2019-11-20 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bundler
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: pry
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: pry-nav
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rake
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rspec
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: rubocop
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: activesupport
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '0'
104
+ type: :runtime
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '0'
111
+ description:
112
+ email:
113
+ - adam.david@bugcrowd.com
114
+ executables: []
115
+ extensions: []
116
+ extra_rdoc_files: []
117
+ files:
118
+ - lib/trackable.rb
119
+ - lib/trackable/errors.rb
120
+ - lib/trackable/helpers/circular_dependency.rb
121
+ - lib/trackable/helpers/verify_options.rb
122
+ - lib/trackable/version.rb
123
+ homepage: https://github.com/adamrdavid/trackable
124
+ licenses:
125
+ - MIT
126
+ metadata: {}
127
+ post_install_message:
128
+ rdoc_options: []
129
+ require_paths:
130
+ - lib
131
+ required_ruby_version: !ruby/object:Gem::Requirement
132
+ requirements:
133
+ - - ">="
134
+ - !ruby/object:Gem::Version
135
+ version: '0'
136
+ required_rubygems_version: !ruby/object:Gem::Requirement
137
+ requirements:
138
+ - - ">"
139
+ - !ruby/object:Gem::Version
140
+ version: 1.3.1
141
+ requirements: []
142
+ rubygems_version: 3.0.3
143
+ signing_key:
144
+ specification_version: 4
145
+ summary: A flowdown wrapper for attribute dependencies
146
+ test_files: []