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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +92 -0
- data/README.md +356 -28
- data/lib/pg_sql_caller/base.rb +45 -139
- 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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b4ea6fd0cd19dd9e2d12d963b0c7689cef8346a63f0981e3a96821faebf4f6bc
|
|
4
|
+
data.tar.gz: f2d40a1f3790e084ebdb9e0bf5908241494aaacca55ca8604f18cf05a6dbb8d8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
3
|
+
[](https://rubygems.org/gems/pg_sql_caller)
|
|
4
|
+
[](https://github.com/didww/pg_sql_caller/actions/workflows/ci.yml)
|
|
5
|
+
[](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
|
|
67
|
+
Add to your application's `Gemfile`:
|
|
8
68
|
|
|
9
69
|
```ruby
|
|
10
70
|
gem 'pg_sql_caller'
|
|
11
71
|
```
|
|
12
72
|
|
|
13
|
-
|
|
73
|
+
Then run:
|
|
74
|
+
|
|
75
|
+
```sh
|
|
76
|
+
bundle install
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Or install it directly:
|
|
14
80
|
|
|
15
|
-
|
|
81
|
+
```sh
|
|
82
|
+
gem install pg_sql_caller
|
|
83
|
+
```
|
|
16
84
|
|
|
17
|
-
|
|
85
|
+
## Configuration
|
|
18
86
|
|
|
19
|
-
|
|
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
|
-
|
|
89
|
+
### 1. Subclass `PgSqlCaller::Base` (recommended)
|
|
22
90
|
|
|
23
|
-
|
|
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
|
|
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
|
-
|
|
100
|
+
Sql.select_values('SELECT id FROM users WHERE parent_name = ?', 'John Doe') # => [1, 2, 3]
|
|
33
101
|
```
|
|
34
102
|
|
|
35
|
-
or
|
|
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
|
|
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
|
|
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
|
-
|
|
52
|
-
|
|
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
|
-
|
|
377
|
+
`bin/console` gives you an interactive prompt to experiment.
|
|
55
378
|
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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).
|