fixtury 0.4.1 → 1.0.0.beta2

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