DmitryTsepelev / store_model

Work with JSON-backed attributes as ActiveRecord-ish models
MIT License
1.03k stars 84 forks source link

Nested Attributes get not serialized correctly #60

Open kaelumania opened 4 years ago

kaelumania commented 4 years ago

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.

DmitryTsepelev commented 4 years ago

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 commented 3 years ago

Closing the issue for now

rmckayfleming commented 3 years ago

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.

DmitryTsepelev commented 3 years ago

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

flop commented 2 years ago

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.

hallelujah commented 2 years ago

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.

makikata commented 2 years ago

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
23tux commented 2 years ago

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.

DmitryTsepelev commented 2 years ago

@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?

23tux commented 1 month ago

@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?