ruby_routes 1.0.0 → 1.1.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: 02d39aae113654256dae0905438ac98a540854a5d131dea6c4b2b606b51bf4ba
4
- data.tar.gz: ba01a85fc21713387e76c183a41e786106f1a053e2485d294895ac36027d94a5
3
+ metadata.gz: 15debcef313430cfc799afcb9ba0b6f9bd8292226d023d7e854afc608d5ede64
4
+ data.tar.gz: 1d7c971980a984738c6239cc1727376b74ab61ae824ee054a92f254451574bcf
5
5
  SHA512:
6
- metadata.gz: c3b230ef35186d4f96714ba708b933cf7897166a74b7162949a3144aa21dc79aab2446a0b14d1e0504ea30f941146a7e0f1841d5908e6a6175d771a9b4a658ad
7
- data.tar.gz: 9d476bdbaf46cef516cd51adb218097137863fb12976e98d87a7c8d7f77e3609421d81ba14ed77d67cd59054d4356690d088b619e8e089964cd4fdd6055225ac
6
+ metadata.gz: 8f272672f91127b65fab8ed4ae2eacfb95fe55310fc4bcd017b5656a0eb475e05255884f19416520ce8b36ec6ff51602561b6f9519befc1f7cf7448bca4ab3f9
7
+ data.tar.gz: c3cc386c128531ef9bb268bb904c806e1632c3d88609fd3de5077d8143f0fa56a1089ea0915de3bf2e1116c0d0d812402877480d6edc702f8923d3ee3252ef9a
data/README.md CHANGED
@@ -62,7 +62,6 @@ end
62
62
  ```
63
63
 
64
64
  This creates the following routes:
65
-
66
65
  - `GET /users` → `users#index`
67
66
  - `GET /users/new` → `users#new`
68
67
  - `POST /users` → `users#create`
@@ -14,43 +14,52 @@ module RubyRoutes
14
14
  @is_endpoint = false
15
15
  end
16
16
 
17
- # Traverse for a single segment using the matcher registry.
17
+ # Fast traversal: minimal allocations, streamlined branching
18
18
  # Returns [next_node_or_nil, should_break_bool] or [nil, false] if no match.
19
19
  def traverse_for(segment, index, segments, params)
20
- # Prefer static children first (exact match).
21
- if @static_children.key?(segment)
22
- return [@static_children[segment], false]
23
- end
20
+ # Static match: O(1) hash lookup
21
+ child = @static_children[segment]
22
+ return [child, false] if child
24
23
 
25
- # Then dynamic param child (single segment)
26
- if @dynamic_child
27
- next_node = @dynamic_child
28
- if params
29
- params[next_node.param_name.to_s] = segment
30
- end
31
- return [next_node, false]
24
+ # Dynamic match: single segment capture
25
+ if (dyn = @dynamic_child)
26
+ params[dyn.param_name] = segment if params
27
+ return [dyn, false]
32
28
  end
33
29
 
34
- # Then wildcard child (consume remainder)
35
- if @wildcard_child
36
- next_node = @wildcard_child
30
+ # Wildcard match: consume remainder (last resort)
31
+ if (wild = @wildcard_child)
37
32
  if params
38
- params[next_node.param_name.to_s] = segments[index..-1].join('/')
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('/')
39
36
  end
40
- return [next_node, true]
37
+ return [wild, true]
41
38
  end
42
39
 
43
- # No match at this node
40
+ # No match
44
41
  [nil, false]
45
42
  end
46
43
 
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
47
55
  def add_handler(method, handler)
48
- @handlers[method.to_s] = handler
56
+ method_key = method.to_s.upcase
57
+ @handlers[method_key] = handler
49
58
  @is_endpoint = true
50
59
  end
51
60
 
52
61
  def get_handler(method)
53
- @handlers[method.to_s]
62
+ @handlers[method] # assume already normalized upstream
54
63
  end
55
64
  end
56
65
  end
@@ -4,12 +4,8 @@ module RubyRoutes
4
4
  class RadixTree
5
5
  class << self
6
6
  # Allow RadixTree.new(path, options...) to act as a convenience factory
7
- # returning a Route (this matches test usage where specs call
8
- # RadixTree.new('/path', to: 'controller#action')).
9
- # Calling RadixTree.new with no arguments returns an actual RadixTree instance.
10
7
  def new(*args, &block)
11
8
  if args.any?
12
- # Delegate to Route initializer when args are provided
13
9
  RubyRoutes::Route.new(*args, &block)
14
10
  else
15
11
  super()
@@ -19,47 +15,85 @@ module RubyRoutes
19
15
 
20
16
  def initialize
21
17
  @root = Node.new
22
- @_split_cache = {} # simple LRU: key -> [value, age]
23
- @split_cache_order = [] # track order for eviction
24
- @split_cache_max = 1024
18
+ @split_cache = {}
19
+ @split_cache_order = []
20
+ @split_cache_max = 2048 # larger cache for better hit rates
21
+ @empty_segments = [].freeze # reuse for root path
25
22
  end
26
23
 
27
24
  def add(path, methods, handler)
28
25
  current = @root
29
- parse_segments(path).each do |seg|
26
+ segments = split_path_raw(path)
27
+
28
+ segments.each do |raw_seg|
29
+ seg = RubyRoutes::Segment.for(raw_seg)
30
30
  current = seg.ensure_child(current)
31
31
  break if seg.wildcard?
32
32
  end
33
33
 
34
- methods.each { |method| current.add_handler(method, handler) }
35
- end
36
-
37
- def parse_segments(path)
38
- split_path(path).map { |s| RubyRoutes::Segment.for(s) }
34
+ # Normalize methods once during registration
35
+ Array(methods).each { |method| current.add_handler(method.to_s.upcase, handler) }
39
36
  end
40
37
 
41
38
  def find(path, method, params_out = nil)
42
- segments = split_path(path)
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 || {}]
44
+ else
45
+ return [nil, {}]
46
+ end
47
+ end
48
+
49
+ segments = split_path_cached(path)
43
50
  current = @root
44
51
  params = params_out || {}
45
52
  params.clear if params_out
46
53
 
47
- segments.each_with_index do |text, idx|
48
- next_node, should_break = current.traverse_for(text, idx, segments, params)
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)
49
69
  return [nil, {}] unless next_node
50
70
  current = next_node
51
- break if should_break
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
78
+ end
79
+ 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
87
+ end
52
88
  end
53
89
 
90
+ return [nil, {}] unless current
54
91
  handler = current.get_handler(method)
55
92
  return [nil, {}] unless current.is_endpoint && handler
56
93
 
57
- # lightweight constraint checks: reject early if route constraints don't match
58
- route = handler
59
- if route.respond_to?(:constraints) && route.constraints.any?
60
- unless constraints_match?(route.constraints, params)
61
- return [nil, {}]
62
- end
94
+ # Fast constraint check
95
+ if handler.respond_to?(:constraints) && !handler.constraints.empty?
96
+ return [nil, {}] unless constraints_match_fast(handler.constraints, params)
63
97
  end
64
98
 
65
99
  [handler, params]
@@ -67,34 +101,48 @@ module RubyRoutes
67
101
 
68
102
  private
69
103
 
70
- # faster, lower-allocation trim + split
71
- def split_path(path)
72
- @split_cache ||= {}
73
- return [''] if path == '/'
104
+ # Cached path splitting with optimized common cases
105
+ def split_path_cached(path)
106
+ return @empty_segments if path == '/' || path.empty?
107
+
74
108
  if (cached = @split_cache[path])
75
109
  return cached
76
110
  end
77
111
 
78
- p = path
79
- p = p[1..-1] if p.start_with?('/')
80
- p = p[0...-1] if p.end_with?('/')
81
- segs = p.split('/')
112
+ result = split_path_raw(path)
82
113
 
83
- # simple LRU insert
84
- @split_cache[path] = segs
114
+ # Cache with simple LRU eviction
115
+ @split_cache[path] = result
85
116
  @split_cache_order << path
86
117
  if @split_cache_order.size > @split_cache_max
87
118
  oldest = @split_cache_order.shift
88
119
  @split_cache.delete(oldest)
89
120
  end
90
121
 
91
- segs
122
+ result
123
+ end
124
+
125
+ # Raw path splitting without caching (for registration)
126
+ def split_path_raw(path)
127
+ return [] if path == '/' || path.empty?
128
+
129
+ # Optimized trimming: avoid string allocations when possible
130
+ start_idx = path.start_with?('/') ? 1 : 0
131
+ end_idx = path.end_with?('/') ? -2 : -1
132
+
133
+ if start_idx == 0 && end_idx == -1
134
+ path.split('/')
135
+ else
136
+ path[start_idx..end_idx].split('/')
137
+ end
92
138
  end
93
139
 
94
- # constraints match helper (non-raising, lightweight)
95
- def constraints_match?(constraints, params)
140
+ # Optimized constraint matching with fast paths
141
+ def constraints_match_fast(constraints, params)
96
142
  constraints.each do |param, constraint|
97
- value = params[param.to_s] || params[param]
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)
98
146
  next unless value
99
147
 
100
148
  case constraint
@@ -102,15 +150,16 @@ module RubyRoutes
102
150
  return false unless constraint.match?(value)
103
151
  when Proc
104
152
  return false unless constraint.call(value)
153
+ when :int
154
+ # Fast integer check without regex
155
+ return false unless value.is_a?(String) && value.match?(/\A\d+\z/)
156
+ 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)
105
160
  when Symbol
106
- case constraint
107
- when :int then return false unless value.match?(/^\d+$/)
108
- when :uuid then return false unless value.match?(/^[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}$/i)
109
- else
110
- # unknown symbol constraint — be conservative and allow
111
- end
112
- else
113
- # unknown constraint type — allow (Route will validate later if needed)
161
+ # Handle other symbolic constraints
162
+ next # unknown symbol constraint allow
114
163
  end
115
164
  end
116
165
  true
@@ -7,199 +7,217 @@ module RubyRoutes
7
7
 
8
8
  def initialize(path, options = {})
9
9
  @path = normalize_path(path)
10
- @methods = Array(options[:via] || :get).map(&:to_s).map(&:upcase)
10
+ # Pre-normalize and freeze methods at creation time
11
+ raw_methods = Array(options[:via] || :get)
12
+ @methods = raw_methods.map { |m| normalize_method(m) }.freeze
13
+ @methods_set = @methods.to_set.freeze
11
14
  @controller = extract_controller(options)
12
15
  @action = options[:action] || extract_action(options[:to])
13
16
  @name = options[:as]
14
17
  @constraints = options[:constraints] || {}
15
- # pre-normalize defaults to string keys to avoid per-request transform_keys
16
- @defaults = (options[:defaults] || {}).transform_keys(&:to_s)
18
+ # Pre-normalize defaults to string keys and freeze
19
+ @defaults = (options[:defaults] || {}).transform_keys(&:to_s).freeze
17
20
 
21
+ # Pre-compile everything at initialization
22
+ precompile_route_data
18
23
  validate_route!
19
24
  end
20
25
 
21
26
  def match?(request_method, request_path)
22
- return false unless methods.include?(request_method.to_s.upcase)
23
- !!extract_path_params(request_path)
27
+ # Fast method check: use frozen Set for O(1) lookup
28
+ return false unless @methods_set.include?(request_method.to_s.upcase)
29
+ !!extract_path_params_fast(request_path)
24
30
  end
25
31
 
26
32
  def extract_params(request_path, parsed_qp = nil)
27
- path_params = extract_path_params(request_path)
28
- return {} unless path_params
33
+ path_params = extract_path_params_fast(request_path)
34
+ return EMPTY_HASH unless path_params
29
35
 
30
- # Reuse a thread-local hash to reduce allocations; return a dup to callers.
31
- tmp = Thread.current[:ruby_routes_params] ||= {}
32
- tmp.clear
33
-
34
- # start with path params (they take precedence)
35
- path_params.each { |k, v| tmp[k] = v }
36
-
37
- # use provided parsed_qp if available, otherwise parse lazily only if needed
38
- qp = parsed_qp
39
- if qp.nil? && request_path.include?('?')
40
- qp = query_params(request_path)
41
- end
42
- qp.each { |k, v| tmp[k] = v } if qp && !qp.empty?
43
-
44
- # only set defaults for keys not already present
45
- defaults.each { |k, v| tmp[k] = v unless tmp.key?(k) } if defaults
46
-
47
- validate_constraints!(tmp)
48
- tmp.dup
36
+ # Use optimized param building
37
+ build_params_hash(path_params, request_path, parsed_qp)
49
38
  end
50
39
 
51
40
  def named?
52
- !name.nil?
41
+ !@name.nil?
53
42
  end
54
43
 
55
44
  def resource?
56
- path.match?(/\/:id$/) || path.match?(/\/:id\./)
45
+ @is_resource
57
46
  end
58
47
 
59
48
  def collection?
60
- !resource?
49
+ !@is_resource
61
50
  end
62
51
 
63
52
  def parse_query_params(path)
64
- query_params(path)
53
+ query_params_fast(path)
65
54
  end
66
55
 
67
- # Fast path generator: uses precompiled token list and a small LRU.
68
- # Avoids unbounded cache growth and skips URI-encoding for safe values.
56
+ # Optimized path generation with better caching and fewer allocations
69
57
  def generate_path(params = {})
70
- return '/' if path == '/'
58
+ return ROOT_PATH if @path == ROOT_PATH
71
59
 
72
- # build merged for only relevant param names, reusing a thread-local hash
73
- tmp = Thread.current[:ruby_routes_merged] ||= {}
74
- tmp.clear
75
- defaults.each { |k, v| tmp[k] = v } if defaults
76
- params.each { |k, v| tmp[k.to_s] = v } if params
77
- merged = tmp
60
+ # Fast path: empty params and no required params
61
+ if params.empty? && @required_params.empty?
62
+ return @static_path if @static_path
63
+ end
78
64
 
79
- missing = compiled_required_params - merged.keys
80
- raise RubyRoutes::RouteNotFound, "Missing params: #{missing.join(', ')}" unless missing.empty?
65
+ # Build merged params efficiently
66
+ merged = build_merged_params(params)
81
67
 
82
- @gen_cache ||= SmallLru.new(256)
83
- cache_key = cache_key_for(merged)
84
- if (cached = @gen_cache.get(cache_key))
85
- return cached
68
+ # Check required params (fast Set operation)
69
+ missing_params = @required_params_set - merged.keys
70
+ unless missing_params.empty?
71
+ raise RubyRoutes::RouteNotFound, "Missing params: #{missing_params.to_a.join(', ')}"
86
72
  end
87
73
 
88
- parts = compiled_segments.map do |seg|
89
- case seg[:type]
90
- when :static
91
- seg[:value]
92
- when :param
93
- v = merged.fetch(seg[:name]).to_s
94
- safe_encode_segment(v)
95
- when :splat
96
- v = merged.fetch(seg[:name], '')
97
- arr = v.is_a?(Array) ? v : v.to_s.split('/')
98
- arr.map { |p| safe_encode_segment(p.to_s) }.join('/')
99
- end
74
+ # Cache lookup
75
+ cache_key = build_cache_key_fast(merged)
76
+ if (cached = @gen_cache.get(cache_key))
77
+ return cached
100
78
  end
101
79
 
102
- out = '/' + parts.join('/')
103
- out = '/' if out == ''
80
+ # Generate path using string buffer (avoid array allocations)
81
+ path_str = generate_path_string(merged)
82
+ @gen_cache.set(cache_key, path_str)
83
+ path_str
84
+ end
104
85
 
105
- @gen_cache.set(cache_key, out)
106
- out
86
+ # Fast query params method (cached and optimized)
87
+ def query_params(request_path)
88
+ query_params_fast(request_path)
107
89
  end
108
90
 
109
91
  private
110
92
 
111
- # compile helpers (memoize)
112
- def compiled_segments
113
- @compiled_segments ||= begin
114
- if path == '/'
115
- []
116
- else
117
- path.split('/').reject(&:empty?).map do |seg|
118
- RubyRoutes::Constant.segment_descriptor(seg)
119
- end
120
- end
121
- end
93
+ # Constants for performance
94
+ EMPTY_HASH = {}.freeze
95
+ ROOT_PATH = '/'.freeze
96
+ UNRESERVED_RE = /\A[a-zA-Z0-9\-._~]+\z/.freeze
97
+ QUERY_CACHE_SIZE = 128
98
+
99
+ # Fast method normalization
100
+ def normalize_method(method)
101
+ case method
102
+ when :get then 'GET'
103
+ when :post then 'POST'
104
+ when :put then 'PUT'
105
+ when :patch then 'PATCH'
106
+ when :delete then 'DELETE'
107
+ when :head then 'HEAD'
108
+ when :options then 'OPTIONS'
109
+ else method.to_s.upcase
110
+ end.freeze
122
111
  end
123
112
 
124
- def compiled_required_params
125
- @compiled_required_params ||= compiled_segments.select { |s| s[:type] != :static }
126
- .map { |s| s[:name] }.uniq
127
- .reject { |n| defaults.to_s.include?(n) }
128
- end
129
-
130
- # Cache key: deterministic param-order key (fast, stable)
131
- def cache_key_for(merged)
132
- # build key in route token order (parameters & splat) to avoid sorting/inspect
133
- names = compiled_param_names
134
- # build with single string buffer to avoid temporary arrays
135
- buf = +""
136
- names.each_with_index do |n, i|
137
- val = merged[n]
138
- part = if val.nil?
139
- ''
140
- elsif val.is_a?(Array)
141
- val.map!(&:to_s) && val.join('/')
142
- else
143
- val.to_s
144
- end
145
- buf << '|' unless i.zero?
146
- buf << part
147
- end
148
- buf
113
+ # Pre-compile all route data at initialization
114
+ def precompile_route_data
115
+ @is_resource = @path.match?(/\/:id(?:$|\.)/)
116
+ @gen_cache = SmallLru.new(512) # larger cache
117
+ @query_cache = SmallLru.new(QUERY_CACHE_SIZE)
118
+
119
+ compile_segments
120
+ compile_required_params
121
+ check_static_path
149
122
  end
150
123
 
151
- def compiled_param_names
152
- @compiled_param_names ||= compiled_segments.map { |s| s[:name] if s[:type] != :static }.compact
124
+ def compile_segments
125
+ @compiled_segments = if @path == ROOT_PATH
126
+ EMPTY_ARRAY
127
+ else
128
+ @path.split('/').reject(&:empty?).map do |seg|
129
+ RubyRoutes::Constant.segment_descriptor(seg)
130
+ end.freeze
131
+ end
153
132
  end
154
133
 
155
- # Only URI-encode a segment when it contains unsafe chars.
156
- UNRESERVED_RE = /\A[a-zA-Z0-9\-._~]+\z/
157
- def safe_encode_segment(str)
158
- # leave slash handling to splat logic (splats already split)
159
- return str if UNRESERVED_RE.match?(str)
160
- URI.encode_www_form_component(str)
134
+ def compile_required_params
135
+ param_names = @compiled_segments.filter_map { |s| s[:name] if s[:type] != :static }
136
+ @param_names = param_names.freeze
137
+ @required_params = param_names.reject { |n| @defaults.key?(n) }.freeze
138
+ @required_params_set = @required_params.to_set.freeze
161
139
  end
162
140
 
163
- def normalize_path(path)
164
- p = path.to_s
165
- p = "/#{p}" unless p.start_with?('/')
166
- p = p.chomp('/') unless p == '/'
167
- p
141
+ def check_static_path
142
+ # Pre-generate static paths (no params)
143
+ if @required_params.empty?
144
+ @static_path = generate_static_path
145
+ end
168
146
  end
169
147
 
170
- def extract_controller(options)
171
- if options[:to]
172
- options[:to].to_s.split('#').first
173
- else
174
- options[:controller]
148
+ def generate_static_path
149
+ return ROOT_PATH if @compiled_segments.empty?
150
+
151
+ parts = @compiled_segments.map { |seg| seg[:value] }
152
+ "/#{parts.join('/')}"
153
+ end
154
+
155
+ # Optimized param building
156
+ def build_params_hash(path_params, request_path, parsed_qp)
157
+ # Use pre-allocated hash when possible
158
+ result = get_thread_local_hash
159
+
160
+ # Path params first (highest priority)
161
+ result.update(path_params)
162
+
163
+ # Query params (if needed)
164
+ if parsed_qp
165
+ result.merge!(parsed_qp)
166
+ elsif request_path.include?('?')
167
+ qp = query_params_fast(request_path)
168
+ result.merge!(qp) unless qp.empty?
175
169
  end
170
+
171
+ # Defaults (lowest priority)
172
+ merge_defaults_fast(result) unless @defaults.empty?
173
+
174
+ # Validate constraints efficiently
175
+ validate_constraints_fast!(result) unless @constraints.empty?
176
+
177
+ result.dup
176
178
  end
177
179
 
178
- def extract_action(to)
179
- return nil unless to
180
- to.to_s.split('#').last
180
+ def get_thread_local_hash
181
+ hash = Thread.current[:ruby_routes_params] ||= {}
182
+ hash.clear
183
+ hash
184
+ end
185
+
186
+ def merge_defaults_fast(result)
187
+ @defaults.each { |k, v| result[k] = v unless result.key?(k) }
181
188
  end
182
189
 
183
- def extract_path_params(request_path)
184
- segs = compiled_segments # memoized compiled route tokens
185
- return nil if segs.empty? && request_path != '/'
190
+ # Fast path parameter extraction
191
+ def extract_path_params_fast(request_path)
192
+ return EMPTY_HASH if @compiled_segments.empty? && request_path == ROOT_PATH
193
+ return nil if @compiled_segments.empty?
186
194
 
187
- req = request_path
188
- req = req[1..-1] if req.start_with?('/')
189
- req = req[0...-1] if req.end_with?('/') && req != '/'
190
- request_parts = req == '' ? [] : req.split('/')
195
+ # Fast path normalization
196
+ path_parts = split_path_fast(request_path)
197
+ return nil if @compiled_segments.size != path_parts.size
191
198
 
192
- return nil if segs.size != request_parts.size
199
+ extract_params_from_parts(path_parts)
200
+ end
201
+
202
+ def split_path_fast(request_path)
203
+ # Optimized path splitting
204
+ path = request_path
205
+ path = path[1..-1] if path.start_with?('/')
206
+ path = path[0...-1] if path.end_with?('/') && path != ROOT_PATH
207
+ path.empty? ? [] : path.split('/')
208
+ end
193
209
 
210
+ def extract_params_from_parts(path_parts)
194
211
  params = {}
195
- segs.each_with_index do |seg, idx|
212
+
213
+ @compiled_segments.each_with_index do |seg, idx|
196
214
  case seg[:type]
197
215
  when :static
198
- return nil unless seg[:value] == request_parts[idx]
216
+ return nil unless seg[:value] == path_parts[idx]
199
217
  when :param
200
- params[seg[:name]] = request_parts[idx]
218
+ params[seg[:name]] = path_parts[idx]
201
219
  when :splat
202
- params[seg[:name]] = request_parts[idx..-1].join('/')
220
+ params[seg[:name]] = path_parts[idx..-1].join('/')
203
221
  break
204
222
  end
205
223
  end
@@ -207,15 +225,128 @@ module RubyRoutes
207
225
  params
208
226
  end
209
227
 
210
- def query_params(path)
211
- qidx = path.index('?')
212
- return {} unless qidx
213
- qs = path[(qidx + 1)..-1] || ''
214
- Rack::Utils.parse_query(qs)
228
+ # Optimized merged params building
229
+ def build_merged_params(params)
230
+ return @defaults if params.empty?
231
+
232
+ merged = get_thread_local_merged_hash
233
+ merged.update(@defaults) unless @defaults.empty?
234
+
235
+ # Convert param keys to strings efficiently
236
+ params.each { |k, v| merged[k.to_s] = v }
237
+ merged
238
+ end
239
+
240
+ def get_thread_local_merged_hash
241
+ hash = Thread.current[:ruby_routes_merged] ||= {}
242
+ hash.clear
243
+ hash
215
244
  end
216
245
 
217
- def validate_constraints!(params)
218
- constraints.each do |param, constraint|
246
+ # Fast cache key building with minimal allocations
247
+ def build_cache_key_fast(merged)
248
+ # Use instance variable buffer to avoid repeated allocations
249
+ @cache_key_buffer ||= String.new(capacity: 128)
250
+ @cache_key_buffer.clear
251
+
252
+ return @cache_key_buffer.dup if @required_params.empty?
253
+
254
+ @required_params.each_with_index do |name, idx|
255
+ @cache_key_buffer << '|' unless idx.zero?
256
+ value = merged[name]
257
+ @cache_key_buffer << (value.is_a?(Array) ? value.join('/') : value.to_s) if value
258
+ end
259
+ @cache_key_buffer.dup
260
+ end
261
+
262
+ # Optimized path generation
263
+ def generate_path_string(merged)
264
+ return ROOT_PATH if @compiled_segments.empty?
265
+
266
+ buffer = String.new(capacity: 128)
267
+ buffer << '/'
268
+
269
+ @compiled_segments.each_with_index do |seg, idx|
270
+ buffer << '/' unless idx.zero?
271
+
272
+ case seg[:type]
273
+ when :static
274
+ buffer << seg[:value]
275
+ when :param
276
+ value = merged.fetch(seg[:name]).to_s
277
+ buffer << encode_segment_fast(value)
278
+ when :splat
279
+ value = merged.fetch(seg[:name], '')
280
+ append_splat_value(buffer, value)
281
+ end
282
+ end
283
+
284
+ buffer == '/' ? ROOT_PATH : buffer
285
+ end
286
+
287
+ def append_splat_value(buffer, value)
288
+ case value
289
+ when Array
290
+ value.each_with_index do |part, idx|
291
+ buffer << '/' unless idx.zero?
292
+ buffer << encode_segment_fast(part.to_s)
293
+ end
294
+ when String
295
+ parts = value.split('/')
296
+ parts.each_with_index do |part, idx|
297
+ buffer << '/' unless idx.zero?
298
+ buffer << encode_segment_fast(part)
299
+ end
300
+ else
301
+ buffer << encode_segment_fast(value.to_s)
302
+ end
303
+ end
304
+
305
+ # Fast segment encoding
306
+ def encode_segment_fast(str)
307
+ return str if UNRESERVED_RE.match?(str)
308
+ URI.encode_www_form_component(str)
309
+ end
310
+
311
+ # Optimized query params with caching
312
+ def query_params_fast(path)
313
+ query_start = path.index('?')
314
+ return EMPTY_HASH unless query_start
315
+
316
+ query_string = path[(query_start + 1)..-1]
317
+ return EMPTY_HASH if query_string.empty?
318
+
319
+ # Cache query param parsing
320
+ if (cached = @query_cache.get(query_string))
321
+ return cached
322
+ end
323
+
324
+ result = Rack::Utils.parse_query(query_string)
325
+ @query_cache.set(query_string, result)
326
+ result
327
+ end
328
+
329
+ def normalize_path(path)
330
+ path_str = path.to_s
331
+ path_str = "/#{path_str}" unless path_str.start_with?('/')
332
+ path_str = path_str.chomp('/') unless path_str == ROOT_PATH
333
+ path_str
334
+ end
335
+
336
+ def extract_controller(options)
337
+ to = options[:to]
338
+ return options[:controller] unless to
339
+ to.to_s.split('#', 2).first
340
+ end
341
+
342
+ def extract_action(to)
343
+ return nil unless to
344
+ to.to_s.split('#', 2).last
345
+ end
346
+
347
+ # Optimized constraint validation
348
+ def validate_constraints_fast!(params)
349
+ @constraints.each do |param, constraint|
219
350
  value = params[param.to_s]
220
351
  next unless value
221
352
 
@@ -224,19 +355,23 @@ module RubyRoutes
224
355
  raise ConstraintViolation unless constraint.match?(value)
225
356
  when Proc
226
357
  raise ConstraintViolation unless constraint.call(value)
227
- when Symbol
228
- case constraint
229
- when :int then raise ConstraintViolation unless value.match?(/^\d+$/)
230
- when :uuid then raise ConstraintViolation unless value.match?(/^[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}$/i)
231
- end
358
+ when :int
359
+ raise ConstraintViolation unless value.match?(/\A\d+\z/)
360
+ when :uuid
361
+ raise ConstraintViolation unless value.length == 36 &&
362
+ 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)
232
363
  end
233
364
  end
234
365
  end
235
366
 
236
367
  def validate_route!
237
- raise InvalidRoute, "Controller is required" if controller.nil?
238
- raise InvalidRoute, "Action is required" if action.nil?
239
- raise InvalidRoute, "Invalid HTTP method: #{methods}" if methods.empty?
368
+ raise InvalidRoute, "Controller is required" if @controller.nil?
369
+ raise InvalidRoute, "Action is required" if @action.nil?
370
+ raise InvalidRoute, "Invalid HTTP method: #{@methods}" if @methods.empty?
240
371
  end
372
+
373
+ # Additional constants
374
+ EMPTY_ARRAY = [].freeze
375
+ EMPTY_STRING = ''.freeze
241
376
  end
242
- end
377
+ end
@@ -5,68 +5,80 @@ module RubyRoutes
5
5
  def initialize
6
6
  @tree = RubyRoutes::RadixTree.new
7
7
  @named_routes = {}
8
- @routes = [] # keep list for specs / iteration / size
9
- @recognition_cache = {} # simple bounded cache: key -> [route, params]
10
- @recognition_cache_order = []
11
- @recognition_cache_max = 4096
8
+ @routes = []
9
+ # Optimized recognition cache with better data structures
10
+ @recognition_cache = {}
11
+ @cache_hits = 0
12
+ @cache_misses = 0
13
+ @recognition_cache_max = 8192 # larger for better hit rates
12
14
  end
13
15
 
14
16
  def add_route(route)
15
17
  @routes << route
16
18
  @tree.add(route.path, route.methods, route)
17
19
  @named_routes[route.name] = route if route.named?
20
+ # Clear recognition cache when routes change
21
+ @recognition_cache.clear if @recognition_cache.size > 100
18
22
  route
19
23
  end
20
24
 
21
25
  def find_route(request_method, request_path)
22
- # Return the Route object (or nil) to match spec expectations.
23
- handler, _params = @tree.find(request_path, request_method.to_s.upcase)
26
+ # Optimized: avoid repeated string allocation
27
+ method_up = request_method.to_s.upcase
28
+ handler, _params = @tree.find(request_path, method_up)
24
29
  handler
25
30
  end
26
31
 
27
32
  def find_named_route(name)
28
- @named_routes[name] or raise RouteNotFound, "No route named '#{name}'"
33
+ route = @named_routes[name]
34
+ return route if route
35
+ raise RouteNotFound, "No route named '#{name}'"
29
36
  end
30
37
 
31
38
  def match(request_method, request_path)
32
- # Normalize method once and attempt recognition cache hit
33
- method_up = request_method.to_s.upcase
34
- cache_key = "#{method_up}:#{request_path}"
39
+ # Fast path: normalize method once
40
+ method_up = method_lookup(request_method)
41
+
42
+ # Optimized cache key: avoid string interpolation when possible
43
+ cache_key = build_cache_key(method_up, request_path)
44
+
45
+ # Cache hit: return immediately
35
46
  if (cached = @recognition_cache[cache_key])
36
- # Return cached params (frozen) directly to avoid heavy dup allocations.
47
+ @cache_hits += 1
37
48
  cached_route, cached_params = cached
38
- return { route: cached_route, params: cached_params, controller: cached_route.controller, action: cached_route.action }
49
+ return {
50
+ route: cached_route,
51
+ params: cached_params,
52
+ controller: cached_route.controller,
53
+ action: cached_route.action
54
+ }
39
55
  end
40
56
 
41
- # Use a thread-local hash as output for RadixTree to avoid allocating a params Hash
42
- tmp = Thread.current[:ruby_routes_params] ||= {}
43
- handler, _ = @tree.find(request_path, method_up, tmp)
57
+ @cache_misses += 1
58
+
59
+ # Use thread-local params to avoid allocations
60
+ params = get_thread_local_params
61
+ handler, _ = @tree.find(request_path, method_up, params)
44
62
  return nil unless handler
63
+
45
64
  route = handler
46
65
 
47
- # tmp now contains path params (filled by RadixTree). Merge defaults and query params in-place.
48
- # defaults first (only set missing keys)
49
- if route.defaults
50
- route.defaults.each { |k, v| tmp[k] = v unless tmp.key?(k) }
51
- end
66
+ # Fast path: merge defaults only if they exist
67
+ merge_defaults(route, params) if route.defaults && !route.defaults.empty?
68
+
69
+ # Fast path: parse query params only if needed
52
70
  if request_path.include?('?')
53
- qp = route.parse_query_params(request_path)
54
- qp.each { |k, v| tmp[k] = v } unless qp.empty?
71
+ merge_query_params(route, request_path, params)
55
72
  end
56
73
 
57
- params = tmp.dup
58
-
59
- # insert into bounded recognition cache (store frozen params to reduce accidental mutation)
60
- @recognition_cache[cache_key] = [route, params.freeze]
61
- @recognition_cache_order << cache_key
62
- if @recognition_cache_order.size > @recognition_cache_max
63
- oldest = @recognition_cache_order.shift
64
- @recognition_cache.delete(oldest)
65
- end
74
+ # Create return hash and cache entry
75
+ result_params = params.dup
76
+ cache_entry = [route, result_params.freeze]
77
+ insert_cache_entry(cache_key, cache_entry)
66
78
 
67
79
  {
68
80
  route: route,
69
- params: params,
81
+ params: result_params,
70
82
  controller: route.controller,
71
83
  action: route.action
72
84
  }
@@ -77,35 +89,119 @@ module RubyRoutes
77
89
  end
78
90
 
79
91
  def generate_path(name, params = {})
80
- route = find_named_route(name)
81
- generate_path_from_route(route, params)
92
+ route = @named_routes[name]
93
+ if route
94
+ route.generate_path(params)
95
+ else
96
+ raise RouteNotFound, "No route named '#{name}'"
97
+ end
82
98
  end
83
99
 
84
100
  def generate_path_from_route(route, params = {})
85
- # Delegate to Route#generate_path which uses precompiled segments + cache
86
101
  route.generate_path(params)
87
102
  end
88
103
 
89
104
  def clear!
90
105
  @routes.clear
91
106
  @named_routes.clear
107
+ @recognition_cache.clear
92
108
  @tree = RadixTree.new
109
+ @cache_hits = @cache_misses = 0
93
110
  end
94
111
 
95
112
  def size
96
113
  @routes.size
97
114
  end
115
+ alias_method :length, :size
98
116
 
99
117
  def empty?
100
118
  @routes.empty?
101
119
  end
102
120
 
103
121
  def each(&block)
122
+ return enum_for(:each) unless block_given?
104
123
  @routes.each(&block)
105
124
  end
106
125
 
107
126
  def include?(route)
108
127
  @routes.include?(route)
109
128
  end
129
+
130
+ # Performance monitoring
131
+ def cache_stats
132
+ total = @cache_hits + @cache_misses
133
+ hit_rate = total > 0 ? (@cache_hits.to_f / total * 100).round(2) : 0
134
+ {
135
+ hits: @cache_hits,
136
+ misses: @cache_misses,
137
+ hit_rate: "#{hit_rate}%",
138
+ size: @recognition_cache.size
139
+ }
140
+ end
141
+
142
+ private
143
+
144
+ # Method lookup table to avoid repeated upcasing
145
+ def method_lookup(method)
146
+ @method_cache ||= Hash.new { |h, k| h[k] = k.to_s.upcase.freeze }
147
+ @method_cache[method]
148
+ end
149
+
150
+ # Optimized cache key building - avoid string interpolation
151
+ def build_cache_key(method, path)
152
+ # Use frozen string concatenation to avoid allocations
153
+ @cache_key_buffer ||= String.new(capacity: 256)
154
+ @cache_key_buffer.clear
155
+ @cache_key_buffer << method << ':' << path
156
+ @cache_key_buffer.dup.freeze
157
+ end
158
+
159
+ # Get thread-local params hash, reusing when possible
160
+ def get_thread_local_params
161
+ # Use object pool to reduce GC pressure
162
+ @params_pool ||= []
163
+ if @params_pool.empty?
164
+ {}
165
+ else
166
+ hash = @params_pool.pop
167
+ hash.clear
168
+ hash
169
+ end
170
+ end
171
+
172
+ def return_params_to_pool(params)
173
+ @params_pool ||= []
174
+ @params_pool.push(params) if @params_pool.size < 10
175
+ end
176
+
177
+ # Fast defaults merging
178
+ def merge_defaults(route, params)
179
+ route.defaults.each do |key, value|
180
+ params[key] = value unless params.key?(key)
181
+ end
182
+ end
183
+
184
+ # Fast query params merging
185
+ def merge_query_params(route, request_path, params)
186
+ if route.respond_to?(:parse_query_params)
187
+ qp = route.parse_query_params(request_path)
188
+ params.merge!(qp) unless qp.empty?
189
+ elsif route.respond_to?(:query_params)
190
+ qp = route.query_params(request_path)
191
+ params.merge!(qp) unless qp.empty?
192
+ end
193
+ end
194
+
195
+ # Efficient cache insertion with LRU eviction
196
+ def insert_cache_entry(cache_key, cache_entry)
197
+ @recognition_cache[cache_key] = cache_entry
198
+
199
+ # Simple eviction: clear cache when it gets too large
200
+ if @recognition_cache.size > @recognition_cache_max
201
+ # Keep most recently used half
202
+ keys_to_delete = @recognition_cache.keys[0...(@recognition_cache_max / 2)]
203
+ keys_to_delete.each { |k| @recognition_cache.delete(k) }
204
+ end
205
+ end
110
206
  end
111
207
  end
@@ -1,3 +1,3 @@
1
1
  module RubyRoutes
2
- VERSION = "1.0.0"
2
+ VERSION = "1.1.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_routes
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yosef Benny Widyokarsono