Skip to content

Commit 8da8ff8

Browse files
committed
Add Postgres Range Serializer support
1 parent fce45e1 commit 8da8ff8

File tree

8 files changed

+205
-12
lines changed

8 files changed

+205
-12
lines changed

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ recommendations of [keepachangelog.com](http://keepachangelog.com/).
7777

7878
### Fixed
7979

80-
- None
80+
- Fixed errors when deserializing Range types from Ruby style strings to Postgres
8181

8282
## 15.1.0 (2023-10-22)
8383

lib/paper_trail/attribute_serializers/attribute_serializer_factory.rb

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# frozen_string_literal: true
22

33
require "paper_trail/type_serializers/postgres_array_serializer"
4+
require "paper_trail/type_serializers/postgres_range_serializer"
45

56
module PaperTrail
67
module AttributeSerializers
@@ -15,11 +16,16 @@ class << self
1516
# @api private
1617
def for(model_class, attr)
1718
active_record_serializer = model_class.type_for_attribute(attr)
19+
1820
if ar_pg_array?(active_record_serializer)
1921
TypeSerializers::PostgresArraySerializer.new(
2022
active_record_serializer.subtype,
2123
active_record_serializer.delimiter
2224
)
25+
elsif ar_pg_range?(active_record_serializer)
26+
TypeSerializers::PostgresRangeSerializer.new(
27+
active_record_serializer
28+
)
2329
else
2430
active_record_serializer
2531
end
@@ -35,6 +41,15 @@ def ar_pg_array?(obj)
3541
false
3642
end
3743
end
44+
45+
# @api private
46+
def ar_pg_range?(obj)
47+
if defined?(::ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Range)
48+
obj.instance_of?(::ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Range)
49+
else
50+
false
51+
end
52+
end
3853
end
3954
end
4055
end

lib/paper_trail/attribute_serializers/cast_attribute_serializer.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ def initialize(model_class)
2525
# ActiveRecord::Enum was added in AR 4.1
2626
# http://edgeguides.rubyonrails.org/4_1_release_notes.html#active-record-enums
2727
def defined_enums
28-
@defined_enums ||= (@model_class.respond_to?(:defined_enums) ? @model_class.defined_enums : {})
28+
@defined_enums ||=
29+
@model_class.respond_to?(:defined_enums) ? @model_class.defined_enums : {}
2930
end
3031

3132
def deserialize(attr, val)

lib/paper_trail/attribute_serializers/object_attribute.rb

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# frozen_string_literal: true
22

33
require "paper_trail/attribute_serializers/cast_attribute_serializer"
4+
require "paper_trail/type_serializers/postgres_range_serializer"
45

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

3533
serializer = CastAttributeSerializer.new(@model_class)
@@ -40,6 +38,24 @@ def alter(attributes, serialization_method)
4038
attributes
4139
end
4240

41+
# Don't de/serialize non-encrypted before values before inserting into columns of type
42+
# `JSON` on `PostgreSQL` databases; Unless it's a special type like a range.
43+
def attributes_to_serialize(attributes)
44+
encrypted_to_serialize = if object_col_is_json?
45+
attributes.slice(*@encrypted_attributes)
46+
else
47+
attributes
48+
end
49+
50+
columns_to_serialize = attributes.select { |column, _|
51+
TypeSerializers::PostgresRangeSerializer.range_type?(
52+
@model_class.columns_hash[column]&.type
53+
)
54+
}
55+
56+
encrypted_to_serialize.merge(columns_to_serialize)
57+
end
58+
4359
def object_col_is_json?
4460
@model_class.paper_trail.version_class.object_col_is_json?
4561
end

lib/paper_trail/attribute_serializers/object_changes_attribute.rb

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# frozen_string_literal: true
22

33
require "paper_trail/attribute_serializers/cast_attribute_serializer"
4+
require "paper_trail/type_serializers/postgres_range_serializer"
45

56
module PaperTrail
67
module AttributeSerializers
@@ -26,10 +27,7 @@ def deserialize(changes)
2627
# Modifies `changes` in place.
2728
# TODO: Return a new hash instead.
2829
def alter(changes, serialization_method)
29-
# Don't serialize non-encrypted before values before inserting into columns of type
30-
# `JSON` on `PostgreSQL` databases.
31-
changes_to_serialize =
32-
object_changes_col_is_json? ? changes.slice(*@encrypted_attributes) : changes.clone
30+
changes_to_serialize = changes_to_serialize(changes)
3331
return changes if changes_to_serialize.blank?
3432

3533
serializer = CastAttributeSerializer.new(@model_class)
@@ -43,6 +41,24 @@ def alter(changes, serialization_method)
4341
changes
4442
end
4543

44+
# Don't de/serialize non-encrypted before values before inserting into columns of type
45+
# `JSON` on `PostgreSQL` databases; Unless it's a special type like a range.
46+
def changes_to_serialize(changes)
47+
encrypted_to_serialize = if object_changes_col_is_json?
48+
changes.slice(*@encrypted_attributes)
49+
else
50+
changes.clone
51+
end
52+
53+
columns_to_serialize = changes.select { |column, _|
54+
TypeSerializers::PostgresRangeSerializer.range_type?(
55+
@model_class.columns_hash[column]&.type
56+
)
57+
}
58+
59+
encrypted_to_serialize.merge(columns_to_serialize)
60+
end
61+
4662
def object_changes_col_is_json?
4763
@model_class.paper_trail.version_class.object_changes_col_is_json?
4864
end
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# frozen_string_literal: true
2+
3+
module PaperTrail
4+
module TypeSerializers
5+
# Provides an alternative method of serialization
6+
# and deserialization of PostgreSQL range columns.
7+
class PostgresRangeSerializer
8+
# @see https://github.com/rails/rails/blob/main/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb#L147-L152
9+
RANGE_TYPES = %i[
10+
daterange
11+
numrange
12+
tsrange
13+
tstzrange
14+
int4range
15+
int8range
16+
].freeze
17+
18+
def self.range_type?(type)
19+
RANGE_TYPES.include?(type)
20+
end
21+
22+
def initialize(active_record_serializer)
23+
@active_record_serializer = active_record_serializer
24+
end
25+
26+
def serialize(range)
27+
range
28+
end
29+
30+
def deserialize(range)
31+
range.is_a?(String) ? deserialize_with_ar(range) : range
32+
end
33+
34+
private
35+
36+
def deserialize_with_ar(string)
37+
return nil if string.blank?
38+
39+
delimiter = string[/\.{2,3}/]
40+
range_start, range_end = string.split(delimiter)
41+
42+
range_start = @active_record_serializer.subtype.cast(range_start)
43+
range_end = @active_record_serializer.subtype.cast(range_end)
44+
45+
Range.new(range_start, range_end, exclude_end: delimiter == "...")
46+
end
47+
end
48+
end
49+
end

spec/paper_trail/attribute_serializers/object_attribute_spec.rb

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,21 @@ module AttributeSerializers
88
if ENV["DB"] == "postgres"
99
describe "postgres-specific column types" do
1010
describe "#serialize" do
11-
it "serializes a postgres array into a plain array" do
11+
it "serializes a postgres array into a ruby array" do
1212
attrs = { "post_ids" => [1, 2, 3] }
1313
described_class.new(PostgresUser).serialize(attrs)
1414
expect(attrs["post_ids"]).to eq [1, 2, 3]
1515
end
16+
17+
it "serializes a postgres range into a ruby array" do
18+
attrs = { "range" => 1..5 }
19+
described_class.new(PostgresUser).serialize(attrs)
20+
expect(attrs["range"]).to eq 1..5
21+
end
1622
end
1723

1824
describe "#deserialize" do
19-
it "deserializes a plain array correctly" do
25+
it "deserializes a ruby array correctly" do
2026
attrs = { "post_ids" => [1, 2, 3] }
2127
described_class.new(PostgresUser).deserialize(attrs)
2228
expect(attrs["post_ids"]).to eq [1, 2, 3]
@@ -37,6 +43,12 @@ module AttributeSerializers
3743
described_class.new(PostgresUser).deserialize(attrs)
3844
expect(attrs["post_ids"]).to eq [date1, date2, date3]
3945
end
46+
47+
it "deserializes a ruby range correctly" do
48+
attrs = { "range" => 1..5 }
49+
described_class.new(PostgresUser).deserialize(attrs)
50+
expect(attrs["range"]).to eq 1..5
51+
end
4052
end
4153
end
4254
end
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
# frozen_string_literal: true
2+
3+
require "spec_helper"
4+
5+
module PaperTrail
6+
module TypeSerializers
7+
::RSpec.describe PostgresRangeSerializer do
8+
if ENV["DB"] == "postgres"
9+
let(:active_record_serializer) {
10+
ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Range.new(subtype)
11+
}
12+
let(:serializer) { described_class.new(active_record_serializer) }
13+
14+
describe ".deserialize" do
15+
let(:range_string) { range_ruby.to_s }
16+
17+
context "with daterange" do
18+
let(:subtype) { ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Date.new }
19+
let(:range_ruby) { Date.new(2024, 1, 1)..Date.new(2024, 1, 31) }
20+
21+
it "deserializes to Ruby" do
22+
expect(serializer.deserialize(range_string)).to eq(range_ruby)
23+
end
24+
25+
context "with exclude_end" do
26+
let(:range_ruby) { Date.new(2024, 1, 1)...Date.new(2024, 1, 31) }
27+
28+
it "deserializes to Ruby" do
29+
expect(serializer.deserialize(range_string)).to eq(range_ruby)
30+
end
31+
end
32+
end
33+
34+
context "with numrange" do
35+
let(:subtype) { ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Decimal.new }
36+
let(:range_ruby) { 1.5..3.5 }
37+
38+
it "deserializes to Ruby" do
39+
expect(serializer.deserialize(range_string)).to eq(range_ruby)
40+
end
41+
end
42+
43+
context "with tsrange" do
44+
let(:subtype) { ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Timestamp.new }
45+
let(:range_ruby) { 1.day.ago..1.day.from_now }
46+
47+
it "deserializes to Ruby" do
48+
expect(serializer.deserialize(range_string)).to eq(range_ruby)
49+
end
50+
end
51+
52+
context "with tstzrange" do
53+
let(:subtype) {
54+
ActiveRecord::ConnectionAdapters::PostgreSQL::OID::TimestampWithTimeZone.new
55+
}
56+
let(:range_ruby) { Date.new(2021, 1, 1)..Date.new(2021, 1, 31) }
57+
58+
it "deserializes to Ruby" do
59+
expect(serializer.deserialize(range_string)).to eq(range_ruby)
60+
end
61+
end
62+
63+
context "with int4range" do
64+
let(:subtype) { ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Integer.new }
65+
let(:range_ruby) { 1..10 }
66+
67+
it "deserializes to Ruby" do
68+
expect(serializer.deserialize(range_string)).to eq(range_ruby)
69+
end
70+
end
71+
72+
context "with int8range" do
73+
let(:subtype) { ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Integer.new }
74+
let(:range_ruby) { 2_200_000_000..2_500_000_000 }
75+
76+
it "deserializes to Ruby" do
77+
expect(serializer.deserialize(range_string)).to eq(range_ruby)
78+
end
79+
end
80+
end
81+
end
82+
end
83+
end
84+
end

0 commit comments

Comments
 (0)