mongoose pre hook 에러 추적기 (3)

1. 들어가며


  • 이전 글에서 작업한 PR이 머지되었고, aggregate 뿐만 아니라 다른 middleware에 대한 작업 제안도 받아들여졌습니다. image
  • 이에 따라서 다른 middleware에 대해서도 작업을 추가적으로 진행하고 PR을 올리고자 합니다.

2. 해결하기


'use strict';

const middlewareFunctions = require('../../constants').queryMiddlewareFunctions;
const promiseOrCallback = require('../promiseOrCallback');

module.exports = function applyStaticHooks(model, hooks, statics) {
  const kareemOptions = {
    useErrorHandlers: true,
    numCallbackParams: 1
  };

  hooks = hooks.filter(hook => {
    // If the custom static overwrites an existing query middleware, don't apply
    // middleware to it by default. This avoids a potential backwards breaking
    // change with plugins like `mongoose-delete` that use statics to overwrite
    // built-in Mongoose functions.
    if (middlewareFunctions.indexOf(hook.name) !== -1) {
      return !!hook.model;
    }
    return hook.model !== false;
  });

  model.$__insertMany = hooks.createWrapper('insertMany',
    model.$__insertMany, model, kareemOptions);

  for (const key of Object.keys(statics)) {
    ...
  }
};
  • 문제는 이전 글에서 언급한 것과 같이 hooks.filter하는 부분에 있어서 특정 middleware만 필터링하기 때문에 발생하는 문제입니다.
    • 따라서 문제와 해결 방안의 맥락은 동일합니다. 대상만 바뀌었습니다.
  • query middleware과 동일한 이름을 가진 static method와 제 작업을 통해서 추가된 aggreate middleware과 동일한 이름을 가진 static method 경우는 pre/post 훅이 적용되지 않습니다.
  • 하지만 여전히 다른 middleware와 동일한 이름을 가진 static method의 경우는 필터링되지 않아서 pre/post 훅이 적용됩니다.

해결 방법


lib/constants.js


'use strict';

/*!
 * ignore
 */

const queryOperations = Object.freeze([
  // Read
  'countDocuments',
  'distinct',
  'estimatedDocumentCount',
  'find',
  'findOne',
  // Update
  'findOneAndReplace',
  'findOneAndUpdate',
  'replaceOne',
  'updateMany',
  'updateOne',
  // Delete
  'deleteMany',
  'deleteOne',
  'findOneAndDelete'
]);

exports.queryOperations = queryOperations;

/*!
 * ignore
 */

const queryMiddlewareFunctions = queryOperations.concat([
  'validate'
]);

exports.queryMiddlewareFunctions = queryMiddlewareFunctions;

/*!
 * ignore
 */

const aggregateMiddlewareFunctions = [
  'aggregate'
];

exports.aggregateMiddlewareFunctions = aggregateMiddlewareFunctions;

/*!
 * ignore
 */

const modelMiddlewareFunctions = [
  'bulkWrite',
  'createCollection',
  'insertMany'
];

exports.modelMiddlewareFunctions = modelMiddlewareFunctions;

/*!
 * ignore
 */

const documentMiddlewareFunctions = [
  'validate',
  'save',
  'remove',
  'updateOne',
  'deleteOne',
  'init'
];

exports.documentMiddlewareFunctions = documentMiddlewareFunctions;

  • 다음과 같이 modelMiddlewareFunctions / documentMiddlewareFunctions 배열을 추가합니다.

lib/helpers/model/applyStaticHooks.js


'use strict';

const promiseOrCallback = require('../promiseOrCallback');
const { queryMiddlewareFunctions, aggregateMiddlewareFunctions, modelMiddlewareFunctions, documentMiddlewareFunctions } = require('../../constants');

const middlewareFunctions = Array.from(
  new Set([
    ...queryMiddlewareFunctions,
    ...aggregateMiddlewareFunctions,
    ...modelMiddlewareFunctions,
    ...documentMiddlewareFunctions
  ])  
);

module.exports = function applyStaticHooks(model, hooks, statics) {
  const kareemOptions = {
    useErrorHandlers: true,
    numCallbackParams: 1
  };

  model.$__insertMany = hooks.createWrapper('insertMany',
    model.$__insertMany, model, kareemOptions);

  hooks = hooks.filter(hook => {
    // If the custom static overwrites an existing middleware, don't apply
    // middleware to it by default. This avoids a potential backwards breaking
    // change with plugins like `mongoose-delete` that use statics to overwrite
    // built-in Mongoose functions.
    if (middlewareFunctions.indexOf(hook.name) !== -1) {
      return !!hook.model;
    }
    return hook.model !== false;
  });

  for (const key of Object.keys(statics)) {
    if (hooks.hasHooks(key)) {
      const original = model[key];

      model[key] = function() {
        const numArgs = arguments.length;
        const lastArg = numArgs > 0 ? arguments[numArgs - 1] : null;
        const cb = typeof lastArg === 'function' ? lastArg : null;
        const args = Array.prototype.slice.
          call(arguments, 0, cb == null ? numArgs : numArgs - 1);
        // Special case: can't use `Kareem#wrap()` because it doesn't currently
        // support wrapped functions that return a promise.
        return promiseOrCallback(cb, callback => {
          hooks.execPre(key, model, args, function(err) {
            if (err != null) {
              return callback(err);
            }

            let postCalled = 0;
            const ret = original.apply(model, args.concat(post));
            if (ret != null && typeof ret.then === 'function') {
              ret.then(res => post(null, res), err => post(err));
            }

            function post(error, res) {
              if (postCalled++ > 0) {
                return;
              }

              if (error != null) {
                return callback(error);
              }

              hooks.execPost(key, model, [res], function(error) {
                if (error != null) {
                  return callback(error);
                }
                callback(null, res);
              });
            }
          });
        }, model.events);
      };
    }
  }
};

  • 모든 middleware를 합친 middlewareFunctions 배열을 만듭니다.
    • 이때 Set을 두는데요, 아래의 메서드들의 이름이 중복되기 떄문입니다.
    • Document middlewareupdateOne / Query middlewareupdateOne
    • Document middlewaredeleteOne / Query middlewaredeleteOne
    • Document middlewarevalidate / Query middlewarevalidate
  • 이제는 hooks.filter 로직에서 필터링되어 mongoose middleware과 동일한 이름을 가진 모든 static method이 pre/post 훅이 적용되지 않습니다.
  • Set에 대한 부분은 코드리뷰를 받았습니다. 😊

5. 해결한 부분을 테스트하기


  • 이제 모든 middleware에 대해서 잘 작동하는지에 대한 테스트를 작성하고 통과하는지 확인해봐야 합니다.

테스트


  • 우선 기존 테스트를 돌리고, 모두 통과하는지 확인합니다.
$ npm run test

테스트 추가


it('custom statics that overwrite model functions dont get hooks by default', async function() {

    const schema = new Schema({ name: String });

    schema.statics.insertMany = function(docs) {
      return model.insertMany.apply(this, [docs]);
    };

    let called = 0;
    schema.pre('insertMany', function(next) {
      ++called;
      next();
    });
    const Model = db.model('Test', schema);

    const res = await Model.insertMany([
      { name: 'foo' },
      { name: 'boo' }
    ]);

    assert.ok(res[0].name);
    assert.ok(res[1].name);
    assert.equal(called, 1);
  });

  it('custom statics that overwrite document functions dont get hooks by default', async function() {

    const schema = new Schema({ name: String });

    schema.statics.save = function() {
      return 'foo';
    };

    let called = 0;
    schema.pre('save', function(next) {
      ++called;
      next();
    });

    const Model = db.model('Test', schema);

    const doc = await Model.save();

    assert.ok(doc);
    assert.equal(doc, 'foo');
    assert.equal(called, 0);
  });

  • 대표적으로 model middlewareinsertMany / document middlewaresave에 대한 테스트 코드를 작성하였습니다.
  • 각각 동일한 이름을 가진 static method를 만들고, 해당 메서드에 pre 훅을 걸어두었습니다.
    • 첫번째 케이스에 대해서는 static method가 실행하는 실제 메서드 호출로 인해서 한번 호출되어야합니다.
    • 두번째 케이스에 대해서는 한번도 호출되어서는 안됩니다.

테스트 확인


  • 똑같이 테스트를 돌리고, 모두 통과하는지 확인합니다.

image

  • 테스트가 모두 통과하는 것을 확인하였습니다. 이제 PR만 작성하면 됩니다.

6. PR을 작성하기


fix(model): filter applying static hooks by default if static name conflicts with mongoose middleware by dragontaek-lee · Pull Request #14908 · Automattic/mongoose
fix(model): filter applying static hooks by default if static name conflicts with mongoose middleware by dragontaek-lee · Pull Request #14908 · Automattic/mongoose
Additional Fix for PR: #14904 Summary Ensure that if a custom static method overwrites an existing mongoose middleware, the middleware is not applied by default, similar to how it works with query ...
  • 작성한 코드에 대해 PR을 작성하였습니다.
  • 메인테이너도 인지하고 있는 이슈라서, 이전 맥락에 대한 내용을 잘 적어두었습니다.

8. 기다리기


  • 또 다시 메인테이너분들의 반응을 기다립니다! 이미 알고 있는 컨텍스트라서 빠르게 확인해 주실 것입니다.

9. 기여 완료!


  • PR이 머지되었습니다. 꽤 빠르게 확인하고 머지해주셨네요!

image

10. 마치며


  • 컨트리뷰션을 쪼개서 할 수 있다는 점이 참 좋았는데요, 도입이 필요한지 명확히 확인할 수 있었고 나중에 히스토리 파악 측면에서도 좋은 것 같습니다.
  • 개인적으로 좋았던 점은 두개의 작업을 기여했다는 점입니다! 🚀