pg_sql_caller 0.2.3 → 1.0.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,193 @@
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
+ # @return [Integer] the number of rows affected
46
+ def self.call(model_class, attrs_list, unique_by: :id)
47
+ new(model_class, attrs_list, unique_by: unique_by).call
48
+ end
49
+
50
+ attr_reader :model_class, :unique_by, :attrs_list
51
+
52
+ # @param model_class [Class<ActiveRecord::Base>] the model whose table is updated
53
+ # @param attrs_list [Array<Hash>] one hash per row; each MUST include every
54
+ # `unique_by` column, and all hashes MUST share the same keys
55
+ # @param unique_by [Symbol, Array<Symbol>] the match column(s) — a single column,
56
+ # or all parts of a composite key (default +:id+)
57
+ def initialize(model_class, attrs_list, unique_by: :id)
58
+ @model_class = model_class
59
+ @attrs_list = attrs_list
60
+ @unique_by = Array(unique_by)
61
+ end
62
+
63
+ # Execute the bulk update as a single `UPDATE ... FROM unnest(...)` statement.
64
+ #
65
+ # @return [Integer] the number of rows affected (0 when +attrs_list+ is empty)
66
+ # @raise [ArgumentError] if a row omits a `unique_by` column, or names a column
67
+ # that does not exist on the model
68
+ def call
69
+ return 0 if attrs_list.empty?
70
+
71
+ sql_caller.execute(sql, *bindings).cmd_tuples
72
+ end
73
+
74
+ private
75
+
76
+ # The SQL executor, built from the model's own connection: it sanitizes the bound
77
+ # values, runs the statement and encodes the typed PostgreSQL arrays.
78
+ #
79
+ # @return [PgSqlCaller::Model]
80
+ def sql_caller
81
+ @sql_caller ||= PgSqlCaller::Model.new(model_class)
82
+ end
83
+
84
+ # Columns to write, taken from the first row (assumed identical across all rows).
85
+ #
86
+ # @return [Array<Symbol>]
87
+ # @raise [ArgumentError] via {#validate_columns!} when the payload is invalid
88
+ def columns
89
+ @columns ||= attrs_list.first.keys.tap { |cols| validate_columns!(cols) }
90
+ end
91
+
92
+ # The columns actually updated — every column except the `unique_by` match column(s).
93
+ #
94
+ # @return [Array<Symbol>]
95
+ def value_columns
96
+ @value_columns ||= columns - unique_by
97
+ end
98
+
99
+ # Validate the payload's columns before any SQL runs: every `unique_by` column must
100
+ # be present, at least one value column must remain, every column must exist on the
101
+ # model, and every row must carry the same key set as the first row (so no row
102
+ # silently writes NULLs or drops extra keys).
103
+ #
104
+ # @param cols [Array<Symbol>] the columns taken from the first row
105
+ # @return [void]
106
+ # @raise [ArgumentError] if a `unique_by` column is missing, there are no value
107
+ # columns to update, a column is unknown, or a row's keys differ from the first row
108
+ def validate_columns!(cols)
109
+ missing = unique_by - cols
110
+ raise ArgumentError, "attrs_list rows must include unique_by #{missing.inspect}" if missing.any?
111
+
112
+ raise ArgumentError, "attrs_list has no value columns to update (only unique_by #{unique_by.inspect})" if (cols - unique_by).empty?
113
+
114
+ unknown = cols.map(&:to_s) - model_class.column_names
115
+ raise ArgumentError, "unknown #{model_class} columns: #{unknown.join(', ')}" if unknown.any?
116
+
117
+ sorted = cols.sort
118
+ attrs_list.each_with_index do |attrs, index|
119
+ next if attrs.keys.sort == sorted
120
+
121
+ raise ArgumentError, "attrs_list[#{index}] keys #{attrs.keys.inspect} differ from first row #{cols.inspect}"
122
+ end
123
+ end
124
+
125
+ # The full `UPDATE ... FROM unnest(...)` statement, with one `?` placeholder per
126
+ # column for the value arrays.
127
+ #
128
+ # @return [String]
129
+ def sql
130
+ <<~SQL.squish
131
+ UPDATE #{model_class.quoted_table_name} AS t
132
+ SET #{set_clause}
133
+ FROM unnest(#{unnest_args}) AS v(#{column_aliases})
134
+ WHERE #{match_clause}
135
+ SQL
136
+ end
137
+
138
+ # The `SET col = v.col, ...` assignments for the value columns.
139
+ #
140
+ # @return [String]
141
+ def set_clause
142
+ value_columns.map { |col| "#{quoted(col)} = v.#{quoted(col)}" }.join(', ')
143
+ end
144
+
145
+ # Match each row on every `unique_by` column — one column, or all parts of a composite key.
146
+ #
147
+ # @return [String] the `WHERE` join condition, e.g. +"t.a = v.a AND t.b = v.b"+
148
+ def match_clause
149
+ unique_by.map { |col| "t.#{quoted(col)} = v.#{quoted(col)}" }.join(' AND ')
150
+ end
151
+
152
+ # One `?` placeholder per column, cast to that column's array type so PostgreSQL
153
+ # can resolve the otherwise-unknown bind parameter.
154
+ #
155
+ # @return [String] e.g. +"?::bigint[], ?::text[]"+
156
+ def unnest_args
157
+ columns.map { |col| "?::#{sql_type(col)}[]" }.join(', ')
158
+ end
159
+
160
+ # The `v(col, ...)` column alias list, in column order.
161
+ #
162
+ # @return [String]
163
+ def column_aliases
164
+ columns.map { |col| quoted(col) }.join(', ')
165
+ end
166
+
167
+ # One PostgreSQL array literal per column, in column order, matching the `?`s above.
168
+ #
169
+ # @return [Array<String>] one encoded array literal per column
170
+ def bindings
171
+ columns.map do |col|
172
+ values = attrs_list.map { |attrs| attrs[col] }
173
+ sql_caller.typecast_array(values, type: model_class.type_for_attribute(col.to_s).type)
174
+ end
175
+ end
176
+
177
+ # The PostgreSQL type of a column, used to build its array cast.
178
+ #
179
+ # @param col [Symbol] a column name
180
+ # @return [String] the column's SQL type (e.g. +"bigint"+, +"timestamp without time zone"+)
181
+ def sql_type(col)
182
+ model_class.columns_hash.fetch(col.to_s).sql_type
183
+ end
184
+
185
+ # Quote a column-name identifier for safe inclusion in the SQL.
186
+ #
187
+ # @param identifier [Symbol, String] a column name
188
+ # @return [String] the quoted identifier
189
+ def quoted(identifier)
190
+ sql_caller.quote_column_name(identifier)
191
+ end
192
+ end
193
+ end