© 2020, Onur Yıldırım (@onury). MIT License.
Utility for modifying / processing the contents of JavaScript objects and arrays, via object or bracket notation strings or globs. (Node and Browser)
Notation.create({ x: 1 }).set('some.prop', true).filter(['*.prop']).value // { some: { prop: true } }
Note that this library should be used to manipulate data objects with enumerable properties. It will NOT deal with preserving the prototype-chain of the given object or objects with circular references.
Install via NPM:
npm i notation
In Node/CommonJS environments:
const { Notation } = require('notation');
With transpilers (TypeScript, Babel):
import { Notation } from 'notation';
In (Modern) Browsers:
<script src="js/notation.min.js"></script>
<script>
const { Notation } = notation;
</script>
Notation
is a class for modifying or inspecting the contents (property keys and values) of a data object or array.
When reading or inspecting an enumerable property value such as obj.very.deep.prop
; with pure JS, you would have to do several checks:
if (obj
&& obj.hasOwnProperty('very')
&& obj.very.hasOwnProperty('deep')
&& obj.very.deep.hasOwnProperty('prop')
) {
return obj.very.deep.prop === undefined ? defaultValue : obj.very.deep.prop;
}
With Notation
, you could do this:
const notate = Notation.create;
return notate(obj).get('very.deep.prop', defaultValue);
You can also inspect & get the value:
console.log(notate(obj).inspectGet('very.deep.prop'));
// {
// notation: 'very.deep.prop',
// has: true,
// value: 'some value',
// type: 'string',
// level: 3,
// lastNote: 'prop'
// }
To modify or build a data object:
const notate = Notation.create;
const obj = { car: { brand: "Dodge", model: "Charger" }, dog: { breed: "Akita" } };
notate(obj) // initialize. equivalent to `new Notation(obj)`
.set('car.color', 'red') // { car: { brand: "Dodge", model: "Charger", color: "red" }, dog: { breed: "Akita" } }
.remove('car.model') // { car: { brand: "Dodge", color: "red" }, dog: { breed: "Akita" } }
.filter(['*', '!car']) // { dog: { breed: "Akita" } } // equivalent to .filter(['dog'])
.flatten() // { "dog.breed": "Akita" }
.expand() // { dog: { breed: "Akita" } }
.merge({ 'dog.color': 'white' }) // { dog: { breed: "Akita", color: "white" } }
.copyFrom(other, 'boat.name') // { dog: { breed: "Akita", color: "white" }, boat: { name: "Mojo" } }
.rename('boat.name', 'dog.name') // { dog: { breed: "Akita", color: "white", name: "Mojo" } }
.value; // result object ^
See API Reference for more...
With a glob-notation, you can use wildcard stars *
and bang !
prefix. A wildcard star will include all the properties at that level and a bang prefix negates that notation for exclusion.
Notation#filter()
method accepts glob notations. Regular notations (without any wildcard *
or !
prefix) should be used with all other members of the Notation
class.Notation.Glob
class.Removes duplicates, redundant items and logically sorts the array:
const { Notation } = require('notation');
const globs = ['*', '!id', 'name', 'car.model', '!car.*', 'id', 'name', 'age'];
console.log(Notation.Glob.normalize(globs));
// ——» ['*', '!car.*', '!id', 'car.model']
In the normalized result ['*', '!car.*', '!id', 'car.model']
:
id
is removed and !id
(negated version) is kept. (In normalization, negated always wins over the positive, if both are same).name
is removed. The remaining name
is also removed bec. *
renders it redundant; which covers all possible notations.car.model
is kept (although *
matches it) bec. it's explicitly defined while we have a negated glob that also matches it: !car.*
.console.log(Notation.Glob.normalize(globs, { restrictive: true }));
// ——» ['*', '!car.*', '!id']
Note:
Notation#filter()
andNotation.Glob.union()
methods automtically pre-normalize the given glob list(s).
Unites two glob arrays optimistically and sorts the result array logically:
const globsA = ['*', '!car.model', 'car.brand', '!*.age'];
const globsB = ['car.model', 'user.age', 'user.name'];
const union = Notation.Glob.union(globsA, globsB);
console.log(union);
// ——» ['*', '!*.age', 'user.age']
In the united result ['*', '!*.age', 'user.age']
:
!car.model
of globsA
is removed because globsB
has the exact positive version of it. (In union, positive wins over the negated, if both are same.) car.model
is redundant and removed bec. we have *
wildcard, which covers all possible non-negated notations. user.age
bec. we have a !*.age
in globsA
, which matches user.age
. So both are kept in the final array.When filtering a data object with a globs array; properties that are explicitly defined with globs or implied with wildcards, will be included. Any matching negated-pattern will be excluded. The resulting object is created from scratch without mutating the original.
const data = {
car: {
brand: 'Ford',
model: 'Mustang',
age: 52
},
user: {
name: 'John',
age: 40
}
};
const globs = ['*', '!*.age', 'user.age'];
const filtered = Notation.create(data).filter(globs).value;
console.log(filtered);
// ——»
// {
// car: {
// brand: 'Ford',
// model: 'Mustang'
// },
// user: {
// name: 'John',
// age: 40
// }
// }
In non-restrictive mode; even though we have the !*.age
negated glob; user.age
is still included in the result because it's explicitly defined.
But you can also do restrictive filtering. Let's take the same example:
const globs = ['*', '!*.age', 'user.age'];
const filtered = Notation.create(data).filter(globs, { restrictive: true }).value;
console.log(filtered);
// ——»
// {
// car: {
// brand: 'Ford',
// model: 'Mustang'
// },
// user: {
// name: 'John'
// }
// }
Note that in restrictive mode, user.age
is removed this time; due to !*.age
pattern.
Each note (level) of a notation is validated against EcmaScript variable syntax, array index notation and object bracket notation.
x[y]
, x.1
, x.y-z
, x.@
are incorrect and will never match. x["y"]
, x['1']
, x["y-z"]
, x['@']
are correct object bracket notations. [0].x
indicates x
property of the first item of the root array.x[1]
indicates second item of x
property of the root object.*
is valid wildcard for glob notation. Indicates all properties of an object.[*]
is valid wildcard for glob notation. Indicates all items of an array.x[*]
is valid wildcard for glob notation. Indicates all items of x
property which should be an array.x['*']
just indicates a property/key (star), not a wildcard. Valid regular notation.x.*
is valid wildcard for glob notation.x
, x.*
and x.*.*
(and so on) are all equivalent globs. All normalize to x
.!x
indicates removal of x
.!x.*
only indicates removal of all first-level properties of x
but not itself (empty object).!x.*.*
only indicates removal of all second-level properties of x
; but not itself and its first-level properties (x.*
).[0]
= [0][*]
but ![0]
≠ ![0][*]
x
= x[*]
but !x
≠ !x[*]
[*]
= [*].*
but ![*]
≠ ![*].*
Below, we filter to;
colors
property (which is an array),my-colors
property (which is an object).const source = {
name: 'Jack',
colors: ['blue', 'green', 'red'],
'my-colors': { '1': 'yellow' } // non-standard name "my-colors"
};
const globs = ['*', '!colors[1]', '!["my-colors"].*'];
console.log(Notation.create(source).filter(globs).value);
// —»
// {
// name: 'Jack',
// colors: ['blue', 'red'],
// 'my-colors': {}
// }
In the example above, colors
item at index 1 is emptied.In a glob list, you cannot have both object and array notations for root level. The root level implies the source type which is either an object or array; never both.
For example, ['[*]', '!x.y']
will throw because when you filter a source array with this glob list; !x.y
will never match since the root x
indicates an object property (e.g. source.x
).
Each glob you use should conform with the given source object.
For example:
const obj = { x: { y: 1 } };
const globs = ['*', '!x.*'];
console.log(Notation.create(obj).filter(globs).value);
// ——» { x: {} }
Here, we used !x.*
negated glob to remove all the properties of x
but not itself. So the result object has an x
property with an empty object as its value. All good.
But in the source object; if the actual value of x
is not an object, using the same glob list would throw:
const obj = { x: 1 }; // x is number
const globs = ['*', '!x.*'];
console.log(Notation.create(obj).filter(globs).value);
// ——» ERROR
This kind of type mismatch is critical so it will throw. The value 1
is a Number
not an object, so it cannot be emptied with !x.*
. (But we could have removed it instead, with glob !x
.)
The source object or array will be mutated by default (except the #filter()
method). To prevent mutation; you can call #clone()
method before calling any method that modifies the object. The source object will be cloned deeply.
const notate = Notation.create;
const mutated = notate(source1).set('newProp', true).value;
console.log(source1.newProp); // ——» true
const cloned = notate(source2).clone().set('newProp', true).value;
console.log('newProp' in source2); // ——» false
console.log(cloned.newProp); // ——» true
Note that
Notation
expects a data object (or array) with enumerable properties. In addition to plain objects and arrays; supported cloneable property/value types are primitives (such asString
,Number
,Boolean
,Symbol
,null
andundefined
) and built-in types (such asDate
andRegExp
).Enumerable properties with types other than these (such as methods, special objects, custom class instances, etc) will be copied by reference. Non-enumerable properties will not be cloned.
If you still need full clone support, you can use a library like lodash. e.g. `Notation.create(.cloneDeep(source))`_
You can read the full API reference here.
Read the CHANGELOG especially if you're migrating from version 1.x.x
to version 2.0.0
and above.
MIT.