I was almost there! I was assuming that there was some special way of handling methods and so that led me to the apply
trap and other distractions, but as it turns out you can do everything with the get
trap:
var obj = {
counter: 0,
method1: function(a) { this.counter += a; return this; },
method2: function(a, b) { this.counter += a*b; return this; },
};
Object.defineProperty(obj, "getter1", {get:function() { this.counter += 7; return this; }});
Object.defineProperty(obj, "getter2", {get:function() { this.counter += 13; return this; }});
var p = new Proxy(obj, {
capturedCalls: [],
get: function(target, property, receiver) {
if(property === "execute") {
let result = target;
for(let call of this.capturedCalls) {
if(call.type === "getter") {
result = result[call.name]
} else if(call.type === "method") {
result = result[call.name].apply(target, call.args)
}
}
return result;
} else {
let desc = Object.getOwnPropertyDescriptor(target, property);
if(desc.value && typeof desc.value === 'function') {
let callDesc = {type:"method", name:property, args:null};
this.capturedCalls.push(callDesc);
return function(...args) { callDesc.args = args; return receiver; };
} else {
this.capturedCalls.push({type:"getter", name:property})
return receiver;
}
}
},
});
The return function(...args) { callDesc.args = args; return receiver; };
bit is where the magic happens. When they're calling a function we return them a "dummy function" which captures their arguments and then returns the proxy like normal. This solution can be tested with commands like p.getter1.method2(1,2).execute
(which yeilds obj
with obj.counter===9
)
This seems to work great, but I'm still testing it and will update this answer if anything needs fixing.
Note: With this approach to "lazy chaining" you'll have to create a new proxy each time obj
is accessed. I do this by simply wrapping obj
in a "root" proxy, and spawning the above-described proxy whenever one of its properties are accessed.
Improved version:
This is probably useless to everyone in the world except me, but I figured I'd post it here just in case. The previous version could only handle methods that returned this
. This version fixes that and gets it closer to a "general purpose" solution for recording chains and executing them lazily only when needed:
var fn = function(){};
var obj = {
counter: 0,
method1: function(a) { this.counter += a; return this; },
method2: function(a, b) { this.counter += a*b; return this; },
[Symbol.toPrimitive]: function(hint) { console.log(hint); return this.counter; }
};
Object.defineProperty(obj, "getter1", {get:function() { this.counter += 7; return this; }});
Object.defineProperty(obj, "getter2", {get:function() { this.counter += 13; return this; }});
let fn = function(){};
fn.obj = obj;
let rootProxy = new Proxy(fn, {
capturedCalls: [],
executionProperties: [
"toString",
"valueOf",
Symbol.hasInstance,
Symbol.isConcatSpreadable,
Symbol.iterator,
Symbol.match,
Symbol.prototype,
Symbol.replace,
Symbol.search,
Symbol.species,
Symbol.split,
Symbol.toPrimitive,
Symbol.toStringTag,
Symbol.unscopables,
Symbol.for,
Symbol.keyFor
],
executeChain: function(target, calls) {
let result = target.obj;
if(this.capturedCalls.length === 0) {
return target.obj;
}
let lastResult, secondLastResult;
for(let i = 0; i < capturedCalls.length; i++) {
let call = capturedCalls[i];
secondLastResult = lastResult; // needed for `apply` (since LAST result is the actual function, and not the object/thing that it's being being called from)
lastResult = result;
if(call.type === "get") {
result = result[call.name];
} else if(call.type === "apply") {
// in my case the `this` variable should be the thing that the method is being called from
// (this is done by default with getters)
result = result.apply(secondLastResult, call.args);
}
// Remember that `result` could be a Proxy
// If it IS a proxy, we want to append this proxy's capturedCalls array to the new one and execute it
if(result.___isProxy) {
leftOverCalls = capturedCalls.slice(i+1);
let allCalls = [...result.___proxyHandler.capturedCalls, ...leftOverCalls];
return this.executeChain(result.___proxyTarget, allCalls);
}
}
return result;
},
get: function(target, property, receiver) {
//console.log("getting:",property)
if(property === "___isProxy") { return true; }
if(property === "___proxyTarget") { return target; }
if(property === "___proxyHandler") { return this; }
if(this.executionProperties.includes(property)) {
let result = this.executeChain(target, this.capturedCalls);
let finalResult = result[property];
if(typeof finalResult === 'function') {
finalResult = finalResult.bind(result);
}
return finalResult;
} else {
// need to return new proxy
let newHandler = {};
Object.assign(newHandler, this);
newHandler.capturedCalls = this.capturedCalls.slice(0);
newHandler.capturedCalls.push({type:"get", name:property});
let np = new Proxy(target, newHandler)
return np;
}
},
apply: function(target, thisArg, args) {
// return a new proxy:
let newHandler = {};
Object.assign(newHandler, this);
newHandler.capturedCalls = this.capturedCalls.slice(0);
// add arguments to last call that was captured
newHandler.capturedCalls.push({type:"apply", args});
let np = new Proxy(target, newHandler);
return np;
},
isExtensible: function(target) { return Object.isExtensible(this.executeChain(target)); },
preventExtensions: function(target) { return Object.preventExtensions(this.executeChain(target)); },
getOwnPropertyDescriptor: function(target, prop) { return Object.getOwnPropertyDescriptor(this.executeChain(target), prop); },
defineProperty: function(target, property, descriptor) { return Object.defineProperty(this.executeChain(target), property, descriptor); },
has: function(target, prop) { return (prop in this.executeChain(target)); },
set: function(target, property, value, receiver) { Object.defineProperty(this.executeChain(target), property, {value, writable:true, configurable:true}); return value; },
deleteProperty: function(target, property) { return delete this.executeChain(target)[property]; },
ownKeys: function(target) { return Reflect.ownKeys(this.executeChain(target)); }
});
Note that it proxies a function so that it can capture apply
s easily. Note also that a new Proxy needs to be made at every step in the chain. It may need some tweaking to suit purposes that aren't exactly the same as mine. Again, I don't doubt it uselessness outside of DSL building and other meta-programming stuff - I'm mostly putting it here to perhaps give inspiration to others who are trying to achieve similar things.