Objection.js: Unique Validation

Created on 9 May 2016  Â·  9Comments  Â·  Source: Vincit/objection.js

Perhaps I missed this in the docs, but I didn't see anything about validating uniqueness of a field.

Let's say I have a post model with a slug field, which is unique. How would I handle validation so a useful error message is thrown if a post is inserted with a slug that already exists? $beforeValidate doesn't allow a promise to be returned to do the look up, and inserting blindly results in a generic SQL error being thrown.

Most helpful comment

@vietthang Sorry the code I posted was incomplete, my actual code looks more like this:

$beforeInsert() {
    return this.$beforeSave(true)
}

$beforeUpdate() {
    return this.$beforeSave(false)
}

$beforeSave(inserting) {
    return this.constructor.query().select('id').where('slug', this.slug).first().then(row => {
        if(typeof row === 'object' && row.id) {
            if(inserting || Number.parseInt(row.id) !== Number.parseInt(this.id)) {
                throw new ValidationError({
                    slug: `Slug is already in use by #${row.id}`
                })
            }
        }
    })
}

That said, I haven't run into a scenario where the model I'm updating doesn't have an id. You _could_ add a step that checks if there's an id, and if there's not first look up the id for the slug:

$beforeUpdate() {
    if(this.id) {
        return this.$beforeSave(false)
    }

    return this.constructor.knexQuery().select('id').where('slug', this.slug).then(row => {
        this.id = row.id
        return this.$beforeSave(false)
    }
}

However I wouldn't advise doing this here — you should do it in your route logic. Take this scenario:

PATCH /posts/original-slug
{
   "slug": "new-slug",
   "title": "New Title"
}
PostModel.query().where('slug', 'original-slug').patch({
    slug: 'new-slug',
    title: 'New Title'
})

If you run that, the previous code is actually going to check if new-slug exists, not original-slug and that’s definitely not ideal. Instead, you could do this:

PostModel.query().where('slug', 'original-slug').first().then(post => {
    if(!post) {
        throw Error('Post not found')
    }

    return post.$query().patch({
        slug: 'new-slug',
        title: 'New Title'
    })
})

Now you’ll have a fully formed post object with id, and when you patch, everything should work as expected.

All 9 comments

You can do the lookup in $beforeUpdate and $beforeInsert methods.

knex (that takes care of the database communication) returns the errors thrown by the database clients. Knex has no unified error interface, and I don't want to maintain one. I think this is more a knex issue than objection.js.

I usually map the generic database error into a error result in a request handler middleware. For example postgres errors have some structure that you can use to detect them:

function isPostgresError(error) {
  if (!error) { return false; }
  // Just check the existence of a bunch of attributes. There doesn't seem to be an easier way.
  return _.all(['severity', 'code', 'detail', 'internalQuery', 'routine'], function(attr) {
    return _.has(error, attr);
  });
}
function parseUniqueViolationError(err) {
  // Parse the failed attribute name and value from the error message.
  var attrs = err.detail.match(/\((.+)\)=/);
  var values = err.detail.match(/=\((.+)\)/);

  if (attrs.length !== 0 && values.length !== 0) {
    attrs = attrs[1].split(', ');
    values = values[1].split(', ');

    var data = _.reduce(_.zipObject(attrs, values), function (result, value, attr) {
      while (attr.indexOf('"') !== -1) {
        attr = attr.replace('"', '');
      }
      result[attr] = 'Entity with "' + attr + '"="' + value + '" already exists';
      return result;
    }, {});

    return new ConflictError(data).toJSON();
  }

  return null;
}

Hacky, I know, but this is the best of the bad options.

Thanks, I ended up going with the $beforeInsert/$beforeUpdate approach since we're using different DBs in prod (maria) vs local (sqlite) -- didn't seem prudent to start detecting and parsing that way.

For anyone else who happens to stumble across this, this is what it looks like in practice:

$beforeInsert() {
    return super.$beforeInsert().then(() => {
        return this.constructor.query().select('id').where('slug', this.slug).first().then((row) => {
            if(typeof row === 'object' && row.id) {
                throw new BadRequestError('Slug already exists')
            }
        })
    })
}

Hi @shnhrrsn, your solution works perfectly with insert, but in update, it will fail if the 'slug' you update is same as the 'slug' in database, which is a positive case.

Eg:

model.$query().patch({ slug: model.slug }) // update with same slug

It should update success but fails with your solution.

So I replace this code:

this.constructor.query().select('id').where('slug', this.slug)

to this

this.constructor.query().select('id').where('slug', this.slug).whereNot('id', this.$id())

But then the 'this' object in $beforeUpdate doesn't have id attribute. So I stuck here.

So, how do you handle the update case?

@vietthang Sorry the code I posted was incomplete, my actual code looks more like this:

$beforeInsert() {
    return this.$beforeSave(true)
}

$beforeUpdate() {
    return this.$beforeSave(false)
}

$beforeSave(inserting) {
    return this.constructor.query().select('id').where('slug', this.slug).first().then(row => {
        if(typeof row === 'object' && row.id) {
            if(inserting || Number.parseInt(row.id) !== Number.parseInt(this.id)) {
                throw new ValidationError({
                    slug: `Slug is already in use by #${row.id}`
                })
            }
        }
    })
}

That said, I haven't run into a scenario where the model I'm updating doesn't have an id. You _could_ add a step that checks if there's an id, and if there's not first look up the id for the slug:

$beforeUpdate() {
    if(this.id) {
        return this.$beforeSave(false)
    }

    return this.constructor.knexQuery().select('id').where('slug', this.slug).then(row => {
        this.id = row.id
        return this.$beforeSave(false)
    }
}

However I wouldn't advise doing this here — you should do it in your route logic. Take this scenario:

PATCH /posts/original-slug
{
   "slug": "new-slug",
   "title": "New Title"
}
PostModel.query().where('slug', 'original-slug').patch({
    slug: 'new-slug',
    title: 'New Title'
})

If you run that, the previous code is actually going to check if new-slug exists, not original-slug and that’s definitely not ideal. Instead, you could do this:

PostModel.query().where('slug', 'original-slug').first().then(post => {
    if(!post) {
        throw Error('Post not found')
    }

    return post.$query().patch({
        slug: 'new-slug',
        title: 'New Title'
    })
})

Now you’ll have a fully formed post object with id, and when you patch, everything should work as expected.

First, sorry for the late response. As I understand from investigating the library source code, when you call:

post.$query().patch({
  slug: 'new-slug',
  title: 'New Title'
})

objection will create new model from this object

{
  slug: 'new-slug',
  title: 'New Title'
}

and this model will be used to construct a UpdateOperation and run $beforeUpdate in onBeforeInternal (UpdateOperation.js). The problem is, the model which newly construct only contains slug and title keys and not id key. So the this.id in $beforeUpdate/$beforeSave from your code is always undefined. It is impossible to validate unique key at Model level. I can do it at controller level, but so I have to repeat it in every saving function.

@koskimas @shnhrrsn: please provide some insight about this.

@vietthang in 0.6 you will be able to access the old values from opt.old object. opt is the first argument passed to $beforeUpdate. You can already try this out by installing the newest rc version using npm install objection@next. There are some breaking changes in 0.6 though. Check out the changes in here.

This issue explains how the hooks work. They are a bit complex because objection is more of a query builder than an ORM.

@koskimas thank you for your information. I'm gonna try it soon.

I tried your suggestion and it works. Just curious, as I understand, it won't work with patchAndFetchById right? Can you include the id to update in params as well? Or maybe some way more generic like the query?

Was this page helpful?
0 / 5 - 0 ratings

Related issues

zacharynevin picture zacharynevin  Â·  4Comments

Gustav0ar picture Gustav0ar  Â·  4Comments

mycahjay-nms picture mycahjay-nms  Â·  4Comments

AhmadRaza786 picture AhmadRaza786  Â·  3Comments

sgangwisch picture sgangwisch  Â·  4Comments