# frozen-string-literal: true
module Sequel
module Plugins
# The finder plugin adds Model.finder for defining optimized finder methods.
# There are two ways to use this. The recommended way is to pass a symbol
# that represents a model class method that returns a dataset:
#
# def Artist.by_name(name)
# where(name: name)
# end
#
# Artist.finder :by_name
#
# This creates an optimized first_by_name method, which you can call normally:
#
# Artist.first_by_name("Joe")
#
# The alternative way to use this to pass your own block:
#
# Artist.finder(name: :first_by_name){|pl, ds| ds.where(name: pl.arg).limit(1)}
#
# Additionally, there is a Model.prepared_finder method. This works similarly
# to Model.finder, but uses a prepared statement. This limits the types of
# arguments that will be accepted, but can perform better in the database.
#
# Usage:
#
# # Make all model subclasses support Model.finder
# # (called before loading subclasses)
# Sequel::Model.plugin :finder
#
# # Make the Album class support Model.finder
# Album.plugin :finder
module Finder
FINDER_TYPES = [:first, :all, :each, :get].freeze
def self.apply(model)
model.instance_exec do
@finders ||= {}
@finder_loaders ||= {}
end
end
module ClassMethods
# Create an optimized finder method using a dataset placeholder literalizer.
# This pre-computes the SQL to use for the query, except for given arguments.
#
# There are two ways to use this. The recommended way is to pass a symbol
# that represents a model class method that returns a dataset:
#
# def Artist.by_name(name)
# where(name: name)
# end
#
# Artist.finder :by_name
#
# This creates an optimized first_by_name method, which you can call normally:
#
# Artist.first_by_name("Joe")
#
# The alternative way to use this to pass your own block:
#
# Artist.finder(name: :first_by_name){|pl, ds| ds.where(name: pl.arg).limit(1)}
#
# Note that if you pass your own block, you are responsible for manually setting
# limits if necessary (as shown above).
#
# Options:
# :arity :: When using a symbol method name, this specifies the arity of the method.
# This should be used if if the method accepts an arbitrary number of arguments,
# or the method has default argument values. Note that if the method is defined
# as a dataset method, the class method Sequel creates accepts an arbitrary number
# of arguments, so you should use this option in that case. If you want to handle
# multiple possible arities, you need to call the finder method multiple times with
# unique :arity and :name methods each time.
# :name :: The name of the method to create. This must be given if you pass a block.
# If you use a symbol, this defaults to the symbol prefixed by the type.
# :mod :: The module in which to create the finder method. Defaults to the singleton
# class of the model.
# :type :: The type of query to run. Can be :first, :each, :all, or :get, defaults to
# :first.
#
# Caveats:
#
# This doesn't handle all possible cases. For example, if you have a method such as:
#
# def Artist.by_name(name)
# name ? where(name: name) : exclude(name: nil)
# end
#
# Then calling a finder without an argument will not work as you expect.
#
# Artist.finder :by_name
# Artist.by_name(nil).first
# # WHERE (name IS NOT NULL)
# Artist.first_by_name(nil)
# # WHERE (name IS NULL)
#
# See Dataset::PlaceholderLiteralizer for additional caveats. Note that if the model's
# dataset does not support placeholder literalizers, you will not be able to use this
# method.
def finder(meth=OPTS, opts=OPTS, &block)
if block
raise Error, "cannot pass both a method name argument and a block of Model.finder" unless meth.is_a?(Hash)
raise Error, "cannot pass two option hashes to Model.finder" unless opts.equal?(OPTS)
opts = meth
raise Error, "must provide method name via :name option when passing block to Model.finder" unless meth_name = opts[:name]
end
type = opts.fetch(:type, :first)
unless prepare = opts[:prepare]
raise Error, ":type option to Model.finder must be :first, :all, :each, or :get" unless FINDER_TYPES.include?(type)
end
limit1 = type == :first || type == :get
meth_name ||= opts[:name] || :"#{type}_#{meth}"
argn = lambda do |model|
if arity = opts[:arity]
arity
else
method = block || model.method(meth)
(method.arity < 0 ? method.arity.abs - 1 : method.arity)
end
end
loader_proc = if prepare
proc do |model|
args = prepare_method_args('$a', argn.call(model))
ds = if block
model.instance_exec(*args, &block)
else
model.public_send(meth, *args)
end
ds = ds.limit(1) if limit1
model_name = model.name
if model_name.to_s.empty?
model_name = model.object_id
else
model_name = model_name.gsub(/\W/, '_')
end
ds.prepare(type, :"#{model_name}_#{meth_name}")
end
else
proc do |model|
n = argn.call(model)
block ||= lambda do |pl, model2|
args = (0...n).map{pl.arg}
ds = model2.public_send(meth, *args)
ds = ds.limit(1) if limit1
ds
end
model.dataset.placeholder_literalizer_class.loader(model, &block)
end
end
@finder_loaders[meth_name] = loader_proc
mod = opts[:mod] || singleton_class
if prepare
def_prepare_method(mod, meth_name)
else
def_finder_method(mod, meth_name, type)
end
end
def freeze
@finder_loaders.freeze
@finder_loaders.each_key{|k| finder_for(k)} if @dataset
@finders.freeze
super
end
# Similar to finder, but uses a prepared statement instead of a placeholder
# literalizer. This makes the SQL used static (cannot vary per call), but
# allows binding argument values instead of literalizing them into the SQL
# query string.
#
# If a block is used with this method, it is instance_execed by the model,
# and should accept the desired number of placeholder arguments.
#
# The options are the same as the options for finder, with the following
# exception:
# :type :: Specifies the type of prepared statement to create
def prepared_finder(meth=OPTS, opts=OPTS, &block)
if block
raise Error, "cannot pass both a method name argument and a block of Model.finder" unless meth.is_a?(Hash)
meth = meth.merge(:prepare=>true)
else
opts = opts.merge(:prepare=>true)
end
finder(meth, opts, &block)
end
Plugins.inherited_instance_variables(self, :@finders=>:dup, :@finder_loaders=>:dup)
private
# Define a finder method in the given module with the given method name that
# load rows using the finder with the given name.
def def_finder_method(mod, meth, type)
mod.send(:define_method, meth){|*args, &block| finder_for(meth).public_send(type, *args, &block)}
end
# Define a prepared_finder method in the given module that will call the associated prepared
# statement.
def def_prepare_method(mod, meth)
mod.send(:define_method, meth){|*args, &block| finder_for(meth).call(prepare_method_arg_hash(args), &block)}
end
# Find the finder to use for the give method. If a finder has not been loaded
# for the method, load the finder and set correctly in the finders hash, then
# return the finder.
def finder_for(meth)
unless finder = (frozen? ? @finders[meth] : Sequel.synchronize{@finders[meth]})
finder_loader = @finder_loaders.fetch(meth)
finder = finder_loader.call(self)
Sequel.synchronize{@finders[meth] = finder}
end
finder
end
# An hash of prepared argument values for the given arguments, with keys
# starting at a. Used by the methods created by prepared_finder.
def prepare_method_arg_hash(args)
h = {}
prepare_method_args('a', args.length).zip(args).each{|k, v| h[k] = v}
h
end
# An array of prepared statement argument names, of length n and starting with base.
def prepare_method_args(base, n)
(0...n).map do
s = base.to_sym
base = base.next
s
end
end
# Clear any finders when reseting the instance dataset
def reset_instance_dataset
Sequel.synchronize{@finders.clear} if @finders && !@finders.frozen?
super
end
end
end
end
end