From 6b824f0af6681f0bf8629cc9b5ce6506d7f35318 Mon Sep 17 00:00:00 2001 From: Kirill Paromonov Date: Tue, 23 Jun 2026 10:59:46 +0300 Subject: [PATCH] Fix BulkUpdate truncating datetime binds to whole seconds --- CHANGELOG.md | 11 ++++++ lib/pg_sql_caller/bulk_update.rb | 49 +++++++++++++++++++++++- lib/pg_sql_caller/version.rb | 2 +- spec/fixtures/active_record.rb | 1 + spec/pg_sql_caller/bulk_update_spec.rb | 52 ++++++++++++++++++++++++++ 5 files changed, 113 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b4412e..b63743f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.1.1] - 2026-06-22 + +### Fixed + +- `PgSqlCaller::BulkUpdate` no longer truncates `datetime`/`time` values to whole seconds. + PostgreSQL's default timestamp array encoder formats elements via Ruby's `Time#to_s`, + dropping sub-seconds — which silently corrupted sub-second writes and, worse, broke any + `unique_by` match on a sub-second timestamp key (the truncated bind never equalled the + stored value, so the row was missed and the update became a silent no-op). Temporal + columns are now encoded at full microsecond precision. + ## [1.1.0] - 2026-06-18 ### Added diff --git a/lib/pg_sql_caller/bulk_update.rb b/lib/pg_sql_caller/bulk_update.rb index a8db6d2..508b2d7 100644 --- a/lib/pg_sql_caller/bulk_update.rb +++ b/lib/pg_sql_caller/bulk_update.rb @@ -216,10 +216,57 @@ def column_aliases def bindings columns.map do |col| values = attrs_list.map { |attrs| attrs[col] } - sql_caller.typecast_array(values, type: model_class.type_for_attribute(col.to_s).type) + encode_column_array(col, values) end end + # Encode one column's values as a PostgreSQL array literal for its `?::[]` + # placeholder. Temporal columns are encoded at full microsecond precision: PostgreSQL's + # default timestamp/time array encoder formats elements via Ruby's `Time#to_s`, which + # truncates to whole seconds — silently corrupting writes and, worse, breaking any + # `unique_by` match on a sub-second key (the truncated bind never equals the stored + # sub-second value, so the row is missed). Non-temporal columns use the standard + # typed-array encoder unchanged. + # + # @param col [Symbol] the column name + # @param values [Array] the per-row values for that column + # @return [String] a PostgreSQL array literal + def encode_column_array(col, values) + ar_type = model_class.type_for_attribute(col.to_s) + case ar_type.type + when :datetime then format_date_time_array(ar_type, values, include_date: true) + when :time then format_date_time_array(ar_type, values, include_date: false) + else sql_caller.typecast_array(values, type: ar_type.type) + end + end + + # Build a `{...}` array literal of microsecond-precision temporal literals, reparsed to + # the column's real type by the surrounding `?::[]` cast with no precision + # loss. When `include_date` is set (`datetime` columns) the value is normalized to UTC and + # suffixed `+00:00` — correct for both `timestamp` (the offset is ignored) and `timestamptz` + # (the offset is honored); otherwise (`time` columns) only the wall-clock time of day is + # emitted, with no date or zone. Each element is built from a value already cast to a Time + # and then `strftime`'d into a fixed numeric format, so the literal can hold only + # `[-0-9:. +]` and needs no escaping. `nil` becomes SQL `NULL`. + # + # @param ar_type [ActiveRecord::Type::Value] the column's cast type, used to coerce each + # value to a Time + # @param values [Array] the per-row values for that column + # @param include_date [Boolean] true for `datetime` (date + time, normalized to UTC), + # false for `time` (time of day only) + # @return [String] a PostgreSQL array literal + def format_date_time_array(ar_type, values, include_date:) + elements = values.map do |value| + time = ar_type.cast(value) + next 'NULL' if time.nil? + + time = time.utc if include_date + formatted = include_date ? time.strftime('%Y-%m-%d %H:%M:%S.%6N%:z') : time.strftime('%H:%M:%S.%6N') + %("#{formatted}") + end + "{#{elements.join(',')}}" + end + # The PostgreSQL type of a column, used to build its array cast. # # @param col [Symbol] a column name diff --git a/lib/pg_sql_caller/version.rb b/lib/pg_sql_caller/version.rb index 5a76873..fe76a1d 100644 --- a/lib/pg_sql_caller/version.rb +++ b/lib/pg_sql_caller/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module PgSqlCaller - VERSION = '1.1.0' + VERSION = '1.1.1' end diff --git a/spec/fixtures/active_record.rb b/spec/fixtures/active_record.rb index bd4d02d..c21b7b0 100644 --- a/spec/fixtures/active_record.rb +++ b/spec/fixtures/active_record.rb @@ -38,6 +38,7 @@ create_table :employees do |t| t.integer :department_id, null: false t.string :name, null: false + t.time :shift_start t.timestamps null: false end end diff --git a/spec/pg_sql_caller/bulk_update_spec.rb b/spec/pg_sql_caller/bulk_update_spec.rb index 82531ae..02c5cd2 100644 --- a/spec/pg_sql_caller/bulk_update_spec.rb +++ b/spec/pg_sql_caller/bulk_update_spec.rb @@ -57,6 +57,58 @@ end end + # PostgreSQL's default timestamp array encoder formats elements via Time#to_s, dropping + # sub-seconds. These guard the microsecond-precision encoding for datetime arrays. + context 'with a sub-second datetime value' do + let(:precise) { Time.utc(2026, 6, 22, 16, 15, 8, 193_456) } + let(:attrs_list) { [{ id: first.id, created_at: precise }] } + + it 'preserves microsecond precision (not truncated to whole seconds)' do + subject + expect(first.reload.created_at.utc.strftime('%6N')).to eq('193456') + end + end + + context 'matching on a sub-second datetime unique_by key' do + subject { described_class.call(Employee, attrs_list, unique_by: %i[created_at]) } + + let(:precise) { Time.utc(2026, 6, 22, 16, 15, 8, 193_000) } + let(:attrs_list) { [{ created_at: precise, name: 'Matched' }] } + + before { first.update_column(:created_at, precise) } + + it 'matches the row despite sub-second precision', :aggregate_failures do + expect(subject).to eq(1) + expect(first.reload.name).to eq('Matched') + end + end + + # `time` columns hit the same default-array-encoder truncation as `datetime`; these guard + # the time-of-day encoding path (no date, no zone). + context 'with a sub-second time value' do + let(:shift_start) { Time.utc(2000, 1, 1, 16, 15, 8, 193_456) } + let(:attrs_list) { [{ id: first.id, shift_start: shift_start }] } + + it 'preserves microsecond precision (not truncated to whole seconds)' do + subject + expect(first.reload.shift_start.strftime('%H:%M:%S.%6N')).to eq('16:15:08.193456') + end + end + + context 'matching on a sub-second time unique_by key' do + subject { described_class.call(Employee, attrs_list, unique_by: %i[shift_start]) } + + let(:shift_start) { Time.utc(2000, 1, 1, 16, 15, 8, 193_000) } + let(:attrs_list) { [{ shift_start: shift_start, name: 'Matched' }] } + + before { first.update_column(:shift_start, shift_start) } + + it 'matches the row despite sub-second precision', :aggregate_failures do + expect(subject).to eq(1) + expect(first.reload.name).to eq('Matched') + end + end + context 'with a composite unique_by' do subject { described_class.call(Employee, attrs_list, unique_by: %i[department_id name]) }