store_model icon indicating copy to clipboard operation
store_model copied to clipboard

Nested Attributes get not serialized correctly

Open kaelumania opened this issue 4 years ago • 10 comments

Hello,

I was wondering, why during serialization my custom types don't get used to determine the json format:

class DateOrTimeType < ActiveRecord::Type::Value
  def type
    :json
  end

  def cast_value(value)
    case value
    when String
      decoded = ActiveSupport::JSON.decode(value) rescue nil
      build_from(decoded)
    when Hash
      build_from(value)
    when Date, Time, DateTime
      value
    end
  end

  def serialize(value)
    case value
    when Date
      ActiveSupport::JSON.encode(date: value)
    when Time, DateTime
      ActiveSupport::JSON.encode(datetime: value)
    else
      super
    end
  end

  def changed_in_place?(raw_old_value, new_value)
    cast_value(raw_old_value) != new_value
  end

  private

  def build_from(hash)
    if hash['date'].present?
      Date.parse(hash['date'])
    else
      Time.parse(hash['datetime'])
    end
  end

end
class Appointments::Schedule
  include StoreModel::Model

  attribute :from, DateOrTimeType.new
  attribute :to, :date_or_time

but during serialization, the default json serialisation of the given type is used.

kaelumania avatar May 27 '20 12:05 kaelumania

Hi @kaelumania! Could you please provide the example of what format you're trying to get and what you see instead? Gist or failing spec would be really helpful

DmitryTsepelev avatar Jun 02 '20 12:06 DmitryTsepelev

Closing the issue for now

DmitryTsepelev avatar Aug 17 '20 12:08 DmitryTsepelev

I’m running into a similar issue. As far as I can tell, serialize and deserialize are never called on attributes with custom types. The only method called is cast.

rmckayfleming avatar Mar 04 '21 00:03 rmckayfleming

Yeah, I re–checked it quickly, and looks like a regular #save calls only cast/cast_value

DmitryTsepelev avatar Mar 04 '21 09:03 DmitryTsepelev

I just ran into this problem. Serialize is not called on save. Here is a simplified version of what I'm trying to do :

class Shipment < ActiveRecord::Base
  attribute :recipient, Shipment::Recipient.to_type
end

class Shipment::Recipient
  include StoreModel::Model
  attribute :country, :country
end

require 'countries' # https://github.com/countries/countries
class CountryType < ActiveModel::Type::Value
  def cast(value)
    ISO3166::Country.new(value)
  end

  def serialize(value)
    value.alpha2
  end
end

shipment = Shipment.create!(recipient: {country: 'FR'})

This should save {"country": ''FR'} in the recipient attribute in the database and shipment.recipient.country should be a ISO3166::Country. But instead the gem is trying to save a hash of all the data from ISO3166::Country without calling serialize.

The CountryType works correctly when used with a direct string attribute.

flop avatar Oct 02 '21 15:10 flop

Yes this is because of https://github.com/DmitryTsepelev/store_model/blob/44071d2f5b3367ab19529438dd56fe02f2149a77/lib/store_model/types/one.rb#L49-L50

TL;DR It calls directly as_json instead of serializing the values as defined.

hallelujah avatar Nov 07 '21 19:11 hallelujah

I worked it around somehow including ActiveModel::Serializers::JSON and overriding read_attribute_for_serialization

class Message < ApplicationRecord
  attribute :payload, MessagePayload.to_type
end

class MessagePayload
  include StoreModel::Model
  # Workaround for: https://github.com/DmitryTsepelev/store_model/issues/60#issuecomment-962668573
  # `as_json` will read values from `read_attribute_for_serialization`
  include ActiveModel::Serializers::JSON

  attribute :user, ActiveModel::Type::GlobalId.new

  def read_attribute_for_serialization(attribute)
    attribute_types[attribute].serialize(super)
  end
end

module ActiveModel
  module Type
    # An ActiveModel::Type that serializes and deserializes GlobalID object    
    class GlobalId < Value

      def type
        :global_id
      end

      def serialize(value)
        value&.to_gid.try(:to_s)
      end

      def cast_value(value)
        value.is_a?(::String) ? GlobalID::Locator.locate(value) : value
      end

      def assert_valid_value(value)
        value.nil? ||  value.respond_to?(:to_gid) || value.to_s.start_with?('gid://')
      end
    end
  end
end

makikata avatar Nov 08 '21 00:11 makikata

Is there any news on this? I'm running into a similar problem, when trying to build a type that encrypts data when stored to the DB and encrypts it on the fly when read.

23tux avatar Apr 21 '22 05:04 23tux

@23tux @flop @kaelumania could you please take a look at #60? I created a custom type with the serialization method and it seems to work. What am I missing?

DmitryTsepelev avatar Jun 14 '22 15:06 DmitryTsepelev

@DmitryTsepelev I just stumbled upon this old issue, as I'm trying to implement some encryption handling into StoreModel (again).

My approach seems to be fine for non-OneOf use cases. I tried to make a ready to use file that can be run with ruby debug.rb:

# frozen_string_literal: true

ENV["BUNDLE_GEMFILE"] = ""
require "bundler/inline"

gemfile(true) do
  source "https://rubygems.org"
  gem "rails", "7.1.3.3"
  gem "sqlite3", "1.7.3"
  gem "store_model", "3.0.0"
end

require "active_record"
require "active_support/all"
require "store_model"

ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")

ActiveRecord::Schema.define do
  create_table :dummies, force: true do |t|
    t.json :duck
    t.json :pond
    t.json :water
  end
end

class EncryptedString < ActiveRecord::Type::String
  def deserialize(value)
    return unless value = super

    Rails::Secrets.decrypt(value)
  end

  def serialize(value)
    return unless value = super

    Rails::Secrets.encrypt(value)
  end
end

class Duck
  include StoreModel::Model
  attribute :name, EncryptedString.new
end

class Pond
  include StoreModel::Model
  attribute :model, :string
  attribute :duck, Duck.to_type
end

class Lake
  include StoreModel::Model
  attribute :model, :string
  attribute :duck, Duck.to_type
end

Water = StoreModel.one_of { Lake }

class Dummy < ActiveRecord::Base
  attribute :duck, Duck.to_type
  attribute :pond, Pond.to_type
  attribute :water, Water.to_type
end

dummy = Dummy.create(
  duck: { name: "Steve" },
  pond: { duck: { name: "John" } },
  water: { duck: { name: "Bob" } }
)
dummy.reload

puts dummy.duck.inspect
=> #<Duck name: "Steve">

puts dummy.pond.inspect
=> #<Pond duck: #<Duck name: "John">>

puts dummy.water.inspect
=> #<Lake duck: #<Duck name: "ucN9gjlKAIHdcHrdhw==--N8nHKnRBj6NL4X6a--SFwr8nj1MfiPZlqykMgRww==">>

As you can see, the water attribute, which has a OneOf configuration, did NOT successfully decrypt the data. Can you help me find out why? Why isn't the #deserialize method called from my custom type?

Edit: It seems that it boils down to these lines

https://github.com/DmitryTsepelev/store_model/blob/842a98c78ac47dd9e80b1ca1084f90d51903d33b/lib/store_model/types/one_base.rb#L51-L52

Here, value is just the json from the DB and it get's decoded and then passed to Lake.new which does not involve #deserialize anymore. Is this intended?

23tux avatar Jun 02 '24 13:06 23tux