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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b23e2a1abd27915a70c5b94e1d58cc4cf31dbc7e09fd5a547e9d0920b2dd3a6d
4
- data.tar.gz: 3d7ebae5a4662ae6cbeee8fc7e8d3abeba142fcc2987477906fafcf5474982d4
3
+ metadata.gz: b4ea6fd0cd19dd9e2d12d963b0c7689cef8346a63f0981e3a96821faebf4f6bc
4
+ data.tar.gz: f2d40a1f3790e084ebdb9e0bf5908241494aaacca55ca8604f18cf05a6dbb8d8
5
5
  SHA512:
6
- metadata.gz: 224ee54d583c10f9b4143f3814b7dc9e387e9ef27065c89d0c05ba85499b49a2f854e6065d74bb5fc96ff94600c7cd74abe0c70ec02c2a5924937546eb99c3af
7
- data.tar.gz: 31e0cb830987de3bef4574b73de6c3b04a75e535c3f994d40e650ed7f47d3e45b22ed0091f4d31caac7e68ceba5ebdadd4454d679117a4636a279eceae480e85
6
+ metadata.gz: 908fcb421cdf254ba4a91cc8c67c304938cfd65ef7a25393ae3019b02e0b84ea634c2fa250fba333e88bd287be02480aafce0cd8f0780e083d3b38800de9ab90
7
+ data.tar.gz: 927f8dc22e285bdf849e85bb850e5e2045429675ccb953b872150b0954ecf95602d6a6c84b52829e9b9af8b29fe8acc016d41be10628ae2181edd29c1df7d9a7
data/CHANGELOG.md ADDED
@@ -0,0 +1,92 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ### Added
11
+
12
+ - `PgSqlCaller::Model` — a standalone, instantiable class holding the SQL API.
13
+ Build one directly with `PgSqlCaller::Model.new(ApplicationRecord)`; `PgSqlCaller::Base`
14
+ is now a thin `Singleton` facade subclassing it.
15
+ - `PgSqlCaller::BulkUpdate` — partial update of many existing rows in a single
16
+ `UPDATE ... FROM unnest(...)` statement and round-trip, with single- or composite-column
17
+ `unique_by` matching and column validation.
18
+ - `quote_value`, `quote_column_name`, and `quote_table_name` quoting helpers.
19
+ - `with_min_messages(level)` — temporarily set the connection's `client_min_messages`
20
+ around a block.
21
+ - `with_notice_processor(callback)` — capture PostgreSQL `NOTICE` output emitted during a block.
22
+ - `define_sql_method` (single-name) helper; the variadic `define_sql_methods` is retained for
23
+ backward compatibility.
24
+ - CI test matrix against Rails 7.1, 7.2, 8.0, and 8.1 (bundled `gemfiles/`).
25
+
26
+ ### Changed
27
+
28
+ - **BREAKING**: Minimum Ruby raised to `>= 3.2.0` (was `>= 2.3.0`).
29
+ - **BREAKING**: `activerecord` and `activesupport` now require `>= 7.1` (previously unconstrained).
30
+ - `PgSqlCaller::Base` now forwards class-level calls to its singleton instance via
31
+ `delegate_missing_to`, so every public `Model` instance method is available as a class
32
+ method automatically.
33
+ - **BREAKING**: `current_database_name` was renamed to `current_database`.
34
+
35
+ ### Removed
36
+
37
+ - **BREAKING**: The custom `Forwardable`-based `delegate` macro on `Base`, superseded by
38
+ ActiveSupport delegation and `delegate_missing_to`.
39
+
40
+ ## [0.2.3] - 2025-02-07
41
+
42
+ ### Fixed
43
+
44
+ - `select_value_serialized` no longer raises when the query returns no rows; it now
45
+ returns `nil`.
46
+
47
+ ## [0.2.2] - 2023-02-08
48
+
49
+ ### Fixed
50
+
51
+ - The class-level `PgSqlCaller::Base.connection` call now resolves correctly (delegated
52
+ to the singleton instance).
53
+
54
+ ## [0.2.1] - 2023-02-08
55
+
56
+ ### Added
57
+
58
+ - `connection` exposed as a public method on the caller.
59
+
60
+ ## [0.2.0] - 2020-12-23
61
+
62
+ ### Added
63
+
64
+ - `select_value_serialized` and `select_values_serialized` type-cast reads.
65
+ - `next_sequence_value` to peek at a table's next sequence value.
66
+ - `table_full_size` and `table_data_size` relation-size helpers.
67
+
68
+ ### Fixed
69
+
70
+ - Serialized reads no longer raise on columns whose type is unknown; the raw value is
71
+ returned unchanged.
72
+
73
+ ### Changed
74
+
75
+ - Homepage moved to the `didww` organization.
76
+
77
+ ## [0.1.0] - 2020-03-24
78
+
79
+ ### Added
80
+
81
+ - Initial release: `PgSqlCaller::Base` singleton facade over an ActiveRecord class,
82
+ with `?`-bound, sanitized SQL helpers — `select_value`, `select_values`, `select_all`,
83
+ `select_rows`, `select_row`, `execute`, `select_all_serialized`, `transaction`,
84
+ `transaction_open?`, `explain_analyze`, `typecast_array`, `sanitize_sql_array`, and
85
+ `current_database_name`.
86
+
87
+ [Unreleased]: https://github.com/didww/pg_sql_caller/compare/v0.2.3...HEAD
88
+ [0.2.3]: https://github.com/didww/pg_sql_caller/compare/v0.2.2...v0.2.3
89
+ [0.2.2]: https://github.com/didww/pg_sql_caller/compare/v0.2.1...v0.2.2
90
+ [0.2.1]: https://github.com/didww/pg_sql_caller/compare/v0.2.0...v0.2.1
91
+ [0.2.0]: https://github.com/didww/pg_sql_caller/compare/v0.1.0...v0.2.0
92
+ [0.1.0]: https://github.com/didww/pg_sql_caller/releases/tag/v0.1.0
data/README.md CHANGED
@@ -1,67 +1,395 @@
1
1
  # PgSqlCaller
2
2
 
3
- Postgresql Sql Caller for ActiveRecord.
3
+ [![Gem Version](https://img.shields.io/gem/v/pg_sql_caller.svg)](https://rubygems.org/gems/pg_sql_caller)
4
+ [![CI](https://github.com/didww/pg_sql_caller/actions/workflows/ci.yml/badge.svg)](https://github.com/didww/pg_sql_caller/actions/workflows/ci.yml)
5
+ [![CodeQL](https://github.com/didww/pg_sql_caller/actions/workflows/codeql.yml/badge.svg)](https://github.com/didww/pg_sql_caller/actions/workflows/codeql.yml)
6
+
7
+ A small, focused wrapper for running **raw SQL against PostgreSQL through ActiveRecord**.
8
+
9
+ It gives you a clean API for the things ActiveRecord's query builder makes awkward — `SELECT`s that return a single scalar, a single column, raw rows, `EXPLAIN ANALYZE`, sequence/table introspection, PostgreSQL `NOTICE` capture, and efficient set-based bulk updates — while keeping every value **bound and sanitized** by ActiveRecord so your statements stay injection-safe.
10
+
11
+ ```ruby
12
+ class Sql < PgSqlCaller::Base
13
+ model_class 'ApplicationRecord'
14
+ end
15
+
16
+ Sql.select_value('SELECT count(*) FROM users WHERE active = ?', true) # => 42
17
+ Sql.select_values('SELECT id FROM users WHERE name = ?', 'John Doe') # => [1, 2, 3]
18
+ Sql.transaction { Sql.execute('DELETE FROM logs WHERE created_at < ?', 1.year.ago) }
19
+ ```
20
+
21
+ ## Table of contents
22
+
23
+ - [Why use this](#why-use-this)
24
+ - [Requirements](#requirements)
25
+ - [Installation](#installation)
26
+ - [Configuration](#configuration) — three ways to set it up
27
+ - [How `?` placeholders work](#how--placeholders-work)
28
+ - [API reference](#api-reference)
29
+ - [Reading data](#reading-data)
30
+ - [Serialized reads (Ruby type casting)](#serialized-reads-ruby-type-casting)
31
+ - [Writing data](#writing-data)
32
+ - [Transactions](#transactions)
33
+ - [Database & table introspection](#database--table-introspection)
34
+ - [Query plans](#query-plans)
35
+ - [PostgreSQL NOTICE capture](#postgresql-notice-capture)
36
+ - [Quoting & sanitizing helpers](#quoting--sanitizing-helpers)
37
+ - [Extending with custom SQL methods](#extending-with-custom-sql-methods)
38
+ - [Bulk updates](#bulk-updates)
39
+ - [Security](#security)
40
+ - [Versioning & changelog](#versioning--changelog)
41
+ - [Development](#development)
42
+ - [Contributing](#contributing)
43
+ - [License](#license)
44
+
45
+ ## Why use this
46
+
47
+ ActiveRecord already exposes low-level connection methods (`select_value`, `select_all`, `execute`, …), but reaching for them directly means writing `Model.connection.select_value(...)` everywhere, manually sanitizing bind values, and re-implementing the same small helpers in every project. `PgSqlCaller`:
48
+
49
+ - Wraps those connection methods behind a stable, documented API on a class **you** name.
50
+ - Binds `?` placeholders through ActiveRecord's sanitizer automatically — no manual quoting.
51
+ - Adds PostgreSQL-specific helpers (type-cast reads, sequence peeking, relation sizes, `EXPLAIN ANALYZE`, `NOTICE` capture).
52
+ - Provides a fast, injection-safe [bulk update](#bulk-updates) for partial updates of many existing rows in a single round-trip.
53
+
54
+ ## Requirements
55
+
56
+ | Dependency | Version |
57
+ | ------------- | ------------------ |
58
+ | Ruby | `>= 3.2.0` |
59
+ | ActiveRecord | `>= 7.1` |
60
+ | ActiveSupport | `>= 7.1` |
61
+ | Database | PostgreSQL |
62
+
63
+ Continuously tested against Rails **7.1, 7.2, 8.0, and 8.1** on Ruby **3.2–3.4**. PostgreSQL is required — the gem uses PostgreSQL-specific features (`pg_total_relation_size`, `unnest`, sequence introspection, the `pg` notice processor).
4
64
 
5
65
  ## Installation
6
66
 
7
- Add this line to your application's Gemfile:
67
+ Add to your application's `Gemfile`:
8
68
 
9
69
  ```ruby
10
70
  gem 'pg_sql_caller'
11
71
  ```
12
72
 
13
- And then execute:
73
+ Then run:
74
+
75
+ ```sh
76
+ bundle install
77
+ ```
78
+
79
+ Or install it directly:
14
80
 
15
- $ bundle install
81
+ ```sh
82
+ gem install pg_sql_caller
83
+ ```
16
84
 
17
- Or install it yourself as:
85
+ ## Configuration
18
86
 
19
- $ gem install pg_sql_caller
87
+ A caller is always backed by **one ActiveRecord class**, whose connection runs every statement and whose column types are used to sanitize and cast values. Pick whichever of the three setups below fits your app.
20
88
 
21
- ## Usage
89
+ ### 1. Subclass `PgSqlCaller::Base` (recommended)
22
90
 
23
- create subclass from `PgSqlCaller::Base` and define `model_class` for it
91
+ Declare the backing model once, then call SQL methods directly on your class. This is the most common setup and lets you have several callers (e.g. one per database) if needed.
24
92
 
25
93
  ```ruby
26
94
  require 'pg_sql_caller'
27
95
 
28
- class MySqlCaller < PgSqlCaller::Base
29
- model_class 'ApplicationRecord'
96
+ class Sql < PgSqlCaller::Base
97
+ model_class 'ApplicationRecord' # a String (constantized on first use) or the Class itself
30
98
  end
31
99
 
32
- MySqlCaller.select_values 'SELECT id from users WHERE parent_name = ?', 'John Doe' # => [1, 2, 3]
100
+ Sql.select_values('SELECT id FROM users WHERE parent_name = ?', 'John Doe') # => [1, 2, 3]
33
101
  ```
34
102
 
35
- or just define `model_class` for `PgSqlCaller::Base` itself
103
+ `model_class` accepts either the class or its name as a `String`. Passing a `String` defers loading the constant until the first call, which avoids autoload-order problems at boot.
104
+
105
+ `PgSqlCaller::Base` is a `Singleton`: every class-level call is forwarded to the shared `.instance`, and **every** public instance method (including ones you add with [`define_sql_method`](#extending-with-custom-sql-methods)) is available as a class method.
106
+
107
+ ### 2. Configure `PgSqlCaller::Base` directly
108
+
109
+ If you only need a single, global caller, configure the base class itself instead of subclassing:
36
110
 
37
111
  ```ruby
38
112
  PgSqlCaller::Base.model_class 'ApplicationRecord'
39
113
 
40
- PgSqlCaller::Base.select_values 'SELECT id from users WHERE parent_name = ?', 'John Doe' # => [1, 2, 3]
114
+ PgSqlCaller::Base.select_values('SELECT id FROM users WHERE parent_name = ?', 'John Doe') # => [1, 2, 3]
115
+ ```
116
+
117
+ ### 3. Instantiate `PgSqlCaller::Model` per call
118
+
119
+ For one-off use, or when you want an ordinary object rather than a singleton, build a `Model` directly. This is also what [`BulkUpdate`](#bulk-updates) uses internally.
120
+
121
+ ```ruby
122
+ sql = PgSqlCaller::Model.new(ApplicationRecord)
123
+ sql.select_value('SELECT count(*) FROM users') # => 42
124
+ ```
125
+
126
+ > The class methods on `PgSqlCaller::Base` and the instance methods on `PgSqlCaller::Model` are the same API — the examples in the reference below use a `sql` instance, but `Sql.select_value(...)` works identically.
127
+
128
+ ## How `?` placeholders work
129
+
130
+ Every reading and writing method takes a SQL string plus optional positional bindings. Each `?` in the SQL is replaced, **in order**, by a binding value that ActiveRecord quotes and escapes — values are never interpolated into the string yourself:
131
+
132
+ ```ruby
133
+ sql.select_value('SELECT id FROM employees WHERE name = ?', "O'Brien")
134
+ # ActiveRecord turns this into: SELECT id FROM employees WHERE name = 'O''Brien'
135
+ ```
136
+
137
+ If you pass no bindings, the SQL is run verbatim. See [Security](#security) for the guarantees this provides.
138
+
139
+ ## API reference
140
+
141
+ The examples use this schema:
142
+
143
+ ```ruby
144
+ class Department < ApplicationRecord; end
145
+ class Employee < ApplicationRecord # columns: id, department_id, name, created_at, updated_at
146
+ belongs_to :department
147
+ end
148
+ ```
149
+
150
+ ### Reading data
151
+
152
+ | Method | Returns |
153
+ | --------------------------------------- | ------------------------------------------------------------------- |
154
+ | `select_value(sql, *bindings)` | First column of the first row, or `nil` if no row matches |
155
+ | `select_values(sql, *bindings)` | First column of **every** row, as an `Array` |
156
+ | `select_row(sql, *bindings)` | First row as an `Array` of column values, or `nil` |
157
+ | `select_rows(sql, *bindings)` | Every row as an `Array` of column-value `Array`s |
158
+ | `select_all(sql, *bindings)` | An `ActiveRecord::Result` of String-keyed row hashes |
159
+
160
+ ```ruby
161
+ sql.select_value('SELECT count(*) FROM employees') # => 2
162
+ sql.select_value('SELECT name FROM employees WHERE id = ?', -1) # => nil
163
+
164
+ sql.select_values('SELECT name FROM employees WHERE department_id = ?', 5)
165
+ # => ["John", "Jane"]
166
+
167
+ sql.select_row('SELECT id, name FROM employees ORDER BY id') # => [1, "John"]
168
+ sql.select_rows('SELECT id, name FROM employees') # => [[1, "John"], [2, "Jane"]]
169
+
170
+ result = sql.select_all('SELECT id, name FROM employees')
171
+ result # => #<ActiveRecord::Result ...>
172
+ result.to_a # => [{ "id" => 1, "name" => "John" }, { "id" => 2, "name" => "Jane" }]
173
+ ```
174
+
175
+ > **Type casting note:** the non-serialized reads above return values as decoded by the PostgreSQL adapter — common scalar types (integers, booleans, floats, timestamps) come back as Ruby objects, but **array and other complex/custom column types arrive as raw strings** (e.g. `'{1,2,3}'`). Use the serialized variants below when you need those cast to Ruby types.
176
+
177
+ ### Serialized reads (Ruby type casting)
178
+
179
+ The `*_serialized` variants run the same query, then cast each value back to its Ruby type using ActiveRecord's column types — handling arrays and custom attribute types that the raw adapter leaves as strings. `select_all_serialized` additionally keys each row by `Symbol`.
180
+
181
+ | Method | Returns |
182
+ | ----------------------------------------- | ------------------------------------------------------------- |
183
+ | `select_value_serialized(sql, *bindings)` | First value of the first row, type-cast, or `nil` |
184
+ | `select_values_serialized(sql, *bindings)`| Every row as an `Array` of type-cast values |
185
+ | `select_all_serialized(sql, *bindings)` | Every row as a `Hash` with `Symbol` keys and type-cast values |
186
+
187
+ ```ruby
188
+ # Raw read returns the PostgreSQL array literal as a String...
189
+ sql.select_value('SELECT ARRAY[1,2,3]::int[]') # => "{1,2,3}"
190
+ # ...the serialized read casts it to a Ruby Array.
191
+ sql.select_value_serialized('SELECT ARRAY[1,2,3]::int[]') # => [1, 2, 3]
192
+
193
+ sql.select_values_serialized('SELECT id, ARRAY[1,2]::int[] FROM employees')
194
+ # => [[1, [1, 2]]]
195
+
196
+ sql.select_all_serialized('SELECT id, created_at FROM employees')
197
+ # => [{ id: 1, created_at: 2026-06-08 12:00:00 +0000 }, ...]
198
+ ```
199
+
200
+ ### Writing data
201
+
202
+ | Method | Returns |
203
+ | -------------------------- | ------------------------------------------------------------- |
204
+ | `execute(sql, *bindings)` | The raw `PG::Result` (use `#cmd_tuples` for affected rows) |
205
+
206
+ `execute` is for `INSERT` / `UPDATE` / `DELETE` / DDL and any statement whose row data you don't need back.
207
+
208
+ ```ruby
209
+ result = sql.execute('UPDATE employees SET name = ? WHERE id = ?', 'Renamed', 1)
210
+ result.cmd_tuples # => 1 (number of rows affected)
211
+ ```
212
+
213
+ For updating many existing rows efficiently, see [Bulk updates](#bulk-updates).
214
+
215
+ ### Transactions
216
+
217
+ ```ruby
218
+ sql.transaction do
219
+ sql.execute('UPDATE accounts SET balance = balance - ? WHERE id = ?', 100, from_id)
220
+ sql.execute('UPDATE accounts SET balance = balance + ? WHERE id = ?', 100, to_id)
221
+ end
222
+ ```
223
+
224
+ - `transaction { ... }` — runs the block inside a database transaction, committing on success and rolling back if it raises. Returns the block's value. Raises `ArgumentError` if no block is given.
225
+ - `transaction_open?` — `true` when a transaction is currently open on the connection (including one opened on the model class itself, e.g. `ApplicationRecord.transaction { ... }`).
226
+
227
+ ### Database & table introspection
228
+
229
+ | Method | Returns |
230
+ | ------------------------------- | ----------------------------------------------------------------------------------------- |
231
+ | `current_database` | The connected database name (`SELECT current_database()`) |
232
+ | `next_sequence_value(table)` | The table's `<table>_id_seq` `last_value + 1`, read **without consuming** the sequence |
233
+ | `table_full_size(table)` | Total on-disk size in bytes including indexes & TOAST (`pg_total_relation_size`) |
234
+ | `table_data_size(table)` | Main data fork size in bytes only (`pg_relation_size`) |
235
+
236
+ ```ruby
237
+ sql.current_database # => "my_app_production"
238
+ sql.next_sequence_value('employees') # => 124
239
+ sql.table_full_size('employees') # => 81920
240
+ sql.table_data_size('employees') # => 8192
241
+ ```
242
+
243
+ > `next_sequence_value` peeks at the sequence's current value; it does not allocate or advance it, so it is **not** safe to use as a way to reserve an id under concurrency.
244
+
245
+ ### Query plans
246
+
247
+ ```ruby
248
+ puts sql.explain_analyze('SELECT * FROM employees WHERE department_id = 5')
249
+ # QUERY_PLAN
250
+ # Seq Scan on employees (cost=0.00..1.05 rows=1 width=...) (actual time=0.012..0.013 rows=1 loops=1)
251
+ # Filter: (department_id = 5)
252
+ # Planning Time: 0.060 ms
253
+ # Execution Time: 0.030 ms
254
+ ```
255
+
256
+ `explain_analyze(sql)` runs `EXPLAIN ANALYZE` (which **executes** the statement) and returns the plan as a single multi-line `String` under a `QUERY_PLAN` header.
257
+
258
+ ### PostgreSQL NOTICE capture
259
+
260
+ Capture `NOTICE` output (e.g. from `RAISE NOTICE` inside a `DO` block or function) emitted while a block runs:
261
+
262
+ ```ruby
263
+ sql.with_notice_processor(->(msg) { Rails.logger.info(msg) }) do
264
+ sql.execute("DO $$ BEGIN RAISE NOTICE 'migrating row %', 42; END $$")
265
+ end
266
+ ```
267
+
268
+ - `with_notice_processor(callback) { ... }` — invokes `callback` with each notice message (a chomped `String`) emitted during the block. Lowers `client_min_messages` to `notice` for the duration and restores the previous notice processor afterward. Returns the block's value.
269
+ - `with_min_messages(level) { ... }` — temporarily sets the connection's `client_min_messages` to `level` (`debug5`…`debug1`, `log`, `notice`, `warning`, `error`) for the block, restoring the previous value afterward. Returns the block's value.
270
+
271
+ ### Quoting & sanitizing helpers
272
+
273
+ For the cases where you must build SQL fragments yourself, these expose ActiveRecord's quoting so you stay safe:
274
+
275
+ | Method | Purpose |
276
+ | ------------------------------ | --------------------------------------------------------------------------------- |
277
+ | `quote_value(value)` | Quote/escape a value as a SQL literal — `"O'Brien"` → `"'O''Brien'"` |
278
+ | `quote_column_name(name)` | Quote a column identifier — `"name"` → `'"name"'` |
279
+ | `quote_table_name(name)` | Quote a table identifier — `"employees"` → `'"employees"'` |
280
+ | `sanitize_sql_array(sql, *b)` | Interpolate `?` placeholders and return the safe SQL `String` (no execution) |
281
+ | `typecast_array(values, type:)`| Encode a Ruby `Array` into a PostgreSQL array literal for the given attribute type |
282
+
283
+ ```ruby
284
+ sql.quote_value("O'Brien") # => "'O''Brien'"
285
+ sql.quote_column_name('name') # => "\"name\""
286
+ sql.sanitize_sql_array('name = ? AND id = ?', "O'Brien", 5) # => "name = 'O''Brien' AND id = 5"
287
+ sql.typecast_array([1, 2, 3], type: :integer) # => "{1,2,3}"
288
+ sql.typecast_array(['a', 'b,c'], type: :string) # => "{a,\"b,c\"}"
289
+ ```
290
+
291
+ Accessors `model_class` (the wrapped class) and `connection` (its adapter) are also public.
292
+
293
+ ### Extending with custom SQL methods
294
+
295
+ `PgSqlCaller::Model` builds its core readers with the class macro `define_sql_method`, which wraps any connection method that takes a SQL string. Subclass `Model` (or `Base`) to expose additional connection methods with the same `?`-binding behavior:
296
+
297
+ ```ruby
298
+ class Sql < PgSqlCaller::Base
299
+ model_class 'ApplicationRecord'
300
+
301
+ # Expose the adapter's #exec_query through the same binding/sanitizing path.
302
+ define_sql_method :exec_query
303
+ end
304
+
305
+ Sql.exec_query('SELECT * FROM employees WHERE id = ?', 1)
41
306
  ```
42
307
 
308
+ Because `PgSqlCaller::Base` delegates missing class methods to its singleton instance, methods added this way are immediately callable at the class level.
309
+
310
+ ## Bulk updates
311
+
312
+ `PgSqlCaller::BulkUpdate` performs a **partial update of many existing rows in a single statement and a single round-trip**, using `UPDATE ... FROM unnest(...)`. Each column is sent as one typed PostgreSQL array; `unnest` zips the arrays back into rows that are joined to the target table on a key.
313
+
314
+ ```ruby
315
+ PgSqlCaller::BulkUpdate.call(Employee, [
316
+ { id: 1, name: 'John', department_id: 10 },
317
+ { id: 2, name: 'Jane', department_id: 20 }
318
+ ])
319
+ # => 2 (number of rows affected)
320
+ ```
321
+
322
+ ### Matching on a composite key
323
+
324
+ By default rows are matched on `:id`. Pass `unique_by` to match on a different column, or an array of columns for a composite key:
325
+
326
+ ```ruby
327
+ PgSqlCaller::BulkUpdate.call(Employee, attrs_list, unique_by: :employee_number)
328
+ PgSqlCaller::BulkUpdate.call(Employee, attrs_list, unique_by: %i[department_id name])
329
+ ```
330
+
331
+ ### Rules and behavior
332
+
333
+ - **Every row must include each `unique_by` column**, and all hashes must share the same set of keys.
334
+ - Only the columns you list are written; `unique_by` columns are used for matching, the rest are updated. Columns you omit (e.g. `created_at`) are left untouched.
335
+ - Rows that don't match an existing row are simply not updated — this **never inserts**.
336
+ - Returns the number of rows affected (`0` when `attrs_list` is empty — a no-op).
337
+ - Raises `ArgumentError` (before touching the database) if a row omits a `unique_by` column or names a column that doesn't exist on the model.
338
+
339
+ ### Why not `upsert_all` or a loop of `update_all`?
340
+
341
+ - **vs. `upsert_all`:** PostgreSQL `NOT NULL`-checks the candidate `INSERT` tuple of `INSERT ... ON CONFLICT DO UPDATE` *before* conflict arbitration, so upsert rejects partial payloads that omit the table's other `NOT NULL` columns. This join only ever touches the listed columns of rows that already exist.
342
+ - **vs. N `update_all` calls in a transaction:** a transaction makes those writes atomic but doesn't batch them — it's still N statements, N round-trips, and N parse/plan cycles. `BulkUpdate` is one statement and one round-trip; round-trip latency dominates the N-call approach as the row count grows, so `BulkUpdate` stays roughly flat while the loop scales linearly.
343
+
344
+ > There's a benchmark demonstrating the speedup in `spec/pg_sql_caller/bulk_update_spec.rb`. Run it with:
345
+ > ```sh
346
+ > bundle exec rspec spec/pg_sql_caller/bulk_update_spec.rb --tag benchmark
347
+ > ```
348
+
349
+ ## Security
350
+
351
+ `PgSqlCaller` is built so that **values are always bound through ActiveRecord's sanitizer and never interpolated into SQL**:
352
+
353
+ - All `?` placeholders in reading/writing methods are sanitized by `sanitize_sql_array` (quoted and escaped).
354
+ - `BulkUpdate` binds every value as a typed PostgreSQL array; the only identifiers placed into its SQL are restricted to the model's own column names (validated against `column_names`), so the statement is injection-safe by construction — even values like `"'); DROP TABLE employees;--"` are stored verbatim as data.
355
+
356
+ What is **your** responsibility: any SQL fragment, table name, or column name you build into a statement string yourself (rather than passing as a `?` binding) is run as-is. Use `quote_column_name`, `quote_table_name`, and `quote_value` for those, and never interpolate untrusted input directly into the SQL string.
357
+
358
+ The repository's CI runs RuboCop, `bundle-audit` (dependency CVEs), CodeQL, and Semgrep (including a custom SQL-injection ruleset) on every change.
359
+
360
+ ## Versioning & changelog
361
+
362
+ This project adheres to [Semantic Versioning](https://semver.org). Given a `MAJOR.MINOR.PATCH` version, breaking API changes bump `MAJOR`, backward-compatible additions bump `MINOR`, and fixes bump `PATCH`.
363
+
364
+ All notable changes are recorded in [CHANGELOG.md](CHANGELOG.md), which follows the [Keep a Changelog](https://keepachangelog.com) format. Unreleased changes are listed there before each release.
365
+
43
366
  ## Development
44
367
 
45
- After checking out the repo, run `bin/setup` to install dependencies.
46
- Create `spec/config/database.yml` (look at `spec/config/database.travis.yml` for example).
47
- You need to create test database, so run `psql -c 'CREATE DATABASE pg_sql_caller_test;'`.
48
- Then, run `rake spec` to run the tests.
49
- You can also run `bin/console` for an interactive prompt that will allow you to experiment.
368
+ After checking out the repo:
50
369
 
51
- To install this gem onto your local machine, run `bundle exec rake install`.
52
- To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
370
+ ```sh
371
+ bin/setup # install dependencies
372
+ cp spec/config/database.github.yml spec/config/database.yml # then edit credentials as needed
373
+ psql -c 'CREATE DATABASE pg_sql_caller_test;' # create the test database
374
+ bundle exec rake spec # run the tests
375
+ ```
53
376
 
54
- ## TODO
377
+ `bin/console` gives you an interactive prompt to experiment.
55
378
 
56
- * add more tests
57
- * add more usage examples
58
- * add documentation
59
- * release 1.0 after all above
379
+ To test against a specific Rails version, use one of the bundled gemfiles:
60
380
 
61
- ## Contributing
381
+ ```sh
382
+ BUNDLE_GEMFILE=gemfiles/rails_8_1.gemfile bundle install
383
+ BUNDLE_GEMFILE=gemfiles/rails_8_1.gemfile bundle exec rspec
384
+ ```
385
+
386
+ Available: `rails_7_1`, `rails_7_2`, `rails_8_0`, `rails_8_1`.
62
387
 
63
- Bug reports and pull requests are welcome on GitHub at https://github.com/didww/pg_sql_caller. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/didww/sql_caller/blob/master/CODE_OF_CONDUCT.md).
388
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `lib/pg_sql_caller/version.rb`, then run `bundle exec rake release`, which creates a git tag, pushes commits and tags, and pushes the `.gem` to [rubygems.org](https://rubygems.org).
389
+
390
+ ## Contributing
64
391
 
392
+ Bug reports and pull requests are welcome on GitHub at https://github.com/didww/pg_sql_caller. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/didww/pg_sql_caller/blob/master/CODE_OF_CONDUCT.md).
65
393
 
66
394
  ## License
67
395
 
@@ -69,4 +397,4 @@ The gem is available as open source under the terms of the [MIT License](https:/
69
397
 
70
398
  ## Code of Conduct
71
399
 
72
- Everyone interacting in the PGSqlCaller project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/didww/pg_sql_caller/blob/master/CODE_OF_CONDUCT.md).
400
+ Everyone interacting in the PgSqlCaller project's codebases, issue trackers, chat rooms, and mailing lists is expected to follow the [code of conduct](https://github.com/didww/pg_sql_caller/blob/master/CODE_OF_CONDUCT.md).