“Rails方式”如何强制执行具有多个但仅有一个当前关联的has_many?

21
我有一个简单的 Rails 应用程序,其中包含项目和阶段模型。一个项目可以拥有多个阶段,但一次只能有一个阶段处于活动状态(即“当前”)。我仍然希望其他阶段可访问,但是当前阶段应该是应用程序的主要锚点。如何实现“有多个但只有一个当前关联”的要求对于处理模型访问、验证以及创建更新的视图/表单方式等方面具有重大影响。因此,问题是:如何在不增加过多复杂性的情况下实现这一目标?主要目标是:简化访问当前阶段 + 确保一次只能存在一个活动阶段。
自然而然地,我自己也有一些想法,并提出了三个选项,我想在这里介绍一下。如果您有任何反馈意见,比如为什么应该选择一个选项而不是另一个选项(或者建议更简单的解决方案),那将不胜感激: 第一种选择:
[Project] has_many :phases
[Project] has_one  :current_phase, :class_name => "Phase", :conditions => { :current => true }

缺点:我有一个嵌套的表单,用于创建项目和相应的阶段。似乎没有简单的方法将其中一个新创建的阶段设置为活动状态。

第二个选项:

[Project] has an attribute "current_phase_id"

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

缺点:与选项1相同,但我有另一个属性和一个belongs_to关联,这似乎很奇怪(为什么一个项目应该属于它的其中一个阶段?)

第三个选项:

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

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

缺点:我必须通过验证确保一次只有一个活动阶段,如果同时创建/编辑多个阶段或在从一个阶段切换到另一个阶段期间,这将是困难的;此外:project.phases.active返回一个数组,如果我没记错的话。

非常感谢您的帮助。谢谢!

更新

添加了奖励以鼓励对该主题提出更多意见。奖励将授予最能解决上述主要目标的解决方案;或者如果没有提到其他解决方案,则授予最好地解释为什么我应该优先考虑给定选项的答案。谢谢!


我更喜欢选项2,虽然看起来很奇怪,但你不需要额外的验证来检查当前唯一性,也不需要更新数据库中的两条记录来更改当前。 - ryaz
感谢您的反馈,@ryaz。我选择了下面edgerunner得到最高票数的答案。更加优雅且易于处理。 - emrass
3个回答

20
为什么不给您的Phase模型添加一个名为activated_at的日期时间列呢?每当您想要激活某个阶段时,将其设置为当前时间。
在任何给定时间,具有最新的activated_at值的阶段就是当前阶段,所以您可以使用@project.phases.order('activated_at DESC').first来获取它。只需将其放在Project中的一个方法中,您就可以得到一个非常简洁的表述:
# in project.rb
def current_phase
  phases.where("activated_at is NOT NULL").order('activated_at DESC').first
end

2
我会稍微改进一下:phases.where("activated_at is NOT NULL").order('activated_at DESC').first - fl00r
1
同意,非常感谢你的贡献,edgerunner。@fl00r:很大的改进 - 我很高兴看到你们在这里的共同努力导致了我现在认为是“最佳实践”的东西 :) - emrass
@fl00r,说得好。您还可以将activated_at列设置为必填列,但是任何一种方法都可以,至少必须执行其中之一。 - edgerunner
2
如果我们一次创建三个新阶段,必填字段将会出现问题。 - fl00r
1
在Rails 6中,我相信你现在可以这样做:phases.where.not(activated_at: nil).order(activated_at: :desc).first - David Gay

4

一个很好的提问。我曾经也遇到了类似的问题。我最终采用了类似于您的选项1,但使用了连接表。

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

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

为了设置新创建的阶段中的一个为活动状态,我在控制器中编写了一些代码,使它们全部处于非活动状态,然后根据传入的参数和一些规则添加一个新的活动阶段,或者选择一个现有的阶段作为活动阶段。虽然这不太美观,但它能够正常工作。我先尝试了选项3,但发现像您描述的那样变得非常混乱。


刚刚重新阅读了我的回答,意识到实际上是你的选项1 - 重写它! - chrispanda
呵呵,谢谢你的想法。虽然我没有考虑在这里使用连接表,但是你的答案肯定有助于可能的解决方案。+1。仍然期待看到其他意见。 - emrass

2

选项1看起来非常本地化。您只需要添加验证以验证是否只有一个带有current标志和project_id的阶段,以及一些JavaScript来控制客户端复选框。

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

所以,您的观点:
<%= 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 %>

还需要一小段javascript代码(实际上是jQuery),用于取消选中当前复选框以外的所有current复选框。

$(document).ready(function(){
  $(".current_phase").click(function(){
    $(".current_phase").not(this).attr('checked', false);
  }
})

非常感谢fl00r - 我很感激你详细回答和提供的JavaScript代码(尽管我会尝试通过单选按钮来解决这个问题 - 但是使用Rails表单助手并不容易...)。我将把正确答案的奖励授予edgerunner,因为他的解决方案简单明了。 - emrass
我同意@edgerunner的解决方案更好。 - fl00r
为了保持一致性和查询速度,我还会在数据库级别上为project_id添加唯一性约束,特别是对于Rails 5,因为默认情况下会为外键创建索引。 - ToTenMilan

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接