active_model_serializers icon indicating copy to clipboard operation
active_model_serializers copied to clipboard

Define a context for a nested association

Open willcosgrove opened this issue 8 years ago • 22 comments

Expected behavior vs actual behavior

From the serializer of an association, I would like to access the object of the parent serializer.

Steps to reproduce

Say we have three models, Ballot, Candidate, and Vote. Candidates and Ballots have a many to many relationship. Votes belong to a Ballot and a Candidate. I'm writing a Ballot serializer that has_many :candidates. I'm then making use of the subclassed serializer to have a custom CandidateSerializer. Inside the CandidateSerializer I want an attribute that represents the number of votes for that Candidate on that Ballot. But to write that method, I need to know which ballot this is being serialized for. Is this possible somehow?

I have managed to hack it by removing the association on the BallotSerializer and just defining a candidates attribute that returns an array of hashes, and build the candidate that way. But having the child serializer seems a lot cleaner. Is there anyway for me to pass that context of which ballot I'm serializing this candidate for?

Environment

ActiveModelSerializers Version (commit ref if not on tag): a6c6979e081fe5c40c471a5fe7cd9d305a471d15

Output of ruby -e "puts RUBY_DESCRIPTION": ruby 2.3.0p0 (2015-12-25 revision 53290) [x86_64-darwin15]

OS Type & Version: OS X 10.11.3

Integrated application and version (e.g., Rails, Grape, etc): Rails 5.0.0.beta2

Additonal helpful information

I am currently using the default attributes adapter.

Let me know if there's anything I can post to make the above situation more clear. Thanks for your help!

willcosgrove avatar Mar 04 '16 04:03 willcosgrove

@willcosgrove Thanks for the issue!

I would like to access the object of the parent serializer. I want an attribute that represents the number of votes for that Candidate on that Ballot. But to write that method, I need to know which ballot this is being serialized for

could you maybe give some more complete code examples? I'm not sure I understand the data model. If you can sketch out the model classes and serializer classes that would be helpful. Are you just asking if you can has_many :ballots { object.ballot.total_votes(object) }?

bf4 avatar Mar 04 '16 07:03 bf4

Yeah definitely. So say I have these serializers:

class BallotSerializer < ActiveModel::Serializer
  attributes :id, :name, :owner_id, :created_at, :updated_at
  has_many :candidates

  class CandidateSerializer < ActiveModel::Serializer
    attributes :id, :name, :vote_count

    def vote_count
      # current_ballot is what I want to be able to access, but I'm at a loss for how
      object.votes.where(ballot_id: current_ballot.id).count
    end
  end
end

Does that help explain the problem? From within the vote_count method of the CandidateSerializer I currently have no way of knowing for which Ballot the candidate is being serialized.

willcosgrove avatar Mar 04 '16 21:03 willcosgrove

@willcosgrove there is something that is not clear here. Does a Candidate model have access to its ballot?

  • If it does then I assume you can use:
def vote_count
  object.votes.where(ballot_id: object.ballot.id).count
end
  • If it does not, then there is an easy workaround in your case. You can use the instance_options which is passed to the association serializer, see #1545 for documentation and check out this gist as example.

@bf4 Another solution here would be to have access to the parent serializer within an association. The only issue I see is that it would only work (nicely) for serializers that would not be serialized outside of the scope of their parent serializer. If the association use a standalone serializer (not embedded within a parent serializer), an if statement would be required to check whether or not it is being serialized within the scope of a parent serializer. I guess that having a parent or parent_serializer method that defaults to nil would be good practical solution. What you would have in such case is:

class CandidateSerializer < ActiveModel::Serializer
  attributes :id, :name
  attribute :vote_count, if: :has_parent? do
    object.votes.where(ballot_id: parent.object.id).count
  end
  def has_parent?
    !parent.nil?
  end
end
class BallotSerializer < ActiveModel::Serializer
  attributes :id, :name, :owner_id, :created_at, :updated_at
  has_many :candidates
end

groyoh avatar Mar 05 '16 16:03 groyoh

@groyoh Wow, thanks for all your help! I'll try to respond to each thing in order.

Does a Candidate model have access to its ballot?

It does not, because a candidate can have multiple ballots. So you can't call candidate.ballot but you can call candidate.ballots. Unfortunately that doesn't get you much closer to figuring out which ballot is being serialized.

You can use the instance_options which is passed to the association serializer

This sounds like my best bet currently. I don't really like that it has to be specified in the controller though. I would prefer to specify it inside the serializer, when I define the relationship:

has_many :candidates, ballot: object

The one glaring problem there is that I don't have access to the object in the class scope. So I would have to give it a symbol or a proc, and have the gem evaluate it for me in the scope of the instance.

Having to define it at the controller level seems confusing

render json: @ballot, ballot: @ballot

My current work-around is to change the candidates from an association, to an attribute that just returns an array of hashes, rather than go through another CandidateSerializer. That way I'm still in the scope of the BallotSerializer and I can still access the object.

Another solution here would be to have access to the parent serializer within an association

This would be my preferred way of dealing with it.

Thanks again everyone for all your help! I've gotten everything I need from this issue, but I'll leave it open in case there is interest in further discussing the parent idea. If not, feel free to close this issue.

willcosgrove avatar Mar 05 '16 18:03 willcosgrove

@willcosgrove thanks for the explanation. It's nice to have such a nice issue description and details.

has_many :candidates, ballot: object

From your example, I also figured out that it would also be possible to add a new method to the association DSL in order to pass some arbitrary options like so:

has_many :candidates do
  instance_options ballot: object
end

@bf4 @willcosgrove how do you feel about such solution? I think that this is something that might actually add some values to the association without messing up with the serializer class itself.

groyoh avatar Mar 05 '16 18:03 groyoh

@groyoh that looks great to me!

willcosgrove avatar Mar 05 '16 18:03 willcosgrove

Another solution here would be to have access to the parent serializer within an association. The only issue I see is

I think the problem is having a recursive dependency. How could you count votes outside od the serializer, in a one liner?

ballot.candidates.map {|c| c.votes(ballot) }

Right?

On Sat, Mar 5, 2016 at 12:22 PM Will Cosgrove [email protected] wrote:

@groyoh https://github.com/groyoh that looks great to me!

— Reply to this email directly or view it on GitHub https://github.com/rails-api/active_model_serializers/issues/1554#issuecomment-192702176 .

bf4 avatar Mar 07 '16 19:03 bf4

@bf4 I'm not sure I'm following what you're saying. A recursive dependency is always a problem, I'm not sure how something like @groyoh proposed makes it any more of a problem.

willcosgrove avatar Mar 31 '16 17:03 willcosgrove

@willcosgrove I believe you can now do what you want now that https://github.com/rails-api/active_model_serializers/pull/1633 has been merged

bf4 avatar Mar 31 '16 19:03 bf4

Oh sweet, I will check it out. I'll go ahead and close this issue then. Thanks for your help!

willcosgrove avatar Mar 31 '16 19:03 willcosgrove

Hey, sorry to reopen this issue. I've gotten a chance to play around with the stuff added in #1633 and it looks great, but I can't find a way to set the instance options of the serializer. I can set the reflection options, but those don't get copied over to the instance options of the serializer.

def serializer_options(subject, parent_serializer_options, reflection_options)
  serializer = reflection_options.fetch(:serializer, nil)

  serializer_options = parent_serializer_options.except(:serializer)
  serializer_options[:serializer] = serializer if serializer
  serializer_options[:serializer_context_class] = subject.class
  serializer_options
end

Source: reflection.rb:137-144

So that means when I'm defining a reflection:

has_many :candidates do |serializer|
  # Would love to do this but cannot, instance_options is not an available method
  # instance_options ballot: serializer.object

  # Can do this, but it doesn't do anything helpful to me, currently
  self.options = {ballot: serializer.object}
end

Hopefully I haven't missed something simple that would let me do this 😁 . Let me know if I can provide further clarification on what I'm looking for.

Thanks!

willcosgrove avatar May 02 '16 18:05 willcosgrove

@bf4 this also isn't working for me. I can do the following:

has_many :assessments
def assessments
  object.assessments.where(vendor_id: instance_options[:vendor_id])
end

but when I try to simplify and do:

has_many :assessments do |object|
  object.assessments.where(vendor_id: instance_options[:vendor_id])
end

I get undefined method 'assessments' for #<VendorProjectSerializer>

swrobel avatar Jan 27 '17 18:01 swrobel

There no yielded object so the blockparam is nil. Aredocs wrong somewhere?

B mobile phone

On Jan 27, 2017, at 12:06 PM, Stefan Wrobel [email protected] wrote:

@bf4 this also isn't working for me. I can do the following:

has_many :assessments def assessments object.assessments.where(vendor_id: instance_options[:vendor_id]) end but when I try to simplify and do:

has_many :assessments do |object| object.assessments.where(vendor_id: instance_options[:vendor_id]) end I get undefined method 'assessments' for #<VendorProjectSerializer>

— You are receiving this because you were mentioned. Reply to this email directly, view it on GitHub, or mute the thread.

bf4 avatar Jan 27 '17 19:01 bf4

I don't understand your question

swrobel avatar Jan 28 '17 00:01 swrobel

You wrote

has_many :assessments do |object|

But should have

has_many :assessments do

And I asked if you wrote the wrong way, w block param, due to docs

B mobile phone

On Jan 27, 2017, at 6:38 PM, Stefan Wrobel [email protected] wrote:

I don't understand your question

— You are receiving this because you were mentioned. Reply to this email directly, view it on GitHub, or mute the thread.

bf4 avatar Jan 28 '17 22:01 bf4

I suppose upon re-reading the docs, they are clear that the block param is the serializer itself, not the object.

When I try

has_many :assessments do
  object.assessments.where(vendor_id: instance_options[:vendor_id])
end

I get undefined local variable or method 'instance_options' for #<ActiveModel::Serializer::HasManyReflection>

swrobel avatar Jan 31 '17 23:01 swrobel

@swrobel so, we can add that as a private method in the Reflection class, or right now you can add the 'serializer' block param call serializer.send(:instance_options).fetch(:vendor_id). I agree this is a usability issue.

bf4 avatar Jan 31 '17 23:01 bf4

Came across this quirk myself today too. Would be nice to look at a permanent solution to get instance_options available locally in the has_many block rather than accessing through the protected method in the serializer.

SirRawlins avatar May 08 '17 12:05 SirRawlins

@willcosgrove Here's what I did to solve a similar problem (adapted to your models):

has_many :candidates do |serializer|
  # Set instance option for parent serializer, the option will be passed to the child serializer
  serializer.send(:instance_options)[:ballot] = serializer.object

  # Find the records for the association
  serializer.object.ballots
end

It would be usable addition though if instance options could be set for only the nested serializers.

jeppester avatar Mar 06 '18 13:03 jeppester

Not sure if this is any better but I ended up doing the following

def ballot(value)
   instance_options[:ballot] = value
end 
has_many :candidates do |serializer|
  # Set instance option for parent serializer, the option will be passed to the child serializer
  ballot(serializer.object)

  # Find the records for the association
  serializer.object.ballots
end

ratneshraval avatar May 07 '19 20:05 ratneshraval

Any update on this?

BWT If you don't want to mutate original collection, just return :nil

has_many :candidates do |serializer|
  # Set instance option for parent serializer, the option will be passed to the child serializer
  serializer.send(:instance_options)[:ballot] = serializer.object
  :nil
end

adis-io avatar May 04 '20 09:05 adis-io

.send(:instance_options)[:ballot] = serializer.object

This is very neat. why :nil though?

lsamon avatar Aug 22 '21 13:08 lsamon