fixtury 1.0.0.beta1 → 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.
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