kgiszczak / shale

Shale is a Ruby object mapper and serializer for JSON, YAML, TOML, CSV and XML. It allows you to parse JSON, YAML, TOML, CSV and XML data and convert it into Ruby data structures, as well as serialize data structures into JSON, YAML, TOML, CSV or XML.
https://shalerb.org/
MIT License
618 stars 19 forks source link

Polymorphic Types #24

Closed stex closed 11 months ago

stex commented 11 months ago

Hey!

First of all, thanks a lot for this gem, having a serializer and deserializer for JSON, YAML and XML at the same time is absolutely wonderful!

I came across an issue that currently hinders its usefulness for me though. Maybe it's such a special case that it's not worth investigating or maybe there is already a solution that I simply didn't see.

In my case, the issue arose when using Rails models as custom models:

class Person < ApplicationRecord
  has_one :pet, polymorphic: true
end

class Dog < ApplicationRecord
  belongs_to :person, as: :pet
end

class Cat < ApplicationRecord
  belongs_to :person, as: :pet
end

# Mapper

class PersonMapper < Shale::Mapper
  model Person

  attribute :id, Shale::Type::Integer
  attribute :pet, ???
end

class DogMapper < Shale::Mapper
  model Dog
  ...
end

class CatMapper < Shale::Mapper
  model Cat
  ...
end

As you can see, I didn't find a way to describe the polymorphic type for pet. How I would handle this if I'd (de)serialize it by hand:

Serialize (JSON as example)

{
  "id": 1,
  "pet": {
    "type": "Dog",
    ...
  }
}

Take the actual type of the object to be serialized and add it as type field.

Deserialize

Expect a type field and use the corresponding mapper.

Is this something that's simply not possible or did I miss it? As far as I can see it, I can manipulate the values when (de)serializing, but not the type.

Thanks a lot in advance!

kgiszczak commented 11 months ago

hey,

it's possible, but since it's very domain specific you have to do some plumbing by hand. Here's an example how could you handle it:

require 'shale'

class Person
  attr_accessor :id
  attr_accessor :pet
end

class Dog
  attr_accessor :dog_name
end

class Cat
  attr_accessor :cat_name
end

# Mapper

class PersonMapper < Shale::Mapper
  model Person

  attribute :id, Shale::Type::Integer

  json do
    map 'id', to: :id
    map 'pet', using: { to: :pet_to_json, from: :pet_from_json }
  end

  def pet_from_json(model, value)
    case value['type']
    when 'Dog'
      model.pet = Dog.new
      model.pet.dog_name = value['dog_name']
      # or mayby load it from database
      # model.pet = Dog.find_by(name: value['dog_name'])
    when 'Cat'
      model.pet = Cat.new
      model.pet.cat_name = value['cat_name']
    end
  end

  def pet_to_json(model, doc)
    case model.pet
    when Dog
      doc['pet'] = { type: 'Dog', **DogMapper.as_json(model.pet) }
    when Cat
      doc['pet'] = { type: 'Cat', **CatMapper.as_json(model.pet) }
    end
  end
end

class DogMapper < Shale::Mapper
  model Dog

  attribute :dog_name, Shale::Type::String
end

class CatMapper < Shale::Mapper
  model Cat

  attribute :cat_name, Shale::Type::String
end

person = Person.new
person.id = 1
person.pet = Dog.new
person.pet.dog_name = 'Lucky'

puts PersonMapper.to_json(person)

person = Person.new
person.id = 1
person.pet = Cat.new
person.pet.cat_name = 'Fluffy'

puts PersonMapper.to_json(person)

puts '--------------------'

p PersonMapper.from_json('{"id":1,"pet":{"type":"Dog","dog_name":"Lucky"}}')
p PersonMapper.from_json('{"id":1,"pet":{"type":"Cat","cat_name":"Fluffy"}}')
stex commented 11 months ago

I see, thank you very much for your detailed explanation! ❤️

I was hoping there was a way without having to manually define the association, but that's definitely a way.