fixtury 0.4.1 → 1.0.0.beta1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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:)