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.
@@ -1,94 +1,103 @@
1
+ require_relative 'utility/key_builder_utility'
2
+
1
3
  module RubyRoutes
2
4
  class RouteSet
3
5
  attr_reader :routes
4
6
 
7
+ include RubyRoutes::Utility::KeyBuilderUtility
8
+
5
9
  def initialize
6
- @tree = RubyRoutes::RadixTree.new
7
- @named_routes = {}
8
10
  @routes = []
9
- # Optimized recognition cache with better data structures
11
+ @named_routes = {}
10
12
  @recognition_cache = {}
13
+ @recognition_cache_max = 2048
11
14
  @cache_hits = 0
12
15
  @cache_misses = 0
13
- @recognition_cache_max = 8192 # larger for better hit rates
16
+ @radix_tree = RadixTree.new
14
17
  end
15
18
 
16
- def add_route(route)
19
+ def add_to_collection(route)
17
20
  @routes << route
18
- @tree.add(route.path, route.methods, route)
21
+ @radix_tree.add(route.path, route.methods, route)
19
22
  @named_routes[route.name] = route if route.named?
20
- # Clear recognition cache when routes change
21
- @recognition_cache.clear if @recognition_cache.size > 100
22
- route
23
23
  end
24
24
 
25
- def find_route(request_method, request_path)
26
- # Optimized: avoid repeated string allocation
27
- method_up = request_method.to_s.upcase
28
- handler, _params = @tree.find(request_path, method_up)
29
- handler
25
+ alias_method :add_route, :add_to_collection
26
+
27
+ def find_route(method, path)
28
+ route, _ = @radix_tree.find(path, method)
29
+ route
30
30
  end
31
31
 
32
32
  def find_named_route(name)
33
33
  route = @named_routes[name]
34
- return route if route
35
- raise RouteNotFound, "No route named '#{name}'"
34
+ raise RouteNotFound.new("No route named '#{name}'") unless route
35
+ route
36
36
  end
37
37
 
38
- def match(request_method, request_path)
39
- # Fast path: normalize method once
40
- method_up = method_lookup(request_method)
38
+ FAST_METHOD_MAP = {
39
+ get: 'GET', post: 'POST', put: 'PUT', patch: 'PATCH',
40
+ delete: 'DELETE', head: 'HEAD', options: 'OPTIONS'
41
+ }.freeze
42
+
43
+ def normalize_method_input(method)
44
+ case method
45
+ when Symbol
46
+ FAST_METHOD_MAP[method] || method.to_s.upcase
47
+ when String
48
+ # Fast path: assume already correct; fallback only for common lowercase
49
+ return method if method.length <= 6 && method == method.upcase
50
+ FAST_METHOD_MAP[method.downcase.to_sym] || method.upcase
51
+ else
52
+ s = method.to_s
53
+ FAST_METHOD_MAP[s.downcase.to_sym] || s.upcase
54
+ end
55
+ end
56
+ private :normalize_method_input
41
57
 
42
- # Optimized cache key: avoid string interpolation when possible
43
- cache_key = build_cache_key(method_up, request_path)
58
+ def match(method, path)
59
+ m = normalize_method_input(method)
60
+ raw = path.to_s
61
+ cache_key = cache_key_for_request(m, raw)
44
62
 
45
- # Cache hit: return immediately (cached result includes full structure)
46
- if (cached_result = @recognition_cache[cache_key])
63
+ # Single cache lookup with proper hit accounting
64
+ if (hit = @recognition_cache[cache_key])
47
65
  @cache_hits += 1
48
- return cached_result
66
+ return hit
49
67
  end
50
68
 
51
69
  @cache_misses += 1
52
70
 
53
- # Use thread-local params to avoid allocations
54
- params = get_thread_local_params
55
- handler, _ = @tree.find(request_path, method_up, params)
56
- return nil unless handler
71
+ path_without_query, _qs = raw.split('?', 2)
57
72
 
58
- route = handler
73
+ # Use normalized method (m) for trie lookup
74
+ route, params = @radix_tree.find(path_without_query, m)
75
+ return nil unless route
59
76
 
60
- # Fast path: merge defaults only if they exist
61
- merge_defaults(route, params) if route.defaults && !route.defaults.empty?
77
+ merge_query_params(route, raw, params)
62
78
 
63
- # Fast path: parse query params only if needed
64
- if request_path.include?('?')
65
- merge_query_params(route, request_path, params)
79
+ if route.respond_to?(:defaults) && route.defaults
80
+ route.defaults.each { |k,v| params[k.to_s] = v unless params.key?(k.to_s) }
66
81
  end
67
82
 
68
- # Create return hash and cache the complete result
69
- result_params = params.dup
70
83
  result = {
71
84
  route: route,
72
- params: result_params,
85
+ params: params,
73
86
  controller: route.controller,
74
87
  action: route.action
75
- }.freeze
76
-
88
+ }
89
+
77
90
  insert_cache_entry(cache_key, result)
78
91
  result
79
92
  end
80
93
 
81
- def recognize_path(path, method = :get)
94
+ def recognize_path(path, method = 'GET')
82
95
  match(method, path)
83
96
  end
84
97
 
85
98
  def generate_path(name, params = {})
86
- route = @named_routes[name]
87
- if route
88
- route.generate_path(params)
89
- else
90
- raise RouteNotFound, "No route named '#{name}'"
91
- end
99
+ route = find_named_route(name)
100
+ route.generate_path(params)
92
101
  end
93
102
 
94
103
  def generate_path_from_route(route, params = {})
@@ -99,95 +108,76 @@ module RubyRoutes
99
108
  @routes.clear
100
109
  @named_routes.clear
101
110
  @recognition_cache.clear
102
- @tree = RadixTree.new
103
- @cache_hits = @cache_misses = 0
111
+ @cache_hits = 0
112
+ @cache_misses = 0
113
+ # Create a new radix tree since we can't clear it
114
+ @radix_tree = RadixTree.new
104
115
  end
105
116
 
106
117
  def size
107
118
  @routes.size
108
119
  end
109
- alias_method :length, :size
110
120
 
111
121
  def empty?
112
122
  @routes.empty?
113
123
  end
114
124
 
115
- def each(&block)
116
- return enum_for(:each) unless block_given?
117
- @routes.each(&block)
118
- end
119
-
120
- def include?(route)
121
- @routes.include?(route)
122
- end
123
-
124
- # Performance monitoring
125
125
  def cache_stats
126
- total = @cache_hits + @cache_misses
127
- hit_rate = total > 0 ? (@cache_hits.to_f / total * 100).round(2) : 0
126
+ lookups = @cache_hits + @cache_misses
128
127
  {
129
128
  hits: @cache_hits,
130
129
  misses: @cache_misses,
131
- hit_rate: "#{hit_rate}%",
130
+ hit_rate: lookups.zero? ? 0.0 : (@cache_hits.to_f / lookups * 100.0),
132
131
  size: @recognition_cache.size
133
132
  }
134
133
  end
135
134
 
136
- private
137
-
138
- # Method lookup table to avoid repeated upcasing with interned strings
139
- def method_lookup(method)
140
- @method_cache ||= Hash.new { |h, k| h[k] = k.to_s.upcase.freeze }
141
- @method_cache[method]
142
- end
143
-
144
- # Optimized cache key building - avoid string interpolation
145
- def build_cache_key(method, path)
146
- # Use string interpolation which is faster than buffer + dup + freeze
147
- # String interpolation creates a new string directly without intermediate allocations
148
- "#{method}:#{path}".freeze
135
+ def each(&block)
136
+ @routes.each(&block)
149
137
  end
150
138
 
151
- # Get thread-local params hash, reusing when possible
152
- def get_thread_local_params
153
- # Use single thread-local hash that gets cleared, avoiding pool management overhead
154
- hash = Thread.current[:ruby_routes_params_hash] ||= {}
155
- hash.clear
156
- hash
139
+ def include?(route)
140
+ @routes.include?(route)
157
141
  end
158
142
 
159
- def return_params_to_pool(params)
160
- # No-op since we're using a single reusable hash per thread
161
- end
143
+ private
162
144
 
163
- # Fast defaults merging
164
- def merge_defaults(route, params)
165
- route.defaults.each do |key, value|
166
- params[key] = value unless params.key?(key)
145
+ def insert_cache_entry(key, value)
146
+ # unchanged cache insert (key already frozen & reusable)
147
+ if @recognition_cache.size >= @recognition_cache_max
148
+ @recognition_cache.keys.first(@recognition_cache_max / 4).each { |k| @recognition_cache.delete(k) }
149
+ end
150
+ @recognition_cache[key] = value
151
+ end
152
+
153
+ # Add the missing method for merging query params
154
+ def merge_query_params(route, path, params)
155
+ # Check for query string
156
+ if path.to_s.include?('?')
157
+ if route.respond_to?(:parse_query_params)
158
+ query_params = route.parse_query_params(path)
159
+ params.merge!(query_params) if query_params
160
+ elsif route.respond_to?(:query_params)
161
+ query_params = route.query_params(path)
162
+ params.merge!(query_params) if query_params
163
+ end
167
164
  end
168
165
  end
169
166
 
170
- # Fast query params merging
171
- def merge_query_params(route, request_path, params)
172
- if route.respond_to?(:parse_query_params)
173
- qp = route.parse_query_params(request_path)
174
- params.merge!(qp) unless qp.empty?
175
- elsif route.respond_to?(:query_params)
176
- qp = route.query_params(request_path)
177
- params.merge!(qp) unless qp.empty?
167
+ # Add thread-local params pool methods
168
+ def get_thread_local_params
169
+ thread_params = Thread.current[:ruby_routes_params_pool] ||= []
170
+ if thread_params.empty?
171
+ {}
172
+ else
173
+ thread_params.pop.clear
178
174
  end
179
175
  end
180
176
 
181
- # Efficient cache insertion with LRU eviction
182
- def insert_cache_entry(cache_key, cache_entry)
183
- @recognition_cache[cache_key] = cache_entry
184
-
185
- # Simple eviction: clear cache when it gets too large
186
- if @recognition_cache.size > @recognition_cache_max
187
- # Keep most recently used half
188
- keys_to_delete = @recognition_cache.keys[0...(@recognition_cache_max / 2)]
189
- keys_to_delete.each { |k| @recognition_cache.delete(k) }
190
- end
177
+ def return_params_to_pool(params)
178
+ params.clear
179
+ thread_pool = Thread.current[:ruby_routes_params_pool] ||= []
180
+ thread_pool << params if thread_pool.size < 10 # Limit pool size
191
181
  end
192
182
  end
193
183
  end
@@ -1,9 +1,12 @@
1
+ require_relative 'utility/route_utility'
2
+
1
3
  module RubyRoutes
2
4
  class Router
3
5
  attr_reader :route_set
4
6
 
5
7
  def initialize(&block)
6
8
  @route_set = RouteSet.new
9
+ @route_utils = RubyRoutes::Utility::RouteUtility.new(@route_set)
7
10
  @scope_stack = []
8
11
  @concerns = {}
9
12
  instance_eval(&block) if block_given?
@@ -102,7 +105,14 @@ module RubyRoutes
102
105
  end
103
106
 
104
107
  # Scope support
105
- def scope(options = {}, &block)
108
+ def scope(options_or_path = {}, &block)
109
+ # Handle the case where the first argument is a string (path)
110
+ options = if options_or_path.is_a?(String)
111
+ { path: options_or_path }
112
+ else
113
+ options_or_path
114
+ end
115
+
106
116
  @scope_stack.push(options)
107
117
 
108
118
  if block_given?
@@ -165,14 +175,9 @@ module RubyRoutes
165
175
 
166
176
  private
167
177
 
168
- def add_route(path, options = {})
169
- # Apply current scope
170
- scoped_options = apply_scope(path, options)
171
-
172
- # Create and add the route
173
- route = Route.new(scoped_options[:path], scoped_options)
174
- @route_set.add_route(route)
175
- route
178
+ def add_route(path, options={})
179
+ scoped = apply_scope(path, options)
180
+ @route_utils.define(scoped[:path], scoped)
176
181
  end
177
182
 
178
183
  def apply_scope(path, options)
@@ -14,8 +14,10 @@ class String
14
14
  case self
15
15
  when /y$/
16
16
  self.sub(/y$/, 'ies')
17
- when /sh$/, /ch$/, /x$/, /z$/
17
+ when /sh$/, /ch$/, /x$/
18
18
  self + 'es'
19
+ when /z$/
20
+ self + 'zes'
19
21
  when /s$/
20
22
  # Words ending in 's' are already plural
21
23
  self
@@ -4,6 +4,7 @@ module RubyRoutes
4
4
  module UrlHelpers
5
5
  def self.included(base)
6
6
  base.extend(ClassMethods)
7
+ base.include(base.url_helpers)
7
8
  end
8
9
 
9
10
  module ClassMethods
@@ -49,11 +50,11 @@ module RubyRoutes
49
50
  # HTML forms only support GET and POST
50
51
  # For other methods, use POST with _method hidden field
51
52
  form_method = (method == 'get') ? 'get' : 'post'
52
-
53
+
53
54
  safe_path = CGI.escapeHTML(path.to_s)
54
55
  safe_form_method = CGI.escapeHTML(form_method)
55
56
  html = "<form action=\"#{safe_path}\" method=\"#{safe_form_method}\">"
56
-
57
+
57
58
  # Add _method hidden field for non-GET/POST methods
58
59
  if method != 'get' && method != 'post'
59
60
  safe_method = CGI.escapeHTML(method)
@@ -0,0 +1,102 @@
1
+ module RubyRoutes
2
+ module Utility
3
+ module KeyBuilderUtility
4
+ # ------------------------------------------------------------------
5
+ # Fast reusable key storage for "METHOD:PATH" strings.
6
+ # O(1) insert + O(1) eviction using a fixed-size ring buffer.
7
+ # Only allocates a new String on the *first* time a (method,path) pair appears.
8
+ # ------------------------------------------------------------------
9
+ REQUEST_KEY_CAPACITY = 4096
10
+
11
+ @pool = {} # { method_string => { path_string => frozen_key_string } }
12
+ @ring = Array.new(REQUEST_KEY_CAPACITY) # ring entries: [method_string, path_string]
13
+ @ring_pos = 0
14
+ @entry_cnt = 0
15
+
16
+ class << self
17
+ attr_reader :pool
18
+ def fetch_request_key(method, path)
19
+ # Method & path must be strings already (callers ensure)
20
+ if (paths = @pool[method])
21
+ if (key = paths[path])
22
+ return key
23
+ end
24
+ end
25
+
26
+ # MISS: build & freeze once
27
+ key = "#{method}:#{path}".freeze
28
+ if paths
29
+ paths[path] = key
30
+ else
31
+ @pool[method] = { path => key }
32
+ end
33
+
34
+ # Evict if ring full (overwrite oldest slot)
35
+ if @entry_cnt < REQUEST_KEY_CAPACITY
36
+ @ring[@entry_cnt] = [method, path]
37
+ @entry_cnt += 1
38
+ else
39
+ ev_m, ev_p = @ring[@ring_pos]
40
+ bucket = @pool[ev_m]
41
+ if bucket&.delete(ev_p) && bucket.empty?
42
+ @pool.delete(ev_m)
43
+ end
44
+ @ring[@ring_pos] = [method, path]
45
+ @ring_pos += 1
46
+ @ring_pos = 0 if @ring_pos == REQUEST_KEY_CAPACITY
47
+ end
48
+
49
+ key
50
+ end
51
+ end
52
+
53
+ # ------------------------------------------------------------------
54
+ # Public helpers mixed into instances
55
+ # ------------------------------------------------------------------
56
+
57
+ # Generic key (rarely hot): joins parts with delim; single allocation.
58
+ def build_key(parts, delim = ':')
59
+ return ''.freeze if parts.empty?
60
+ buf = Thread.current[:ruby_routes_key_buf] ||= String.new
61
+ buf.clear
62
+ i = 0
63
+ while i < parts.length
64
+ buf << delim unless i.zero?
65
+ buf << parts[i].to_s
66
+ i += 1
67
+ end
68
+ buf.dup
69
+ end
70
+
71
+ # HOT: request cache key (reused frozen interned string)
72
+ def cache_key_for_request(method, path)
73
+ KeyBuilderUtility.fetch_request_key(method, path.to_s)
74
+ end
75
+
76
+ # HOT: params key – produces a short-lived String (dup, not re-frozen each time).
77
+ # Callers usually put it into an LRU that duplicates again, so keep it lean.
78
+ def cache_key_for_params(required_params, merged)
79
+ return ''.freeze if required_params.nil? || required_params.empty?
80
+ buf = Thread.current[:ruby_routes_param_key_buf] ||= String.new
81
+ buf.clear
82
+ i = 0
83
+ while i < required_params.length
84
+ buf << '|' unless i.zero?
85
+ v = merged[required_params[i]]
86
+ if v.is_a?(Array)
87
+ j = 0
88
+ while j < v.length
89
+ buf << '/' unless j.zero?
90
+ buf << v[j].to_s
91
+ j += 1
92
+ end
93
+ else
94
+ buf << v.to_s
95
+ end
96
+ i += 1
97
+ end
98
+ buf.dup
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,42 @@
1
+ module RubyRoutes
2
+ module Utility
3
+ module PathUtility
4
+ ROOT_PATH = '/'.freeze
5
+
6
+ def normalize_path(path)
7
+ path = path.to_s
8
+ # Add leading slash if missing
9
+ path = '/' + path unless path.start_with?('/')
10
+ # Remove trailing slash if present (unless root)
11
+ path = path[0..-2] if path.length > 1 && path.end_with?('/')
12
+ path
13
+ end
14
+
15
+ def split_path(path)
16
+ # Remove query string before splitting
17
+ path = path.split('?', 2).first
18
+ path = path[1..-1] if path.start_with?('/')
19
+ path = path[0...-1] if path.end_with?('/') && path != ROOT_PATH
20
+ path.empty? ? [] : path.split('/')
21
+ end
22
+
23
+ def join_path_parts(parts)
24
+ # Pre-calculate the size to avoid buffer resizing
25
+ size = parts.sum { |p| p.length + 1 } # +1 for slash
26
+
27
+ # Use string buffer for better performance
28
+ result = String.new(capacity: size)
29
+ result << '/'
30
+
31
+ # Join with explicit concatenation rather than array join
32
+ last_idx = parts.size - 1
33
+ parts.each_with_index do |part, i|
34
+ result << part
35
+ result << '/' unless i == last_idx
36
+ end
37
+
38
+ result
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,21 @@
1
+ module RubyRoutes
2
+ module Utility
3
+ class RouteUtility
4
+ def initialize(route_set)
5
+ @route_set = route_set
6
+ end
7
+
8
+ # DSL wants to merge scope, RouteSet wants to add a pre‐built Route,
9
+ # so we offer two entry points:
10
+ def define(path, options = {})
11
+ route = Route.new(path, options)
12
+ register(route)
13
+ end
14
+
15
+ def register(route)
16
+ @route_set.add_to_collection(route)
17
+ route
18
+ end
19
+ end
20
+ end
21
+ end
@@ -1,3 +1,3 @@
1
1
  module RubyRoutes
2
- VERSION = "2.0.0"
2
+ VERSION = "2.2.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: 2.0.0
4
+ version: 2.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yosef Benny Widyokarsono
@@ -37,6 +37,20 @@ dependencies:
37
37
  - - "~>"
38
38
  - !ruby/object:Gem::Version
39
39
  version: '13.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rack
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '2.2'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '2.2'
40
54
  - !ruby/object:Gem::Dependency
41
55
  name: simplecov
42
56
  requirement: !ruby/object:Gem::Requirement
@@ -78,6 +92,9 @@ files:
78
92
  - lib/ruby_routes/segments/wildcard_segment.rb
79
93
  - lib/ruby_routes/string_extensions.rb
80
94
  - lib/ruby_routes/url_helpers.rb
95
+ - lib/ruby_routes/utility/key_builder_utility.rb
96
+ - lib/ruby_routes/utility/path_utility.rb
97
+ - lib/ruby_routes/utility/route_utility.rb
81
98
  - lib/ruby_routes/version.rb
82
99
  homepage: https://github.com/yosefbennywidyo/ruby_routes
83
100
  licenses: