5

Grails 2.2.0

I am trying to create a custom constraint to force user to have only one master email. Here is the simplified code causing the error:

User domain class

class User {

    static hasMany = [emails: Email]

    static constraints = {
    }
}

Email domain class

class Email {

    static belongsTo = [user: User]
    String emailAddress
    Boolean isMaster

    static constraints = {

        emailAddress unique: ['user']
        isMaster validator: { val, obj ->
            return !val || Email.findByUserAndIsMaster(obj.user, true) == null
        }

    }
}

Integration test

class EmailTests {

    @Before
    void setUp() {

    }

    @After
    void tearDown() {
        // Tear down logic here
    }

    @Test
    void testSomething() {
        def john = (new User(login: 'johnDoe')).save(failOnError: true, flush: true)
        assert (new Email(emailAddress: 'john@gmail.com', user: john, isMaster: true)).save(failOnError: true)
    }
}

Running "grails test-app -integration" will cause:

| Failure: testSomething(webapp.EmailTests)
| org.hibernate.AssertionFailure: null id in webapp.Email entry (don't flush the Session after an exception occurs) at org.grails.datastore.gorm.GormStaticApi$_methodMissing_closure2.doCall(GormStaticApi.groovy:105) at webapp.Email$__clinit__closure1_closure2.doCall(Email.groovy:13) at org.grails.datastore.mapping.engine.event.AbstractPersistenceEventListener.onApplicationEvent(AbstractPersistenceEventListener.java:46) at webapp.EmailTests.testSomething(EmailTests.groovy:21)

If I change the unique constraint to be after the custom constraint the problem will not happen. What is happening here? I want to understand how is the order of the constraints of any relevance here?

To be clear this does NOT cause the problem:

static constraints = {
        isMaster validator: { val, obj ->
            return !val || Email.findByUserAndIsMaster(obj.user, true) == null
        }
        emailAddress unique: ['user']
    }
Charles Wood
  • 864
  • 8
  • 23
Raipe
  • 786
  • 1
  • 9
  • 22
  • Isn't the relation in the test backwards? I think you want to use `john.addToEmails()`. John is the owning side. In your test you use `Email` like it's the owning side :-) – Bart Mar 19 '13 at 07:08
  • Same problem if I do: `void testSomething() { def john = new User(login: 'johnDoe').save(failOnError: true) def email = new Email(emailAddress: 'john@gmail.com', isMaster: true) john.addToEmails(email) assert john.save(failOnError: true) } ` – Raipe Mar 19 '13 at 18:32

1 Answers1

4

I think I figured it out... The one-to-many relationship is broken.

Let me explain

  • The first line in the test creates the User john which is saved and flushed.
  • The second line creates an Email and sets john as the user property.

Once you try to save the Email instance GORM will complain. That is because you assigned john to Email which is the inverse side of the relationship. The owning side isn't aware of this and owns nothing at that point. Simply put. You cannot save and email instance before added to a user.

Here's a test method that should work.

void testSomething() {
   def john = new User(login: 'johnDoe')

   john.addToEmails(new Email(emailAddress: 'john@gmail.com', isMaster: true))
   john.save(flush:true)

   assert false == john.errors.hasErrors()
   assert 1 == john.emails.size()  
}

The addToEmails() method adds the email instance to the collection and sets the user on the inverse side of the relationship. The relationship is now satisfied and saving john should also save all emails.


Another route

Since the problem seems to be the reference to the user instance in the Email validator I though maybe there's another route you could take.

class User {
    static hasOne = [master: Email]
    static hasMany = [emails: Email]
}

This would eliminate the need for the validator in question which makes you Email class depending on a User for validation. You can let the user take responsibility on what e-mail addresses he owns and what rules should be applied. You could add validators to User to verify that you have a master address that is not present in the emails list and also verify if all the addresses assigned are unique. Like for example:

static constraints = {
    master validator: { master, user, errors ->
        if (master.emailAddress in user.emails*.emailAddress) {
            errors.rejectValue('master', 'error.master', 'Master already in e-mails')
            return false
        }
    }

    emails validator: { emails, user, errors ->
        def addresses = emails*.emailAddress
        if (!addresses.equals(emails*.emailAddress.unique())) {
            errors.rejectValue('emails', 'error.emails', 'Non unique e-mail')
            return false
        }
    }
}

I did some tests and they came out fine doing it in this way.

Bart
  • 17,070
  • 5
  • 61
  • 80
  • Makes sense. But I do not understand why I get the 'object references an unsaved transient instance' error as it is the first user&email pair I try to save and should pass the custom constraint. – Raipe Mar 20 '13 at 11:27
  • 1
    I think `Email.findByUserAndIsMaster(obj.user, true)` is the problem. It references to the unsaved transient `user`. I think you maybe need to find an alternative way to enforce your constraint. Maybe wrap the `User.addToEmails` to get past this 'chicken & egg' problem? – Bart Mar 20 '13 at 11:58
  • OK. I was hoping to get all the data model constraints implemented as domain constaints. Seems I was hoping too much. – Raipe Mar 20 '13 at 14:23
  • What do you mean exactly by wrapping the dynamic addTo method? Overwriting the method in User and doing the check in it? How do I call the original addTo method after? I was also thinking could this be achieved by using the domain events (e.g. beforeInsert)? – Raipe Mar 20 '13 at 14:29
  • Never mind what I said about a wrapper. That doesn't compute :-) I updated the post with another route you could take. – Bart Mar 20 '13 at 18:21
  • Thanks for the responses. I somewhat followed the approach route you suggested. But only one list of emails in User domain (no additional master email field) Here is what I ended up using eventually: User domain constraints: `emails validator: { emails, user, errors -> if(emails.grep({ it.isMaster }).size() > 1){ errors.rejectValue('emails', 'error.emails.multipleMaster', 'Only one master allowed') return false } }` It seems to be working fine accordingly to my tests. Thanks! I still try to accomplish the unique emails per user a little differently but I it is outside the scope – Raipe Mar 22 '13 at 12:39