File: finder.rb

package info (click to toggle)
ruby-sequel 5.63.0-2
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid, trixie
  • size: 10,408 kB
  • sloc: ruby: 113,747; makefile: 3
file content (248 lines) | stat: -rw-r--r-- 9,827 bytes parent folder | download | duplicates (2)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
# 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

              Sequel::Dataset::PlaceholderLiteralizer.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