4

I have a Rails app with a model containing a birthdate attribute. This corresponds to a column in my database defined using the ActiveRecord date type. With this I am able to use the date_select form helper method to render this as a three-select input in my view. The form parameters corresponding to this field are then serialized back to the controller as birthdate(1i), birthdate(2i) and birthdate(3i). Consequently, I can use the standard update_attributes method within my controller on my model to update all fields on my model.

I'm now experimenting with encrypting this field using the attr_encrypted gem. While the gem supports marshalling (this is nice), there is no longer a real column of name birthdate of type date - instead, attr_encrypted exposes the value as a virtual attribute birthdate backed by a real encrypted_birthdate column. This means that update_attributes is unable to perform the previous multiparameter attribute assignment to populate and save this column. Instead, I get a MultiparameterAssignmentErrors error resulting from the call to the internal column_for_attribute method returning nil for this column (from somewhere within execute_callstack_for_multiparameter_attributes).

I'm currently working around this as follows:

My model in app/models/person.rb:

class Person < ActiveRecord::Base
  attr_encrypted :birthdate
end

My controller in app/controllers/people_controller.rb:

class PeopleController < ApplicationController
  def update

    # This is the bit I would like to avoid having to do.
    params[:person] = munge_params(params[:person])

    respond_to do |format|
      if @person.update_attributes(params[:person])
        format.html { redirect_to @person, notice: 'Person was successfully updated.' }
        format.json { head :no_content }
      else
        format.html { render action: "edit" }
        format.json { render json: @person.errors, status: :unprocessable_entity }
      end
    end
  end

  private

  def munge_params(params)
    # This separates the "birthdate" parameters from the other parameters in the request.
    birthdate_params, munged_params = extract_multiparameter_attribute(params, :birthdate)

    # Now we place a scalar "birthdate" where the multiparameter attribute used to be.
    munged_params['birthdate'] = Date.new(
      birthdate_params[1],
      birthdate_params[2],
      birthdate_params[3]
    )

    munged_params
  end

  def extract_multiparameter_attribute(params, name)
    # This is sample code for demonstration purposes only and currently
    # only handles the integer "i" type.
    regex = /^#{Regexp.quote(name.to_s)}\((\d+)i)\)$/
    attribute_params, other_params = params.segment { |k, v| k =~ regex }
    attribute_params2 = Hash[attribute_params.collect do |key, value|
      key =~ regex or raise RuntimeError.new("Invalid key \"#{key}\"")
      index = Integer($1)
      [index, Integer(value)]
    end]
    [attribute_params2, other_params]
  end

  def segment(hash, &discriminator)
    hash.to_a.partition(&discriminator).map do |a|
      a.each_with_object(Hash.new) { |e, h| h[e.first] = e.last }
    end
  end
end

And my view app/views/people/_form.html.erb:

<%= form_for @person do |f| %>
    <%= f.label :birthdate %>
    <%= f.date_select :birthdate %>

    <% f.submit %>
<% end %>

What's the proper way to handle this type of attribute without having to introduce ad hoc munging of the params array like this?

Update: Looks like this might refer to a related problem. And this too.

Another update:

Here is my current solution, based on Chris Heald's answer. This code should be added to the Person model class:

class EncryptedAttributeClassWrapper
  attr_reader :klass
  def initialize(klass); @klass = klass; end
end

# TODO: Modify attr_encrypted to take a :class option in order
# to populate this hash.
ENCRYPTED_ATTRIBUTE_CLASS_WRAPPERS = {
  :birthdate => EncryptedAttributeClassWrapper.new(Date)
}

def column_for_attribute(attribute)
  attribute_sym = attribute.to_sym
  if encrypted = self.class.encrypted_attributes[attribute_sym]
    column_info = ENCRYPTED_ATTRIBUTE_CLASS_WRAPPERS[attribute_sym]
    column_info ||= super encrypted[:attribute]
    column_info
  else
    super
  end
end

This solution works as is, but would be even better if attr_encrypted were to take a :class option that would construct the ENCRYPTED_ATTRIBUTE_CLASS_WRAPPERS hash dynamically. I'm going to look at ways I can extend/monkeypatch attr_encrypted to do this. Gist available here: https://gist.github.com/rcook/5992293.

Richard Cook
  • 32,523
  • 5
  • 46
  • 71

1 Answers1

1

You can monkeypatch your model to pass through column_for_attribute calls. I haven't tested this, but it should cause reflection on the birthday field to instead return the reflection for the encrypted_birthday field, which should cause multiparam attributes to properly assign (as AR will then be able to infer the field type):

def column_for_attribute(attribute)
  if encrypted = encrypted_attributes[attribute.to_sym]
    super encrypted[:attribute]
  else
    super
  end
end

We're patching column_for_attribute per this line so that AR can infer the proper type for the column. It needs to figure out that parameters for "birthday" should be a DateTime type of whatnot, and can't infer that from a virtual attribute. Mapping the reflection onto the actual column should resolve that.

Chris Heald
  • 61,439
  • 10
  • 123
  • 137
  • Thanks, Chris. I've marked your suggestion as the solution. You'll get the bounty in four days' time! I've updated my original question to include my current working solution based on yours. Since `attr_encrypted` unfortunately treats all encrypted columns as strings, your solution as stated doesn't quite work: the extra piece of work is to store the intended data type of the column elsewhere. That's what `ENCRYPTED_ATTRIBUTE_CLASS_WRAPPERS` does in my variant. Ideally, `attr_encrypted` would take care of this. – Richard Cook Jul 12 '13 at 22:30
  • Cool, glad you got it sorted. Maybe a pull request to the `attr_encrypted` gem is in order? – Chris Heald Jul 12 '13 at 22:59
  • 1
    Yep. I'm working on the extension to `attr_encrypted` as we speak! – Richard Cook Jul 12 '13 at 23:01
  • I've updated this answer for Rails > 4.2, since column_for_attribute is no longer used by Rails when assigning multiparameter attributes. Richard Cook's gist works great for Rails < 4.2. https://gist.github.com/stevehodges/dde0da195da29300e9a8bb3cfc337eb4 – MrDerp Oct 07 '16 at 13:31