Skip to content

Commit

Permalink
Make class_matcher and symbol_matcher plugin be able to build on top …
Browse files Browse the repository at this point in the history
…of existing registered matchers

This makes it significantly simpler to use existing matchers
to create more powerful matchers.  For example, you can
build an Employee matcher that uses the conversion from
the Integer matcher, and then looks up the related id.

  class_matcher Employee, Integer do |id|
    Employee[id]
  end

You can mix the class and symbol matchers, for more
specific matching.  This is helpful when you don't have
separate classes for everything you want to match.  For
example, if you want to match only active employees,
you can have a symbol_matcher that builds on top of the
Employee class matcher:

  symbol_matcher :ActiveEmployee, Employee |employee|
    employee if employee.active?
  end

In order to work with the Integer_matcher_max plugin
without significant slowdown, this duplicates the
integer conversion code at the application class level.

In order for the symbol matchers generated from class
matchers to work with the placeholder_string_matchers
plugin, this has both symbol matchers and class matchers
store both the base and full regexps used for matching.

This also speeds up the symbol_matcher by caching the
symbol matcher at the class level, instead of having to
perform a cache lookup during every match.
  • Loading branch information
jeremyevans committed Sep 17, 2024
1 parent bec99de commit e0854b9
Show file tree
Hide file tree
Showing 8 changed files with 465 additions and 16 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
= master

* Make class_matcher and symbol_matcher plugin be able to build on top of existing registered matchers (jeremyevans)

* Make capture_erb plugin not break if String#capture is defined (jeremyevans)

= 3.84.0 (2024-09-12)
Expand Down
10 changes: 10 additions & 0 deletions lib/roda/plugins/Integer_matcher_max.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,16 @@ def self.configure(app, max=nil)
private :_match_class_max_Integer
end
end
app.opts[:Integer_matcher_max] = max || 9223372036854775807
end

module ClassMethods
# Integrate with class matchers created by using class_matcher
# with the Integer class as the matcher.
def match_class_convert_Integer(i)
value = i.to_i
value if value <= opts[:Integer_matcher_max]
end
end

module RequestMethods
Expand Down
117 changes: 111 additions & 6 deletions lib/roda/plugins/class_matchers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -40,19 +40,124 @@ module RodaPlugins
# [Date.new(y, m, d)] if Date.valid_date?(y, m, d)
# end
#
# The second argument to class_matcher can be a class already registered
# as a class matcher. This can DRY up code that wants a conversion
# performed by an existing class matcher:
#
# class_matcher Employee, Integer do |id|
# Employee[id]
# end
#
# With the above example, the Integer matcher performs the conversion to
# integer, so +id+ is yielded as an integer. The block then looks up the
# employee with that id. If there is no employee with that id, then
# the Employee matcher will not match.
#
# If using the symbol_matchers plugin, you can provide a recognized symbol
# matcher as the second argument to class_matcher, and it will work in
# a similar manner:
#
# symbol_matcher(:employee_id, /E-(\d{6})/) do |employee_id|
# employee_id.to_i
# end
# class_matcher Employee, :employee_id do |id|
# Employee[id]
# end
#
# This plugin does not work with the params_capturing plugin, as it does not
# offer the ability to associate block arguments with named keys.
module ClassMatchers
def self.configure(app)
app.opts[:class_matchers] ||= {
Integer=>[/(\d{1,100})/, /\A\/(\d{1,100})(?=\/|\z)/, proc{|i| if i = app.match_class_convert_Integer(i); [i] end}].freeze,
String=>[/([^\/]+)/, /\A\/([^\/]+)(?=\/|\z)/, nil].freeze
}
end

module ClassMethods
# Set the regexp to use for the given class. The block given will be
# called with all matched values from the regexp, and should return an
# array with the captures to yield to the match block.
def class_matcher(klass, re, &block)
# Set the matcher and block to use for the given class.
# The matcher can be a regexp, registered class matcher, or registered symbol
# matcher (if using the symbol_matchers plugin).
#
# If providing a regexp, the block given will be called with all regexp captures.
# If providing a registered class or symbol, the block will be called with the
# captures returned by the block for the registered class or symbol, or the regexp
# captures if no block was registered with the class or symbol. In either case,
# if a block is given, it should return an array with the captures to yield to
# the match block.
def class_matcher(klass, matcher, &block)
meth = :"_match_class_#{klass}"
opts = self.opts
self::RodaRequest.class_eval do
consume_re = consume_pattern(re)
define_method(meth){consume(consume_re, &block)}
case matcher
when Regexp
regexp_matcher = matcher
regexp = consume_pattern(matcher)
define_method(meth){consume(regexp, &block)}
when Class
regexp_matcher, regexp, matcher_block = opts[:class_matchers][matcher]
unless regexp
raise RodaError, "unregistered class matcher given to class_matcher: #{matcher.inspect}"
end

block = merge_class_matcher_blocks(block, matcher_block)
define_method(meth){consume(regexp, &block)}
when Symbol
unless opts[:symbol_matchers]
raise RodaError, "cannot provide Symbol matcher to class_matcher unless using symbol_matchers plugin: #{matcher.inspect}"
end

regexp_matcher, regexp, matcher_block = opts[:symbol_matchers][matcher]
unless regexp
raise RodaError, "unregistered symbol matcher given to class_matcher: #{matcher.inspect}"
end

block = merge_class_matcher_blocks(block, matcher_block)
define_method(meth){consume(regexp, &block)}
else
raise RodaError, "unsupported matcher given to class_matcher: #{matcher.inspect}"
end

private meth
opts[:class_matchers][klass] = [regexp_matcher, regexp, block].freeze
nil
end
end

# Integrate with the Integer_matcher_max plugin.
def match_class_convert_Integer(i)
return super if defined?(super)
i.to_i
end

# Freeze the class_matchers hash when freezing the app.
def freeze
opts[:class_matchers].freeze
super
end
end

module RequestClassMethods
private

# If both block and matcher_block are given, return a
# proc that calls matcher block first, and only calls
# block with the return values of matcher_block if
# the matcher_block returns an array.
# Otherwise, return matcher_block or block.
def merge_class_matcher_blocks(block, matcher_block)
if matcher_block
if block
proc do |*a|
if captures = matcher_block.call(*a)
block.call(*captures)
end
end
else
matcher_block
end
elsif block
block
end
end
end
Expand Down
4 changes: 4 additions & 0 deletions lib/roda/plugins/placeholder_string_matchers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ module RodaPlugins
#
# r.is "foo", String
# r.is "foo", :bar
#
# If used with the symbol_matchers plugin, this plugin respects the regexps
# for the registered symbols, but it does not perform the conversions, the
# captures for the regexp are used directly as the captures for the match method.
module PlaceholderStringMatchers
def self.load_dependencies(app)
app.plugin :_symbol_regexp_matchers
Expand Down
105 changes: 98 additions & 7 deletions lib/roda/plugins/symbol_matchers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ module RodaPlugins
#
# :d :: <tt>/(\d+)/</tt>, a decimal segment
# :rest :: <tt>/(.*)/</tt>, all remaining characters, if any
# :w :: <tt>/(\w+)/</tt>, a alphanumeric segment
# :w :: <tt>/(\w+)/</tt>, an alphanumeric segment
#
# If the placeholder_string_matchers plugin is loaded, this feature also applies to
# placeholders in strings, so the following:
Expand Down Expand Up @@ -64,27 +64,118 @@ module RodaPlugins
# [Date.new(y, m, d)] if Date.valid_date?(y, m, d)
# end
#
# However, if providing a block to the symbol_matchers plugin, the symbol may
# The second argument to symbol_matcher can be a symbol already registered
# as a symbol matcher. This can DRY up code that wants a conversion
# performed by an existing class matcher or to use the same regexp:
#
# symbol_matcher :employee_id, :d do |id|
# id.to_i
# end
# symbol_matcher :employee, :employee_id do |id|
# Employee[id]
# end
#
# With the above example, the :d matcher matches only decimal strings, but
# yields them as string. The registered :employee_id matcher converts the
# decimal string to an integer. The registered :employee matcher builds
# on that and uses the integer to lookup the related employee. If there is
# no employee with that id, then the :employee matcher will not match.
#
# If using the class_matchers plugin, you can provide a recognized class
# matcher as the second argument to symbol_matcher, and it will work in
# a similar manner:
#
# symbol_matcher :employee, Integer do |id|
# Employee[id]
# end
#
# If providing a block to the symbol_matchers plugin, the symbol may
# not work with the params_capturing plugin.
module SymbolMatchers
def self.load_dependencies(app)
app.plugin :_symbol_regexp_matchers
end

def self.configure(app)
app.opts[:symbol_matchers] ||= {}
app.symbol_matcher(:d, /(\d+)/)
app.symbol_matcher(:w, /(\w+)/)
app.symbol_matcher(:rest, /(.*)/)
end

module ClassMethods
# Set the regexp to use for the given symbol, instead of the default.
def symbol_matcher(s, re, &block)
# Set the matcher and block to use for the given class.
# The matcher can be a regexp, registered symbol matcher, or registered class
# matcher (if using the class_matchers plugin).
#
# If providing a regexp, the block given will be called with all regexp captures.
# If providing a registered symbol or class, the block will be called with the
# captures returned by the block for the registered symbol or class, or the regexp
# captures if no block was registered with the symbol or class. In either case,
# if a block is given, it should return an array with the captures to yield to
# the match block.
def symbol_matcher(s, matcher, &block)
meth = :"match_symbol_#{s}"
array = [re, block].freeze

case matcher
when Regexp
regexp = matcher
consume_regexp = self::RodaRequest.send(:consume_pattern, regexp)
when Symbol
regexp, consume_regexp, matcher_block = opts[:symbol_matchers][matcher]

unless regexp
raise RodaError, "unregistered symbol matcher given to symbol_matcher: #{matcher.inspect}"
end

block = merge_symbol_matcher_blocks(block, matcher_block)
when Class
unless opts[:class_matchers]
raise RodaError, "cannot provide Class matcher to symbol_matcher unless using class_matchers plugin: #{matcher.inspect}"
end

regexp, consume_regexp, matcher_block = opts[:class_matchers][matcher]
unless regexp
raise RodaError, "unregistered class matcher given to symbol_matcher: #{matcher.inspect}"
end
block = merge_symbol_matcher_blocks(block, matcher_block)
else
raise RodaError, "unsupported matcher given to symbol_matcher: #{matcher.inspect}"
end

array = opts[:symbol_matchers][s] = [regexp, consume_regexp, block].freeze
self::RodaRequest.send(:define_method, meth){array}
self::RodaRequest.send(:private, meth)
end

# Freeze the class_matchers hash when freezing the app.
def freeze
opts[:symbol_matchers].freeze
super
end

private

# If both block and matcher_block are given, return a
# proc that calls matcher block first, and only calls
# block with the return values of matcher_block if
# the matcher_block returns an array.
# Otherwise, return matcher_block or block.
def merge_symbol_matcher_blocks(block, matcher_block)
if matcher_block
if block
proc do |*a|
if captures = matcher_block.call(*a)
block.call(*captures)
end
end
else
matcher_block
end
elsif block
block
end
end
end

module RequestMethods
Expand All @@ -97,8 +188,8 @@ def _match_symbol(s)
meth = :"match_symbol_#{s}"
if respond_to?(meth, true)
# Allow calling private match methods
re, block = send(meth)
consume(self.class.cached_matcher(re){re}, &block)
_, re, block = send(meth)
consume(re, &block)
else
super
end
Expand Down
Loading

0 comments on commit e0854b9

Please sign in to comment.