Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix/method binding on knex proxy #3717

Merged
13 changes: 4 additions & 9 deletions lib/transaction.js
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,7 @@ finallyMixin(Transaction.prototype);
function makeTransactor(trx, connection, trxClient) {
const transactor = makeKnex(trxClient);

transactor.withUserParams = () => {
transactor.context.withUserParams = () => {
throw new Error(
'Cannot set user params on a transaction - it can only inherit params from main knex instance'
);
Expand All @@ -264,21 +264,16 @@ function makeTransactor(trx, connection, trxClient) {
transactor.isTransaction = true;
transactor.userParams = trx.userParams || {};

transactor.transaction = function(container, options) {
transactor.context.transaction = function(container, options) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like this line was only working because of the way the proxy shortcuts had been setup. In other words: one bug was canceling another bug.

So, now that the proxies are being setup correctly, we need to make sure we override the method in the correct place. (on the context object rather than the knex function)

if (!options) {
options = { doNotRejectOnRollback: true };
} else if (isUndefined(options.doNotRejectOnRollback)) {
options.doNotRejectOnRollback = true;
}

if (container) {
return trxClient.transaction(container, options, trx);
} else {
return new Promise((resolve, _reject) => {
trxClient.transaction(resolve, options, trx).catch(_reject);
});
}
return this._transaction(container, options, trx);
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this correctly refers to the transaction.context now, which means that this method call works now. Hooray!

};

transactor.savepoint = function(container, options) {
return transactor.transaction(container, options);
};
Expand Down
206 changes: 127 additions & 79 deletions lib/util/make-knex.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,92 @@ const QueryInterface = require('../query/methods');
const { merge, isUndefined } = require('lodash');
const batchInsert = require('./batchInsert');

// Javascript does not officially support "callable objects". Instead,
// you must create a regular Function and inject properties/methods
// into it. In other words: you can't leverage Prototype Inheritance
// to share the property/method definitions.
//
// To work around this, we're creating an Object Property Definition.
// This allow us to quickly inject everything into the `knex` function
// via the `Object.defineProperties(..)` function. More importantly,
// it allows the same definitions to be shared across `knex` instances.
const KNEX_PROPERTY_DEFINITIONS = {
client: {
get() {
return this.context.client;
},
set(client) {
this.context.client = client;
},
configurable: true,
},

userParams: {
get() {
return this.context.userParams;
},
set(userParams) {
this.context.userParams = userParams;
},
configurable: true,
},

schema: {
get() {
return this.client.schemaBuilder();
},
configurable: true,
},

migrate: {
get() {
return new Migrator(this);
},
configurable: true,
},

seed: {
get() {
return new Seeder(this);
},
configurable: true,
},

fn: {
get() {
return new FunctionHelper(this.client);
},
configurable: true,
},
};
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I replaced all of the references to knex with references to this instead. This means that the property definitions can now be identical for every knex instance. Therefore, I moved the property definitions into the top-level module so that they could be reused. (Rather than creating a new instance of the definitions for each knex instance)


// `knex` instances serve as proxies around `context` objects. So, calling
// any of these methods on the `knex` instance will forward the call to
// the `knex.context` object. This ensures that `this` will correctly refer
// to `context` within each of these methods.
const CONTEXT_METHODS = [
'raw',
'batchInsert',
'transaction',
'transactionProvider',
'initialize',
'destroy',
'ref',
'withUserParams',
'queryBuilder',
'disableProcessing',
'enableProcessing',
];

for (const m of CONTEXT_METHODS) {
KNEX_PROPERTY_DEFINITIONS[m] = {
value: function(...args) {
return this.context[m](...args);
},
configurable: true,
};
}

function makeKnex(client) {
// The object we're potentially using to kick off an initial chain.
function knex(tableName, options) {
Expand Down Expand Up @@ -38,20 +124,25 @@ function initContext(knexFn) {
// when transaction is ready to be used.
transaction(container, _config) {
const config = Object.assign({}, _config);
config.userParams = this.userParams || {}
if(isUndefined(config.doNotRejectOnRollback)) {
config.userParams = this.userParams || {};
if (isUndefined(config.doNotRejectOnRollback)) {
// Backwards-compatibility: default value changes depending upon
// whether or not a `container` was provided.
config.doNotRejectOnRollback = !container;
}

return this._transaction(container, config);
},

if(container) {
const trx = this.client.transaction(container, config);
// Internal method that actually establishes the Transaction. It makes no assumptions
// about the `config` or `outerTx`, and expects the caller to handle these details.
_transaction(container, config, outerTx = null) {
if (container) {
const trx = this.client.transaction(container, config, outerTx);
return trx;
} else {
return new Promise((resolve, reject) => {
const trx = this.client.transaction(resolve, config);
const trx = this.client.transaction(resolve, config, outerTx);
trx.catch(reject);
});
}
Comment on lines 125 to 148
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I refactored the transaction(..) method just to demonstrate that the this pointer was referencing the correct object now. So, this allows this.transaction(..) to call this._transaction(..) without encountering any reference errors.

Expand Down Expand Up @@ -138,89 +229,46 @@ function _copyEventListeners(eventName, sourceKnex, targetKnex) {
function redefineProperties(knex, client) {
// Allow chaining methods from the root object, before
// any other information is specified.
//
// TODO: `QueryBuilder.extend(..)` allows new QueryBuilder
// methods to be introduced via external components.
// As a side-effect, it also pushes the new method names
// into the `QueryInterface` array.
//
// The Problem: due to the way the code is currently
// structured, these new methods cannot be retroactively
// injected into existing `knex` instances! As a result,
// some `knex` instances will support the methods, and
// others will not.
//
// We should revisit this once we figure out the desired
// behavior / usage. For instance: do we really want to
// allow external components to directly manipulate `knex`
// data structures? Or, should we come up w/ a different
// approach that avoids side-effects / mutation?
//
// (FYI: I noticed this issue because I attempted to integrate
// this logic directly into the `KNEX_PROPERTY_DEFINITIONS`
// construction. However, `KNEX_PROPERTY_DEFINITIONS` is
// constructed before any `knex` instances are created.
// As a result, the method extensions were missing from all
// `knex` instances.)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I decided to omit this part from the PR until we have a chance to discuss it.

On one hand, we could probably work around this by using an actual Proxy(..). On the other hand, it seems strange that we're allowing knex data / structures to be directly manipulated in this way. Perhaps we can come up w/ some way to preserve the original QueryBuilder, yet also allow end-users to create/use custom QueryBuilders?

QueryInterface.forEach(function(method) {
knex[method] = function() {
const builder = knex.queryBuilder();
const builder = this.queryBuilder();
return builder[method].apply(builder, arguments);
};
});

Object.defineProperties(knex, {
context: {
get() {
return knex._context;
},
set(context) {
knex._context = context;

// Redefine public API for knex instance that would be proxying methods from correct context
knex.raw = context.raw;
knex.batchInsert = context.batchInsert;
knex.transaction = context.transaction;
knex.transactionProvider = context.transactionProvider;
knex.initialize = context.initialize;
knex.destroy = context.destroy;
knex.ref = context.ref;
knex.withUserParams = context.withUserParams;
knex.queryBuilder = context.queryBuilder;
knex.disableProcessing = context.disableProcessing;
knex.enableProcessing = context.enableProcessing;
},
configurable: true,
},

client: {
get() {
return knex.context.client;
},
set(client) {
knex.context.client = client;
},
configurable: true,
},

userParams: {
get() {
return knex.context.userParams;
},
set(userParams) {
knex.context.userParams = userParams;
},
configurable: true,
},

schema: {
get() {
return knex.client.schemaBuilder();
},
configurable: true,
},

migrate: {
get() {
return new Migrator(knex);
},
configurable: true,
},

seed: {
get() {
return new Seeder(knex);
},
configurable: true,
},

fn: {
get() {
return new FunctionHelper(knex.client);
},
configurable: true,
},
});
Object.defineProperties(knex, KNEX_PROPERTY_DEFINITIONS);

initContext(knex);
knex.client = client;

// TODO: It looks like this field is never actually used.
// It should probably be removed in a future PR.
knex.client.makeKnex = makeKnex;

knex.userParams = {};

// Hook up the "knex" object as an EventEmitter.
Expand Down Expand Up @@ -283,7 +331,7 @@ function shallowCloneFunction(originalFunction) {

const clonedFunction = knexFnWrapper.bind(fnContext);
Object.assign(clonedFunction, originalFunction);
clonedFunction._context = knexContext;
clonedFunction.context = knexContext;
return clonedFunction;
}

Expand Down
2 changes: 1 addition & 1 deletion test/integration/builder/additional.js
Original file line number Diff line number Diff line change
Expand Up @@ -1089,7 +1089,7 @@ module.exports = function(knex) {
});
it('should capture stack trace on raw query', () => {
return knex.raw('select * from some_nonexisten_table').catch((err) => {
expect(err.stack.split('\n')[2]).to.match(/at Function\.raw \(/); // the index 2 might need adjustment if the code is refactored
expect(err.stack.split('\n')[2]).to.match(/at Object\.raw \(/); // the index 2 might need adjustment if the code is refactored
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previously, the stacktrace said Function here because the underlying method was not actually being proxied. Instead, it was being invoked through knex, which was a Function.

expect(typeof err.originalStack).to.equal('string');
});
});
Expand Down