I'm building my first Rails app and one of the important features in it is having users who speak and/or want to learn languages. In the user edit profile page, I allow him/her to choose what languages he/she speaks and/or wants to learn from a list (I'm using ryanb's nested_form gem):

There are 3 models involved in this: User, Speaks, Language
The languages table is just a table with languages of the world, it doesn't change. It consists basically of ISO codes for languages and their names. I populate it by running a script that reads from the official file I downloaded. Still I was simply using Rails defaults, so the table had an id column, and it was all working fine.
Then I decided to make a change and remove the id column, because it didn't make any sense anyway. I want my app to be up to date with the ISO list. I want the ISO code to identify the languages, not a meaningless id. I want to use
user.speaks.create!(language_id: "pt", level: 6)
instead of
user.speaks.create!(language_id: 129, level: 6)
I know it's unlikely that the ISO list will change but, if it does, I want to simply run my script again with the new file and not worry if the id column will still match the same ISO code as before. So I made the change. Now I can use user.speaks.create the way I want and the association works perfectly in the console. The problem is my form simply isn't working anymore. The data is sent but I don't understand the logs. They show a bunch of SELECTS but no INSERTS or UPDATES, I don't get why. Does anybody have any idea?
Here are my models:
class User < ActiveRecord::Base
attr_accessible ..., :speaks, :speaks_attributes, :wants_to_learn_attributes
has_many :speaks, :class_name => "Speaks", :dependent => :destroy
has_many :speaks_languages, :through => :speaks, :source => :language #, :primary_key => "iso_639_1_code"
has_many :wants_to_learn, :class_name => "WantsToLearn", :dependent => :destroy
has_many :wants_to_learn_languages, :through => :wants_to_learn, :source => :language #, :primary_key => "iso_639_1_code"
...
accepts_nested_attributes_for :speaks #, :reject_if => :speaks_duplicate, :allow_destroy => true
accepts_nested_attributes_for :wants_to_learn #, :reject_if => :wants_to_learn_duplicate, :allow_destroy => true
# EDIT 1: I remembered these pieces of code silenced errors, so I commented them out
...
end
class Speaks < ActiveRecord::Base
self.table_name = "speak"
attr_accessible :language, :language_id, :level
belongs_to :user
belongs_to :language
validates :user, :language, :level, presence: true
...
end
#EDIT 4:
class WantsToLearn < ActiveRecord::Base
self.table_name = "want_to_learn"
attr_accessible :language, :language_id
belongs_to :user
belongs_to :language
validates :user, :language, presence: true
...
end
class Language < ActiveRecord::Base
attr_accessible :iso_639_1_code, :name_en, :name_fr, :name_pt
has_many :speak, :class_name => "Speaks"
has_many :users_who_speak, :through => :speak, :source => :user
has_many :want_to_learn, :class_name => "WantsToLearn"
has_many :users_who_want_to_learn, :through => :want_to_learn, :source => :user
end
Controller:
def update
logger.debug params
if @user.update_attributes(params[:user])
@user.save
flash[:success] = "Profile updated"
sign_in @user
redirect_to :action => :edit
else
render :action => :edit
end
end
View:
<%= nested_form_for(@user, :html => { :class => "edit-profile-form"} ) do |f| %>
<%= render 'shared/error_messages' %>
<table border="0">
<tr><td colspan="2"><h2 id="languages" class="bblabla">Languages</h2></td></tr>
<tr>
<td><span>Languages you speak</span></td>
<td class="languages-cell">
<div id="speaks">
<%= f.fields_for :speaks, :wrapper => false do |speaks| %>
<div class="fields">
<%= speaks.select(:language_id,
Language.all.collect {|lang| [lang.name_en, lang.id]},
{ :selected => speaks.object.language_id, :include_blank => false },
:class => 'language') %>
<%= speaks.label :level, "Level: " %>
<%= speaks.select(:level, Speaks.level_options, { :selected => speaks.object.level }, :class => 'level') %>
<%= speaks.link_to_remove raw("<i class='icon-remove icon-2x'></i>"), :class => "remove-language" %>
</div>
<% end %>
</div>
<p class="add-language"><%= f.link_to_add "Add language", :speaks, :data => { :target => "#speaks" } %></p>
</td>
</tr>
...
Log:
Started PUT "/users/1" for 127.0.0.1 at 2013-07-19 08:41:16 -0300
Processing by UsersController#update as HTML
Parameters: {"utf8"=>"✓", "authenticity_token"=>"ZmaU9...", "user"=>{"speaks_attributes"=>{"0"=>{"language_id"=>"pt", "level"=>"6", "_destroy"=>"false"}, "1374234067848"=>{"language_id"=>"en", "level"=>"5", "_destroy"=>"false"}}, "wants_to_learn_attributes"=>{"0"=>{"language_id"=>"ro", "_destroy"=>"false", "id"=>"1"}}, "home_location_attributes"=>{"google_id"=>"7789d9...", "latitude"=>"-22.9035393", "longitude"=>"-43.20958689999998", "city"=>"Rio de Janeiro", "neighborhood"=>"", "administrative_area_level_1"=>"Rio de Janeiro", "administrative_area_level_2"=>"", "country_id"=>"BR", "id"=>"1"}, "gender"=>"2", "relationship_status"=>"2", "about_me"=>""}, "commit"=>"Save changes", "id"=>"1"}
[1m[35mUser Load (0.3ms)[0m SELECT "users".* FROM "users" WHERE "users"."remember_token" = 'bjdvI...' LIMIT 1
[1m[36mUser Load (0.2ms)[0m [1mSELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT 1[0m [["id", "1"]]
{"utf8"=>"✓", "_method"=>"put", "authenticity_token"=>"ZmaU9W...", "user"=>{"speaks_attributes"=>{"0"=>{"language_id"=>"pt", "level"=>"6", "_destroy"=>"false"}, "1374234067848"=>{"language_id"=>"en", "level"=>"5", "_destroy"=>"false"}}, "wants_to_learn_attributes"=>{"0"=>{"language_id"=>"ro", "_destroy"=>"false", "id"=>"1"}}, "home_location_attributes"=>{"google_id"=>"7789d9...", "latitude"=>"-22.9035393", "longitude"=>"-43.20958689999998", "city"=>"Rio de Janeiro", "neighborhood"=>"", "administrative_area_level_1"=>"Rio de Janeiro", "administrative_area_level_2"=>"", "country_id"=>"BR", "id"=>"1"}, "gender"=>"2", "relationship_status"=>"2", "about_me"=>""}, "commit"=>"Save changes", "action"=>"update", "controller"=>"users", "id"=>"1"}
[1m[35m (0.1ms)[0m BEGIN
[1m[36mWantsToLearn Load (0.2ms)[0m [1mSELECT "want_to_learn".* FROM "want_to_learn" WHERE "want_to_learn"."user_id" = 1 AND "want_to_learn"."id" IN (1)[0m
[1m[35mLocation Load (0.3ms)[0m SELECT "locations".* FROM "locations" WHERE "locations"."google_id" = '7789d...' AND "locations"."latitude" = '-22.9035393' AND "locations"."longitude" = '-43.20958689999998' AND "locations"."city" = 'Rio de Janeiro' AND "locations"."neighborhood" = '' AND "locations"."administrative_area_level_1" = 'Rio de Janeiro' AND "locations"."administrative_area_level_2" = '' AND "locations"."country_id" = 'BR' LIMIT 1
[1m[36mUser Exists (40.0ms)[0m [1mSELECT 1 AS one FROM "users" WHERE (LOWER("users"."email") = LOWER('[email protected]') AND "users"."id" != 1) LIMIT 1[0m
[1m[35m (96.7ms)[0m UPDATE "users" SET "remember_token" = 'd0pb...', "updated_at" = '2013-07-19 11:41:16.808422' WHERE "users"."id" = 1
[1m[36m (28.7ms)[0m [1mCOMMIT[0m
[1m[35m (0.1ms)[0m BEGIN
[1m[36mUser Exists (0.3ms)[0m [1mSELECT 1 AS one FROM "users" WHERE (LOWER("users"."email") = LOWER('[email protected]') AND "users"."id" != 1) LIMIT 1[0m
[1m[35m (0.3ms)[0m UPDATE "users" SET "remember_token" = 'gKlW...', "updated_at" = '2013-07-19 11:41:17.072654' WHERE "users"."id" = 1
[1m[36m (0.4ms)[0m [1mCOMMIT[0m
Rendered shared/_error_messages.html.erb (0.0ms)
[1m[35mSpeaks Load (0.3ms)[0m SELECT "speak".* FROM "speak" WHERE "speak"."user_id" = 1
[1m[36mWantsToLearn Load (0.2ms)[0m [1mSELECT "want_to_learn".* FROM "want_to_learn" WHERE "want_to_learn"."user_id" = 1[0m
[1m[35mLanguage Load (0.3ms)[0m SELECT "languages".* FROM "languages"
[1m[36mCountry Load (0.3ms)[0m [1mSELECT "countries".* FROM "countries" WHERE "countries"."iso_3166_code" = 'BR' LIMIT 1[0m
[1m[35mCACHE (0.0ms)[0m SELECT "languages".* FROM "languages"
[1m[36mCACHE (0.0ms)[0m [1mSELECT "languages".* FROM "languages" [0m
Rendered users/edit.html.erb within layouts/application (39.8ms)
Rendered layouts/_shim.html.erb (0.0ms)
Rendered layouts/_header.html.erb (1.1ms)
Rendered layouts/_footer.html.erb (0.2ms)
Completed 200 OK in 576ms (Views: 160.7ms | ActiveRecord: 168.7ms)
Hope someone has an insight cause I've been looking all over the internet for the past 2 days with no luck. Thanks in advance!
EDIT 1
I placed the accepts_nested_attributes_for lines after the associations were made, as suggested by ovatsug25, but it didn't seem to make any change. I remembered, however, that there were some options in the User model that silenced errors, which of course hinders the debugging, so I commented these options out. Now I have the following error:
PG::Error: ERROR: operator does not exist: character varying = integer
LINE 1: ...M "languages" WHERE "languages"."iso_639_1_code" = 0 LIMIT ...
^
HINT: No operator matches the given name and argument type(s). You might need to add explicit type casts.
: SELECT "languages".* FROM "languages" WHERE "languages"."iso_639_1_code" = 0 LIMIT 1
I have NO IDEA why Rails is trying to select a language with pk = 0. Even if the pk WAS an integer this wouldn't make sense (would it???) since the default id starts from 1. And even if it started from zero, why would it be trying to select it anyway? Where is this zero comming from?? And I can't "add explicit type casts". The pk is a string and will never be 0 or '0' for that matter. This query doesn't make sense and simply isn't supposed to happen!
EDIT 2
I tried to update the attributes in the console and got the following:
irb(main):006:0> ariel = User.find(1)
User Load (101.5ms) SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT 1 [["id", 1]]
=> #<User id: 1, first_name: "Ariel", last_name: "Pontes", ...>
irb(main):007:0> params = {"user"=>{"speaks_attributes"=>{"0"=>{"language_id"=>"pt", "level"=>"6", "_destroy"=>"false"}, "1374444891951"=>{"language_id"=>"en", "l
evel"=>"5", "_destroy"=>"false"}}, "wants_to_learn_attributes"=>{"0"=>{"language_id"=>"ro", "_destroy"=>"false"}}, "home_location_attributes"=>{"google_id"=>"778...c5a", "latitude"=>"-22.9035393", "longitude"=>"-43.20958689999998", "city"=>"Rio de Janeiro", "neighborhood"=>"", "administrative
_area_level_1"=>"Rio de Janeiro", "administrative_area_level_2"=>"", "country_id"=>"BR", "id"=>"1"}, "gender"=>"2", "relationship_status"=>"2", "about_me"=>""}}
=> {"user"=>{"speaks_attributes"=>{"0"=>{"language_id"=>"pt", "level"=>"6", "_destroy"=>"false"}, "1374444891951"=>{"language_id"=>"en", "level"=>"5", "_destroy"=
>"false"}}, "wants_to_learn_attributes"=>{"0"=>{"language_id"=>"ro", "_destroy"=>"false"}}, "home_location_attributes"=>{"google_id"=>"778...c5a", "latitude"=>"-22.9035393", "longitude"=>"-43.20958689999998", "city"=>"Rio de Janeiro", "neighborhood"=>"", "administrative_area_level_1"=>"Rio de
Janeiro", "administrative_area_level_2"=>"", "country_id"=>"BR", "id"=>"1"}, "gender"=>"2", "relationship_status"=>"2", "about_me"=>""}}
irb(main):008:0> ariel.update_attributes(params[:user])
(0.1ms) BEGIN
User Exists (0.5ms) SELECT 1 AS one FROM "users" WHERE (LOWER("users"."email") = LOWER('[email protected]') AND "users"."id" != 1) LIMIT 1
(24.9ms) UPDATE "users" SET "remember_token" = '0tv...Cw', "updated_at" = '2013-07-22 15:45:30.705217' WHERE "users"."id" = 1
(54.3ms) COMMIT
=> true
irb(main):009:0>
Basically, it only updates the remember_token and updated_at for some reason.
EDIT 3
I tried to update only the spoken languages and it worked:
irb(main):012:0> ariel.update_attributes({"speaks_attributes"=>{"0"=>{"language_id"=>"pt", "level"=>"6", "_destroy"=>"false"}, "1374444891951"=>{"language_id"=>"e
n", "level"=>"5", "_destroy"=>"false"}}})
(0.2ms) BEGIN
User Load (0.4ms) SELECT "users".* FROM "users" WHERE "users"."id" = 1 LIMIT 1
Language Load (0.8ms) SELECT "languages".* FROM "languages" WHERE "languages"."iso_639_1_code" = 'pt' LIMIT 1
User Load (0.3ms) SELECT "users".* FROM "users" WHERE "users"."id" = 1 LIMIT 1
Language Load (0.2ms) SELECT "languages".* FROM "languages" WHERE "languages"."iso_639_1_code" = 'en' LIMIT 1
User Exists (0.2ms) SELECT 1 AS one FROM "users" WHERE (LOWER("users"."email") = LOWER('[email protected]') AND "users"."id" != 1) LIMIT 1
(0.2ms) UPDATE "users" SET "remember_token" = 'MYh5X1XoF6OsVIo3rhDNzQ', "updated_at" = '2013-07-22 22:05:08.198025' WHERE "users"."id" = 1
SQL (42.9ms) INSERT INTO "speak" ("created_at", "language_id", "level", "updated_at", "user_id") VALUES ($1, $2, $3, $4, $5) RETURNING "id" [["created_at", Mo
n, 22 Jul 2013 22:05:08 UTC +00:00], ["language_id", "pt"], ["level", 6], ["updated_at", Mon, 22 Jul 2013 22:05:08 UTC +00:00], ["user_id", 1]]
SQL (0.4ms) INSERT INTO "speak" ("created_at", "language_id", "level", "updated_at", "user_id") VALUES ($1, $2, $3, $4, $5) RETURNING "id" [["created_at", Mon
, 22 Jul 2013 22:05:08 UTC +00:00], ["language_id", "en"], ["level", 5], ["updated_at", Mon, 22 Jul 2013 22:05:08 UTC +00:00], ["user_id", 1]]
(14.7ms) COMMIT
=> true
I'm starting to fear it may be a case of witchcraft.
PS: Does anybody know why it loads the User 3 times? Seems rather pointless and wasteful.
primary_keyoption is on the wrong side of the association. You need to specify that on the parent - see here for reference. Also, since you no longer wish to use 'language_id' as a foreign key in yourSpeaksmodel you should setup a new field to better reflect the field that associates your two models - ISO in your case. Note you'll also need to specify aforeign_keyconstraint in yourbelongs_tofor this to work, make the new fieldattr_accessibleas well.iso_639_1_code, and it isattr_accessible. As for theprimary_key, I'm not sure I get it. It's a many-to-many relationship, but I suppose you mean the user since that's the parent form. Am I right? In any case I did specify theprimary_keyto be used on join in theUsermodel (iso_639_1_code). Besides, I'm not sure I was clear in my question, but the association works fine in the console. It's just the form that doesn't work. And that's what makes everything even more confusing for me.