qim
v0.0.52
Published
Immutable/functional select/update queries for plain JS.
Downloads
1,628
Readme
qim
Immutable/functional select/update queries for plain JS.
WARNING: Qim is already useful, but it's still considered experimental. It might have some rough edges, and the API might change!
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!
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;
}, {})
}
};
Yuck. That is ugly. Lots of references to things we don't really care about. Okay, hopefully nobody writes code like that. Let's use Lodash/fp to clean that up.
import fp from 'lodash/fp';
const newState = fp.update(['entity', 'account'], fp.mapValues(account =>
account.type === 'savings' ? (
fp.update('balance', fp.cond([
[bal => bal >= 1000, bal => bal * 1.05],
[bal => bal < 100, bal => bal - 10],
[fp.stubTrue, bal => bal]
]), account)
) : account
), state)
Okay, that's a lot more concise, but there are still some problems:
- We have to return
account
in the case where it's not a savings account. Our original requirement was really to filter out savings accounts and operate on those, but we can't really do that, because we want to modify the whole state. Using afilter
would strip out the accounts we don't modify. - Similarly, we have to return the balance even if it's not a low or high balance that we want to change.
- If we nest deeper and break out of point-free style, it gets pretty awkward to write or read the code. We could clean that up by splitting this into multiple functions, but remember how concise the requirement is vs the resulting code complexity.
- If none of our accounts actually match these criteria, we'll still end up with a new state object.
Qim boils this down to the essential declarative parts, using an expressive query path, and it avoids unnecessary mutations.
Let's stretch out the previous example to take a closer look at some of the navigators used.
const newState = update([
// A string navigates to that key in the object.
'entity', 'account',
// $each is like a wildcard that matches each value of an object or array.
$each,
// Functions act like predicates and navigate only if it matches.
account => account.type === 'savings',
// Another key navigator.
'balance',
// Arrays are just nested queries and will descend...
[
// Another predicate to test for a high balance.
bal => bal >= 1000,
// $apply is used to transform that part of the object.
$apply(bal => bal * 1.05)
],
// ...and then return
[
// Another predicate to test for a low balance.
bal => bal < 100,
// Having the transform function inside the query path allows
// us to do multiple transformations on different paths.
$apply(bal => bal - 10)
]
], state);
Because navigators are only ever working on the part of the object that you've navigated to, you don't ever have to worry about the parts of the object that you don't touch. Those parts remain intact.
These modifications are immutable, and they share unmodified branches:
console.log(newState !== state);
// true
console.log(newState[300] === state[300]);
// true
Changing something to its current value is a no-op:
const newState = update(['entity', 'account', 300, 'type', () => 'checking'], state);
console.log(newState === state);
// true
And of course these navigators are useful for selecting data too. Instead of modifying an object, the select
method navigates to each matching part of the query and returns all the matching parts in an array.
import {select} from 'qim';
const names = select(['entity', 'account', $each, 'owner'], state);
// ['joe', 'mary', 'bob']
Let's get a little more fancy. Let's grab all the usernames of people that have high balances.
import {has} from 'qim';
// All functions are curried, so you can leave off the data to get a function.
const hasHighBalance = has(['balance', bal => bal >= 1000]);
const usernames = select(['entity', 'account', $each, hasHighBalance, 'owner'], state);
// ['mary']
has
checks if a selection returns anything. We use currying to create a function for checking if an account balance
is high, and we use that as a predicate to select the owners with a high balance.
Cool, huh?
Installation
npm install qim --save
Usage
All functions and navigators are available as named exports:
import {select, update, $each, $apply} from 'qim';
Or of course you can just import everything:
import * as qim from 'qim';
You can also import individual functions. If you import from qim
, then don't do this! qim
points to a bundle that includes all the individual modules, so importing individual modules will import them again.
import select from 'qim/select';
import update from 'qim/select';
import $each from 'qim/$each';
import $apply from 'qim/$apply';
If you npm install qim
, you'll have UMD builds in node_modules/qim/build/umd/qim.js
and node_modules/qim/build/umd.min.js
.
API
All methods in this section are curried, meaning if you leave off any of the arguments, you'll get a function that takes the remaining arguments. Specifically, this is most useful for the last object
parameter. For example:
const archiveAllMessages = update(['messages', $each, 'isArchived', $set(true)]);
const archivedMessageState = archiveAllMessages(state);
apply(query, transform, object)
Just a convenience method for updating with a single transform ($apply) function.
apply(
['users', 'joe', 'name'],
name => name.toUpperCase(),
{users: {joe: {name: 'Joe'}}}
)
// {users: {joe: {name: 'JOE'}}}
apply(
['numbers', $each, value => value % 2 === 0],
num => num * num,
{numbers: [1, 2, 3, 4, 5, 6]}
)
// {numbers: [1, 4, 3, 16, 5, 36]}
find(query, object)
Like select
, but only returns a single result. If many results would be returned from a select
, it will return the first result.
find(
[$each, value => value % 2 === 0],
[1, 2, 3, 4, 5, 6]
)
// 2
Generally, this will perform much better than taking the first item of the array returned by a select
.
has(query, object)
Returns true if an object has a matching result.
has(
[$each, value => value % 2 === 0],
[1, 2, 3]
)
// true
has(
[$each, value => value % 2 === 0],
[1, 3, 5]
)
// false
select(query, object)
Returns an array of selected results from an object.
select(
['numbers', $each, value => value % 2 === 0],
{numbers: [1, 2, 3, 4, 5, 6]}
)
// [2, 4, 6]
set(query, value, object)
Just a convenience method to set a query path to a constant value.
set(
['users', 'joe', 'name'],
'Joseph',
{users: {joe: {name: 'Joe'}}}
)
// {users: {joe: {name: 'Joseph'}}}
set(
['numbers', $each, value => value % 2 === 0],
0,
{numbers: [1, 2, 3, 4, 5, 6]}
)
// {numbers: [1, 0, 3, 0, 5, 0]}
update(query, object)
Returns a mutation of an object without changing the original object.
update(
['users', 'joe', 'name', $apply(name => name.toUpperCase())],
{users: {joe: {name: 'Joe'}}}
)
// {users: {joe: {name: 'JOE'}}}
update(
['numbers', $each, value => value % 2 === 0, $apply(value => value * 2)],
{numbers: [1, 2, 3, 4, 5, 6]}
)
// {'numbers': [1, 4, 3, 8, 5, 12]}
Navigators
Built-in, type-based navigators
Key (string/integer)
Navigates to that key of an object/array.
select(
['users', 'name', 'joe'],
{users: {name: {joe: 'Joe'}}}
)
// [Joe]
select(
['0', '0'],
[['a', 'b'], ['c', 'd']]
)
// ['a']
update(
['users', 'name', 'joe', $set('Joseph')],
{users: {name: {joe: 'Joseph'}}}
)
// {"users": {"name": {"joe": "Joseph"}}}
update(
['0', '0', $apply(letter => letter.toUpperCase())],
[['a', 'b'], ['c', 'd']]
)
// [['A', 'b'], ['c', 'd']]
Predicate (function)
Passes the currently navigated value to the function and continues navigating if the function returns true.
select(
[$each, value => value > 0],
[-2, -1, 0, 1, 2, 3]
)
// [1, 2, 3]
update(
[$each, value => value > 0, $set(0)],
[-2, -1, 0, 1, 2, 3]
)
// [-2, -1, 0, 0, 0, 0]
Nested (array)
Branches and performs a sub-query. Mainly useful for update
, since you may want to update different branches of an object in different ways. You can branch with select
, but this is less useful since you typically want to select homogenous value types.
update(
[$each,
['x', $apply(x => x + 1)],
['y', $apply(y => y * 10)]
],
[{x: 1, y: 1}, {x: 2, y: 2}]
)
// [{x: 2, y: 10}, {x: 3, y: 20}]
Stop (undefined/null)
Stops navigation. This is most useful inside $nav
or custom path navigators to stop navigation for certain values.
select(['x', undefined, 'y'], {x: {y: 1}})
// []
update(['x', undefined, 'y', $apply(value => value + 1)], {x: {y: 1}})
// {x: {y: 1}}
For nested queries, undefined
/null
only stops the current nested query, not the whole query.
select([
['a', undefined, 'x'],
['b', 'x']
], {a: {x: 'ax'}, b: {x: 'bx'}})
// ['bx']
update([
['a', undefined, 'x', $apply(s => s.toUpperCase())],
['b', 'x', $apply(s => s.toUpperCase())]
], {a: {x: 'ax'}, b: {x: 'bx'}})
// {a: {x: 'ax'}, b: {x: 'BX'}}
Named navigators
By convention, all navigators are prefixed with a $
. This is mainly intended to visually distinguish them in a query path. But it also is meant to distinguish them from normal functions. Navigators are declarative, meaning they represent a navigation to be performed, rather than actually doing an operation.
$apply(fn)
Transforms the currently navigated value using the provided function. Typically used for update
, but can be used for select
to transform the values selected.
select(
['numbers', $each, $apply(value => value * 2)],
{numbers: [0, 1, 2, 3]}
)
// [0, 2, 4, 6]
update(
[$each, $apply(value => value * 2)],
{numbers: [0, 1, 2, 3]}
)
// {numbers: [0, 2, 4, 6]}
$begin
Navigates to an empty list at the beginning of an array. Useful for adding things to the beginning of a list.
update(
[$begin, $set([-2, -1, 0])],
[1, 2, 3]
)
// [-2, -1, 0, 1, 2, 3]
$default(value)
By default, Qim will create missing objects when you try to update a path that doesn't exist. You can use $default
to change this behavior and provide your own default value.
set(['x', $default([]), 0], 'a', {})
// {x: ['a']}
set(['x', $default({a: 0}), 'y'], 0, {})
// {x: {a: 0, y: 0}}
set(['names', $default(['a', 'b']), 0], 'joe', {})
// {names: ['joe', 'b']}
$each
Navigates to each value of an array or object.
update(
[$each, $apply(num => num * 2)],
[1, 2, 3]
)
// [2, 4, 6]
update(
[$each, $apply(num => num * 2)],
{x: 1, y: 2, z: 3}
)
// {x: 2, y: 4, z: 6}
select(
[$each],
[1, 2, 3]
)
// [1, 2, 3]
select(
[$each],
{x: 1, y: 2, z: 3}
)
// [1, 2, 3]
$eachKey
Navigates to each key of an array or object.
update(
[$eachKey, $apply(key => key.toUpperCase())],
{x: 1, y: 2, z: 3}
)
// {X: 1, Y: 2, Z: 3}
select(
[$eachKey],
{x: 1, y: 2, z: 3}
)
// ['x', 'y', 'x']
$eachPair
Navigates to each key/value pair of an array or object. A key/value pair is just an array of [key, value]
.
update(
[$eachPair, $apply(([key, value]) => [key.toUpperCase(), value * 2])],
{x: 1, y: 2, z: 3}
)
// {X: 2, Y: 4, Z: 6}
update(
[$eachPair,
[0, $apply(key => key.toUpperCase())],
[1, $apply(value => value * 2)]
],
{x: 1, y: 2, z: 3}
)
// {X: 2, Y: 4, Z: 6}
select(
[$eachPair],
{x: 1, y: 2, z: 3}
)
// [['x', 1], ['y', 2], ['z', 3]]
$end
Navigates to an empty list at the end of an array. Useful for adding things to the end of a list.
update(
[$end, $set([4, 5, 6])],
[1, 2, 3]
)
// [1, 2, 3, 4, 5, 6]
$first
Navigates to the first value of an array or object.
select([$first], [0, 1, 2])
// [0]
select([$first], {x: 0, y: 1, z: 2})
// [0]
update([$first, $set('first')], [0, 1, 2])
// ['first', 1, 2]
update([$first, $set('first')], {x: 0, y: 1, z: 2})
// {x: 'first', y: 1, z: 2}
$last
Navigates to the last value of an array or object.
select([$last], [0, 1, 2])
// [2]
select([$last], {x: 0, y: 1, z: 2})
// [2]
update([$last, $set('last')], [0, 1, 2])
// [0, 1, 'last']
update([$last, $set('last')], {x: 0, y: 1, z: 2})
// {x: 0, y: 1, z: 'last'}
$lens(fn, fromFn)
Lens is like $apply
, and the first function behaves identically, in that it transforms the current value using the provided function. But it also takes a second function that can be used to apply the result of a transformation to the current object during an update.
const $pct = $lens(
// The first function transforms the result, just like $apply.
// This simple example transforms a decimal value to its percentage equivalent.
n => n * 100,
// The second function inverts the transformation on the way back.
// This simple example transforms a percentage value to its decimal equivalent.
pct => pct / 100
);
update(
['x', $pct, pct => pct > 50, $apply(pct => pct + 5)],
{x: .75}
)
// {x: .80}
See custom navigators for more about $lens
.
$merge(spec)
Similar to $apply(object => ({...object, ...spec}))
except:
- Does not create a new object if
object
already has the same keys/values asspec
. - Will create a new array if
object
is an array so can be used to merge arrays.
update([$merge({y: 2})], {x: 1})
// {x: 1, y: 2}
update([$merge(['a'])], ['x', 'y', 'z'])
// ['a', 'y', 'z']
$mergeDeep(spec)
Deep merging version of $merge
. Typically, deep merging is better handled with nested queries. But if you must...
update([$mergeDeep({a: {ab: 2}})], {a: {aa: 1}, b: 2})
// {a: {aa: 1, ab: 2}, b: 2}
$nav(path, ...morePaths)
Given a query path, $nav
navigates as if that query was a single selector. This is useful for using queries as navigators (instead of nested queries). This has the same affect as spreading (...
) a query into another query.
const $eachUser = $nav(['users', $each]);
select(
[$eachUser, 'name'],
{
users: {
joe: {
name: 'Joe'
},
mary: {
name: 'Mary'
}
}
}
)
// ['Joe', 'Mary']
const $eachUser = $nav(['users', $each]);
update(
[$eachUser, 'name', $apply(name => name.toUpperCase())],
{
users: {
joe: {
name: 'Joe'
},
mary: {
name: 'Mary'
}
}
}
)
// {users: {joe: {name: 'JOE'}, mary: {name: 'MARY'}}}
You can pass multiple paths to $nav
to navigate to all of those paths.
update(
['users', $nav(['joe'], ['mary']), 'name', $apply(name => name.toUpperCase())],
{
users: {
joe: {
name: 'Joe'
},
mary: {
name: 'Mary'
}
}
}
)
// {users: {joe: {name: 'JOE'}, mary: {name: 'MARY'}}}
If path
is a function, it will be passed the current object, and it can return a dynamic query. This can be used to inline a custom navigator or just to get the current value in scope.
update(
[
$each, $nav(
obj => ['isEqual', $set(obj.x === obj.y)]
)
],
[{x: 1, y: 1}, {x: 1, y: 2}]
)
// [
// {x: 1, y: 1, isEqual: true},
// {x: 1, y: 2, isEqual: false}
// ]
See custom navigators for more about $nav
.
$none
Navigates to nothing so you can delete properties from objects and items from arrays. It would be great to use undefined
for this, but technically undefined
is a value in JS, so $none
exists to allow for the edge case of being able to set a property or item to undefined
.
update(
['x', $none],
{x: 1, y: 2}
)
// {y: 2}
update(
[0, $none],
['a', 'b']
)
// ['b']
update(
[$each, value => value % 2 === 0, $none],
[1, 2, 3, 4, 5, 6]
)
// [1, 3, 5]
$pick(keys, ...keys)
Navigates to a subset of an object, using the provided keys (or arrays of keys).
select(
[$pick('joe', 'mary'), $each, 'name'],
{joe: {name: 'Joe'}, mary: {name: 'Mary'}, bob: {name: 'Bob'}}
)
// ['Joe', 'Mary']
update(
[$pick('x', 'y'), $set({a: 1})],
{x: 1, y: 1, z: 1}
)
// {a: 1, z: 1}
update(
[$pick('x', 'y'), $each, $apply(val => val + 1)],
{x: 1, y: 1, z: 1}
)
{x: 2, y: 2, z: 1}
$pushContext(key, (obj, context) => contextValue)
This is a convenient form of $setContext
where the current value for the key is assumed to be an array and the new value is pushed onto that array. This is especially useful for recursive queries where you want to retain parent context values.
select(
[
$eachPair, $pushContext('path', find(0)), 1,
$eachPair, $pushContext('path', find(0)), 1,
$apply((message, ctx) => ({path: ctx.path, message}))
],
{error: {foo: 'a', bar: 'b'}, warning: {baz: 'c', qux: 'd'}}
)
// [
// {path: ['error', 'foo'], message: 'a'},
// {path: ['error', 'bar'], message: 'b'},
// {path: ['warning', 'baz'], message: 'c'},
// {path: ['warning', 'qux'], message: 'd'}
// ]
$set(value)
Just a convenience for setting a value, rather than using $apply(() => value)
. (Also a teensy bit more performant.)
update(
[$each, $set(0)],
[1, 2, 3]
)
// [0, 0, 0]
$setContext(key, (value, context) => contextValue)
Sets a context value for later retrieval in an $apply
or $nav
.
Context is a way to grab a piece of data at one point in a query and use it at a later point in a query. A good example is grabbing a key from a key/value pair and retrieving that key along with a descendant value so that it can be returned in a selection or used in an update. You could use a $nav
function to pull that key into scope, but for multiple levels of nesting, this can get unwieldy. And for recursive queries, this is impossible without creating some kind of complex enveloping mechanism.
$setContext
takes a key and a function as arguments. The key is just a way of keeping that piece of context separate from other pieces of context. The function takes the current value and context and returns a value to store for that piece of context. Note that context is never mutated, so the new context only applies to the rest of the query.
select(
[$setContext('first', find($first)), $each, $apply((letter, ctx) => `${ctx.first}${letter}`)],
['a', 'b', 'c']
)
// ['aa', 'ab', 'ac']
select(
[
$eachPair, $setContext('level', find(0)), 1,
$eachPair, $setContext('key', find(0)), 1,
$apply((message, ctx) => ({level: ctx.level, key: ctx.key, message}))
],
{error: {foo: 'a', bar: 'b'}, warning: {baz: 'c', qux: 'd'}}
)
// [
// {level: 'error', key: 'foo', message: 'a'},
// {level: 'error', key: 'bar', message: 'b'},
// {level: 'warning', key: 'baz', message: 'c'},
// {level: 'warning', key: 'qux', message: 'd'}
// ]
select(
[
$setContext('favorites', select(['favorites', $each])),
'stooges',
$nav((nodes, ctx) => [$pick(ctx.favorites)]),
$each, 'name'
],
{
stooges: {
a: {name: 'Moe'},
b: {name: 'Larry'},
c: {name: 'Curly'}
},
favorites: ['a', 'c']
}
)
// ['Moe', 'Curly']
$slice(begin, end)
Navigates to a slice of an array from begin
to end
index.
select(
[$slice(0, 3), $each],
[1, 2, 3, 4, 5, 6]
)
// [1, 2, 3]
update(
[$slice(0, 3), $each, $set(0)],
[1, 2, 3, 4, 5, 6]
)
// [0, 0, 0, 4, 5, 6]
update(
[$slice(2, 4), $set([])],
['a', 'b', 'c', 'd', 'e', 'f']
)
// ['a', 'b', 'e', 'f']
$traverse({select, update})
See custom navigators.
Custom navigators
Custom navigators are simply composed of other navigators. For example, you could create a $toggle
navigator that flips boolean values like this:
const $toggle = $apply(val => !Boolean(val));
update(['isOn', $toggle], {isOn: false})
// {isOn: true}
In particular though, the important navigators for building other navigators are $nav
, $lens
, and $traverse
.
$nav
lets you build "path" navigators. A path navigator is the highest level option. If you want to abstract away multiple other navigators, or you want to dynamically choose a navigator based on the current object being navigated to, or you want to do a recursive query, you probably want a path navigator.
$lens
lets you build "lens" navigators. A lens navigator is generally going to be simpler than a core navigator and will be almost as performant, but doesn't offer quite as much control. Think of a lens navigator as a two-way $apply
. You transform the current object for the rest of the query, and for an update, you define a transformation to be applied to the object. If you always want to call the rest of the query, and you only want to call the rest of the query once, then a lens navigator is a good fit.
$traverse
lets you build, for lack of a better term, "core" navigators. It's the lowest-level option and gives you complete control of data selection and updates. Generally, core navigators are going to be the most performant, but they're also going to require the most code. If you need to maybe call the rest of the query, or you want to call the rest of the query many times, you probably need a core navigator.
Path navigators
The simplest path navigator just points to a query path. This is useful for using queries as navigators (instead of nested queries). This has the same affect as spreading (...
) a query into another query.
const $eachUser = $nav(['users', $each]);
select(
[$eachUser, 'name'],
{
users: {
joe: {
name: 'Joe'
},
mary: {
name: 'Mary'
}
}
}
)
// ['Joe', 'Mary']
update(
[$eachUser, 'name', $apply(name => name.toUpperCase())],
{
users: {
joe: {
name: 'Joe'
},
mary: {
name: 'Mary'
}
}
}
)
// {users: {joe: {name: 'JOE'}, mary: {name: 'MARY'}}}
The path
passed to $nav
can also be a function, which allows it to provide a different path based on the current value.
const $fileNames = $nav(
path: (obj) => {
if (obj.type === 'folder') {
return ['files', $each, 'name'];
}
if (obj.type === 'file') {
return ['name'];
}
// Returns `undefined` which stops navigation.
}
);
select(
[$each, $fileNames],
[{type: 'file', name: 'foo'}, {type: 'folder', files: [{name: 'bar'}]}, {type: 'other', name: 'thing'}]
)
['foo', 'bar']
You can also create recursive queries using path navigators.
const $walk = $nav(
(item, $self) =>
Array.isArray(item) ? [$each, $self] : []
);
select(
[$walk, val => val % 2 === 0],
[0, 1, 2, [3, 4, 5, [6, 7, 8]]]
)
// [0, 2, 4, 6, 8]
update(
[$walk, val => val % 2 === 0, $apply(val => val * 10)],
[0, 1, 2, [3, 4, 5, [6, 7, 8]]]
)
// [0, 1, 20, [3, 40, 5, [60, 7, 80]]]
Parameterized path navigators
To parameterize a path navigator, just create a function that returns a path navigator.
const $take = (count) => $nav(
(obj) => {
if (!Array.isArray(obj)) {
throw new Error('$take only works on arrays');
}
return [$slice(0, count)];
}
);
select(
[$take(3), $each],
[0, 1, 2, 3, 4, 5]
)
// [0, 1, 2]
update(
[$take(3), $each, $apply(val => val * 10)],
[0, 1, 2, 3, 4, 5]
)
// [0, 10, 20, 3, 4]
Lens navigators
Lens navigators are built by using $lens
, which takes two functions. The first function applies a transformation for either a select or update. The second function is only invoked for an update and allows you to apply that transformation to the current object.
// Create a navigator that selects or modifies the length of an array.
const $length = $lens(
(object) => {
if (Array.isArray(object)) {
return object.length;
}
throw new Error('$length only works on arrays');
},
(newLength, object) => {
const newLength = next(object.length);
if (newLength < object.length) {
return object.slice(0, newLength);
}
if (newLength > object.length) {
object = object.slice(0);
for (let i = 0; i < newLength - object.length; i++) {
object.push(undefined);
}
return object;
}
return object;
}
);
select([$length], [1, 1, 1])
// [3]
set([$length], 3, [1, 1, 1])
// [1, 1, 1]
set([$length], 2, [1, 1, 1])
// [1, 1]
set([$length], 4, [1, 1, 1])
// [1, 1, 1, undefined]
Parameterized lens navigators
To parameterize a lens navigator, just create a function that returns a lens navigator.
const $split = (char) => $lens(
(string) => string.split(char),
(splitString) => splitString.join(char)
);
update(
[$split('@'), 0, $apply(name => name.toUpperCase())],
'[email protected]'
)
// [email protected]
Core navigators
Core navigators are built by using $traverse
, which takes an object with a select
and update
function.
// Create a navigator that selects or modifies the length of an array.
const $length = $traverse({
select: (object, next) => {
if (Array.isArray(object)) {
return next(object.length);
}
throw new Error('$length only works on arrays');
},
update: (object, next) => {
if (Array.isArray(object)) {
const newLength = next(object.length);
if (newLength < object.length) {
return object.slice(0, newLength);
}
if (newLength > object.length) {
object = object.slice(0);
for (let i = 0; i < newLength - object.length; i++) {
object.push(undefined);
}
return object;
}
return object;
}
throw new Error('$length only works on arrays');
}
});
select([$length], [1, 1, 1])
// [3]
set([$length], 3, [1, 1, 1])
// [1, 1, 1]
set([$length], 2, [1, 1, 1])
// [1, 1]
set([$length], 4, [1, 1, 1])
// [1, 1, 1, undefined]
Parameterized core navigators
To parameterize a core navigator, just create a function that returns a core navigator.
// Create a navigator that selects or updates the first n items of an array.
const $take = (count) => $traverse({
select: (object, next) => {
if (Array.isArray(object)) {
return next(object.slice(0, count));
}
throw new Error('$take only works on arrays');
},
update: (object, next) => {
if (Array.isArray(object)) {
const result = next(object.slice(0, count));
const newArray = object.slice(0);
newArray.splice(0, count, ...result);
return newArray;
}
throw new Error('$take only works on arrays');
}
});
select([$take(2)], ['a', 'b', 'c'])
// [['a', 'b']]
set([$take(2)], ['x'], ['a', 'b', 'c'])
// ['x', 'c']
Performance
Qim aims to be performant enough.
For Lodash operations that are immutable (like get
and set
), Qim should have similar performance. Many Lodash functions mutate (like update
), and in nearly all cases, Qim will be faster than Lodash/fp's immutable functions. Likewise, Qim will typically be faster than React's immutability helper (now an external package). Ramda seems to perform much better than Lodash/fp, and sometimes it will be faster than Qim, while sometimes Qim will be faster.
In some cases, a custom native helper function using Object.assign
or slice
along with a mutation may be faster for simple operations, but Qim aims to be as close as possible, while still allowing for a flexible querying API.
Comparing to Immutable.js is difficult in that it heavily depends on your particular use case. The sweet spot for Immutable.js is lots of transformations of large objects or arrays (thousands of items). And even then, you need to avoid marshalling data back and forth between Immutable and plain JS. If you marshal the data back and forth, you'll lose most of the benefit, and if you work with smaller objects and arrays, you're unlikely to see much benefit.
If you want flexible select and update of plain JS objects, Qim is likely to be a good fit. You can check out the current benchmarks to get an idea how Qim stacks up. As with all benchmarks, be careful reading into them too much. Also, Qim is new, and performance tradeoffs could change in favor of simplifying the code or API. Overall, remember that the goal of Qim is to be expressive and performant enough, not to win at every benchmark.
TODO
- Work with iterables (like Map).
- More tests.
- Better error messages.
- Expose ES6 modules.
- Static typing? Qim might be diametrically opposed to static types, but worth seeing how good/bad they would fit.
Contributing
- Do the usual fork and PR thing.
- Make sure tests pass with
npm test
and preferaby add additional tests. - Make sure to run
npm run benchmark
to make sure the benchmarks pass. Currently, the benchmarks are running against Node 6.2.2 on a late 2013 MacBook Pro. The benchmarks are by no means perfect, and small regressions may be allowed in exchange for significant improvements, but for the most part, performance needs to remain consistent or improve. - Thanks!
Thanks
As mentioned above, the navigator concept borrows heavily from Specter, a Clojure library written by Nathan Marz.