9

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, and has an additional attribute called started (set by default on "false").

I also have added an index in the join-table made of student_id and course_id, and set a unique check on that, like this

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

I wanted that to be a composite primary key, but since in rails there are no composite primary keys (without using a gem) I also added a primary key called id:

t.column :id, :primary_key

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.


That said, I am struggling to wrap my mind around association resolutions in ActiveRecord queries. Let me explain this better:

For reference, Student.all, Course.all and StudentCourses.all would return tables like these:

Student.all

+----+-----------+
| id | name      |
+----+-----------+
| 1  | Aidan     |
| 2  | Alison    |
| 3  | Elizabeth |
+----+-----------+

Course.all

+----+----------+------------------+
| id | name     | description      |
+----+----------+------------------+
| 1  | english  | desc. here       |
| 2  | music    | desc. here       |
| 3  | dance    | desc. here       |
| 4  | science  | desc. here       |
| 5  | french   | desc. here       |
| 6  | arts     | desc. here       |
+----+----------+------------------+

StudentCourse.all

+-------------+------------+------------+----+
| course_id   | student_id | started    | id |
+-------------+------------+------------+----+
| 1           | 1          | false      | 1  |
| 2           | 1          | false      | 2  |
| 3           | 1          | false      | 3  |
| 1           | 2          | true       | 4  |
| 2           | 2          | true       | 5  |
| 4           | 3          | false      | 6  |
| 5           | 2          | false      | 7  |
| 5           | 1          | true       | 8  |
| 6           | 2          | false      | 9  |
+-------------+------------+------------+----+


So far I can happily render a json object of all courses, and names of all students for each course like this:

render json: Course.all.to_json(:include => {:students => {:only => :name}})

I can also easily render all courses that a student is attending or about to attend with

render json: @student.courses.to_json(:include => {:students => {:only => :name}})

which also includes other students for those courses.

But suppose I wanted to render one student's courses which the student has not yet started, together with all the other students who are on that course (which have or not started the course) [Please read the UPDATE section below, I'm looking for the opposite thing actually!]

I think the easieast approach is to query the join-table for something like:

StudentCourse.all.where(student_id: @student.id, started: false)

+-------------+------------+------------+----+
| course_id   | student_id | started    | id |
+-------------+------------+------------+----+
| 5           | 2          | false      | 7  |
| 6           | 2          | false      | 9  |
+-------------+------------+------------+----+

But how do I go on from this resulting table (association object) to get a nicely packaged json object with courses names (and all other attributes) and also including students in it, like I did with: Course.all.to_json(:include => {:students => {:only => :name}}) ?

I think I'm missing some basic knowledge of some important key concepts here, but at this point I cannot even indentify them and would greatly appreciate some help... thank you.



Update:

I just realized that the following part is what I was originally trying to do. It's the opposite thing. Among all these details I got lost along the path. I hope that it's ok if I just add it here.

So, given a student (let's call him Aiden), I need to return a JSON object containing only the courses that he is in and that he has started, only when such courses have other students in them who have not started them, and it has to include the names of those students for each course too.

So...

I now have:

aiden_started_courses = Student(1).courses.where(:student_courses => {:started => true } )

which for a student takes all the courses that have a "true" value in the join-table "started" column. (again in the join table each student-course record is "compositely" unique, so there can just be one unique record for a given student_id and course_id).

With the next query, for one of "aiden_started_courses" I can pull off all the relative student-courses associations which have a false value on "started"

aiden_started_courses[0].student_courses.where(:started => false).includes(:student).to_json(:include => :student)

+-------------+------------+------------+----+
| course_id   | student_id | started    | id |
+-------------+------------+------------+----+
| 1           | 2          | false      | 4  |
+-------------+------------+------------+----+
| 1           | 9          | false      | 5  |
+-------------+------------+------------+----+

So here lies the problem: I have managed to get this just for a single course in aiden_started_courses array, but how would I be able to build a query that returns this data for all of Aiden's started courses?

Is it possible to do that in one line? I know I could probably use Ruby enumerator loops but I somewhat feel that I would be kind of breaking some pattern both on a Rails coding convention level and on performance level? (hitting N+1 problem again?) ...


What I could so far:

I came up with this where I find all students who have not started the courses which a given user has started:

Student.includes(:student_courses).
where(:student_courses => { :started => false, :course_id => aiden.courses.where
           (:student_courses => {started: true}).ids  } ) 

or this:

Course.includes(:students).where(:student_courses => {:started => false, 
   :course_id => aiden.courses.where(:student_courses => {:started =>true}).ids })

which finds all the courses that a given student has started if those courses include students who have not started them yet

But what I really need is to get a JSON object like this:

[
    {
        "id": 1,
        "name": "english",
        "students": [
            {"name": "ALison"},
            {"name": "Robert"}]
    },
    {
        "id": 2,
        "name": "music",
        "description": null,
        "students": [
            {"name": "Robert"},
            {"name": "Kate"}]
    }
]


where I can see the courses that a given student is on and has started, but only those in which there are other students that have not yet started it, together with the names of those students...

I'm thinking that probably there is no way how I could get that through a regular AR query, so maybe should a build a JSON manually? But how could I do that?


Thanks in adv. and I apologise for the verbosity.. but hopefully it will help..

Redoman
  • 3,059
  • 3
  • 34
  • 62
  • append `.to_json` to the end: `StudentCourse.all.where(student_id: @student.id, started: false).to_json`. – Aleksei Matiushkin Nov 07 '14 at 07:57
  • This doesn't answer my question. I got to this (exact) point already. I know that adding "to_json" renders JSON... But what I want to render is not just that! Please read the last part of the question more carefully. – Redoman Nov 07 '14 at 08:27
  • Aye, sorry. To get the student/courses automatically populated you should specify `:belongs_to` and `:has_many` attributes on respective `ActiveRecord`s as specified here: http://api.rubyonrails.org/classes/ActiveRecord/Associations/ClassMethods.html – Aleksei Matiushkin Nov 07 '14 at 08:36
  • 1
    This has been done already! I say this on first line! "I have a many-to-many relationship between Student and Course"... which implies `has_many ... :through ...`s and `belongs_to`s in models. Please would you just read the questions with more attention, if you really have to comment it? Thank you. – Redoman Nov 07 '14 at 09:11

2 Answers2

9

Use scope to your advantage:

class Course < ActiveRecord::Base
  # ...
  scope :not_started, -> { joins(:student_courses) \
                                      .where(student_courses: {started: false}) }

  scope :with_classmates, -> { includes(:students) } # use eager loading
end

Then call:

@student.courses.not_started.with_classmates \
                                     .to_json(include: {students: {only: :name}})

Output:

[
    {
        "id": 1,
        "name": "english",
        "description": null,
        "students": [
            {"name": "Aiden"},
            {"name": "Alison"}]},
    {
        "id": 2,
        "name": "music",
        "description": null,
        "students": [
            {"name": "Aiden"},
            {"name": "Alison"}]},
    {
        "id": 3,
        "name": "dance",
        "description": null,
        "students": [
            {"name": "Aiden"}]}]
Substantial
  • 6,684
  • 2
  • 31
  • 40
  • Thanks! You gave me also info on scopes which I appreciate a lot! But there is something I don't get: why do I need to call #includes(:students) once and then I need to call it again on the #to_json method? I noticed that if I don't call it on the AR query itself, but I keep :include => students in the to_json call, it will still include the students. Is this because with the first approach the DB is queried for students and then this data is available to to_json, while in the second approach to_json will issue the query for students? How these 2 ways differ in querying and filtering students? – Redoman Nov 07 '14 at 21:09
  • 1
    It avoids N+1 queries when `to_json` hits the database for [each course](http://i.stack.imgur.com/WQjxM.png). Using `includes` will eager load all associated students [at once](http://i.stack.imgur.com/2r6c4.png) and `to_json` will simply pull from that result set. For example, gathering students from 20 courses will now take 3 queries instead of 21 queries. – Substantial Nov 07 '14 at 22:54
  • I apologize for having to ask again, but could you take a look at the question update? I finally understood what I was originally trying to do.. thanks.. – Redoman Nov 10 '14 at 05:16
5

Use JBuilder, it comes by default with Rails. Ignore the lines starting with '#':

Jbuilder.new do |j|

    # courses: [{
    j.courses <student.courses - replace this with whatever variable> do |course|

      # id: <course.id>
      j.id course.id 

      # name: <course.name>
      j.name course.name

      # students: [{
      j.students <course.students - replace with whatever variable> do |student|

          # name: <student.name>
          j.name student.name

      end
      # }]

   end
   # }]

end

Not a lot of code. Removing the comments and simplifying some features, it will look like:

student_courses = <...blah...>
json = Jbuilder.new do |j|
  j.courses student_courses do |course|
    j.(course, :id, :name)
    j.students <course.students - whatever method here>, :name
  end
end.target!

Check out their guide, its pretty awesome to generate JSON in plain Ruby DSL. So go ahead and use whatever ruby code you want to fetch students/courses/studentcourses.

Subhas
  • 14,290
  • 1
  • 29
  • 37
  • Thanks, upvoting. I will look into JBuilder and I think it's a great suggestion, but since I'm just learning, I would also like to know how to do what I am trying to do the "basic" way first, so to get a deeper understanding of the involved parts and the associated coding patterns. – Redoman Nov 12 '14 at 00:59
  • Okay, thanks. If you simply want to call `.to_json` and get the structure you're looking for, its not optimal, because you'll first have to build that complete object with all nested structures. The recommended way for complex JSON is to build it on-the-fly, without having to generate dozens of nested objects. – Subhas Nov 12 '14 at 06:20
  • So are you saying that my attempt at getting as much info as I can about that object by the means of a single AR query rather than iterating multiple times via Ruby block loops would not be a good thing ? – Redoman Nov 13 '14 at 02:01
  • 1
    Pretty much. AR, or any ORM for that matter, are not meant to be **100%** DB abstraction layers. Your usecase is complex enough that you'll have to *fight* against AR to do it in one query. Try doing the same in Java Hibernate and you'll have the same problem. Use nested relationships and scopes to achieve what you want (`course.each { |course| course.students.not_started.each { |student| ...`). Rails also gives you enough Caching mechanisms (including russian doll caching), so if performance is an issue, cache the results, or call `ActiveRecord::Base.connection.execute ...` and use plain SQL. – Subhas Nov 15 '14 at 12:33