Ruby on Rails Wednesday, April 30, 2014



On Tuesday, 29 April 2014 03:50:39 UTC-4, ngo...@googlemail.com wrote:
Hi,

On Monday, April 28, 2014 9:31:06 PM UTC+2, Matt Jones wrote:

This doesn't make any sense to me - if I request a ReportsSubject object from the database directly (via `find`, for instance), what do I get if I ask for its subject? What would the reports_subjects table even *store*? A bare `subject_id` would be insufficient since without a class_name it's unclear what table that ID refers to.

A "report subject" (a row in the join table) doesn't carry any semantics without the report, which has further information about required details. A report also only ever has a single type of subject, so IMO it would not make sense to store a couple thousand repetitions of an STI name (although note that the subjects are not all part of a single STI hierarchy!) in a column that adds no information at all to the system.
 
And that's not even considering what should happen when this sort of code runs:

Report.joins(:subjects).where(name: 'hey wait WHAT TABLE IS THIS EVEN QUERYING')

This is not functionally different from having the polymorphic subject type specified in the join table.

There are a bunch of ways out of this situation, but the right choice depends on what exactly you need to do with the models. Starting from this use case, I'm going to discuss some tradeoffs and possible approaches. The code is more for illustration than anything else; details may be missing, etc.

  class Report < ActiveRecord::Base
    has_many :reports_subjects
    has_many :subjects, through: :reports_subjects, class_name: ->(report) { report.subjects_type }

    def subjects_type
      # divine required subject model class somehow
    end
  end

  class ReportsSubject < ActiveRecord::Base
    belongs_to :report
    belongs_to :subject # DOESN'T EXIST, class_name is based on parent Report instance
  end

Approaches:

* STI for subjects, with a validation to ensure all the subjects are the same type. I assume this is impractical in your use case. But if it works, it's ideal - eager-loading works, adding subjects with `@report.subjects << subject` works, joins work.

None of the approaches below will work with eager-loading.

* If the set of possible subject types is known to Report (likely, since it's deciding which one to use) a polymorphic association + some metaprogramming could work:

  class Report < ActiveRecord::Base
    POSSIBLE_TYPES = %w(Foo Bar Baz)
    has_many :reports_subjects

    POSSIBLE_TYPES.each do |type|
      has_many "#{type.underscore}_subjects", through: :reports_subjects, source: :subject, source_type: type
    end

    def subjects(reload=false)
      send("#{subjects_type.underscore}_subjects", reload)
    end

    def subjects_type
      # divine required subject model class somehow
      # return a *string* from POSSIBLE_TYPES
    end
  end

  class ReportsSubject < ActiveRecord::Base
    belongs_to :report
    belongs_to :subject, polymorphic: true
  end

This gives a "faux" association `subjects` that behaves mostly like a real association:

  @report.subjects.to_a            # works
  @report.subjects << new_subject  # works, raises if you try to push an object that isn't of subjects_type
  @report.subjects.count           # works

but not quite:

  Report.joins(:subjects)         # unknown association 'subjects'

This approach does require a `subject_type` column on reports_subjects. Worries about the performance impact of storing duplicate string values many times is (IMO) premature optimization, but your use case may be different.

* If a `subject_type` column is unfeasible for reasons, there's another way which drops more ActiveRecord machinery.

  class Report < ActiveRecord::Base

    has_many :reports_subjects

    def subjects
      subjects_type.joins(:reports_subject).where(reports_subject: { report_id: id })
    end

    def subjects_type
      # divine required subject model class somehow
      # prefer to return a real Class object here
    end
  end

  class ReportsSubject < ActiveRecord::Base
    belongs_to :report

    def subject
      report.subjects_type.find(subject_id)
    end
  end

  class SomeSubjectThingy < ActiveRecord::Base
    # this is needed in any class that Report can refer to; consider making it a concern
    has_many :reports_subjects, foreign_key: :subject_id
  end

You'll need to supply a method to actually build ReportsSubject objects for a given report, as << will not work on a report's `subjects` property. You'll also likely want to guard against inserting subjects of multiple types, as `ReportsSubject` no longer contains any information regarding what type its subject is.

Some even farther-afield approaches to think about:

* if a Report can only have one subject_type, are all Reports really instances of the same class? Perhaps Report is just a base class for more specific types (FooReport, etc) that have specific associations?

  class FooReport < Report
    has_many :foo_reports_subjects
    has_many :subjects, through: :foo_reports_subjects
  end

  class FooReportsSubject < ActiveRecord::Base # or ReportsSubject, YMMV
    self.table_name = 'reports_subjects'

    belongs_to :report, class_name: 'FooReport'
    belongs_to :subject, class_name: 'Foo'
  end

Given how similar these classes are, it might make sense to metaprogram them. Note that `reports_subjects` does *not* need a `type` column here.

* if you need eager-loading but don't want to do STI subjects, maybe consider a "wrapper" object that lets you eager-load what you want. For instance, suppose that you'd like to get the name of each subject (for UI, etc) when loading a bunch of reports.

  class Report < ActiveRecord::Base
    POSSIBLE_TYPES = %w(Foo Bar Baz)
    has_many :reports_subjects
    has_many :subject_shims, through: :reports_subjects

    def title
      "Report on #{subject_shims.map(&:name).join(', ')}"
    end

    # deal with :subjects somehow
  end

  class ReportsSubject < ActiveRecord::Base
    belongs_to :report
    belongs_to :subject_shim
  end

  class SubjectShim < ActiveRecord::Base
    # denormalize the `name` value from subject into subject_shims `name` column
    has_many :reports_subjects
    belongs_to :subject, polymorphic: true
  end

Then you can still do this:

  Report.includes(:subject_shims).each { |x| puts x.title }

without causing a SQL query per-Report. There are fewer `type` columns here (one per subject, instead of one per subject per report) but an extra database table.

---

Happy to help out if any of these look promising.

--Matt Jones

--
You received this message because you are subscribed to the Google Groups "Ruby on Rails: Talk" group.
To unsubscribe from this group and stop receiving emails from it, send an email to rubyonrails-talk+unsubscribe@googlegroups.com.
To post to this group, send email to rubyonrails-talk@googlegroups.com.
To view this discussion on the web visit https://groups.google.com/d/msgid/rubyonrails-talk/71067b1b-42e9-4ae2-ac2d-ec8318807077%40googlegroups.com.
For more options, visit https://groups.google.com/d/optout.

No comments:

Post a Comment