Skip to content

Instantly share code, notes, and snippets.

@sscovil
Last active January 2, 2018 19:09
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save sscovil/28ee0a5f6d4fa882c31642201ef179c5 to your computer and use it in GitHub Desktop.
Save sscovil/28ee0a5f6d4fa882c31642201ef179c5 to your computer and use it in GitHub Desktop.
Objection.js base model with code coverage
'use strict';
const Knex = require('knex');
const { knexSnakeCaseMappers, Model } = require('objection');
const uuid = require('uuid');
const knex = Knex({
client: 'pg',
connection: {
host: '127.0.0.1',
user: 'objection',
database: 'objection_test'
},
// Merge `postProcessResponse` and `wrapIdentifier` mappers.
...knexSnakeCaseMappers()
});
Model.knex(knex);
class Base extends Model {
/**
* Called before a model is inserted into the database.
*
* You can return a promise from this function if you need to do asynchronous stuff. You can also throw an exception
* to abort the insert and reject the query. This can be useful if you need to do insert specific validation.
*
* @param {object} queryContext - Context object of the insert query. http://vincit.github.io/objection.js/#context
*/
$beforeInsert() {
// Strip values for immutable properties.
const immutableProperties = this.constructor.immutableProperties;
if (Array.isArray(immutableProperties)) {
immutableProperties.forEach((key) => {
delete this[key];
});
}
// Auto-generate UUID if applicable.
const uuidColumn = this.constructor.uuidColumn;
if (uuidColumn && this.hasProperty(uuidColumn)) {
this[uuidColumn] = uuid.v4();
}
// Set createdAt timestamp if applicable.
if (this.hasProperty('createdAt')) {
this.createdAt = new Date().toISOString();
}
}
/**
* Called before a model is updated.
*
* You can return a promise from this function if you need to do asynchronous stuff. You can also throw an exception
* to abort the update and reject the query. This can be useful if you need to do update specific validation.
*
* This method is also called before a model is patched. Therefore all the model’s properties may not exist. You can
* check if the update operation is a patch by checking the opt.patch boolean.
*
* The opt.old object contains the old values while this contains the updated values. The old values are never
* fetched from the database implicitly. For non-instance queries the opt.old object is undefined.
*
* @param {ModelOptions} opt - Update options.
* @param {object} queryContext - Context object of the update query. http://vincit.github.io/objection.js/#context
*/
$beforeUpdate(opt) {
// Strip values for immutable properties.
const immutableProperties = this.constructor.immutableProperties;
if (Array.isArray(immutableProperties)) {
immutableProperties.forEach((key) => {
delete this[key];
});
}
// Set updatedAt timestamp if applicable.
if (this.hasProperty('updatedAt')) {
this.updatedAt = new Date().toISOString();
}
}
/**
* Check if model jsonSchema contains a given property.
*
* @param {string} property - Name of property to check for.
* @return {boolean}
*/
hasProperty(property) {
const properties = this.constructor.jsonSchema && this.constructor.jsonSchema.properties;
if (!properties) {
return false;
}
return properties.hasOwnProperty(property);
}
/**
* Default sort order used by getPaginated method.
*
* @return {string}
*/
static get defaultOrder() {
return 'ASC';
}
/**
* Default sort property used by getPaginated method.
*
* @return {string}
*/
static get defaultOrderBy() {
return this.idColumn;
}
/**
* Default page size used by getPaginated method.
*
* @return {number}
*/
static get defaultPageSize() {
return 100;
}
/**
* Model properties that should be excluded from inserts and updates.
*
* These properties are deleted from the model $beforeInsert and $beforeUpdate. If the model has a createdAt
* property, it will be set $beforeInsert. If the model has an updatedAt property, it will be set $beforeUpdate.
*
* @return {string[]}
*/
static get immutableProperties() {
return [
this.idColumn,
'createdAt',
'updatedAt'
];
}
/**
* Default page size used by getPaginated method.
*
* @return {number}
*/
static get maxPageSize() {
return 500;
}
/**
* Create a new record.
*
* @param {object} attrs - Model properties based on this.jsonSchema.
* @return {Promise<T>}
*/
static create(attrs) {
return this.query()
.insert(attrs)
.returning('*')
.execute();
}
/**
* Delete a record by ID.
*
* @param {*} id - ID of the record to delete based on this.idColumn.
* @return {Promise<T>}
*/
static delete(id) {
return this
.query()
.delete()
.first()
.where(this.idColumn, id)
.returning('*')
.throwIfNotFound()
.execute();
}
/**
* Fetch a record by ID.
*
* @param {*} id - ID of the record to fetch based on this.idColumn.
* @return {Promise<T>}
*/
static getById(id) {
return this.query()
.findOne('id', id)
.throwIfNotFound()
.execute();
}
/**
* Fetch a paginated set of records.
*
* @param {object} opts - Order and pagination options.
* @return {Promise<T[]>}
*/
static getPaginated(opts) {
const { order, orderBy, page, pageSize } = Object.assign({
order: this.defaultOrder,
orderBy: this.defaultOrderBy,
page: 0,
pageSize: this.defaultPageSize
}, opts);
return this.query()
.page(page, Math.min(pageSize, this.maxPageSize))
.orderBy(orderBy, order)
.execute();
}
/**
* Update a record by ID.
*
* @param {*} id - ID of the record to update based on this.idColumn.
* @param {object} attrs - Model properties based on this.jsonSchema.
* @return {Promise<T>}
*/
static patch(id, attrs) {
return this.query()
.patch(attrs)
.first()
.where(this.idColumn, id)
.returning('*')
.throwIfNotFound()
.execute();
}
}
module.exports = Base;
'use strict';
require('dotenv').config();
const { assert, expect } = require('chai');
const { NotFoundError, ValidationError } = require('objection');
const Base = require('../../src/models/base');
const Promise = require('bluebird');
const random = require('lodash/random');
const sortBy = require('lodash/sortBy');
const uuid = require('uuid');
describe('Base model', function() {
/**
* Test model that extends Base model.
*/
class Person extends Base {
static get tableName() {
return 'test_person';
}
static get jsonSchema () {
return {
type: 'object',
required: ['firstName', 'lastName'],
properties: {
id: {
type: 'integer'
},
uuid: {
type: 'string'
},
firstName: {
type: 'string',
minLength: 1,
maxLength: 36
},
lastName: {
type: 'string',
minLength: 1,
maxLength: 36
},
age: {
type: 'number'
},
address: {
type: 'object',
properties: {
street: {
type: 'string'
},
city: {
type: 'string'
},
postalCode: {
type: 'string'
}
}
},
createdAt: {
format: 'date-time'
},
updatedAt: {
format: 'date-time'
}
}
};
}
}
/**
* Create test_person table before first test runs.
*/
before(function() {
return Base.knex().schema.createTableIfNotExists(Person.tableName, (table) => {
table.increments('id').primary();
table.string('uuid');
table.string('first_name');
table.string('last_name');
table.integer('age');
table.jsonb('address');
table.timestamps(true, false);
});
});
/**
* Drop test_person table after last test runs.
*/
after(function() {
return Base.knex().schema.dropTableIfExists(Person.tableName);
});
/**
* Delete all test_person table rows after each test.
*/
afterEach(function() {
return Person.query().delete().execute();
});
/**
* $beforeInsert (override)
*/
describe('$beforeInsert', function() {
it('should auto-generate UUID if class has uuidColumn value', async function() {
class PersonWithUUID extends Person {
static get uuidColumn() {
return 'uuid';
}
}
const attrs = {
firstName: uuid.v4(),
lastName: uuid.v4(),
age: random(1, 99),
address: {
street: '123 Main Street',
city: 'Springfield',
postalCode: '12345-6789'
}
};
const person = await PersonWithUUID.create(attrs);
expect(person.uuid).to.be.a('string').lengthOf(36);
});
it('should not auto-generate UUID if class does not have uuidColumn value', async function() {
const attrs = {
firstName: uuid.v4(),
lastName: uuid.v4(),
age: random(1, 99),
address: {
street: '123 Main Street',
city: 'Springfield',
postalCode: '12345-6789'
}
};
const person = await Person.create(attrs);
expect(person.uuid).to.equal(null);
});
it('should ignore id value in insert query attributes', async function() {
const attrs = {
id: 99999999,
firstName: uuid.v4(),
lastName: uuid.v4(),
age: random(1, 99),
address: {
street: '123 Main Street',
city: 'Springfield',
postalCode: '12345-6789'
}
};
const person = await Person.query().insert(attrs).returning('*').execute();
expect(person.id).to.be.a('number');
expect(person.id).to.not.equal(attrs.id);
});
it('should ignore createdAt value in insert query attributes', async function() {
const createdAt = new Date(Date.now() - 1000);
const attrs = {
id: 99999999,
firstName: uuid.v4(),
lastName: uuid.v4(),
age: random(1, 99),
address: {
street: '123 Main Street',
city: 'Springfield',
postalCode: '12345-6789'
},
createdAt: createdAt.toISOString()
};
const person = await Person.query().insert(attrs).returning('*').execute();
expect(person.createdAt).to.be.above(createdAt);
});
it('should ignore updatedAt value in insert query attributes', async function() {
const updatedAt = new Date(Date.now() - 1);
const attrs = {
id: 99999999,
firstName: uuid.v4(),
lastName: uuid.v4(),
age: random(1, 99),
address: {
street: '123 Main Street',
city: 'Springfield',
postalCode: '12345-6789'
},
updatedAt: updatedAt.toISOString()
};
const person = await Person.query().insert(attrs).returning('*').execute();
expect(person.updatedAt).to.equal(null);
});
});
/**
* $beforeUpdate (override)
*/
describe('$beforeUpdate', function() {
let person;
/**
* Create record to update before each test runs.
*/
beforeEach(async function() {
const attrs = {
firstName: uuid.v4(),
lastName: uuid.v4(),
age: random(1, 99),
address: {
street: '123 Main Street',
city: 'Springfield',
postalCode: '12345-6789'
}
};
person = await Person.query().insert(attrs).returning('*').execute();
});
context('using update query', function() {
it('should ignore id value in update query attributes', async function() {
const attrs = {
id: 99999999,
firstName: 'changed',
lastName: 'changed',
age: person.age + 1,
address: {
street: 'changed',
city: 'changed',
postalCode: 'changed'
}
};
const updated = await person.$query().update(attrs).returning('*').execute();
expect(updated.id).to.equal(person.id);
expect(updated.id).to.not.equal(attrs.id);
});
it('should ignore createdAt value in update query attributes', async function() {
const createdAt = new Date();
const attrs = {
firstName: 'changed',
lastName: 'changed',
age: person.age + 1,
address: {
street: 'changed',
city: 'changed',
postalCode: 'changed'
},
createdAt: createdAt.toISOString()
};
const updated = await person.$query().update(attrs).returning('*').execute();
expect(updated.createdAt).to.eql(person.createdAt);
expect(updated.createdAt).to.be.below(createdAt);
});
it('should ignore updatedAt value in update query attributes', async function() {
const updatedAt = new Date(Date.now() - 1);
const attrs = {
firstName: 'changed',
lastName: 'changed',
age: person.age + 1,
address: {
street: 'changed',
city: 'changed',
postalCode: 'changed'
},
updatedAt: updatedAt.toISOString()
};
const updated = await person.$query().update(attrs).returning('*').execute();
expect(updated.updatedAt).to.not.equal(null);
expect(updated.updatedAt).to.be.above(updatedAt);
});
});
context('using patch query', function() {
it('should ignore id value in update query attributes', async function() {
const attrs = {
id: 99999999,
firstName: 'changed'
};
const updated = await person.$query().patch(attrs).returning('*').execute();
expect(updated.id).to.equal(person.id);
expect(updated.id).to.not.equal(attrs.id);
});
it('should ignore createdAt value in update query attributes', async function() {
const createdAt = new Date();
const attrs = {
firstName: 'changed',
createdAt: createdAt.toISOString()
};
const updated = await person.$query().patch(attrs).returning('*').execute();
expect(updated.createdAt).to.eql(person.createdAt);
expect(updated.createdAt).to.be.below(createdAt);
});
it('should ignore updatedAt value in update query attributes', async function() {
const updatedAt = new Date(Date.now() - 1);
const attrs = {
firstName: 'changed',
updatedAt: updatedAt.toISOString()
};
const updated = await person.$query().patch(attrs).returning('*').execute();
expect(updated.updatedAt).to.not.equal(null);
expect(updated.updatedAt).to.be.above(updatedAt);
});
});
});
/**
* $validate (override)
*/
describe('$validate', function() {
it('should reject with ValidationError if a required attribute is missing', async function() {
const attrs = {
lastName: uuid.v4(),
age: random(1, 99),
address: {
street: '123 Main Street',
city: 'Springfield',
postalCode: '12345-6789'
}
};
const person = Person.fromJson(attrs, { skipValidation: true });
const $validate = () => person.$validate();
expect($validate).to.throw(ValidationError).satisfy((err) => {
return err.data &&
err.data.firstName &&
Array.isArray(err.data.firstName) &&
err.data.firstName.length === 1 &&
err.data.firstName[0].keyword === 'required';
});
});
it('should reject with ValidationError if attribute with minLength is too short', async function() {
const attrs = {
firstName: '',
lastName: uuid.v4(),
age: random(1, 99),
address: {
street: '123 Main Street',
city: 'Springfield',
postalCode: '12345-6789'
}
};
const person = Person.fromJson(attrs, { skipValidation: true });
const $validate = () => person.$validate();
expect($validate).to.throw(ValidationError).satisfy((err) => {
return err.data &&
err.data.firstName &&
Array.isArray(err.data.firstName) &&
err.data.firstName.length === 1 &&
err.data.firstName[0].keyword === 'minLength';
});
});
it('should reject with ValidationError if attribute with maxLength is too long', async function() {
const attrs = {
firstName: `too-long-${uuid.v4()}`,
lastName: uuid.v4(),
age: random(1, 99),
address: {
street: '123 Main Street',
city: 'Springfield',
postalCode: '12345-6789'
}
};
const person = Person.fromJson(attrs, { skipValidation: true });
const $validate = () => person.$validate();
expect($validate).to.throw(ValidationError).satisfy((err) => {
return err.data &&
err.data.firstName &&
Array.isArray(err.data.firstName) &&
err.data.firstName.length === 1 &&
err.data.firstName[0].keyword === 'maxLength';
});
});
it('should reject with ValidationError if attribute type is incorrect', async function() {
const attrs = {
firstName: uuid.v4(),
lastName: uuid.v4(),
age: 'should be a number',
address: {
street: '123 Main Street',
city: 'Springfield',
postalCode: '12345-6789'
}
};
const person = Person.fromJson(attrs, { skipValidation: true });
const $validate = () => person.$validate();
expect($validate).to.throw(ValidationError).satisfy((err) => {
return err.data &&
err.data.age &&
Array.isArray(err.data.age) &&
err.data.age.length === 1 &&
err.data.age[0].keyword === 'type';
});
});
});
/**
* create
*/
describe('create', function() {
it('should create a new record and resolve with model', async function() {
const attrs = {
firstName: uuid.v4(),
lastName: uuid.v4(),
age: random(1, 99),
address: {
street: '123 Main Street',
city: 'Springfield',
postalCode: '12345-6789'
}
};
const person = await Person.create(attrs);
expect(person).to.be.instanceof(Person);
expect(person.toJSON()).to.deep.include(attrs);
});
});
/**
* delete
*/
describe('.delete', function() {
let person;
/**
* Create record to delete before each test runs.
*/
beforeEach(async function() {
const attrs = {
firstName: uuid.v4(),
lastName: uuid.v4(),
age: random(1, 99),
address: {
street: '123 Main Street',
city: 'Springfield',
postalCode: '12345-6789'
}
};
person = await Person.create(attrs);
});
it('should delete a record and resolve with model', async function() {
const deleted = await Person.delete(person.id);
expect(deleted).to.be.instanceof(Person);
expect(deleted.toJSON()).to.deep.equal(person.toJSON());
});
it('should reject with NotFoundError if record does not exist', async function() {
try {
await Person.delete(0);
assert.fail();
} catch(err) {
expect(err).to.be.instanceof(NotFoundError);
}
});
});
/**
* getById
*/
describe('.getById', function() {
let person;
/**
* Create record to fetch before each test runs.
*/
beforeEach(async function() {
const attrs = {
firstName: uuid.v4(),
lastName: uuid.v4(),
age: random(1, 99),
address: {
street: '123 Main Street',
city: 'Springfield',
postalCode: '12345-6789'
}
};
person = await Person.create(attrs);
});
it('should fetch a record and resolve with model', async function() {
const fetched = await Person.getById(person.id);
expect(fetched).to.be.instanceof(Person);
expect(fetched.toJSON()).to.deep.equal(person.toJSON());
});
it('should reject with NotFoundError if record does not exist', async function() {
try {
await Person.getById(0);
assert.fail();
} catch(err) {
expect(err).to.be.instanceof(NotFoundError);
}
});
});
/**
* getPaginated
*/
describe('.getPaginated', function() {
context('where no records exist', function() {
it('should return an object with an empty results array', async function() {
const fetched = await Person.getPaginated();
expect(fetched).to.be.an('object');
expect(fetched.results).to.be.an('array').lengthOf(0);
expect(fetched.total).to.equal(0);
});
});
context('where multiple records exist', function() {
let people;
/**
* Create record to update before each test runs.
*/
beforeEach(async function() {
const attrs = [];
for (let i = 0; i < 3; i++) {
attrs.push({
firstName: uuid.v4(),
lastName: uuid.v4(),
age: random(1, 99)
});
}
people = await Promise.map(attrs, (_attrs) => Person.create(_attrs));
});
it('should return an object with a results array containing all models', async function() {
const fetched = await Person.getPaginated();
expect(fetched).to.be.an('object');
expect(fetched.results).to.be.an('array').lengthOf(people.length);
expect(fetched.total).to.equal(people.length);
fetched.results.forEach((person) => {
expect(person).to.be.instanceof(Person);
});
});
it('should order results by id (ascending) by default', async function() {
const sorted = sortBy(people, 'id');
const fetched = await Person.getPaginated();
fetched.results.forEach((person, index) => {
expect(person.id).to.equal(sorted[index].id);
});
});
it('should order results based on options when set', async function() {
const sorted = sortBy(people, 'firstName').reverse();
const fetched = await Person.getPaginated({ orderBy: 'firstName', order: 'DESC' });
fetched.results.forEach((person, index) => {
expect(person.id).to.equal(sorted[index].id);
});
});
it('should return paginated results based on options when set', async function() {
const sorted = sortBy(people, 'id');
const fetched = await Person.getPaginated({ page: 2, pageSize: 1 });
expect(fetched.results).to.be.an('array').lengthOf(1);
expect(fetched.results[0].id).to.equal(sorted[2].id);
expect(fetched.total).to.equal(3);
});
});
});
/**
* patch
*/
describe('.patch', function() {
let person;
/**
* Create record to update before each test runs.
*/
beforeEach(async function() {
const attrs = {
firstName: uuid.v4(),
lastName: uuid.v4(),
age: random(1, 99),
address: {
street: '123 Main Street',
city: 'Springfield',
postalCode: '12345-6789'
}
};
person = await Person.create(attrs);
});
it('should patch a record and resolve with model', async function() {
const attrs = {
firstName: 'changed'
};
const updated = await Person.patch(person.id, attrs);
const expected = Object.assign(person.toJSON(), attrs, { updatedAt: updated.updatedAt });
expect(updated).to.be.instanceof(Person);
expect(updated.toJSON()).to.deep.equal(expected);
});
it('should reject with NotFoundError if record does not exist', async function() {
const attrs = {
firstName: 'changed'
};
try {
await Person.patch(0, attrs);
assert.fail();
} catch(err) {
expect(err).to.be.instanceof(NotFoundError);
}
});
});
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment