fixtury 0.4.1 → 1.0.0.beta2

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,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fixtury
4
+ # Provides a mechanism for observing Fixtury lifecycle events.
5
+ class Hooks
6
+
7
+ attr_reader :hooks
8
+
9
+ def initialize
10
+ @hooks = Hash.new { |h, k| h[k] = { before: [], after: [], around: [], on: [] } }
11
+ end
12
+
13
+ # Register a hook to be called around the execution of a trigger.
14
+ # The around hook should ensure the return value is preserved.
15
+ # This also means that the hook itself could modify the return value.
16
+ #
17
+ # @param trigger_type [Symbol] the type of trigger to hook into
18
+ # @param hook [Proc] the hook to be called
19
+ def around(trigger_type, &hook)
20
+ register_hook(trigger_type, :around, hook)
21
+ end
22
+
23
+ # Register a hook to be called before the execution of a trigger.
24
+ # (see #register_hook)
25
+ def before(trigger_type, &hook)
26
+ register_hook(trigger_type, :before, hook)
27
+ end
28
+
29
+ # Register a hook to be called after the execution of a trigger.
30
+ # The return value will be provided as the first argument to the hook.
31
+ # (see #register_hook)
32
+ def after(trigger_type, &hook)
33
+ register_hook(trigger_type, :after, hook)
34
+ end
35
+
36
+ # Similar to after, but the return value is not injected.
37
+ # (see #register_hook)
38
+ def on(trigger_type, &hook)
39
+ register_hook(trigger_type, :on, hook)
40
+ end
41
+
42
+ # Trigger the hooks registered for a specific trigger type.
43
+ # :before hooks will be triggered first, followed by :around hooks,
44
+ # :on hooks, and finally :after hooks.
45
+ #
46
+ # @param trigger_type [Symbol] the type of trigger to initiate
47
+ # @param args [Array] arguments to be passed to the hooks
48
+ # @param block [Proc] a block of code to be executed
49
+ # @return [Object] the return value of the block
50
+ def call(trigger_type, *args, &block)
51
+ hook_lists = hooks[trigger_type.to_sym]
52
+
53
+ call_inline_hooks(hook_lists[:before], *args)
54
+ return_value = call_around_hooks(hook_lists[:around], 0, block, *args)
55
+ call_inline_hooks(hook_lists[:on], *args)
56
+ call_inline_hooks(hook_lists[:after], return_value, *args)
57
+
58
+ return_value
59
+ end
60
+
61
+ private
62
+
63
+ # Register a hook to be called for a specific trigger type and hook type.
64
+ #
65
+ # @param trigger_type [Symbol] the type of trigger to hook into
66
+ # @param hook_type [Symbol] the point in the trigger to hook into
67
+ # @param hook [Proc] the hook to be called
68
+ # @return [Fixtury::Hooks] the current instance
69
+ def register_hook(trigger_type, hook_type, hook)
70
+ hooks[trigger_type.to_sym][hook_type.to_sym] << hook
71
+ self
72
+ end
73
+
74
+ def call_around_hooks(hook_list, idx, block, *args)
75
+ if idx >= hook_list.length
76
+ block.call
77
+ else
78
+ hook_list[idx].call(*args) do
79
+ call_around_hooks(hook_list, idx + 1, block, *args)
80
+ end
81
+ end
82
+ end
83
+
84
+ def call_inline_hooks(hook_list, *args)
85
+ hook_list.each do |hook|
86
+ hook.call(*args)
87
+ end
88
+ end
89
+ end
90
+ end
@@ -1,48 +1,77 @@
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
- )
11
+ def self.from(thing)
12
+ case thing
13
+ when ::Fixtury::Locator
14
+ thing
15
+ when nil
16
+ ::Fixtury::Locator.new
17
+ when Symbol
18
+ begin
19
+ require "fixtury/locator_backend/#{thing}"
20
+ rescue LoadError
16
21
  end
22
+ backend = ::Fixtury::LocatorBackend.const_get(thing.to_s.camelize, false).new
23
+ ::Fixtury::Locator.new(backend: backend)
24
+ else
25
+ raise ArgumentError, "Unable to create a locator from #{thing.inspect}"
17
26
  end
18
-
19
27
  end
20
28
 
21
29
  attr_reader :backend
22
30
 
23
- def initialize(backend:)
31
+ def initialize(backend: ::Fixtury::LocatorBackend::Memory.new)
24
32
  @backend = backend
25
33
  end
26
34
 
27
- def recognize?(ref)
28
- raise ArgumentError, "Unable to recognize a nil ref" if ref.nil?
35
+ def inspect
36
+ "#{self.class}(backend: #{backend.class})"
37
+ end
38
+
39
+ # Determine if the provided locator_key is a valid form recognized by the backend.
40
+ #
41
+ # @param locator_key [Object] the locator key to check
42
+ # @return [Boolean] true if the locator key is recognizable by the backend
43
+ # @raise [ArgumentError] if the locator key is nil
44
+ def recognizable_key?(locator_key)
45
+ raise ArgumentError, "Unable to recognize a nil locator value" if locator_key.nil?
29
46
 
30
- backend.recognized_reference?(ref)
47
+ backend.recognizable_key?(locator_key)
31
48
  end
32
49
 
33
- def load(ref)
34
- raise ArgumentError, "Unable to load a nil ref" if ref.nil?
50
+ # Load the value associated with the provided locator key.
51
+ #
52
+ # @param locator_key [Object] the locator key to load
53
+ # @return [Object] the loaded value
54
+ # @raise [ArgumentError] if the locator key is nil
55
+ def load(locator_key)
56
+ raise ArgumentError, "Unable to load a nil locator value" if locator_key.nil?
35
57
 
36
- backend.load(ref)
58
+ backend.load(locator_key)
37
59
  end
38
60
 
39
- def dump(value)
40
- raise ArgumentError, "Unable to dump a nil value" if value.nil?
61
+ # Provide the value to the backend to generate a locator key.
62
+ #
63
+ # @param stored_value [Object] the value to dump
64
+ # @param context [String] a string to include in the error message if the value is nil
65
+ # @return [Object] the locator key
66
+ # @raise [ArgumentError] if the value is nil
67
+ # @raise [ArgumentError] if the backend is unable to dump the value
68
+ def dump(stored_value, context: nil)
69
+ raise ArgumentError, "Unable to dump a nil value. #{context}" if stored_value.nil?
41
70
 
42
- ref = backend.dump(value)
43
- raise ArgumentError, "The value resulted in a nil ref" if ref.nil?
71
+ locator_key = backend.dump(stored_value)
72
+ raise ArgumentError, "Dump resulted in a nil locator value. #{context}" if locator_key.nil?
44
73
 
45
- ref
74
+ locator_key
46
75
  end
47
76
 
48
77
  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
 
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "./common"
4
+ require "globalid"
5
+
6
+ module Fixtury
7
+ module LocatorBackend
8
+ class GlobalId
9
+
10
+ include ::Fixtury::LocatorBackend::Common
11
+
12
+ MATCHER = %r{^gid://}.freeze
13
+
14
+ def recognizable_key?(locator_value)
15
+ locator_value.is_a?(String) && MATCHER.match?(locator_value)
16
+ end
17
+
18
+ def recognizable_value?(stored_value)
19
+ stored_value.respond_to?(:to_global_id)
20
+ end
21
+
22
+ def load_reference(locator_value)
23
+ ::GlobalID::Locator.locate locator_value
24
+ end
25
+
26
+ def dump_value(stored_value)
27
+ stored_value.to_global_id.to_s
28
+ end
29
+
30
+ end
31
+ end
32
+ end
@@ -8,27 +8,28 @@ module Fixtury
8
8
 
9
9
  include ::Fixtury::LocatorBackend::Common
10
10
 
11
- MATCHER = /^fixtury-oid-(?<object_id>[\d]+)$/.freeze
11
+ MATCHER = /^fixtury-oid-(?<process_id>[\d]+)-(?<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
+ return nil unless match[:process_id].to_i == Process.pid
24
25
 
25
26
  ::ObjectSpace._id2ref(match[:object_id].to_i)
26
27
  rescue RangeError
27
28
  nil
28
29
  end
29
30
 
30
- def dump_recognized_value(value)
31
- "fixtury-oid-#{value.object_id}"
31
+ def dump_value(stored_value)
32
+ "fixtury-oid-#{Process.pid}-#{stored_value.object_id}"
32
33
  end
33
34
 
34
35
  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?