fixtury 0.4.1 → 1.0.0.beta2

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,73 @@
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
4
+ # A Fixtury::SchemaNode implementation that represents a top-level schema
5
+ # or a namespace within a schema.
11
6
  class Schema
12
7
 
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
8
+ include ::Fixtury::SchemaNode
54
9
 
55
- def reset!
56
- @children = {}
57
- @definitions = {}
10
+ # @param name [String] the name of the schema, defaults to "" to represent
11
+ # a new top-level schema.
12
+ def initialize(name: "", **options)
13
+ super(name: name, **options)
58
14
  end
59
15
 
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?
16
+ # Object#acts_like? adherence.
17
+ def acts_like_fixtury_schema?
18
+ true
74
19
  end
75
20
 
21
+ # Open up self for child definitions.
22
+ # @param block [Proc] The block to be executed in the context of the schema.
23
+ # @return [Fixtury::Schema] self
76
24
  def define(&block)
77
- ensure_not_frozen!
78
25
  instance_eval(&block)
79
26
  self
80
27
  end
81
28
 
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
29
+ # Returns "schema" if top-level, otherwise returns "namespace".
30
+ # @return [String] the type of the schema node.
31
+ def schema_node_type
32
+ first_ancestor? ? "schema" : "namespace"
170
33
  end
171
34
 
172
- def get_namespace(name)
173
- path = ::Fixtury::Path.new(namespace: self.name, path: name)
174
- top_level = top_level_schema
35
+ # Create a child schema at the given relative name. If a child by the name
36
+ # already exists it will be reopened as long as it's a fixtury schema.
37
+ #
38
+ # @param relative_name [String] The relative name of the child schema.
39
+ # @param options [Hash] Additional options for the child schema, applied after instantiation.
40
+ # @param block [Proc] A block of code to be executed in the context of the child schema.
41
+ # @return [Fixtury::Schema] The child schema.
42
+ # @raise [Fixtury::Errors::AlreadyDefinedError] if the child is already defined and not a fixtury schema.
43
+ def namespace(relative_name, **options, &block)
44
+ child = get("./#{relative_name}")
175
45
 
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
46
+ if child && !child.acts_like?(:fixtury_schema)
47
+ raise Errors::AlreadyDefinedError, child.pathname
190
48
  end
191
49
 
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)
50
+ child ||= self.class.new(name: relative_name, parent: self)
51
+ child.apply_options!(options)
52
+ child.instance_eval(&block) if block_given?
211
53
  child
212
54
  end
213
55
 
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
56
+ # Create a fixture definition at the given relative name. If the name is already
57
+ # used, a Fixtury::Errors::AlreadyDefinedError will be raised.
58
+ #
59
+ # @param relative_name [String] The relative name of the fixture.
60
+ # @param options [Hash] Additional options for the fixture.
61
+ # @param block [Proc] The block representing the build function of the fixture.
62
+ # @return [Fixtury::Definition] The fixture definition.
63
+ #
64
+ def fixture(relative_name, **options, &block)
65
+ ::Fixtury::Definition.new(
66
+ name: relative_name,
67
+ parent: self,
68
+ **options,
69
+ &block
70
+ )
249
71
  end
250
72
 
251
73
  end
@@ -0,0 +1,175 @@
1
+ module Fixtury
2
+ # This module is used to provide a common interface for all nodes in the schema tree.
3
+ # Namespaces and fixture definitions adhere to this interface and are provided with
4
+ # common behaviors for registration, traversal, inspection, etc
5
+ module SchemaNode
6
+
7
+ extend ActiveSupport::Concern
8
+
9
+ VALID_NODE_NAME = /^[a-zA-Z0-9_]*$/
10
+
11
+ included do
12
+ attr_reader :name, :pathname, :parent, :first_ancestor, :children, :options
13
+ end
14
+
15
+ # Constructs a new SchemaNode object.
16
+ #
17
+ # @param name [String] The relative name of the node.
18
+ # @param parent [Object] The parent node of the node.
19
+ # @param options [Hash] Additional options for the node.
20
+ # @return [Fixtury::SchemaNode] The new SchemaNode object.
21
+ # @raise [ArgumentError] if the name does not match the VALID_NODE_NAME regex.
22
+ def initialize(name:, parent: nil, **options)
23
+ name = name.to_s
24
+ raise ArgumentError, "#{name.inspect} is an invalid node name" unless name.match?(VALID_NODE_NAME)
25
+
26
+ @name = name
27
+ @parent = parent
28
+ @pathname = File.join(*[parent&.pathname, "/", @name].compact).to_s
29
+ @children = {}
30
+ @options = {}
31
+ apply_options!(options)
32
+ @first_ancestor = @parent&.first_ancestor || self
33
+ @parent&.add_child(self)
34
+ end
35
+
36
+ # Inspect the SchemaNode object without representing the parent or children to avoid
37
+ # large prints.
38
+ #
39
+ # @return [String] The inspection string.
40
+ def inspect
41
+ "#{self.class}(pathname: #{pathname.inspect}, children: #{children.size})"
42
+ end
43
+
44
+ # An identifier used during the printing of the tree structure.
45
+ #
46
+ # @return [String] The demodularized class name.
47
+ def schema_node_type
48
+ self.class.name.demodulize.underscore
49
+ end
50
+
51
+ # Adherance to the acts_like? interface
52
+ def acts_like_fixtury_schema_node?
53
+ true
54
+ end
55
+
56
+ # Adds child to the node's children hash as long as another is not already defined.
57
+ #
58
+ # @param child [Fixtury::SchemaNode] The child node to add.
59
+ # @raise [Fixtury::Errors::AlreadyDefinedError] if the child is already defined and not the provided child.
60
+ # @return [Fixtury::Errors::AlreadyDefinedError] the child that was suscessfully added
61
+ def add_child(child)
62
+ if children.key?(child.name) && children[child.name] != child
63
+ raise Errors::AlreadyDefinedError, child.pathname
64
+ end
65
+
66
+ children[child.name] = child
67
+ end
68
+
69
+ # Is the current node the first ancestor?
70
+ #
71
+ # @return [TrueClass, FalseClass] `true` if the node is the first ancestor, `false` otherwise.
72
+ def first_ancestor?
73
+ parent.nil?
74
+ end
75
+
76
+ # Determines the isolation key in a top-down manner. It first accepts an isolation key
77
+ # set by the parent, then it checks for an isolation key set by the node itself. If no
78
+ # isolation key is found, it defaults to the node's name unless default is set to falsy.
79
+ #
80
+ # @param default [TrueClass, FalseClass, String] if no isolation key is present, what should the default value be?
81
+ # @option default [true] The default value is the node's name.
82
+ # @option default [String] The default value is a custom string.
83
+ # @option default [false, nil, ""] No isolation key should be represented
84
+ # @return [String, NilClass] The isolation key.
85
+ def isolation_key(default: true)
86
+ from_parent = parent&.isolation_key(default: nil)
87
+ return from_parent if from_parent
88
+
89
+ value = options[:isolate] || default
90
+ value = (value == true ? pathname : value&.to_s).presence
91
+ value = (value == "/" ? nil : value) # special case to accommodate root nodes
92
+ value.presence
93
+ end
94
+
95
+ # Performs get() but raises if the result is nil.
96
+ # @raise [Fixtury::Errors::SchemaNodeNotDefinedError] if the search does not return a node.
97
+ # (see #get)
98
+ def get!(search)
99
+ thing = get(search)
100
+ raise Errors::SchemaNodeNotDefinedError.new(pathname, search) unless thing
101
+
102
+ thing
103
+ end
104
+
105
+ # Retrieves a node in the tree relative to self. Absolute and relative searches are
106
+ # accepted. The potential absolute paths are determined by a Fixtury::PathResolver instance
107
+ # relative to this node's pathname.
108
+ #
109
+ # @param search [String] The search to be used for finding the node.
110
+ # @return [Fixtury::SchemaNode, NilClass] The node if found, `nil` otherwise.
111
+ # @raise [ArgumentError] if the search is blank.
112
+ # @alias []
113
+ def get(search)
114
+ raise ArgumentError, "`search` must be provided" if search.blank?
115
+
116
+ resolver = Fixtury::PathResolver.new(namespace: self.pathname, search: search)
117
+ resolver.possible_absolute_paths.each do |path|
118
+ target = first_ancestor
119
+ segments = path.split("/")
120
+ segments.reject!(&:blank?)
121
+ segments.shift if segments.first == target.name
122
+ segments.each do |segment|
123
+ target = target.children[segment]
124
+ break unless target
125
+ end
126
+
127
+ return target if target
128
+ end
129
+
130
+ nil
131
+ end
132
+ alias [] get
133
+
134
+ # Generates a string representing the structure of the schema tree.
135
+ # The string will be in the form of "type:name[isolation_key](options)". The children
136
+ # will be on the next line and indented by two spaces.
137
+ #
138
+ # @param prefix [String] The prefix to be used for any lines produced.
139
+ # @return [String] The structure string.
140
+ def structure(prefix = "")
141
+ out = []
142
+
143
+ opts = options.except(:isolate)
144
+ opts.compact!
145
+
146
+ my_structure = +"#{prefix}#{schema_node_type}:#{name}"
147
+ iso = isolation_key(default: nil)
148
+ my_structure << "[#{iso}]" if iso
149
+ my_structure << "(#{opts.to_a.map { |k, v| "#{k}: #{v.inspect}" }.join(", ")})" if opts.present?
150
+ out << my_structure
151
+
152
+ children.each_value do |child|
153
+ out << child.structure("#{prefix} ")
154
+ end
155
+ out.join("\n")
156
+ end
157
+
158
+ # Applies options to the node and raises if a collision occurs.
159
+ # This is useful for reopening a node and ensuring options are not altered.
160
+ #
161
+ # @param opts [Hash] The options to apply to the node.
162
+ # @raise [Fixtury::Errors::OptionCollisionError] if the option is already set and the new value is different.
163
+ # @return [void]
164
+ def apply_options!(opts = {})
165
+ opts.each do |key, value|
166
+ if options.key?(key) && options[key] != value
167
+ raise Errors::OptionCollisionError.new(name, key, options[key], value)
168
+ end
169
+
170
+ options[key] = value
171
+ end
172
+ end
173
+
174
+ end
175
+ end