Mapping hash maps to class instances - ruby-on-rails

Looking for gem or at least idea how to approach this problem, the ones I have are not exactly elegant :)
Idea is simple I would like to map hashes such as:
{ :name => 'foo',
:age => 15,
:job => {
:job_name => 'bar',
:position => 'something'
...
}
}
To objects of classes (with flat member structure) or Struct such as:
class Person {
#name
#age
#job_name
...
}
Thanks all.

Assuming that you can be certain sub-entry keys won't conflict with containing entry keys, here's some code that should work...
require 'ostruct'
def flatten_hash(hash)
hash = hash.dup
hash.entries.each do |k,v|
next unless v.is_a?(Hash)
v = flatten_hash(v)
hash.delete(k)
hash.merge! v
end
hash
end
def flat_struct_from_hash(hash)
hash = flatten_hash(hash)
OpenStruct.new(hash)
end

Solution that I used it solves problem with same key names but it does not give flat class structure. Somebody might find this handy just keep in mind that values with reserved names such as id, type need to be handled.
require 'ostruct'
def to_open_struct(element)
struct = OpenStruct.new
element.each do |k,v|
value = Hash === v ? to_open_struct(v) : v
eval("object.#{k}=value")
end
return struct
end

An alternate answer where you know the keys before hand
class Job
attr_accessor :job_name, :position
def initialize(params = {})
self.job_name = params.fetch(:job_name, nil)
self.position = params.fetch(:position, nil)
end
end
class Person
attr_accessor :name, :age, :job
def initialize(params = {})
self.name = params.fetch(:name, nil)
self.age = params.fetch(:age, nil)
self.job = Job.new(params.fetch(:job, {}))
end
end
hash = { :name => 'foo', :age => 1, :job => { :job_name => 'bar', :position => 'soetmhing' }}
p = Person.new(hash)
p.name
==> "foo"
p.job
==> #<Job:0x96cacd8 #job_name="bar", #position="soetmhing">
p.job.name
==> "bar"

Related

Cleanest way to create an object from JSON?

I have a class (not active record) and I would like to create objects from API data.
Since fields name/structure don't match, I don't think that it's possible to use params as we would use with forms.
That's why I'm mapping the attributes as follow:
job = Job.new()
job.id = attributes['id']
job.title = attributes['fields']['title']
job.body = attributes['fields']['body-html']
job.how_to_apply = attributes['fields']['how_to_apply-html'].presence
attributes['fields']['city'].each { |city| job.cities << city['name'] } if attributes['fields']['city']
attributes['fields']['country'].each { |country| job.countries << country['name'] }
job.start_date = Date.parse(attributes['fields']['date']['created'])
job.end_date = Date.parse(attributes['fields']['date']['closing'])
attributes['fields']['source'].each { |source| job.sources << source['name'] }
attributes['fields']['categories'].each { |category| job.categories << category['name'] }
job
attributes is the data part of a JSON response.
What do you guys think?
A more readable way is to have an initializer in Job and call it like this:
job = Job.new(
id: attributes['id'],
title: attributes['fields']['title'],
body: attributes['fields']['body-html'],
how_to_apply: attributes['fields']['how_to_apply-html'].presence,
cities: attributes['fields']['city']&.map { |city| city['name'] },
countries: attributes['fields']['country'].map { |country| country['name'] },
start_date: Date.parse(attributes['fields']['date']['created']),
end_date: Date.parse(attributes['fields']['date']['closing']),
sources: attributes['fields']['source'].map { |source| source['name'] },
categories: attributes['fields']['categories'].map { |category| category['name'] }
)
initializer can take named parameters or just a options hash (not recommended):
class Job < ...
def initializer(id:, title:, cities: nil, and_so_on__:)
self.id = id
# ...
end
end
You can use .tap method, its a little bit cleaner this way. Also some things can be moved to methods, for example:
fields = attributes['fields']
job = Job.new.tap do |j|
j.id = attributes['id']
j.title = fields['title']
j.body = fields['body-html']
j.how_to_apply = fields['how_to_apply-html'].presence
j.start_date = date_parser(fields['date']['created'])
j.end_date = date_parser(fields['date']['closing'])
j.countries = fields['country'].map { |country| country['name'] }
j.cities = fields['city']&.map { |city| city['name'] }
(...)
end
def date_parser(date)
Date.parse(date)
end
Since this question is tagged Rails you can use ActiveModel::Model and ActiveModel::Attributes to create a rich model with typecasting, validations etc.
Then just create a factory method to create model instances from raw JSON:
class Job
include ActiveModel::Model
include ActiveModel::Attributes
attribute :id, :integer
attribute :title, :string
attribute :body, :string
attribute :how_to_apply, :string
attribute :start_date, :date
attribute :end_date, :date
# Unfortunately ActiveModel::Attributes does not support array attributes
attr_accessor :city
attr_accessor :country
attr_accessor :source
attr_accessor :categories
def self.from_json(**attributes)
# use attributes.fetch('fields') instead if you
# want to raise and halt execution
fields = attributes['fields']
new(attributes.slice('id', 'title')) do |job|
job.assign_attributes(
body: fields['body-html'],
how_to_apply: fields['how_to_apply-html'],
city: fields['city']&.map {|c| c['name'] },
country: fields['country']&.map {|c| c['name'] },
start_date: fields.dig('date', 'created'),
end_date: fields.dig('date', 'closing'),
source: fields['source']&.map {|s| s['name'] },
categories: fields['categories']&.map {|c| c['name'] }
) if fields
end
end
end
If this method glows to an unruly size or if the complexity increases you can use the adapter pattern or a serializer.
Since fields name/structure don't match, I don't think that it's possible to use params as we would use with forms.
This is not quite true. ActionController::Parameters is really just a Hash like object and you can use .merge to manipulate it just like a hash:
params = ActionController::Parameters.new(json_hash)
.permit(:id, :title, fields: {})
params .slice(:id, :title).merge(
how_to_apply: params[:fields]['how_to_apply-html'],
# ...
)

Ruby, Map, Object attributes

I have an object that looks like the below:
class Report
attr_accessor :weekly_stats, :report_times
def initialize
#weekly_stats = Hash.new {|h, k| h[k]={}}
#report_times = Hash.new {|h, k| h[k]={}}
values = []
end
end
I want to loop through the weekly_stats and report_times and upcase each key and assign it its value.
Right now I have this:
report.weekly_stats.map do |attribute_name, value|
report.values <<
{
:name => attribute_name.upcase,
:content => value ||= "Not Currently Available"
}
end
report.report_times.map do |attribute_name, value|
report.values <<
{
:name => attribute_name.upcase,
:content => format_date(value)
}
end
report.values
Is there a way I could map both the weekly stats and report times in one loop?
Thanks
(#report_times.keys + #weekly_stats.keys).map do |attribute_name|
{
:name => attribute_name.upcase,
:content => #report_times[attribute_name] ? format_date(#report_times[attribute_name]) : #weekly_stats[attribute_name] || "Not Currently Available"
}
end
If you are guaranteed nil or empty string in weekly_stats, and a date object in report_times, then you could use this information to work through a merged hash:
merged = report.report_times.merge( report.weekly_stats )
report.values = merged.map do |attribute_name, value|
{
:name => attribute_name.upcase,
:content => value.is_a?(Date) ? format_date(value) : ( value || "Not Currently Available")
}
end

How to get attribue in the to_json attribute

I am using Rails 3 and here is my model
class LineItem < ActiveRecord::Base
attr_reader :price
belongs_to :product
def price
self.product.price * self.quantity
end
def as_json(options = {})
super(:include => [:product])
end
end
Above code works. However now I want my json to also have value for price in addition to the other values that I am getting.
How do I accomplish that?
You can use :methods:
def as_json(options = {})
super(options.merge(:include => [:product], :methods => [:price]))
end
You might want to pay proper attention to any incoming :include and :method settings in your options though. So you might want to use the block form of merge:
EXTRAS = { :include => [:product], :methods => [:price] }
def as_json(options = { })
super(options.merge(EXTRAS) { |k,ov,nv| ov.is_a?(Array) ? ov + nv : nv }
end

Finding relevant attributes in a model

I have a model with 30 attributes. but those attributes can be grouped in 2 groups.
For example I have:
string:title
string:text
...
and
string:title_old
string:text_old
...
I want to be able: When I check title attribute at the same time to check the title_old attribute. Can I perform that with a loop if I make an array of the 15 first strings or I should write hard coded if statements
Final goal:
[
{
:name => :title,
:y => 1 (constant),
:color=> red, (if title_old == "something" color = red else color = green)
},
{
:name=> :text,
:y => 1 (constant)
:color => red (if text_old == "something" color = red else color = green)
},
.... (all other 13 attributes)
]
your model:
class MyModel < AR::Base
def attributize
attrs = self.attributes.except(:created_at, :updated_at).reject{ |attr, val| attr =~ /.*_old/ && !val }
attrs.inject([]) do |arr, (attr, val)|
arr << { :name => attr, :y => 1, :color => (self.send("#{attr}_old") == "something" ? "red" : "green") }
end
end
end
usage:
my_object = MyModel.last
my_object.attributize
Very simple example:
class MyModel
def identify_color
if send("#{name}_old".to_sym) == "something"
'red'
else
'green'
end
end
end
MyModel.all.collect do |instance|
attrs = instance.attributes
attrs.merge!('color' => identify_color)
attrs
end
Add some rescue at will, but it can be done in different ways.
Try this:
[
:title,
..,
..
:description
].map do |attr|
{
:name => attr,
:y => 1 (constant),
:color=> (read_attribute("#{attr}_old") == "something") ? "red" : "green"
}
end
PS: Naming an attribute text is a bad idea.
use state_machine, that way your logic will be in one place with a clear dsl. https://github.com/pluginaweek/state_machine

How can I inizialize that ActiveRecord Tableless Model?

I am using Ruby on Rails 3 and I would like to inizialize an ActiveRecord Tableless Model.
In my model I have:
class Account < ActiveRecord::Base
# The following ActiveRecord Tableless Model statement is from http://codetunes.com/2008/07/20/tableless-models-in-rails/
def self.columns()
#columns ||= [];
end
def self.column(name, sql_type = nil, default = nil, null = true)
columns << ActiveRecord::ConnectionAdapters::Column.new(name.to_s, default, sql_type.to_s, null)
end
attr_reader :id,
:firstname,
:lastname,
def initialize(attributes = {})
#id = attributes[:id]
#firstname = attributes[:firstname]
#lastname = attributes[:lastname]
end
end
If in a controller, for example in the application_controller.rb file, I do:
#new_account = Account.new({:id => "1", :firstname => "Test name", :lastname => "Test lastname"})
a debug\inspect output of the #new_account variable is
"#<Account >"
Why? How I should inizialize properly that ActiveRecord Tableless Model and make it to work?
According to that blog post it would have to look like this:
class Account < ActiveRecord::Base
class_inheritable_accessor :columns
def self.columns()
#columns ||= [];
end
def self.column(name, sql_type = nil, default = nil, null = true)
columns << ActiveRecord::ConnectionAdapters::Column.new(name.to_s, default, sql_type.to_s, null)
end
column :id, :integer
column :firstname, :string
column :lastname, :string
end
And then:
#new_account = Account.new({:id => "1", :firstname => "Test name", :lastname => "Test lastname"})
Did you already try it like that?
I my view, you don't need to extend ActiveRecord::Base class.
You can write your own model class something like this
# models/letter.rb
class Letter
attr_reader :char
def self.all
('A'..'Z').map { |c| new(c) }
end
def self.find(param)
all.detect { |l| l.to_param == param } || raise(ActiveRecord::RecordNotFound)
end
def initialize(char)
#char = char
end
def to_param
#char.downcase
end
def products
Product.find(:all, :conditions => ["name LIKE ?", #char + '%'], :order => "name")
end
end
# letters_controller.rb
def index
#letters = Letter.all
end
def show
#letter = Letter.find(params[:id])
end
I hope it will help you.
Reference: http://railscasts.com/episodes/121-non-active-record-model

Resources