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

how to change output of a single attribute? #35

Closed glaucocustodio closed 2 months ago

glaucocustodio commented 2 months ago

Hey, thank you for the awesome work on Shale, it's really great.

Since discussions is not enabled on this repo, I am opening a issue to hear from you.

Problem

Imagine we have a very long mapper:

class User < Shale::Mapper
  attribute :first_name, Shale::Type::String
  attribute :last_name, Shale::Type::String
  attribute :gender, Shale::Type::String # gender will come as either 'f' or 'm'
  # many more other attributes...
end

And we would like to change the output of a single attribute (across all formats JSON, YAML, TOML, CSV and XML) so gender returns male instead of m and female instead of f

If I got it right, one would need to define map for all fields across all formats right? Eg:

class User < Shale::Mapper
  attribute :first_name, Shale::Type::String
  attribute :last_name, Shale::Type::String
  attribute :gender, Shale::Type::String # gender will come as either 'f' or 'm'
  # many more other attributes...

  json do
    map "first_name", to: :first_name
    map "last_name", to: :last_name
    # and so on...
    map "gender", using: {from: :gender_from_json, to: :gender_to_json}
  end

  def gender_from_json(model, value)
    model.gender = if value == 'f'
      'female'
    else
      'male'
    end
  end
end

we could also create a custom type, eg:

class CustomGender < Shale::Type::Value
  def self.cast(value)
    if value == "m"
      "male"
    else
      "female"
    end
  end
end

class User < Shale::Mapper
  attribute :first_name, Shale::Type::String
  attribute :last_name, Shale::Type::String
  attribute :gender, CustomGender
  # many more other attributes...
end

Using custom type seems less worse, but it still feels way too verbose..

Proposed solution #1

Would you be happy if we could override a single method instead of defining mappings for all attributes? Eg:

class User < Shale::Mapper
  attribute :first_name, Shale::Type::String
  attribute :last_name, Shale::Type::String
  attribute :gender, Shale::Type::String

  # change output of gender for all formats: json, xml etc
  def gender(model, value)
    model.gender = if value == 'f'
      'female'
    else
      'male'
    end
  end
end

Proposed solution #2

In case you are not happy with #1, what about defining a method to be called when reading a single attribute? Eg:

class User < Shale::Mapper
  attribute :first_name, Shale::Type::String
  attribute :last_name, Shale::Type::String
  attribute :gender, Shale::Type::String, read_with: :verbose_gender

  def verbose_gender(model, value)
    model.gender = if value == 'f'
      'female'
    else
      'male'
    end
  end
end

Please let me know if there is an easier way to accomplish the thing or if you would accept a PR implementing either of the proposed solutions.

kgiszczak commented 2 months ago

hey, you can achieve that using just plain Ruby, no need for special constructs:

class User < Shale::Mapper
  attribute :gender, Shale::Type::String

  def gender
    if super == 'f'
      'female'
    else
      'male'
    end
  end
end

although this behavior is confusing, the JSON doc you generate will be different then the one you're consuming (unless that's what you specifically need):

puts User.from_json('{"gender":"m"}').to_json
# => {"gender":"male"}

The proper way to solve this imo is to use custom type:

class Gender < Shale::Type::Value
  def self.of_json(value, **)
    value == 'm' ? 'male' : 'female'
  end

  def self.as_json(value, **)
    value == 'male' ? 'm' : 'f'
  end
end

class User < Shale::Mapper
  attribute :gender, Gender
end

This works as you would expect:

user = User.from_json('{"gender":"m"}')

puts user.gender
# => male

puts user.to_json
# => {"gender":"m"}
glaucocustodio commented 2 months ago

Since I'm only parsing a json and not to converting to it back, I am gonna override the method like you showed:

class User < Shale::Mapper
  attribute :gender, Shale::Type::String

  def gender
    if super == 'f'
      'female'
    else
      'male'
    end
  end
end

Would you accept a PR describing this behaviour with its caveat on README?

kgiszczak commented 2 months ago

having that in README would be a great addition, PR is welcome