ActiveModel::Serializer: Recommended way to use with join table (has_many through) - ruby-on-rails

I'm facing a case when a need to display information contained in my join table. For example:
# == Schema Information
#
# Table name: quality_inspections
#
# id, content
#
# =============================================================================
class QualityInspection
has_many :inspection_inspectors
has_many :inspector, through: :inspection_inspectors
end
# == Schema Information
#
# Table name: inspection_inspectors
#
# quality_inspection_id, inspector_id, inspection_date
#
# =============================================================================
class InspectionInspector
belongs_to :quality_inspection
belongs_to :user, foreign_key: :inspector_id
end
Then, I'd like to have the following json:
{
"quality_inspectors": [{
"id": 1,
"content": "foo",
"inspectors": [{
"id": 1, // which is the user's id
"first_name": "Bar",
"last_name": "FooFoo",
"inspection_date": "random date"
}]
}]
}
For now, I'm doing the following in my serializer:
module Api::V1::QualityInspections
class InspectorSerializer < ActiveModel::Serializer
type :inspector
attributes :id, :first_name, :last_name, :inspection_date
def id
inspector.try(:public_send, __callee__)
end
def first_name
inspector.try(:public_send, __callee__)
end
def last_name
inspector.try(:public_send, __callee__)
end
private
def inspector
#inspector ||= object.inspector
end
end
end
Do you have any better solution ? Or maybe I'm not using the right methods on my Serializer ?
Anyway, I'm really stuck when it came to display information on a join table. Oddly, I'd the same issue when using cerebris/jsonapi-resources.
EDIT: Linked issue on GH: https://github.com/rails-api/active_model_serializers/issues/1704

I don't think you need to put additional methods such as id, first_name, last_name. Instead, use association within your serializer to get the appropriate JSON data as mentioned afore.

Related

activeadmin and dynamic store accessors fails on new resource

I want to generate forms for a resource that has a postgres jsonb column :data, and I want the schema for these forms to be stored in a table in the database. After a lot of research I am 90% there but my method fails in ActiveAdmin forms upon create (not update). Can anyone explain this?
Sorry for the long code snippets. This is a fairly elaborate setup but I think it would be of some interest since if this works one could build arbitrary new schemas dynamically without hard-coding.
I am following along this previous discussion with Rails 6 and ActiveAdmin 2.6.1 and ruby 2.6.5.
I want to store Json Schemas in a table SampleActionSchema that belong_to SampleAction (using the json-schema gem for validation)
class SampleActionSchema < ApplicationRecord
validates :category, uniqueness: { case_sensitive: false }, allow_nil: false, allow_blank: true
validate :schema_is_json_schema
private
def schema_is_json_schema
metaschema = JSON::Validator.validator_for_name("draft4").metaschema
unless JSON::Validator.validate(metaschema, schema)
errors.add :schema, 'not a compliant json schema'
end
end
end
class SampleAction < ActiveRecord::Base
belongs_to :sample
validate :is_sample_action
validates :name, uniqueness: { case_sensitive: false }
after_initialize :add_field_accessors
before_create :add_field_accessors
before_update :add_field_accessors
def add_store_accessor field_name
singleton_class.class_eval {store_accessor :data, field_name.to_sym}
end
def add_field_accessors
num_fields = schema_properties.try(:keys).try(:count) || 0
schema_properties.keys.each {|field_name| add_store_accessor field_name} if num_fields > 0
end
def schema_properties
schema_arr=SampleActionSchema.where(category: category)
if schema_arr.size>0
sc=schema_arr[0]
if !sc.schema.empty?
props=sc.schema["properties"]
else
props=[]
end
else
[]
end
end
private
def is_sample_action
sa=SampleActionSchema.where(category: category)
errors.add :category, 'not a known sample action' unless (sa.size>0)
errors.add :base, 'incorrect json format' unless (sa.size>0) && JSON::Validator.validate(sa[0].schema, data)
end
end
This all works correctly; For example, for a simple schema called category: "cleave", where :data looks like data: {quality: "good"}, I can create a resource as follows in the rails console:
sa=SampleAction.new(sample_id: 6, name: "test0", data: {}, category: "cleave" )
=> #<SampleAction id: nil, name: "test0", category: "cleave", data: {}, created_at: nil, updated_at: nil, sample_id: 6>
sa.quality = "good" => true
sa.save => true
To make this system work in AA forms, I call the normal path (new or edit)_admix_sample_action_form with params: {category: "cleave"} and then I generate permit_params dynamically:
ActiveAdmin.register SampleAction, namespace: :admix do
permit_params do
prms=[:name, :category, :data, :sample_id, :created_at, :updated_at]
#the first case is creating a new record (gets parameter from admix/sample_actions/new?category="xxx"
#the second case is updating an existing record
#falls back to blank (no extra parameters)
categ = #_params[:category] || (#_params[:sample_action][:category] if #_params[:sample_action]) || nil
cat=SampleActionSchema.where(category: categ)
if cat.size>0 && !cat[0].schema.empty?
cat[0].schema["properties"].each do |key, value|
prms+=[key.to_sym]
end
end
prms
end
form do |f|
f.semantic_errors
new=f.object.new_record?
cat=params[:category] || f.object.category
f.object.category=cat if cat && new
f.object.add_field_accessors if new
sas=SampleActionSchema.where(category: cat)
is_schema=(sas.size>0) && !sas[0].schema.empty?
if session[:active_sample]
f.object.sample_id=session[:active_sample]
end
f.inputs "Sample Action" do
f.input :sample_id
f.input :name
f.input :category
if !is_schema
f.input :data, as: :jsonb
else
f.object.schema_properties.each do |key, value|
f.input key.to_sym, as: :string
end
end
end
f.actions
end
Everything works fine if I am editing an existing resource (as created in the console above). The form is displayed and all the dynamic fields are updated upon submit. But when creating a new resource where e.g. :data is of the form data: {quality: "good"} I get
ActiveModel::UnknownAttributeError in Admix::SampleActionsController#create
unknown attribute 'quality' for SampleAction.
I have tried to both add_accessors in the form and to override the new command to add the accessors after initialize (these should not be needed because the ActiveRecord callback appears to do the job at the right time).
def new
build_resource
resource.add_field_accessors
new!
end
Somehow when the resource is created in the AA controller, it seems impossible to get the accessors stored even though it works fine in the console. Does anyone have a strategy to initialize the resource correctly?
SOLUTION:
I traced what AA was doing to figure out the minimum number of commands needed. It was necessary to add code to build_new_resource to ensure that any new resource AA built had the correct :category field, and once doing so, make the call to dynamically add the store_accessor keys to the newly built instance.
Now users can create their own original schemas and records that use them, without any further programming! I hope others find this useful, I certainly will.
There are a couple ugly solutions here, one is that adding the parameters to the active admin new route call is not expected by AA, but it still works. I guess this parameter could be passed in some other way, but quick and dirty does the job. The other is that I had to have the form generate a session variable to store what kind of schema was used, in order for the post-form-submission build to know, since pressing the "Create Move" button clears the params from the url.
The operations are as follows: for a model called Move with field :data that should be dynamically serialized into fields according to the json schema tables, both
admin/moves/new?category="cleave" and admin/moves/#/edit find the "cleave" schema from the schema table, and correctly create and populate a form with the serialized parameters. And, direct writes to the db
m=Move.new(category: "cleave") ==> true
m.update(name: "t2", quality: "fine") ==> true
work as expected. The schema table is defined as:
require "json-schema"
class SampleActionSchema < ApplicationRecord
validates :category, uniqueness: { case_sensitive: false }, allow_nil: false, allow_blank: true
validate :schema_is_json_schema
def self.schema_keys(categ)
sas=SampleActionSchema.find_by(category: categ)
schema_keys= sas.nil? ? [] : sas[:schema]["properties"].keys.map{|k| k.to_sym}
end
private
def schema_is_json_schema
metaschema = JSON::Validator.validator_for_name("draft4").metaschema
unless JSON::Validator.validate(metaschema, schema)
errors.add :schema, 'not a compliant json schema'
end
end
end
The Move table that employs this schema is:
class Move < ApplicationRecord
after_initialize :add_field_accessors
def add_field_accessors
if category!=""
keys=SampleActionSchema.schema_keys(category)
keys.each {|k| singleton_class.class_eval{store_accessor :data, k}}
end
end
end
Finally, the working controller:
ActiveAdmin.register Move do
permit_params do
#choice 1 is for new records, choice 2 is for editing existing
categ = #_params[:category] || (#_params[:move][:category] if #_params[:move]) || ""
keys=SampleActionSchema.schema_keys(categ)
prms = [:name, :data] + keys
end
form do |f|
new=f.object.new_record?
f.object.category=params[:category] if new
if new
session[:current_category]=params[:category]
f.object.add_field_accessors
else
session[:current_category] = ""
end
keys=SampleActionSchema.schema_keys(f.object.category)
f.inputs do
f.input :name
f.input :category
keys.each {|k| f.input k}
end
f.actions
end
controller do
def build_new_resource
r=super
r.assign_attributes(category: session[:current_category])
r.add_field_accessors
r
end
end
end

Include IDs of nested associations in ActiveModel serializers

In some cases nested associations are embedded in the JSON, in others they are not. So far so good, that behaves the way I want. But I want that in the cases where they aren't embedded the IDs of the nested associations are still emitted.
E.g.:
class FooSerializer < ActiveModel::Serializer
attributes :id, :x, :y
belongs_to :bar
end
class BarSerializer < ActiveModel::Serializer
attributes :id, :z
end
When I serialize a Foo object without include: [:bar] I want the result to look like:
{
"id": 123
"x": 1,
"y": 2,
"bar": 456
}
And if bar would be a polymorphic association I'd like something like that:
{
"id": 123
"x": 1,
"y": 2,
"bar": {"id": 456, "schema": "Bar"}
}
Actually I would like the IDs to be strings ("id": "123") because they should be black boxes for the API consumer and definitely not use JavaScript's Number type (which is double precision floating point!).
How do I do that? I didn't find any information about that.
Define attribute id this way in FooSerializer to get it as a string:
attribute :id do
object.to_s
end
"When I serialize a Foo object without include: [:bar] I want the result to look like:"
attribute :bar do
object.bar.id.to_s
end
"And if bar would be a polymorphic association I'd like something like that:"
attribute :bar do
{id: object.barable_id.to_s, schema: object.barable_type}
end
NOTE: I haven't tested this.
I found a way to do that by using a BaseSerializer like this:
class BaseSerializer < ActiveModel::Serializer
attributes :id, :type
def id
if object.id.nil?
nil
else
object.id.to_s
end
end
def type
# I also want all the emitted objects to include a "type" field.
object.class.name
end
def self.belongs_to(key, *args, &block)
attribute key do
assoc = object.class.reflect_on_association(key)
foreign_id = object.send(assoc.foreign_key)
if foreign_id.nil?
nil
elsif assoc.polymorphic?
{
type: object.send(assoc.foreign_type),
id: foreign_id.to_s
}
else
foreign_id.to_s
end
end
super
end
end

How can I render an index of objects to JSON in Rails, when I need to join 2 tables?

The title is likely bad at explaining what I need.
Here's what that need is...
I have two models. Walkthru and WalkthruComplete.
Walkthru has columns (:id, :name, :reward)
WalkthruComplete has columns (:id, :user_id, :walkthru_id)
If a user completes a walkthru/guide in my application, a new row will be written to WalkthruComplete, saying for instance that user_id 17 completed walkthru_id 2. They may be rewarded some points for this.
Now, I need to fetch all the available walkthrus. This is easy. However, I want to include more than Walkthru.(:id, :name, :reward). I also would like to include a column I make up on the fly called 'completed'. In my Walkthru#index, I will check to see if the particular user requesting the Walkthru#index has any WalkthruComplete rows. If they do, 'completed' would be set to YES for those particular walkthrus. If they don't, 'completed' would be set to NO.
The reason the user_id is not set to Walkthru is because some walkthrus are allows for any users, including non-signed in users. Others are only for signed in users, and reward points to those signed in users.
In the end I want something like...
{
"walkthrus": [
{
"id": 1,
"name": "Intro",
"reward": 0,
"completed": 0
},
{
"id": 2,
"name": "Widgets",
"reward": 10,
"completed": 1
},
{
"id": 3,
"name": "Gadgets",
"reward": 10,
"completed": 0
}
]
}
Basically you want to have a presentation layer for your JSON response. I'll make some assumptions about the code you have and outline what I would do to solve this.
Given a helper method in your model
class Walkthru < ActiveRecord::Base
has_many :walkthru_completions
def completed_status(user)
walkthru_completions.where(user_id: user.id).present? ? 1 : 0
end
end
And a decorator for your model:
class WalkthruDecorator
def initialize(user, walkthru)
#walkthru = walkthru
#user = user
end
def as_json(*args)
#walkthru.as_json(args).merge(completed: #walkthru.completed_status(#user))
end
end
Then in your controller, let's assume you have a #user representing the current user:
class WalkthruController < ActionController
def index
decorated_walkthrus = []
Walkthru.all.each do |walkthru|
decorated_walkthrus << WalkthruDecorator.new(#user, walkthru)
end
render json: decorated_walkthrus
end
end
This structure lets you define your JSON separately from your model.

How to use attr_encrypted with as_json and join and get the decrypted attribute?

I have an attribute encypted using attr_encrypted and I'm using as_json. Under some circumstances I don't want the ssn to be part of a API response, and other times I want it to be included but using the name ssn not encrypted_ssn and to show the decrypted value. In all my cases encrypted_ssn should not be included in the result of as_json.
My first question is, how do I get as_json to return the decrypted ssn field?
With this code
class Person
attr_encrypted :ssn, key: 'key whatever'
end
I want this
Person.first.as_json
=> {"id"=>1,
"ssn"=>"333-22-4444"}
What I don't want is this:
Person.include_ssn.first.as_json
=> {"id"=>1,
"encrypted_ssn"=>"mS+mwRIsMI5Y6AzAcNoOwQ==\n"}
My second question is, how do I make it so a controller using a model can choose to include the decrypted ssn in the JSON ("ssn"=>"333-22-4444") or exclude the field (no "encrypted_ssn"=>"mS+mwRIsMI5Y6AzAcNoOwQ==\n")? I don't even want encrypted values going out to the client if the controller doesn't explicitly specify to include it.
This is what I have so far and seems to work:
class Person
attr_encrypted :ssn, key: 'key whatever'
scope :without_ssn, -> { select( column_names - [ 'encrypted_ssn' ]) }
default_scope { without_ssn }
end
Person.first.as_json
=> {"id"=>1}
I haven't figured out how to make this work in a way that includes the decrypted ssn field as in the first question. What I would like is something like this:
Person.include_ssn.first.as_json
=> {"id"=>1,
"ssn"=>"333-22-4444"}
My final question is, how do I make the above work through a join and how do I specify to include or exclude the encrypted value (or scope) in the join?
With this code:
class Person
has_many :companies
attr_encrypted :ssn, key: 'key whatever'
scope :without_ssn, -> { select( column_names - [ 'encrypted_ssn' ]) }
default_scope { without_ssn }
end
class Company
belongs_to :person
end
This seems to work like I want it
Company.where(... stuff ...).joins(:person).as_json(include: [ :person ])
=> {"id"=>1,
"person"=>
{"id"=>1}}
But I don't know how to implement include_ssn like below or alternatives to tell the person model to include the ssn decrypted.
Company.where(... stuff ...).joins(:person).include_ssn.as_json(include: [ :person ])
=> {"id"=>1,
"person"=>
{"id"=>1,
"ssn"=>"333-22-4444"}}
I've solved this in a different way. Originally I was doing this:
app/models/company.rb
class Company
# ...
def self.special_get_people
people = Company.where( ... ).joins(:person)
# I was doing this in the Company model
people.instance_eval do
def as_json_with_ssn
self.map do |d|
d.as_json(except: [:encrypted_ssn] ).merge('ssn' => d.person.ssn)
end
end
def as_json(*params)
if params.empty?
super(except: [:encrypted_ssn] ).map{ |p| p.merge('ssn' => nil) }
else
super(*params)
end
end
end
return people
end
end
app/controllers/person_controller.rb
class PersonController < ApplicationController
def index
#people = Company.special_get_people
# Then manually responding with JSON
respond_to do |format|
format.html { render nothing: true, status: :not_implemented }
format.json do
render json: #people.as_json and return unless can_view_ssn
render json: #people.as_json_with_ssn
end
end
end
end
However this was fragile and error prone. I've since refactored the above code to look more like this:
app/models/company.rb
class Company
# ...
def self.special_get_people
Company.where( ... ).joins(:person)
end
end
app/controllers/person_controller.rb
class PersonController < ApplicationController
def index
#people = Company.special_get_people
end
end
app/views/person/index.jbuilder
json.people do
json.array!(#people) do |person|
json.extract! person, :id # ...
json.ssn person.ssn if can_view_ssn
end
end
And this ends up being a much better solution that's more flexible, more robust and easier to understand.

ActiveRecord::Store with default values

Using the new ActiveRecord::Store for serialization, the docs give the following example implementation:
class User < ActiveRecord::Base
store :settings, accessors: [ :color, :homepage ]
end
Is it possible to declare attributes with default values, something akin to:
store :settings, accessors: { color: 'blue', homepage: 'rubyonrails.org' }
?
No, there's no way to supply defaults inside the store call. The store macro is quite simple:
def store(store_attribute, options = {})
serialize store_attribute, Hash
store_accessor(store_attribute, options[:accessors]) if options.has_key? :accessors
end
And all store_accessor does is iterate through the :accessors and create accessor and mutator methods for each one. If you try to use a Hash with :accessors you'll end up adding some things to your store that you didn't mean to.
If you want to supply defaults then you could use an after_initialize hook:
class User < ActiveRecord::Base
store :settings, accessors: [ :color, :homepage ]
after_initialize :initialize_defaults, :if => :new_record?
private
def initialize_defaults
self.color = 'blue' unless(color_changed?)
self.homepage = 'rubyonrails.org' unless(homepage_changed?)
end
end
I wanted to solve this too and ended up contributing to Storext:
class Book < ActiveRecord::Base
include Storext.model
# You can define attributes on the :data hstore column like this:
store_attributes :data do
author String
title String, default: "Great Voyage"
available Boolean, default: true
copies Integer, default: 0
end
end
try to use https://github.com/byroot/activerecord-typedstore gem. It allows you to set default value, use validation end other.
The following code has advantage of the defaults not being saved on every user record which reduces database storage usage and makes it easy in case if you want to change the defaults
class User < ApplicationRecord
DEFAULT_SETTINGS = { color: 'blue', homepage: 'rubyonrails.org' }
store :settings, accessors: DEFAULT_SETTINGS.keys
DEFAULT_SETTINGS.each do |key,value|
define_method(key) {
settings[key] or value
}
end
end
Here's what I just hacked together to solve this problem:
# migration
def change
add_column :my_objects, :settings, :text
end
# app/models/concerns/settings_accessors_with_defaults.rb
module SettingsAccessorsWithDefaults
extend ActiveSupport::Concern
included do
serialize :settings, Hash
cattr_reader :default_settings
end
def settings
self.class.default_settings.merge(self[:settings])
end
def restore_setting_to_default(key)
self[:settings].delete key
end
module ClassMethods
def load_default_settings(accessors_and_values)
self.class_variable_set '##default_settings', accessors_and_values
self.default_settings.keys.each do |key|
define_method("#{key}=") do |value|
self[:settings][key.to_sym] = value
end
define_method(key) do
self.settings[key.to_sym]
end
end
end
end
end
# app/models/my_object.rb
include SettingsAccessorsWithDefaults
load_default_settings(
attribute_1: 'default_value',
attribute_2: 'default_value_2'
)
validates :attribute_1, presence: true
irb(main):004:0> MyObject.default_settings
=> {:attribute_1=>'default_value', :attribute_2=>'default_value_2'}
irb(main):005:0> m = MyObject.last
=> #<MyObject ..., settings: {}>
irb(main):005:0> m.settings
=> {:attribute_1=>'default_value', :attribute_2=>'default_value_2'}
irb(main):007:0> m.attribute_1 = 'foo'
=> "foo"
irb(main):008:0> m.settings
=> {:attribute_1=>"foo", :attribute_2=>'default_value_2'}
irb(main):009:0> m
=> #<MyObject ..., settings: {:attribute_1=>"foo"}>

Resources