pg_sql_caller 0.2.2 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +92 -0
- data/README.md +356 -28
- data/lib/pg_sql_caller/base.rb +45 -137
- data/lib/pg_sql_caller/bulk_update.rb +193 -0
- data/lib/pg_sql_caller/model.rb +304 -0
- data/lib/pg_sql_caller/version.rb +1 -1
- data/lib/pg_sql_caller.rb +2 -0
- metadata +27 -24
- data/.gitignore +0 -14
- data/.rspec +0 -3
- data/.rubocop.yml +0 -71
- data/.travis.yml +0 -12
- data/CODE_OF_CONDUCT.md +0 -74
- data/Gemfile +0 -12
- data/Rakefile +0 -10
- data/bin/console +0 -15
- data/bin/setup +0 -8
- data/sql_caller.gemspec +0 -32
data/lib/pg_sql_caller/base.rb
CHANGED
|
@@ -1,158 +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
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
#
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
value = result.first.values.first
|
|
93
|
-
deserialize_result(result, key, value)
|
|
94
|
-
end
|
|
95
|
-
|
|
96
|
-
def select_values_serialized(sql, *bindings)
|
|
97
|
-
result = select_all(sql, *bindings)
|
|
98
|
-
result.map do |row|
|
|
99
|
-
row.map { |key, value| deserialize_result(result, key, value) }
|
|
100
|
-
end
|
|
101
|
-
end
|
|
102
|
-
|
|
103
|
-
def next_sequence_value(table_name)
|
|
104
|
-
select_value("SELECT last_value FROM #{table_name}_id_seq") + 1
|
|
105
|
-
end
|
|
106
|
-
|
|
107
|
-
def table_full_size(table_name)
|
|
108
|
-
select_value('SELECT pg_total_relation_size(?)', table_name)
|
|
109
|
-
end
|
|
110
50
|
|
|
111
|
-
|
|
112
|
-
select_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
|
|
113
56
|
end
|
|
114
57
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
connection.transaction { yield }
|
|
123
|
-
end
|
|
124
|
-
|
|
125
|
-
def explain_analyze(sql)
|
|
126
|
-
result = select_values("EXPLAIN ANALYZE #{sql}")
|
|
127
|
-
['QUERY_PLAN', *result].join("\n")
|
|
128
|
-
end
|
|
129
|
-
|
|
130
|
-
def typecast_array(values, type:)
|
|
131
|
-
type = ActiveRecord::Type.lookup(type, array: true)
|
|
132
|
-
data = type.serialize(values)
|
|
133
|
-
data.encoder.encode(data.values)
|
|
134
|
-
end
|
|
135
|
-
|
|
136
|
-
def sanitize_sql_array(sql, *bindings)
|
|
137
|
-
model_class.send :sanitize_sql_array, bindings.unshift(sql)
|
|
138
|
-
end
|
|
139
|
-
|
|
140
|
-
def current_database_name
|
|
141
|
-
select_value('SELECT current_database();')
|
|
142
|
-
end
|
|
143
|
-
|
|
144
|
-
private
|
|
145
|
-
|
|
146
|
-
def deserialize_result(result, column_name, raw_value)
|
|
147
|
-
column_type = result.column_types[column_name]
|
|
148
|
-
return raw_value if column_type.nil?
|
|
149
|
-
|
|
150
|
-
column_type.deserialize(raw_value)
|
|
151
|
-
end
|
|
152
|
-
|
|
153
|
-
def model_class
|
|
154
|
-
return @model_class if defined?(@model_class)
|
|
155
|
-
|
|
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
|
|
156
64
|
raise NotImplementedError, "define model_class in #{self.class}" if _model_class.nil?
|
|
157
65
|
|
|
158
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
|