How to save a nested many-to-many relationship in API-only Rails?

1k Views Asked by At

In my Rails (api only) learning project, I have 2 models, Group and Artist, that have a many-to-many relationship with a joining model, Role, that has additional information about the relationship. I have been able to save m2m relationships before by saving the joining model by itself, but here I am trying to save the relationship as a nested relationship. I'm using the jsonapi-serializer gem, but not married to it nor am I tied to the JSON api spec. Getting this to work is more important than following best practice.

With this setup, I'm getting a 500 error when trying to save with the following errors: Unpermitted parameters: :artists, :albums and ActiveModel::UnknownAttributeError (unknown attribute 'relationships' for Group.)

I'm suspecting that my problem lies in the strong param and/or the json payload.

Models

class Group < ApplicationRecord
  has_many :roles
  has_many :artists, through: :roles

  accepts_nested_attributes_for :artists, :roles
end


class Artist < ApplicationRecord
  has_many :groups, through: :roles
end


class Role < ApplicationRecord
  belongs_to :artist
  belongs_to :group
end

Controller#create

def create
  group = Group.new(group_params)

  if group.save
    render json: GroupSerializer.new(group).serializable_hash
  else
    render json: { error: group.errors.messages }, status: 422
  end
end

Controller#group_params

def group_params
  params.require(:data)
    .permit(attributes: [:name, :notes],
      relationships: [:artists])
end

Serializers

class GroupSerializer
  include JSONAPI::Serializer
  attributes :name, :notes

  has_many :artists
  has_many :roles
end


class ArtistSerializer
  include JSONAPI::Serializer
  attributes :first_name, :last_name, :notes
end


class RoleSerializer
  include JSONAPI::Serializer
  attributes :artist_id, :group_id, :instruments
end

Example JSON payload

{
  "data": {
    "attributes": {
      "name": "Pink Floyd",
      "notes": "",
    },
    "relationships": {
      "artists": [{ type: "artist", "id": 3445 }, { type: "artist", "id": 3447 }]
    }
}

Additional Info

It might help to know that I was able to save another model with the following combination of json and strong params.

# Example JSON

"data": {
  "attributes": {
    "title": "Wish You Were Here",
    "release_date": "1975-09-15",
    "release_date_accuracy": 1
    "notes": "",
    "group_id": 3455
  }
}


# in albums_controller.rb 

def album_params
  params.require(:data).require(:attributes)
    .permit(:title, :group_id, :release_date, :release_date_accuracy, :notes)
end
1

There are 1 best solutions below

0
On BEST ANSWER

From looking at https://api.rubyonrails.org/classes/ActiveRecord/NestedAttributes/ClassMethods.html I think the data format that Rails is normally going to expect will look something like:

{
  "group": {
    "name": "Pink Floyd",
    "notes": "",
    "roles_attributes": [
      { "artist_id": 3445 },
      { "artist_id": 3447 }
    ]
  }
}

with a permit statement that looks something like (note the . before permit has moved):

params.require(:group).
    permit(:name, :notes, roles_attributes: [:artist_id])

I think you have a few options here:

  1. Change the data format coming into the action.
  2. Craft a permit statement that works with your current data (not sure how tricky that is), you can test your current version in the console with:
params = ActionController::Parameters.new({
  "data": {
    "attributes": {
      "name": "Pink Floyd",
      "notes": "",
    },
    "relationships": {
      "artists": [{ type: "artist", "id": 3445 }, { type: "artist", "id": 3447 }]
    }
  }
})

group_params = params.require(:data).
    permit(attributes: [:name, :notes],
      relationships: [:artists])

group_params.to_h.inspect

and then restructure the data to a form the model will accept; or

  1. Restructure the data before you try to permit it e.g. something like:
def group_params
    params_hash = params.to_unsafe_h

    new_params_hash = {
      "group": params_hash["data"]["attributes"].merge({
        "roles_attributes": params_hash["data"]["relationships"]["artists"].
            map { |a| { "artist_id": a["id"] } }
      })
    }

    new_params = ActionController::Parameters.new(new_params_hash)

    new_params.require(:group).
        permit(:name, :notes, roles_attributes: [:artist_id])
end

But ... I'm sort of hopeful that I'm totally wrong and someone else will come along with a better solution to this stuff.