pg_sql_caller 1.1.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 97ef871de183917ba98a3c35bdeb4a184c5aa39ab7b1e6cdc89b11ccb60e8874
4
- data.tar.gz: d18b3aaada2a2a432e892f067233d6d22a45f2bd39045b1015d6b9fb34c6c154
3
+ metadata.gz: adaedfadf46e6d79df0bdee58f8264ce6a5414a271898ddc1a7104751f1cdcca
4
+ data.tar.gz: 26a3c8bfb922d08eea5570a0d05dc0bc4c639c06546ae1c34ea7aac8b9f0e2a5
5
5
  SHA512:
6
- metadata.gz: 75e47d26186bec99d485e7661343f8096386bea04f380385df624e53ba50e0859e89fec3d6de621efe077710a8919dfe28bcd6000c7b45695588132deddd5f36
7
- data.tar.gz: 5863e6f4406d10316fbfe76f4ee7504ff79e3175786e702315b39ceef2d78ade4249fdd370cbfc0b39210ba339927e04e9ac8d58e8748b86c5619cd808b819ce
6
+ metadata.gz: 70dc60fc4336c0bc8bda7b1c68b1c4de2b7964ab57e98a79831e0a3a9d05b90f5acef160792d4b9ba4c0a4da2e702708be21bfc6fe66f46aebada7eff62ed365
7
+ data.tar.gz: 0e51a8dd6bf7db096566d1b86784a8134baed55ee1ffa59dd9681af645b427b533455dea9baffd1eac1f8dd03974f62fe1a2f39511c35c8b3e7de4930b1d056c
data/CHANGELOG.md CHANGED
@@ -5,6 +5,41 @@ 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
+
32
+ ## [1.1.1] - 2026-06-22
33
+
34
+ ### Fixed
35
+
36
+ - `PgSqlCaller::BulkUpdate` no longer truncates `datetime`/`time` values to whole seconds.
37
+ PostgreSQL's default timestamp array encoder formats elements via Ruby's `Time#to_s`,
38
+ dropping sub-seconds — which silently corrupted sub-second writes and, worse, broke any
39
+ `unique_by` match on a sub-second timestamp key (the truncated bind never equalled the
40
+ stored value, so the row was missed and the update became a silent no-op). Temporal
41
+ columns are now encoded at full microsecond precision.
42
+
8
43
  ## [1.1.0] - 2026-06-18
9
44
 
10
45
  ### Added
@@ -93,6 +128,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
93
128
  `transaction_open?`, `explain_analyze`, `typecast_array`, `sanitize_sql_array`, and
94
129
  `current_database_name`.
95
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
96
133
  [1.1.0]: https://github.com/didww/pg_sql_caller/compare/v1.0.0...v1.1.0
97
134
  [1.0.0]: https://github.com/didww/pg_sql_caller/compare/v0.2.3...v1.0.0
98
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, or `returning` is empty or names an unknown column.
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) — a single column,
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(model_class, attrs_list, unique_by: unique_by, returning: returning).call
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) — a single column,
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
- def initialize(model_class, attrs_list, unique_by: :id, returning: nil)
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, or +returning+ is empty or names an unknown column
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
- validate_returning! unless returning.nil?
116
+ validate!
78
117
  return empty_result if attrs_list.empty?
79
118
 
80
119
  if returning.nil?
81
- sql_caller.execute(sql, *bindings).cmd_tuples
120
+ sql_caller.execute(build_sql, *bindings).cmd_tuples
82
121
  else
83
- sql_caller.select_all_serialized(sql, *bindings)
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 value column must remain, every column must exist on the
135
- # model, and every row must carry the same key set as the first row (so no row
136
- # silently writes NULLs or drops extra keys).
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 are no value
141
- # columns to update, a column is unknown, or a row's keys differ from the first row
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
- raise ArgumentError, "attrs_list has no value columns to update (only unique_by #{unique_by.inspect})" if (cols - unique_by).empty?
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 for the value columns.
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
- value_columns.map { |col| "#{quoted(col)} = v.#{quoted(col)}" }.join(', ')
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.
@@ -216,8 +337,55 @@ module PgSqlCaller
216
337
  def bindings
217
338
  columns.map do |col|
218
339
  values = attrs_list.map { |attrs| attrs[col] }
219
- sql_caller.typecast_array(values, type: model_class.type_for_attribute(col.to_s).type)
340
+ encode_column_array(col, values)
341
+ end
342
+ end
343
+
344
+ # Encode one column's values as a PostgreSQL array literal for its `?::<sql_type>[]`
345
+ # placeholder. Temporal columns are encoded at full microsecond precision: PostgreSQL's
346
+ # default timestamp/time array encoder formats elements via Ruby's `Time#to_s`, which
347
+ # truncates to whole seconds — silently corrupting writes and, worse, breaking any
348
+ # `unique_by` match on a sub-second key (the truncated bind never equals the stored
349
+ # sub-second value, so the row is missed). Non-temporal columns use the standard
350
+ # typed-array encoder unchanged.
351
+ #
352
+ # @param col [Symbol] the column name
353
+ # @param values [Array] the per-row values for that column
354
+ # @return [String] a PostgreSQL array literal
355
+ def encode_column_array(col, values)
356
+ ar_type = model_class.type_for_attribute(col.to_s)
357
+ case ar_type.type
358
+ when :datetime then format_date_time_array(ar_type, values, include_date: true)
359
+ when :time then format_date_time_array(ar_type, values, include_date: false)
360
+ else sql_caller.typecast_array(values, type: ar_type.type)
361
+ end
362
+ end
363
+
364
+ # Build a `{...}` array literal of microsecond-precision temporal literals, reparsed to
365
+ # the column's real type by the surrounding `?::<sql_type>[]` cast with no precision
366
+ # loss. When `include_date` is set (`datetime` columns) the value is normalized to UTC and
367
+ # suffixed `+00:00` — correct for both `timestamp` (the offset is ignored) and `timestamptz`
368
+ # (the offset is honored); otherwise (`time` columns) only the wall-clock time of day is
369
+ # emitted, with no date or zone. Each element is built from a value already cast to a Time
370
+ # and then `strftime`'d into a fixed numeric format, so the literal can hold only
371
+ # `[-0-9:. +]` and needs no escaping. `nil` becomes SQL `NULL`.
372
+ #
373
+ # @param ar_type [ActiveRecord::Type::Value] the column's cast type, used to coerce each
374
+ # value to a Time
375
+ # @param values [Array] the per-row values for that column
376
+ # @param include_date [Boolean] true for `datetime` (date + time, normalized to UTC),
377
+ # false for `time` (time of day only)
378
+ # @return [String] a PostgreSQL array literal
379
+ def format_date_time_array(ar_type, values, include_date:)
380
+ elements = values.map do |value|
381
+ time = ar_type.cast(value)
382
+ next 'NULL' if time.nil?
383
+
384
+ time = time.utc if include_date
385
+ formatted = include_date ? time.strftime('%Y-%m-%d %H:%M:%S.%6N%:z') : time.strftime('%H:%M:%S.%6N')
386
+ %("#{formatted}")
220
387
  end
388
+ "{#{elements.join(',')}}"
221
389
  end
222
390
 
223
391
  # The PostgreSQL type of a column, used to build its array cast.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PgSqlCaller
4
- VERSION = '1.1.0'
4
+ VERSION = '1.2.0'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pg_sql_caller
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Denis Talakevich