In a Rails ( 4.1.5 / ruby 2.0.0p481 / win64 ) application I have a many-to-many relationship between Student and Course and a join model StudentCourse which represents the association, which has an additional attribute called "started", which is set by default on "false".
I also have added an index in the join table made of the student_id and the course_id, and set a unique check on that, like this
t.index [:student_id, :course_id], :unique => true, :name => 'by_student_and_course'
Now I see that associations are created by either doing:
Student.first.courses.create(:name => "english")
or
Course.first.students << Student.first
This is fine and it's the expected behaviour, I suppose.
What I am looking after is the correct way to get and set the "started" attribute. I am seeing an odd behaviour when accessing that attribute from the other models and not straight from the join model.
s = Student.create
c = Course.create(:name => "english")
s.student_courses.first
=> | "english" | false | # (represented as a table for practicity)
s.student_courses.first.started = true
=> | "english" | true |
s.save
=> true
Ok this looks like it has been saved but when I loot ak:
StudentCourse.first
=> | 1 | 1 | false |
So it is set on true if I go through the student nested attributes, but it's still false in the join model. I also tried doing "reload!" but it makes no difference and they will mantaint their own different value.
If something is going so bad that values are not actually persisted I should be told instead of getting "true" when saving, because otherwise how bad could be the consequences of this ? What am I missing here?
Anyway, if I try modifying the "started" attribute on the join model directly, I meet another kind of problem:
StudentCourse.first.started = true
StudentCourse Load (1.0ms) SELECT "student_courses".* FROM "student_courses" LIMIT 1 => true
StudentCourse.first.started
=> false
It has not changed!
StudentCourse.find_by(:student_id => "10", :course_id => "1").started = true
=> true
StudentCourse.find_by(:student_id => "10", :course_id => "1").started
=> false
Same as before.. I try with:
StudentCourse.find(1).started = true
ActiveRecord::UnknownPrimaryKey: Unknown primary key for table student_courses in model StudentCourse.
Then with:
sc = StudentCourse.first
sc.started = true
=> true
sc
=> | 1 | 1 | true |
seems great but when saving:
sc.save
(0.0ms) begin transaction
SQL (1.0ms) UPDATE "student_courses" SET "started" = ? WHERE "student_courses"."" IS NULL [["started", "true"]] SQLite3::SQLException: no such column: student_courses.: UPDATE "student_courses" SET "started" = ? WHERE "student_courses"."" IS NULL (1.0ms) rollback transaction ActiveRecord::StatementInvalid: SQLite3::SQLException: no such column: student_courses.: UPDATE "student_courses" SET "started" = ? WHERE "student_courses"."" IS NULL from C:/Ruby200-x64/lib/ruby/gems/2.0.0/gems/sqlite3-1.3.9-x64-mingw32/lib/sqlite3/database.rb:91:in `initialize'
So I think this all has to do with not having a primary key in join-table?
But I am not sure enough on how to use it and if that'd represent a good practice for the case I am trying to solve ?
Also, if this is the problem, why then I don't get the same warning here when I save the student after I do
s.student_courses.first.started = true
, as shown in the examples above?
Code
student.rb
class Student < ActiveRecord::Base
has_many :student_courses
has_many :courses, :through => :student_courses
end
course.rb
class Course < ActiveRecord::Base
has_many :student_courses
has_many :students, :through => :student_courses
end
student_course.rb
class StudentCourse < ActiveRecord::Base
belongs_to :course
belongs_to :student
end
schema.rb
ActiveRecord::Schema.define(version: 20141020135702) do
create_table "student_courses", id: false, force: true do |t|
t.integer "course_id", null: false
t.integer "student_id", null: false
t.string "started", limit: 8, default: "pending", null: false
end
add_index "student_courses", ["course_id", "student_id"], name: "by_course_and_student", unique: true
create_table "courses", force: true do |t|
t.string "name", limit: 50, null: false
t.datetime "created_at"
t.datetime "updated_at"
end
create_table "students", force: true do |t|
t.string "name", limit: 50, null: false
t.datetime "created_at"
t.datetime "updated_at"
end
end
create_join_table.rb (migration for join table)
class CreateJoinTable < ActiveRecord::Migration
def change
create_join_table :courses, :students, table_name: :student_courses do |t|
t.index [:course_id, :student_id], :unique => true, :name => 'by_course_and_student'
t.boolean :started, :null => false, :default => false
end
end
end
I think the clue here is in the first line that you posted (represented above). s is an instance of the student and when you call s.save then you're asking the student to save any changes to its attributes. There are not any changes to save, however, because you made a change to an association.
You have a couple of options. If you prefer the direct access approach from your code snippet then the following should work.
Another alternative would be to use the accepts_nested_attributes_for macro to expose the started attribute from the student perspective.
Ok I finally got what was going on here:
If you create a join table in a migration using
#create_join_table
, this method will not create the default primary key called "id" (and not add an index for it) which is what rails does by default when using#create_table
.ActiveRecord needs a primary key to build its queries, because it is the column that it will be used by default when doing things like
Model.find(3)
.Also if you think you can get around this by doing something like
StudentCourse.find_by(:course_id => "1", :student_id => "2").update_attributes(:started => true)
[0] it will still fail, because after the record it's found, AR will still try to update it looking at the "id" of the record it found.Also
StudentCourse.find_by(:course_id => "1", :student_id => "2").started = true
will retrun true but of course it is not saved until you call #save on it. If you assign it to a varrelationship
and then you callrelationship.save
you will see it will fail to save for the above reasons.[0] In the join table I didn't want duplicate records for a "student_id" and "course_id" so in the migration I had explicitely added a unique constraint for them (using unique index).
This led me to think that I did not need anymore a primary key to uniquely identify a record, because I had those two values... I thought that adding an index on them was enough for they to work as a primary key... but it is not. You need to explicitely define a primary-key when you are not using the default "id" one.
Also turns out that Rails does not support composite primary keys and so even if I wanted to add a primary key build on those two values (so making them primary-key and unique-index, like default rails "id" works) it would have not been possible.
A gem for that exists: https://github.com/composite-primary-keys/composite_primary_keys
So, end of the story, the way I fixed it was simply adding
t.column :id, :primary_key
to the migration for the join table creation. Also I could have not created the join table with#create_join_table
but instead using just#create_table
(which would create an "id" automatically").Hope this helps someone else.
Also this answer to another question was very helpful, thank you @Peter Alfvin !
OK, it appears that you don't have a primary key (we are getting confirmation shortly) in your join table. You do need to have a primary key when trying to access the join table.
I would suggest your migration be:
The model definitions look good, so that would be the only change I can see that needs to be made.
After that, doing what you have been doing should work correctly. You would create the join and then access it after the creation. If you want to assign the boolean to true upon creation, you would need to create the record through the
StudentCourse
model with the information you need (student_id, course_id and started = true) instead of through either association.