fixtury 0.4.1 → 1.0.0.beta2

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