Skip to content

Commit 6551f38

Browse files
committed
Merge pull request #15 from jon-hall/feature/mutating-required-modules-bug
Add noMutate flag, which addresses #12 and adds the ability to…
2 parents 0435d61 + 73f8216 commit 6551f38

File tree

7 files changed

+199
-36
lines changed

7 files changed

+199
-36
lines changed

README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,32 @@ myObj.myMethod({ msg: "Failure!" }, null).then(null, function(err) {
7070
});
7171
```
7272

73+
Wrap without mutating the original:
74+
```javascript
75+
var promisify = require("promisify-node");
76+
77+
var myObj = {
78+
myMethod: function(a, b, cb) {
79+
cb(a, b);
80+
}
81+
};
82+
83+
// Store the original method to check later
84+
var originalMethod = myObj.myMethod;
85+
86+
// Now store the result, since the 'true' value means it won't mutate 'myObj'.
87+
var promisifiedObj = promisify(myObj, undefined, true);
88+
89+
// Intentionally cause a failure by passing an object and inspect the message.
90+
promisifiedObj.myMethod({ msg: "Failure!" }, null).then(null, function(err) {
91+
console.log(err.msg);
92+
});
93+
94+
// The original method is still intact
95+
assert(myObj.myMethod === originalMethod);
96+
assert(promisifiedObj.myMethod !== myObj.myMethod);
97+
```
98+
7399
### Tests ###
74100

75101
Run the tests after installing dependencies with:

index.js

Lines changed: 67 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
const Promise = require("nodegit-promise");
22
const args = require("./utils/args");
3+
const cloneFunction = require("./utils/cloneFunction");
4+
const objectAssign = require("object-assign");
35

46
// Unfortunately this list is not exhaustive, so if you find that a method does
57
// not use a "standard"-ish name, you'll have to extend this list.
@@ -12,22 +14,26 @@ var callbacks = ["cb", "callback", "callback_", "done"];
1214
* @param {*} exports - Should be a function or an object, identity other.
1315
* @param {Function} test - Optional function to identify async methods.
1416
* @param {String} parentKeyName - Tracks the keyName in a digestable format.
17+
* @param {Boolean} noMutate - if set to true then all reference properties are
18+
* cloned to avoid mutating the original object.
1519
* @returns {*} exports - Identity.
1620
*/
17-
function processExports(exports, test, cached, parentKeyName) {
18-
// Return early if this object has already been processed.
19-
if (cached.indexOf(exports) > -1) {
21+
function processExports(exports, test, cached, parentKeyName, noMutate) {
22+
if(!exports) {
2023
return exports;
21-
} else if(typeof exports === "function") {
22-
// For functions, cache the original and wrapped version, else non-wrapped
23-
// functions end up being given back when encountered multiple times.
24-
var cacheResult = cached.filter(function(c) {
25-
return c.original === exports;
26-
});
24+
}
2725

26+
if(noMutate || typeof exports === "function") {
27+
// When not mutating we have to cache the original and the wrapped clone.
28+
var cacheResult = cached.filter(function(c) { return c.original === exports; });
2829
if(cacheResult.length) {
2930
return cacheResult[0].wrapped;
3031
}
32+
} else {
33+
// Return early if this object has already been processed.
34+
if (cached.indexOf(exports) > -1) {
35+
return exports;
36+
}
3137
}
3238

3339
// Record this object in the cache, if it is not a function.
@@ -41,94 +47,121 @@ function processExports(exports, test, cached, parentKeyName) {
4147
}
4248

4349
var name = exports.name + "#";
50+
var target;
4451

4552
// If a function, simply return it wrapped.
4653
if (typeof exports === "function") {
47-
// Assign the new function in place.
48-
var wrapped = Promise.denodeify(exports);
54+
var wrapped = exports;
55+
var isAsyncFunction = false;
56+
57+
// Check the callback either passes the test function, or accepts a callback.
58+
if ((test && test(exports, exports.name, parentKeyName))
59+
// If the callback name exists as the last argument, consider it an
60+
// asynchronous function. Brittle? Fragile? Effective.
61+
|| (callbacks.indexOf(args(exports).slice(-1)[0]) > -1)) {
62+
// Assign the new function in place.
63+
wrapped = Promise.denodeify(exports);
64+
65+
isAsyncFunction = true;
66+
} else if(noMutate) {
67+
// If not mutating, then we need to clone the function, even though it isn't async.
68+
wrapped = cloneFunction(exports);
69+
}
70+
71+
// Set which object we'll mutate based upon the noMutate flag.
72+
target = noMutate ? wrapped : exports;
4973

50-
// Push the wrapped function onto the cache before processing properties,
51-
// else a cyclical function property causes a stack overflow.
74+
// Here we can push our cloned/wrapped function and original onto cache.
5275
cached.push({
5376
original: exports,
5477
wrapped: wrapped
5578
});
5679

5780
// Find properties added to functions.
5881
for (var keyName in exports) {
59-
exports[keyName] = processExports(exports[keyName], test, cached, name);
82+
target[keyName] = processExports(exports[keyName], test, cached, name, noMutate);
6083
}
6184

6285
// Find methods on the prototype, if there are any.
6386
if (Object.keys(exports.prototype).length) {
64-
processExports(exports.prototype, test, cached, name);
87+
// Attach the augmented prototype.
88+
wrapped.prototype = processExports(exports.prototype, test, cached, name, noMutate);
6589
}
6690

67-
// Attach the augmented prototype.
68-
wrapped.prototype = exports.prototype;
69-
7091
// Ensure attached properties to the previous function are accessible.
71-
wrapped.__proto__ = exports;
92+
// Only do this if it's an async (wrapped) function, else we're setting
93+
// __proto__ to itself, which isn't allowed.
94+
if(isAsyncFunction) {
95+
wrapped.__proto__ = exports;
96+
}
7297

7398
return wrapped;
7499
}
75100

76-
Object.keys(exports).map(function(keyName) {
101+
// Make a shallow clone if we're not mutating and set it as the target, else just use exports
102+
target = noMutate ? objectAssign({}, exports) : exports;
103+
104+
// We have our shallow cloned object, so put it (and the original) in the cache
105+
if(noMutate) {
106+
cached.push({
107+
original: exports,
108+
wrapped: target
109+
});
110+
}
111+
112+
Object.keys(target).map(function(keyName) {
77113
// Convert to values.
78-
return [keyName, exports[keyName]];
114+
return [keyName, target[keyName]];
79115
}).filter(function(keyVal) {
80116
var keyName = keyVal[0];
81117
var value = keyVal[1];
82118

83119
// If an object is encountered, recursively traverse.
84120
if (typeof value === "object") {
85-
processExports(exports, test, cached, keyName + ".");
86-
}
87-
// Filter to functions with callbacks only.
88-
else if (typeof value === "function") {
121+
processExports(value, test, cached, keyName + ".", noMutate);
122+
} else if (typeof value === "function") {
89123
// If a filter function exists, use this to determine if the function
90124
// is asynchronous.
91125
if (test) {
92126
// Pass the function itself, its keyName, and the parent keyName.
93127
return test(value, keyName, parentKeyName);
94128
}
95129

96-
// If the callback name exists as the last argument, consider it an
97-
// asynchronous function. Brittle? Fragile? Effective.
98-
if (callbacks.indexOf(args(value).slice(-1)[0]) > -1) {
99-
return true;
100-
}
130+
return true;
101131
}
102132
}).forEach(function(keyVal) {
103133
var keyName = keyVal[0];
104134
var func = keyVal[1];
105135

106136
// Wrap this function and reassign.
107-
exports[keyName] = processExports(func, test, cached, parentKeyName);
137+
target[keyName] = processExports(func, test, cached, parentKeyName, noMutate);
108138
});
109139

110-
return exports;
140+
return target;
111141
}
112142

113143
/**
114144
* Public API for Promisify. Will resolve modules names using `require`.
115145
*
116146
* @param {*} name - Can be a module name, object, or function.
117147
* @param {Function} test - Optional function to identify async methods.
148+
* @param {Boolean} noMutate - Optional set to true to avoid mutating the target.
118149
* @returns {*} exports - The resolved value from require or passed in value.
119150
*/
120-
module.exports = function(name, test) {
151+
module.exports = function(name, test, noMutate) {
121152
var exports = name;
122153

123154
// If the name argument is a String, will need to resovle using the built in
124155
// Node require function.
125156
if (typeof name === "string") {
126157
exports = require(name);
158+
// Unless explicitly overridden, don't mutate when requiring modules.
159+
noMutate = !(noMutate === false);
127160
}
128161

129162
// Iterate over all properties and find asynchronous functions to convert to
130163
// promises.
131-
return processExports(exports, test, []);
164+
return processExports(exports, test, [], undefined, noMutate);
132165
};
133166

134167
// Export callbacks to the module.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
"description": "Wrap Node-callback functions to return Promises.",
55
"main": "index.js",
66
"dependencies": {
7-
"nodegit-promise": "~4.0.0"
7+
"nodegit-promise": "~4.0.0",
8+
"object-assign": "^4.0.1"
89
},
910
"devDependencies": {
1011
"mocha": "~1.18.2",

test/examples/fn-export.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
module.exports = function(d, cb) { setTimeout(cb, d); };
2+
module.exports.x = function(d, cb) { setTimeout(cb, d); };

test/examples/proto-export.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
function A(d) { this.d = d; }
2+
A.prototype.a = function(cb) { setTimeout(cb, this.d); };
3+
module.exports = A;

test/index.js

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
var promisify = require("../");
2-
var assert = require("assert");
2+
var assert = require("assert"),
3+
fsOriginal = require('fs');
34

45
describe("Promisify", function() {
6+
function isPromisified(fn, ctx) {
7+
var result = fn && fn.apply(ctx, Array.prototype.slice.call(arguments, 2));
8+
return result && (typeof result.then === 'function');
9+
}
10+
511
it("can convert a basic async function", function() {
612
function test(cb) {
713
cb(null, true);
@@ -30,6 +36,41 @@ describe("Promisify", function() {
3036

3137
return fs.readFile(__dirname + "/../LICENSE");
3238
});
39+
40+
it("doesn't mutate objects for other consumers", function() {
41+
var fsp = promisify("fs");
42+
var fs2 = require("fs");
43+
44+
assert(fsOriginal.readFile !== fsp.readFile, "pre-required mutated");
45+
assert(fsOriginal.readFile === fs2.readFile, "post-required mutated");
46+
assert(fsp.readFile !== fs2.readFile, "post-required mutated");
47+
});
48+
49+
it("doesn't mutate functions for other consumers", function() {
50+
var fn = require(__dirname + "/examples/fn-export.js");
51+
var fnx = fn.x;
52+
var fnp = promisify(__dirname + "/examples/fn-export.js");
53+
var fn2 = require(__dirname + "/examples/fn-export.js");
54+
55+
assert(fn.x !== fnp, "pre-required mutated");
56+
assert(fn2.x !== fnp, "post-required mutated");
57+
assert(fn.x === fnx, "function property mutated");
58+
assert(fnp.x !== fn, "function property not replaced");
59+
});
60+
61+
it("doesn't mutate prototypes for other consumers", function() {
62+
var A = require(__dirname + "/examples/proto-export.js");
63+
var a = new A(5);
64+
var Ap = promisify(__dirname + "/examples/proto-export.js");
65+
var ap = new Ap(5);
66+
var A2 = require(__dirname + "/examples/proto-export.js");
67+
var a2 = new A2(5);
68+
69+
assert(isPromisified(ap.a, ap), "prototype method not promisified");
70+
assert(a.a !== ap.a, "pre-required mutated");
71+
assert(a2.a !== ap.a, "post-required mutated");
72+
assert(a2.a === a.a, "post-required mutated");
73+
});
3374
});
3475

3576
describe("asynchronous method inference", function() {
@@ -137,4 +178,44 @@ describe("Promisify", function() {
137178
assert(typeof a.a(5).then === "function", "function property not wrapped");
138179
});
139180
});
181+
182+
183+
describe("no mutate", function() {
184+
it("can promisify an object without mutating it", function() {
185+
var a = {
186+
a: function(cb) { cb(); }
187+
};
188+
189+
var b = promisify(a, undefined, true);
190+
191+
assert(isPromisified(b.a, b), "method not promisified");
192+
assert(a.a !== b.a, "object mutated");
193+
});
194+
195+
it("can promisify a function's properties without mutating it", function() {
196+
var a = function(cb){ cb(null, 1); };
197+
a.a = function(cb) { cb(); };
198+
199+
var b = promisify(a, undefined, true);
200+
201+
assert(isPromisified(b), "method not promisified");
202+
assert(isPromisified(b.a, b), "method property not promisified");
203+
assert(a.a !== b, "method property mutated");
204+
assert(a.a !== b.a, "method property mutated");
205+
});
206+
207+
it("can promisify a constructor without mutating it", function() {
208+
var A = function(){ };
209+
A.a = function(cb) { cb(); };
210+
A.prototype.a = function(cb) { cb(null, 2); };
211+
212+
var B = promisify(A, undefined, true);
213+
var b = new B();
214+
215+
assert(isPromisified(B.a, B), "method property not promisified");
216+
assert(isPromisified(b.a, b), "prototype method not promisified");
217+
assert(A.a !== B.a, "method property mutated");
218+
assert(A.prototype.a !== b.a, "prototype mutated");
219+
});
220+
});
140221
});

utils/cloneFunction.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/**
2+
* Clones a function, including copying any properties of the function to the clone.
3+
*
4+
* @param {Function} func - The function to clone.
5+
*/
6+
module.exports = function cloneFn(func) {
7+
var temp;
8+
// Check for the memoized value on the function in-case we get called to wrap the same function
9+
// (or already wrapped function) again.
10+
return func.__cloneFn || (temp = function() {
11+
return func.apply(this, arguments);
12+
}) &&
13+
// Assign __proto__ as a quick way to copy function properties.
14+
(temp.__proto__ = func) &&
15+
// Lastly, set a cache var on the original and clone, and return the result.
16+
(func.__cloneFn = temp.__cloneFn = temp);
17+
};

0 commit comments

Comments
 (0)