From eed5368339e520e88d65fb5d8d5b5ffff912a70d Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Mon, 26 Jun 2017 22:16:19 +0530 Subject: [PATCH] feat(relations): add basic support for hasMany --- src/Lucid/Model/index.js | 44 ++- src/Lucid/Relations/HasMany.js | 83 ++++ src/Lucid/Serializers/Collection.js | 4 + test/unit/helpers/index.js | 8 + test/unit/lucid-has-many.spec.js | 563 ++++++++++++++++++++++++++++ 5 files changed, 684 insertions(+), 18 deletions(-) diff --git a/src/Lucid/Model/index.js b/src/Lucid/Model/index.js index 88e4e670..1b27ff07 100644 --- a/src/Lucid/Model/index.js +++ b/src/Lucid/Model/index.js @@ -903,23 +903,20 @@ class Model { if (this.$relations[key]) { throw new Error('Trying to reset twice') } + this.$relations[key] = value - /** - * If related value exists, then see if it's an array - * or not. Each model instance whether inside an array - * or not should have a parent. - * - * HOPE MAKES SENSE :) - * - * If not, then `hasOne` set the value to a model instances - * and `hasMany` set it to a collection of instances. - */ - if (_.size(value)) { - const arrayify = _.isArray(value) ? value : [value] - _(arrayify).filter((val) => !!val).each((val) => val.$parent = this.constructor.name) + if (!value) { + return } - this.$relations[key] = value + if (value instanceof Model) { + value.$parent = this.constructor.name + return + } + + if (value.rows) { + _(value.rows).filter((val) => !!val).each((val) => val.$parent = this.constructor.name) + } } /** @@ -972,13 +969,13 @@ class Model { } /** - * Returns an instance of hasOne relation. + * Returns an instance of @ref('HasOne') relation. * * @method hasOne * - * @param {String} relatedModel - * @param {String} primaryKey - * @param {String} foreignKey + * @param {String|Class} relatedModel + * @param {String} primaryKey + * @param {String} foreignKey * * @return {HasOne} */ @@ -986,6 +983,17 @@ class Model { return new HasOne(this, relatedModel, primaryKey, foreignKey) } + /** + * Returns an instance of @ref('HasMany') relation + * + * @method hasMany + * + * @param {String|Class} relatedModel + * @param {String} primaryKey + * @param {String} foreignKey + * + * @return {HasMany} + */ hasMany (relatedModel, primaryKey = this.constructor.primaryKey, foreignKey = this.constructor.foreignKey) { return new HasMany(this, relatedModel, primaryKey, foreignKey) } diff --git a/src/Lucid/Relations/HasMany.js b/src/Lucid/Relations/HasMany.js index 7b99deb4..56765388 100644 --- a/src/Lucid/Relations/HasMany.js +++ b/src/Lucid/Relations/HasMany.js @@ -9,10 +9,93 @@ * file that was distributed with this source code. */ +const _ = require('lodash') const BaseRelation = require('./BaseRelation') const CE = require('../../Exceptions') class HasMany extends BaseRelation { + /** + * Load a single relationship from parent to child + * model, but only for one row. + * + * @method load + * + * @param {String|Number} value + * + * @return {Model} + */ + load () { + return this.relatedQuery.where(this.foreignKey, this.$primaryKeyValue).fetch() + } + + /** + * Returns an array of values to be used for running + * whereIn query when eagerloading relationships. + * + * @method mapValues + * + * @param {Array} modelInstances - An array of model instances + * + * @return {Array} + */ + mapValues (modelInstances) { + return _.map(modelInstances, (modelInstance) => modelInstance[this.primaryKey]) + } + + /** + * Takes an array of related instances and returns an array + * for each parent record. + * + * @method group + * + * @param {Array} relatedInstances + * + * @return {Object} @multiple([key=String, values=Array, defaultValue=Null]) + */ + group (relatedInstances) { + const Serializer = this.relatedModel.serializer + + const transformedValues = _.transform(relatedInstances, (result, relatedInstance) => { + const foreignKeyValue = relatedInstance[this.foreignKey] + const existingRelation = _.find(result, (row) => row.identity === foreignKeyValue) + + /** + * If there is already an existing instance for same parent + * record. We should override the value and do WARN the + * user since hasOne should never have multiple + * related instance. + */ + if (existingRelation) { + existingRelation.value.addRow(relatedInstance) + return result + } + + result.push({ + identity: foreignKeyValue, + value: new Serializer([relatedInstance]) + }) + return result + }, []) + + return { key: this.primaryKey, values: transformedValues, defaultValue: new Serializer([]) } + } + + /** + * Returns the related where query + * + * @method relatedWhere + * + * @param {Boolean} count + * + * @return {Object} + */ + relatedWhere (count) { + this.relatedQuery.whereRaw(`${this.$primaryTable}.${this.primaryKey} = ${this.$foriegnTable}.${this.foreignKey}`) + if (count) { + this.relatedQuery.count('*') + } + return this.relatedQuery.query + } } module.exports = HasMany diff --git a/src/Lucid/Serializers/Collection.js b/src/Lucid/Serializers/Collection.js index 3f0866e4..028bf507 100644 --- a/src/Lucid/Serializers/Collection.js +++ b/src/Lucid/Serializers/Collection.js @@ -28,6 +28,10 @@ class Collection { return json } + addRow (row) { + this.rows.push(row) + } + first () { return _.first(this.rows) } diff --git a/test/unit/helpers/index.js b/test/unit/helpers/index.js index 84c6b4e7..5a80296d 100644 --- a/test/unit/helpers/index.js +++ b/test/unit/helpers/index.js @@ -79,6 +79,13 @@ module.exports = { table.timestamps() table.timestamp('deleted_at').nullable() }), + db.schema.createTable('parts', function (table) { + table.increments() + table.integer('car_id') + table.string('part_name') + table.timestamps() + table.timestamp('deleted_at').nullable() + }), db.schema.createTable('profiles', function (table) { table.increments() table.integer('user_id') @@ -114,6 +121,7 @@ module.exports = { return Promise.all([ db.schema.dropTable('users'), db.schema.dropTable('cars'), + db.schema.dropTable('parts'), db.schema.dropTable('profiles'), db.schema.dropTable('pictures'), db.schema.dropTable('identities'), diff --git a/test/unit/lucid-has-many.spec.js b/test/unit/lucid-has-many.spec.js index 57e87ce7..593d6c9c 100644 --- a/test/unit/lucid-has-many.spec.js +++ b/test/unit/lucid-has-many.spec.js @@ -39,6 +39,7 @@ test.group('Relations | Has Many', (group) => { group.afterEach(async () => { await ioc.use('Adonis/Src/Database').table('users').truncate() await ioc.use('Adonis/Src/Database').table('cars').truncate() + await ioc.use('Adonis/Src/Database').table('parts').truncate() }) group.after(async () => { @@ -105,4 +106,566 @@ test.group('Relations | Has Many', (group) => { assert.equal(carQuery.sql, helpers.formatQuery('select * from "cars" where "user_id" = ? limit ?')) assert.deepEqual(carQuery.bindings, helpers.formatBindings([1, 1])) }) + + test('eagerload relation', async (assert) => { + class Car extends Model { + } + + class User extends Model { + cars () { + return this.hasMany(Car) + } + } + + Car._bootIfNotBooted() + User._bootIfNotBooted() + + let carQuery = null + Car.onQuery((query) => carQuery = query) + + await ioc.use('Database').table('users').insert({ username: 'virk' }) + await ioc.use('Database').table('cars').insert([ + { user_id: 1, name: 'merc', model: '1990' }, + { user_id: 1, name: 'audi', model: '2001' } + ]) + + const user = await User.query().with('cars').first() + assert.instanceOf(user.getRelated('cars'), CollectionSerializer) + assert.equal(user.getRelated('cars').size(), 2) + assert.deepEqual(user.getRelated('cars').rows.map((car) => car.$parent), ['User', 'User']) + assert.equal(carQuery.sql, helpers.formatQuery('select * from "cars" where "user_id" = ?')) + assert.deepEqual(carQuery.bindings, helpers.formatBindings([1])) + }) + + test('add constraints when eagerloading', async (assert) => { + class Car extends Model { + } + + class User extends Model { + cars () { + return this.hasMany(Car) + } + } + + Car._bootIfNotBooted() + User._bootIfNotBooted() + + let carQuery = null + Car.onQuery((query) => carQuery = query) + + await ioc.use('Database').table('users').insert({ username: 'virk' }) + await ioc.use('Database').table('cars').insert([ + { user_id: 1, name: 'merc', model: '1990' }, + { user_id: 1, name: 'audi', model: '2001' } + ]) + + const users = await User.query().with('cars', (builder) => { + builder.where('model', '>', '2000') + }).fetch() + const user = users.first() + assert.equal(user.getRelated('cars').size(), 1) + assert.equal(user.getRelated('cars').rows[0].name, 'audi') + assert.equal(carQuery.sql, helpers.formatQuery('select * from "cars" where "model" > ? and "user_id" in (?)')) + assert.deepEqual(carQuery.bindings, helpers.formatBindings(['2000', 1])) + }) + + test('return serailizer instance when nothing exists', async (assert) => { + class Car extends Model { + } + + class User extends Model { + cars () { + return this.hasMany(Car) + } + } + + Car._bootIfNotBooted() + User._bootIfNotBooted() + + let carQuery = null + Car.onQuery((query) => carQuery = query) + + await ioc.use('Database').table('users').insert({ username: 'virk' }) + const users = await User.query().with('cars').fetch() + const user = users.first() + assert.equal(user.getRelated('cars').size(), 0) + assert.equal(carQuery.sql, helpers.formatQuery('select * from "cars" where "user_id" in (?)')) + assert.deepEqual(carQuery.bindings, helpers.formatBindings([1])) + }) + + test('calling toJSON should build right json structure', async (assert) => { + class Car extends Model { + } + + class User extends Model { + cars () { + return this.hasMany(Car) + } + } + + Car._bootIfNotBooted() + User._bootIfNotBooted() + + let carQuery = null + Car.onQuery((query) => carQuery = query) + + await ioc.use('Database').table('users').insert([{ username: 'virk' }, { username: 'nikk' }]) + await ioc.use('Database').table('cars').insert([ + { user_id: 1, name: 'merc', model: '1990' }, + { user_id: 2, name: 'audi', model: '2001' } + ]) + + const users = await User.query().with('cars').fetch() + const json = users.toJSON() + assert.equal(json[0].cars[0].name, 'merc') + assert.equal(json[1].cars[0].name, 'audi') + }) + + test('calling toJSON should build right json structure', async (assert) => { + class Car extends Model { + } + + class User extends Model { + cars () { + return this.hasMany(Car) + } + } + + Car._bootIfNotBooted() + User._bootIfNotBooted() + + let carQuery = null + Car.onQuery((query) => carQuery = query) + + await ioc.use('Database').table('users').insert([{ username: 'virk' }, { username: 'nikk' }]) + await ioc.use('Database').table('cars').insert([ + { user_id: 1, name: 'merc', model: '1990' }, + { user_id: 2, name: 'audi', model: '2001' } + ]) + + const users = await User.query().with('cars').fetch() + const json = users.toJSON() + assert.equal(json[0].cars[0].name, 'merc') + assert.equal(json[1].cars[0].name, 'audi') + }) + + test('calling toJSON should build right json structure', async (assert) => { + class Car extends Model { + } + + class User extends Model { + cars () { + return this.hasMany(Car) + } + } + + Car._bootIfNotBooted() + User._bootIfNotBooted() + + await ioc.use('Database').table('users').insert([{ username: 'virk' }, { username: 'nikk' }]) + await ioc.use('Database').table('cars').insert([ + { user_id: 1, name: 'merc', model: '1990' }, + { user_id: 2, name: 'audi', model: '2001' } + ]) + + const users = await User.query().with('cars').fetch() + const json = users.toJSON() + assert.equal(json[0].cars[0].name, 'merc') + assert.equal(json[1].cars[0].name, 'audi') + }) + + test('should work with nested relations', async (assert) => { + class Part extends Model { + } + + class Car extends Model { + parts () { + return this.hasMany(Part) + } + } + + class User extends Model { + cars () { + return this.hasMany(Car) + } + } + + Part._bootIfNotBooted() + Car._bootIfNotBooted() + User._bootIfNotBooted() + + let carQuery = null + let partQuery = null + Car.onQuery((query) => carQuery = query) + Part.onQuery((query) => partQuery = query) + + await ioc.use('Database').table('users').insert({ username: 'virk' }) + await ioc.use('Database').table('cars').insert([ + { user_id: 1, name: 'mercedes', model: '1990' }, + { user_id: 1, name: 'audi', model: '2001' } + ]) + await ioc.use('Database').table('parts').insert([ + { car_id: 1, part_name: 'wheels' }, + { car_id: 1, part_name: 'engine' }, + { car_id: 2, part_name: 'wheels' }, + { car_id: 2, part_name: 'engine' } + ]) + + const user = await User.query().with('cars.parts').first() + assert.equal(user.getRelated('cars').size(), 2) + assert.equal(user.getRelated('cars').first().getRelated('parts').size(), 2) + assert.equal(user.getRelated('cars').last().getRelated('parts').size(), 2) + assert.equal(carQuery.sql, helpers.formatQuery('select * from "cars" where "user_id" = ?')) + assert.equal(partQuery.sql, helpers.formatQuery('select * from "parts" where "car_id" in (?, ?)')) + }) + + test('add query constraint to nested query', async (assert) => { + class Part extends Model { + } + + class Car extends Model { + parts () { + return this.hasMany(Part) + } + } + + class User extends Model { + cars () { + return this.hasMany(Car) + } + } + + Part._bootIfNotBooted() + Car._bootIfNotBooted() + User._bootIfNotBooted() + + let carQuery = null + let partQuery = null + Car.onQuery((query) => carQuery = query) + Part.onQuery((query) => partQuery = query) + + await ioc.use('Database').table('users').insert({ username: 'virk' }) + await ioc.use('Database').table('cars').insert([ + { user_id: 1, name: 'mercedes', model: '1990' }, + { user_id: 1, name: 'audi', model: '2001' } + ]) + await ioc.use('Database').table('parts').insert([ + { car_id: 1, part_name: 'wheels' }, + { car_id: 1, part_name: 'engine' }, + { car_id: 2, part_name: 'wheels' }, + { car_id: 2, part_name: 'engine' } + ]) + + const user = await User.query().with('cars.parts', (builder) => builder.where('part_name', 'engine')).first() + assert.equal(user.getRelated('cars').size(), 2) + assert.equal(user.getRelated('cars').first().getRelated('parts').size(), 1) + assert.equal(user.getRelated('cars').last().getRelated('parts').size(), 1) + assert.equal(carQuery.sql, helpers.formatQuery('select * from "cars" where "user_id" = ?')) + assert.equal(partQuery.sql, helpers.formatQuery('select * from "parts" where "part_name" = ? and "car_id" in (?, ?)')) + }) + + test('add query constraint to child and grand child query', async (assert) => { + class Part extends Model { + } + + class Car extends Model { + parts () { + return this.hasMany(Part) + } + } + + class User extends Model { + cars () { + return this.hasMany(Car) + } + } + + Part._bootIfNotBooted() + Car._bootIfNotBooted() + User._bootIfNotBooted() + + let carQuery = null + let partQuery = null + Car.onQuery((query) => carQuery = query) + Part.onQuery((query) => partQuery = query) + + await ioc.use('Database').table('users').insert({ username: 'virk' }) + await ioc.use('Database').table('cars').insert([ + { user_id: 1, name: 'mercedes', model: '1990' }, + { user_id: 1, name: 'audi', model: '2001' } + ]) + await ioc.use('Database').table('parts').insert([ + { car_id: 1, part_name: 'wheels' }, + { car_id: 1, part_name: 'engine' }, + { car_id: 2, part_name: 'wheels' }, + { car_id: 2, part_name: 'engine' } + ]) + + const user = await User.query().with('cars', (builder) => { + builder.where('name', 'audi').with('parts', (builder) => builder.where('part_name', 'engine')) + }).first() + + assert.equal(user.getRelated('cars').size(), 1) + assert.equal(user.getRelated('cars').first().getRelated('parts').size(), 1) + assert.equal(carQuery.sql, helpers.formatQuery('select * from "cars" where "name" = ? and "user_id" = ?')) + assert.equal(partQuery.sql, helpers.formatQuery('select * from "parts" where "part_name" = ? and "car_id" in (?)')) + }) + + test('get relation count', async (assert) => { + class Car extends Model { + parts () { + return this.hasMany(Part) + } + } + + class User extends Model { + cars () { + return this.hasMany(Car) + } + } + + Car._bootIfNotBooted() + User._bootIfNotBooted() + + let userQuery = null + User.onQuery((query) => userQuery = query) + + await ioc.use('Database').table('users').insert({ username: 'virk' }) + await ioc.use('Database').table('cars').insert([ + { user_id: 1, name: 'mercedes', model: '1990' }, + { user_id: 1, name: 'audi', model: '2001' } + ]) + + const user = await User.query().withCount('cars').first() + assert.deepEqual(user.$sideLoaded, { cars_count: helpers.formatNumber(2) }) + assert.equal(userQuery.sql, helpers.formatQuery('select *, (select count(*) from "cars" where users.id = cars.user_id) as "cars_count" from "users" limit ?')) + }) + + test('filter parent based upon child', async (assert) => { + class Car extends Model { + parts () { + return this.hasMany(Part) + } + } + + class User extends Model { + cars () { + return this.hasMany(Car) + } + } + + Car._bootIfNotBooted() + User._bootIfNotBooted() + + let userQuery = null + User.onQuery((query) => userQuery = query) + + await ioc.use('Database').table('users').insert([{ username: 'virk' }, { username: 'nikk' }]) + await ioc.use('Database').table('cars').insert([ + { user_id: 1, name: 'mercedes', model: '1990' }, + { user_id: 1, name: 'audi', model: '2001' } + ]) + + const users = await User.query().has('cars').fetch() + assert.equal(users.size(), 1) + assert.equal(userQuery.sql, helpers.formatQuery('select * from "users" where exists (select * from "cars" where users.id = cars.user_id)')) + }) + + test('define minimum count via has', async (assert) => { + class Car extends Model { + parts () { + return this.hasMany(Part) + } + } + + class User extends Model { + cars () { + return this.hasMany(Car) + } + } + + Car._bootIfNotBooted() + User._bootIfNotBooted() + + let userQuery = null + User.onQuery((query) => userQuery = query) + + await ioc.use('Database').table('users').insert([{ username: 'virk' }, { username: 'nikk' }]) + await ioc.use('Database').table('cars').insert([ + { user_id: 1, name: 'mercedes', model: '1990' }, + { user_id: 1, name: 'audi', model: '2001' }, + { user_id: 2, name: 'audi', model: '2001' } + ]) + + const users = await User.query().has('cars', '>=', 2).fetch() + assert.equal(users.size(), 1) + assert.equal(userQuery.sql, helpers.formatQuery('select * from "users" where (select count(*) from "cars" where users.id = cars.user_id) >= ?')) + }) + + test('add additional constraints via where has', async (assert) => { + class Car extends Model { + parts () { + return this.hasMany(Part) + } + } + + class User extends Model { + cars () { + return this.hasMany(Car) + } + } + + Car._bootIfNotBooted() + User._bootIfNotBooted() + + let userQuery = null + User.onQuery((query) => userQuery = query) + + await ioc.use('Database').table('users').insert([{ username: 'virk' }, { username: 'nikk' }]) + await ioc.use('Database').table('cars').insert([ + { user_id: 1, name: 'mercedes', model: '1990' }, + { user_id: 1, name: 'audi', model: '2001' }, + { user_id: 2, name: 'audi', model: '2001' } + ]) + + const users = await User.query().whereHas('cars', (builder) => { + return builder.where('name', 'audi') + }).fetch() + assert.equal(users.size(), 2) + assert.equal(userQuery.sql, helpers.formatQuery('select * from "users" where exists (select * from "cars" where "name" = ? and users.id = cars.user_id)')) + }) + + test('add additional constraints and count constraints at same time', async (assert) => { + class Car extends Model { + parts () { + return this.hasMany(Part) + } + } + + class User extends Model { + cars () { + return this.hasMany(Car) + } + } + + Car._bootIfNotBooted() + User._bootIfNotBooted() + + let userQuery = null + User.onQuery((query) => userQuery = query) + + await ioc.use('Database').table('users').insert([{ username: 'virk' }, { username: 'nikk' }]) + await ioc.use('Database').table('cars').insert([ + { user_id: 1, name: 'mercedes', model: '1990' }, + { user_id: 1, name: 'audi', model: '2001' }, + { user_id: 2, name: 'audi', model: '2001' } + ]) + + const users = await User.query().whereHas('cars', (builder) => { + return builder.where('name', 'audi') + }, '>', 1).fetch() + assert.equal(users.size(), 0) + assert.equal(userQuery.sql, helpers.formatQuery('select * from "users" where (select count(*) from "cars" where "name" = ? and users.id = cars.user_id) > ?')) + }) + + test('add orWhereHas clause', async (assert) => { + class Car extends Model { + parts () { + return this.hasMany(Part) + } + } + + class User extends Model { + cars () { + return this.hasMany(Car) + } + } + + Car._bootIfNotBooted() + User._bootIfNotBooted() + + let userQuery = null + User.onQuery((query) => userQuery = query) + + await ioc.use('Database').table('users').insert([{ username: 'virk' }, { username: 'nikk' }]) + await ioc.use('Database').table('cars').insert([ + { user_id: 1, name: 'mercedes', model: '1990' }, + { user_id: 1, name: 'audi', model: '2001' }, + { user_id: 2, name: 'audi', model: '2001' } + ]) + + const users = await User.query().whereHas('cars', (builder) => { + return builder.where('name', 'audi') + }, '>', 1).orWhereHas('cars', (builder) => builder.where('name', 'mercedes')).fetch() + assert.equal(users.size(), 1) + assert.equal(userQuery.sql, helpers.formatQuery('select * from "users" where (select count(*) from "cars" where "name" = ? and users.id = cars.user_id) > ? or exists (select * from "cars" where "name" = ? and users.id = cars.user_id)')) + }) + + test('paginate records', async (assert) => { + class Car extends Model { + parts () { + return this.hasMany(Part) + } + } + + class User extends Model { + cars () { + return this.hasMany(Car) + } + } + + Car._bootIfNotBooted() + User._bootIfNotBooted() + + let userQuery = null + User.onQuery((query) => userQuery = query) + + await ioc.use('Database').table('users').insert([{ username: 'virk' }, { username: 'nikk' }]) + await ioc.use('Database').table('cars').insert([ + { user_id: 1, name: 'mercedes', model: '1990' }, + { user_id: 1, name: 'audi', model: '2001' }, + { user_id: 2, name: 'audi', model: '2001' } + ]) + + const users = await User.query().with('cars').paginate() + assert.equal(users.size(), 2) + assert.deepEqual(users.pages, { total: 2, perPage: 20, page: 1, lastPage: 1 }) + }) + + test('convert paginated records to json', async (assert) => { + class Car extends Model { + parts () { + return this.hasMany(Part) + } + } + + class User extends Model { + cars () { + return this.hasMany(Car) + } + } + + Car._bootIfNotBooted() + User._bootIfNotBooted() + + let userQuery = null + User.onQuery((query) => userQuery = query) + + await ioc.use('Database').table('users').insert([{ username: 'virk' }, { username: 'nikk' }]) + await ioc.use('Database').table('cars').insert([ + { user_id: 1, name: 'mercedes', model: '1990' }, + { user_id: 1, name: 'audi', model: '2001' }, + { user_id: 2, name: 'audi', model: '2001' } + ]) + + const users = await User.query().with('cars').paginate() + const json = users.toJSON() + assert.deepEqual(json.total, 2) + assert.deepEqual(json.perPage, 20) + assert.deepEqual(json.page, 1) + assert.deepEqual(json.lastPage, 1) + assert.isArray(json.data) + assert.isArray(json.data[0].cars) + assert.isArray(json.data[1].cars) + }) })