JSON with full hierarchy in Rails - ruby-on-rails

I have an hierarchical structure in my app:
Environment has Containers
Container has Items
Item has expression
so my Model code looks like:
class Environment < ActiveRecord::Base
has_many :containers, :dependent => :destroy
def as_json(options = {})
super(options.merge(include: :containers))
end
end
class Container < ActiveRecord::Base
has_many :items, :dependent => :destroy
belongs_to :environment
def as_json(options = {})
super(options.merge(include: :items))
end
end
class Item < ActiveRecord::Base
has_many :expressions, :dependent => :destroy
belongs_to :container
def as_json(options = {})
super(options.merge(include: :expressions))
end
end
class Expression < ActiveRecord::Base
belongs_to :item
def as_json(options = {})
super()
end
end
In a regular get of a record I usually need only one hierarchy below the desired record, that's why in the as_json I merge only one hierarchy down (get Environment will return a collection of containers but those containers will not have Items)
My Question:
Now what I need is to add a method to the controller that allows full hierarchy response i.e. GET /environment/getFullHierarchy/3 will return: environment with id=3 with all its containers and for every container all it's Items & for every Item all it's expressions. without breaking the current as_json
I'm kinda new to Rails, wirking with Rails 4.2.6 & don't know where to start - can anyone help?

Sure it goes something like this hopefully you get the idea.
EnvironmentSerializer.new(environment) to get the hierarchy json.
lets say environments table has columns environment_attr1 , environment_attr2
class EnvironmentSerializer < ActiveModel::Serializer
attributes :environment_attr1, :environment_attr2 , :containers
# This method is called if you have defined a
# attribute above which is not a direct value like for
# a rectancle serializer will have attributes length and width
# but you can add a attribute area as a symbol and define a method
# area which returns object.length * object.width
def containers
ActiveModel::ArraySerializer.new(object.containers,
each_serializer: ContainerSerializer)
end
end
class ContainerSerializer < ActiveModel::Serializer
attributes :container_attr1, :container_attr2 , :items
def items
ActiveModel::ArraySerializer.new(object.items,
each_serializer: ItemSerializer)
end
end
class ItemSerializer < ActiveModel::Serializer
...
end
class ExpressionSerializer < ActiveModel::Serializer
...
end

Related

How to format Json to not nest associations with ActiveModel::Serializer

I'm using ActiveModel::Serializer in a rails application to format my model data as a json response, but I would like to change the formatting so that the associations of my main model are not nested. I tried setting root: false and that doesn't work
Expected behavior vs actual behavior
I have a model Account with an association belongs_to :account_status
and I was able to add this association in the AccountSerializer to get that associated data just fine. But do to my api contract requirements, I need the json to be formatted without the association nesting.
So I'm getting this:
{
"account_id": 1
<other account info>
...
"account_status": {
"status_code": 1
"desc": "status description"
....
}
}
But I want this:
{
"account_id": 1
<other account info>
...
"account_status_status_code": 1
"account_status_desc": "status description"
....
}
Model + Serializer code
How can I achieve the expected behavior without writing each account_status field as an individual attribute in the AccountSerializer ??
Controller
class AccountsController < ActionController::API
def show
account = Account.find(params[:account_id])
render json: account
end
end
Model
class Account < ActiveRecord::Base
self.primary_key = :account_id
belongs_to :account_status, foreign_key: :account_status_code, inverse_of: :accounts
validates :account_status_code, presence: true
end
Serializer
class AccountSerializer < ActiveModel::Serializer
attributes(*Account.attribute_names.map(&:to_sym))
belongs_to :account_status,
foreign_key: :account_status_code,
inverse_of: :accounts
end
Environment
OS Type & Version: macOS Catalina v 10.15.7
Rails 6.1.4:
ActiveModelSerializers Version 0.10.0:
Output of ruby -e "puts RUBY_DESCRIPTION":
ruby 3.0.2p107 (2021-07-07 revision 0db68f0233) [x86_64-darwin19]
you could replace belongs_to :account_status,... by below code
class AccountSerializer < ActiveModel::Serializer
attributes(*Account.attribute_names.map(&:to_sym))
AccountStatus.attribute_names.each do |attr_name|
key = "account_status_#{attr_name}"
define_method(key) do
# i checked and see that `object.account_status` just call one time
object.account_status.send(attr_name)
end
attribute key.to_sym
end
end
Instead of using an assocation in your serializer you can setup delegation in your model:
class Account < ApplicationRecord
delegate :desc, ...,
to: :account_status,
prefix: true
end
This will create a account_status_desc method which you can simply call from your serializer:
class AccountSerializer < ActiveModel::Serializer
attributes(
:foo,
:bar,
:baz,
:account_status_code, # this is already a attribute of Account
:account_status_desc
# ...
)
end
Another way of doing this is by simply adding methods to your serializer:
class AccountSerializer < ActiveModel::Serializer
attributes :foo
def foo
object.bar.do_something
end
end
This is a good alternative when the result of serialization does not align with the internal representation in the model.

Rails has_many STI with sub STI

I think it is more of a "Model Design" issue than a rails issue.
For clarity sake here is the business logic: I've Venues and I want to implement multiple APIs to get data about those venues. All this APIs have a lot in common, therefore I used STI.
# /app/models/venue.rb
class Venue < ApplicationRecord
has_one :google_api
has_one :other_api
has_many :apis
end
# /app/models/api.rb
class Api < ApplicationRecord
belongs_to :venue
end
# /app/models/google_api.rb
class GoogleApi < Api
def find_venue_reference
# ...
end
def synch_data
# ...
end
end
# /app/models/other_api.rb
class OtherApi < Api
def find_venue_reference
# ...
end
def synch_data
# ...
end
end
That part works, now what I'm trying to add is Photos to the venue. I will be fetching those photos from the API and I realise that every API might be different. I thought about using STI for that as well and I will end up with something like that
# /app/models/api_photo.rb
class ApiPhoto < ApplicationRecord
belongs_to :api
end
# /app/models/google_api_photo.rb
class GoogleApiPhoto < ApiPhoto
def url
"www.google.com/#{reference}"
end
end
# /app/models/other_api_photo.rb
class OtherApiPhoto < ApiPhoto
def url
self[url] || nil
end
end
My goal being to have this at the end
# /app/models/venue.rb
class Venue < ApplicationRecord
has_one :google_api
has_one :other_api
has_many :apis
has_many :photos :through => :apis
end
# /app/views/venues/show.html.erb
<%# ... %>
#venue.photos.each do |photo|
photo.url
end
<%# ... %>
And photo.url will give me the right formatting that is dependent of the api it is.
As I'm going deeper in the integration, something seems not right. If I had to Api the has_many :google_api_photo then every Api will have GoogleApiPhoto. What does not make sense to me.
Any idea how I should proceed from here?
I think I solved it.
By adding this to venue.rb
has_many :apis, :dependent => :destroy
has_many :photos, :through => :apis, :source => :api_photos
By calling venue.photos[0].url call the right Class based on the type field of the ApiPhoto

Rails: includes with polymorphic association

I read this interesting article about Using Polymorphism to Make a Better Activity Feed in Rails.
We end up with something like
class Activity < ActiveRecord::Base
belongs_to :subject, polymorphic: true
end
Now, if two of those subjects are for example:
class Event < ActiveRecord::Base
has_many :guests
after_create :create_activities
has_one :activity, as: :subject, dependent: :destroy
end
class Image < ActiveRecord::Base
has_many :tags
after_create :create_activities
has_one :activity, as: :subject, dependent: :destroy
end
With create_activities defined as
def create_activities
Activity.create(subject: self)
end
And with guests and tags defined as:
class Guest < ActiveRecord::Base
belongs_to :event
end
class Tag < ActiveRecord::Base
belongs_to :image
end
If we query the last 20 activities logged, we can do:
Activity.order(created_at: :desc).limit(20)
We have a first N+1 query issue that we can solve with:
Activity.includes(:subject).order(created_at: :desc).limit(20)
But then, when we call guests or tags, we have another N+1 query problem.
What's the proper way to solve that in order to be able to use pagination ?
Edit 2: I'm now using rails 4.2 and eager loading polymorphism is now a feature :)
Edit: This seemed to work in the console, but for some reason, my suggestion of use with the partials below still generates N+1 Query Stack warnings with the bullet gem. I need to investigate...
Ok, I found the solution ([edit] or did I ?), but it assumes that you know all subjects types.
class Activity < ActiveRecord::Base
belongs_to :subject, polymorphic: true
belongs_to :event, -> { includes(:activities).where(activities: { subject_type: 'Event' }) }, foreign_key: :subject_id
belongs_to :image, -> { includes(:activities).where(activities: { subject_type: 'Image' }) }, foreign_key: :subject_id
end
And now you can do
Activity.includes(:part, event: :guests, image: :tags).order(created_at: :desc).limit(10)
But for eager loading to work, you must use for example
activity.event.guests.first
and not
activity.part.guests.first
So you can probably define a method to use instead of subject
def eager_loaded_subject
public_send(subject.class.to_s.underscore)
end
So now you can have a view with
render partial: :subject, collection: activity
A partial with
# _activity.html.erb
render :partial => 'activities/' + activity.subject_type.underscore, object: activity.eager_loaded_subject
And two (dummy) partials
# _event.html.erb
<p><%= event.guests.map(&:name).join(', ') %></p>
# _image.html.erb
<p><%= image.tags.first.map(&:name).join(', ') %></p>
This will hopefully be fixed in rails 5.0. There is already an issue and a pull request for it.
https://github.com/rails/rails/pull/17479
https://github.com/rails/rails/issues/8005
I have forked rails and applied the patch to 4.2-stable and it works for me. Feel free to use my fork, even though I cannot guarantee to sync with upstream on a regular basis.
https://github.com/ttosch/rails/tree/4-2-stable
You can use ActiveRecord::Associations::Preloader to preload guests and tags linked, respectively, to each of the event and image objects that are associated as a subject with the collection of activities.
class ActivitiesController < ApplicationController
def index
activities = current_user.activities.page(:page)
#activities = Activities::PreloadForIndex.new(activities).run
end
end
class Activities::PreloadForIndex
def initialize(activities)
#activities = activities
end
def run
preload_for event(activities), subject: :guests
preload_for image(activities), subject: :tags
activities
end
private
def preload_for(activities, associations)
ActiveRecord::Associations::Preloader.new.preload(activities, associations)
end
def event(activities)
activities.select &:event?
end
def image(activities)
activities.select &:image?
end
end
image_activities = Activity.where(:subject_type => 'Image').includes(:subject => :tags).order(created_at: :desc).limit(20)
event_activities = Activity.where(:subject_type => 'Event').includes(:subject => :guests).order(created_at: :desc).limit(20)
activities = (image_activities + event_activities).sort_by(&:created_at).reverse.first(20)
I would suggest adding the polymorphic association to your Event and Guest models.
polymorphic doc
class Event < ActiveRecord::Base
has_many :guests
has_many :subjects
after_create :create_activities
end
class Image < ActiveRecord::Base
has_many :tags
has_many :subjects
after_create :create_activities
end
and then try doing
Activity.includes(:subject => [:event, :guest]).order(created_at: :desc).limit(20)
Does this generate a valid SQL query or does it fail because events can't be JOINed with tags and images can't be JOINed with guests?
class Activity < ActiveRecord::Base
self.per_page = 10
def self.feed
includes(subject: [:guests, :tags]).order(created_at: :desc)
end
end
# in the controller
Activity.feed.paginate(page: params[:page])
This would use will_paginate.

In Rails, is attr_accessible necessary for foreign key in model?

Using Rails 3.2, I have the following:
# shop.rb
class Shop < ActiveRecord::Base
has_many :nearby_shops
after_update :find_nearby_shops
def find_nearby_shops
NearbyShop.create(
:shop_id => self.id,
:shop_type => "test",
)
end
end
# nearby_shop.rb
class NearbyShop < ActiveRecord::Base
attr_accessible :shop_id, :shop_type
belongs_to :shop
end
I find it was necessary to declare shop_id in attr_accessible to get the shop_id saved, else it would be blank. Is this behavior correct?
Yes, it is. Otherwise you could use
def find_nearby_shops
self.nearby_shops.create(
shop_type: "test"
)
end
self.nearby_shops will scope to the nearby_shops with current shop it and bypasses the protected attributes because you are not using a mass assignment method (such as create) to assign the shop_id.

Model Relationship Problem

I am trying to calculate the average (mean) rating for all entries within a category based on the following model associations ...
class Entry < ActiveRecord::Base
acts_as_rateable
belongs_to :category
...
end
class Category < ActiveRecord::Base
has_many :entry
...
end
class Rating < ActiveRecord::Base
belongs_to :rateable, :polymorphic => true
...
end
The rating model is handled by the acts as rateable plugin, so the rateable model looks like this ...
module Rateable #:nodoc:
...
module ClassMethods
def acts_as_rateable
has_many :ratings, :as => :rateable, :dependent => :destroy
...
end
end
...
end
How can I perform the average calculation? Can this be accomplished through the rails model associations or do I have to resort to a SQL query?
The average method is probably what you're looking for. Here's how to use it in your situation:
#category.entries.average('ratings.rating', :joins => :ratings)
Could you use a named_scope or custom method on the model. Either way it would still require some SQL since, if I understand the question, your are calculating a value.
In a traditional database application this would be a view on the data tables.
So in this context you might do something like... (note not tested or sure it is 100% complete)
class Category
has_many :entry do
def avg_rating()
#entries = find :all
#entres.each do |en|
#value += en.rating
end
return #value / entries.count
end
end
Edit - Check out EmFi's revised answer.
I make no promises but try this
class Category
def average_rating
Rating.average :rating,
:conditions => [ "type = ? AND entries.category_id = ?", "Entry", id ],
:join => "JOIN entries ON rateable_id = entries.id"
end
end

Resources