fixtury 1.0.0.beta1 → 1.0.0.beta2

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 02d3acd3d4f3904ae6e00f53e81a5c29b3aa65b53a0bba96029625e99deb618e
4
- data.tar.gz: 12a1fa78d029e6781ce240a3bed206b63b0fd02589773a84deeb80f18556585d
3
+ metadata.gz: cd14efe323fc1de1a2ec63805508e7fb816db30f028998e951c78e95cbe14dfe
4
+ data.tar.gz: e0a1055bf1ebfff930569a5b678b1b433c21b48e2a4305197bdeb7938e22332a
5
5
  SHA512:
6
- metadata.gz: 69b2c38ebddf785a673adb7084ae1f18acc9dce299381ca747af1db6d7a1a2e38772e09d8f4848b7f923d7ade9acac3d3780540c5b42c83b23e547897c3379f3
7
- data.tar.gz: 9ef4c658ab06864de8192111596af75c44498ae26c9af3178c63c11e915bda8a6634fa5d1540eb85491085a90e4d58c00772599f4a85df0e286a825befb95b0d
6
+ metadata.gz: d819ff4d3096eee7a2ac3c4617f89463b28b7826f3046efbf391408b595b8758f52ac556c8e337eae396109839a148a033750b2ae5ac10ebddce27a041344be3
7
+ data.tar.gz: febce1127eceb8f3ef6e148b7212591e657dab0d62f4b57d0ab6e8b6f8ba8d9c210180f6713fe78d5de6ce35356a5c448cfd9903d60a406efe5bc835c9cbbe11
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- fixtury (1.0.0.beta1)
4
+ fixtury (1.0.0.beta2)
5
5
 
6
6
  GEM
7
7
  remote: https://rubygems.org/
@@ -0,0 +1,117 @@
1
+ require "digest"
2
+
3
+ module Fixtury
4
+ # Provides an interface for managing settings and dependencies related to fixture
5
+ # generation.
6
+ class Configuration
7
+
8
+ attr_reader :filepath, :fixture_files, :dependency_files
9
+
10
+ def initialize
11
+ @filepath = nil
12
+ @fixture_files = Set.new
13
+ @dependency_files = Set.new
14
+ end
15
+
16
+ def log_level
17
+ return @log_level if @log_level
18
+
19
+ @log_level = ENV["FIXTURY_LOG_LEVEL"]
20
+ @log_level ||= DEFAULT_LOG_LEVEL
21
+ @log_level = @log_level.to_s.to_sym
22
+ @log_level
23
+ end
24
+
25
+ # Delete the storage file if it exists.
26
+ def reset
27
+ File.delete(filepath) if filepath && File.file?(filepath)
28
+ end
29
+
30
+ # Set the location of the storage file. The storage file will maintain
31
+ # checksums of all tracked files and serialized references to fixtures.
32
+ #
33
+ # @param path [String] The path to the storage file.
34
+ def filepath=(path)
35
+ @filepath = path.to_s
36
+ end
37
+
38
+ # Add a file or glob pattern to the list of fixture files.
39
+ #
40
+ # @param path_or_globs [String, Array<String>] The file or glob pattern(s) to add.
41
+ def add_fixture_path(*path_or_globs)
42
+ @fixture_files = fixture_files | Dir[*path_or_globs]
43
+ end
44
+ alias add_fixture_paths add_fixture_path
45
+
46
+ # Add a file or glob pattern to the list of dependency files.
47
+ #
48
+ # @param path_or_globs [String, Array<String>] The file or glob pattern(s) to add.
49
+ def add_dependency_path(*path_or_globs)
50
+ @dependency_files = dependency_files | Dir[*path_or_globs]
51
+ end
52
+ alias add_dependency_paths add_dependency_path
53
+
54
+ # The references stored in the dependency file. When stores are initialized
55
+ # these will be used to bootstrap the references.
56
+ #
57
+ # @return [Hash] The references stored in the dependency file.
58
+ def stored_references
59
+ return {} if stored_data.nil?
60
+
61
+ stored_data[:references] || {}
62
+ end
63
+
64
+ # Dump the current state of the dependency manager to the storage file.
65
+ def dump_file
66
+ return unless filepath
67
+
68
+ FileUtils.mkdir_p(File.dirname(filepath))
69
+ File.binwrite(filepath, file_data.to_yaml)
70
+ end
71
+
72
+ private
73
+
74
+ def file_data
75
+ checksums = {}
76
+ calculate_checksums do |filepath, checksum|
77
+ checksums[filepath] = checksum
78
+ end
79
+
80
+ {
81
+ dependencies: checksums,
82
+ references: ::Fixtury.store.references,
83
+ }
84
+ end
85
+
86
+ def stored_data
87
+ return nil unless filepath
88
+ return nil unless File.file?(filepath)
89
+
90
+ YAML.unsafe_load_file(filepath)
91
+ end
92
+
93
+ def files_changed?
94
+ return true if stored_data.nil?
95
+
96
+ stored_checksums = (stored_data[:dependencies] || {})
97
+ seen_filepaths = []
98
+ calculate_checksums do |filepath, checksum|
99
+ # Early return if the checksums don't match
100
+ return true unless stored_checksums[filepath] == checksum
101
+
102
+ seen_filepaths << filepath
103
+ end
104
+
105
+ # If we have a new file or a file has been removed, we need to report a change.
106
+ seen_filepaths.sort != stored_checksums.keys.sort
107
+ end
108
+
109
+ def calculate_checksums(&block)
110
+ (fixture_files.to_a | dependency_files.to_a).sort.each do |filepath|
111
+ yield filepath, Digest::MD5.file(filepath).hexdigest
112
+ end
113
+ end
114
+
115
+
116
+ end
117
+ end
@@ -28,13 +28,6 @@ module Fixtury
28
28
  @callable = block
29
29
  end
30
30
 
31
- # Returns the type of the schema node.
32
- #
33
- # @return [String] The schema node type.
34
- def schema_node_type
35
- "dfn"
36
- end
37
-
38
31
  # Indicates whether the Definition acts like a Fixtury definition.
39
32
  #
40
33
  # @return [Boolean] `true` if it acts like a Fixtury definition, `false` otherwise.
@@ -8,6 +8,24 @@ module Fixtury
8
8
  # The backend is expected to implement the following methods: recognizable_key?, recognized_value?, load_recognized_reference, dump_recognized_value.
9
9
  class Locator
10
10
 
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
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}"
26
+ end
27
+ end
28
+
11
29
  attr_reader :backend
12
30
 
13
31
  def initialize(backend: ::Fixtury::LocatorBackend::Memory.new)
@@ -5,7 +5,7 @@ require "globalid"
5
5
 
6
6
  module Fixtury
7
7
  module LocatorBackend
8
- class GlobalID
8
+ class GlobalId
9
9
 
10
10
  include ::Fixtury::LocatorBackend::Common
11
11
 
@@ -8,7 +8,7 @@ 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
13
  def recognizable_key?(locator_value)
14
14
  locator_value.is_a?(String) && MATCHER.match?(locator_value)
@@ -21,6 +21,7 @@ module Fixtury
21
21
  def load_reference(locator_value)
22
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
@@ -28,7 +29,7 @@ module Fixtury
28
29
  end
29
30
 
30
31
  def dump_value(stored_value)
31
- "fixtury-oid-#{stored_value.object_id}"
32
+ "fixtury-oid-#{Process.pid}-#{stored_value.object_id}"
32
33
  end
33
34
 
34
35
  end
@@ -1,43 +1,69 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Fixtury
4
+ # A Fixtury::SchemaNode implementation that represents a top-level schema
5
+ # or a namespace within a schema.
4
6
  class Schema
5
7
 
6
8
  include ::Fixtury::SchemaNode
7
9
 
10
+ # @param name [String] the name of the schema, defaults to "" to represent
11
+ # a new top-level schema.
8
12
  def initialize(name: "", **options)
9
13
  super(name: name, **options)
10
14
  end
11
15
 
16
+ # Object#acts_like? adherence.
12
17
  def acts_like_fixtury_schema?
13
18
  true
14
19
  end
15
20
 
21
+ # Open up self for child definitions.
22
+ # @param block [Proc] The block to be executed in the context of the schema.
23
+ # @return [Fixtury::Schema] self
16
24
  def define(&block)
17
25
  instance_eval(&block)
18
26
  self
19
27
  end
20
28
 
29
+ # Returns "schema" if top-level, otherwise returns "namespace".
30
+ # @return [String] the type of the schema node.
21
31
  def schema_node_type
22
- first_ancestor? ? "schema" : "ns"
32
+ first_ancestor? ? "schema" : "namespace"
23
33
  end
24
34
 
25
- def namespace(name, **options, &block)
26
- child = get("./#{name}")
35
+ # Create a child schema at the given relative name. If a child by the name
36
+ # already exists it will be reopened as long as it's a fixtury schema.
37
+ #
38
+ # @param relative_name [String] The relative name of the child schema.
39
+ # @param options [Hash] Additional options for the child schema, applied after instantiation.
40
+ # @param block [Proc] A block of code to be executed in the context of the child schema.
41
+ # @return [Fixtury::Schema] The child schema.
42
+ # @raise [Fixtury::Errors::AlreadyDefinedError] if the child is already defined and not a fixtury schema.
43
+ def namespace(relative_name, **options, &block)
44
+ child = get("./#{relative_name}")
27
45
 
28
46
  if child && !child.acts_like?(:fixtury_schema)
29
47
  raise Errors::AlreadyDefinedError, child.pathname
30
48
  end
31
49
 
32
- child ||= self.class.new(name: name, parent: self)
50
+ child ||= self.class.new(name: relative_name, parent: self)
33
51
  child.apply_options!(options)
34
52
  child.instance_eval(&block) if block_given?
35
53
  child
36
54
  end
37
55
 
38
- def fixture(name, **options, &block)
56
+ # Create a fixture definition at the given relative name. If the name is already
57
+ # used, a Fixtury::Errors::AlreadyDefinedError will be raised.
58
+ #
59
+ # @param relative_name [String] The relative name of the fixture.
60
+ # @param options [Hash] Additional options for the fixture.
61
+ # @param block [Proc] The block representing the build function of the fixture.
62
+ # @return [Fixtury::Definition] The fixture definition.
63
+ #
64
+ def fixture(relative_name, **options, &block)
39
65
  ::Fixtury::Definition.new(
40
- name: name,
66
+ name: relative_name,
41
67
  parent: self,
42
68
  **options,
43
69
  &block
@@ -1,4 +1,7 @@
1
1
  module Fixtury
2
+ # This module is used to provide a common interface for all nodes in the schema tree.
3
+ # Namespaces and fixture definitions adhere to this interface and are provided with
4
+ # common behaviors for registration, traversal, inspection, etc
2
5
  module SchemaNode
3
6
 
4
7
  extend ActiveSupport::Concern
@@ -6,9 +9,16 @@ module Fixtury
6
9
  VALID_NODE_NAME = /^[a-zA-Z0-9_]*$/
7
10
 
8
11
  included do
9
- attr_reader :name, :pathname, :parent, :children, :options
12
+ attr_reader :name, :pathname, :parent, :first_ancestor, :children, :options
10
13
  end
11
14
 
15
+ # Constructs a new SchemaNode object.
16
+ #
17
+ # @param name [String] The relative name of the node.
18
+ # @param parent [Object] The parent node of the node.
19
+ # @param options [Hash] Additional options for the node.
20
+ # @return [Fixtury::SchemaNode] The new SchemaNode object.
21
+ # @raise [ArgumentError] if the name does not match the VALID_NODE_NAME regex.
12
22
  def initialize(name:, parent: nil, **options)
13
23
  name = name.to_s
14
24
  raise ArgumentError, "#{name.inspect} is an invalid node name" unless name.match?(VALID_NODE_NAME)
@@ -19,25 +29,35 @@ module Fixtury
19
29
  @children = {}
20
30
  @options = {}
21
31
  apply_options!(options)
22
- @parent.add_child(self) if @parent
32
+ @first_ancestor = @parent&.first_ancestor || self
33
+ @parent&.add_child(self)
23
34
  end
24
35
 
36
+ # Inspect the SchemaNode object without representing the parent or children to avoid
37
+ # large prints.
38
+ #
39
+ # @return [String] The inspection string.
25
40
  def inspect
26
41
  "#{self.class}(pathname: #{pathname.inspect}, children: #{children.size})"
27
42
  end
28
43
 
44
+ # An identifier used during the printing of the tree structure.
45
+ #
46
+ # @return [String] The demodularized class name.
29
47
  def schema_node_type
30
- raise NotImplementedError
48
+ self.class.name.demodulize.underscore
31
49
  end
32
50
 
51
+ # Adherance to the acts_like? interface
33
52
  def acts_like_fixtury_schema_node?
34
53
  true
35
54
  end
36
55
 
37
- def first_ancestor
38
- first_ancestor? ? self : parent.first_ancestor
39
- end
40
-
56
+ # Adds child to the node's children hash as long as another is not already defined.
57
+ #
58
+ # @param child [Fixtury::SchemaNode] The child node to add.
59
+ # @raise [Fixtury::Errors::AlreadyDefinedError] if the child is already defined and not the provided child.
60
+ # @return [Fixtury::Errors::AlreadyDefinedError] the child that was suscessfully added
41
61
  def add_child(child)
42
62
  if children.key?(child.name) && children[child.name] != child
43
63
  raise Errors::AlreadyDefinedError, child.pathname
@@ -46,19 +66,35 @@ module Fixtury
46
66
  children[child.name] = child
47
67
  end
48
68
 
69
+ # Is the current node the first ancestor?
70
+ #
71
+ # @return [TrueClass, FalseClass] `true` if the node is the first ancestor, `false` otherwise.
49
72
  def first_ancestor?
50
73
  parent.nil?
51
74
  end
52
75
 
76
+ # Determines the isolation key in a top-down manner. It first accepts an isolation key
77
+ # set by the parent, then it checks for an isolation key set by the node itself. If no
78
+ # isolation key is found, it defaults to the node's name unless default is set to falsy.
79
+ #
80
+ # @param default [TrueClass, FalseClass, String] if no isolation key is present, what should the default value be?
81
+ # @option default [true] The default value is the node's name.
82
+ # @option default [String] The default value is a custom string.
83
+ # @option default [false, nil, ""] No isolation key should be represented
84
+ # @return [String, NilClass] The isolation key.
53
85
  def isolation_key(default: true)
54
86
  from_parent = parent&.isolation_key(default: nil)
55
87
  return from_parent if from_parent
56
88
 
57
89
  value = options[:isolate] || default
58
90
  value = (value == true ? pathname : value&.to_s).presence
59
- value == "/" ? nil : value # special case to accommodate root nodes
91
+ value = (value == "/" ? nil : value) # special case to accommodate root nodes
92
+ value.presence
60
93
  end
61
94
 
95
+ # Performs get() but raises if the result is nil.
96
+ # @raise [Fixtury::Errors::SchemaNodeNotDefinedError] if the search does not return a node.
97
+ # (see #get)
62
98
  def get!(search)
63
99
  thing = get(search)
64
100
  raise Errors::SchemaNodeNotDefinedError.new(pathname, search) unless thing
@@ -66,6 +102,14 @@ module Fixtury
66
102
  thing
67
103
  end
68
104
 
105
+ # Retrieves a node in the tree relative to self. Absolute and relative searches are
106
+ # accepted. The potential absolute paths are determined by a Fixtury::PathResolver instance
107
+ # relative to this node's pathname.
108
+ #
109
+ # @param search [String] The search to be used for finding the node.
110
+ # @return [Fixtury::SchemaNode, NilClass] The node if found, `nil` otherwise.
111
+ # @raise [ArgumentError] if the search is blank.
112
+ # @alias []
69
113
  def get(search)
70
114
  raise ArgumentError, "`search` must be provided" if search.blank?
71
115
 
@@ -87,18 +131,36 @@ module Fixtury
87
131
  end
88
132
  alias [] get
89
133
 
90
- # helpful for inspection
134
+ # Generates a string representing the structure of the schema tree.
135
+ # The string will be in the form of "type:name[isolation_key](options)". The children
136
+ # will be on the next line and indented by two spaces.
137
+ #
138
+ # @param prefix [String] The prefix to be used for any lines produced.
139
+ # @return [String] The structure string.
91
140
  def structure(prefix = "")
92
141
  out = []
142
+
143
+ opts = options.except(:isolate)
144
+ opts.compact!
145
+
93
146
  my_structure = +"#{prefix}#{schema_node_type}:#{name}"
94
- my_structure << "(#{options.inspect})" if options.present?
147
+ iso = isolation_key(default: nil)
148
+ my_structure << "[#{iso}]" if iso
149
+ my_structure << "(#{opts.to_a.map { |k, v| "#{k}: #{v.inspect}" }.join(", ")})" if opts.present?
95
150
  out << my_structure
151
+
96
152
  children.each_value do |child|
97
153
  out << child.structure("#{prefix} ")
98
154
  end
99
155
  out.join("\n")
100
156
  end
101
157
 
158
+ # Applies options to the node and raises if a collision occurs.
159
+ # This is useful for reopening a node and ensuring options are not altered.
160
+ #
161
+ # @param opts [Hash] The options to apply to the node.
162
+ # @raise [Fixtury::Errors::OptionCollisionError] if the option is already set and the new value is different.
163
+ # @return [void]
102
164
  def apply_options!(opts = {})
103
165
  opts.each do |key, value|
104
166
  if options.key?(key) && options[key] != value
data/lib/fixtury/store.rb CHANGED
@@ -1,62 +1,69 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "concurrent/atomic/thread_local_var"
3
4
  require "fileutils"
4
5
  require "singleton"
5
6
  require "yaml"
6
7
 
7
8
  module Fixtury
9
+ # A store is a container for built fixture references. It is responsible for loading and caching fixtures
10
+ # based on a schema and a locator.
8
11
  class Store
9
12
 
10
- attr_reader :filepath
11
- attr_reader :loaded_isolation_keys
12
13
  attr_reader :locator
13
- attr_reader :log_level
14
- attr_reader :references
15
14
  attr_reader :schema
16
15
  attr_reader :ttl
17
16
 
18
- def initialize(filepath: nil, locator: nil, ttl: nil, schema: nil)
17
+ # Create a new store.
18
+ # @param locator [Fixtury::Locator, Symbol, NilClass] (see Fixtury::Locator#from)
19
+ # @param ttl [Integer, NilClass] The time-to-live for references in seconds.
20
+ # @param schema [Fixtury::Schema, NilClass] The schema to use for fixture definitions, defaults to the global schema.
21
+ # @return [Fixtury::Store]
22
+ def initialize(locator: nil, ttl: nil, schema: nil)
19
23
  @schema = schema || ::Fixtury.schema
20
- @locator = locator || ::Fixtury::Locator.new
21
- @filepath = filepath
22
- @references = load_reference_from_file || {}
24
+ @locator = ::Fixtury::Locator.from(locator)
23
25
  @ttl = ttl&.to_i
24
- @loaded_isolation_keys = {}
26
+ self.references = ::Fixtury.configuration.stored_references
25
27
  end
26
28
 
29
+ def references
30
+ @references ||= ::Concurrent::ThreadLocalVar.new({})
31
+ @references.value
32
+ end
33
+
34
+ def references=(value)
35
+ references.clear
36
+ @references.value = value
37
+ end
38
+
39
+ def loaded_isolation_keys
40
+ @loaded_isolation_keys ||= ::Concurrent::ThreadLocalVar.new({})
41
+ @loaded_isolation_keys.value
42
+ end
43
+
44
+ # Empty the store of any references and loaded isolation keys.
45
+ def reset
46
+ references.clear
47
+ loaded_isolation_keys.clear
48
+ end
49
+
50
+ # Summarize the current state of the store.
51
+ #
52
+ # @return [String]
27
53
  def inspect
28
54
  parts = []
29
55
  parts << "schema: #{schema.inspect}"
30
56
  parts << "locator: #{locator.inspect}"
31
- parts << "filepath: #{filepath.inspect}" if filepath
32
57
  parts << "ttl: #{ttl.inspect}" if ttl
33
58
  parts << "references: #{references.size}"
34
59
 
35
60
  "#{self.class}(#{parts.join(", ")})"
36
61
  end
37
62
 
38
- def dump_to_file
39
- return unless filepath
40
-
41
- ::FileUtils.mkdir_p(File.dirname(filepath))
42
-
43
- writable = references.each_with_object({}) do |(pathname, ref), h|
44
- h[pathname] = ref if ref.real?
45
- end
46
-
47
- ::File.binwrite(filepath, writable.to_yaml)
48
- end
49
-
50
- def load_reference_from_file
51
- return unless filepath
52
- return unless File.file?(filepath)
53
-
54
- ::YAML.unsafe_load_file(filepath)
55
- end
56
-
63
+ # Clear any references that are beyond their ttl or are no longer recognizable by the locator.
64
+ #
65
+ # @return [void]
57
66
  def clear_stale_references!
58
- return unless ttl
59
-
60
67
  references.delete_if do |name, ref|
61
68
  stale = reference_stale?(ref)
62
69
  log("expiring #{name}", level: LOG_LEVEL_DEBUG) if stale
@@ -64,6 +71,11 @@ module Fixtury
64
71
  end
65
72
  end
66
73
 
74
+ # Load all fixtures in the target schema, defaulting to the store's schema.
75
+ # This will load all fixtures in the schema and any child schemas.
76
+ #
77
+ # @param schema [Fixtury::Schema] The schema to load, defaults to the store's schema.
78
+ # @return [void]
67
79
  def load_all(schema = self.schema)
68
80
  schema.children.each_value do |item|
69
81
  get(item.name) if item.acts_like?(:fixtury_definition)
@@ -71,19 +83,12 @@ module Fixtury
71
83
  end
72
84
  end
73
85
 
74
- def clear_cache!(pattern: nil)
75
- pattern ||= "*"
76
- pattern = "/#{pattern}" unless pattern.start_with?("/")
77
- glob = pattern.end_with?("*")
78
- pattern = pattern[0...-1] if glob
79
- references.delete_if do |key, _value|
80
- hit = glob ? key.start_with?(pattern) : key == pattern
81
- log("clearing #{key}", level: LOG_LEVEL_DEBUG) if hit
82
- hit
83
- end
84
- dump_to_file
85
- end
86
-
86
+ # Temporarily set a contextual schema to use for loading fixtures. This is
87
+ # useful when evaluating dependencies of a definition while still storing the results.
88
+ #
89
+ # @param schema [Fixtury::Schema] The schema to use.
90
+ # @yield [void] The block to execute with the given schema.
91
+ # @return [Object] The result of the block
87
92
  def with_relative_schema(schema)
88
93
  prior = @schema
89
94
  @schema = schema
@@ -92,53 +97,38 @@ module Fixtury
92
97
  @schema = prior
93
98
  end
94
99
 
95
- def loaded?(name)
96
- dfn = schema.get!(name)
100
+ # Is a fixture for the given search already loaded?
101
+ #
102
+ # @param search [String] The name of the fixture to search for.
103
+ # @return [TrueClass, FalseClass] `true` if the fixture is loaded, `false` otherwise.
104
+ def loaded?(search)
105
+ dfn = schema.get!(search)
97
106
  ref = references[dfn.pathname]
98
107
  result = ref&.real?
99
108
  log(result ? "hit #{dfn.pathname}" : "miss #{dfn.pathname}", level: LOG_LEVEL_ALL)
100
109
  result
101
110
  end
102
111
 
103
- def loaded_or_loading?(pathname)
104
- !!references[pathname]
105
- end
106
-
107
- def maybe_load_isolation_dependencies(definition)
108
- isolation_key = definition.isolation_key
109
- return if loaded_isolation_keys[isolation_key]
110
-
111
- load_isolation_dependencies(isolation_key, schema.first_ancestor)
112
- end
113
-
114
- def load_isolation_dependencies(isolation_key, target_schema)
115
- loaded_isolation_keys[isolation_key] = true
116
- target_schema.children.each_value do |child|
117
- if child.acts_like?(:fixtury_definition)
118
- next unless child.isolation_key == isolation_key
119
- next if loaded_or_loading?(child.pathname)
120
- get(child.pathname)
121
- elsif child.acts_like?(:fixtury_schema)
122
- load_isolation_dependencies(isolation_key, child)
123
- else
124
- raise NotImplementedError, "Unknown isolation loading behavior: #{child.class.name}"
125
- end
126
- end
127
- end
128
-
129
112
  # Fetch a fixture by name. This will load the fixture if it has not been loaded yet.
130
113
  # If a definition contains an isolation key, all fixtures with the same isolation key will be loaded.
131
- def get(name)
132
- log("getting #{name}", level: LOG_LEVEL_DEBUG)
114
+ #
115
+ # @param search [String] The name of the fixture to search for.
116
+ # @return [Object] The loaded fixture.
117
+ # @raise [Fixtury::Errors::CircularDependencyError] if a circular dependency is detected.
118
+ # @raise [Fixtury::Errors::SchemaNodeNotDefinedError] if the search does not return a node.
119
+ # @raise [Fixtury::Errors::UnknownDefinitionError] if the search does not return a definition.
120
+ # @raise [Fixtury::Errors::DefinitionExecutorError] if the definition executor fails.
121
+ def get(search)
122
+ log("getting #{search} relative to #{schema.pathname}", level: LOG_LEVEL_DEBUG)
133
123
 
134
124
  # Find the definition.
135
- dfn = schema.get!(name)
136
- raise ArgumentError, "#{name.inspect} must refer to a definition" unless dfn.acts_like?(:fixtury_definition)
125
+ dfn = schema.get!(search)
126
+ raise ArgumentError, "#{search.inspect} must refer to a definition" unless dfn.acts_like?(:fixtury_definition)
137
127
 
138
128
  pathname = dfn.pathname
139
129
 
140
130
  # Ensure that if we're part of an isolation group, we load all the fixtures in that group.
141
- maybe_load_isolation_dependencies(dfn)
131
+ maybe_load_isolation_dependencies(dfn.isolation_key)
142
132
 
143
133
  # See if we already hold a reference to the fixture.
144
134
  ref = references[pathname]
@@ -190,16 +180,65 @@ module Fixtury
190
180
  end
191
181
  alias [] get
192
182
 
183
+ protected
184
+
185
+ # Determine if the given pathname is already loaded or is currently being loaded.
186
+ #
187
+ # @param pathname [String] The pathname to check.
188
+ # @return [TrueClass, FalseClass] `true` if the pathname is already loaded or is currently being loaded, `false` otherwise.
189
+ def loaded_or_loading?(pathname)
190
+ !!references[pathname]
191
+ end
192
+
193
+ # Load all fixtures with the given isolation key in the target schema
194
+ # if we're not already attempting to load them.
195
+ def maybe_load_isolation_dependencies(isolation_key)
196
+ return if loaded_isolation_keys[isolation_key]
197
+ loaded_isolation_keys[isolation_key] = true
198
+
199
+ load_isolation_dependencies(isolation_key, schema.first_ancestor)
200
+ end
201
+
202
+ # Load all fixtures with the given isolation key in the target schema.
203
+ #
204
+ # @param isolation_key [String] The isolation key to load fixtures for.
205
+ # @param target_schema [Fixtury::Schema] The schema to search within.
206
+ # @return [void]
207
+ def load_isolation_dependencies(isolation_key, target_schema)
208
+ target_schema.children.each_value do |child|
209
+ if child.acts_like?(:fixtury_definition)
210
+ next unless child.isolation_key == isolation_key
211
+ next if loaded_or_loading?(child.pathname)
212
+
213
+ get(child.pathname)
214
+ elsif child.acts_like?(:fixtury_schema)
215
+ load_isolation_dependencies(isolation_key, child)
216
+ else
217
+ raise NotImplementedError, "Unknown isolation loading behavior: #{child.class.name}"
218
+ end
219
+ end
220
+ end
221
+
222
+ # Remove a reference at the given pathname from the stored references.
223
+ #
224
+ # @param pathname [String] The pathname to remove.
225
+ # @return [void]
193
226
  def clear_reference(pathname)
194
227
  references.delete(pathname)
195
228
  end
196
229
 
230
+ # Determine if a reference is stale. A reference is stale if it is beyond its ttl or
231
+ # if it is no longer recognizable by the locator.
232
+ #
233
+ # @param ref [Fixtury::Reference] The reference to check.
234
+ # @return [TrueClass, FalseClass] `true` if the reference is stale, `false` otherwise.
197
235
  def reference_stale?(ref)
198
236
  return true if ttl && ref.created_at < (Time.now.to_i - ttl)
199
237
 
200
238
  !locator.recognizable_key?(ref.locator_key)
201
239
  end
202
240
 
241
+ # Log a contextual message using Fixtury.log
203
242
  def log(msg, level:)
204
243
  ::Fixtury.log(msg, level: level, name: "store")
205
244
  end
@@ -4,6 +4,54 @@ require "fixtury"
4
4
  require "active_support/core_ext/class/attribute"
5
5
 
6
6
  module Fixtury
7
+ # TestHooks is a module designed to hook into a Minitest test case, and
8
+ # provide a way to load fixtures into the test case. It is designed to be
9
+ # prepended into the test case class, and will automatically load fixtures
10
+ # before the test case is setup, and rollback any changes after the test
11
+ # case is torn down.
12
+ #
13
+ # The module also provides a way to define fixture dependencies, and will
14
+ # automatically load those dependencies before the test case is setup.
15
+ #
16
+ # @example
17
+ # class MyTest < Minitest::Test
18
+ # prepend Fixtury::TestHooks
19
+ #
20
+ # fixtury "user"
21
+ # fixtury "post"
22
+ #
23
+ # def test_something
24
+ # user # => returns the `users` fixture
25
+ # user.do_some_mutation
26
+ # assert_equal 1, user.mutations.count
27
+ # end
28
+ # end
29
+ #
30
+ # # In the above example, the `users` and `posts` fixtures will be loaded
31
+ # # before the test case is setup, and any changes will be rolled back
32
+ # # after the test case is torn down.
33
+ #
34
+ # # The `fixtury` method also accepts a `:as` option, which can be used to
35
+ # # define a named accessor method for a fixture. This is useful when
36
+ # # defining a single fixture, and you want to access it using a different
37
+ # # name. If no `:as` option is provided, the fixture will be accessed
38
+ # # using the last segment of the fixture's pathname.
39
+ #
40
+ # class MyTest < Minitest::Test
41
+ # prepend Fixtury::TestHooks
42
+ #
43
+ # fixtury "/my/user_record", as: :user
44
+ #
45
+ # end
46
+ #
47
+ # A Set object named fixtury_dependencies is made available on the test class.
48
+ # This allows you to load all Minitest runnables and analyze what fixtures are
49
+ # needed. This is very helpful in CI pipelines when you want to prepare all fixtures
50
+ # ahead of time to share between multiple processes.
51
+ #
52
+ # The setup and teardown attempt to manage a transaction for each registered database
53
+ # connection if ActiveRecord::Base is present. If use_transaction_tests or use_transactional_fixtures
54
+ # are present, those settings will be respected. If neither are present, a transaction will be used.
7
55
  module TestHooks
8
56
 
9
57
  def self.prepended(klass)
@@ -18,17 +66,17 @@ module Fixtury
18
66
 
19
67
  module ClassMethods
20
68
 
21
- def fixtury_store
22
- ::Fixtury.store
23
- end
24
-
25
- def fixtury_schema
26
- ::Fixtury.schema
27
- end
28
-
69
+ # Declare fixtury dependencies for this test case. This will automatically
70
+ # load the fixtures before the test case is setup, and rollback any changes
71
+ # after the test case is torn down.
72
+ #
73
+ # @param searches [Array<String>] A list of fixture names to load. These should be resolvable paths relative to Fixtury.schema (root).
74
+ # @param opts [Hash] A list of options to customize the behavior of the fixtures.
75
+ # @option opts [Symbol, String, Boolean] :as (true) The name of the accessor method to define for the fixture. If true (default), the last segment will be used.
76
+ # @return [void]
29
77
  def fixtury(*searches, **opts)
30
78
  pathnames = searches.map do |search|
31
- dfn = fixtury_schema.get!(search)
79
+ dfn = Fixtury.schema.get!(search)
32
80
  dfn.pathname
33
81
  end
34
82
 
@@ -66,41 +114,47 @@ module Fixtury
66
114
 
67
115
  end
68
116
 
117
+ # Minitest before_setup hook. This will load the fixtures before the test.
69
118
  def before_setup(...)
70
119
  fixtury_setup if fixtury_dependencies.any?
71
120
  super
72
121
  end
73
122
 
123
+ # Minitest after_teardown hook. This will rollback any changes made to the fixtures after the test.
74
124
  def after_teardown(...)
75
125
  super
76
126
  fixtury_teardown if fixtury_dependencies.any?
77
127
  end
78
128
 
79
-
80
- def fixtury(name)
81
- return nil unless self.class.fixtury_store
82
-
83
- dfn = self.class.fixtury_schema.get!(name)
129
+ # Access a fixture via a search term. This will access the fixture from the Fixtury store.
130
+ # If the fixture was not declared as a dependency, an error will be raised.
131
+ #
132
+ # @param search [String] The search term to use to find the fixture.
133
+ # @return [Object] The fixture.
134
+ # @raise [Fixtury::Errors::UnknownTestDependencyError] if the search term does not result in a declared dependency.
135
+ # @raise [Fixtury::Errors::SchemaNodeNotDefinedError] if the search term does not result in a recognized fixture.
136
+ def fixtury(search)
137
+ dfn = Fixtury.schema.get!(search)
84
138
 
85
139
  unless fixtury_dependencies.include?(dfn.pathname)
86
140
  raise Errors::UnknownTestDependencyError, "Unrecognized fixtury dependency `#{dfn.pathname}` for #{self.class}"
87
141
  end
88
142
 
89
- self.class.fixtury_store.get(dfn.pathname)
90
- end
91
-
92
- def fixtury_loaded?(name)
93
- return false unless self.class.fixtury_store
94
-
95
- self.class.fixtury_store.loaded?(name)
143
+ Fixtury.store.get(dfn.pathname)
96
144
  end
97
145
 
146
+ # Retrieve all database connections that are currently registered with a writing role.
147
+ #
148
+ # @return [Array<ActiveRecord::ConnectionAdapters::AbstractAdapter>] The list of database connections.
98
149
  def fixtury_database_connections
150
+ return [] unless defined?(ActiveRecord::Base)
151
+
99
152
  ActiveRecord::Base.connection_handler.connection_pool_list(:writing).map(&:connection)
100
153
  end
101
154
 
155
+ # Load all dependenct fixtures and begin a transaction for each database connection.
102
156
  def fixtury_setup
103
- fixtury_clear_stale_fixtures!
157
+ Fixtury.store.clear_stale_references!
104
158
  fixtury_load_all_fixtures!
105
159
  return unless fixtury_use_transactions?
106
160
 
@@ -109,6 +163,7 @@ module Fixtury
109
163
  end
110
164
  end
111
165
 
166
+ # Rollback any changes made to the fixtures
112
167
  def fixtury_teardown
113
168
  return unless fixtury_use_transactions?
114
169
 
@@ -117,21 +172,19 @@ module Fixtury
117
172
  end
118
173
  end
119
174
 
120
- def fixtury_clear_stale_fixtures!
121
- return unless self.class.fixtury_store
122
-
123
- self.class.fixtury_store.clear_stale_references!
124
- end
125
-
175
+ # Load all fixture dependencies that have not previously been loaded into the store.
176
+ #
177
+ # @return [void]
126
178
  def fixtury_load_all_fixtures!
127
179
  fixtury_dependencies.each do |name|
128
- unless fixtury_loaded?(name)
129
- ::Fixtury.log("preloading #{name.inspect}", name: "test", level: ::Fixtury::LOG_LEVEL_INFO)
130
- fixtury(name)
131
- end
180
+ next if Fixtury.store.loaded?(name)
181
+
182
+ ::Fixtury.log("preloading #{name.inspect}", name: "test", level: ::Fixtury::LOG_LEVEL_INFO)
183
+ fixtury(name)
132
184
  end
133
185
  end
134
186
 
187
+ # Adhere to common Rails test transaction settings.
135
188
  def fixtury_use_transactions?
136
189
  return use_transactional_tests if respond_to?(:use_transactional_tests)
137
190
  return use_transactional_fixtures if respond_to?(:use_transactional_fixtures)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Fixtury
4
4
 
5
- VERSION = "1.0.0.beta1"
5
+ VERSION = "1.0.0.beta2"
6
6
 
7
7
  end
data/lib/fixtury.rb CHANGED
@@ -9,6 +9,7 @@ require "active_support/core_ext/object/blank"
9
9
 
10
10
  require "fixtury/version"
11
11
 
12
+ require "fixtury/configuration"
12
13
  require "fixtury/definition_executor"
13
14
  require "fixtury/dependency"
14
15
  require "fixtury/dependency_store"
@@ -38,6 +39,17 @@ module Fixtury
38
39
 
39
40
  DEFAULT_LOG_LEVEL = LOG_LEVEL_INFO
40
41
 
42
+ def self.configuration
43
+ @configuration ||= ::Fixtury::Configuration.new
44
+ yield @configuration if block_given?
45
+ @configuration
46
+ end
47
+
48
+ def self.configure(&block)
49
+ self.configuration(&block)
50
+ end
51
+
52
+
41
53
  # Shortcut for opening the top level schema.
42
54
  def self.define(&block)
43
55
  schema.define(&block)
@@ -61,24 +73,45 @@ module Fixtury
61
73
 
62
74
  # Default store for fixtures. This is a shared store that can be used across the application.
63
75
  def self.store
64
- @store ||= ::Fixtury::Store.new(schema: schema)
76
+ @store ||= ::Fixtury::Store.new
65
77
  end
66
78
 
67
79
  def self.store=(store)
68
80
  @store = store
69
81
  end
70
82
 
71
- def self.log_level
72
- return @log_level if @log_level
83
+ # Require each schema file to ensure that all definitions are loaded.
84
+ def self.load_all_schemas
85
+ configuration.fixture_files.each do |filepath|
86
+ require filepath
87
+ end
88
+ end
89
+
90
+ # Ensure all definitions are loaded and then load all known fixtures.
91
+ def self.load_all_fixtures
92
+ load_all_schemas
93
+ Fixtury.store.load_all
94
+ end
95
+
96
+ # Remove all references from the active store and reset the dependency file
97
+ def self.reset
98
+ log("resetting", level: LOG_LEVEL_INFO)
99
+
100
+ configuration.reset
101
+ store.reset
102
+ end
73
103
 
74
- @log_level = ENV["FIXTURY_LOG_LEVEL"]
75
- @log_level ||= DEFAULT_LOG_LEVEL
76
- @log_level = @log_level.to_s.to_sym
77
- @log_level
104
+ # Perform a reset if any of the tracked files have changed.
105
+ def self.reset_if_changed
106
+ if configuration.files_changed?
107
+ reset
108
+ else
109
+ log("no changes, skipping reset", level: LOG_LEVEL_INFO)
110
+ end
78
111
  end
79
112
 
80
113
  def self.log(text = nil, level: LOG_LEVEL_DEBUG, name: nil, newline: true)
81
- desired_level = LOG_LEVELS.fetch(log_level) { DEFAULT_LOG_LEVEL }
114
+ desired_level = LOG_LEVELS.fetch(configuration.log_level) { DEFAULT_LOG_LEVEL }
82
115
  return if desired_level == LOG_LEVEL_NONE
83
116
 
84
117
  message_level = LOG_LEVELS.fetch(level) { LOG_LEVEL_DEBUG }
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fixtury
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0.beta1
4
+ version: 1.0.0.beta2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mike Nelson
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2024-03-11 00:00:00.000000000 Z
11
+ date: 2024-03-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -154,6 +154,7 @@ files:
154
154
  - bin/setup
155
155
  - fixtury.gemspec
156
156
  - lib/fixtury.rb
157
+ - lib/fixtury/configuration.rb
157
158
  - lib/fixtury/definition.rb
158
159
  - lib/fixtury/definition_executor.rb
159
160
  - lib/fixtury/dependency.rb
@@ -162,7 +163,7 @@ files:
162
163
  - lib/fixtury/hooks.rb
163
164
  - lib/fixtury/locator.rb
164
165
  - lib/fixtury/locator_backend/common.rb
165
- - lib/fixtury/locator_backend/globalid.rb
166
+ - lib/fixtury/locator_backend/global_id.rb
166
167
  - lib/fixtury/locator_backend/memory.rb
167
168
  - lib/fixtury/mutation_observer.rb
168
169
  - lib/fixtury/path_resolver.rb
@@ -171,7 +172,6 @@ files:
171
172
  - lib/fixtury/schema.rb
172
173
  - lib/fixtury/schema_node.rb
173
174
  - lib/fixtury/store.rb
174
- - lib/fixtury/tasks.rake
175
175
  - lib/fixtury/test_hooks.rb
176
176
  - lib/fixtury/version.rb
177
177
  homepage: https://github.com/guideline-tech/fixtury
@@ -1,10 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- namespace :fixtury do
4
- task :setup
5
-
6
- desc "Clear fixtures from your cache. Accepts a pattern or fixture name such as foo/bar or /foo/*. Default pattern is /*"
7
- task :clear_cache, [:pattern] => :setup do |_t, args|
8
- ::Fixtury::Store.instance.clear_cache!(pattern: args[:pattern])
9
- end
10
- end