ruby_routes 2.0.0 → 2.2.0

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4017abab11dd0945bcfe1659b2ed1456c2cf82a8993db55d094d283c48c8ebd0
4
- data.tar.gz: 07f0aba728bf81cdfdc245e20a212f1775b2314a826c88f0e25b8ff2b608c195
3
+ metadata.gz: 66764348265233b0904630e4193e21fc93f79e3257ec4d0e43e5c533faf1f7e1
4
+ data.tar.gz: de32cc9f5f42398b12e829c4da34aa82afd071fa63ed869180a56203834beecd
5
5
  SHA512:
6
- metadata.gz: a4aa77ba441b9e87cdcb31cae5b5f12babaf93396e400723d5f845392ac132520c4398e19d49d0755e05fc305791b54a3676a460ba8c73cd4b5ee55647fb2589
7
- data.tar.gz: a3b883c253731dfad5eaf34a28fb89684d0f9fa921127f6e428225743a93d87934508e49f59c3710964cc8a444b729f9278872aa8d991c4302abbb7ff8645b66
6
+ metadata.gz: 21442f1a73e4045fedbb9bd215c639770c55135f6d55c78c17384996515d1347851d7b3afa952db345793a5aa92d5c14368d28faf0e35d4c985d608d94c8b980
7
+ data.tar.gz: 5570f4054ceaf76569bb4f28eca4d94347a559a24febe1936e39dfb9896ccfa281cdd1e2ce24e8e2b157b3d7f3870a67cc9ecdb61b62338bce7e28b5d0c32e4e
@@ -1,3 +1,4 @@
1
+ require_relative 'segments/base_segment'
1
2
  require_relative 'segments/dynamic_segment'
2
3
  require_relative 'segments/static_segment'
3
4
  require_relative 'segments/wildcard_segment'
@@ -1,65 +1,64 @@
1
1
  require_relative 'segment'
2
2
 
3
3
  module RubyRoutes
4
+ # Node represents a single node in the radix tree structure.
5
+ # Each node can have static children (exact matches), one dynamic child (parameter capture),
6
+ # and one wildcard child (consumes remaining path segments).
4
7
  class Node
5
- attr_accessor :static_children, :dynamic_child, :wildcard_child,
6
- :handlers, :param_name, :is_endpoint
8
+ attr_accessor :param_name, :is_endpoint, :dynamic_child, :wildcard_child
9
+ attr_reader :handlers, :static_children
7
10
 
8
11
  def initialize
12
+ @is_endpoint = false
13
+ @handlers = {}
9
14
  @static_children = {}
10
15
  @dynamic_child = nil
11
16
  @wildcard_child = nil
12
- @handlers = {}
13
17
  @param_name = nil
14
- @is_endpoint = false
15
18
  end
16
19
 
17
- # Fast traversal: minimal allocations, streamlined branching
18
- # Returns [next_node_or_nil, should_break_bool] or [nil, false] if no match.
19
- def traverse_for(segment, index, segments, params)
20
- # Static match: O(1) hash lookup
21
- child = @static_children[segment]
22
- return [child, false] if child
20
+ def add_handler(method, handler)
21
+ method_str = normalize_method(method)
22
+ @handlers[method_str] = handler
23
+ @is_endpoint = true
24
+ end
23
25
 
24
- # Dynamic match: single segment capture
25
- if (dyn = @dynamic_child)
26
- params[dyn.param_name] = segment if params
27
- return [dyn, false]
28
- end
26
+ def get_handler(method)
27
+ @handlers[method]
28
+ end
29
29
 
30
- # Wildcard match: consume remainder (last resort)
31
- if (wild = @wildcard_child)
32
- if params
33
- # Build remainder path without intermediate array allocation
34
- remainder = segments[index..-1]
35
- params[wild.param_name] = remainder.size == 1 ? remainder[0] : remainder.join('/')
30
+ # Fast traversal method with minimal allocations and streamlined branching.
31
+ # Matching order: static (most specific) → dynamic → wildcard (least specific)
32
+ # Returns [next_node_or_nil, should_break_bool] where should_break indicates
33
+ # wildcard capture that consumes remaining path segments.
34
+ def traverse_for(segment, index, segments, params)
35
+ # Try static child first (most specific) - O(1) hash lookup
36
+ if @static_children.key?(segment)
37
+ return [@static_children[segment], false]
38
+ # Try dynamic child (parameter capture) - less specific than static
39
+ elsif @dynamic_child
40
+ # Capture parameter if params hash provided and param_name is set
41
+ params[@dynamic_child.param_name] = segment if params && @dynamic_child.param_name
42
+ return [@dynamic_child, false]
43
+ # Try wildcard child (consumes remaining segments) - least specific
44
+ elsif @wildcard_child
45
+ # Capture remaining path segments for wildcard parameter
46
+ if params && @wildcard_child.param_name
47
+ remaining = segments[index..-1]
48
+ params[@wildcard_child.param_name] = remaining.join('/')
36
49
  end
37
- return [wild, true]
50
+ return [@wildcard_child, true] # true signals to stop traversal
38
51
  end
39
52
 
40
- # No match
53
+ # No match found at this node
41
54
  [nil, false]
42
55
  end
43
56
 
44
- # Pre-cache param names as strings to avoid repeated .to_s calls
45
- def param_name
46
- @param_name_str ||= @param_name&.to_s
47
- end
48
-
49
- def param_name=(name)
50
- @param_name = name
51
- @param_name_str = nil # invalidate cache
52
- end
53
-
54
- # Normalize method once and cache string keys
55
- def add_handler(method, handler)
56
- method_key = method.to_s.upcase
57
- @handlers[method_key] = handler
58
- @is_endpoint = true
59
- end
57
+ private
60
58
 
61
- def get_handler(method)
62
- @handlers[method] # assume already normalized upstream
59
+ # Fast method normalization - converts method to uppercase string
60
+ def normalize_method(method)
61
+ method.to_s.upcase
63
62
  end
64
63
  end
65
64
  end
@@ -1,7 +1,13 @@
1
1
  require_relative 'segment'
2
+ require_relative 'utility/path_utility'
2
3
 
3
4
  module RubyRoutes
5
+ # RadixTree provides an optimized tree structure for fast route matching.
6
+ # Supports static segments, dynamic parameters (:param), and wildcards (*splat).
7
+ # Features longest prefix matching and improved LRU caching for performance.
4
8
  class RadixTree
9
+ include RubyRoutes::Utility::PathUtility
10
+
5
11
  class << self
6
12
  # Allow RadixTree.new(path, options...) to act as a convenience factory
7
13
  def new(*args, &block)
@@ -17,151 +23,239 @@ module RubyRoutes
17
23
  @root = Node.new
18
24
  @split_cache = {}
19
25
  @split_cache_order = []
20
- @split_cache_max = 2048 # larger cache for better hit rates
21
- @empty_segments = [].freeze # reuse for root path
26
+ @split_cache_max = 2048
27
+ @empty_segments = [].freeze
22
28
  end
23
29
 
30
+ # Add a route to the radix tree with specified path, HTTP methods, and handler.
31
+ # Returns the handler for method chaining.
24
32
  def add(path, methods, handler)
25
- current = @root
26
- segments = split_path_raw(path)
33
+ # Normalize path
34
+ normalized_path = normalize_path(path)
35
+
36
+ # Insert into the tree
37
+ insert_route(normalized_path, methods, handler)
38
+
39
+ # Return handler for chaining
40
+ handler
41
+ end
42
+
43
+ # Insert a route into the tree structure, creating nodes as needed.
44
+ # Supports static segments, dynamic parameters (:param), and wildcards (*splat).
45
+ def insert_route(path_str, methods, handler)
46
+ # Skip empty paths
47
+ return handler if path_str.nil? || path_str.empty?
48
+
49
+ path_parts = split_path(path_str)
50
+ current_node = @root
51
+
52
+ # Add path segments to tree
53
+ path_parts.each_with_index do |segment, i|
54
+ if segment.start_with?(':')
55
+ # Dynamic segment (e.g., :id)
56
+ param_name = segment[1..-1]
57
+
58
+ # Create dynamic child if needed
59
+ unless current_node.dynamic_child
60
+ current_node.dynamic_child = Node.new
61
+ current_node.dynamic_child.param_name = param_name
62
+ end
63
+
64
+ current_node = current_node.dynamic_child
65
+ elsif segment.start_with?('*')
66
+ # Wildcard segment (e.g., *path)
67
+ param_name = segment[1..-1]
27
68
 
28
- segments.each do |raw_seg|
29
- seg = RubyRoutes::Segment.for(raw_seg)
30
- current = seg.ensure_child(current)
31
- break if seg.wildcard?
69
+ # Create wildcard child if needed
70
+ unless current_node.wildcard_child
71
+ current_node.wildcard_child = Node.new
72
+ current_node.wildcard_child.param_name = param_name
73
+ end
74
+
75
+ current_node = current_node.wildcard_child
76
+ break # Wildcard consumes the rest of the path
77
+ else
78
+ # Static segment - freeze key for memory efficiency and performance
79
+ segment_key = segment.freeze
80
+ unless current_node.static_children[segment_key]
81
+ current_node.static_children[segment_key] = Node.new
82
+ end
83
+
84
+ current_node = current_node.static_children[segment_key]
85
+ end
86
+ end
87
+
88
+ # Mark node as endpoint and add handler for methods
89
+ current_node.is_endpoint = true
90
+ Array(methods).each do |method|
91
+ method_str = method.to_s.upcase
92
+ current_node.handlers[method_str] = handler
32
93
  end
33
94
 
34
- # Normalize methods once during registration
35
- Array(methods).each { |method| current.add_handler(method.to_s.upcase, handler) }
95
+ handler
36
96
  end
37
97
 
38
- def find(path, method, params_out = nil)
39
- # Fast path: root route
40
- if path == '/' || path.empty?
41
- handler = @root.get_handler(method)
42
- if @root.is_endpoint && handler
43
- return [handler, params_out || {}]
98
+ # Find a matching route in the radix tree with longest prefix match support.
99
+ # Tracks the deepest endpoint node during traversal so partial matches return
100
+ # the longest valid prefix, increasing matching flexibility and correctness
101
+ # for overlapping/static/dynamic/wildcard routes.
102
+ def find(path, method, params_out = {})
103
+ # Handle empty path as root
104
+ path_str = path.to_s
105
+ method_str = method.to_s.upcase
106
+
107
+ # Special case for root path
108
+ if path_str.empty? || path_str == '/'
109
+ if @root.is_endpoint && @root.handlers[method_str]
110
+ return [@root.handlers[method_str], params_out || {}]
44
111
  else
45
- return [nil, {}]
112
+ return [nil, params_out || {}]
46
113
  end
47
114
  end
48
115
 
49
- segments = split_path_cached(path)
50
- current = @root
116
+ # Split path into segments
117
+ segments = split_path_cached(path_str)
118
+ return [nil, params_out || {}] if segments.empty?
119
+
51
120
  params = params_out || {}
52
- params.clear if params_out
53
-
54
- # Unrolled traversal for common case (1-3 segments)
55
- case segments.size
56
- when 1
57
- next_node, _ = current.traverse_for(segments[0], 0, segments, params)
58
- current = next_node
59
- when 2
60
- next_node, should_break = current.traverse_for(segments[0], 0, segments, params)
61
- return [nil, {}] unless next_node
62
- current = next_node
63
- unless should_break
64
- next_node, _ = current.traverse_for(segments[1], 1, segments, params)
65
- current = next_node
66
- end
67
- when 3
68
- next_node, should_break = current.traverse_for(segments[0], 0, segments, params)
69
- return [nil, {}] unless next_node
70
- current = next_node
71
- unless should_break
72
- next_node, should_break = current.traverse_for(segments[1], 1, segments, params)
73
- return [nil, {}] unless next_node
74
- current = next_node
75
- unless should_break
76
- next_node, _ = current.traverse_for(segments[2], 2, segments, params)
77
- current = next_node
121
+
122
+ # Track the longest prefix match (deepest endpoint found during traversal)
123
+ # Only consider nodes that actually match some part of the path
124
+ longest_match_node = nil
125
+ longest_match_params = nil
126
+
127
+ # Traverse the tree to find matching route
128
+ current_node = @root
129
+ segments.each_with_index do |segment, i|
130
+ next_node, should_break = current_node.traverse_for(segment, i, segments, params)
131
+
132
+ # No match found for this segment
133
+ unless next_node
134
+ # Return longest prefix match if we found any valid endpoint during traversal
135
+ if longest_match_node
136
+ handler = longest_match_node.handlers[method_str]
137
+ if handler.respond_to?(:constraints)
138
+ constraints = handler.constraints
139
+ if constraints && !constraints.empty?
140
+ return check_constraints(handler, longest_match_params) ? [handler, longest_match_params] : [nil, params]
141
+ end
142
+ end
143
+ return [handler, longest_match_params]
78
144
  end
145
+ return [nil, params]
79
146
  end
80
- else
81
- # General case for longer paths
82
- segments.each_with_index do |text, idx|
83
- next_node, should_break = current.traverse_for(text, idx, segments, params)
84
- return [nil, {}] unless next_node
85
- current = next_node
86
- break if should_break
147
+
148
+ current_node = next_node
149
+
150
+ # Check if current node is a valid endpoint after successful traversal
151
+ if current_node.is_endpoint && current_node.handlers[method_str]
152
+ # Store this as our current best match
153
+ longest_match_node = current_node
154
+ longest_match_params = params.dup
87
155
  end
156
+
157
+ break if should_break # For wildcard paths
88
158
  end
89
159
 
90
- return [nil, {}] unless current
91
- handler = current.get_handler(method)
92
- return [nil, {}] unless current.is_endpoint && handler
160
+ # Check if final node is an endpoint and has a handler for the method
161
+ if current_node.is_endpoint && current_node.handlers[method_str]
162
+ handler = current_node.handlers[method_str]
163
+
164
+ # Handle constraints correctly - only check constraints
165
+ # Don't try to call matches? which test doubles won't have properly stubbed
166
+ if handler.respond_to?(:constraints)
167
+ constraints = handler.constraints
168
+ if constraints && !constraints.empty?
169
+ if check_constraints(handler, params)
170
+ return [handler, params]
171
+ else
172
+ # If constraints fail, try longest prefix match as fallback
173
+ if longest_match_node && longest_match_node != current_node
174
+ fallback_handler = longest_match_node.handlers[method_str]
175
+ return [fallback_handler, longest_match_params] if fallback_handler
176
+ end
177
+ return [nil, params]
178
+ end
179
+ end
180
+ end
93
181
 
94
- # Fast constraint check
95
- if handler.respond_to?(:constraints) && !handler.constraints.empty?
96
- return [nil, {}] unless constraints_match_fast(handler.constraints, params)
182
+ return [handler, params]
97
183
  end
98
184
 
99
- [handler, params]
185
+ # If we reach here, final node isn't an endpoint - return longest prefix match
186
+ if longest_match_node
187
+ handler = longest_match_node.handlers[method_str]
188
+ if handler.respond_to?(:constraints)
189
+ constraints = handler.constraints
190
+ if constraints && !constraints.empty?
191
+ return check_constraints(handler, longest_match_params) ? [handler, longest_match_params] : [nil, params]
192
+ end
193
+ end
194
+ return [handler, longest_match_params]
195
+ end
196
+
197
+ [nil, params]
100
198
  end
101
199
 
102
200
  private
103
201
 
104
- # Cached path splitting with optimized common cases
202
+ # Improved LRU Path Segment Cache: Accessed keys are moved to the end of the
203
+ # order array to ensure proper LRU eviction behavior
105
204
  def split_path_cached(path)
106
- return @empty_segments if path == '/' || path.empty?
205
+ return @empty_segments if path == '/'
107
206
 
108
- if (cached = @split_cache[path])
109
- return cached
207
+ # Check if path is in cache
208
+ if @split_cache.key?(path)
209
+ # Move accessed key to end for proper LRU behavior
210
+ @split_cache_order.delete(path)
211
+ @split_cache_order << path
212
+ return @split_cache[path]
110
213
  end
111
214
 
112
- result = split_path_raw(path)
215
+ # Split path and add to cache
216
+ segments = split_path(path)
113
217
 
114
- # Cache with simple LRU eviction
115
- @split_cache[path] = result
116
- @split_cache_order << path
117
- if @split_cache_order.size > @split_cache_max
118
- oldest = @split_cache_order.shift
119
- @split_cache.delete(oldest)
218
+ # Manage cache size - evict oldest entries when limit reached
219
+ if @split_cache.size >= @split_cache_max
220
+ old_key = @split_cache_order.shift
221
+ @split_cache.delete(old_key)
120
222
  end
121
223
 
122
- result
224
+ @split_cache[path] = segments
225
+ @split_cache_order << path
226
+
227
+ segments
123
228
  end
124
229
 
125
- # Raw path splitting without caching (for registration)
126
- def split_path_raw(path)
127
- return [] if path == '/' || path.empty?
230
+ # Validates route constraints against extracted parameters.
231
+ # Returns true if all constraints pass, false otherwise.
232
+ def check_constraints(handler, params)
233
+ return true unless handler.respond_to?(:constraints)
128
234
 
129
- # Optimized trimming: avoid string allocations when possible
130
- start_idx = path.start_with?('/') ? 1 : 0
131
- end_idx = path.end_with?('/') ? -2 : -1
235
+ constraints = handler.constraints
236
+ return true unless constraints && !constraints.empty?
132
237
 
133
- if start_idx == 0 && end_idx == -1
134
- path.split('/')
135
- else
136
- path[start_idx..end_idx].split('/')
137
- end
138
- end
139
-
140
- # Optimized constraint matching with fast paths
141
- def constraints_match_fast(constraints, params)
238
+ # Check each constraint
142
239
  constraints.each do |param, constraint|
143
- # Try both string and symbol keys (common pattern)
144
- value = params[param.to_s]
145
- value ||= params[param] if param.respond_to?(:to_s)
240
+ param_key = param.to_s
241
+ value = params[param_key]
146
242
  next unless value
147
243
 
148
244
  case constraint
149
245
  when Regexp
150
- return false unless constraint.match?(value)
151
- when Proc
152
- return false unless constraint.call(value)
246
+ return false unless constraint.match?(value.to_s)
153
247
  when :int
154
- # Fast integer check without regex
155
- return false unless value.is_a?(String) && value.match?(/\A\d+\z/)
248
+ return false unless value.to_s.match?(/\A\d+\z/)
156
249
  when :uuid
157
- # Fast UUID check
158
- return false unless value.is_a?(String) && value.length == 36 &&
159
- value.match?(/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i)
160
- when Symbol
161
- # Handle other symbolic constraints
162
- next # unknown symbol constraint — allow
250
+ return false unless value.to_s.match?(/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i)
251
+ when Hash
252
+ if constraint[:range].is_a?(Range)
253
+ value_num = value.to_i
254
+ return false unless constraint[:range].include?(value_num)
255
+ end
163
256
  end
164
257
  end
258
+
165
259
  true
166
260
  end
167
261
  end
@@ -31,6 +31,10 @@ module RubyRoutes
31
31
  val
32
32
  end
33
33
 
34
+ def size
35
+ @h.size
36
+ end
37
+
34
38
  def increment_hits
35
39
  @hits += 1
36
40
  end