1
votes

I'm building a web application with Rails 5 and have run into an issue updating associated records through a parent record when I have defined non-persistent attributes (with attr_accessor) on the associated records. Specifically, I have the user supply the non-persistent attributes on the child records in some way, and, based on the values of those attributes, assign values to persistent attributes in a before_save callback. The problem is that the child records are not saved to the database (and hence the save callback is not called) unless persistent attributes are changed on the child records through the parent.

I've run into this issue in several different situations, but the (simplified) example here deals with using the Paperclip Gem to process images uploaded to AWS S3 by a client browser.

app/models/dog.rb

class Dog < ApplicationRecord
  has_many :certificates, :dependent => :destroy
  accepts_nested_attributes_for :certificates, :allow_destroy => true
end

app/models/certificate.rb

class Certificate < ApplicationRecord
  # load with path to client-uploaded file on S3 and save to
  # update digitized_proof attachment
  attr_accessor :s3_key

  belongs_to :dog
  has_attached_file :digitized_proof, 
    :content_type => { :content_type => ['image/jpg', 'image/png'] }
  before_save :fetch_digitized_proof_from_s3

  def fetch_digitized_proof_from_s3
    return unless self.s3_key.present?

    # note `S3_BUCKET` is set in the aws initializer
    s3_obj = S3_BUCKET.object(self.s3_key)
    # load paperclip attachment via S3 presigned URL
    s3_presigned_url = s3_obj.presigned_url(:get, 
      :expires_in => 10.minutes.to_i)
    self.digitized_proof = URI.parse(s3_presigned_url)
  end
end

apps/controllers/dogs_controller.rb excerpt

def update
  @dog = Dog.find(params[:id])

  if @dog.update(dog_params)
    redirect_to ...
  ...
end

private
  def dog_params
    params.require(:dog).permit(
      ...,
      :certificates_attributes => [:id, :_destroy, :s3_key]
    )
  end

I've written javascript that uploads images to a temporary folder in an S3 bucket directly from the client's browser and adds the s3_key to the update form so the image can be identified and processed server-side (see the fetch_digitized_proof_from_s3 method in certificate.rb). The issue is that the certificates are never updated unless an actual database attribute has changed in the update parameters.

Why is this occurring and how can I work around it?

Sample parameters

{
  ...,
  certificates_attributes: [
    {id: '1', _destroy: '0', s3_key: 'tmp/uploads/certificates/.../photo.jpg'},
    {id: '2', _destroy: '0', s3_key: 'tmp/uploads/certificates/.../photo2.jpg'}
  ]
}

Gem Versions

rails-5.0.0
activerecord-5.0.0
paperclip-5.1.0
aws-sdk-2.10.0

EDIT

I'm able to accomplish the update on the certificates by calling fetch_digitized_proof_from_s3 from within the setter method for s3_key (and removing the before_save callback):

# app/models/certificate.rb
def s3_key=(key)
  @s3_key = key
  self.fetch_digitized_proof_from_s3
end

This triggers the associated certificates to save properly (I'm thinking this occurs since digitized_proof, which is a persistent attribute, is updated by the call to fetch_digitized_proof_from_s3). This works, but I'd still rather fetch the image from S3 when the record is saved.

1
maybe use before_validation instead? The save call won't get run if the record is invalid.max pleaner
@max It appears before_validation is producing the same behavior. The callbacks don't seem to trigger when s3_key is the only modified attribute.w_hile

1 Answers

1
votes

It appears the associated records will not update unless a change is registered with ActiveModel::Dirty. This does not occur when non-persisted attributes are set:

cert = Certificate.find(1)
cert.s3_key = 'tmp/uploads/certificates/...'
cert.changed? # => false

Adding the following method to Certificate.rb produces the desired behavior:

def s3_key=(key)
  attribute_will_change!('s3_key') unless s3_key == key
  @s3_key = key
end

Now the result is

cert = Certificate.find(1)
cert.s3_key = 'tmp/uploads/certificates/...'
cert.changed? # => true

and the associated records update appropriately when s3_key is set.