diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f5b0ab8..da422a00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/lib/paper_trail/attribute_serializers/attribute_serializer_factory.rb b/lib/paper_trail/attribute_serializers/attribute_serializer_factory.rb index 49501aeb..767f330f 100644 --- a/lib/paper_trail/attribute_serializers/attribute_serializer_factory.rb +++ b/lib/paper_trail/attribute_serializers/attribute_serializer_factory.rb @@ -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 @@ -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 @@ -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 diff --git a/lib/paper_trail/attribute_serializers/cast_attribute_serializer.rb b/lib/paper_trail/attribute_serializers/cast_attribute_serializer.rb index ccb470b3..38206b64 100644 --- a/lib/paper_trail/attribute_serializers/cast_attribute_serializer.rb +++ b/lib/paper_trail/attribute_serializers/cast_attribute_serializer.rb @@ -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) diff --git a/lib/paper_trail/attribute_serializers/object_attribute.rb b/lib/paper_trail/attribute_serializers/object_attribute.rb index 2ef869de..76cdec17 100644 --- a/lib/paper_trail/attribute_serializers/object_attribute.rb +++ b/lib/paper_trail/attribute_serializers/object_attribute.rb @@ -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 @@ -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) @@ -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 diff --git a/lib/paper_trail/attribute_serializers/object_changes_attribute.rb b/lib/paper_trail/attribute_serializers/object_changes_attribute.rb index fa156188..f44ed9d3 100644 --- a/lib/paper_trail/attribute_serializers/object_changes_attribute.rb +++ b/lib/paper_trail/attribute_serializers/object_changes_attribute.rb @@ -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 @@ -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) @@ -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 diff --git a/lib/paper_trail/type_serializers/postgres_range_serializer.rb b/lib/paper_trail/type_serializers/postgres_range_serializer.rb new file mode 100644 index 00000000..7b95ad09 --- /dev/null +++ b/lib/paper_trail/type_serializers/postgres_range_serializer.rb @@ -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 diff --git a/spec/paper_trail/attribute_serializers/object_attribute_spec.rb b/spec/paper_trail/attribute_serializers/object_attribute_spec.rb index 3bcc3e36..6a5039b1 100644 --- a/spec/paper_trail/attribute_serializers/object_attribute_spec.rb +++ b/spec/paper_trail/attribute_serializers/object_attribute_spec.rb @@ -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] @@ -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 diff --git a/spec/paper_trail/type_serializers/postgres_range_serializer_spec.rb b/spec/paper_trail/type_serializers/postgres_range_serializer_spec.rb new file mode 100644 index 00000000..42e0a469 --- /dev/null +++ b/spec/paper_trail/type_serializers/postgres_range_serializer_spec.rb @@ -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