1# frozen_string_literal: true
2
3module WhereComposite
4  extend ActiveSupport::Concern
5
6  class TooManyIds < ArgumentError
7    LIMIT = 100
8
9    def initialize(no_of_ids)
10      super(<<~MSG)
11      At most #{LIMIT} identifier sets at a time please! Got #{no_of_ids}.
12      Have you considered splitting your request into batches?
13      MSG
14    end
15
16    def self.guard(collection)
17      n = collection.size
18      return collection if n <= LIMIT
19
20      raise self, n
21    end
22  end
23
24  class_methods do
25    # Apply a set of constraints that function as composite IDs.
26    #
27    # This is the plural form of the standard ActiveRecord idiom:
28    # `where(foo: x, bar: y)`, except it allows multiple pairs of `x` and
29    # `y` to be specified, with the semantics that translate to:
30    #
31    # ```sql
32    # WHERE
33    #     (foo = x_0 AND bar = y_0)
34    #  OR (foo = x_1 AND bar = y_1)
35    #  OR ...
36    # ```
37    #
38    # or the equivalent:
39    #
40    # ```sql
41    # WHERE
42    #   (foo, bar) IN ((x_0, y_0), (x_1, y_1), ...)
43    # ```
44    #
45    # @param permitted_keys [Array<Symbol>] The keys each hash must have. There
46    #                                       must be at least one key (but really,
47    #                                       it ought to be at least two)
48    # @param hashes [Array<#to_h>|#to_h] The constraints. Each parameter must have a
49    #                                    value for the keys named in `permitted_keys`
50    #
51    # e.g.:
52    # ```
53    #   where_composite(%i[foo bar], [{foo: 1, bar: 2}, {foo: 1, bar: 3}])
54    # ```
55    #
56    def where_composite(permitted_keys, hashes)
57      raise ArgumentError, 'no permitted_keys' unless permitted_keys.present?
58
59      # accept any hash-like thing, such as Structs
60      hashes = TooManyIds.guard(Array.wrap(hashes)).map(&:to_h)
61
62      return none if hashes.empty?
63
64      case permitted_keys.size
65      when 1
66        key = permitted_keys.first
67        where(key => hashes.map { |hash| hash.fetch(key) })
68      else
69        clauses = hashes.map do |hash|
70          permitted_keys.map do |key|
71            arel_table[key].eq(hash.fetch(key))
72          end.reduce(:and)
73        end
74
75        where(clauses.reduce(:or))
76      end
77    rescue KeyError
78      raise ArgumentError, "all arguments must contain #{permitted_keys}"
79    end
80  end
81end
82