What is the "rails way" to enforce a has_many but has-only-one-current association?
Asked Answered
U

3

21

I have a simple rails app with models project and phase. A project has many phases, but only on phase can be active (i.e. "current") at a time. I still want the other phases to be accessible, but the current phase should be the main anchor for the application. The decision on how to implement this requirement has major implications on how I handle model access, validations and views / forms for creation update.

So the question is: How do I achieve this "has_many but has-only-one-current association" without adding too much complexity? Main goals being: simplicity in access of current phase + ensuring there cannot be more than 1 active phase at a time.

Naturally, I had some thoughts myself and came up with three options, which I want to present here. Any feedback on why I should choose one option over the other (or suggestion of a simpler solution) would be appreciated:

First Option:

[Project] has_many :phases
[Project] has_one  :current_phase, :class_name => "Phase", :conditions => { :current => true }

Drawback: I have a nested form for creating projects and corresponding phases. There seems to be no easy way to set exactly one of the newly created phases as active

Second Option:

[Project] has an attribute "current_phase_id"

[Project] has_many :phases
[Project] belongs_to phase, :foreign_key => "current_phase_id"

Drawback: same as option 1, but I have another attribute and a belongs_to association, which seems weird (why should a project belong to one of its phases?)

Third Option:

[Phase] has an attribute "active" (boolean)
[Phase] scope :active, :conditions => { :active => true}

# Access to current phase via: project.phases.active

Drawback: I have to ensure via validations that there is only one active phase at a time, which is hard if multiple phases are created / edited at the same time OR during switch from one phase to another; plus: project.phases.active returns an array, if I'm not mistaken

Your help is greatly appreciated. Thanks!

Update

Added a bounty to encourage further opinions on the topic. Bounty will be awarded to the solution which best addresses the main goals expressed above; or if no alternative solution is mentioned, to the answer that best explains why I should favor one of the given options over the other. Thanks!

Uproar answered 11/7, 2011 at 10:28 Comment(2)
I prefer option 2, it looks weird, I agree, but you don't need extra validations to check for uniqueness of current and you don't need to update two records in database to change current.Agora
Thanks for your feedback, @ryaz. I went for the highest-voted answer by edgerunner below. Much more elegant and easy to handle.Uproar
M
20

Why don't you just add a date-time column called activated_at to your Phase model. Then set this to the current time whenever you want to make a phase active.

At any given time, the phase with the latest activated_at value is the current phase so you can just get it with @project.phases.order('activated_at DESC').first. Just wrap this in a method in Project and you have a very concise representation:

# in project.rb
def current_phase
  phases.where("activated_at is NOT NULL").order('activated_at DESC').first
end
Madera answered 18/7, 2011 at 10:40 Comment(5)
I would improve it a little: phases.where("activated_at is NOT NULL").order('activated_at DESC').firstSuch
Second that. Thank you very much, edgerunner. @fl00r: great improvement - I am happy to see how your joint efforts here lead to something I now consider a "best practice" :)Uproar
@fl00r, good point. You could also make activated_at a required column, but either approach stands, and at least one must be done.Madera
required field will mess it if we'll create three new phases at once.Such
In Rails 6, I believe you can now do something like phases.where.not(activated_at: nil).order(activated_at: :desc).firstWhangee
O
4

A well-presented question. I have struggled with something very similar. What I ended up with was similar to your option 1, but using a join table.

class Project < ActiveRecord::Base
has_many :phases, :through=> :project_phase

has_one :active_project_phase, :class_name => 'ProjectPhase'`

To set exactly one of the newly created phases active I have a bit of code in the controller that makes them all inactive and then either adds a new active phase if there are no phases or picks one to make active depending on the parameters passed in and a bunch of rules. It's not pretty, but it works. I did try option 3 first, but found this got very messy for the reasons you describe

Okie answered 11/7, 2011 at 11:8 Comment(2)
just reread my answer and realise it is actually your option 1 - rewriting it!Okie
Hehe, thanks for your thoughts, though. Didn't think of using a join-table here, so your answer definitely contributes to a possible solution. +1 for that. Still looking forward to seeing other opinions.Uproar
S
2

Option 1 looks very native. You need just to add validation to validate if there is only one phase with current flag and project_id and some javascript to control checkboxes on client side.

class Project < AR::Base
  has_many :phases
  has_one  :current_phase, :class_name => "Phase", :conditions => { :current => true }
  accepts_nested_attributes_for :phases, :allow_destroy => true
end

class Phase < AR::Base
  belongs_to :project
  validates :project_id, :uniqueness => {:scope => :current}, :if => proc{ self.current }
end

So, your views:

<%= form_for @project do |f| %>
  ...
  <%= f.fields_for :phases do |phase| %>
    <%= phase.text_field :title %> # or whatever
    <%= phase.check_box :current, :class => "current_phase" %>
  <% end %>
  ...
<% end %>

And small javascript (jQuery actually) to uncheck all current checkboxes but one you clicked.

$(document).ready(function(){
  $(".current_phase").click(function(){
    $(".current_phase").not(this).attr('checked', false);
  }
})
Such answered 17/7, 2011 at 20:2 Comment(3)
Thanks a lot fl00r - I appreciate your effort for the detailed answer and the javascript bit (although I'll try to solve this via radio buttons - which is unfortunately not so easy to do with rails form helpers ...). I'll award the correct answer to edgerunner for the simplicity of his solution.Uproar
I agree that @Madera solution is betterSuch
For consistency and query speed, I would also add a uniqueness constraint at database level for project_id, especially for rails 5, where index is created by default for foreign keyMicky

© 2022 - 2024 — McMap. All rights reserved.