Qim
Immutable/functional select/update queries for plain JS.
Install / Use
/learn @jdeal/QimREADME
qim
Immutable/functional select/update queries for plain JS.
<img src="media/qim-logo.png" width="300">WARNING: Qim is really useful, but it's still considered somewhat experimental. It might have a few rough edges, and the API might change a little in the future! It's used in production at https://zapier.com though, so feel free to try it out!
Qim makes it simple to reach in and modify complex nested JS objects. This is possible with a query path that is just a simple JS array, much like you might use with set and update from Lodash, but with a more powerful concept of "navigators" (borrowed from Specter, a Clojure library). Instead of just string keys, Qim's navigators can act as predicates, wildcards, slices, and other tools. Those same navigators allow you to reach in and select parts of JS objects as well.
Qim's updates are immutable, returning new objects, but those objects share any unchanged parts with the original object.
Qim's API is curried and data last, so it should fit well with other functional libraries like Lodash/fp and ramda.
And Qim does its best to stay performant!
<!-- START doctoc generated TOC please keep comment here to allow auto update --> <!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->Contents
- A simple (kind-of-contrived) example
- A more complex (not-too-contrived) example
- Installation
- Usage
- API
- Navigators
- Built-in, type-based navigators
- Named navigators
$apply(fn)$begin$default(value)$each$eachKey$eachPair$end$first$last$lens(fn, fromFn)$merge(spec)$mergeDeep(spec)$nav(path, ...morePaths)$none$pick(keys, ...keys)$pushContext(key, (obj, context) => contextValue)$set(value)$setContext(key, (value, context) => contextValue)$slice(begin, end)$traverse({select, update})
- Custom navigators
- Performance
- TODO
- Contributing
- Thanks
A simple (kind-of-contrived) example
Let's start with some data like this:
const state = {
users: {
joe: {
name: {
first: 'Joe',
last: 'Foo'
},
other: 'stuff'
},
mary: {
name: {
first: 'Mary',
last: 'Bar'
},
other: 'stuff'
}
},
other: 'stuff'
};
Let's import a couple things from Qim:
import {select, $each} from 'qim';
Now let's grab all the first names:
const firstNames = select(['users', $each, 'name', 'first'], state);
(We'll explain $each a little more later, but you can probably guess: it's like a wildcard.)
firstNames now looks like:
['Joe', 'Mary']
Let's import a couple more things:
import {update, $apply} from 'qim';
And now we can upper-case all our first names.
const newState = update(['users', $each, 'name', 'first',
$apply(firstName => firstName.toUpperCase())
], state);
Notice we used the same path from our select but added an $apply to do a transformation. (Again, we'll explain $apply better in the next section.)
After that, newState looks like:
const state = {
users: {
joe: {
name: {
first: 'JOE',
last: 'Foo'
},
other: 'stuff'
},
mary: {
name: {
first: 'MARY',
last: 'Bar'
},
other: 'stuff'
}
},
other: 'stuff'
};
Just for comparison, let's grab the first names with plain JS:
const firstNames = Object.keys(state.users)
.map(username => state.users[username].name.first);
That's not too bad, but this is a very simple example. The $each from Qim makes things a lot more expressive. Let's look at Lodash/fp and Ramda too:
const {map, get} from 'lodash/fp';
const firstName = flow(
get('users'),
map(get(['name', 'first']))
)(state);
import R from 'ramda';
const firstName = R.pipe(
R.prop('users'),
R.values,
R.map(R.path(['name', 'first']))
)(state);
Lodash/fp is nice and expressive, but it costs a lot in terms of performance.
| Test | Ops/Sec | | :------------- | --------: | | native | 2,223,356 | | lodash/fp flow | 17,110 | | Ramda pipe | 278,705 | | qim select | 953,949 |
Ramda performs a lot better, but it's a little less concise.
Qim is slower than native, but it's doing more than the native equivalent, because it's accounting for things like missing keys. And as you'll see soon, it has a lot more expressive power.
That update in plain JS is a lot more verbose, even for this really simple example:
const newState = {
...state,
users: Object.keys(state.users)
.reduce((users, username) => {
const user = state.users[username];
users[username] = {
...user,
name: {
...user.name,
first: user.name.first.toUpperCase()
}
};
return users;
}, {})
};
So we go with something like Lodash/fp:
const newState = fp.update('users', fp.mapValues(
fp.update(['name', 'first'], firstName => firstName.toUpperCase())
), state)
Or Ramda:
R.over(R.lensProp('users'), R.map(
R.over(R.lensPath(['name', 'first']), firstName => firstName.toUpperCase())
), state)
Again, performance is going to take a hit for Lodash/fp.
| Test | Ops/Sec | | :--------------- | ------: | | native | 300,219 | | lodash/fp update | 16,663 | | Ramda update | 117,961 | | qim update | 176,196 |
Ramda is much faster, but we've had to start using lenses. Again, native is the fastest, but at a cost of being awfully unreadable. Qim's main goal isn't to be performant but rather to be expressive. Lodash/fp looks pretty nice, but remember how closely the update resembled the select with Qim? With Lodash/fp, an update is a different animal. With Ramda, we've had to switch to completely different concepts. As we'll see with a more complex example, Qim will retain its simple, expressive query power for updates while Lodash/fp and Ramda are going to get more complicated.
A more complex (not-too-contrived) example
Let's start with some data like this:
const state = {
entity: {
account: {
100: {
owner: 'joe',
type: 'savings',
balance: 90
},
200: {
owner: 'mary',
type: 'savings',
balance: 1100
},
300: {
owner: 'bob',
type: 'checking',
balance: 50
}
}
}
};
Let's say we want to change our state so that for every savings account, we:
- Add 5% interest to any balance > 1000.
- Subtract 10 from any balance < 100. Cause fees are how banks make money, right?
(And I know banks should have transactions, yada, yada.)
Okay, drum roll... with Qim, we can do that like this:
import {update, $each, $apply} from 'qim';
const newState = update(['entity', 'account', $each,
account => account.type === 'savings', 'balance',
[bal => bal >= 1000, $apply(bal => bal * 1.05)],
[bal => bal < 100, $apply(bal => bal - 10)]
], state);
Even without any explanation, hopefully you have a rough idea of what's going on. Like we saw in the simple example with $each and $apply, instead of only accepting an array of strings for a path, Qim's update function accepts an array of navigators. Using different types of navigators together creates a rich query path for updating a nested object. We'll look closer at this particular query in a bit, but first let's try the same thing with vanilla JS.
const newState = {
...state,
entity: {
...state.entity,
account: Object.keys(state.entity.account).reduce((result, id) => {
const account = state.entity.account[id];
if (account.type === 'savings') {
if (account.balance >= 1000) {
result[id] = {
...account,
balance: account.balance * 1.05
};
return result;
}
if (account.balance < 100) {
result[id] = {
...account,
balance: account.balance - 10
};
return result;
}
}
result[id] = account;
return result;
