Skip to content

Commit ccb0301

Browse files
committed
Use a custom route vistor for optimized route generation
Using a Regexp to replace dynamic segments in a path string is fraught with difficulty and can lead to odd edge cases like rails#13349. Since we already have a parsed representation of the path it makes sense to use that to generate an array of segments that can be used to build an optimized route's path quickly. Tests on a simple route (e.g. /posts/:id) show a speedup of 35%: https://gist.github.com/pixeltrix/8261932 Calculating ------------------------------------- Current Helper: 5274 i/100ms New Helper: 8050 i/100ms ------------------------------------------------- Current Helper: 79263.6 (±3.7%) i/s - 395550 in 4.997252s New Helper: 153464.5 (±4.9%) i/s - 772800 in 5.047834s Tests on a more complex route show even an greater performance boost: https://gist.github.com/pixeltrix/8261957 Calculating ------------------------------------- Current Helper: 2367 i/100ms New Helper: 5382 i/100ms ------------------------------------------------- Current Helper: 29506.0 (±3.2%) i/s - 149121 in 5.059294s New Helper: 78815.5 (±4.1%) i/s - 398268 in 5.062161s It also has the added benefit of fixing the edge cases described above. Fixes rails#13349 (cherry picked from commit d017e92) Conflicts: actionpack/CHANGELOG.md actionpack/lib/action_dispatch/routing/route_set.rb
1 parent ce671be commit ccb0301

File tree

4 files changed

+64
-34
lines changed

4 files changed

+64
-34
lines changed

actionpack/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
* Use a custom route visitor for optimized url generation. Fixes #13349.
2+
3+
*Andrew White*
4+
15
* Set the `:shallow_path` scope option as each scope is generated rather than
26
waiting until the `shallow` option is set. Also make the behavior of the
37
`:shallow` resource option consistent with the behavior of the `shallow` method.

actionpack/lib/action_dispatch/journey/visitors.rb

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,12 +77,32 @@ def visit_GROUP(node)
7777
end
7878
end
7979

80-
class OptimizedPath < String # :nodoc:
80+
class OptimizedPath < Visitor # :nodoc:
81+
def accept(node)
82+
Array(visit(node))
83+
end
84+
8185
private
8286

83-
def visit_GROUP(node)
84-
""
85-
end
87+
def visit_CAT(node)
88+
[visit(node.left), visit(node.right)].flatten
89+
end
90+
91+
def visit_SYMBOL(node)
92+
node.left[1..-1].to_sym
93+
end
94+
95+
def visit_STAR(node)
96+
visit(node.left)
97+
end
98+
99+
def visit_GROUP(node)
100+
[]
101+
end
102+
103+
%w{ LITERAL SLASH DOT }.each do |t|
104+
class_eval %{ def visit_#{t}(n); n.left; end }, __FILE__, __LINE__
105+
end
86106
end
87107

88108
# Used for formatting urls (url_for)

actionpack/lib/action_dispatch/routing/route_set.rb

Lines changed: 24 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ def initialize(options={})
2828
def call(env)
2929
params = env[PARAMETERS_KEY]
3030

31-
# If any of the path parameters has a invalid encoding then
31+
# If any of the path parameters has an invalid encoding then
3232
# raise since it's likely to trigger errors further on.
3333
params.each do |key, value|
3434
next unless value.respond_to?(:valid_encoding?)
@@ -163,9 +163,10 @@ class OptimizedUrlHelper < UrlHelper # :nodoc:
163163

164164
def initialize(route, options)
165165
super
166-
@path_parts = @route.required_parts
167-
@arg_size = @path_parts.size
168-
@string_route = @route.optimized_path
166+
@klass = Journey::Router::Utils
167+
@required_parts = @route.required_parts
168+
@arg_size = @required_parts.size
169+
@optimized_path = @route.optimized_path
169170
end
170171

171172
def call(t, args)
@@ -182,42 +183,35 @@ def call(t, args)
182183
private
183184

184185
def optimized_helper(args)
185-
path = @string_route.dup
186-
klass = Journey::Router::Utils
186+
params = Hash[parameterize_args(args)]
187+
missing_keys = missing_keys(params)
187188

188-
@path_parts.zip(args) do |part, arg|
189-
parameterized_arg = arg.to_param
189+
unless missing_keys.empty?
190+
raise_generation_error(params, missing_keys)
191+
end
190192

191-
if parameterized_arg.nil? || parameterized_arg.empty?
192-
raise_generation_error(args)
193-
end
193+
@optimized_path.map{ |segment| replace_segment(params, segment) }.join
194+
end
194195

195-
# Replace each route parameter
196-
# e.g. :id for regular parameter or *path for globbing
197-
# with ruby string interpolation code
198-
path.gsub!(/(\*|:)#{part}/, klass.escape_fragment(parameterized_arg))
199-
end
200-
path
196+
def replace_segment(params, segment)
197+
Symbol === segment ? @klass.escape_fragment(params[segment]) : segment
201198
end
202199

203200
def optimize_routes_generation?(t)
204201
t.send(:optimize_routes_generation?)
205202
end
206203

207-
def raise_generation_error(args)
208-
parts, missing_keys = @route.requirements.to_a, []
209-
210-
@path_parts.zip(args) do |part, arg|
211-
parameterized_arg = arg.to_param
212-
213-
if parameterized_arg.nil? || parameterized_arg.empty?
214-
missing_keys << part
215-
end
204+
def parameterize_args(args)
205+
@required_parts.zip(args.map(&:to_param))
206+
end
216207

217-
parts << [part, arg]
218-
end
208+
def missing_keys(args)
209+
args.select{ |part, arg| arg.nil? || arg.empty? }.keys
210+
end
219211

220-
message = "No route matches #{Hash[parts.sort].inspect}"
212+
def raise_generation_error(args, missing_keys)
213+
constraints = Hash[@route.requirements.merge(args).sort]
214+
message = "No route matches #{constraints.inspect}"
221215
message << " missing required keys: #{missing_keys.sort.inspect}"
222216

223217
raise ActionController::UrlGenerationError, message
@@ -361,7 +355,7 @@ module MountedHelpers #:nodoc:
361355
include UrlFor
362356
end
363357

364-
# Contains all the mounted helpers accross different
358+
# Contains all the mounted helpers across different
365359
# engines and the `main_app` helper for the application.
366360
# You can include this in your classes if you want to
367361
# access routes for other engines.

actionpack/test/dispatch/routing_test.rb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3397,6 +3397,8 @@ class TestOptimizedNamedRoutes < ActionDispatch::IntegrationTest
33973397
ok = lambda { |env| [200, { 'Content-Type' => 'text/plain' }, []] }
33983398
get '/foo' => ok, as: :foo
33993399
get '/post(/:action(/:id))' => ok, as: :posts
3400+
get '/:foo/:foo_type/bars/:id' => ok, as: :bar
3401+
get '/projects/:id.:format' => ok, as: :project
34003402
end
34013403
end
34023404

@@ -3419,6 +3421,16 @@ def app; Routes end
34193421
assert_equal '/post', Routes.url_helpers.posts_path
34203422
assert_equal '/post', posts_path
34213423
end
3424+
3425+
test 'segments with same prefix are replaced correctly' do
3426+
assert_equal '/foo/baz/bars/1', Routes.url_helpers.bar_path('foo', 'baz', '1')
3427+
assert_equal '/foo/baz/bars/1', bar_path('foo', 'baz', '1')
3428+
end
3429+
3430+
test 'segments separated with a period are replaced correctly' do
3431+
assert_equal '/projects/1.json', Routes.url_helpers.project_path(1, :json)
3432+
assert_equal '/projects/1.json', project_path(1, :json)
3433+
end
34223434
end
34233435

34243436
class TestNamedRouteUrlHelpers < ActionDispatch::IntegrationTest

0 commit comments

Comments
 (0)