Building a LINQ-like query API in JavaScript

2019-07-17 08:01发布

问题:

I'd like to write a JavaScript class that works like C#'s IQueryable<T> interface to enable nicely-formatted queries against a remote data source. In other words, I'd like to be able to write the following (using ES6 arrow syntax):

datasource.query(Sandwich)
    .where(s => s.bread.type == 'rye')
    .orderBy(s => s.ketchup.amount)
    .take(5)
    .select(s => { 'name': s.name });

and turn that into something like

SELECT s.name AS name
FROM sandwich s
JOIN bread b ON b.sandwich_id = s.id
JOIN ketchup k on k.sandwich_id = s.id
WHERE b.type = 'rye'
ORDER BY k.amount
LIMIT 5;

with the SQL query (or whatever query language is used) being actually sent to the server. (Doing the filtering on the client side is not feasible because the server might return tons of data.)

In C#, this functionality is supported by the Expression class, which lets you construct an expression tree from a lambda function. But JavaScript has no equivalent, as far as I know. My original plan was to feed f.toString() to Esprima's parser for the function f passed as the argument to select(), where(), etc. and use that expression tree. This approach works great as long as the expressions refer only to literals, but when you try something like

var breadType = 'rye';

datasource.query(Sandwich)
    .where(s => s.bread.type == breadType)
...

it fails, because you'll have a token breadType that you can't replace with a value. As far as I can tell, JavaScript has no way to introspect the function closure and get the value of breadType after the fact externally.

My next thought was that since Esprima will give me a list of tokens, I could modify the function body in-place to something like

return {
    'breadType': breadType
};

and then call it, taking advantage of the fact that even if I can't access the closure, the function itself can. But modification of a function's code in-place also seems to be impossible.

Another approach that would not require Esprima would be to pass in a sentinel object as the argument to the inner function f and override its comparison operators, which is how SQLAlchemy's filter() works in Python. But Python provides operator overloading and JavaScript does not, so this also fails.

This leaves me with two inferior solutions. One is to do something like this:

var breadType = 'rye';

datasource.query(Sandwich)
    .where(s => s.bread.type == breadType)
    .forValues(() => {
        'breadType': breadType
    });

In other words, I could force the caller to provide the closure context manually. But this is pretty lame.

Another approach is to do the sentinel object thing but with functions instead of operators since operators can't be overloaded:

var breadType = 'rye';

datasource.query(Sandwich)
    .where(s => s.bread.type.equals(breadType));

ES6's Proxy objects will make this simple to implement, but it's still not as good as the version with regular operators.

Sorry for the long post. My ultimate question is whether it's possible to achieve this with the ideal syntax shown in the first code block and, if so, how to do it. Thanks!

回答1:

No, this is indeed impossible for the reasons you outlined. If you want to support passing arbitrary closures as arguments, then your only choice is to execute the functions. You cannot transform them to SQL statements, at some degree this will always fail regardless how many static code analysis you perform on the files.

I guess your best bet here are template literals, where you could have something like

var breadType = 'rye';
datasource.query(Sandwich, `
    .where(s => s.bread.type == ${breadType})
    .orderBy(s => s.ketchup.amount)
    .take(5)
    .select(s => { 'name': s.name })
`)

so that you can keep your syntax as you want, but will have to supply all external variables explicitly.