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 +4 -4
- data/README.md +0 -1
- data/lib/ruby_routes/node.rb +29 -20
- data/lib/ruby_routes/radix_tree.rb +94 -45
- data/lib/ruby_routes/route.rb +281 -146
- data/lib/ruby_routes/route_set.rb +131 -35
- data/lib/ruby_routes/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 15debcef313430cfc799afcb9ba0b6f9bd8292226d023d7e854afc608d5ede64
|
4
|
+
data.tar.gz: 1d7c971980a984738c6239cc1727376b74ab61ae824ee054a92f254451574bcf
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 8f272672f91127b65fab8ed4ae2eacfb95fe55310fc4bcd017b5656a0eb475e05255884f19416520ce8b36ec6ff51602561b6f9519befc1f7cf7448bca4ab3f9
|
7
|
+
data.tar.gz: c3cc386c128531ef9bb268bb904c806e1632c3d88609fd3de5077d8143f0fa56a1089ea0915de3bf2e1116c0d0d812402877480d6edc702f8923d3ee3252ef9a
|
data/README.md
CHANGED
data/lib/ruby_routes/node.rb
CHANGED
@@ -14,43 +14,52 @@ module RubyRoutes
|
|
14
14
|
@is_endpoint = false
|
15
15
|
end
|
16
16
|
|
17
|
-
#
|
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
|
-
#
|
21
|
-
|
22
|
-
|
23
|
-
end
|
20
|
+
# Static match: O(1) hash lookup
|
21
|
+
child = @static_children[segment]
|
22
|
+
return [child, false] if child
|
24
23
|
|
25
|
-
#
|
26
|
-
if @dynamic_child
|
27
|
-
|
28
|
-
|
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
|
-
#
|
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
|
-
|
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 [
|
37
|
+
return [wild, true]
|
41
38
|
end
|
42
39
|
|
43
|
-
# No match
|
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
|
-
|
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
|
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
|
-
@
|
23
|
-
@split_cache_order = []
|
24
|
-
@split_cache_max =
|
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
|
-
|
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
|
-
|
35
|
-
|
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
|
-
|
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
|
-
|
48
|
-
|
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
|
-
|
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
|
-
#
|
58
|
-
|
59
|
-
|
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
|
-
#
|
71
|
-
def
|
72
|
-
@
|
73
|
-
|
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
|
-
|
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
|
84
|
-
@split_cache[path] =
|
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
|
-
|
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
|
-
#
|
95
|
-
def
|
140
|
+
# Optimized constraint matching with fast paths
|
141
|
+
def constraints_match_fast(constraints, params)
|
96
142
|
constraints.each do |param, constraint|
|
97
|
-
|
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
|
-
|
107
|
-
|
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
|
data/lib/ruby_routes/route.rb
CHANGED
@@ -7,199 +7,217 @@ module RubyRoutes
|
|
7
7
|
|
8
8
|
def initialize(path, options = {})
|
9
9
|
@path = normalize_path(path)
|
10
|
-
|
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
|
-
#
|
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
|
-
|
23
|
-
|
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 =
|
28
|
-
return
|
33
|
+
path_params = extract_path_params_fast(request_path)
|
34
|
+
return EMPTY_HASH unless path_params
|
29
35
|
|
30
|
-
#
|
31
|
-
|
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
|
-
|
41
|
+
!@name.nil?
|
53
42
|
end
|
54
43
|
|
55
44
|
def resource?
|
56
|
-
|
45
|
+
@is_resource
|
57
46
|
end
|
58
47
|
|
59
48
|
def collection?
|
60
|
-
|
49
|
+
!@is_resource
|
61
50
|
end
|
62
51
|
|
63
52
|
def parse_query_params(path)
|
64
|
-
|
53
|
+
query_params_fast(path)
|
65
54
|
end
|
66
55
|
|
67
|
-
#
|
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
|
58
|
+
return ROOT_PATH if @path == ROOT_PATH
|
71
59
|
|
72
|
-
#
|
73
|
-
|
74
|
-
|
75
|
-
|
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
|
-
|
80
|
-
|
65
|
+
# Build merged params efficiently
|
66
|
+
merged = build_merged_params(params)
|
81
67
|
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
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
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
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
|
-
|
103
|
-
|
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
|
-
|
106
|
-
|
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
|
-
#
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
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
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
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
|
152
|
-
@
|
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
|
-
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
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
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
|
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
|
171
|
-
if
|
172
|
-
|
173
|
-
|
174
|
-
|
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
|
179
|
-
|
180
|
-
|
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
|
-
|
184
|
-
|
185
|
-
return
|
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
|
-
|
188
|
-
|
189
|
-
|
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
|
-
|
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
|
-
|
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] ==
|
216
|
+
return nil unless seg[:value] == path_parts[idx]
|
199
217
|
when :param
|
200
|
-
params[seg[:name]] =
|
218
|
+
params[seg[:name]] = path_parts[idx]
|
201
219
|
when :splat
|
202
|
-
params[seg[:name]] =
|
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
|
-
|
211
|
-
|
212
|
-
return
|
213
|
-
|
214
|
-
|
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
|
-
|
218
|
-
|
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
|
228
|
-
|
229
|
-
|
230
|
-
|
231
|
-
|
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 = []
|
9
|
-
|
10
|
-
@
|
11
|
-
@
|
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
|
-
#
|
23
|
-
|
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]
|
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
|
-
#
|
33
|
-
method_up = request_method
|
34
|
-
|
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
|
-
|
47
|
+
@cache_hits += 1
|
37
48
|
cached_route, cached_params = cached
|
38
|
-
return {
|
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
|
-
|
42
|
-
|
43
|
-
|
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
|
-
#
|
48
|
-
|
49
|
-
|
50
|
-
|
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
|
-
|
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
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
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:
|
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 =
|
81
|
-
|
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
|
data/lib/ruby_routes/version.rb
CHANGED