Search code examples
ruby-on-railslocationassociationshas-manybelongs-to

User/Address association in Rails


I'm working on my first Rails project and I'm trying create a proper User/Address (or rather User/Location) association. I thought my problem was common and that others had done what I'm trying successfully and that it would be easy to find an answer but it wasn't the case. There are many different questions with extremely different answers and I couldn't figure out how to do things in my case. Here's my scenario:

The location can be a country, State/Region or City, not more specific than that (I'm using Google Places Library's autocomplete feature). So to avoid a lot of duplicate information in the users table every time one or more users live in the same city (country, state, city, latitude, longitude etc), I figured I should have a locations table. Even worse, the user should have a "home_location" and "current_location". Now the associations:

has_one

At first I thought: "Well, a user has an address/location, so I should probably use the has_one association. But it turns out that if I use user has_one location, the location table will be the one pointing to the user's table, which is not what I want, since many users should be able to reference the same location.

belongs_to/has_many

"OK, then I'll use the belongs_to/has_many association" I thought. Seemed pretty neat at first. Seemed to make a lot of sense. I'm using the nested forms gem, which works pretty well for everything else I've done so far, and the nested form for locations loaded and the information was sent correctly. The location was created and added to the database successfully, but there was one problem: The user.location wasn't saved. Trying to figure out why, I realize there's something very weird about this whole thing. Rails is not treating this design structure the way I need (the way I feel is more intuitive). Indeed, usually this association is used for "user/microposts" (like in Hartl's tutorial) and this kind of thing. But in my case the idea is different. We humans understand that, but Rails doesn't seem to. I want a user to be able to say where he lives in his "edit profile" page. But From what I've been reading it seems like rails is expecting the LOCATION'S edit page to have a nested form for users. Like it's expecting me to create a user like this:

@location.user.create(:email=>"john@example.com")

While what I need is to create a location like this:

@user.location.create(:city=>"Rio de Janeiro", etc)

OK. Maybe what I want is too specific and Rail's standard association won't work. After all this code above would create duplicate cities, so I'd need something like:

@user.location.find_or_create(:city=>"Rio de Janeiro") (Does this even exist for associated objects?)

Here's the code for this attempt that saved the location successfully but didn't create the association:

model

class User < ActiveRecord::Base
  attr_accessible ... :home_location, :home_location_attributes,
                  :current_location, :current_location_attributes, etc


  belongs_to :home_location, :class_name => 'Location'
  accepts_nested_attributes_for :home_location

  belongs_to :current_location, :class_name => 'Location'
  accepts_nested_attributes_for :current_location

  ...
end

class Location < ActiveRecord::Base
  attr_accessible :google_id, :latitude, :longitude,
                  :administrative_area_level_1, :administrative_area_level_2,
                  :city, :neighborhood, :country, :country_id

  belongs_to :country

  has_many :users
end

schema

  create_table "users", :force => true do |t|
    ...
    t.integer  "home_location"
    t.integer  "current_location"
    t.text     "about_me"
    t.datetime "created_at",                         :null => false
    t.datetime "updated_at",                         :null => false
    ...
  end

  create_table "locations", :force => true do |t|
    t.string   "google_id",                   :null => false
    t.string   "latitude",                    :null => false
    t.string   "longitude",                   :null => false
    t.integer  "country_id",                  :null => false
    t.string   "administrative_area_level_1"
    t.string   "administrative_area_level_2"
    t.string   "city"
    t.string   "neighborhood"
    t.datetime "created_at",                  :null => false
    t.datetime "updated_at",                  :null => false
  end

But then it seemed what I wanted was too particular so I gave everything up and tried to simply add a form_for @location. Then I could check in the user controller if the record already existed and save it in the user's home_location attribute. So I removed the following lines from the user model:

accepts_nested_attributes_for :home_location
accepts_nested_attributes_for :current_location

And replaced the f.fields_for in the edit_profile view with form_for @location.

Then in the controller I added some code to deal with the location:

class UsersController < ApplicationController
  before_filter :signed_in_user,  only: [:index, :edit, :update, :destroy]
  before_filter :signed_out_user, only: [:new, :create]
  before_filter :correct_user,    only: [:edit, :update]

  ...
  def edit
    if @user.speaks.length == 0
      @user.speaks.new
    end
    if @user.wants_to_learn.length == 0
      @user.wants_to_learn.new
    end
    if @user.home_location.blank?
      @user.home_location = Location.new
    end
  end

  def update
    logger.debug params

    ...

    @home_location = nil

    params[:home_location][:country_id] = params[:home_location].delete(:country)

    unless params[:home_location][:country_id].blank?
      params[:home_location][:country_id] = Country.find_by_iso_3166_code(params[:home_location][:country_id]).id

      #@home_location = Location.where(params[:home_location])
      #The line above describe the behavior I actually want,
      #but for testing purposes I simplified as below:
      @home_location = Location.find(2) #There is a Location with id=2 in the DB

      if @home_location.nil?
        @home_location = Location.new(params[:home_location])
        if !@home_location.save then @home_location = nil end
      end
    end

    if @user.update_attributes(params[:user])
      @user.home_location = @home_location
      @user.save
      flash[:success] = "Profile updated"
      sign_in @user
      #render :action => :edit
      redirect_to :action => :edit
    else
      render :action => :edit
    end
  end
  ...
end

But still it doesn't save the association. In fact even in the console, no matter what I do, the user's home_location attribute never gets saved to the database.

Does anybody have any idea of what may be happening??

Why is the association not being saved? What's is the most correct way of doing this in Rails?

I'm sorry about the length of the question. Hope somebody reads it. I'd be very grateful!

Thanks

EDIT 1

Following RobHeaton's tip, I added a custom setter in my User model:

  def home_location= location #params[:user][:home_location]
   locations = Location.where(location)
   @home_location = locations.first.id unless locations.empty?
  end

After fixing some issues that popped up, it started working! The data was finally saved to the database. I still have no idea why it didn't work in the controller though. Does any body have any guess? I'm wondering if the mistake wasn't actually somewhere else and I ended up fixing it without noticing just because the code was better organized in the model.

Still, the problem is not completely solved. Although the value is saved to the database, I can't access the associated model in the standard Rails way.

1.9.3p362 :001 > ariel = User.first
  User Load (0.5ms)  SELECT "users".* FROM "users" LIMIT 1
 => #<User id: 1, first_name: "Ariel", last_name: "Pontes", username: nil, email: "pontes@ariel.com", password_digest: "$2a$10$Ue8dh/nRh9kL9lUd8JjQU.okD6TtE
i4H1tphmRoNIQ15...", remember_token: "kadybXrh1g0X-BE_HxhA4w", date_of_birth: nil, gender: nil, interested_in: 0, relationship_status: nil, home_location: 3, curr
ent_location: nil, about_me: "", created_at: "2013-05-07 14:28:05", updated_at: "2013-05-09 20:10:41", home_details: nil, current_details: nil> 
1.9.3p362 :002 > ariel.home_location
 => nil

The weird thing is that not only isn't Rails able to return the associated object, it won't even return the integer id stored in the object! How does that make any sense? I was hoping for a Location object and, worst case scenario, expecting a 3 in return. But instead I get nil.

I tried to write a custom getter anyway:

  def home_location
    if @home_location.blank? then return nil end
    Location.find(@home_location)
  end

But of course it doesn't work. Any more ideas??


Solution

  • Solved!

    Thanks to RobHeaton for enlightening me about good practices and to rossta for explaining Rails' conventions.

    Here's the configuration I have now:

    model

    attr_accessible :first_name, :last_name, ...,
                    :home_location, :home_location_id, :home_location_attributes,
                    :current_location, :current_location_id, :current_location_attributes, ...
    
    ...
    
    belongs_to :home_location, :class_name => 'Location'
    accepts_nested_attributes_for :home_location
    
    belongs_to :current_location, :class_name => 'Location'
    accepts_nested_attributes_for :current_location
    

    schema

    create_table "users", :force => true do |t|
      t.string   "first_name"
      t.string   "last_name"
      ...
      t.integer  "home_location_id"
      t.integer  "current_location_id"
      ...
    end
    

    My controller is untouched. Actually even cleaner than before, no more messing around with params.

    As for the custom setters, they weren't even necessary for the purposes I described in the question after all. But it helped me organize my code and make it easier to spot mistakes. I'm using them now for other things I had to fix along the way and to keep my controller clean.

    It seems Rails does understand both "types" of belongs_to/has_many associations after all! It's funny that didn't find the distinction being made explicitly anywhere though. Ended up assuming Rails couldn't do it when in fact all I had to do was correct some names to respect the conventions.