Skip to content

Latest commit

 

History

History
306 lines (232 loc) · 11.1 KB

README.md

File metadata and controls

306 lines (232 loc) · 11.1 KB

Botanist Build Status

A JavaScript DSL for taming tree structures using rules about the parts they're composed from. Inspired by Parslet's Transforms, Botanist allows you to define a transformation over arbitrarily complex data by describing the structure of the specific constituents that you're interested in.

Getting Started

A Botanist transform is composed of one or more rules. Each rule declares the structure it intends to match and a function to be called any time a matching structure is found. Note that this sequence of examples uses the proposed decorator syntax for declaring rules, but if you'd prefer plain old ES5, examples of how to do that are further below.

Hello, World

Let's start with nearly the simplest possible rule. We'd like to match any object with a single key message whose value is 'hello'. If we find such an object, we'd like to expand the scope of that message to address the entire world.

import { transform, rule } from 'botanist';

let myFirstTransform = transform({
  @rule({ message: 'hello' })
  expandHorizons() {
    return { message: 'hello', scope: 'world' }};
  }
});

myFirstTransform({ message: 'hello' });
// => { message: 'hello', scope: 'world' }

myFirstTransform({});
// => {}

myFirstTransform({ message: 'hello', irrelevant: true });
// => { message: 'hello', irrelevant: true }

myFirstTransform({ deeply: { nested: { message: 'hello' } } });
// => { deeply: { nested: { message: 'hello', scope: 'world' } } }

Some important things to keep in mind about rules:

  • expandHorizons() could have been called anything, but picking a method name that describes what the rule does can be helpful for readability and testing
  • anything that doesn't match a rule will come out the other side untouched
  • an object-based rule will only apply to an object with exactly the same keys as the rule itself
  • rules can also match based on the contents of arrays (e.g. @rule([1, 2, 3]))
  • a single rule can apply to multiple different substructures in one transformation

Capturing Data

While there are plenty of scenarios where we may know the exact structure we'd like to match, what about cases where we only know part of it? For this, Botanist supplies a set of matchers which can match and capture data in positions where you don't necessarily know what's going to appear. The simplest matcher is (shockingly) called simple. It will match any JavaScript primitive like a number or string, and pass it through to the rule's function using the given name.

import { transform, rule, simple } from 'botanist';

let doMath = transform({
  @rule({ op: 'add', lhs: simple('left'), rhs: simple('right') })
  add({ left, right }) {
    return left + right;
  },

  @rule({ op: 'sub', lhs: simple('left'), rhs: simple('right') })
  subtract({ left, right }) {
    return left - right;
  }
});

doMath({ op: 'add', lhs: 1, rhs: 2 });
// => 3

doMath({ op: 'sub', lhs: { op: 'add', lhs: 2, rhs: 2 }, rhs: 1 });
// => 3

doMath({ op: 'add', lhs: [1, 2], rhs: 3 });
// => { op: 'add', lhs: [1, 2], rhs: 3 }

Note that you may bind two different fields in a single rule to the same name. If you do so, the rule will only be considered to match if both fields have the same value (according to ===).

More information about simple and the other available matchers can be found in a dedicated section below.

Relaxing Structural Restrictions

By default, @rule({ tag: 'important' }) will only match objects whose only key is tag with the value 'important'. What if we want to match any object tagged as important, regardless of the rest of its structure? Enter rest().

import { transform, rule, rest } from 'botanist';

let emphasizeImportantThings = transform({
  @rule({ tag: 'important', ...rest('item') })
  emphasizeIt({ item }) {
    let result = {};
    for (let [key, value] of Object.entries(item)) {
      result[key.toUpperCase()] = `${value}`.toUpperCase();
    }
    return result;
  }
});

emphasizeImportantThings([
  { tag: 'important', message: 'uh oh' },
  { tag: 'snoozed', key: 'nbd' },
  { tag: 'important', subject: 'hi', content: 'are you there?' }
]);
// => [{ MESSAGE: 'UH OH' }, { tag: 'snoozed', key: 'nbd' }, { SUBJECT: 'HI', CONTENT: 'ARE YOU THERE?' }]

You can also use rest to bind the remaining elements of an array:

import { transform, rule, simple, rest } from 'botanist';

let queenOfHearts = transform({
  @rule([simple('head'), ...rest('tail')])
  offWithIt({ head, tail }) {
    return { head, tail };
  }
});

queenOfHearts([1, 2, 3, 4, 5]);
// => { head: 1, tail: [2, 3, 4, 5] }

If you're operating in an environment without the ... spread operator, rest also has an ES5-compatible usage pattern where it wraps the pattern in question instead of spreading into it.

For objects, rather than writing { x: 'y', ...rest('remainder') }, you'd write rest({ x: 'y' }, 'remainder').

For arrays, [1, 2, ...rest('remainder')] becomes rest([1, 2], 'remainder').

Rule Interactions

Rules are applied in the order given, so if two rules could both match the same object, the first one will "win" and be applied. Rules are also applied from the bottom up, so all properties of an object will be considered and potentially transformed before the object itself is evaluated.

import { transform, rule, simple, sequence } from 'botanist';

let makeValueJudgments = transform({
  @rule([simple('first'), simple('second'), simple('third')])
  shoutItOut({ first, second, third }) {
    return [first, second, third].map(item => item.toUpperCase());
  },

  @rule({ value: 42 })
  handleSpecialValue() {
    return 'special';
  },

  @rule({ value: simple('value') })
  handleBoringValue({ value }) {
    return `ordinary (${value})`;
  }
});

makeValueJudgments([
  { value: 1 },
  { value: 42 },
  { value: 100 }
]);
// => ['ORDINARY (1)', 'SPECIAL', 'ORDINARY (100)']

Available Matchers

simple(name)

The simple matcher will bind any JS primitive or custom class instance to the given name. It may seem strange that custom classes are considered "simple", but since Botanist only traverses down through arrays and POJOs by design, instances of MyClass are treated as terminal "leaf" nodes.

Many use cases for Botanist involve taking a JSON-compatible object and either distilling it down to a simpler encoding such as a string, or building it into a richer one, such as a hierarchy of complex objects with their own methods and prototype chains. Because of this, matching on simple subtrees is a way to help catch bugs in your transform early, since a deep JSON object that no rule matches will ripple its "non simpleness" back up to the top, preventing any of its ancestors from matching as well and making it clear where the problem was introduced.

Sample Values simple Will Match

  • null
  • undefined
  • false
  • 87
  • 'hello'
  • new MyClass()

Sample Values simple Will Not Match

  • [1, 2, 3]
  • { x: 1, y: 2 }
  • []
  • {}

choice(options, name)

The choice matcher will bind any simple value that is present in a given list of options.

Sample Values choice Will Match

  • choice([1, 2, 3])
    • 1
    • 2
    • 3
  • choice(['hello'])
    • 'hello'

Sample Values choice Will Not Match

  • choice([1, 2, 3])
    • 4
    • '1'
  • choice([null, false])
    • undefined
    • ''
    • []
  • choice([])
    • null
    • undefined
    • ''
    • []

sequence(name)

The sequence matcher will bind an array of simple elements to the given name.

Sample Values sequence Will Match

  • [1, 2, 3]
  • ['hi', false, new MyClass()]
  • []

Sample Values sequence Will Not Match

  • [{}]
  • [[1, 2, 3]]
  • {}
  • 'sequence'

match(regex, name)

The match matcher accepts a regular expression, and will bind an array of captured strings to the given name. The final element of the array will be the full matched string.

Sample Values match Will Match

  • match(/foo(bar|baz)/)
    • 'foobar' => ['bar', 'foobar']
    • 'foobaz' => ['baz', 'foobaz']
    • 'abcfoobardef' => ['bar', 'foobar']
  • match(/\d+/)
    • '123' => ['123']
    • 123 => ['123']
  • match(/.*/)
    • undefined => ['undefined']
    • null => ['null']
    • true => ['true']
    • new MyClass() => ['[object Object]']

Note that match will coerce any simple value to a string before evaluating whether or not it matches the given regular expression.

Sample Values match Will Not Match

  • match(/foo(bar|baz)/)
    • ''
    • 'qux'
  • match(/.*/)
    • []
    • {}

subtree(name)

The subtree matcher binds any value to the given name, including arrays and POJOs. Use with caution, as this escape valve has the potential to be a footgun. If you know anything about the structure you're attempting to match, you're probably better off using rest, but if you truly want to match anything, subtree is an efficient way to do it.

Transform Options

When running a transform, you can pass a second argument if you want to be able to customize its behavior. This value will be exposed to every rule function as it executes.

let replaceNames = transform({
  @rule({ name: simple('name') })
  replaceName({ name }, replacements = {}) {
    return { name: replacements[name] || name };
  }
});

let people = [
  { name: 'Alice' },
  { name: 'Bob' }
];

replaceNames(people);
// => [{ name: 'Alice' }, { name: 'Bob' }]

replaceNames(people, { Bob: 'Barbara' });
// => [{ name: 'Alice' }, { name: 'Barbara' }]

replaceNames(people, { Alice: 'Alex', Bob: 'Brad' });
// => [{ name: 'Alex' }, { name: 'Brad' }]

Modularity and Composition

Rather than a single object with rules, transform will also accept an array of such objects. In this way, you have the option of packaging up your rules into smaller logical groups that you can develop and test individually.

Then, you can compose all those sets of rules together to produce your final transformation function. Note that, just like with a single object, rules will be tested in the order given.

Usage Without Decorators

If you wish to use Botanist in an ES5 environment (or you just don't like having to come up with names for your rules), everything documented above will also work if you treat rule as a regular function and just pass a second argument representing the rule's behavior.

Revisiting our math example from earlier:

var botanist = require('botanist');
var simple = botanist.simple;
var rule = botanist.rule;

var doMath = botanist.transform([
  rule({ op: 'add', lhs: simple('left'), rhs: simple('right') }, function(values) {
    return values.left + values.right;
  }),

  rule({ op: 'sub', lhs: simple('left'), rhs: simple('right') }, function(values) {
    return values.left - values.right;
  })
]);

doMath({ op: 'add', lhs: 1, rhs: 2 });
// => 3

doMath({ op: 'sub', lhs: { op: 'add', lhs: 2, rhs: 2 }, rhs: 1 });
// => 3

doMath({ op: 'add', lhs: [1, 2], rhs: 3 });
// => { op: 'add', lhs: [1, 2], rhs: 3 }