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