Objection.js: upsertGraph with m2m's not triggering $afterInsert/Delete

Created on 1 Apr 2019  路  16Comments  路  Source: Vincit/objection.js

Using version 1.6.6

I have a many to many relationship between Assignment and Position through AssignmentPosition. I'm calling upsertGraph with the relate and unrelate options set to true. It correctly associates the assignment to the position and prying on knex, it does insert and delete the assignment_position rows, but does not trigger the AssignmentPosition $afterInsert when it's related nor $afterDelete when unrelated.

The upsertGraph call looks something like this:

models.Assignment.query().upsertGraph({
  id: 1,
  positions: [{ id: 2 }]
}, { relate: ['positions'], unrelate: ['positions'] });

The models:

class Assignment extends Model {
  static tableName = 'assignment';
  static get relationMappings() {
    return {
      positions: {
        relation: Model.ManyToManyRelation,
        modelClass: models.Position,
        join: {
          from: 'assignment.id',
          through: {
            from: 'assignment_position.assignment_id',
            to: 'assignment_position.position_id'
          },
          to: 'position.id'
        }
      }
  }
}

class Position extends Model {
  static tableName = 'position';
}

class AssignmentPosition extends Model {
  async $afterInsert(context) {
    await super.$afterInsert(context);
    // Doesn't reach here...
  }
  async $afterDelete(context) {
    await super.$afterDelete(context);
    // Doesn't reach here...
  }
}

Calling an insert directly on the AssignmentPosition does trigger the hook as well as the upsert graph directly to assignment.assignmentPositions.

Most helpful comment

Objection 2.0 will have a static afterDelete and beforeDelete hooks that work in all situations. This will be a lot easier to implement then.

All 16 comments

Actually, $afterDelete doesn't appear to trigger even when directly relating through assignment.assignmentPositions

You need to specify the model class in through.modelClass property of the relationmapping

That did solve the $afterInsert problem but the $afterDelete still does not appear to trigger.

Read the docs. Affterdelete doesn't work like that.

Hooks are instance methods. For delete, there is no instance for which to call the hook. Objection does't fetch the row from db just so that it can call the hook. You need to explicitly do that.

You're right, the docs do specify that $afterDelete is only for instance calls:

Note that this method is only called for instance deletes started with $query() method..

I assumed the insert and delete hooks worked the same. That makes sense though that you're not doing extra db calls just for a hook. Thanks for your time!

For future travelers, this is my work around to avoid making another call to figure out which rows are being deleted:

class AssignmentPositionQueryBuilder extends Model.QueryBuilder {
  // Override
  delete() {
    return super.select('id').runAfter(async results  => {
      // Perform "afterDelete" logic here...
      await AssignmentPosition.query()
        .whereIn('id', results.map(r => r.id))
        .hardDelete();
      return results;
    });
  }

  hardDelete() {
    return super.delete();
  }
}

export default class AssignmentPosition extends BaseModel {
  static tableName = 'assignment_position';

  static QueryBuilder = AssignmentPositionQueryBuilder;
}

Thanks @jarommadsen, your idea saved me time!
But I wonder if it does support transactions?

@adefrutoscasado You can bind the model to the transaction and then the hardDelete would be included. Since delete is now your method to do with as you wish you could also pass a transaction object into it.

delete(trx) {
  return super.select('id').runAfter(async results  => {
     // Perform "afterDelete" logic here...
    await AssignmentPosition.query(trx)
      .whereIn('id', results.map(r => r.id))
      .hardDelete();
    return results;
  });
}

// Call it like this
await transaction(AssignmentPosition, async (AssignmentPosition, trx) => {
  // `trx` is the knex transaction object.
  await AssignmentPosition.query(trx).delete(trx);
});

Thanks @jarommadsen, but Im using upsertGraph method so I think cannot pass it in delete() param:

await model.query(trx).upsertGraphAndFetch(data)

I think trx should be accesible somewhere in the QueryBuilder class:

class CustomQueryBuilder extends QueryBuilder {
  delete() {
    return super.select('*').first().runAfter(async olds => {
      // Since the delete operation is called from an instance of a model, now $afterDelete should be called
      return await Promise.all(olds.map((old) => old.$query().nativeDelete()))
    })
  }

  nativeDelete() {
    return super.delete()
  }
}

Ok so I found the transaction of the context at builder._context.userContext.transaction so the CustomQueryBuilder would be:

class CustomQueryBuilder extends QueryBuilder {
  delete() {
    return super.select('*').first().runAfter(async (olds, builder) => {
      // Since the delete operation is called from an instance of a model, now $afterDelete should be called
      return await Promise.all(olds.map((old) => old.$query(builder._context.userContext.transaction).nativeDelete()))
    })
  }

  nativeDelete() {
    return super.delete()
  }
}

But since _context is private, how bad is this idea? Is it okay since Im extending the QueryBuilder? I suppose some internal change in objection.js or knex.js can break it

@adefrutoscasado You can access the query context using the getter method queryBuilder.context().

In addition to properties added using this method (and mergeContext) the query context object always has a transaction property that holds the active transaction. If there is no active transaction the transaction property contains the normal knex instance. In both cases the value can be passed anywhere where a transaction object can be passed so you never need to check for the existence of the transaction property.

https://vincit.github.io/objection.js/api/query-builder/other-methods.html#context

Objection 2.0 will have a static afterDelete and beforeDelete hooks that work in all situations. This will be a lot easier to implement then.

Couldnt reply before @jarommadsen , its finally working well with queryBuilder.context()
Thank you very much!

Objection 2.0 will have a static afterDelete and beforeDelete hooks that work in all situations. This will be a lot easier to implement then.

It does not work.

```javascript

// asFindQuery replies with a wrong object
// everything else is either empty or undefined

import path from 'path';
import { Base } from '.';

class OrderVouchers extends Base {
static tableName = 'order_vouchers';

static async beforeDelete({ asFindQuery, inputItems, items, relation }) {
console.log('afterDelete', relation, items, inputItems, await asFindQuery().select('id'));
}

static async beforeInsert({ asFindQuery, items, inputItems, relation }) {
console.log('afterInsert', relation, items, inputItems, await asFindQuery().select('id'));
}

static relationMappings = {
orders: {
relation: Base.BelongsToOneRelation,
modelClass: path.resolve(__dirname, 'Orders'),
join: {
from: 'order_vouchers.order_id',
to: 'orders.id',
},
},
vouchers: {
relation: Base.BelongsToOneRelation,
modelClass: path.resolve(__dirname, 'Vouchers'),
join: {
from: 'order_vouchers.voucher_id',
to: 'vouchers.id',
},
},
};
}

@adefrutoscasado @jarommadsen

Just to confirm I got everything right in this thread. I use a custom delete() method that overrides my BaseModel while reimplementing my nativeDelete with a runAfter _middleware_.

At the same time I use the queryBuilder.context().transaction from my runAfter builder to run everything in one transaction.

```javascript

class MyTable extends Base {
static tableName = 'my_table';
static relationMappings = { ... };
}

class MyTableQueryBuilder extends Base.QueryBuilder {
delete() {
return super.select('*').runAfter(async (results, QueryBuilder) => {
const { transaction: trx } = QueryBuilder.context();
// Perform "afterDelete" logic here...

  await OrderVouchers.query(trx)
    .whereIn('id', results.map(prop('id')))
    .nativeDelete();

  return results;
});

}

nativeDelete() {
return super.delete();
}
}

OrderVouchers.QueryBuilder = OrderVoucherQueryBuilder;

export default OrderVouchers;

Was this page helpful?
0 / 5 - 0 ratings