attribute-trackable 0.0.2.pre.rc1

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.
@@ -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: []