Grails
  1. Grails
  2. GRAILS-6206

Persisting a List hasMany association fails in some cases if the associated class belongsTo the other class

    Details

    • Type: Bug Bug
    • Status: Open
    • Priority: Major Major
    • Resolution: Unresolved
    • Affects Version/s: 1.2.1
    • Fix Version/s: None
    • Component/s: None
    • Labels:
      None
    • Environment:
      OSX 10.5.8
    • Testcase included:
      yes

      Description

      Persisting a List association does not function correctly in certain cases where the associated class belongsTo the other class. This appears to happen most noticeably if you call removeFrom* on the list and then add new elements to it before saving. Here is are some example domain classes which illustrat the problem:

      Author.groovy
      package com.example.project
      
      class Author {
        List books = []
        List novels = []
        static hasMany = [books:Book, novels:Novel]
      }
      
      Book.groovy
      package com.example.project
      
      class Book {
        String title
        static belongsTo = [author:Author]
      }
      
      Novel.groovy
      package com.example.project
      
      class Novel {
        String title
      }
      

      Book and Novel are identical, save that Book belongsTo Author, and Novel does not. This changes the database schema such that Book stores the list index and foreign key to Author within the Book table, but an author_novel join table is created to store that information for Novel. The schema looks like this:

      schemaExport.sql
      create table author (id bigint generated by default as identity (start with 1), version bigint not null, primary key (id));
      create table author_novel (author_novels_id bigint, novel_id bigint, novels_idx integer);
      create table book (id bigint generated by default as identity (start with 1), version bigint not null, author_id bigint not null, title varchar(255) not null, books_idx integer, primary key (id));
      create table novel (id bigint generated by default as identity (start with 1), version bigint not null, title varchar(255) not null, primary key (id));
      alter table author_novel add constraint FK25714688A3C0D6D4 foreign key (novel_id) references novel;
      alter table book add constraint FK2E3AE941D0F680 foreign key (author_id) references author;
      

      To illustrate the difference in behavior between the two domain classes, I have created a few integration tests:

      DomainTests.groovy
      package com.example.project;
      
      public class DomainTests extends GroovyTestCase{
        void testBehaviorDifferences(){
          Author author = new Author().save(flush:true)
          author.books = [new Book(title:"test1"), new Book(title:"test2"), new Book(title:"test3")]
          author.novels = [new Novel(title:"test1"), new Novel(title:"test2"), new Novel(title:"test3")]
      
          author.save(flush:true)
          author.refresh()
      
          assert author.books.size() == 3
          assert author.novels.size() == 3
      
          author.removeFromBooks(author.books[0])  //will NOT actually delete the book; book 1 will still have author 1 in its author_id field!
          author.removeFromNovels(author.novels[0])
      
          author.save(flush:true)
          author.refresh()
      
          assert author.books.size() == 2
          assert author.novels.size() == 2
      
          def books = Book.list()
          books.each{ book ->
            author.removeFromBooks(book) //will NOT actually remove them from the database on save
          }
      
          def novels = Novel.list()
          novels.each{ novel ->
            author.removeFromNovels(novel)
          }
          assert author.books.size() == 0
          assert author.novels.size() == 0
      
          author.addToBooks(new Book(title:"new book"))
          author.addToNovels(new Novel(title:"new novel"))
      
          assert author.books.size() == 1
          assert author.novels.size() == 1
      
          author.save(flush:true)
          author.refresh()
      
          assert author.novels[0].title == "new novel"
          assert author.books[0].title == "new book"
      
          assert author.novels.size() == 1
          assert author.books.size() == 1 //fails, still has test3 in index 1
      
        }
        void testRemoveFrom(){
          Author author = new Author().save(flush:true)
          author.books = [new Book(title:"test1"), new Book(title:"test2"), new Book(title:"test3")]
      
          author.save(flush:true)
          author.refresh()
      
          assert author.books.size() == 3
      
          author.removeFromBooks(author.books[0])
      
          author.save(flush:true)
          author.refresh()
      
          assert author.books.size() == 2 //the book appears to have been removed, but..
      
          Book.list().each{b->
            b.refresh()
            if(b.author == author){
              assert author.books.contains(b) //fails! test1.author is still set
            }
          }
        }
      }
      

      Both seem to behave identically when persisting except when a new instance is added at the same time as old instances are removed. Novel functions correctly- after saving, only the newly added novel "new novel" is present in author.novels. Book, however does not work as desired- instead of removing the existing instances and creating a new one, it only creates the new instance. The old books, which were removed via removeFromBooks prior to the creation of the new instance, remain in the unchanged in database. This also results in a data integrity issue: both "new book" AND "test2" belongTo author 1 with books_idx = 0.

      Further, it seems that calling removeFrom* does not actually delete records from the db in any case, nor does it update both sides of the relationship: even though author.books is updated correctly in the second test, the book that was removed still references author.

        Activity

        Hide
        Victor Benarbia added a comment -

        I've checked with grails 1.3.6. The issue is still there.
        Documentation is also not clear.

        Show
        Victor Benarbia added a comment - I've checked with grails 1.3.6. The issue is still there. Documentation is also not clear.
        Hide
        Graeme Rocher added a comment -

        The solution here is to use a different join table for each association

        Show
        Graeme Rocher added a comment - The solution here is to use a different join table for each association

          People

          • Assignee:
            Unassigned
            Reporter:
            Ellery Crane
          • Votes:
            4 Vote for this issue
            Watchers:
            5 Start watching this issue

            Dates

            • Created:
              Updated:
              Last Reviewed:

              Development