Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
49 changes: 48 additions & 1 deletion lib/pg_sql_caller/bulk_update.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 `?::<sql_type>[]`
# 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 `?::<sql_type>[]` 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
Expand Down
2 changes: 1 addition & 1 deletion lib/pg_sql_caller/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module PgSqlCaller
VERSION = '1.1.0'
VERSION = '1.1.1'
end
1 change: 1 addition & 0 deletions spec/fixtures/active_record.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 52 additions & 0 deletions spec/pg_sql_caller/bulk_update_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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]) }

Expand Down
Loading