pg_sql_caller 1.1.1 → 1.2.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 +26 -0
- data/README.md +41 -3
- data/lib/pg_sql_caller/bulk_update.rb +159 -38
- data/lib/pg_sql_caller/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: adaedfadf46e6d79df0bdee58f8264ce6a5414a271898ddc1a7104751f1cdcca
|
|
4
|
+
data.tar.gz: 26a3c8bfb922d08eea5570a0d05dc0bc4c639c06546ae1c34ea7aac8b9f0e2a5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 70dc60fc4336c0bc8bda7b1c68b1c4de2b7964ab57e98a79831e0a3a9d05b90f5acef160792d4b9ba4c0a4da2e702708be21bfc6fe66f46aebada7eff62ed365
|
|
7
|
+
data.tar.gz: 0e51a8dd6bf7db096566d1b86784a8134baed55ee1ffa59dd9681af645b427b533455dea9baffd1eac1f8dd03974f62fe1a2f39511c35c8b3e7de4930b1d056c
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,30 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [1.2.0] - 2026-07-23
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `PgSqlCaller::BulkUpdate` now accepts an optional `condition:` keyword — a raw-SQL predicate
|
|
15
|
+
ANDed onto the key match, so only rows that also satisfy it are updated (e.g. apply a payload
|
|
16
|
+
only while the row is still in the state you read it in).
|
|
17
|
+
- `PgSqlCaller::BulkUpdate` now accepts an optional `set_override:` keyword — a hash of
|
|
18
|
+
column ⇒ raw-SQL expression replacing that column's default `col = v.col` assignment (e.g.
|
|
19
|
+
`CASE WHEN t."status" = 'pending' THEN v."status" ELSE t."status" END`). A key absent from
|
|
20
|
+
`attrs_list` contributes an assignment of its own, so a column can be written purely from SQL.
|
|
21
|
+
Both fragments are interpolated verbatim and must not be built from untrusted input; column
|
|
22
|
+
references in them must be qualified with `t.` or `v.`.
|
|
23
|
+
- `PgSqlCaller::BulkUpdate#sql` is now public, returning the exact statement `#call` runs. It
|
|
24
|
+
runs the same validations, so any input `#call` rejects raises the same `ArgumentError` rather
|
|
25
|
+
than yielding an invalid statement; an empty `attrs_list`, for which `#call` runs no statement
|
|
26
|
+
at all, raises too.
|
|
27
|
+
- `PgSqlCaller::BulkUpdate` now accepts `unique_by:` as String(s) as well as Symbol(s). They were
|
|
28
|
+
previously matched against the payload's Symbol keys as-is, so a String silently failed to
|
|
29
|
+
exclude its column from the `SET` clause and escaped the `set_override` guard against
|
|
30
|
+
rewriting a match column.
|
|
31
|
+
|
|
8
32
|
## [1.1.1] - 2026-06-22
|
|
9
33
|
|
|
10
34
|
### Fixed
|
|
@@ -104,6 +128,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
104
128
|
`transaction_open?`, `explain_analyze`, `typecast_array`, `sanitize_sql_array`, and
|
|
105
129
|
`current_database_name`.
|
|
106
130
|
|
|
131
|
+
[1.2.0]: https://github.com/didww/pg_sql_caller/compare/v1.1.1...v1.2.0
|
|
132
|
+
[1.1.1]: https://github.com/didww/pg_sql_caller/compare/v1.1.0...v1.1.1
|
|
107
133
|
[1.1.0]: https://github.com/didww/pg_sql_caller/compare/v1.0.0...v1.1.0
|
|
108
134
|
[1.0.0]: https://github.com/didww/pg_sql_caller/compare/v0.2.3...v1.0.0
|
|
109
135
|
[0.2.3]: https://github.com/didww/pg_sql_caller/compare/v0.2.2...v0.2.3
|
data/README.md
CHANGED
|
@@ -342,13 +342,51 @@ PgSqlCaller::BulkUpdate.call(Employee, [
|
|
|
342
342
|
|
|
343
343
|
A single column may be passed as a `Symbol` (`returning: :id`). Without `returning` (the default) the call returns the affected-row **count** exactly as before — the behavior is unchanged.
|
|
344
344
|
|
|
345
|
+
### Updating only rows in an expected state
|
|
346
|
+
|
|
347
|
+
Pass `condition` — a raw-SQL predicate ANDed onto the key match — to skip rows whose current state isn't what you expect. Typical use: apply the payload only while the row is still in the state you read it in.
|
|
348
|
+
|
|
349
|
+
```ruby
|
|
350
|
+
# Move to 'processing', but only rows still 'pending' in the database.
|
|
351
|
+
PgSqlCaller::BulkUpdate.call(Order, [
|
|
352
|
+
{ id: 1, status: 'processing' },
|
|
353
|
+
{ id: 2, status: 'processing' }
|
|
354
|
+
], condition: %q{t."status" = 'pending'})
|
|
355
|
+
# => 1 (only one of the two was still pending)
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
Non-matching rows are simply not updated — with `returning`, they are absent from the result, so the return value tells you exactly which rows the condition let through.
|
|
359
|
+
|
|
360
|
+
### Overriding how a column is assigned
|
|
361
|
+
|
|
362
|
+
Pass `set_override` — a hash of column ⇒ raw-SQL expression — to replace the default `col = v.col` assignment. Typical use: leave a column at its stored value unless the row is in the expected state, while still writing the other columns.
|
|
363
|
+
|
|
364
|
+
```ruby
|
|
365
|
+
PgSqlCaller::BulkUpdate.call(Order, [
|
|
366
|
+
{ id: 1, status: 'delivered', delivered_at: Time.current }
|
|
367
|
+
], set_override: {
|
|
368
|
+
status: %q{CASE WHEN t."status" = 'pending' THEN v."status" ELSE t."status" END}
|
|
369
|
+
})
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
A key that isn't in `attrs_list` adds an assignment of its own (there is no `v.col` for it to replace), so a column can be written purely from SQL:
|
|
373
|
+
|
|
374
|
+
```ruby
|
|
375
|
+
PgSqlCaller::BulkUpdate.call(Employee, attrs_list, set_override: { updated_at: 'NOW()' })
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
Overridden payload columns keep their position in the `SET` clause; overrides of columns absent from the payload are appended after it.
|
|
379
|
+
|
|
380
|
+
> ⚠️ **`condition` and `set_override` values are raw SQL, interpolated verbatim** — they are the one part of `BulkUpdate` that is not bound through the sanitizer. Never build them from untrusted input; use `quote_value` for any value you need to embed. Qualify every column reference with `t.` (the target table) or `v.` (the `unnest` source): both aliases expose the same column names, so an unqualified reference is rejected by PostgreSQL as `column reference "..." is ambiguous`.
|
|
381
|
+
|
|
345
382
|
### Rules and behavior
|
|
346
383
|
|
|
347
384
|
- **Every row must include each `unique_by` column**, and all hashes must share the same set of keys.
|
|
348
|
-
- 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.
|
|
385
|
+
- 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 — unless a `set_override` names them.
|
|
349
386
|
- Rows that don't match an existing row are simply not updated — this **never inserts**.
|
|
350
387
|
- Returns the number of rows affected (`0` when `attrs_list` is empty — a no-op). With `returning`, it instead returns the updated rows as `Symbol`-keyed hashes (`[]` when `attrs_list` is empty).
|
|
351
|
-
- Raises `ArgumentError` (before touching the database) if a row omits a `unique_by` column, names a column that doesn't exist on the model,
|
|
388
|
+
- Raises `ArgumentError` (before touching the database) if a row omits a `unique_by` column, names a column that doesn't exist on the model, `returning` is empty or names an unknown column, `condition` is blank, or `set_override` names an unknown or `unique_by` column or carries a blank expression.
|
|
389
|
+
- The statement is available without running it: `PgSqlCaller::BulkUpdate.new(Employee, attrs_list, **options).sql` returns the exact SQL `.call` would execute (its `?` placeholders bound to one typed array per column). It validates exactly what `.call` does, so anything `.call` would reject raises the same `ArgumentError` here instead of yielding an invalid statement — including an empty `attrs_list`, for which `.call` runs no statement at all.
|
|
352
390
|
|
|
353
391
|
### Why not `upsert_all` or a loop of `update_all`?
|
|
354
392
|
|
|
@@ -365,7 +403,7 @@ A single column may be passed as a `Symbol` (`returning: :id`). Without `returni
|
|
|
365
403
|
`PgSqlCaller` is built so that **values are always bound through ActiveRecord's sanitizer and never interpolated into SQL**:
|
|
366
404
|
|
|
367
405
|
- All `?` placeholders in reading/writing methods are sanitized by `sanitize_sql_array` (quoted and escaped).
|
|
368
|
-
- `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.
|
|
406
|
+
- `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. Its optional `condition:` and `set_override:` fragments are the exception: they are raw SQL you supply and are interpolated verbatim (see [Bulk updates](#bulk-updates)).
|
|
369
407
|
|
|
370
408
|
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.
|
|
371
409
|
|
|
@@ -17,6 +17,24 @@ module PgSqlCaller
|
|
|
17
17
|
#
|
|
18
18
|
# PgSqlCaller::BulkUpdate.call(Employee, attrs_list, unique_by: %i[department_id name])
|
|
19
19
|
#
|
|
20
|
+
# Narrow which rows are eligible with `condition`, and rewrite how individual columns are
|
|
21
|
+
# assigned with `set_override` — both are raw SQL fragments, interpolated verbatim:
|
|
22
|
+
#
|
|
23
|
+
# PgSqlCaller::BulkUpdate.call(Order, [{ id: 1, status: 'processing' }],
|
|
24
|
+
# condition: "t.status = 'pending'")
|
|
25
|
+
#
|
|
26
|
+
# PgSqlCaller::BulkUpdate.call(
|
|
27
|
+
# Order,
|
|
28
|
+
# [{ id: 1, status: 'delivered', delivered_at: now }],
|
|
29
|
+
# set_override: {
|
|
30
|
+
# status: "CASE WHEN t.status = 'pending' THEN v.status ELSE t.status END"
|
|
31
|
+
# }
|
|
32
|
+
# )
|
|
33
|
+
#
|
|
34
|
+
# Qualify every column reference in those fragments with `t.` (the target table) or `v.`
|
|
35
|
+
# (the unnest source): both aliases expose the same column names, so an unqualified
|
|
36
|
+
# reference raises `column reference "..." is ambiguous`.
|
|
37
|
+
#
|
|
20
38
|
# Chosen over `upsert_all`: PostgreSQL NOT NULL-checks the candidate INSERT tuple of
|
|
21
39
|
# `INSERT ... ON CONFLICT DO UPDATE` *before* conflict arbitration, so upsert rejects
|
|
22
40
|
# partial payloads that omit the table's other NOT NULL columns. This join only ever
|
|
@@ -33,37 +51,56 @@ module PgSqlCaller
|
|
|
33
51
|
# Each column is sent as one typed PostgreSQL array; `unnest` zips the arrays back
|
|
34
52
|
# into rows. Values are bound through ActiveRecord's sanitizer (PgSqlCaller::Model) and
|
|
35
53
|
# 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.
|
|
54
|
+
# model's own columns, so the statement is injection-safe by construction. The exception
|
|
55
|
+
# is `condition` and the `set_override` expressions: those are raw SQL supplied by the
|
|
56
|
+
# caller and interpolated verbatim, so they must never be built from untrusted input.
|
|
37
57
|
class BulkUpdate
|
|
38
58
|
# Build and run a bulk update in one call.
|
|
39
59
|
#
|
|
40
60
|
# @param model_class [Class<ActiveRecord::Base>] the model whose table is updated
|
|
41
61
|
# @param attrs_list [Array<Hash>] one hash per row; each MUST include every
|
|
42
62
|
# `unique_by` column, and all hashes MUST share the same keys
|
|
43
|
-
# @param unique_by [Symbol, Array<Symbol>] the match column(s) —
|
|
44
|
-
# or all parts of a composite key (default +:id+)
|
|
63
|
+
# @param unique_by [Symbol, String, Array<Symbol>, Array<String>] the match column(s) —
|
|
64
|
+
# a single column, or all parts of a composite key (default +:id+)
|
|
45
65
|
# @param returning [Symbol, Array<Symbol>, nil] column(s) to read back from each
|
|
46
66
|
# updated row via SQL `RETURNING`; +nil+ (default) keeps the row-count behavior
|
|
67
|
+
# @param condition [String, nil] an extra raw-SQL predicate ANDed onto the match
|
|
68
|
+
# clause, so only rows also satisfying it are updated; +nil+ (default) adds nothing
|
|
69
|
+
# @param set_override [Hash{Symbol, String => String}] raw-SQL expressions replacing the
|
|
70
|
+
# default +v.col+ assignment of the named columns (default +{}+)
|
|
47
71
|
# @return [Integer, Array<Hash{Symbol => Object}>] the number of rows affected, or —
|
|
48
72
|
# when +returning+ is given — the updated rows as type-cast, Symbol-keyed hashes
|
|
49
|
-
def self.call(model_class, attrs_list, unique_by: :id, returning: nil)
|
|
50
|
-
new(
|
|
73
|
+
def self.call(model_class, attrs_list, unique_by: :id, returning: nil, condition: nil, set_override: {})
|
|
74
|
+
new(
|
|
75
|
+
model_class,
|
|
76
|
+
attrs_list,
|
|
77
|
+
unique_by: unique_by,
|
|
78
|
+
returning: returning,
|
|
79
|
+
condition: condition,
|
|
80
|
+
set_override: set_override
|
|
81
|
+
).call
|
|
51
82
|
end
|
|
52
83
|
|
|
53
|
-
attr_reader :model_class, :unique_by, :attrs_list, :returning
|
|
84
|
+
attr_reader :model_class, :unique_by, :attrs_list, :returning, :condition, :set_override
|
|
54
85
|
|
|
55
86
|
# @param model_class [Class<ActiveRecord::Base>] the model whose table is updated
|
|
56
87
|
# @param attrs_list [Array<Hash>] one hash per row; each MUST include every
|
|
57
88
|
# `unique_by` column, and all hashes MUST share the same keys
|
|
58
|
-
# @param unique_by [Symbol, Array<Symbol>] the match column(s) —
|
|
59
|
-
# or all parts of a composite key (default +:id+)
|
|
89
|
+
# @param unique_by [Symbol, String, Array<Symbol>, Array<String>] the match column(s) —
|
|
90
|
+
# a single column, or all parts of a composite key (default +:id+)
|
|
60
91
|
# @param returning [Symbol, Array<Symbol>, nil] column(s) to read back from each
|
|
61
92
|
# updated row via SQL `RETURNING`; +nil+ (default) keeps the row-count behavior
|
|
62
|
-
|
|
93
|
+
# @param condition [String, nil] an extra raw-SQL predicate ANDed onto the match
|
|
94
|
+
# clause, so only rows also satisfying it are updated; +nil+ (default) adds nothing
|
|
95
|
+
# @param set_override [Hash{Symbol, String => String}] raw-SQL expressions replacing the
|
|
96
|
+
# default +v.col+ assignment of the named columns (default +{}+)
|
|
97
|
+
def initialize(model_class, attrs_list, unique_by: :id, returning: nil, condition: nil, set_override: {})
|
|
63
98
|
@model_class = model_class
|
|
64
99
|
@attrs_list = attrs_list
|
|
65
|
-
@unique_by = Array(unique_by)
|
|
100
|
+
@unique_by = Array(unique_by).map(&:to_sym)
|
|
66
101
|
@returning = returning.nil? ? nil : Array(returning)
|
|
102
|
+
@condition = condition
|
|
103
|
+
@set_override = set_override.to_h.transform_keys(&:to_sym)
|
|
67
104
|
end
|
|
68
105
|
|
|
69
106
|
# Execute the bulk update as a single `UPDATE ... FROM unnest(...)` statement.
|
|
@@ -72,20 +109,57 @@ module PgSqlCaller
|
|
|
72
109
|
# rows affected (0 when +attrs_list+ is empty); with +returning+, the updated rows as
|
|
73
110
|
# type-cast, Symbol-keyed hashes (+[]+ when +attrs_list+ is empty)
|
|
74
111
|
# @raise [ArgumentError] if a row omits a `unique_by` column, names a column that does
|
|
75
|
-
# not exist on the model,
|
|
112
|
+
# not exist on the model, +returning+ is empty or names an unknown column,
|
|
113
|
+
# +condition+ is blank, or +set_override+ names an unknown or `unique_by` column
|
|
114
|
+
# or carries a blank expression
|
|
76
115
|
def call
|
|
77
|
-
|
|
116
|
+
validate!
|
|
78
117
|
return empty_result if attrs_list.empty?
|
|
79
118
|
|
|
80
119
|
if returning.nil?
|
|
81
|
-
sql_caller.execute(
|
|
120
|
+
sql_caller.execute(build_sql, *bindings).cmd_tuples
|
|
82
121
|
else
|
|
83
|
-
sql_caller.select_all_serialized(
|
|
122
|
+
sql_caller.select_all_serialized(build_sql, *bindings)
|
|
84
123
|
end
|
|
85
124
|
end
|
|
86
125
|
|
|
126
|
+
# The full `UPDATE ... FROM unnest(...)` statement, with one `?` placeholder per
|
|
127
|
+
# column for the value arrays, plus a `RETURNING` clause when +returning+ was given.
|
|
128
|
+
# Public so the generated SQL can be inspected and asserted on directly: it runs the
|
|
129
|
+
# very same validations as {#call}, so an input {#call} would reject never yields a
|
|
130
|
+
# statement here either. An empty +attrs_list+ has no statement at all ({#call}
|
|
131
|
+
# short-circuits to {#empty_result} instead of building one), so it raises.
|
|
132
|
+
#
|
|
133
|
+
# @return [String]
|
|
134
|
+
# @raise [ArgumentError] on any input {#call} rejects (see {#validate!} and
|
|
135
|
+
# {#validate_columns!}), or when +attrs_list+ is empty
|
|
136
|
+
def sql
|
|
137
|
+
validate!
|
|
138
|
+
raise ArgumentError, 'attrs_list must not be empty to build SQL' if attrs_list.empty?
|
|
139
|
+
|
|
140
|
+
build_sql
|
|
141
|
+
end
|
|
142
|
+
|
|
87
143
|
private
|
|
88
144
|
|
|
145
|
+
# Assemble the statement itself, with no validation of its own: {#call} and {#sql} each
|
|
146
|
+
# validate before reaching here, so this is only ever called on inputs already checked
|
|
147
|
+
# and on a non-empty +attrs_list+.
|
|
148
|
+
#
|
|
149
|
+
# @return [String]
|
|
150
|
+
# @raise [ArgumentError] via {#validate_columns!} on first use of {#columns}
|
|
151
|
+
def build_sql
|
|
152
|
+
statement = <<~SQL.squish
|
|
153
|
+
UPDATE #{model_class.quoted_table_name} AS t
|
|
154
|
+
SET #{set_clause}
|
|
155
|
+
FROM unnest(#{unnest_args}) AS v(#{column_aliases})
|
|
156
|
+
WHERE #{where_clause}
|
|
157
|
+
SQL
|
|
158
|
+
return statement if returning.nil?
|
|
159
|
+
|
|
160
|
+
"#{statement} RETURNING #{returning_clause}"
|
|
161
|
+
end
|
|
162
|
+
|
|
89
163
|
# The value returned for an empty +attrs_list+: a zero row count, or an empty row set
|
|
90
164
|
# when +returning+ was requested — mirroring the shape {#call} returns when it runs.
|
|
91
165
|
#
|
|
@@ -94,6 +168,20 @@ module PgSqlCaller
|
|
|
94
168
|
returning.nil? ? 0 : []
|
|
95
169
|
end
|
|
96
170
|
|
|
171
|
+
# Validate every option that does not depend on the payload — the entry point of both
|
|
172
|
+
# {#call} and {#sql}, so the two reject exactly the same inputs. {#call} runs it before
|
|
173
|
+
# its empty-+attrs_list+ short-circuit, so bad options raise even when there is nothing
|
|
174
|
+
# to update. The payload's own columns are validated separately, by {#validate_columns!}
|
|
175
|
+
# on first use of {#columns}.
|
|
176
|
+
#
|
|
177
|
+
# @return [void]
|
|
178
|
+
# @raise [ArgumentError] if +returning+, +condition+ or +set_override+ is invalid
|
|
179
|
+
def validate!
|
|
180
|
+
validate_returning! unless returning.nil?
|
|
181
|
+
validate_condition! unless condition.nil?
|
|
182
|
+
validate_set_override! unless set_override.empty?
|
|
183
|
+
end
|
|
184
|
+
|
|
97
185
|
# Validate the requested `RETURNING` columns before any SQL runs: at least one column
|
|
98
186
|
# must be named, and every column must exist on the model (each is qualified with the
|
|
99
187
|
# target alias `t`, so it must be a real column, never an expression).
|
|
@@ -107,6 +195,35 @@ module PgSqlCaller
|
|
|
107
195
|
raise ArgumentError, "unknown #{model_class} returning columns: #{unknown.join(', ')}" if unknown.any?
|
|
108
196
|
end
|
|
109
197
|
|
|
198
|
+
# Validate the extra `WHERE` predicate before any SQL runs. Its contents are raw SQL and
|
|
199
|
+
# cannot be checked further — only that something was actually given, so a blank string
|
|
200
|
+
# never silently produces `... AND ()`.
|
|
201
|
+
#
|
|
202
|
+
# @return [void]
|
|
203
|
+
# @raise [ArgumentError] if +condition+ is blank
|
|
204
|
+
def validate_condition!
|
|
205
|
+
raise ArgumentError, 'condition must not be blank' if condition.to_s.strip.empty?
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Validate the `SET` overrides before any SQL runs: every key must be a real column of the
|
|
209
|
+
# model (it becomes a quoted assignment target) and must not be one of the `unique_by`
|
|
210
|
+
# columns (rewriting a match column would change the very key the row was found by), and
|
|
211
|
+
# every expression must be non-blank so no assignment is left dangling. The expressions
|
|
212
|
+
# themselves are raw SQL and cannot be checked further.
|
|
213
|
+
#
|
|
214
|
+
# @return [void]
|
|
215
|
+
# @raise [ArgumentError] if a key is unknown or a `unique_by` column, or a value is blank
|
|
216
|
+
def validate_set_override!
|
|
217
|
+
unknown = set_override.keys.map(&:to_s) - model_class.column_names
|
|
218
|
+
raise ArgumentError, "unknown #{model_class} set_override columns: #{unknown.join(', ')}" if unknown.any?
|
|
219
|
+
|
|
220
|
+
overridden_keys = set_override.keys & unique_by
|
|
221
|
+
raise ArgumentError, "set_override must not override unique_by #{overridden_keys.inspect}" if overridden_keys.any?
|
|
222
|
+
|
|
223
|
+
blank = set_override.select { |_col, expression| expression.to_s.strip.empty? }.keys
|
|
224
|
+
raise ArgumentError, "set_override expressions must not be blank: #{blank.inspect}" if blank.any?
|
|
225
|
+
end
|
|
226
|
+
|
|
110
227
|
# The SQL executor, built from the model's own connection: it sanitizes the bound
|
|
111
228
|
# values, runs the statement and encodes the typed PostgreSQL arrays.
|
|
112
229
|
#
|
|
@@ -131,19 +248,22 @@ module PgSqlCaller
|
|
|
131
248
|
end
|
|
132
249
|
|
|
133
250
|
# Validate the payload's columns before any SQL runs: every `unique_by` column must
|
|
134
|
-
# be present, at least one
|
|
135
|
-
#
|
|
136
|
-
# silently writes NULLs or
|
|
251
|
+
# be present, at least one column must be assigned (a value column, or a `set_override`
|
|
252
|
+
# expression standing in for one), every column must exist on the model, and every row
|
|
253
|
+
# must carry the same key set as the first row (so no row silently writes NULLs or
|
|
254
|
+
# drops extra keys).
|
|
137
255
|
#
|
|
138
256
|
# @param cols [Array<Symbol>] the columns taken from the first row
|
|
139
257
|
# @return [void]
|
|
140
|
-
# @raise [ArgumentError] if a `unique_by` column is missing, there
|
|
141
|
-
#
|
|
258
|
+
# @raise [ArgumentError] if a `unique_by` column is missing, there is nothing to
|
|
259
|
+
# assign, a column is unknown, or a row's keys differ from the first row
|
|
142
260
|
def validate_columns!(cols)
|
|
143
261
|
missing = unique_by - cols
|
|
144
262
|
raise ArgumentError, "attrs_list rows must include unique_by #{missing.inspect}" if missing.any?
|
|
145
263
|
|
|
146
|
-
|
|
264
|
+
if (cols - unique_by).empty? && set_override.empty?
|
|
265
|
+
raise ArgumentError, "attrs_list has no value columns to update (only unique_by #{unique_by.inspect})"
|
|
266
|
+
end
|
|
147
267
|
|
|
148
268
|
unknown = cols.map(&:to_s) - model_class.column_names
|
|
149
269
|
raise ArgumentError, "unknown #{model_class} columns: #{unknown.join(', ')}" if unknown.any?
|
|
@@ -156,22 +276,6 @@ module PgSqlCaller
|
|
|
156
276
|
end
|
|
157
277
|
end
|
|
158
278
|
|
|
159
|
-
# The full `UPDATE ... FROM unnest(...)` statement, with one `?` placeholder per
|
|
160
|
-
# column for the value arrays, plus a `RETURNING` clause when +returning+ was given.
|
|
161
|
-
#
|
|
162
|
-
# @return [String]
|
|
163
|
-
def sql
|
|
164
|
-
statement = <<~SQL.squish
|
|
165
|
-
UPDATE #{model_class.quoted_table_name} AS t
|
|
166
|
-
SET #{set_clause}
|
|
167
|
-
FROM unnest(#{unnest_args}) AS v(#{column_aliases})
|
|
168
|
-
WHERE #{match_clause}
|
|
169
|
-
SQL
|
|
170
|
-
return statement if returning.nil?
|
|
171
|
-
|
|
172
|
-
"#{statement} RETURNING #{returning_clause}"
|
|
173
|
-
end
|
|
174
|
-
|
|
175
279
|
# The `RETURNING t.col, ...` projection. Each column is qualified with the target
|
|
176
280
|
# alias `t` because the `unnest` source alias `v` shares the same column names, so an
|
|
177
281
|
# unqualified `RETURNING` would be ambiguous.
|
|
@@ -181,11 +285,28 @@ module PgSqlCaller
|
|
|
181
285
|
returning.map { |col| "t.#{quoted(col)}" }.join(', ')
|
|
182
286
|
end
|
|
183
287
|
|
|
184
|
-
# The `SET col = v.col, ...` assignments
|
|
288
|
+
# The `SET col = v.col, ...` assignments: one per value column, in payload order, each
|
|
289
|
+
# taking its raw-SQL `set_override` expression in place of `v.col` when one was given —
|
|
290
|
+
# followed by the overrides that name a column absent from the payload, which contribute
|
|
291
|
+
# an assignment of their own (there is no `v.col` for them to replace).
|
|
185
292
|
#
|
|
186
293
|
# @return [String]
|
|
187
294
|
def set_clause
|
|
188
|
-
|
|
295
|
+
extra_columns = set_override.keys - value_columns
|
|
296
|
+
(value_columns + extra_columns).map { |col|
|
|
297
|
+
"#{quoted(col)} = #{set_override.fetch(col) { "v.#{quoted(col)}" }}"
|
|
298
|
+
}.join(', ')
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# The `WHERE` clause: the key match, narrowed by the raw-SQL +condition+ when one was
|
|
302
|
+
# given. The condition is parenthesized so a top-level `OR` inside it cannot widen the
|
|
303
|
+
# match beyond the join keys.
|
|
304
|
+
#
|
|
305
|
+
# @return [String]
|
|
306
|
+
def where_clause
|
|
307
|
+
return match_clause if condition.nil?
|
|
308
|
+
|
|
309
|
+
"#{match_clause} AND (#{condition})"
|
|
189
310
|
end
|
|
190
311
|
|
|
191
312
|
# Match each row on every `unique_by` column — one column, or all parts of a composite key.
|