pluckr 0.1.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.
@@ -0,0 +1,414 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pluckr
4
+ # Immutable, chainable wrapper around the root ActiveRecord relation.
5
+ #
6
+ # Root filtering/ordering/pagination is delegated to ActiveRecord - Pluckr
7
+ # only adds its projections and joins when the query is compiled.
8
+ class Relation
9
+ include Enumerable
10
+
11
+ attr_reader :query_class, :relation
12
+
13
+ def initialize(query_class, relation)
14
+ @query_class = query_class
15
+ @relation = relation
16
+ freeze
17
+ end
18
+
19
+ # `where` with no arguments returns a chain, so `where.not(...)` works like
20
+ # it does on an ActiveRecord relation.
21
+ def where(*args, **options, &block)
22
+ return WhereChain.new(self) if args.empty? && options.empty? && block.nil?
23
+
24
+ spawn(relation.where(*args, **options, &block))
25
+ end
26
+
27
+ def order(...)
28
+ spawn(relation.order(...))
29
+ end
30
+
31
+ def limit(value)
32
+ spawn(relation.limit(value))
33
+ end
34
+
35
+ def offset(value)
36
+ spawn(relation.offset(value))
37
+ end
38
+
39
+ def to_sql
40
+ query_class.compiler.apply(relation).to_sql
41
+ end
42
+
43
+ # One statement, one round trip, plain result objects.
44
+ def fetch
45
+ builder = query_class.result_builder
46
+ rows = Pluckr.select_all(query_class.compiler.connection, to_sql, name: query_class.name)
47
+ rows.map { |row| builder.build(row) }
48
+ end
49
+ alias to_a fetch
50
+
51
+ def each(&block)
52
+ fetch.each(&block)
53
+ end
54
+
55
+ # Enumerable would answer these by building every result object first, which
56
+ # is the opposite of the point. They are questions about the root rows, so
57
+ # ActiveRecord answers them with `COUNT(*)`/`SELECT 1`, without Pluckr's
58
+ # projections.
59
+ # A block (or an Enumerable pattern) is a question about the results, so it
60
+ # goes back to Enumerable - exactly where ActiveRecord sends it.
61
+ def count(*args)
62
+ return super if block_given?
63
+
64
+ relation.count(*args)
65
+ end
66
+ alias size count
67
+
68
+ def exists?(...)
69
+ relation.exists?(...)
70
+ end
71
+
72
+ def any?(*args)
73
+ return super if args.any? || block_given?
74
+
75
+ exists?
76
+ end
77
+
78
+ def none?(*args)
79
+ return super if args.any? || block_given?
80
+
81
+ !exists?
82
+ end
83
+
84
+ def empty?
85
+ !exists?
86
+ end
87
+
88
+ def explain(...)
89
+ query_class.compiler.apply(relation).explain(...)
90
+ end
91
+
92
+ # ActiveRecord orders by primary key when the relation has no order of its
93
+ # own, so that `first` means something. Same here.
94
+ def first(limit = nil)
95
+ results = ordered_by_primary_key.limit(within_limit(limit || 1)).fetch
96
+
97
+ limit ? results : results.first
98
+ end
99
+
100
+ def first!
101
+ first || raise(ActiveRecord::RecordNotFound, "Couldn't find #{model.name}")
102
+ end
103
+
104
+ # The other end of the same order, read in one `LIMIT` rather than by
105
+ # hydrating the chain and dropping all but the tail. A chain that already
106
+ # pages is the exception ActiveRecord makes too: its own `limit` cannot be
107
+ # replaced by ours, so the page is read and the tail taken from it.
108
+ def last(limit = nil)
109
+ return last_of_page(limit) if paginated?(relation)
110
+
111
+ results = spawn(ordered_by_primary_key.relation.reverse_order).limit(limit || 1).fetch
112
+
113
+ limit ? results.reverse : results.first
114
+ end
115
+
116
+ def last!
117
+ last || raise(ActiveRecord::RecordNotFound, "Couldn't find #{model.name}")
118
+ end
119
+
120
+ # Enumerable's `take` would read the whole chain to keep the front of it.
121
+ def take(limit = nil)
122
+ results = self.limit(limit || 1).fetch
123
+
124
+ limit ? results : results.first
125
+ end
126
+
127
+ def take!
128
+ take || raise(ActiveRecord::RecordNotFound, "Couldn't find #{model.name}")
129
+ end
130
+
131
+ # Two rows is all it takes to answer either question - unless the chain
132
+ # already limits itself to fewer, in which case that limit is the answer.
133
+ def one?(*args)
134
+ return super if args.any? || block_given?
135
+
136
+ limited_count == 1
137
+ end
138
+
139
+ def many?(*args)
140
+ return super if args.any? || block_given?
141
+
142
+ limited_count > 1
143
+ end
144
+
145
+ # `find` is the primary key lookup; a block is Enumerable's `find`, which is
146
+ # where ActiveRecord sends it too. Several ids (or an array of them) come
147
+ # back as an array, in the order they were asked for.
148
+ def find(*args, &block)
149
+ return super if block
150
+ raise ArgumentError, "wrong number of arguments (given 0, expected 1+)" if args.empty?
151
+
152
+ key = single_primary_key
153
+ ids = args.size == 1 ? args.first : args
154
+ return for_ids(ids, key) if ids.is_a?(Array)
155
+ raise ActiveRecord::RecordNotFound, "Couldn't find #{model.name} without an ID" if ids.nil?
156
+
157
+ where(key => ids).first || raise(not_found(key, ids))
158
+ end
159
+
160
+ # The arity is ActiveRecord's: at least one condition, or `ArgumentError`.
161
+ def find_by(arg, *args)
162
+ where(arg, *args).first
163
+ end
164
+
165
+ def find_by!(arg, *args)
166
+ find_by(arg, *args) ||
167
+ raise(ActiveRecord::RecordNotFound,
168
+ "Couldn't find #{model.name} with #{[arg, *args].map(&:inspect).join(", ")}")
169
+ end
170
+
171
+ # Reads the whole chain in pages, so an export does not build every result
172
+ # object at once. Like ActiveRecord's, the batches are ordered by primary
173
+ # key and any order of your own is ignored - unlike ActiveRecord's, the page
174
+ # after the first is a keyset seek (`id > last`), never an OFFSET.
175
+ def find_each(batch_size: 1_000, &block)
176
+ assert_batchable!(batch_size)
177
+ return to_enum(:find_each, batch_size: batch_size) unless block_given?
178
+
179
+ in_batches(of: batch_size) { |results| results.each(&block) }
180
+ end
181
+
182
+ def in_batches(of: 1_000)
183
+ assert_batchable!(of)
184
+ return to_enum(:in_batches, of: of) unless block_given?
185
+
186
+ key = single_primary_key
187
+ output = primary_key_output(key)
188
+ scope = spawn(relation.reorder(key => :asc))
189
+ cursor = nil
190
+
191
+ loop do
192
+ page = cursor ? scope.where(model.arel_table[key].gt(cursor)) : scope
193
+ results = page.limit(of).fetch
194
+ break if results.empty?
195
+
196
+ yield results
197
+ break if results.size < of
198
+
199
+ cursor = results.last.public_send(output)
200
+ end
201
+ end
202
+
203
+ # See Query.for. An unloaded Relation is filtered in SQL (subquery), never
204
+ # loaded into Ruby; a loaded one already has its records, so they are used.
205
+ # A paginated one is resolved to primary keys first - see
206
+ # for_paginated_relation.
207
+ def for(records)
208
+ key = single_primary_key
209
+
210
+ if records.is_a?(self.class)
211
+ raise ConfigurationError, "`for` expects #{model.name} records, not another Pluckr query; use `fetch`"
212
+ end
213
+
214
+ return for_relation(records, key) if records.is_a?(ActiveRecord::Relation) && !records.loaded?
215
+ return for_many(records.to_a, key) if records.is_a?(Enumerable)
216
+
217
+ for_one(records, key)
218
+ end
219
+
220
+ def inspect
221
+ "#<Pluckr::Relation #{query_class.name} #{to_sql}>"
222
+ end
223
+
224
+ def spawn(new_relation)
225
+ self.class.new(query_class, new_relation)
226
+ end
227
+
228
+ # Mirrors ActiveRecord::QueryMethods::WhereChain for the operations Pluckr
229
+ # supports on the root relation.
230
+ class WhereChain
231
+ def initialize(pluckr_relation)
232
+ @pluckr_relation = pluckr_relation
233
+ end
234
+
235
+ def not(...)
236
+ @pluckr_relation.spawn(@pluckr_relation.relation.where.not(...))
237
+ end
238
+ end
239
+
240
+ private
241
+
242
+ def model
243
+ query_class.source_model
244
+ end
245
+
246
+ # Reading the front of a page cannot reach past its end, the way
247
+ # ActiveRecord's `find_nth_with_limit` clamps.
248
+ def within_limit(wanted)
249
+ relation.limit_value ? [relation.limit_value, wanted].min : wanted
250
+ end
251
+
252
+ # Both mirror ActiveRecord: a `limit` of the caller's own is never widened
253
+ # to make an answer easier to reach.
254
+ def limited_count
255
+ relation.limit_value ? relation.count : relation.limit(2).count
256
+ end
257
+
258
+ def last_of_page(limit)
259
+ results = ordered_by_primary_key.fetch
260
+
261
+ limit ? results.last(limit) : results.last
262
+ end
263
+
264
+ # Qualified, because `one` nodes join tables that have an `id` too, and per
265
+ # column, because a primary key can be composite.
266
+ def ordered_by_primary_key
267
+ return self unless relation.order_values.empty?
268
+
269
+ order(*Array(model.primary_key).map { |key| model.arel_table[key].asc })
270
+ end
271
+
272
+ def single_primary_key
273
+ key = model.primary_key
274
+ if key.is_a?(Array)
275
+ raise ConfigurationError,
276
+ "#{model.name} has a composite primary key, which Pluckr cannot look a row up by; " \
277
+ "use `where(...)` with `fetch`/`first` instead"
278
+ end
279
+
280
+ key
281
+ end
282
+
283
+ def for_one(record, key)
284
+ assert_record!(record)
285
+ find(record[key])
286
+ end
287
+
288
+ def for_many(records, key)
289
+ records.each { |record| assert_record!(record) }
290
+ for_ids(records.map { |record| record[key] }, key)
291
+ end
292
+
293
+ # Records the caller handed over must all come back - a missing one is a
294
+ # deleted record, and silence would hide it. A relation is a set of
295
+ # conditions instead, so rows the query itself filters out are simply not
296
+ # part of the answer, exactly as they are not for an unpaginated one.
297
+ def for_ids(ids, key, strict: true)
298
+ return [] if ids.empty?
299
+
300
+ index = index_by_primary_key(where(key => ids).fetch, key)
301
+ return ids.filter_map { |id| index[id] } unless strict
302
+
303
+ ids.map { |id| index[id] || raise(not_found(key, id)) }
304
+ end
305
+
306
+ # A relation only contributes its conditions here: `select`/`order` and the
307
+ # preloading of rows Pluckr never instantiates are dropped, and `lock` is
308
+ # not carried into a read model. What cannot be dropped without changing
309
+ # which rows come back has to be loaded by the caller instead.
310
+ UNUSED_RELATION_VALUES = %i[select includes eager_load preload order lock].freeze
311
+
312
+ # A paginated relation keeps its order, so it cannot lose it to a subquery -
313
+ # nor to the joins that order may depend on. Only the projection and the
314
+ # lock go; `pluck` ignores a preload it does not need.
315
+ PAGINATED_RELATION_VALUES = %i[select lock].freeze
316
+
317
+ def for_relation(records, key)
318
+ unless records.klass <= model
319
+ raise ConfigurationError,
320
+ "`for` expects a relation for #{model.name}, got #{records.klass}"
321
+ end
322
+
323
+ assert_subqueryable!(records)
324
+ return for_paginated_relation(records, key) if paginated?(records)
325
+ scope = records.except(*UNUSED_RELATION_VALUES)
326
+ # `offset(0)` skips nothing, but ActiveRecord still emits a LIMIT for it,
327
+ # and MySQL cannot subquery a LIMIT at all.
328
+ scope = scope.except(:offset) if scope.offset_value&.zero?
329
+
330
+ where(key => scope.select(key)).fetch
331
+ end
332
+
333
+ def paginated?(records)
334
+ records.limit_value || records.offset_value.to_i.positive?
335
+ end
336
+
337
+ # Which rows a page holds depends on the order the subquery cannot carry -
338
+ # and MySQL rejects `IN (SELECT ... LIMIT ...)` outright. So the page is
339
+ # resolved to primary keys first: one extra, narrow statement, and the page
340
+ # and its order are the relation's own. See `page_keys` for the one shape
341
+ # that has to be loaded to be paged at all.
342
+ def for_paginated_relation(records, key)
343
+ scope = records.except(*PAGINATED_RELATION_VALUES)
344
+
345
+ for_ids(page_keys(scope, key), key, strict: false)
346
+ end
347
+
348
+ # A relation that joins to preload counts its `LIMIT` in parent rows, and
349
+ # only ActiveRecord knows how to page that - `pluck` would join first and
350
+ # cut the page mid fan-out, handing back duplicates and too many of them.
351
+ # So that page is resolved the way ActiveRecord resolves it, by loading it;
352
+ # the caller asked for preloaded records anyway. Every other relation keeps
353
+ # the cheap read: keys only, nothing instantiated.
354
+ def page_keys(scope, key)
355
+ return scope.pluck(key) unless scope.eager_loading? || scope.includes_values.any?
356
+
357
+ scope.map { |record| record[key] }
358
+ end
359
+
360
+ # A grouped relation selects the primary key it does not group by, which is
361
+ # not valid SQL - as a subquery or plucked.
362
+ def assert_subqueryable!(records)
363
+ return if records.group_values.empty?
364
+
365
+ raise ConfigurationError,
366
+ "`for` cannot subquery a relation with group/having; pass the records themselves: " \
367
+ "`for(relation.to_a)`"
368
+ end
369
+
370
+ # Batching owns the ordering and the paging, so a chain that already pages
371
+ # would silently get a different answer than `fetch` gives. A page size that
372
+ # is not a positive integer reads nothing at all, or reads everything.
373
+ def assert_batchable!(size)
374
+ unless size.is_a?(Integer) && size.positive?
375
+ raise ArgumentError, "page size must be a positive integer, got #{size.inspect}"
376
+ end
377
+
378
+ return unless paginated?(relation)
379
+
380
+ raise ConfigurationError,
381
+ "`find_each`/`in_batches` page the whole chain themselves; " \
382
+ "drop the `limit`/`offset` or use `fetch`"
383
+ end
384
+
385
+ def not_found(key, id)
386
+ ActiveRecord::RecordNotFound.new(
387
+ "Couldn't find #{model.name} with '#{key}'=#{id}", model.name, key, id
388
+ )
389
+ end
390
+
391
+ def index_by_primary_key(results, key)
392
+ output = primary_key_output(key)
393
+ results.to_h { |row| [row[output], row] }
394
+ end
395
+
396
+ # Bulk `.for` reorders rows to match the input and batching seeks past the
397
+ # last one read; both need the primary key on the result.
398
+ def primary_key_output(key)
399
+ field = query_class.pluckr_schema.fields.find { |node| node.column.to_s == key.to_s }
400
+ return field.output if field
401
+
402
+ raise ConfigurationError,
403
+ "#{query_class.name} needs `field :#{key}` (or `field :#{key}, as: ...`) " \
404
+ "to align several results with the rows they came from"
405
+ end
406
+
407
+ def assert_record!(record)
408
+ return if record.is_a?(model)
409
+
410
+ got = record.is_a?(ActiveRecord::Base) ? record.class.name : record.inspect
411
+ raise ConfigurationError, "`for` expects #{model.name} records, got #{got}"
412
+ end
413
+ end
414
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pluckr
4
+ module Result
5
+ # Turns one flat database row into nested result objects.
6
+ #
7
+ # Result classes and per-column casters are built once per schema level when
8
+ # the builder is created, so hydration itself is hash lookups plus a cast.
9
+ class Builder
10
+ BOOLEAN = ActiveModel::Type::Boolean.new
11
+ INTEGER = ActiveModel::Type::Integer.new
12
+ # AVG of an integer column is not an integer; ActiveRecord answers a
13
+ # BigDecimal here and so does Pluckr.
14
+ DECIMAL = ActiveRecord::Type::Decimal.new
15
+
16
+ # Casts one column of a row. `default` covers SQL returning NULL where the
17
+ # equivalent ActiveRecord call would not: SUM over no rows is NULL in SQL
18
+ # and 0 in `relation.sum(:column)`.
19
+ Caster = Struct.new(:type, :default) do
20
+ def call(value)
21
+ return default if value.nil?
22
+
23
+ type ? type.deserialize(value) : value
24
+ end
25
+ end
26
+
27
+ def initialize(schema, label: "Pluckr::Result")
28
+ @schema = schema
29
+ @classes = {}
30
+ @casters = {}
31
+ prepare(schema, label)
32
+ end
33
+
34
+ # @param row [Hash] flat row keyed by SQL alias
35
+ def build(row)
36
+ hydrate(@schema, [], row)
37
+ end
38
+
39
+ private
40
+
41
+ def prepare(scope, label)
42
+ @classes[scope] = Object.build_class(scope.outputs, label)
43
+ @casters[scope] = scope.nodes.to_h { |node| [node.output, caster_for(node, scope.model)] }
44
+
45
+ scope.ones.each do |node|
46
+ prepare(node.scope, "#{label}.#{node.output}")
47
+ end
48
+ end
49
+
50
+ # SQLite and MySQL hand back 1/0 where PostgreSQL hands back true/false,
51
+ # and raw SQL carries no column type, so Pluckr casts values itself.
52
+ def caster_for(node, model)
53
+ case node
54
+ when Schema::Field then Caster.new(model.type_for_attribute(node.column))
55
+ when Schema::Exists then Caster.new(BOOLEAN)
56
+ when Schema::Aggregate then aggregate_caster(node)
57
+ end
58
+ end
59
+
60
+ def aggregate_caster(node)
61
+ case node.operation
62
+ when :count then Caster.new(INTEGER)
63
+ when :exists then Caster.new(BOOLEAN)
64
+ when :avg then Caster.new(DECIMAL) # nil for no rows, like `relation.average`
65
+ else
66
+ type = node.target_model.type_for_attribute(node.column)
67
+ # `sum` answers 0 for no rows, like ActiveRecord; `min`/`max` stay nil,
68
+ # because no row means no minimum.
69
+ Caster.new(type, node.operation == :sum ? type.cast(0) : nil)
70
+ end
71
+ end
72
+
73
+ def hydrate(scope, path, row)
74
+ casters = @casters[scope]
75
+ attributes = {}
76
+
77
+ scope.nodes.each do |node|
78
+ attributes[node.output] =
79
+ if node.is_a?(Schema::One)
80
+ hydrate_one(node, path, row)
81
+ else
82
+ casters[node.output].call(row[Aliases.output(path, node.output)])
83
+ end
84
+ end
85
+
86
+ @classes[scope].new(attributes)
87
+ end
88
+
89
+ def hydrate_one(node, path, row)
90
+ child_path = path + [node.output.to_s]
91
+ # No presence marker -> no associated row at all (as opposed to a row
92
+ # whose selected columns happen to be NULL).
93
+ return nil if row[Aliases.presence(child_path)].nil?
94
+
95
+ hydrate(node.scope, child_path, row)
96
+ end
97
+
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pluckr
4
+ module Result
5
+ # Immutable, ActiveRecord-free value object.
6
+ #
7
+ # One subclass is generated per schema level, with a plain reader per
8
+ # selected output. Internal aliases and presence markers never reach here.
9
+ class Object
10
+ # @param outputs [Array<Symbol>] readers this level exposes
11
+ # @param label [String] shown by #inspect
12
+ def self.build_class(outputs, label)
13
+ Class.new(self) do
14
+ define_singleton_method(:pluckr_outputs) { outputs }
15
+ define_singleton_method(:pluckr_label) { label }
16
+
17
+ outputs.each do |output|
18
+ define_method(output) { @attributes[output] }
19
+ end
20
+ end
21
+ end
22
+
23
+ def self.pluckr_outputs
24
+ []
25
+ end
26
+
27
+ def self.pluckr_label
28
+ "Pluckr::Result"
29
+ end
30
+
31
+ def initialize(attributes)
32
+ @attributes = attributes.freeze
33
+ freeze
34
+ end
35
+
36
+ def [](key)
37
+ @attributes[key.to_sym]
38
+ end
39
+
40
+ def to_h
41
+ @attributes.transform_values do |value|
42
+ value.is_a?(Object) ? value.to_h : value
43
+ end
44
+ end
45
+ alias to_hash to_h
46
+
47
+ # Without this, ActiveSupport serialises the object's ivars and
48
+ # `render json: result` emits {"attributes": {...}}. `to_json` is left to
49
+ # ActiveSupport, which routes it back here with the caller's options.
50
+ def as_json(options = nil)
51
+ to_h.as_json(options)
52
+ end
53
+
54
+ def keys
55
+ @attributes.keys
56
+ end
57
+
58
+ def ==(other)
59
+ other.class == self.class && other.to_h == to_h
60
+ end
61
+ alias eql? ==
62
+
63
+ def hash
64
+ [self.class, to_h].hash
65
+ end
66
+
67
+ def inspect
68
+ pairs = @attributes.map { |key, value| "#{key}=#{value.inspect}" }
69
+ "#<#{self.class.pluckr_label} #{pairs.join(", ")}>"
70
+ end
71
+ alias to_s inspect
72
+
73
+ def pretty_print(pp)
74
+ pp.text(inspect)
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pluckr
4
+ module Schema
5
+ # Two flavours share this node:
6
+ #
7
+ # count :videos # correlated: association is set
8
+ # count :users, from: User # independent: model is set
9
+ #
10
+ # Both compile to a scalar subquery in the SELECT list, never to a JOIN +
11
+ # GROUP BY, so root rows are never multiplied.
12
+ class Aggregate < Node
13
+ include Conditions
14
+
15
+ OPERATIONS = %i[count sum avg min max exists].freeze
16
+
17
+ attr_reader :operation, :association, :model, :relation, :column, :where, :scope
18
+
19
+ def initialize(output:, operation:, association: nil, model: nil, relation: nil,
20
+ column: nil, where: nil, scope: nil)
21
+ super(output: output)
22
+ @operation = operation.to_sym
23
+ unless OPERATIONS.include?(@operation)
24
+ raise ConfigurationError, "unsupported aggregate operation `#{operation}`"
25
+ end
26
+
27
+ @association = association
28
+ @model = model
29
+ @relation = relation
30
+ @column = column&.to_sym
31
+ @where = where.respond_to?(:call) ? where : where&.dup&.freeze
32
+ @scope = scope
33
+ freeze
34
+ end
35
+
36
+ # Independent aggregates are not rooted in the source record.
37
+ def independent?
38
+ !model.nil? || !relation.nil?
39
+ end
40
+
41
+ def correlated?
42
+ !association.nil?
43
+ end
44
+
45
+ # True when the subquery must be built from an ActiveRecord relation
46
+ # rather than straight Arel.
47
+ def relation_scoped?
48
+ !scope.nil? || !where.nil?
49
+ end
50
+
51
+
52
+ # Target model for column validation / table resolution.
53
+ def target_model
54
+ model || relation&.klass || association.klass
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pluckr
4
+ module Schema
5
+ # `where:` on an aggregate or existence node.
6
+ #
7
+ # A Hash is validated once, when the schema is defined, and is therefore
8
+ # frozen at load time - `where: { created_at: 1.week.ago.. }` would keep
9
+ # asking about the week before boot, forever.
10
+ #
11
+ # A callable is invoked on every compilation instead, so runtime values stay
12
+ # runtime values:
13
+ #
14
+ # count :recent_orders, from: Order, where: -> { { created_at: 1.week.ago.. } }
15
+ module Conditions
16
+ def dynamic_where?
17
+ where.respond_to?(:call)
18
+ end
19
+
20
+ # @return [Hash, nil] the conditions to apply to this compilation
21
+ def resolved_where
22
+ return where unless dynamic_where?
23
+
24
+ conditions = where.call
25
+
26
+ unless conditions.is_a?(Hash)
27
+ raise ConfigurationError,
28
+ "`#{output}`: a `where:` lambda must return a Hash of column => value, " \
29
+ "got #{conditions.inspect}"
30
+ end
31
+
32
+ conditions.each_key { |key| validate_column!(key) }
33
+ conditions
34
+ end
35
+
36
+ private
37
+
38
+ def validate_column!(name)
39
+ return if target_model.column_names.include?(name.to_s)
40
+
41
+ raise UnknownField, "#{target_model.name} does not have column `#{name}`"
42
+ end
43
+ end
44
+ end
45
+ end