Rails 6 nested resources with strong params

Viewed 375

I have nested resources in my Rails app, basically a Project has Targets, and I figured the easiest way to go about creating the relationship would be to do this in my routes.rb:

resource :projects do
  resource :targets
end

My Models pretty much match that as well:

- Project.rb
class Project < ApplicationRecord
  has_many :targets
end

- Target.rb
class Target < ApplicationRecord
  has_one :project
end

Running rake routes shows me what I would expect:

project_targets GET    /projects/:project_id/targets(.:format)                                                  targets#index
                                      POST   /projects/:project_id/targets(.:format)                                                  targets#create
                   new_project_target GET    /projects/:project_id/targets/new(.:format)                                              targets#new
                  edit_project_target GET    /projects/:project_id/targets/:id/edit(.:format)                                         targets#edit
                       project_target GET    /projects/:project_id/targets/:id(.:format)                                              targets#show
                                      PATCH  /projects/:project_id/targets/:id(.:format)                                              targets#update
                                      PUT    /projects/:project_id/targets/:id(.:format)                                              targets#update
                                      DELETE /projects/:project_id/targets/:id(.:format)                                              targets#destroy
                             projects GET    /projects(.:format)                                                                      projects#index
                                      POST   /projects(.:format)                                                                      projects#create
                          new_project GET    /projects/new(.:format)                                                                  projects#new
                         edit_project GET    /projects/:id/edit(.:format)                                                             projects#edit
                              project GET    /projects/:id(.:format)                                                                  projects#show
                                      PATCH  /projects/:id(.:format)                                                                  projects#update
                                      PUT    /projects/:id(.:format)                                                                  projects#update
                                      DELETE /projects/:id(.:format)                                                                  projects#destroy

After I did this, my create/edit forms no longer worked for target, so I had to adjust the first line of form_for from:

<%= form_for @target do |f| %>

to

<%= form_for @target, url: project_targets_path do |f| %>

Notice I had to explicitly declare the url

The create method in the Target controller is pretty basic:

def create
  @target = Target.create(target_params)
  if @target.valid?
    redirect_to project_target_path(id: @target.id)
  else
    flash[:errors] = @target.errors.full_messages
    redirect_to new
  end
end

My attempt to create a target is successful, but the target has no project_id assigned to it, per the db schema:

create_table :targets do |t|
  t.string :domain
  t.boolean :investigated, default: false
  t.boolean :research, default: false
  t.integer :project_id
  t.timestamps
end

This is what I see in the logs, clearly the project_id is being passed as part of the URL, but it's not being associated to the new target on creation.

Started POST "/projects/1/targets" for ::1 at 2020-03-13 19:19:57 -0400
Processing by TargetsController#create as HTML
  Parameters: {"authenticity_token"=>"jLzQmpsxMRW4z66WguFVwZcLnMxFJYIy86EDfru6fIhysWDU/fd6yq5HV2uv1Z3TICGQSAXZDll66DwizReWaQ==", "target"=>{"domain"=>"2-test.com"}, "commit"=>"Create", "project_id"=>"1"}
   (0.1ms)  begin transaction
  ↳ app/controllers/targets_controller.rb:11:in `create'
  Target Create (0.8ms)  INSERT INTO "targets" ("domain", "created_at", "updated_at") VALUES (?, ?, ?)  [["domain", "2-test.com"], ["created_at", "2020-03-13 23:19:57.097233"], ["updated_at", "2020-03-13 23:19:57.097233"]]
  ↳ app/controllers/targets_controller.rb:11:in `create'
   (1.1ms)  commit transaction
  ↳ app/controllers/targets_controller.rb:11:in `create'

Is my setup incorrect? How can I fix this so that when I create a new target, the project_id is included? I'd like to keep this RESTful and not pass a hidden field when the project_id in it if possible.

1 Answers

Set the form up as as:

<%= form_for [@project, @target] do |f| %>

While you can explicitly pass a url:

<%= form_for @target, url: project_targets_path(@project) do |f| %>

This will break the edit and update actions if you are sharing the form in a partial as the action attribute should point to /projects/:project_id/targets/:id when updating. Prefer convention over configuration.

Your create method is also broken in many ways.

class TargetsController < ApplicationController
  before_action :set_project
  before_action :set_target, except: [:new, :index]

  # POST /projects/1/targets
  def create
    # building the new record off the parent sets the project_id
    @target = @project.targets.new(target_params)
    if @target.save
      redirect_to [@project, @target] 
    else
      flash[:errors] = @target.errors.full_messages
      # When a record is invalid always render - never redirect.
      render :new
    end
  end

  private
  def set_project
    @project = Project.find(params[:project_id])
  end
  # ...
end

if @project.valid? just checks if the application validations have passed - not if the the record was actually persisted to the database. Checking the return value of @target.save or @target.persisted? does.

Note that :project_id should not be included in your params whitelist as its passed through the url and not though mass assignment. Nesting routes really does not have anything to with strong parameters.

You should also read up on shallow nesting as you most likely don't need to nest the member routes and it reduces the complexity considerably.

Related