Skip to content
Open
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
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ recommendations of [keepachangelog.com](http://keepachangelog.com/).

### Fixed

- None
- Fixed errors when deserializing Range types from Ruby style strings to Postgres
Copy link

Copilot AI Nov 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CHANGELOG entry states "Fixed errors when deserializing Range types from Ruby style strings to Postgres" but this is confusing. It should clarify whether it's deserializing FROM Ruby strings or FROM Postgres format strings. Based on the code, it appears to deserialize Ruby-style range strings (e.g., "1..5") to Ruby Range objects. Consider rewording to: "Fixed support for PostgreSQL range column types in object/object_changes serialization"

Suggested change
- Fixed errors when deserializing Range types from Ruby style strings to Postgres
- Fixed support for PostgreSQL range column types in object/object_changes serialization

Copilot uses AI. Check for mistakes.

## 15.1.0 (2023-10-22)

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# frozen_string_literal: true

require "paper_trail/type_serializers/postgres_array_serializer"
require "paper_trail/type_serializers/postgres_range_serializer"

module PaperTrail
module AttributeSerializers
Expand All @@ -13,13 +14,18 @@ module AttributeSerializers
module AttributeSerializerFactory
class << self
# @api private
def for(klass, attr)
active_record_serializer = klass.type_for_attribute(attr)
def for(model_class, attr)
active_record_serializer = model_class.type_for_attribute(attr)

if ar_pg_array?(active_record_serializer)
TypeSerializers::PostgresArraySerializer.new(
active_record_serializer.subtype,
active_record_serializer.delimiter
)
elsif ar_pg_range?(active_record_serializer)
TypeSerializers::PostgresRangeSerializer.new(
active_record_serializer
)
else
active_record_serializer
end
Expand All @@ -35,6 +41,15 @@ def ar_pg_array?(obj)
false
end
end

# @api private
def ar_pg_range?(obj)
if defined?(::ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Range)
obj.instance_of?(::ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Range)
else
false
end
end
end
end
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ module AttributeSerializers
# example, the string "1.99" serializes into the integer `1` when assigned
# to an attribute of type `ActiveRecord::Type::Integer`.
class CastAttributeSerializer
def initialize(klass)
@klass = klass
def initialize(model_class)
@model_class = model_class
end

private
Expand All @@ -25,7 +25,8 @@ def initialize(klass)
# ActiveRecord::Enum was added in AR 4.1
# http://edgeguides.rubyonrails.org/4_1_release_notes.html#active-record-enums
def defined_enums
@defined_enums ||= (@klass.respond_to?(:defined_enums) ? @klass.defined_enums : {})
@defined_enums ||=
@model_class.respond_to?(:defined_enums) ? @model_class.defined_enums : {}
end

def deserialize(attr, val)
Expand All @@ -39,12 +40,12 @@ def deserialize(attr, val)
# https://github.com/rails/rails/issues/43966
val.instance_variable_get(:@time)
else
AttributeSerializerFactory.for(@klass, attr).deserialize(val)
AttributeSerializerFactory.for(@model_class, attr).deserialize(val)
end
end

def serialize(attr, val)
AttributeSerializerFactory.for(@klass, attr).serialize(val)
AttributeSerializerFactory.for(@model_class, attr).serialize(val)
end
end
end
Expand Down
24 changes: 20 additions & 4 deletions lib/paper_trail/attribute_serializers/object_attribute.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# frozen_string_literal: true

require "paper_trail/attribute_serializers/cast_attribute_serializer"
require "paper_trail/type_serializers/postgres_range_serializer"

module PaperTrail
module AttributeSerializers
Expand All @@ -26,10 +27,7 @@ def deserialize(attributes)
# Modifies `attributes` in place.
# TODO: Return a new hash instead.
def alter(attributes, serialization_method)
# Don't serialize non-encrypted before values before inserting into columns of type
# `JSON` on `PostgreSQL` databases.
attributes_to_serialize =
object_col_is_json? ? attributes.slice(*@encrypted_attributes) : attributes
attributes_to_serialize = attributes_to_serialize(attributes)
return attributes if attributes_to_serialize.blank?

serializer = CastAttributeSerializer.new(@model_class)
Expand All @@ -40,6 +38,24 @@ def alter(attributes, serialization_method)
attributes
end

# Don't de/serialize non-encrypted before values before inserting into columns of type
# `JSON` on `PostgreSQL` databases; Unless it's a special type like a range.
def attributes_to_serialize(attributes)
encrypted_to_serialize = if object_col_is_json?
attributes.slice(*@encrypted_attributes)
else
attributes
end

columns_to_serialize = attributes.select { |column, _|
TypeSerializers::PostgresRangeSerializer.range_type?(
@model_class.columns_hash[column]&.type
)
}

encrypted_to_serialize.merge(columns_to_serialize)
end

def object_col_is_json?
@model_class.paper_trail.version_class.object_col_is_json?
end
Expand Down
34 changes: 25 additions & 9 deletions lib/paper_trail/attribute_serializers/object_changes_attribute.rb
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
# frozen_string_literal: true

require "paper_trail/attribute_serializers/cast_attribute_serializer"
require "paper_trail/type_serializers/postgres_range_serializer"

module PaperTrail
module AttributeSerializers
# Serialize or deserialize the `version.object_changes` column.
class ObjectChangesAttribute
def initialize(item_class)
@item_class = item_class
def initialize(model_class)
@model_class = model_class

# ActiveRecord since 7.0 has a built-in encryption mechanism
@encrypted_attributes = @item_class.encrypted_attributes&.map(&:to_s)
@encrypted_attributes = @model_class.encrypted_attributes&.map(&:to_s)
end

def serialize(changes)
Expand All @@ -26,13 +27,10 @@ def deserialize(changes)
# Modifies `changes` in place.
# TODO: Return a new hash instead.
def alter(changes, serialization_method)
# Don't serialize non-encrypted before values before inserting into columns of type
# `JSON` on `PostgreSQL` databases.
changes_to_serialize =
object_changes_col_is_json? ? changes.slice(*@encrypted_attributes) : changes.clone
changes_to_serialize = changes_to_serialize(changes)
return changes if changes_to_serialize.blank?

serializer = CastAttributeSerializer.new(@item_class)
serializer = CastAttributeSerializer.new(@model_class)
changes_to_serialize.each do |key, change|
# `change` is an Array with two elements, representing before and after.
changes[key] = Array(change).map do |value|
Expand All @@ -43,8 +41,26 @@ def alter(changes, serialization_method)
changes
end

# Don't de/serialize non-encrypted before values before inserting into columns of type
# `JSON` on `PostgreSQL` databases; Unless it's a special type like a range.
def changes_to_serialize(changes)
encrypted_to_serialize = if object_changes_col_is_json?
changes.slice(*@encrypted_attributes)
else
changes.clone
end

columns_to_serialize = changes.select { |column, _|
TypeSerializers::PostgresRangeSerializer.range_type?(
@model_class.columns_hash[column]&.type
)
}

encrypted_to_serialize.merge(columns_to_serialize)
end

def object_changes_col_is_json?
@item_class.paper_trail.version_class.object_changes_col_is_json?
@model_class.paper_trail.version_class.object_changes_col_is_json?
end
end
end
Expand Down
49 changes: 49 additions & 0 deletions lib/paper_trail/type_serializers/postgres_range_serializer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# frozen_string_literal: true

module PaperTrail
module TypeSerializers
# Provides an alternative method of serialization
# and deserialization of PostgreSQL range columns.
class PostgresRangeSerializer
# @see https://github.com/rails/rails/blob/main/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb#L147-L152
RANGE_TYPES = %i[
daterange
numrange
tsrange
tstzrange
int4range
int8range
].freeze

def self.range_type?(type)
RANGE_TYPES.include?(type)
end

def initialize(active_record_serializer)
@active_record_serializer = active_record_serializer
end

def serialize(range)
range
end

def deserialize(range)
range.is_a?(String) ? deserialize_with_ar(range) : range
end

private

def deserialize_with_ar(string)
return nil if string.blank?

delimiter = string[/\.{2,3}/]
Copy link

Copilot AI Nov 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The regex pattern /\.{2,3}/ will match 2 or more consecutive dots, which means it could match "....", ".....", etc. This should be /\.{2}|\.{3}/ or more simply /(\.\.\.?)/ to match only ".." or "..." exactly.

Suggested change
delimiter = string[/\.{2,3}/]
delimiter = string[/(\.\.\.?)/]

Copilot uses AI. Check for mistakes.
range_start, range_end = string.split(delimiter)

range_start = @active_record_serializer.subtype.cast(range_start)
range_end = @active_record_serializer.subtype.cast(range_end)

Range.new(range_start, range_end, exclude_end: delimiter == "...")
end
end
end
end
16 changes: 14 additions & 2 deletions spec/paper_trail/attribute_serializers/object_attribute_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,21 @@ module AttributeSerializers
if ENV["DB"] == "postgres"
describe "postgres-specific column types" do
describe "#serialize" do
it "serializes a postgres array into a plain array" do
it "serializes a postgres array into a ruby array" do
attrs = { "post_ids" => [1, 2, 3] }
described_class.new(PostgresUser).serialize(attrs)
expect(attrs["post_ids"]).to eq [1, 2, 3]
end

it "serializes a postgres range into a ruby array" do
Copy link

Copilot AI Nov 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test description says "serializes a postgres range into a ruby array" but it should say "serializes a postgres range into a ruby range" since ranges are being serialized as ranges, not arrays.

Suggested change
it "serializes a postgres range into a ruby array" do
it "serializes a postgres range into a ruby range" do

Copilot uses AI. Check for mistakes.
attrs = { "range" => 1..5 }
described_class.new(PostgresUser).serialize(attrs)
expect(attrs["range"]).to eq 1..5
end
end

describe "#deserialize" do
it "deserializes a plain array correctly" do
it "deserializes a ruby array correctly" do
attrs = { "post_ids" => [1, 2, 3] }
described_class.new(PostgresUser).deserialize(attrs)
expect(attrs["post_ids"]).to eq [1, 2, 3]
Expand All @@ -37,6 +43,12 @@ module AttributeSerializers
described_class.new(PostgresUser).deserialize(attrs)
expect(attrs["post_ids"]).to eq [date1, date2, date3]
end

it "deserializes a ruby range correctly" do
attrs = { "range" => 1..5 }
described_class.new(PostgresUser).deserialize(attrs)
expect(attrs["range"]).to eq 1..5
end
end
end
end
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# frozen_string_literal: true

require "spec_helper"

module PaperTrail
module TypeSerializers
::RSpec.describe PostgresRangeSerializer do
if ENV["DB"] == "postgres"
let(:active_record_serializer) {
ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Range.new(subtype)
}
let(:serializer) { described_class.new(active_record_serializer) }

describe ".deserialize" do
let(:range_string) { range_ruby.to_s }

context "with daterange" do
let(:subtype) { ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Date.new }
let(:range_ruby) { Date.new(2024, 1, 1)..Date.new(2024, 1, 31) }

it "deserializes to Ruby" do
expect(serializer.deserialize(range_string)).to eq(range_ruby)
end

context "with exclude_end" do
let(:range_ruby) { Date.new(2024, 1, 1)...Date.new(2024, 1, 31) }

it "deserializes to Ruby" do
expect(serializer.deserialize(range_string)).to eq(range_ruby)
end
end
end

context "with numrange" do
let(:subtype) { ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Decimal.new }
let(:range_ruby) { 1.5..3.5 }

it "deserializes to Ruby" do
expect(serializer.deserialize(range_string)).to eq(range_ruby)
end
end

context "with tsrange" do
let(:subtype) { ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Timestamp.new }
let(:range_ruby) { 1.day.ago..1.day.from_now }

it "deserializes to Ruby" do
expect(serializer.deserialize(range_string)).to eq(range_ruby)
end
end

context "with tstzrange" do
let(:subtype) {
ActiveRecord::ConnectionAdapters::PostgreSQL::OID::TimestampWithTimeZone.new
}
let(:range_ruby) { Date.new(2021, 1, 1)..Date.new(2021, 1, 31) }

it "deserializes to Ruby" do
expect(serializer.deserialize(range_string)).to eq(range_ruby)
end
end

context "with int4range" do
let(:subtype) { ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Integer.new }
let(:range_ruby) { 1..10 }

it "deserializes to Ruby" do
expect(serializer.deserialize(range_string)).to eq(range_ruby)
end
end

context "with int8range" do
let(:subtype) { ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Integer.new }
let(:range_ruby) { 2_200_000_000..2_500_000_000 }

it "deserializes to Ruby" do
expect(serializer.deserialize(range_string)).to eq(range_ruby)
end
end
end
end
end
end
end
Loading