lib/fixtury/store.rb in fixtury-0.4.1 vs lib/fixtury/store.rb in fixtury-1.0.0.beta1
- old
+ new
@@ -1,74 +1,81 @@
# frozen_string_literal: true
require "fileutils"
require "singleton"
require "yaml"
-require "fixtury/locator"
-require "fixtury/errors/circular_dependency_error"
-require "fixtury/reference"
module Fixtury
class Store
- cattr_accessor :instance
-
- attr_reader :filepath, :references, :ttl, :auto_refresh_expired
- attr_reader :schema, :locator
+ 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: ::Fixtury::Locator.instance,
- ttl: nil,
- schema: nil,
- auto_refresh_expired: false
- )
+ def initialize(filepath: nil, locator: nil, ttl: nil, schema: nil)
@schema = schema || ::Fixtury.schema
- @locator = locator
+ @locator = locator || ::Fixtury::Locator.new
@filepath = filepath
- @references = @filepath && ::File.file?(@filepath) ? ::YAML.load_file(@filepath) : {}
- @ttl = ttl ? ttl.to_i : ttl
- @auto_refresh_expired = !!auto_refresh_expired
- self.class.instance ||= self
+ @references = load_reference_from_file || {}
+ @ttl = ttl&.to_i
+ @loaded_isolation_keys = {}
end
+ 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 |(full_name, ref), h|
- h[full_name] = ref if ref.real?
+ writable = references.each_with_object({}) do |(pathname, ref), h|
+ h[pathname] = ref if ref.real?
end
- ::File.open(filepath, "wb") { |io| io.write(writable.to_yaml) }
+ ::File.binwrite(filepath, writable.to_yaml)
end
- def clear_expired_references!
+ def load_reference_from_file
+ return unless filepath
+ return unless File.file?(filepath)
+
+ ::YAML.unsafe_load_file(filepath)
+ end
+
+ def clear_stale_references!
return unless ttl
references.delete_if do |name, ref|
- is_expired = ref_invalid?(ref)
- log("expiring #{name}", level: LOG_LEVEL_DEBUG) if is_expired
- is_expired
+ stale = reference_stale?(ref)
+ log("expiring #{name}", level: LOG_LEVEL_DEBUG) if stale
+ stale
end
end
def load_all(schema = self.schema)
- schema.definitions.each_pair do |_key, dfn|
- get(dfn.name)
+ 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
-
- schema.children.each_pair do |_key, ns|
- load_all(ns)
- end
end
def clear_cache!(pattern: nil)
pattern ||= "*"
- pattern = "/" + pattern unless pattern.start_with?("/")
+ 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
@@ -84,76 +91,114 @@
ensure
@schema = prior
end
def loaded?(name)
- dfn = schema.get_definition!(name)
- full_name = dfn.name
- ref = references[full_name]
+ dfn = schema.get!(name)
+ ref = references[dfn.pathname]
result = ref&.real?
- log(result ? "hit #{full_name}" : "miss #{full_name}", level: LOG_LEVEL_ALL)
+ log(result ? "hit #{dfn.pathname}" : "miss #{dfn.pathname}", level: LOG_LEVEL_ALL)
result
end
- def get(name, execution_context: nil)
- dfn = schema.get_definition!(name)
- full_name = dfn.name
- ref = references[full_name]
+ 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)
+
+ # Find the definition.
+ dfn = schema.get!(name)
+ raise ArgumentError, "#{name.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)
+
+ # See if we already hold a reference to the fixture.
+ ref = references[pathname]
+
+ # If the reference is a placeholder, we have a circular dependency.
if ref&.holder?
- raise ::Fixtury::Errors::CircularDependencyError, full_name
+ raise Errors::CircularDependencyError, pathname
end
- if ref && auto_refresh_expired && ref_invalid?(ref)
- log("refreshing #{full_name}", level: LOG_LEVEL_DEBUG)
- clear_ref(full_name)
+ # If the reference is stale, we should refresh it.
+ # We do so by clearing it from the store and setting the reference to nil.
+ if ref && reference_stale?(ref)
+ log("refreshing #{pathname}", level: LOG_LEVEL_DEBUG)
+ clear_reference(pathname)
ref = nil
end
value = nil
if ref
- log("hit #{full_name}", level: LOG_LEVEL_ALL)
- value = load_ref(ref.value)
+ log("hit #{pathname}", level: LOG_LEVEL_ALL)
+ value = locator.load(ref.locator_key)
if value.nil?
- clear_ref(full_name)
- log("missing #{full_name}", level: LOG_LEVEL_ALL)
+ clear_reference(pathname)
+ ref = nil
+ log("missing #{pathname}", level: LOG_LEVEL_ALL)
end
end
if value.nil?
# set the references to a holder value so any recursive behavior ends up hitting a circular dependency error if the same fixture load is attempted
- references[full_name] = ::Fixtury::Reference.holder(full_name)
+ references[pathname] = ::Fixtury::Reference.holder(pathname)
- value = dfn.call(store: self, execution_context: execution_context)
+ begin
+ executor = ::Fixtury::DefinitionExecutor.new(store: self, definition: dfn)
+ value = executor.call
+ rescue StandardError
+ clear_reference(pathname)
+ raise
+ end
- log("store #{full_name}", level: LOG_LEVEL_DEBUG)
+ log("store #{pathname}", level: LOG_LEVEL_DEBUG)
- ref = dump_ref(full_name, value)
- ref = ::Fixtury::Reference.new(full_name, ref)
- references[full_name] = ref
+ locator_key = locator.dump(value, context: pathname)
+ references[pathname] = ::Fixtury::Reference.new(pathname, locator_key)
end
value
end
alias [] get
- def load_ref(ref)
- locator.load(ref)
+ def clear_reference(pathname)
+ references.delete(pathname)
end
- def dump_ref(_name, value)
- locator.dump(value)
- end
-
- def clear_ref(name)
- references.delete(name)
- end
-
- def ref_invalid?(ref)
+ def reference_stale?(ref)
return true if ttl && ref.created_at < (Time.now.to_i - ttl)
- !locator.recognize?(ref.value)
+ !locator.recognizable_key?(ref.locator_key)
end
def log(msg, level:)
::Fixtury.log(msg, level: level, name: "store")
end