fixtury 0.4.1 → 1.0.0.beta1

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.
@@ -1,251 +1,47 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "fixtury/definition"
4
- require "fixtury/path"
5
- require "fixtury/errors/already_defined_error"
6
- require "fixtury/errors/fixture_not_defined_error"
7
- require "fixtury/errors/schema_frozen_error"
8
- require "fixtury/errors/option_collision_error"
9
-
10
3
  module Fixtury
11
4
  class Schema
12
5
 
13
- attr_reader :definitions, :children, :name, :parent, :relative_name, :around_fixture_definition, :options
14
-
15
- def initialize(parent:, name:)
16
- @name = name
17
- @parent = parent
18
- @relative_name = @name.split("/").last
19
- @around_fixture_definition = nil
20
- @options = {}
21
- @frozen = false
22
- reset!
23
- end
24
-
25
- def merge_options(opts = {})
26
- opts.each_pair do |k, v|
27
- if options.key?(k) && options[k] != v
28
- raise ::Fixtury::Errors::OptionCollisionError.new(name, k, options[k], v)
29
- end
30
-
31
- options[k] = v
32
- end
33
- end
34
-
35
- def around_fixture(&block)
36
- @around_fixture_definition = block
37
- end
38
-
39
- def around_fixture_hook(executor, &definition)
40
- maybe_invoke_parent_around_fixture_hook(executor) do
41
- if around_fixture_definition.nil?
42
- yield
43
- else
44
- around_fixture_definition.call(executor, definition)
45
- end
46
- end
47
- end
48
-
49
- def maybe_invoke_parent_around_fixture_hook(executor, &block)
50
- return yield unless parent
51
-
52
- parent.around_fixture_hook(executor, &block)
53
- end
6
+ include ::Fixtury::SchemaNode
54
7
 
55
- def reset!
56
- @children = {}
57
- @definitions = {}
8
+ def initialize(name: "", **options)
9
+ super(name: name, **options)
58
10
  end
59
11
 
60
- def freeze!
61
- @frozen = true
62
- end
63
-
64
- def frozen?
65
- !!@frozen
66
- end
67
-
68
- def top_level_schema
69
- top_level_schema? ? self : parent.top_level_schema
70
- end
71
-
72
- def top_level_schema?
73
- parent.nil?
12
+ def acts_like_fixtury_schema?
13
+ true
74
14
  end
75
15
 
76
16
  def define(&block)
77
- ensure_not_frozen!
78
17
  instance_eval(&block)
79
18
  self
80
19
  end
81
20
 
82
- # helpful for inspection
83
- def structure(indent = "")
84
- out = []
85
- out << "#{indent}ns:#{relative_name}"
86
- definitions.keys.sort.each do |key|
87
- out << "#{indent} defn:#{key}"
88
- end
89
-
90
- children.keys.sort.each do |key|
91
- child = children[key]
92
- out << child.structure("#{indent} ")
93
- end
94
-
95
- out.join("\n")
96
- end
97
-
98
- def namespace(name, options = {}, &block)
99
- ensure_not_frozen!
100
- ensure_no_conflict!(name: name, definitions: true, namespaces: false)
101
-
102
- child = find_or_create_child_schema(name: name, options: options)
103
- child.instance_eval(&block) if block_given?
104
- child
105
- end
106
-
107
- def fixture(name, options = {}, &block)
108
- ensure_not_frozen!
109
- ensure_no_conflict!(name: name, definitions: true, namespaces: true)
110
- create_child_definition(name: name, options: options, &block)
111
- end
112
-
113
- def enhance(name, &block)
114
- ensure_not_frozen!
115
- definition = get_definition!(name)
116
- definition.enhance(&block)
117
- definition
118
- end
119
-
120
- def merge(other_ns)
121
- ensure_not_frozen!
122
- other_ns.definitions.each_pair do |name, dfn|
123
- fixture(name, dfn.options, &dfn.callable)
124
- dfn.enhancements.each do |e|
125
- enhance(name, &e)
126
- end
127
- end
128
-
129
- other_ns.children.each_pair do |name, other_ns_child|
130
- namespace(name, other_ns_child.options) do
131
- merge(other_ns_child)
132
- end
133
- end
134
-
135
- around_fixture(&other_ns.around_fixture_definition) if other_ns.around_fixture_definition
136
-
137
- self
138
- end
139
-
140
- def get_definition!(name)
141
- dfn = get_definition(name)
142
- raise ::Fixtury::Errors::FixtureNotDefinedError, name unless dfn
143
-
144
- dfn
145
- end
146
-
147
- def get_definition(name)
148
- path = ::Fixtury::Path.new(namespace: self.name, path: name)
149
- top_level = top_level_schema
150
-
151
- dfn = nil
152
- path.possible_absolute_paths.each do |abs_path|
153
- *namespaces, definition_name = abs_path.split("/")
154
-
155
- namespaces.shift if namespaces.first == top_level.name
156
- target = top_level
157
-
158
- namespaces.each do |ns|
159
- next if ns.empty?
160
-
161
- target = target.children[ns]
162
- break unless target
163
- end
164
-
165
- dfn = target.definitions[definition_name] if target
166
- return dfn if dfn
167
- end
168
-
169
- nil
21
+ def schema_node_type
22
+ first_ancestor? ? "schema" : "ns"
170
23
  end
171
24
 
172
- def get_namespace(name)
173
- path = ::Fixtury::Path.new(namespace: self.name, path: name)
174
- top_level = top_level_schema
25
+ def namespace(name, **options, &block)
26
+ child = get("./#{name}")
175
27
 
176
- path.possible_absolute_paths.each do |abs_path|
177
- *namespaces, _definition_name = abs_path.split("/")
178
-
179
- namespaces.shift if namespaces.first == top_level.name
180
- target = top_level
181
-
182
- namespaces.each do |ns|
183
- next if ns.empty?
184
-
185
- target = target.children[ns]
186
- break unless target
187
- end
188
-
189
- return target if target
28
+ if child && !child.acts_like?(:fixtury_schema)
29
+ raise Errors::AlreadyDefinedError, child.pathname
190
30
  end
191
31
 
192
- nil
193
- end
194
-
195
- protected
196
-
197
- def find_child_schema(name:)
198
- children[name.to_s]
199
- end
200
-
201
- def find_or_create_child_schema(name:, options:)
202
- name = name.to_s
203
- child = find_child_schema(name: name)
204
- child ||= begin
205
- children[name] = begin
206
- child_name = build_child_name(name: name)
207
- self.class.new(name: child_name, parent: self)
208
- end
209
- end
210
- child.merge_options(options)
32
+ child ||= self.class.new(name: name, parent: self)
33
+ child.apply_options!(options)
34
+ child.instance_eval(&block) if block_given?
211
35
  child
212
36
  end
213
37
 
214
- def find_child_definition(name:)
215
- definitions[name.to_s]
216
- end
217
-
218
- def create_child_definition(name:, options:, &block)
219
- child_name = build_child_name(name: name)
220
- definition = ::Fixtury::Definition.new(name: child_name, schema: self, options: options, &block)
221
- definitions[name.to_s] = definition
222
- end
223
-
224
- def build_child_name(name:)
225
- name = name&.to_s
226
- raise ArgumentError, "`name` must be provided" if name.nil?
227
- raise ArgumentError, "#{name} is invalid. `name` must contain only a-z, A-Z, 0-9, and _." unless /^[a-zA-Z_0-9]+$/.match?(name)
228
-
229
- arr = ["", self.name, name]
230
- arr.join("/").gsub(%r{/{2,}}, "/")
231
- end
232
-
233
- def ensure_no_conflict!(name:, namespaces:, definitions:)
234
- if definitions
235
- definition = find_child_definition(name: name)
236
- raise ::Fixtury::Errors::AlreadyDefinedError, definition.name if definition
237
- end
238
-
239
- if namespaces
240
- ns = find_child_schema(name: name)
241
- raise ::Fixtury::Errors::AlreadyDefinedError, ns.name if ns
242
- end
243
- end
244
-
245
- def ensure_not_frozen!
246
- return unless frozen?
247
-
248
- raise ::Fixtury::Errors::SchemaFrozenError
38
+ def fixture(name, **options, &block)
39
+ ::Fixtury::Definition.new(
40
+ name: name,
41
+ parent: self,
42
+ **options,
43
+ &block
44
+ )
249
45
  end
250
46
 
251
47
  end
@@ -0,0 +1,113 @@
1
+ module Fixtury
2
+ module SchemaNode
3
+
4
+ extend ActiveSupport::Concern
5
+
6
+ VALID_NODE_NAME = /^[a-zA-Z0-9_]*$/
7
+
8
+ included do
9
+ attr_reader :name, :pathname, :parent, :children, :options
10
+ end
11
+
12
+ def initialize(name:, parent: nil, **options)
13
+ name = name.to_s
14
+ raise ArgumentError, "#{name.inspect} is an invalid node name" unless name.match?(VALID_NODE_NAME)
15
+
16
+ @name = name
17
+ @parent = parent
18
+ @pathname = File.join(*[parent&.pathname, "/", @name].compact).to_s
19
+ @children = {}
20
+ @options = {}
21
+ apply_options!(options)
22
+ @parent.add_child(self) if @parent
23
+ end
24
+
25
+ def inspect
26
+ "#{self.class}(pathname: #{pathname.inspect}, children: #{children.size})"
27
+ end
28
+
29
+ def schema_node_type
30
+ raise NotImplementedError
31
+ end
32
+
33
+ def acts_like_fixtury_schema_node?
34
+ true
35
+ end
36
+
37
+ def first_ancestor
38
+ first_ancestor? ? self : parent.first_ancestor
39
+ end
40
+
41
+ def add_child(child)
42
+ if children.key?(child.name) && children[child.name] != child
43
+ raise Errors::AlreadyDefinedError, child.pathname
44
+ end
45
+
46
+ children[child.name] = child
47
+ end
48
+
49
+ def first_ancestor?
50
+ parent.nil?
51
+ end
52
+
53
+ def isolation_key(default: true)
54
+ from_parent = parent&.isolation_key(default: nil)
55
+ return from_parent if from_parent
56
+
57
+ value = options[:isolate] || default
58
+ value = (value == true ? pathname : value&.to_s).presence
59
+ value == "/" ? nil : value # special case to accommodate root nodes
60
+ end
61
+
62
+ def get!(search)
63
+ thing = get(search)
64
+ raise Errors::SchemaNodeNotDefinedError.new(pathname, search) unless thing
65
+
66
+ thing
67
+ end
68
+
69
+ def get(search)
70
+ raise ArgumentError, "`search` must be provided" if search.blank?
71
+
72
+ resolver = Fixtury::PathResolver.new(namespace: self.pathname, search: search)
73
+ resolver.possible_absolute_paths.each do |path|
74
+ target = first_ancestor
75
+ segments = path.split("/")
76
+ segments.reject!(&:blank?)
77
+ segments.shift if segments.first == target.name
78
+ segments.each do |segment|
79
+ target = target.children[segment]
80
+ break unless target
81
+ end
82
+
83
+ return target if target
84
+ end
85
+
86
+ nil
87
+ end
88
+ alias [] get
89
+
90
+ # helpful for inspection
91
+ def structure(prefix = "")
92
+ out = []
93
+ my_structure = +"#{prefix}#{schema_node_type}:#{name}"
94
+ my_structure << "(#{options.inspect})" if options.present?
95
+ out << my_structure
96
+ children.each_value do |child|
97
+ out << child.structure("#{prefix} ")
98
+ end
99
+ out.join("\n")
100
+ end
101
+
102
+ def apply_options!(opts = {})
103
+ opts.each do |key, value|
104
+ if options.key?(key) && options[key] != value
105
+ raise Errors::OptionCollisionError.new(name, key, options[key], value)
106
+ end
107
+
108
+ options[key] = value
109
+ end
110
+ end
111
+
112
+ end
113
+ end
data/lib/fixtury/store.rb CHANGED
@@ -3,33 +3,36 @@
3
3
  require "fileutils"
4
4
  require "singleton"
5
5
  require "yaml"
6
- require "fixtury/locator"
7
- require "fixtury/errors/circular_dependency_error"
8
- require "fixtury/reference"
9
6
 
10
7
  module Fixtury
11
8
  class Store
12
9
 
13
- cattr_accessor :instance
14
-
15
- attr_reader :filepath, :references, :ttl, :auto_refresh_expired
16
- attr_reader :schema, :locator
10
+ attr_reader :filepath
11
+ attr_reader :loaded_isolation_keys
12
+ attr_reader :locator
17
13
  attr_reader :log_level
14
+ attr_reader :references
15
+ attr_reader :schema
16
+ attr_reader :ttl
18
17
 
19
- def initialize(
20
- filepath: nil,
21
- locator: ::Fixtury::Locator.instance,
22
- ttl: nil,
23
- schema: nil,
24
- auto_refresh_expired: false
25
- )
18
+ def initialize(filepath: nil, locator: nil, ttl: nil, schema: nil)
26
19
  @schema = schema || ::Fixtury.schema
27
- @locator = locator
20
+ @locator = locator || ::Fixtury::Locator.new
28
21
  @filepath = filepath
29
- @references = @filepath && ::File.file?(@filepath) ? ::YAML.load_file(@filepath) : {}
30
- @ttl = ttl ? ttl.to_i : ttl
31
- @auto_refresh_expired = !!auto_refresh_expired
32
- self.class.instance ||= self
22
+ @references = load_reference_from_file || {}
23
+ @ttl = ttl&.to_i
24
+ @loaded_isolation_keys = {}
25
+ end
26
+
27
+ def inspect
28
+ parts = []
29
+ parts << "schema: #{schema.inspect}"
30
+ parts << "locator: #{locator.inspect}"
31
+ parts << "filepath: #{filepath.inspect}" if filepath
32
+ parts << "ttl: #{ttl.inspect}" if ttl
33
+ parts << "references: #{references.size}"
34
+
35
+ "#{self.class}(#{parts.join(", ")})"
33
36
  end
34
37
 
35
38
  def dump_to_file
@@ -37,36 +40,40 @@ module Fixtury
37
40
 
38
41
  ::FileUtils.mkdir_p(File.dirname(filepath))
39
42
 
40
- writable = references.each_with_object({}) do |(full_name, ref), h|
41
- h[full_name] = ref if ref.real?
43
+ writable = references.each_with_object({}) do |(pathname, ref), h|
44
+ h[pathname] = ref if ref.real?
42
45
  end
43
46
 
44
- ::File.open(filepath, "wb") { |io| io.write(writable.to_yaml) }
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)
45
55
  end
46
56
 
47
- def clear_expired_references!
57
+ def clear_stale_references!
48
58
  return unless ttl
49
59
 
50
60
  references.delete_if do |name, ref|
51
- is_expired = ref_invalid?(ref)
52
- log("expiring #{name}", level: LOG_LEVEL_DEBUG) if is_expired
53
- is_expired
61
+ stale = reference_stale?(ref)
62
+ log("expiring #{name}", level: LOG_LEVEL_DEBUG) if stale
63
+ stale
54
64
  end
55
65
  end
56
66
 
57
67
  def load_all(schema = self.schema)
58
- schema.definitions.each_pair do |_key, dfn|
59
- get(dfn.name)
60
- end
61
-
62
- schema.children.each_pair do |_key, ns|
63
- load_all(ns)
68
+ schema.children.each_value do |item|
69
+ get(item.name) if item.acts_like?(:fixtury_definition)
70
+ load_all(item) if item.acts_like?(:fixtury_schema)
64
71
  end
65
72
  end
66
73
 
67
74
  def clear_cache!(pattern: nil)
68
75
  pattern ||= "*"
69
- pattern = "/" + pattern unless pattern.start_with?("/")
76
+ pattern = "/#{pattern}" unless pattern.start_with?("/")
70
77
  glob = pattern.end_with?("*")
71
78
  pattern = pattern[0...-1] if glob
72
79
  references.delete_if do |key, _value|
@@ -86,73 +93,111 @@ module Fixtury
86
93
  end
87
94
 
88
95
  def loaded?(name)
89
- dfn = schema.get_definition!(name)
90
- full_name = dfn.name
91
- ref = references[full_name]
96
+ dfn = schema.get!(name)
97
+ ref = references[dfn.pathname]
92
98
  result = ref&.real?
93
- log(result ? "hit #{full_name}" : "miss #{full_name}", level: LOG_LEVEL_ALL)
99
+ log(result ? "hit #{dfn.pathname}" : "miss #{dfn.pathname}", level: LOG_LEVEL_ALL)
94
100
  result
95
101
  end
96
102
 
97
- def get(name, execution_context: nil)
98
- dfn = schema.get_definition!(name)
99
- full_name = dfn.name
100
- ref = references[full_name]
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]
101
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
+ # Fetch a fixture by name. This will load the fixture if it has not been loaded yet.
130
+ # 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)
133
+
134
+ # Find the definition.
135
+ dfn = schema.get!(name)
136
+ raise ArgumentError, "#{name.inspect} must refer to a definition" unless dfn.acts_like?(:fixtury_definition)
137
+
138
+ pathname = dfn.pathname
139
+
140
+ # Ensure that if we're part of an isolation group, we load all the fixtures in that group.
141
+ maybe_load_isolation_dependencies(dfn)
142
+
143
+ # See if we already hold a reference to the fixture.
144
+ ref = references[pathname]
145
+
146
+ # If the reference is a placeholder, we have a circular dependency.
102
147
  if ref&.holder?
103
- raise ::Fixtury::Errors::CircularDependencyError, full_name
148
+ raise Errors::CircularDependencyError, pathname
104
149
  end
105
150
 
106
- if ref && auto_refresh_expired && ref_invalid?(ref)
107
- log("refreshing #{full_name}", level: LOG_LEVEL_DEBUG)
108
- clear_ref(full_name)
151
+ # If the reference is stale, we should refresh it.
152
+ # We do so by clearing it from the store and setting the reference to nil.
153
+ if ref && reference_stale?(ref)
154
+ log("refreshing #{pathname}", level: LOG_LEVEL_DEBUG)
155
+ clear_reference(pathname)
109
156
  ref = nil
110
157
  end
111
158
 
112
159
  value = nil
113
160
 
114
161
  if ref
115
- log("hit #{full_name}", level: LOG_LEVEL_ALL)
116
- value = load_ref(ref.value)
162
+ log("hit #{pathname}", level: LOG_LEVEL_ALL)
163
+ value = locator.load(ref.locator_key)
117
164
  if value.nil?
118
- clear_ref(full_name)
119
- log("missing #{full_name}", level: LOG_LEVEL_ALL)
165
+ clear_reference(pathname)
166
+ ref = nil
167
+ log("missing #{pathname}", level: LOG_LEVEL_ALL)
120
168
  end
121
169
  end
122
170
 
123
171
  if value.nil?
124
172
  # 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
125
- references[full_name] = ::Fixtury::Reference.holder(full_name)
126
-
127
- value = dfn.call(store: self, execution_context: execution_context)
173
+ references[pathname] = ::Fixtury::Reference.holder(pathname)
174
+
175
+ begin
176
+ executor = ::Fixtury::DefinitionExecutor.new(store: self, definition: dfn)
177
+ value = executor.call
178
+ rescue StandardError
179
+ clear_reference(pathname)
180
+ raise
181
+ end
128
182
 
129
- log("store #{full_name}", level: LOG_LEVEL_DEBUG)
183
+ log("store #{pathname}", level: LOG_LEVEL_DEBUG)
130
184
 
131
- ref = dump_ref(full_name, value)
132
- ref = ::Fixtury::Reference.new(full_name, ref)
133
- references[full_name] = ref
185
+ locator_key = locator.dump(value, context: pathname)
186
+ references[pathname] = ::Fixtury::Reference.new(pathname, locator_key)
134
187
  end
135
188
 
136
189
  value
137
190
  end
138
191
  alias [] get
139
192
 
140
- def load_ref(ref)
141
- locator.load(ref)
142
- end
143
-
144
- def dump_ref(_name, value)
145
- locator.dump(value)
146
- end
147
-
148
- def clear_ref(name)
149
- references.delete(name)
193
+ def clear_reference(pathname)
194
+ references.delete(pathname)
150
195
  end
151
196
 
152
- def ref_invalid?(ref)
197
+ def reference_stale?(ref)
153
198
  return true if ttl && ref.created_at < (Time.now.to_i - ttl)
154
199
 
155
- !locator.recognize?(ref.value)
200
+ !locator.recognizable_key?(ref.locator_key)
156
201
  end
157
202
 
158
203
  def log(msg, level:)