pg_sql_caller 0.2.3 → 1.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.
@@ -1,160 +1,66 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'singleton'
4
- require 'forwardable'
5
4
  require 'active_support/core_ext/class/attribute'
5
+ require 'active_support/core_ext/module/delegation'
6
+ require 'active_support/core_ext/string/inflections'
7
+ require 'pg_sql_caller/model'
6
8
 
7
9
  module PgSqlCaller
8
- class Base
10
+ # Class-level, app-wide facade over a single shared Model instance (a Singleton).
11
+ # Declare the ActiveRecord class once, then call the same SQL methods directly on
12
+ # the class — every call is forwarded to `.instance`.
13
+ #
14
+ # class Sql < PgSqlCaller::Base
15
+ # model_class 'ApplicationRecord' # a String (constantized on first use) or the Class itself
16
+ # end
17
+ #
18
+ # Sql.select_value('SELECT count(*) FROM users WHERE active = ?', true) # => 42
19
+ # Sql.transaction { Sql.execute('DELETE FROM logs') }
20
+ #
21
+ # `PgSqlCaller::Base` can also be configured and used directly:
22
+ #
23
+ # PgSqlCaller::Base.model_class ApplicationRecord
24
+ # PgSqlCaller::Base.current_database # => 'my_db'
25
+ #
26
+ # Every public {PgSqlCaller::Model} instance method is available as a class method here.
27
+ #
28
+ # @see PgSqlCaller::Model
29
+ class Base < Model
9
30
  include Singleton
10
- extend Forwardable
11
- extend SingleForwardable
12
31
 
13
- CONNECTION_SQL_METHODS = [
14
- :select_value,
15
- :select_values,
16
- :execute,
17
- :select_all,
18
- :select_rows
19
- ].freeze
32
+ # @!method self.instance
33
+ # The shared singleton instance (from Ruby's Singleton) that every class-level
34
+ # call is delegated to. Built on first access.
35
+ # @return [PgSqlCaller::Base]
20
36
 
21
37
  class_attribute :_model_class, instance_writer: false
22
38
 
23
39
  class << self
24
- # @names [Array] method names
25
- def delegate(*names, **options)
26
- raise ArgumentError, 'provide at least one method name' if names.empty?
27
-
28
- target = options.fetch(:to)
29
- type = options.fetch(:type, :instance)
30
- raise ArgumentError, ':type can be :single or :instance' unless [:single, :instance].include?(type)
31
-
32
- if type == :instance
33
- instance_delegate names => target
34
- else
35
- single_delegate names => target
36
- end
37
- end
38
-
39
- def define_sql_methods(*names)
40
- names.each do |name|
41
- define_method(name) do |sql, *bindings|
42
- sql = sanitize_sql_array(sql, *bindings) if bindings.any?
43
- connection.send(name, sql)
44
- end
45
- end
46
- end
47
-
48
- # @param klass [Class<ActiveRecord::Base>, String] class or class name
40
+ # Configure which ActiveRecord class backs this caller — the class itself or its
41
+ # name as a String (constantized lazily on first use). Call once, at boot.
42
+ #
43
+ # PgSqlCaller::Base.model_class ApplicationRecord
44
+ #
45
+ # @param klass [Class<ActiveRecord::Base>, String] the class, or its name
46
+ # @return [Class<ActiveRecord::Base>, String] the value just set
49
47
  def model_class(klass)
50
48
  self._model_class = klass
51
49
  end
52
- end
53
-
54
- delegate(
55
- *CONNECTION_SQL_METHODS,
56
- :connection,
57
- :transaction_open?,
58
- :select_all_serialized,
59
- :select_value_serialized,
60
- :select_values_serialized,
61
- :next_sequence_value,
62
- :table_full_size,
63
- :table_data_size,
64
- :select_row,
65
- :transaction,
66
- :explain_analyze,
67
- :typecast_array,
68
- :sanitize_sql_array,
69
- :current_database,
70
- to: :instance,
71
- type: :single
72
- )
73
-
74
- define_sql_methods(*CONNECTION_SQL_METHODS)
75
-
76
- delegate :connection, to: :model_class
77
-
78
- def transaction_open?
79
- connection.send(:transaction_open?)
80
- end
81
-
82
- def select_all_serialized(sql, *bindings)
83
- result = select_all(sql, *bindings)
84
- result.map do |row|
85
- row.map { |key, value| [key.to_sym, deserialize_result(result, key, value)] }.to_h
86
- end
87
- end
88
-
89
- def select_value_serialized(sql, *bindings)
90
- result = select_all(sql, *bindings)
91
- key = result.first&.keys&.first
92
- return if key.nil?
93
50
 
94
- value = result.first.values.first
95
- deserialize_result(result, key, value)
51
+ # Forward any unknown class-level call to the shared Singleton instance —
52
+ # e.g. `Base.select_value(...)` runs `Base.instance.select_value(...)`. This
53
+ # covers every public Model instance method (including ones added later via
54
+ # `define_sql_method`) without maintaining an explicit list.
55
+ delegate_missing_to :instance
96
56
  end
97
57
 
98
- def select_values_serialized(sql, *bindings)
99
- result = select_all(sql, *bindings)
100
- result.map do |row|
101
- row.map { |key, value| deserialize_result(result, key, value) }
102
- end
103
- end
104
-
105
- def next_sequence_value(table_name)
106
- select_value("SELECT last_value FROM #{table_name}_id_seq") + 1
107
- end
108
-
109
- def table_full_size(table_name)
110
- select_value('SELECT pg_total_relation_size(?)', table_name)
111
- end
112
-
113
- def table_data_size(table_name)
114
- select_value('SELECT pg_relation_size(?)', table_name)
115
- end
116
-
117
- def select_row(sql, *bindings)
118
- select_rows(sql, *bindings)[0]
119
- end
120
-
121
- def transaction
122
- raise ArgumentError, 'block must be given' unless block_given?
123
-
124
- connection.transaction { yield }
125
- end
126
-
127
- def explain_analyze(sql)
128
- result = select_values("EXPLAIN ANALYZE #{sql}")
129
- ['QUERY_PLAN', *result].join("\n")
130
- end
131
-
132
- def typecast_array(values, type:)
133
- type = ActiveRecord::Type.lookup(type, array: true)
134
- data = type.serialize(values)
135
- data.encoder.encode(data.values)
136
- end
137
-
138
- def sanitize_sql_array(sql, *bindings)
139
- model_class.send :sanitize_sql_array, bindings.unshift(sql)
140
- end
141
-
142
- def current_database_name
143
- select_value('SELECT current_database();')
144
- end
145
-
146
- private
147
-
148
- def deserialize_result(result, column_name, raw_value)
149
- column_type = result.column_types[column_name]
150
- return raw_value if column_type.nil?
151
-
152
- column_type.deserialize(raw_value)
153
- end
154
-
155
- def model_class
156
- return @model_class if defined?(@model_class)
157
-
58
+ # Build the singleton instance. Invoked once by {.instance}; never called directly
59
+ # (Singleton makes +.new+ private). Resolves the configured {.model_class} name/class
60
+ # into a Class for {#model_class}.
61
+ #
62
+ # @raise [NotImplementedError] if {.model_class} was never configured
63
+ def initialize
158
64
  raise NotImplementedError, "define model_class in #{self.class}" if _model_class.nil?
159
65
 
160
66
  @model_class = _model_class.is_a?(String) ? _model_class.constantize : _model_class
@@ -0,0 +1,239 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/core_ext/string/filters'
4
+ require 'pg_sql_caller/model'
5
+
6
+ module PgSqlCaller
7
+ # Bulk partial-update of existing rows keyed by one or more columns, via
8
+ # `UPDATE ... FROM unnest(...)`:
9
+ #
10
+ # PgSqlCaller::BulkUpdate.call(Employee, [
11
+ # { id: 1, name: 'John', department_id: 10 },
12
+ # { id: 2, name: 'Jane', department_id: 20 }
13
+ # ])
14
+ #
15
+ # Match on a composite key (or any custom set of uniqueness columns) by passing
16
+ # `unique_by` an array instead of a single column:
17
+ #
18
+ # PgSqlCaller::BulkUpdate.call(Employee, attrs_list, unique_by: %i[department_id name])
19
+ #
20
+ # Chosen over `upsert_all`: PostgreSQL NOT NULL-checks the candidate INSERT tuple of
21
+ # `INSERT ... ON CONFLICT DO UPDATE` *before* conflict arbitration, so upsert rejects
22
+ # partial payloads that omit the table's other NOT NULL columns. This join only ever
23
+ # touches the listed columns of rows that already exist.
24
+ #
25
+ # Preferred over N separate `update_all` calls wrapped in a transaction: a transaction
26
+ # makes those writes atomic but does nothing to batch them — it is still N statements,
27
+ # N client<->server round-trips, and N parse/plan cycles. This is a single statement
28
+ # and a single round-trip; PostgreSQL applies the whole set-based update server-side.
29
+ # Round-trip latency dominates the N-call approach as the row count grows, so this stays
30
+ # roughly flat while the loop scales linearly (see
31
+ # spec/pg_sql_caller/bulk_update_spec.rb benchmark).
32
+ #
33
+ # Each column is sent as one typed PostgreSQL array; `unnest` zips the arrays back
34
+ # into rows. Values are bound through ActiveRecord's sanitizer (PgSqlCaller::Model) and
35
+ # never interpolated; the only identifiers placed into the SQL are restricted to the
36
+ # model's own columns, so the statement is injection-safe by construction.
37
+ class BulkUpdate
38
+ # Build and run a bulk update in one call.
39
+ #
40
+ # @param model_class [Class<ActiveRecord::Base>] the model whose table is updated
41
+ # @param attrs_list [Array<Hash>] one hash per row; each MUST include every
42
+ # `unique_by` column, and all hashes MUST share the same keys
43
+ # @param unique_by [Symbol, Array<Symbol>] the match column(s) — a single column,
44
+ # or all parts of a composite key (default +:id+)
45
+ # @param returning [Symbol, Array<Symbol>, nil] column(s) to read back from each
46
+ # updated row via SQL `RETURNING`; +nil+ (default) keeps the row-count behavior
47
+ # @return [Integer, Array<Hash{Symbol => Object}>] the number of rows affected, or —
48
+ # when +returning+ is given — the updated rows as type-cast, Symbol-keyed hashes
49
+ def self.call(model_class, attrs_list, unique_by: :id, returning: nil)
50
+ new(model_class, attrs_list, unique_by: unique_by, returning: returning).call
51
+ end
52
+
53
+ attr_reader :model_class, :unique_by, :attrs_list, :returning
54
+
55
+ # @param model_class [Class<ActiveRecord::Base>] the model whose table is updated
56
+ # @param attrs_list [Array<Hash>] one hash per row; each MUST include every
57
+ # `unique_by` column, and all hashes MUST share the same keys
58
+ # @param unique_by [Symbol, Array<Symbol>] the match column(s) — a single column,
59
+ # or all parts of a composite key (default +:id+)
60
+ # @param returning [Symbol, Array<Symbol>, nil] column(s) to read back from each
61
+ # updated row via SQL `RETURNING`; +nil+ (default) keeps the row-count behavior
62
+ def initialize(model_class, attrs_list, unique_by: :id, returning: nil)
63
+ @model_class = model_class
64
+ @attrs_list = attrs_list
65
+ @unique_by = Array(unique_by)
66
+ @returning = returning.nil? ? nil : Array(returning)
67
+ end
68
+
69
+ # Execute the bulk update as a single `UPDATE ... FROM unnest(...)` statement.
70
+ #
71
+ # @return [Integer, Array<Hash{Symbol => Object}>] without +returning+, the number of
72
+ # rows affected (0 when +attrs_list+ is empty); with +returning+, the updated rows as
73
+ # type-cast, Symbol-keyed hashes (+[]+ when +attrs_list+ is empty)
74
+ # @raise [ArgumentError] if a row omits a `unique_by` column, names a column that does
75
+ # not exist on the model, or +returning+ is empty or names an unknown column
76
+ def call
77
+ validate_returning! unless returning.nil?
78
+ return empty_result if attrs_list.empty?
79
+
80
+ if returning.nil?
81
+ sql_caller.execute(sql, *bindings).cmd_tuples
82
+ else
83
+ sql_caller.select_all_serialized(sql, *bindings)
84
+ end
85
+ end
86
+
87
+ private
88
+
89
+ # The value returned for an empty +attrs_list+: a zero row count, or an empty row set
90
+ # when +returning+ was requested — mirroring the shape {#call} returns when it runs.
91
+ #
92
+ # @return [Integer, Array]
93
+ def empty_result
94
+ returning.nil? ? 0 : []
95
+ end
96
+
97
+ # Validate the requested `RETURNING` columns before any SQL runs: at least one column
98
+ # must be named, and every column must exist on the model (each is qualified with the
99
+ # target alias `t`, so it must be a real column, never an expression).
100
+ #
101
+ # @return [void]
102
+ # @raise [ArgumentError] if +returning+ is empty or names a column unknown to the model
103
+ def validate_returning!
104
+ raise ArgumentError, 'returning must name at least one column' if returning.empty?
105
+
106
+ unknown = returning.map(&:to_s) - model_class.column_names
107
+ raise ArgumentError, "unknown #{model_class} returning columns: #{unknown.join(', ')}" if unknown.any?
108
+ end
109
+
110
+ # The SQL executor, built from the model's own connection: it sanitizes the bound
111
+ # values, runs the statement and encodes the typed PostgreSQL arrays.
112
+ #
113
+ # @return [PgSqlCaller::Model]
114
+ def sql_caller
115
+ @sql_caller ||= PgSqlCaller::Model.new(model_class)
116
+ end
117
+
118
+ # Columns to write, taken from the first row (assumed identical across all rows).
119
+ #
120
+ # @return [Array<Symbol>]
121
+ # @raise [ArgumentError] via {#validate_columns!} when the payload is invalid
122
+ def columns
123
+ @columns ||= attrs_list.first.keys.tap { |cols| validate_columns!(cols) }
124
+ end
125
+
126
+ # The columns actually updated — every column except the `unique_by` match column(s).
127
+ #
128
+ # @return [Array<Symbol>]
129
+ def value_columns
130
+ @value_columns ||= columns - unique_by
131
+ end
132
+
133
+ # Validate the payload's columns before any SQL runs: every `unique_by` column must
134
+ # be present, at least one value column must remain, every column must exist on the
135
+ # model, and every row must carry the same key set as the first row (so no row
136
+ # silently writes NULLs or drops extra keys).
137
+ #
138
+ # @param cols [Array<Symbol>] the columns taken from the first row
139
+ # @return [void]
140
+ # @raise [ArgumentError] if a `unique_by` column is missing, there are no value
141
+ # columns to update, a column is unknown, or a row's keys differ from the first row
142
+ def validate_columns!(cols)
143
+ missing = unique_by - cols
144
+ raise ArgumentError, "attrs_list rows must include unique_by #{missing.inspect}" if missing.any?
145
+
146
+ raise ArgumentError, "attrs_list has no value columns to update (only unique_by #{unique_by.inspect})" if (cols - unique_by).empty?
147
+
148
+ unknown = cols.map(&:to_s) - model_class.column_names
149
+ raise ArgumentError, "unknown #{model_class} columns: #{unknown.join(', ')}" if unknown.any?
150
+
151
+ sorted = cols.sort
152
+ attrs_list.each_with_index do |attrs, index|
153
+ next if attrs.keys.sort == sorted
154
+
155
+ raise ArgumentError, "attrs_list[#{index}] keys #{attrs.keys.inspect} differ from first row #{cols.inspect}"
156
+ end
157
+ end
158
+
159
+ # The full `UPDATE ... FROM unnest(...)` statement, with one `?` placeholder per
160
+ # column for the value arrays, plus a `RETURNING` clause when +returning+ was given.
161
+ #
162
+ # @return [String]
163
+ def sql
164
+ statement = <<~SQL.squish
165
+ UPDATE #{model_class.quoted_table_name} AS t
166
+ SET #{set_clause}
167
+ FROM unnest(#{unnest_args}) AS v(#{column_aliases})
168
+ WHERE #{match_clause}
169
+ SQL
170
+ return statement if returning.nil?
171
+
172
+ "#{statement} RETURNING #{returning_clause}"
173
+ end
174
+
175
+ # The `RETURNING t.col, ...` projection. Each column is qualified with the target
176
+ # alias `t` because the `unnest` source alias `v` shares the same column names, so an
177
+ # unqualified `RETURNING` would be ambiguous.
178
+ #
179
+ # @return [String]
180
+ def returning_clause
181
+ returning.map { |col| "t.#{quoted(col)}" }.join(', ')
182
+ end
183
+
184
+ # The `SET col = v.col, ...` assignments for the value columns.
185
+ #
186
+ # @return [String]
187
+ def set_clause
188
+ value_columns.map { |col| "#{quoted(col)} = v.#{quoted(col)}" }.join(', ')
189
+ end
190
+
191
+ # Match each row on every `unique_by` column — one column, or all parts of a composite key.
192
+ #
193
+ # @return [String] the `WHERE` join condition, e.g. +"t.a = v.a AND t.b = v.b"+
194
+ def match_clause
195
+ unique_by.map { |col| "t.#{quoted(col)} = v.#{quoted(col)}" }.join(' AND ')
196
+ end
197
+
198
+ # One `?` placeholder per column, cast to that column's array type so PostgreSQL
199
+ # can resolve the otherwise-unknown bind parameter.
200
+ #
201
+ # @return [String] e.g. +"?::bigint[], ?::text[]"+
202
+ def unnest_args
203
+ columns.map { |col| "?::#{sql_type(col)}[]" }.join(', ')
204
+ end
205
+
206
+ # The `v(col, ...)` column alias list, in column order.
207
+ #
208
+ # @return [String]
209
+ def column_aliases
210
+ columns.map { |col| quoted(col) }.join(', ')
211
+ end
212
+
213
+ # One PostgreSQL array literal per column, in column order, matching the `?`s above.
214
+ #
215
+ # @return [Array<String>] one encoded array literal per column
216
+ def bindings
217
+ columns.map do |col|
218
+ values = attrs_list.map { |attrs| attrs[col] }
219
+ sql_caller.typecast_array(values, type: model_class.type_for_attribute(col.to_s).type)
220
+ end
221
+ end
222
+
223
+ # The PostgreSQL type of a column, used to build its array cast.
224
+ #
225
+ # @param col [Symbol] a column name
226
+ # @return [String] the column's SQL type (e.g. +"bigint"+, +"timestamp without time zone"+)
227
+ def sql_type(col)
228
+ model_class.columns_hash.fetch(col.to_s).sql_type
229
+ end
230
+
231
+ # Quote a column-name identifier for safe inclusion in the SQL.
232
+ #
233
+ # @param identifier [Symbol, String] a column name
234
+ # @return [String] the quoted identifier
235
+ def quoted(identifier)
236
+ sql_caller.quote_column_name(identifier)
237
+ end
238
+ end
239
+ end