From 128ee1ab31e241372e9d45abb74c98edfe1ca398 Mon Sep 17 00:00:00 2001 From: Zane Wolfgang Pickett Date: Sun, 18 Aug 2024 17:07:47 -0400 Subject: [PATCH 1/2] Normalize AttributeSerializers model_class refs --- .../attribute_serializer_factory.rb | 4 ++-- .../attribute_serializers/cast_attribute_serializer.rb | 10 +++++----- .../attribute_serializers/object_changes_attribute.rb | 10 +++++----- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/lib/paper_trail/attribute_serializers/attribute_serializer_factory.rb b/lib/paper_trail/attribute_serializers/attribute_serializer_factory.rb index 9c73c65e..49501aeb 100644 --- a/lib/paper_trail/attribute_serializers/attribute_serializer_factory.rb +++ b/lib/paper_trail/attribute_serializers/attribute_serializer_factory.rb @@ -13,8 +13,8 @@ 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, diff --git a/lib/paper_trail/attribute_serializers/cast_attribute_serializer.rb b/lib/paper_trail/attribute_serializers/cast_attribute_serializer.rb index 1371b3b5..ccb470b3 100644 --- a/lib/paper_trail/attribute_serializers/cast_attribute_serializer.rb +++ b/lib/paper_trail/attribute_serializers/cast_attribute_serializer.rb @@ -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 @@ -25,7 +25,7 @@ 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) @@ -39,12 +39,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 diff --git a/lib/paper_trail/attribute_serializers/object_changes_attribute.rb b/lib/paper_trail/attribute_serializers/object_changes_attribute.rb index 65805089..fa156188 100644 --- a/lib/paper_trail/attribute_serializers/object_changes_attribute.rb +++ b/lib/paper_trail/attribute_serializers/object_changes_attribute.rb @@ -6,13 +6,13 @@ 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 @@ -35,7 +35,7 @@ def alter(changes, serialization_method) object_changes_col_is_json? ? changes.slice(*@encrypted_attributes) : changes.clone 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| @@ -47,7 +47,7 @@ def alter(changes, serialization_method) 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 From dd8b8c830f2bf8a267be32ec8f22b7494c52a856 Mon Sep 17 00:00:00 2001 From: Zane Wolfgang Pickett Date: Sun, 18 Aug 2024 17:43:46 -0400 Subject: [PATCH 2/2] Add Postgres Range Serializer support --- CHANGELOG.md | 2 +- .../attribute_serializer_factory.rb | 15 ++++ .../cast_attribute_serializer.rb | 3 +- .../attribute_serializers/object_attribute.rb | 24 +++++- .../object_changes_attribute.rb | 24 +++++- .../postgres_range_serializer.rb | 49 +++++++++++ .../object_attribute_spec.rb | 16 +++- .../postgres_range_serializer_spec.rb | 84 +++++++++++++++++++ 8 files changed, 205 insertions(+), 12 deletions(-) create mode 100644 lib/paper_trail/type_serializers/postgres_range_serializer.rb create mode 100644 spec/paper_trail/type_serializers/postgres_range_serializer_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index b31b4435..1b3e5f83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) 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