lib/fixtury/store.rb in fixtury-1.0.0.beta1 vs lib/fixtury/store.rb in fixtury-1.0.0.beta2
- old
+ new
@@ -1,146 +1,136 @@
# frozen_string_literal: true
+require "concurrent/atomic/thread_local_var"
require "fileutils"
require "singleton"
require "yaml"
module Fixtury
+ # A store is a container for built fixture references. It is responsible for loading and caching fixtures
+ # based on a schema and a locator.
class Store
- attr_reader :filepath
- attr_reader :loaded_isolation_keys
attr_reader :locator
- attr_reader :log_level
- attr_reader :references
attr_reader :schema
attr_reader :ttl
- def initialize(filepath: nil, locator: nil, ttl: nil, schema: nil)
+ # Create a new store.
+ # @param locator [Fixtury::Locator, Symbol, NilClass] (see Fixtury::Locator#from)
+ # @param ttl [Integer, NilClass] The time-to-live for references in seconds.
+ # @param schema [Fixtury::Schema, NilClass] The schema to use for fixture definitions, defaults to the global schema.
+ # @return [Fixtury::Store]
+ def initialize(locator: nil, ttl: nil, schema: nil)
@schema = schema || ::Fixtury.schema
- @locator = locator || ::Fixtury::Locator.new
- @filepath = filepath
- @references = load_reference_from_file || {}
+ @locator = ::Fixtury::Locator.from(locator)
@ttl = ttl&.to_i
- @loaded_isolation_keys = {}
+ self.references = ::Fixtury.configuration.stored_references
end
+ def references
+ @references ||= ::Concurrent::ThreadLocalVar.new({})
+ @references.value
+ end
+
+ def references=(value)
+ references.clear
+ @references.value = value
+ end
+
+ def loaded_isolation_keys
+ @loaded_isolation_keys ||= ::Concurrent::ThreadLocalVar.new({})
+ @loaded_isolation_keys.value
+ end
+
+ # Empty the store of any references and loaded isolation keys.
+ def reset
+ references.clear
+ loaded_isolation_keys.clear
+ end
+
+ # Summarize the current state of the store.
+ #
+ # @return [String]
def inspect
parts = []
parts << "schema: #{schema.inspect}"
parts << "locator: #{locator.inspect}"
- parts << "filepath: #{filepath.inspect}" if filepath
parts << "ttl: #{ttl.inspect}" if ttl
parts << "references: #{references.size}"
"#{self.class}(#{parts.join(", ")})"
end
- def dump_to_file
- return unless filepath
-
- ::FileUtils.mkdir_p(File.dirname(filepath))
-
- writable = references.each_with_object({}) do |(pathname, ref), h|
- h[pathname] = ref if ref.real?
- end
-
- ::File.binwrite(filepath, writable.to_yaml)
- end
-
- def load_reference_from_file
- return unless filepath
- return unless File.file?(filepath)
-
- ::YAML.unsafe_load_file(filepath)
- end
-
+ # Clear any references that are beyond their ttl or are no longer recognizable by the locator.
+ #
+ # @return [void]
def clear_stale_references!
- return unless ttl
-
references.delete_if do |name, ref|
stale = reference_stale?(ref)
log("expiring #{name}", level: LOG_LEVEL_DEBUG) if stale
stale
end
end
+ # Load all fixtures in the target schema, defaulting to the store's schema.
+ # This will load all fixtures in the schema and any child schemas.
+ #
+ # @param schema [Fixtury::Schema] The schema to load, defaults to the store's schema.
+ # @return [void]
def load_all(schema = self.schema)
schema.children.each_value do |item|
get(item.name) if item.acts_like?(:fixtury_definition)
load_all(item) if item.acts_like?(:fixtury_schema)
end
end
- def clear_cache!(pattern: nil)
- pattern ||= "*"
- pattern = "/#{pattern}" unless pattern.start_with?("/")
- glob = pattern.end_with?("*")
- pattern = pattern[0...-1] if glob
- references.delete_if do |key, _value|
- hit = glob ? key.start_with?(pattern) : key == pattern
- log("clearing #{key}", level: LOG_LEVEL_DEBUG) if hit
- hit
- end
- dump_to_file
- end
-
+ # Temporarily set a contextual schema to use for loading fixtures. This is
+ # useful when evaluating dependencies of a definition while still storing the results.
+ #
+ # @param schema [Fixtury::Schema] The schema to use.
+ # @yield [void] The block to execute with the given schema.
+ # @return [Object] The result of the block
def with_relative_schema(schema)
prior = @schema
@schema = schema
yield
ensure
@schema = prior
end
- def loaded?(name)
- dfn = schema.get!(name)
+ # Is a fixture for the given search already loaded?
+ #
+ # @param search [String] The name of the fixture to search for.
+ # @return [TrueClass, FalseClass] `true` if the fixture is loaded, `false` otherwise.
+ def loaded?(search)
+ dfn = schema.get!(search)
ref = references[dfn.pathname]
result = ref&.real?
log(result ? "hit #{dfn.pathname}" : "miss #{dfn.pathname}", level: LOG_LEVEL_ALL)
result
end
- def loaded_or_loading?(pathname)
- !!references[pathname]
- end
-
- def maybe_load_isolation_dependencies(definition)
- isolation_key = definition.isolation_key
- return if loaded_isolation_keys[isolation_key]
-
- load_isolation_dependencies(isolation_key, schema.first_ancestor)
- end
-
- def load_isolation_dependencies(isolation_key, target_schema)
- loaded_isolation_keys[isolation_key] = true
- target_schema.children.each_value do |child|
- if child.acts_like?(:fixtury_definition)
- next unless child.isolation_key == isolation_key
- next if loaded_or_loading?(child.pathname)
- get(child.pathname)
- elsif child.acts_like?(:fixtury_schema)
- load_isolation_dependencies(isolation_key, child)
- else
- raise NotImplementedError, "Unknown isolation loading behavior: #{child.class.name}"
- end
- end
- end
-
# Fetch a fixture by name. This will load the fixture if it has not been loaded yet.
# If a definition contains an isolation key, all fixtures with the same isolation key will be loaded.
- def get(name)
- log("getting #{name}", level: LOG_LEVEL_DEBUG)
+ #
+ # @param search [String] The name of the fixture to search for.
+ # @return [Object] The loaded fixture.
+ # @raise [Fixtury::Errors::CircularDependencyError] if a circular dependency is detected.
+ # @raise [Fixtury::Errors::SchemaNodeNotDefinedError] if the search does not return a node.
+ # @raise [Fixtury::Errors::UnknownDefinitionError] if the search does not return a definition.
+ # @raise [Fixtury::Errors::DefinitionExecutorError] if the definition executor fails.
+ def get(search)
+ log("getting #{search} relative to #{schema.pathname}", level: LOG_LEVEL_DEBUG)
# Find the definition.
- dfn = schema.get!(name)
- raise ArgumentError, "#{name.inspect} must refer to a definition" unless dfn.acts_like?(:fixtury_definition)
+ dfn = schema.get!(search)
+ raise ArgumentError, "#{search.inspect} must refer to a definition" unless dfn.acts_like?(:fixtury_definition)
pathname = dfn.pathname
# Ensure that if we're part of an isolation group, we load all the fixtures in that group.
- maybe_load_isolation_dependencies(dfn)
+ maybe_load_isolation_dependencies(dfn.isolation_key)
# See if we already hold a reference to the fixture.
ref = references[pathname]
# If the reference is a placeholder, we have a circular dependency.
@@ -188,19 +178,68 @@
value
end
alias [] get
+ protected
+
+ # Determine if the given pathname is already loaded or is currently being loaded.
+ #
+ # @param pathname [String] The pathname to check.
+ # @return [TrueClass, FalseClass] `true` if the pathname is already loaded or is currently being loaded, `false` otherwise.
+ def loaded_or_loading?(pathname)
+ !!references[pathname]
+ end
+
+ # Load all fixtures with the given isolation key in the target schema
+ # if we're not already attempting to load them.
+ def maybe_load_isolation_dependencies(isolation_key)
+ return if loaded_isolation_keys[isolation_key]
+ loaded_isolation_keys[isolation_key] = true
+
+ load_isolation_dependencies(isolation_key, schema.first_ancestor)
+ end
+
+ # Load all fixtures with the given isolation key in the target schema.
+ #
+ # @param isolation_key [String] The isolation key to load fixtures for.
+ # @param target_schema [Fixtury::Schema] The schema to search within.
+ # @return [void]
+ def load_isolation_dependencies(isolation_key, target_schema)
+ target_schema.children.each_value do |child|
+ if child.acts_like?(:fixtury_definition)
+ next unless child.isolation_key == isolation_key
+ next if loaded_or_loading?(child.pathname)
+
+ get(child.pathname)
+ elsif child.acts_like?(:fixtury_schema)
+ load_isolation_dependencies(isolation_key, child)
+ else
+ raise NotImplementedError, "Unknown isolation loading behavior: #{child.class.name}"
+ end
+ end
+ end
+
+ # Remove a reference at the given pathname from the stored references.
+ #
+ # @param pathname [String] The pathname to remove.
+ # @return [void]
def clear_reference(pathname)
references.delete(pathname)
end
+ # Determine if a reference is stale. A reference is stale if it is beyond its ttl or
+ # if it is no longer recognizable by the locator.
+ #
+ # @param ref [Fixtury::Reference] The reference to check.
+ # @return [TrueClass, FalseClass] `true` if the reference is stale, `false` otherwise.
def reference_stale?(ref)
return true if ttl && ref.created_at < (Time.now.to_i - ttl)
!locator.recognizable_key?(ref.locator_key)
end
+ # Log a contextual message using Fixtury.log
def log(msg, level:)
::Fixtury.log(msg, level: level, name: "store")
end
end