Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for Postgres Range Types #1487

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
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 @@ -15,7 +15,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 @@ -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 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
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,18 +1,19 @@
# 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 =
if PaperTrail.active_record_gte_7_0?
@item_class.encrypted_attributes&.map(&:to_s)
@model_class.encrypted_attributes&.map(&:to_s)
end
end

Expand All @@ -29,13 +30,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 @@ -46,8 +44,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}/]
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
Loading