Accessing a join-model attribute stored in a join

2019-07-20 16:21发布

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

3条回答
何必那么认真
2楼-- · 2019-07-20 16:55
s = Student.create
c = Course.create(:name => "english")
s.student_courses.first.started = true
s.save

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.

s = Student.create
c = Course.create(:name => 'english')
s.courses << c
s.student_courses.first.update_attributes(:started => true)

Another alternative would be to use the accepts_nested_attributes_for macro to expose the started attribute from the student perspective.

class Student
  has_many :student_courses, :inverse_of => :student
  has_many :courses,         :through => :student_courses

  accepts_nested_attributes_for :student_courses
end

s = Student.create
c = Course.create(:name => 'english')
s.courses << c
s.update_attributes(:student_courses_attributes=>[{:id => 1, :started => true}])
查看更多
时光不老,我们不散
3楼-- · 2019-07-20 17:15

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 var relationship and then you call relationship.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 !

查看更多
叛逆
4楼-- · 2019-07-20 17:17

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:

class CreateStudentCourses < ActiveRecord::Migration
  def change
    create_table :student_courses do |t|
      t.references :course
      t.references :student
      t.boolean :started, default: false

      t.timestamps

      t.index [:student_id, :course_id], :unique => true, :name => 'by_student_and_course'
    end
  end
end

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.

StudentCourse.create(course_id: course.id, student_id: student.id, started: true)
查看更多
登录 后发表回答