Skip to content

Commit

Permalink
Add Postgres Range Serializer support
Browse files Browse the repository at this point in the history
  • Loading branch information
sirwolfgang committed Nov 4, 2024
1 parent 7923128 commit a4a6e7b
Show file tree
Hide file tree
Showing 8 changed files with 205 additions and 12 deletions.
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ recommendations of [keepachangelog.com](http://keepachangelog.com/).

### Fixed

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

## 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 @@ -15,11 +16,16 @@ class << self
# @api private
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 @@ -25,7 +25,8 @@ def initialize(model_class)
# 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 ||= (@model_class.respond_to?(:defined_enums) ? @model_class.defined_enums : {})
@defined_enums ||=
@model_class.respond_to?(:defined_enums) ? @model_class.defined_enums : {}
end

def deserialize(attr, val)
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 Down Expand Up @@ -29,10 +30,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 @@ -43,6 +41,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
24 changes: 20 additions & 4 deletions lib/paper_trail/attribute_serializers/object_changes_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 Down Expand Up @@ -29,10 +30,7 @@ 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(@model_class)
Expand All @@ -46,6 +44,24 @@ 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?
@model_class.paper_trail.version_class.object_changes_col_is_json?
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}/]
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
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

0 comments on commit a4a6e7b

Please sign in to comment.