Cleanest way to create an object from JSON? - ruby-on-rails

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'],
# ...
)

Related

Rails 5 and ActiveRecord: reusable filters for listing resources

In my application I have a number of pages where I need to display a list of people and allow the user to filter them with a form. And these pages are often similar looking. The filters share parts but still not the same.
I'm wondering how can I avoid repeating almost the same code for different controllers? I tried scopes but I still need to parse parameters and populate form in a view anyway.
Thanks!
Disclaimer: author of https://github.com/dubadub/filtered is here.
ActiveRecord offers a merge method for relations. It intersects two query parts which allows breaking query logic into parts.
Based on that idea I created a gem https://github.com/dubadub/filtered.
In your case it could be something like:
# app/controllers/people_controller.rb
class PeopleController < ApplicationController
before_action :set_filter
def index
#people = People.all.merge(#filter)
end
private
def set_filter
#filter = PersonFilter.new(filter_params)
end
def filter_params
params.fetch(:filter, {}).permit(:age, :active, :sorting)
end
end
# app/filters/person_filter.rb
class PersonFilter < ApplicationFilter
field :age
field :active do |active|
-> { joins(:memberships).merge(Membership.where(active: active)) }
end
field :sorting do |value|
order_by, direction = value.values_at("order", "direction")
case order_by
when "name"
-> { order(name: direction) }
when "age"
-> { order(age: direction) }
else
raise "Incorrect Filter Value"
end
end
end
# app/views/people/index.slim
= form_for(#filter, url: search_path, method: "GET", as: :filter) do |f|
.fields
span Age
= f.select :age, (18..90).map { |a| [ a, a ] }
.fields
span Active
= f.check_box :active
.fields
span Sorting
span Name
= f.radio_button :sorting, "name asc"
= f.radio_button :sorting, "name desc"
span Age
= f.radio_button :sorting, "age asc"
= f.radio_button :sorting, "age desc"
.actions
= f.submit "Filter"
Hope it helps!
Have you had a look at query objects?
https://mkdev.me/en/posts/how-to-use-query-objects-to-refactor-rails-sql-queries
They allow you to reuse the code in many places, you'd be able to simply pass the params.permit(...) and get get AR output.
# app/queries/user_query.rb
class UserQuery
attr_accessor :initial_scope
def initialize(scoped = User.all)
#initial_scope = initial_scope
end
def call(params) # is what you pass from your controller
scoped = by_email(#initial_scope, params[:email]
scoped = by_phone(scoped, params[:phone]
# ...
scoped
end
def by_email(scoped, email = nil)
email ? where(email: email) : scoped
end
def by_phone(scoped, phone = nil)
phone ? where(phone: phone) : scoped
end
end
# users_controller.rb
class UsersController < ApplicationController
def index
#users = UserQuery.new(User.all)
.call(params.permit(:email, :phone))
.order(id: :desc)
.limit(100)
end
end
# some other controller
class RandomController < ApplicationController
def index
#users = UserQuery.new(User.where(status: 1))
.call(params.permit(:email))
.limit(1)
end
end
You can probably refactor this example to reduce the upfront investment into writing these queries for richer objects, do post here if you come up with alternatives for so that others can learn how to use query objects.

Return custom columns in Rails ActiveRecord Query

I am querying my ActiveRecords in rails with the following:
result = MyObj.where({customer: current_id}).as_json()
There are two columns returned:
result = [{id:1, name: "david", last_name: "Smith:"}]
I would like create a third column (which will not be saved to the DB) like so:
result = [{id:1, name: "David", last_name: "Smith:", full_name:"David Smith"}]
Is this possible within the WHERE query?
Add a full_name method to your MyObj model, then pass methods: :full_name to the as_json method:
class MyObj
def full_name
"{name} #{last_name}"
end
end
result = MyObj.where({customer: current_id}).as_json(methods: :full_name)
From the documentation for as_json:
To include the result of some method calls on the model use :methods:
user.as_json(methods: :permalink)
# => { "id" => 1, "name" => "Konata Izumi", "age" => 16,
# "created_at" => "2006/08/01", "awesome" => true,
# "permalink" => "1-konata-izumi" }
Or alternately, you could override as_json on the model to include full_name by default:
class MyObj
def full_name
"{name} #{last_name}"
end
def as_json(options={})
super({methods: :full_name}.merge options)
end
end
Sure. Override the method in your model...
class MyObj < ActiveRecord::Base
def full_name
"#{name} #{last_name}"
end
def as_json options={}
{
id: id,
name: name,
last_name: last_name,
full_name: full_name
}
end
end
Quick and dirty just manipulate the results you get back
result = MyObj.where({customer: current_id})
result.map{|customer| "full_name: #{customer.first_name + customer.last_name}" }
But be careful of nil values.

Rails/mongoid: Advanced querying of arrays

Im stuck with an advanced query in rails. I need a solution that works in mongoid and if possible also active record (probably not possible). I've put together a simplified example below.
Consider the following model:
class Announcement
include Mongoid::Document
field :title, type: String
field :user_group, type: Array
field :year, type: Array
field :tags, type: Array
has_and_belongs_to_many :subjects
before_save :generate_tags
private
def generate_tags
tags = []
if self.subjects.present?
self.subjects.each { |x| tags << x.name.downcase.gsub(" ", "_") }
end
if self.year.present?
self.year.each { |x| tags << "year_" + x.to_s }
end
self.tags = tags
end
end
Given the tags array of document 1:
["hsc_mathematics", "hsc_chemistry", "year_9"]
And document 2:
["hsc_mathematics", "hsc_chemistry"]
And document 3:
["hsc_mathematics", "hsc_chemistry", "year_9", "year_10"]
And document 4:
["year_9", "year_10"]
Now consider the following model:
class Student < User
include Mongoid::Document
field :year, type: Integer
has_many :subjects
def announcements
tags = []
if self.subjects.present?
self.subjects.each { |x| subjects << x.name.downcase.gsub(" ", "_") }
end
tags << "year_" + self.year.to_s
Announcement.where("user_group" => { "$in" => ["Student", "all_groups"]}).any_of({"tags" => { "$in" => tags }}, {tags: []})
end
end
For the purpose of our example our student has the following tags:
[ "hsc_mathematics", "hsc_physics", "year_10" ]
My query is incorrect as I want to return documents 2, 3 and 4 but not document 1.
I need the query to adhere to the following when returning announcements:
i. If the announcement has subject tags match on any subject
ii. If the announcement has year tags match on any year
iii. If announcement has year and subject tags match on any year and any subject
How would I go about writing this?
EDIT
Im happy to split year out of my tags but im still stuck
Announcement.where("user_group" => { "$in" => ["Student", "all_groups"]}).any_of({"tags" => { "$in" => ["hsc_mathematics", "hsc_physics"] }}, {tags: []}).any_of({"year_at_school" => { "$in" => 10 }}, {year_at_school: []})
So the solution was to adjust my models and use a more organised query rather then an entire tag bank.
Announcement model:
class Announcement
include Mongoid::Document
field :title, type: String
field :user_group, type: Array, default: [""]
field :year, type: Array, default: [""]
field :tags, type: Array, default: [""]
has_and_belongs_to_many :subjects
before_save :generate_tags
private
def generate_tags
tags = []
if self.subjects.present?
self.subjects.each { |x| tags << x.name.downcase.gsub(" ", "_") }
end
self.tags = tags
end
end
User model:
class Student < User
include Mongoid::Document
field :year, type: Integer
has_many :subjects
def announcements
year = "year_" + self.year.to_s
tags = [""]
if self.subjects.present?
self.subjects.each { |x| tags << x.name.downcase.gsub(" ", "_") }
end
Announcement.where("user_group" => { "$in" => ["Student", ""] }).and("year" => { "$in" => [year, ""]}).any_in(tags: tags).all.entries
end
end
EDIT: Heres a neater version of the query as suggested
This example also has an expiry field which assumes nil = never expires
Announcement.where(:user_group.in => ["Student", ""], :year.in => [year, ""], :tags.in => tags).any_of({:expires_at.gte => Time.zone.now}, {:expires_at => nil}).all.entries

How can I lessen the verbosity of my populate method?

I wrote a form object to populate an Order, Billing, and Shipping Address objects. The populate method looks pretty verbose. Since the form fields don't correspond to Address attributes directly, I'm forced to manually assign them. For example:
shipping_address.name = params[:shipping_name]
billing_address.name = params[:billing_name]
Here's the object. Note that I snipped most address fields and validations, and some other code, for brevity. But this should give you an idea. Take note of the populate method:
class OrderForm
attr_accessor :params
delegate :email, :bill_to_shipping_address, to: :order
delegate :name, :street, to: :shipping_address, prefix: :shipping
delegate :name, :street, to: :billing_address, prefix: :billing
validates :shipping_name, presence: true
validates :billing_name, presence: true, unless: -> { bill_to_shipping_address }
def initialize(item, params = nil, customer = nil)
#item, #params, #customer = item, params, customer
end
def submit
populate
# snip
end
def order
#order ||= #item.build_order do |order|
order.customer = #customer if #customer
end
end
def shipping_address
#shipping_address ||= order.build_shipping_address
end
def billing_address
#billing_address ||= order.build_billing_address
end
def populate
order.email = params[:email]
shipping_address.name = params[:shipping_name]
shipping_address.street = params[:shipping_street]
# Repeat for city, state, post, code, etc...
if order.bill_to_shipping_address?
billing_address.name = params[:shipping_name]
billing_address.street = params[:shipping_street]
# Repeat for city, state, post, code, etc...
else
billing_address.name = params[:billing_name]
billing_address.street = params[:billing_street]
# Repeat for city, state, post, code, etc...
end
end
end
Here's the controller code:
def new
#order_form = OrderForm.new(#item)
end
def create
#order_form = OrderForm.new(#item, params[:order], current_user)
if #order_form.submit
# handle payment
else
render 'new'
end
end
Noe I am not interested in accepts_nested_attributes_for, which presents several problems, hence why I wrote the form object.
def populate
order.email = params[:email]
shipping_params = %i[shipping_name shipping_street]
billing_params = order.bill_to_shipping_address? ?
shipping_params : %i[billing_name billing_street]
[[shipping_address, shipping_params], [billing_address, billing_params]]
.each{|a, p|
a.name, a.street = params.at(*p)
}
end
How about
class Order < ActiveRecord::Base
has_one :shipping_address, class_name: 'Address'
has_one :billing_address, class_name: 'Address'
accepts_nested_attributes_for :shipping_address, :billing_address
before_save :clone_shipping_address_into_billing_address, if: [check if billing address is blank]
Then when you set up the form, you can have fields_for the two Address objects, and side step the populate method entirely.
A possible fix would be to use a variable for retrieving those matching params, like so:
def populate
order.email = params[:email]
shipping_address.name = params[:shipping_name]
shipping_address.street = params[:shipping_street]
# etc...
#set a default state
shipping_or_billing = "shipping_"
#or use a ternary here...
shipping_or_billing = "billing_" if order.bill_to_shipping_address?
billing_address.name = params["shipping_or_billing" + "name"]
billing_address.street = params["shipping_or_billing" + "street"]
...
end
Your address classes should probably have a method that would set the values for all the address properties from a hash that it would receive as an argument.
That way your populate method would only check for order.bill_to_shipping_address? and them pass the correct dictionary to the method I'm suggesting.
That method on the other hand, would just assign the values from the hash to the correct properties, without the need for a conditional check.

Mapping hash maps to class instances

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"

Resources