fixtury 0.4.1 → 1.0.0.beta1

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.
@@ -1,48 +1,59 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "fixtury/locator_backend/memory"
4
+
3
5
  module Fixtury
6
+ # Locator is responsible for recognizing, loading, and dumping references.
7
+ # It is a simple wrapper around a backend that is responsible for the actual work.
8
+ # The backend is expected to implement the following methods: recognizable_key?, recognized_value?, load_recognized_reference, dump_recognized_value.
4
9
  class Locator
5
10
 
6
- class << self
7
-
8
- attr_accessor :instance
9
-
10
- def instance
11
- @instance ||= begin
12
- require "fixtury/locator_backend/memory"
13
- ::Fixtury::Locator.new(
14
- backend: ::Fixtury::LocatorBackend::Memory.new
15
- )
16
- end
17
- end
18
-
19
- end
20
-
21
11
  attr_reader :backend
22
12
 
23
- def initialize(backend:)
13
+ def initialize(backend: ::Fixtury::LocatorBackend::Memory.new)
24
14
  @backend = backend
25
15
  end
26
16
 
27
- def recognize?(ref)
28
- raise ArgumentError, "Unable to recognize a nil ref" if ref.nil?
17
+ def inspect
18
+ "#{self.class}(backend: #{backend.class})"
19
+ end
20
+
21
+ # Determine if the provided locator_key is a valid form recognized by the backend.
22
+ #
23
+ # @param locator_key [Object] the locator key to check
24
+ # @return [Boolean] true if the locator key is recognizable by the backend
25
+ # @raise [ArgumentError] if the locator key is nil
26
+ def recognizable_key?(locator_key)
27
+ raise ArgumentError, "Unable to recognize a nil locator value" if locator_key.nil?
29
28
 
30
- backend.recognized_reference?(ref)
29
+ backend.recognizable_key?(locator_key)
31
30
  end
32
31
 
33
- def load(ref)
34
- raise ArgumentError, "Unable to load a nil ref" if ref.nil?
32
+ # Load the value associated with the provided locator key.
33
+ #
34
+ # @param locator_key [Object] the locator key to load
35
+ # @return [Object] the loaded value
36
+ # @raise [ArgumentError] if the locator key is nil
37
+ def load(locator_key)
38
+ raise ArgumentError, "Unable to load a nil locator value" if locator_key.nil?
35
39
 
36
- backend.load(ref)
40
+ backend.load(locator_key)
37
41
  end
38
42
 
39
- def dump(value)
40
- raise ArgumentError, "Unable to dump a nil value" if value.nil?
43
+ # Provide the value to the backend to generate a locator key.
44
+ #
45
+ # @param stored_value [Object] the value to dump
46
+ # @param context [String] a string to include in the error message if the value is nil
47
+ # @return [Object] the locator key
48
+ # @raise [ArgumentError] if the value is nil
49
+ # @raise [ArgumentError] if the backend is unable to dump the value
50
+ def dump(stored_value, context: nil)
51
+ raise ArgumentError, "Unable to dump a nil value. #{context}" if stored_value.nil?
41
52
 
42
- ref = backend.dump(value)
43
- raise ArgumentError, "The value resulted in a nil ref" if ref.nil?
53
+ locator_key = backend.dump(stored_value)
54
+ raise ArgumentError, "Dump resulted in a nil locator value. #{context}" if locator_key.nil?
44
55
 
45
- ref
56
+ locator_key
46
57
  end
47
58
 
48
59
  end
@@ -1,54 +1,52 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "fixtury/errors/unrecognizable_locator_error"
4
-
5
3
  module Fixtury
6
4
  module LocatorBackend
7
5
  module Common
8
6
 
9
- def recognized_reference?(_ref)
7
+ def recognizable_key?(_locator_value)
10
8
  raise NotImplementedError
11
9
  end
12
10
 
13
- def recognized_value?(_value)
11
+ def recognizable_value?(_stored_value)
14
12
  raise NotImplementedError
15
13
  end
16
14
 
17
- def load_recognized_reference(_ref)
15
+ def load_reference(_locator_value)
18
16
  raise NotImplementedError
19
17
  end
20
18
 
21
- def dump_recognized_value(_value)
19
+ def dump_value(_stored_value)
22
20
  raise NotImplementedError
23
21
  end
24
22
 
25
- def load(ref)
26
- return load_recognized_reference(ref) if recognized_reference?(ref)
23
+ def load(locator_value)
24
+ return load_reference(locator_value) if recognizable_key?(locator_value)
27
25
 
28
- case ref
26
+ case locator_value
29
27
  when Array
30
- ref.map { |subref| self.load(subref) }
28
+ locator_value.map { |subvalue| self.load(subvalue) }
31
29
  when Hash
32
- ref.each_with_object({}) do |(k, subref), h|
33
- h[k] = self.load(subref)
30
+ locator_value.each_with_object({}) do |(k, subvalue), h|
31
+ h[k] = self.load(subvalue)
34
32
  end
35
33
  else
36
- raise ::Fixtury::Errors::UnrecognizableLocatorError.new(:load, ref)
34
+ raise Errors::UnrecognizableLocatorError.new(:load, locator_value)
37
35
  end
38
36
  end
39
37
 
40
- def dump(value)
41
- return dump_recognized_value(value) if recognized_value?(value)
38
+ def dump(stored_value)
39
+ return dump_value(stored_value) if recognizable_value?(stored_value)
42
40
 
43
- case value
41
+ case stored_value
44
42
  when Array
45
- value.map { |subvalue| dump(subvalue) }
43
+ stored_value.map { |subvalue| dump(subvalue) }
46
44
  when Hash
47
- ref.each_with_object({}) do |(k, subvalue), h|
45
+ stored_value.each_with_object({}) do |(k, subvalue), h|
48
46
  h[k] = dump(subvalue)
49
47
  end
50
48
  else
51
- raise ::Fixtury::Errors::UnrecognizableLocatorError.new(:dump, value)
49
+ raise Errors::UnrecognizableLocatorError.new(:dump, stored_value)
52
50
  end
53
51
  end
54
52
 
@@ -11,20 +11,20 @@ module Fixtury
11
11
 
12
12
  MATCHER = %r{^gid://}.freeze
13
13
 
14
- def recognized_reference?(ref)
15
- ref.is_a?(String) && MATCHER.match?(ref)
14
+ def recognizable_key?(locator_value)
15
+ locator_value.is_a?(String) && MATCHER.match?(locator_value)
16
16
  end
17
17
 
18
- def recognized_value?(val)
19
- val.respond_to?(:to_global_id)
18
+ def recognizable_value?(stored_value)
19
+ stored_value.respond_to?(:to_global_id)
20
20
  end
21
21
 
22
- def load_recognized_reference(ref)
23
- ::GlobalID::Locator.locate ref
22
+ def load_reference(locator_value)
23
+ ::GlobalID::Locator.locate locator_value
24
24
  end
25
25
 
26
- def dump_recognized_value(value)
27
- value.to_global_id.to_s
26
+ def dump_value(stored_value)
27
+ stored_value.to_global_id.to_s
28
28
  end
29
29
 
30
30
  end
@@ -10,16 +10,16 @@ module Fixtury
10
10
 
11
11
  MATCHER = /^fixtury-oid-(?<object_id>[\d]+)$/.freeze
12
12
 
13
- def recognized_reference?(ref)
14
- ref.is_a?(String) && MATCHER.match?(ref)
13
+ def recognizable_key?(locator_value)
14
+ locator_value.is_a?(String) && MATCHER.match?(locator_value)
15
15
  end
16
16
 
17
- def recognized_value?(_val)
17
+ def recognizable_value?(_stored_value)
18
18
  true
19
19
  end
20
20
 
21
- def load_recognized_reference(ref)
22
- match = MATCHER.match(ref)
21
+ def load_reference(locator_value)
22
+ match = MATCHER.match(locator_value)
23
23
  return nil unless match
24
24
 
25
25
  ::ObjectSpace._id2ref(match[:object_id].to_i)
@@ -27,8 +27,8 @@ module Fixtury
27
27
  nil
28
28
  end
29
29
 
30
- def dump_recognized_value(value)
31
- "fixtury-oid-#{value.object_id}"
30
+ def dump_value(stored_value)
31
+ "fixtury-oid-#{stored_value.object_id}"
32
32
  end
33
33
 
34
34
  end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/lazy_load_hooks"
4
+
5
+ module Fixtury
6
+ # The mutation observer class is responsible for tracking the isolation level of resources as they are created and updated.
7
+ # If a resource is created in one isolation level, but updated in another, the mutation observer will raise an error.
8
+ # If Rails is present, the Railtie will hook into ActiveRecord to automatically report these changes to the MutationObserver
9
+ module MutationObserver
10
+
11
+ # Hooks into the lifecycle of an ActiveRecord::Base object to report changes to the MutationObserver.
12
+ # This is automatically prepended to ActiveRecord::Base when Rails is present.
13
+ module ActiveRecordHooks
14
+
15
+ def _create_record(*args)
16
+ result = super
17
+ MutationObserver.on_record_create(self)
18
+ result
19
+ end
20
+
21
+ def _update_record(**args)
22
+ MutationObserver.on_record_update(self, changes)
23
+ super
24
+ end
25
+
26
+ def update_columns(changes)
27
+ MutationObserver.on_record_update(self, changes)
28
+ super
29
+ end
30
+
31
+ end
32
+
33
+ class << self
34
+
35
+ attr_reader :current_execution
36
+
37
+ def log(msg, level: ::Fixtury::LOG_LEVEL_DEBUG)
38
+ ::Fixtury.log(msg, name: "mutation_observer", level: level)
39
+ end
40
+
41
+ def owners
42
+ @owners ||= {}
43
+ end
44
+
45
+ def reported_owner(locator_key)
46
+ owners[locator_key]
47
+ end
48
+
49
+ # Observe mutation activity while the given block is executed.
50
+ #
51
+ # @param execution [Fixtury::Execution] The execution that is currently being observed.
52
+ # @yield [void] The block to execute while observing the given execution.
53
+ def observe(execution)
54
+ prev_execution = current_execution
55
+ @current_execution = execution
56
+ yield
57
+ ensure
58
+ @current_execution = prev_execution
59
+ end
60
+
61
+ # The isolation key of the current definition associated with the current execution.
62
+ #
63
+ # @return [String, nil] The isolation key of the current definition, or nil if there is no current definition.
64
+ def current_isolation_key
65
+ current_definition&.isolation_key
66
+ end
67
+
68
+ # The definition associated with the current execution.
69
+ #
70
+ # @return [Fixtury::Definition, nil] The definition associated with the current execution, or nil if there is no current execution.
71
+ def current_definition
72
+ current_execution&.definition
73
+ end
74
+
75
+ # Since there may be inheritance at play, we use the base class to consolidate
76
+ # ensure the same db record always produces the same locator key by using the
77
+ # base class to generate the locator key.
78
+ #
79
+ # @param obj [ActiveRecord::Base] The object to generate a locator key for.
80
+ # @return [String, nil] The locator key for the given object, or nil if there is no current execution.
81
+ def normalized_locator_key(obj)
82
+ return nil unless current_execution
83
+
84
+ pk = obj.class.primary_key
85
+ delegate_object = obj.class.base_class.new(pk => obj.read_attribute(pk))
86
+ current_execution.store.locator.dump(delegate_object, context: "<mutation_observer>")
87
+ end
88
+
89
+ # When a record is created we assign ownership to the current isolation key, if present.
90
+ #
91
+ # @param obj [ActiveRecord::Base] The record that was created.
92
+ # @return [void]
93
+ def on_record_create(obj)
94
+ locator_key = normalized_locator_key(obj)
95
+ return unless locator_key
96
+
97
+ log("Setting isolation level of #{locator_key.inspect} to #{current_isolation_key.inspect} via #{current_definition.inspect}")
98
+ owners[locator_key] = current_isolation_key
99
+ end
100
+
101
+ # When a record is updated we check to see if the reported owner matches the current isolation key.
102
+ # If it doesn't, we raise an error.
103
+ #
104
+ # @param obj [ActiveRecord::Base] The record that was updated.
105
+ # @param changes [Hash] The changes that were made to the record.
106
+ # @return [void]
107
+ # @raise [Fixtury::Errors::IsolatedMutationError] if the record is updated in a different isolation level than it was created in.
108
+ def on_record_update(obj, changes)
109
+ return if changes.blank?
110
+
111
+ locator_key = normalized_locator_key(obj)
112
+ log("verifying record update for #{locator_key}")
113
+
114
+ actual_owner = reported_owner(locator_key)
115
+ return unless actual_owner
116
+
117
+ if current_isolation_key.nil?
118
+ log("Allowing update to #{locator_key.inspect} because there is no registered owner.")
119
+ return
120
+ end
121
+
122
+ if actual_owner == current_isolation_key
123
+ log("Allowing update to #{locator_key.inspect} in the #{actual_owner.inspect} isolation level via #{current_definition.inspect}.")
124
+ return
125
+ end
126
+
127
+ raise Errors::IsolatedMutationError, "Cannot modify #{locator_key.inspect}. Owned by: #{actual_owner.inspect}. Modified by: #{current_isolation_key.inspect}. Requested changes: #{changes.inspect}"
128
+ end
129
+
130
+ end
131
+ end
132
+ end
133
+
134
+ # Observe all executions and report changes to the MutationObserver.
135
+ ::Fixtury.hooks.around(:execution) do |execution, &block|
136
+ ::Fixtury::MutationObserver.observe(execution, &block)
137
+ end
138
+
139
+ # If/when activerecord loads, prepend the hooks module to ActiveRecord::Base
140
+ ActiveSupport.on_load(:active_record) { prepend Fixtury::MutationObserver::ActiveRecordHooks }
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fixtury
4
+ # Takes a namespace as context and a search string and resolves the possible
5
+ # absolute paths that a user could be referring to.
6
+ class PathResolver
7
+
8
+ attr_reader :namespace, :search
9
+
10
+ def initialize(namespace:, search:)
11
+ @namespace = namespace.to_s
12
+ @search = search.to_s
13
+ end
14
+
15
+ def possible_absolute_paths
16
+ @possible_absolute_paths ||= begin
17
+ out = []
18
+ # If the search starts with a slash it's an absolute
19
+ # path and it should be the only possible path.
20
+ if search.start_with?("/")
21
+ out << search
22
+
23
+ # Otherwise we need to consider the namespace.
24
+ else
25
+ # Try the namespace as a prefix for the search.
26
+ # This should take priority because it is the most specific.
27
+ out << ::File.join(namespace, search)
28
+
29
+ # In addition, someone may be referencing a path relative
30
+ # to root but not including the leading slash. We should
31
+ # consider this case as well.
32
+ out << ::File.join("/", search) unless search.include?(".")
33
+ end
34
+
35
+ # Get rid of any `.` and `..` in the paths.
36
+ out.map! { |path| File.expand_path(path, "/").to_s }
37
+ # Get rid of any duplicates.
38
+ out.uniq!
39
+ # voila
40
+ out
41
+ end
42
+ end
43
+
44
+ end
45
+ end
@@ -7,5 +7,9 @@ module Fixtury
7
7
  load "fixtury/tasks.rake"
8
8
  end
9
9
 
10
+ initializer "fixtury.activerecord_hooks" do
11
+ require "fixtury/mutation_observer"
12
+ end
13
+
10
14
  end
11
15
  end
@@ -1,29 +1,31 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Fixtury
4
+ # Acts as an reference between the schema and an object in some remote store.
5
+ # The Store uses these references to keep track of the fixtures it has created.
6
+ # The references are used by the locator to retrieve the fixture data from whatever
7
+ # backend is being used.
4
8
  class Reference
5
9
 
6
- HOLDER_VALUE = "__BUILDING_FIXTURE__"
10
+ # A special key used to indicate that the a definition is currently building an
11
+ # object for this locator_key. This is used to prevent circular dependencies.
12
+ HOLDER_KEY = "__BUILDING_FIXTURE__"
7
13
 
8
14
  def self.holder(name)
9
- new(name, HOLDER_VALUE)
15
+ new(name, HOLDER_KEY)
10
16
  end
11
17
 
12
- def self.create(name, value)
13
- new(name, value)
14
- end
15
-
16
- attr_reader :name, :value, :created_at, :options
18
+ attr_reader :name, :locator_key, :created_at, :options
17
19
 
18
- def initialize(name, value, options = {})
20
+ def initialize(name, locator_key, options = {})
19
21
  @name = name
20
- @value = value
22
+ @locator_key = locator_key
21
23
  @created_at = Time.now.to_i
22
24
  @options = options
23
25
  end
24
26
 
25
27
  def holder?
26
- value == HOLDER_VALUE
28
+ locator_key == HOLDER_KEY
27
29
  end
28
30
 
29
31
  def real?