Forum: Ruby on Rails Validation spanning multiple models(tables) - how can this be achieved in Rails?

Announcement (2017-05-07): www.ruby-forum.com is now read-only since I unfortunately do not have the time to support and maintain the forum any more. Please see rubyonrails.org/community and ruby-lang.org/en/community for other Rails- und Ruby-related community platforms.
D5df9fcd7ef4c3c937435d7d6adeab2a?d=identicon&s=25 Greg Hauptmann (Guest)
on 2009-01-13 00:29
(Received via mailing list)
Hi,

QUESTION: How can establishing validation that spans multiple models be
achieved in Rails?  That is in such a fashion that it is not possible
for a
developer to break the validation via using any of the public model
methods
(e.g. update_attribute, save, create etc).

EXAMPLE:
* Concept: MAGAZINE can contain multiple ARTICLES, and an ARTICLE can be
associated with multiple MAGAZINE (i.e. many to many).  Cost of Magazine
=
Sum(Cost of Articles)
* Tables:
   (a) magazines (has "cost" field)
   (b) articles_magazines (to map the many-to-many)
   (c) articles (has "total_cost" field)

BUSINESS RULE to be implemented:  Not possible for a database update
that
would end up with a Magazine's "total_cost" not being equal to the
Sum(associated articles "cost"s)

ISSUES / QUESTIONS:
(1) Assume would not try to implement rules at database constraint
level???
(2) Use of Model "before_create" - but I'm assuming here if the Article
is
generated (Article.new), and then validation occurs, the code has NOT
yet
got to the bit where it updates the Magazine?
(3) Use of "after_create" - Add a check for both Magazine and Article
perhaps here, noting the database record has been created by transaction
NOT
finalised yet.   So would the following be the best way:

-----example-------
class Magazine < ActiveRecord::Base
  after_save :business_rule_validation
  def business_rule_validation
    sum_of_articles = << INSERT code that calculates SUM of Articles
costs
for all articles that are associated with the Magazine >>
    errors.add_to_base("business rules fail") if self.total_cost !=
sum_of_articles
  end
end

class Article < ActiveRecord::Base
  << Add Same Concept as per Magazine >>
end
-----example-------

BUT wouldn't this fail, as it assumes the Article create/update/delete
and
the Magazine create/update/delete is in the SAME transaction no???
Does
this mean you really have to create an overarching facade that handles
creates/updates/deletes for Article/Magazines and somehow hide the
normal
per model save/update/delete???

--
Greg
http://blog.gregnet.org/
5772c599ccab3081e0fffb1d54f3b6de?d=identicon&s=25 Andrew Timberlake (andrewtimberlake)
on 2009-01-13 04:12
(Received via mailing list)
On Tue, Jan 13, 2009 at 1:28 AM, Greg Hauptmann <
greg.hauptmann.ruby@gmail.com> wrote:

> Hi,
>
> QUESTION: How can establishing validation that spans multiple models be
> achieved in Rails?  That is in such a fashion that it is not possible for a
> developer to break the validation via using any of the public model methods
> (e.g. update_attribute, save, create etc).


A developer can always break validation with something like
save_with_validation(false)


>
>
> EXAMPLE:
> * Concept: MAGAZINE can contain multiple ARTICLES, and an ARTICLE can be
> associated with multiple MAGAZINE (i.e. many to many).  Cost of Magazine =
> Sum(Cost of Articles)
> * Tables:
>    (a) magazines (has "cost" field)
>    (b) articles_magazines (to map the many-to-many)
>    (c) articles (has "total_cost" field)


Shouldn't the magazine have the total_cost field, and the article have a
cost field?


> got to the bit where it updates the Magazine?
>     errors.add_to_base("business rules fail") if self.total_cost !=
> sum_of_articles
>   end
> end


The after_save callback is not for validation - see
http://api.rubyonrails.org/classes/ActiveRecord/Ca...
If you must validate use the validate method or and do your calculations
and
validation in there.


> per model save/update/delete???
>
> --
> Greg
> http://blog.gregnet.org/
>
>
>
> >
>
Instead of storing the total cost and doing all this validation, I'd
calculate the magazine total_cost as needed
    class Magazine < ActiveRecord::Base
      def total_cost
        articles.to_a.sum(&:cost)
      end
    end

--
Andrew Timberlake
http://ramblingsonrails.com
http://www.linkedin.com/in/andrewtimberlake

"I have never let my schooling interfere with my education" - Mark Twain
D5df9fcd7ef4c3c937435d7d6adeab2a?d=identicon&s=25 Greg Hauptmann (Guest)
on 2009-01-13 07:16
(Received via mailing list)
yep - the magazine should have the total_cost field (slip when I typed
in
the example).  Also you're last suggestion is good, but I was trying to
construct an easy example where it highlighted the
multiple-model-spanning
validation brick wall I'm at.  So with this in mind, and assuming I use
the
"validate" approach (c.f. after_create hook), I still have the same
question
really?  So an example of the issue is:

i) assuming there is a validation routine in both Magazine and Article
to
check business rules, that is:
   (a) for Magazine: Has to have an associated Article before successful
validation &
   (b) for Article: Has to have an associated Magazine before succesful
validation
ii) issue is that if I create a Magazine, the validation is then hit and
fails because I haven't yet created the Article (i.e. so they don't tie
together)
iii) if I create Article first to cover this then it fails because there
isn't a Magazine yet
iv) I could put the creation in a method like
"create_magazine_article_pair"
and remove these above-mentioned validation checks, however this
wouldn't
then protect against use of base methods (e.g. create/update/delete
would
still be available as part of ActiveRecord)

Is there anyway out of this?  i.e. to end up with a very solid
data-access
layer that doesn't allow for the business rules to be broken?

thanks

On Tue, Jan 13, 2009 at 1:11 PM, Andrew Timberlake <
andrew@andrewtimberlake.com> wrote:

>
>> * Tables:
>>
>> (3) Use of "after_create" - Add a check for both Magazine and Article
>> sum_of_articles
>>
>>
>     class Magazine < ActiveRecord::Base
> "I have never let my schooling interfere with my education" - Mark Twain
>
> >
>


--
Greg
http://blog.gregnet.org/
5772c599ccab3081e0fffb1d54f3b6de?d=identicon&s=25 Andrew Timberlake (andrewtimberlake)
on 2009-01-13 10:13
(Received via mailing list)
On Tue, Jan 13, 2009 at 8:15 AM, Greg Hauptmann <
greg.hauptmann.ruby@gmail.com> wrote:

> validation &
> create/update/delete would still be available as part of ActiveRecord)
>> greg.hauptmann.ruby@gmail.com> wrote:
>> save_with_validation(false)
>>>    (b) articles_magazines (to map the many-to-many)
>>> would end up with a Magazine's "total_cost" not being equal to the
>>> finalised yet.   So would the following be the best way:
>>> end
>>>   << Add Same Concept as per Magazine >>
>>> Greg
>>         articles.to_a.sum(&:cost)
>>
>
Nice challenge, I've never had to do this (and am still not convinced of
why
you would need to - I would usually be happy with something like a
magazine
being created with no articles and then have the articles added later)
but
anyway, here's a solution:

You (can't) do it with the standard association helpers (that I can work
out) but I solved it by creating an add_article method that stores
to-be-saved articles in an array and then checks both that array and the
association on validation. Once the magazine is saved, it takes care of
saving the articles which will validate because they have a magazine.

class Magazine < ActiveRecord::Base
  has_and_belongs_to_many :articles
  after_save :save_articles

  def initialize(*args)
    super(*args)
    @article_array = []
  end

  def total_cost
    articles.to_a.sum(&:cost)
  end

  def add_article(article)
    @article_array << article
  end

private
  def validate
    errors.add(:title, "can't have no articles") if articles.size == 0
&&
@article_array.size == 0
  end

  def save_articles
    @article_array.each do |article|
      article.magazines << self
      article.save!
    end
  end
end

class Article < ActiveRecord::Base
  has_and_belongs_to_many :magazines

  def validate
    errors.add(:title, "can't have no magazines") if magazines.size == 0
  end
end

class MagazineTest < ActiveSupport::TestCase
  test "magazine can't be saved with no articles" do
    assert_raise ActiveRecord::RecordInvalid do
      Magazine.create!(:title => 'Test Magazine')
    end
  end

  test "magazine can be save with articles" do
    magazine = Magazine.new(:title => 'Test Magazine')
    article = Article.new(:title => 'Test Article', :cost => 20)
    magazine.add_article article

    assert_nothing_thrown do
      magazine.save!
    end

    assert !magazine.new_record?
    assert !article.new_record?
  end
end

class ArticleTest < ActiveSupport::TestCase
  test "article can't be saved with no magazines" do
    assert_raise ActiveRecord::RecordInvalid do
      Article.create!(:title => 'Test Article', :cost => 20)
    end
  end
end


--
Andrew Timberlake
http://ramblingsonrails.com
http://www.linkedin.com/in/andrewtimberlake

"I have never let my schooling interfere with my education" - Mark Twain
D5df9fcd7ef4c3c937435d7d6adeab2a?d=identicon&s=25 Greg Hauptmann (Guest)
on 2009-01-13 13:24
(Received via mailing list)
thanks for pondering this one with me Andrew - I'll need to think about
this
tomorrow  :) ,  couple of off-the-cuff comments:
* very neat

* just wondering if this will work for multiple magazines linked to one
article (i.e. many-to-many)

* do you think there's no way to solve this without creating a new
method in
fact (like your "add_article")?

* one thing that this has made me realize is that I was also thinking
of/assuming that my validation checks would be database based (e.g.
search
database to see result), but by working in the object world this helps
remove the inherit database transaction/commits only approach where I
was
getting stuck seeing how to do it

regards
Greg

On Tue, Jan 13, 2009 at 7:12 PM, Andrew Timberlake <
andrew@andrewtimberlake.com> wrote:

>> i) assuming there is a validation routine in both Magazine and Article to
>> iv) I could put the creation in a method like
>> andrew@andrewtimberlake.com> wrote:
>>>
>>>> Sum(Cost of Articles)
>>>>
>>>> got to the bit where it updates the Magazine?
>>>>     errors.add_to_base("business rules fail") if self.total_cost !=
>>>
>>>> per model save/update/delete???
>>> calculate the magazine total_cost as needed
>>>
>>
> to-be-saved articles in an array and then checks both that array and the
>   end
>   def validate
> end
>   test "magazine can't be saved with no articles" do
>     assert_nothing_thrown do
>     assert_raise ActiveRecord::RecordInvalid do
> http://www.linkedin.com/in/andrewtimberlake
>
> "I have never let my schooling interfere with my education" - Mark Twain
>
> >
>


--
Greg
http://blog.gregnet.org/
5772c599ccab3081e0fffb1d54f3b6de?d=identicon&s=25 Andrew Timberlake (andrewtimberlake)
on 2009-01-13 14:12
(Received via mailing list)
Greg

On Tue, Jan 13, 2009 at 2:24 PM, Greg Hauptmann <
greg.hauptmann.ruby@gmail.com> wrote:

> thanks for pondering this one with me Andrew - I'll need to think about
> this tomorrow  :) ,  couple of off-the-cuff comments:
> * very neat
>

Thank you


>
> * just wondering if this will work for multiple magazines linked to one
> article (i.e. many-to-many)
>

It does but I only focussed on the magazine side as with the
magazine/article relationship it's more likely that you'll create a
magazine
and add articles than create an article and add magazines.
You can do it though, you'll just need to implement an add_magazine
method
to the article model
The validations definitely work both ways, check the tests.


>
> * do you think there's no way to solve this without creating a new method
> in fact (like your "add_article")?
>

I played around with the various collection methods but each of them
tries
to save a model at some point where the other isn't saved and then the
validations will fail. Also most of the association methods rely on
there
being an id in the association model (i.e. it must be saved).
My solution doesn't require either model to be saved and handles the
saving
of children when needed.
My solution will also continue to work after creation if you're updating
deleting children etc. E.g. You don't have to use the add_article method
once you have at least one article saved with a link to the magazine.
You could do:
magazine.add_article article
magazine.save!
magazine.articles << Article.new(...)


> * one thing that this has made me realize is that I was also thinking
> of/assuming that my validation checks would be database based (e.g. search
> database to see result), but by working in the object world this helps
> remove the inherit database transaction/commits only approach where I was
> getting stuck seeing how to do it
>

You could deal with it more in the database but then you would lose the
abstraction of the models and you would need to couple more tightly to
the
database.


> regards
> Greg
>


--
Andrew Timberlake
http://ramblingsonrails.com
http://www.linkedin.com/in/andrewtimberlake

"I have never let my schooling interfere with my education" - Mark Twain
D5df9fcd7ef4c3c937435d7d6adeab2a?d=identicon&s=25 Greg Hauptmann (Guest)
on 2009-01-15 01:21
(Received via mailing list)
I was thinking that one generic approach to handle cross model
validations
could be:

[1] VALIDATE AT OBJECT LEVEL: Validate your specific cross model rules
at
the Rails object level (i.e. before a "save" using the Rails validate)
 - have a "model objects array" to add each associated model object that
was
part of the validation

[2] ENSURE ALL MODELS THAT WERE PART OF VALIDATION ARE SAVED: In each
models
"after_save" then check it's list of "model objects array" to ensure
each is
actually saved, then if not either save all, or if there is an issue
then
issue manual Rollback so that all items are rolled back.

Probem: I'm noting that re [1] and validating at the object level, if I
allocate a book to a chapter, whilst the "chapter.book" works, the call
"book_object.chapters" does not work??? Any way around this or is this a
rails thing?   i.e. my concept was in the object world the links should
have
been in place.   Example:
  b = Book.new
  c = Chapter.new
  c.book = b
  c.book ==> works and gives b object
  b.chapters ==> DOES NOT WORK - gives []

tks

On Tue, Jan 13, 2009 at 11:11 PM, Andrew Timberlake <
andrew@andrewtimberlake.com> wrote:

> Thank you
> You can do it though, you'll just need to implement an add_magazine method
> to save a model at some point where the other isn't saved and then the
> magazine.articles << Article.new(...)
> abstraction of the models and you would need to couple more tightly to the
> http://ramblingsonrails.com
> http://www.linkedin.com/in/andrewtimberlake
>
> "I have never let my schooling interfere with my education" - Mark Twain
>
> >
>


--
Greg
http://blog.gregnet.org/
D5df9fcd7ef4c3c937435d7d6adeab2a?d=identicon&s=25 Greg Hauptmann (Guest)
on 2009-01-15 06:47
(Received via mailing list)
PS.  Here's an update where I'm at if anyone whats to comment.  Not
quite
finished however I'm wondering now if I ensure solid validation level
checks
in model validations (e.g. both ends of an association are working,
means
have to set both ends manually) that I could then rely on Rails to
actually
save both ends of an association even if you only save one. (e.g. seems
when
I save book, chapter also gets saved, and vice-versa).  Here's where I'm
at:

----------------------------------------------------------
require File.expand_path(File.dirname(__FILE__) + '/../spec_helper')

# ----------- BOOK ---------------
class Book < ActiveRecord::Base
  has_many :chapters

  def validate
    if self.chapters.length == 0
      errors.add_to_base("Book does not have an associated Chapter")
    end
  end

end

# ----------- CHAPTER ---------------
class Chapter < ActiveRecord::Base
  belongs_to :book

  def validate
    if !self.book
      errors.add_to_base("Chapter does not have an associated Book")
    end
  end

end

# --------- RSPEC (BOOK) ------------
describe Book do
  before(:each) do
    @valid_attributes = {:amount => 100}
    @b = Book.new(:amount => 100)
    @c = Chapter.new(:amount => 100)
  end

  it "should save without error when association is in place" do
    @c.book = @b
    @b.chapters = [@c]
    @b.save!
    @c.save!
  end

  it "should delete without error when both ends of association are
deleted"
do
    @c.book = @b
    @b.chapters = [@c]
    @b.save!
    @c.save!

    @c.destroy
    @b.destroy
  end

  it "should fail for SAVE! if there is no association (OBJECT LEVEL)"
do
    lambda{ @b.save! }.should raise_error
  end

  it "should fail for SAVE! if there is no association (DATABASE LEVEL)"
do
    @c.book = @b
    @b.chapters = [@c]
    lambda{ @b.save! }.should_not raise_error  #on basis that Rails will
save Chapter automatically
  end

  it "should fail for DELETE if one side left open" do
    @c.book = @b
    @b.chapters = [@c]
    @b.save!
    @c.save!

    lambda{ @b.destroy }.should raise_error
  end

end

# ----------RSPEC (CHAPTER) --------------------
describe Chapter do
  before(:each) do
    @valid_attributes = {:amount => 100}
    @b = Book.new(:amount => 100)
    @c = Chapter.new(:amount => 100)
  end

  it "should save without error when association is in place" do
    @c.book = @b
    @b.chapters = [@c]
    @b.save!
    @c.save!
  end

  it "should delete without error when both ends of association are
deleted"
do
    @c.book = @b
    @b.chapters = [@c]
    @b.save!
    @c.save!

    @c.destroy
    @b.destroy
  end

  it "should fail for SAVE! if there is no association (OBJECT LEVEL)"
do
    lambda{ @c.save! }.should raise_error
  end

  it "should fail for SAVE! if there is no association (DATABASE LEVEL)"
do
    @c.book = @b
    @b.chapters = [@c]
    lambda{ @c.save! }.should_not raise_error  # on basis that Rails
will
automatically save Book
  end

  it "should fail for DELETE if one side left open" do
    @c.book = @b
    @b.chapters = [@c]
    @b.save!
    @c.save!

    lambda{ @c.destroy }.should raise_error
  end

end
----------------------------------------------------------

$ ./script/autospec
loading autotest/rails_rspec
/opt/local/bin/ruby
/opt/local/lib/ruby/gems/1.8/gems/rspec-1.1.12/bin/spec
spec/models/all_in_one_test_spec.rb -O spec/spec.opts
F.........

1)
'Chapter should fail for DELETE if one side left open' FAILED
expected Exception but nothing was raised
./spec/models/all_in_one_test_spec.rb:114:

Finished in 0.280484 seconds

10 examples, 1 failure

----------------------------------------------------------



On Thu, Jan 15, 2009 at 10:21 AM, Greg Hauptmann <
greg.hauptmann.ruby@gmail.com> wrote:

> each is actually saved, then if not either save all, or if there is an issue
>   c.book ==> works and gives b object
>> On Tue, Jan 13, 2009 at 2:24 PM, Greg Hauptmann <
>>>
>>
>> My solution doesn't require either model to be saved and handles the
>>> * one thing that this has made me realize is that I was also thinking
>>
>> "I have never let my schooling interfere with my education" - Mark Twain
>
--
Greg
http://blog.gregnet.org/
5772c599ccab3081e0fffb1d54f3b6de?d=identicon&s=25 Andrew Timberlake (andrewtimberlake)
on 2009-01-15 07:21
(Received via mailing list)
On Thu, Jan 15, 2009 at 7:46 AM, Greg Hauptmann <
greg.hauptmann.ruby@gmail.com> wrote:

> # ----------- BOOK ---------------
>
> end
>     @c.book = @b
>     @c.save!
>     @c.book = @b
>
>     @c = Chapter.new(:amount => 100)
> deleted" do
>     lambda{ @c.save! }.should raise_error
>     @c.book = @b
> $ ./script/autospec
> Finished in 0.280484 seconds
>
> 10 examples, 1 failure
>
> ----------------------------------------------------------
>
>
Greg, put in a before_delete callback to see if the deletion would leave
the
one side open and then return false if it would.

--
Andrew Timberlake
http://ramblingsonrails.com
http://www.linkedin.com/in/andrewtimberlake

"I have never let my schooling interfere with my education" - Mark Twain
5772c599ccab3081e0fffb1d54f3b6de?d=identicon&s=25 Andrew Timberlake (andrewtimberlake)
on 2009-01-15 07:22
(Received via mailing list)
On Thu, Jan 15, 2009 at 8:20 AM, Andrew Timberlake <
andrew@andrewtimberlake.com> wrote:

>> ----------------------------------------------------------
>>   end
>>     end
>>   end
>>     @c.book = @b
>>   end
>>     @c.book = @b
>> describe Chapter do
>>     @c.save!
>>     @b.destroy
>>     lambda{ @c.save! }.should_not raise_error  # on basis that Rails will
>>   end
>>
>>
>>
> Greg, put in a before_delete callback to see if the deletion would leave
> the one side open and then return false if it would.
>
>
Sorry, it's before_destroy

--
Andrew Timberlake
http://ramblingsonrails.com
http://www.linkedin.com/in/andrewtimberlake

"I have never let my schooling interfere with my education" - Mark Twain
D5df9fcd7ef4c3c937435d7d6adeab2a?d=identicon&s=25 Greg Hauptmann (Guest)
on 2009-01-15 07:51
(Received via mailing list)
just wondering what I would put in:
(a) before_destroy - then proactively destroy the other associated
object,
BUT if those objects had a before_destory this could become circular no?
(b) after_destroy - test to see if both ends were destroy, but if one
end
tests first prior to the other end being destroyed this would be an
issue
no?


On Thu, Jan 15, 2009 at 4:21 PM, Andrew Timberlake <
andrew@andrewtimberlake.com> wrote:

>>> save both ends of an association even if you only save one. (e.g. seems when
>>>     if self.chapters.length == 0
>>>   def validate
>>>     @valid_attributes = {:amount => 100}
>>>
>>>
>>>   end
>>> end
>>>     @c.book = @b
>>>     @c.save!
>>> do
>>>     @c.save!
>>> /opt/local/lib/ruby/gems/1.8/gems/rspec-1.1.12/bin/spec
>>> 10 examples, 1 failure
>
> --
> Andrew Timberlake
> http://ramblingsonrails.com
> http://www.linkedin.com/in/andrewtimberlake
>
> "I have never let my schooling interfere with my education" - Mark Twain
>
> >
>


--
Greg
http://blog.gregnet.org/
5772c599ccab3081e0fffb1d54f3b6de?d=identicon&s=25 Andrew Timberlake (andrewtimberlake)
on 2009-01-15 09:19
(Received via mailing list)
On Thu, Jan 15, 2009 at 8:51 AM, Greg Hauptmann <
greg.hauptmann.ruby@gmail.com> wrote:

>>>
>>>
>> Sorry, it's before_destroy
>
>
I wouldn't try do a dependant destroy, I'd return false (to stop the
destroy) or raise an error so that the destroy never happens.
If you want to destroy with the dependant you are going to have to put a
special case destroy on one side that will automatically destroy it's
association.

So the one will make sure it can't be destroyed if it will break
referential
integrity, the other side will destroy it's dependent along with itself.

--
Andrew Timberlake
http://ramblingsonrails.com
http://www.linkedin.com/in/andrewtimberlake

"I have never let my schooling interfere with my education" - Mark Twain
This topic is locked and can not be replied to.