pg_sql_caller 1.0.0 → 1.1.1

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: b4ea6fd0cd19dd9e2d12d963b0c7689cef8346a63f0981e3a96821faebf4f6bc
4
- data.tar.gz: f2d40a1f3790e084ebdb9e0bf5908241494aaacca55ca8604f18cf05a6dbb8d8
3
+ metadata.gz: dcc72483e5ca6259c0d54d32100d69dc0b95d280a1980326508ef0a925d610fb
4
+ data.tar.gz: 2d59c43a923c2ca41040b5c427a1dead6d025329a9eb67df20ede963b187f55b
5
5
  SHA512:
6
- metadata.gz: 908fcb421cdf254ba4a91cc8c67c304938cfd65ef7a25393ae3019b02e0b84ea634c2fa250fba333e88bd287be02480aafce0cd8f0780e083d3b38800de9ab90
7
- data.tar.gz: 927f8dc22e285bdf849e85bb850e5e2045429675ccb953b872150b0954ecf95602d6a6c84b52829e9b9af8b29fe8acc016d41be10628ae2181edd29c1df7d9a7
6
+ metadata.gz: 5d416d3882391975f85c6145fcac4e05a7a09d97dbbfb4c14f232121c1e4e63054efea8e7901ebc8093ed3c5388bc0791236f494b187ce3cf08e8838081828ee
7
+ data.tar.gz: a7280cd40f97b54f51f3c9deb241dc41d1448850e0f7df092b2c6da54ce0c8bfe4d2bdcf4835d91253ff9ea0aff0564ad9da80d05b0c2faf325633f3211b2eab
data/CHANGELOG.md CHANGED
@@ -5,7 +5,27 @@ 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]
8
+ ## [1.1.1] - 2026-06-22
9
+
10
+ ### Fixed
11
+
12
+ - `PgSqlCaller::BulkUpdate` no longer truncates `datetime`/`time` values to whole seconds.
13
+ PostgreSQL's default timestamp array encoder formats elements via Ruby's `Time#to_s`,
14
+ dropping sub-seconds — which silently corrupted sub-second writes and, worse, broke any
15
+ `unique_by` match on a sub-second timestamp key (the truncated bind never equalled the
16
+ stored value, so the row was missed and the update became a silent no-op). Temporal
17
+ columns are now encoded at full microsecond precision.
18
+
19
+ ## [1.1.0] - 2026-06-18
20
+
21
+ ### Added
22
+
23
+ - `PgSqlCaller::BulkUpdate` now accepts an optional `returning:` keyword — pass one or more
24
+ column names to read them back from each updated row via SQL `RETURNING`. The result is one
25
+ `Symbol`-keyed, type-cast hash per updated row (`[]` when `attrs_list` is empty). Omitting
26
+ `returning:` keeps the existing behavior of returning the affected-row count.
27
+
28
+ ## [1.0.0] - 2026-06-08
9
29
 
10
30
  ### Added
11
31
 
@@ -84,7 +104,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
84
104
  `transaction_open?`, `explain_analyze`, `typecast_array`, `sanitize_sql_array`, and
85
105
  `current_database_name`.
86
106
 
87
- [Unreleased]: https://github.com/didww/pg_sql_caller/compare/v0.2.3...HEAD
107
+ [1.1.0]: https://github.com/didww/pg_sql_caller/compare/v1.0.0...v1.1.0
108
+ [1.0.0]: https://github.com/didww/pg_sql_caller/compare/v0.2.3...v1.0.0
88
109
  [0.2.3]: https://github.com/didww/pg_sql_caller/compare/v0.2.2...v0.2.3
89
110
  [0.2.2]: https://github.com/didww/pg_sql_caller/compare/v0.2.1...v0.2.2
90
111
  [0.2.1]: https://github.com/didww/pg_sql_caller/compare/v0.2.0...v0.2.1
data/README.md CHANGED
@@ -328,13 +328,27 @@ PgSqlCaller::BulkUpdate.call(Employee, attrs_list, unique_by: :employee_number)
328
328
  PgSqlCaller::BulkUpdate.call(Employee, attrs_list, unique_by: %i[department_id name])
329
329
  ```
330
330
 
331
+ ### Reading back updated rows
332
+
333
+ Pass `returning` to read columns back from each updated row (SQL `RETURNING`) instead of a row count. The result is one `Symbol`-keyed hash per updated row, with values cast to their Ruby types (the same casting as [`select_all_serialized`](#serialized-reads-ruby-type-casting)):
334
+
335
+ ```ruby
336
+ PgSqlCaller::BulkUpdate.call(Employee, [
337
+ { id: 1, name: 'John', department_id: 10 },
338
+ { id: 2, name: 'Jane', department_id: 20 }
339
+ ], returning: %i[id name])
340
+ # => [{ id: 1, name: 'John' }, { id: 2, name: 'Jane' }]
341
+ ```
342
+
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
+
331
345
  ### Rules and behavior
332
346
 
333
347
  - **Every row must include each `unique_by` column**, and all hashes must share the same set of keys.
334
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.
335
349
  - 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.
350
+ - 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.
338
352
 
339
353
  ### Why not `upsert_all` or a loop of `update_all`?
340
354
 
@@ -42,37 +42,71 @@ module PgSqlCaller
42
42
  # `unique_by` column, and all hashes MUST share the same keys
43
43
  # @param unique_by [Symbol, Array<Symbol>] the match column(s) — a single column,
44
44
  # or all parts of a composite key (default +:id+)
45
- # @return [Integer] the number of rows affected
46
- def self.call(model_class, attrs_list, unique_by: :id)
47
- new(model_class, attrs_list, unique_by: unique_by).call
45
+ # @param returning [Symbol, Array<Symbol>, nil] column(s) to read back from each
46
+ # updated row via SQL `RETURNING`; +nil+ (default) keeps the row-count behavior
47
+ # @return [Integer, Array<Hash{Symbol => Object}>] the number of rows affected, or
48
+ # 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
48
51
  end
49
52
 
50
- attr_reader :model_class, :unique_by, :attrs_list
53
+ attr_reader :model_class, :unique_by, :attrs_list, :returning
51
54
 
52
55
  # @param model_class [Class<ActiveRecord::Base>] the model whose table is updated
53
56
  # @param attrs_list [Array<Hash>] one hash per row; each MUST include every
54
57
  # `unique_by` column, and all hashes MUST share the same keys
55
58
  # @param unique_by [Symbol, Array<Symbol>] the match column(s) — a single column,
56
59
  # or all parts of a composite key (default +:id+)
57
- def initialize(model_class, attrs_list, unique_by: :id)
60
+ # @param returning [Symbol, Array<Symbol>, nil] column(s) to read back from each
61
+ # updated row via SQL `RETURNING`; +nil+ (default) keeps the row-count behavior
62
+ def initialize(model_class, attrs_list, unique_by: :id, returning: nil)
58
63
  @model_class = model_class
59
64
  @attrs_list = attrs_list
60
65
  @unique_by = Array(unique_by)
66
+ @returning = returning.nil? ? nil : Array(returning)
61
67
  end
62
68
 
63
69
  # Execute the bulk update as a single `UPDATE ... FROM unnest(...)` statement.
64
70
  #
65
- # @return [Integer] the number of rows affected (0 when +attrs_list+ is empty)
66
- # @raise [ArgumentError] if a row omits a `unique_by` column, or names a column
67
- # that does not exist on the model
71
+ # @return [Integer, Array<Hash{Symbol => Object}>] without +returning+, the number of
72
+ # rows affected (0 when +attrs_list+ is empty); with +returning+, the updated rows as
73
+ # type-cast, Symbol-keyed hashes (+[]+ when +attrs_list+ is empty)
74
+ # @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
68
76
  def call
69
- return 0 if attrs_list.empty?
77
+ validate_returning! unless returning.nil?
78
+ return empty_result if attrs_list.empty?
70
79
 
71
- sql_caller.execute(sql, *bindings).cmd_tuples
80
+ if returning.nil?
81
+ sql_caller.execute(sql, *bindings).cmd_tuples
82
+ else
83
+ sql_caller.select_all_serialized(sql, *bindings)
84
+ end
72
85
  end
73
86
 
74
87
  private
75
88
 
89
+ # The value returned for an empty +attrs_list+: a zero row count, or an empty row set
90
+ # when +returning+ was requested — mirroring the shape {#call} returns when it runs.
91
+ #
92
+ # @return [Integer, Array]
93
+ def empty_result
94
+ returning.nil? ? 0 : []
95
+ end
96
+
97
+ # Validate the requested `RETURNING` columns before any SQL runs: at least one column
98
+ # must be named, and every column must exist on the model (each is qualified with the
99
+ # target alias `t`, so it must be a real column, never an expression).
100
+ #
101
+ # @return [void]
102
+ # @raise [ArgumentError] if +returning+ is empty or names a column unknown to the model
103
+ def validate_returning!
104
+ raise ArgumentError, 'returning must name at least one column' if returning.empty?
105
+
106
+ unknown = returning.map(&:to_s) - model_class.column_names
107
+ raise ArgumentError, "unknown #{model_class} returning columns: #{unknown.join(', ')}" if unknown.any?
108
+ end
109
+
76
110
  # The SQL executor, built from the model's own connection: it sanitizes the bound
77
111
  # values, runs the statement and encodes the typed PostgreSQL arrays.
78
112
  #
@@ -123,16 +157,28 @@ module PgSqlCaller
123
157
  end
124
158
 
125
159
  # The full `UPDATE ... FROM unnest(...)` statement, with one `?` placeholder per
126
- # column for the value arrays.
160
+ # column for the value arrays, plus a `RETURNING` clause when +returning+ was given.
127
161
  #
128
162
  # @return [String]
129
163
  def sql
130
- <<~SQL.squish
164
+ statement = <<~SQL.squish
131
165
  UPDATE #{model_class.quoted_table_name} AS t
132
166
  SET #{set_clause}
133
167
  FROM unnest(#{unnest_args}) AS v(#{column_aliases})
134
168
  WHERE #{match_clause}
135
169
  SQL
170
+ return statement if returning.nil?
171
+
172
+ "#{statement} RETURNING #{returning_clause}"
173
+ end
174
+
175
+ # The `RETURNING t.col, ...` projection. Each column is qualified with the target
176
+ # alias `t` because the `unnest` source alias `v` shares the same column names, so an
177
+ # unqualified `RETURNING` would be ambiguous.
178
+ #
179
+ # @return [String]
180
+ def returning_clause
181
+ returning.map { |col| "t.#{quoted(col)}" }.join(', ')
136
182
  end
137
183
 
138
184
  # The `SET col = v.col, ...` assignments for the value columns.
@@ -170,8 +216,55 @@ module PgSqlCaller
170
216
  def bindings
171
217
  columns.map do |col|
172
218
  values = attrs_list.map { |attrs| attrs[col] }
173
- sql_caller.typecast_array(values, type: model_class.type_for_attribute(col.to_s).type)
219
+ encode_column_array(col, values)
220
+ end
221
+ end
222
+
223
+ # Encode one column's values as a PostgreSQL array literal for its `?::<sql_type>[]`
224
+ # placeholder. Temporal columns are encoded at full microsecond precision: PostgreSQL's
225
+ # default timestamp/time array encoder formats elements via Ruby's `Time#to_s`, which
226
+ # truncates to whole seconds — silently corrupting writes and, worse, breaking any
227
+ # `unique_by` match on a sub-second key (the truncated bind never equals the stored
228
+ # sub-second value, so the row is missed). Non-temporal columns use the standard
229
+ # typed-array encoder unchanged.
230
+ #
231
+ # @param col [Symbol] the column name
232
+ # @param values [Array] the per-row values for that column
233
+ # @return [String] a PostgreSQL array literal
234
+ def encode_column_array(col, values)
235
+ ar_type = model_class.type_for_attribute(col.to_s)
236
+ case ar_type.type
237
+ when :datetime then format_date_time_array(ar_type, values, include_date: true)
238
+ when :time then format_date_time_array(ar_type, values, include_date: false)
239
+ else sql_caller.typecast_array(values, type: ar_type.type)
240
+ end
241
+ end
242
+
243
+ # Build a `{...}` array literal of microsecond-precision temporal literals, reparsed to
244
+ # the column's real type by the surrounding `?::<sql_type>[]` cast with no precision
245
+ # loss. When `include_date` is set (`datetime` columns) the value is normalized to UTC and
246
+ # suffixed `+00:00` — correct for both `timestamp` (the offset is ignored) and `timestamptz`
247
+ # (the offset is honored); otherwise (`time` columns) only the wall-clock time of day is
248
+ # emitted, with no date or zone. Each element is built from a value already cast to a Time
249
+ # and then `strftime`'d into a fixed numeric format, so the literal can hold only
250
+ # `[-0-9:. +]` and needs no escaping. `nil` becomes SQL `NULL`.
251
+ #
252
+ # @param ar_type [ActiveRecord::Type::Value] the column's cast type, used to coerce each
253
+ # value to a Time
254
+ # @param values [Array] the per-row values for that column
255
+ # @param include_date [Boolean] true for `datetime` (date + time, normalized to UTC),
256
+ # false for `time` (time of day only)
257
+ # @return [String] a PostgreSQL array literal
258
+ def format_date_time_array(ar_type, values, include_date:)
259
+ elements = values.map do |value|
260
+ time = ar_type.cast(value)
261
+ next 'NULL' if time.nil?
262
+
263
+ time = time.utc if include_date
264
+ formatted = include_date ? time.strftime('%Y-%m-%d %H:%M:%S.%6N%:z') : time.strftime('%H:%M:%S.%6N')
265
+ %("#{formatted}")
174
266
  end
267
+ "{#{elements.join(',')}}"
175
268
  end
176
269
 
177
270
  # 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.0.0'
4
+ VERSION = '1.1.1'
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.0.0
4
+ version: 1.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Denis Talakevich