Functional Expressions ("fexp") provides a simple functional scripting syntax. fexp-js is a supported JavaScript implementation for developers to offer in their applications.
- Simple syntax 😌
- General purpose expressions (filtering, map/reduce etc) 🔨 🔧
- Portable via serialization (JSON/YAML) ✉️
- Compiles expressions into functions 🚤 🚀
- Tiny, with a full-featured syntax 🎒
- Optional libs, for GIS 🌍 🌎 🌏
- Or able to support your own set of functions ✂️ 💡
Developers can implement fexp into your application environments to offer scripting syntax within their product for other developers. These could be used to describe filter evaluation criteria, or perform various tranformations or map/reduce expressions.
[<name>, [arg1[, arg2[, ..., argN]]]]
fexp processes the syntax and will invoke the required language functions as defined in the supplied lang features.
"fexp" expressions form a tree structure. Params are evaluated using a depth-first approach, evaluating parameters before executing parent functions.
// Given the expression:
const expr = ["all", ["is-boolean", true], ["==", "foobar", "foobar"]];
// Order of evaluation, DFS
// 1. Evaluate ["is-boolean", ...]
// 2. Evaluate ["==", ...]
// 3. Evaluate ["all", ...]
The fexp-js library is generic enough in scripting purpose to have a wide range of use cases. It could be used for filtering, other map/reduce functions.
Expressions can be used to filter a collection.
import { parse } from "@alpaca-travel/fexp-js";
import lang from "@alpaca-travel/fexp-js-lang";
// Our collection (see hotels.json for example)
import hotels from "./hotels.json";
// Serializable/stringify expressions (not required)
const expr = [
"all",
[">=", ["get", "stars-rating"], 3.5],
["in", ["get", "tags"], "boutique"],
];
// Compile our expression
const fn = parse(expr, lang);
// Match against our collection
const firstMatch = hotels.find((item) => fn(item));
console.log(firstMatch);
The following language functions are part of the core expression library. They are used to support some of the runtime functions, such as dealing with literal arrays, negation and functions. You don't need to supply these in your language set for them to be used.
To stop processing through params (such as an string array which appears like an expression), use the "literal" function.
// Use literal to take the params without evaluating the array contents
const expr = ["in", ["literal", ["foo", "bar"]], "bar"];
You can negate an expression with either the function prefix of !fn
or using "!" by itself.
const expr = ["!", ["==", "foo", "bar"]]; // Negates the == result
const expr2 = ["!my-function"]; // Negates the result of the function call to "my-function"
You can build functions that can be passed as function arguments to other functions (such as map reduce etc)
const expr = ["fn", ...]; // Returns a function that can be executed with arguments
- Equality: ==, !=, <, >, <=, >=, eq, lt, lte, gt, gte
- Deep Equality: equal/equals, !equal/!equals
- Accessors: get (also using paths like foo.bar), at, length, fn-arg
- Existence: has/have/exist/exists/empty, !has/!have/!exist/!exists/!empty
- Membership: in/!in
- Types: typeof, to-boolean, to-string, to-number, to-regex, to-date, is-array, is-number, is-boolean, is-object, is-regex
- Regular Expressions: "regex-test"
- Combining: all/any/none
- String manipulation: concat, uppercase, lowercase
- Math: +, -, *, /, floor, ceil, sin/cos/tan/asin/acos/atan, pow, sqrt, min, max, random, e, pi, ln, ln2, ln10, log2e, log10e
- control: match, case
- map reduce: map/reduce/filter/find
- more..
fexp-js-lang supports a number of functions to work with types in expressions.
// Obtain the "typeof" of parameter
["typeof", "example"] === "string"
["typeof", true] === "boolean"
["typeof", { foo: "bar" }] === "object"
["typeof", ["literal", ["value1", "value2"]]] === "array"
// Cast the param as boolean
["to-boolean", "yes"] === true
// Check if the param is a boolean
["is-boolean", false] === true
// Cast the param as string
["to-string", true] === "true"
// Check if the param is a string
["is-string", "foo"] === true
// Cast the param as number
["to-number", "10"] === 10
// Check if the param is a number
["is-number", "10"] === false
["is-number", 10] === true
// Cast the param as RegExp
["to-regex", "regex?", "i"] === new RegExp("regex?", "i")
// Check if the param is a RegExp
["is-regex", new RegExp("regex", "i")] === true
// Cast the param as Date
["to-date", "2020-01-01"] === new Date("2020-01-01")
// Check if the param is a Date
["is-date", new Date("2020-01-01")] === true
import { parse } from "@alpaca-travel/fexp-js";
import lang from "@alpaca-travel/fexp-js-lang";
describe("Using Types with fexp-js-lang", () => {
it("will return typeof for the supplied parameters", () => {
// ['typeof', 0] === 'number'
expect(parse(["typeof", 0], lang)()).toBe("number");
expect(parse(["typeof", "test"], lang)()).toBe("string");
expect(parse(["typeof", { foo: "bar" }], lang)()).toBe("object");
expect(parse(["typeof", ["literal", ["value1", "value2"]]], lang)()).toBe(
"object"
);
});
it("will cast using to-boolean", () => {
expect(parse(["to-boolean", "true"], lang)()).toBe(true);
expect(parse(["to-boolean", "yes"], lang)()).toBe(true);
expect(parse(["to-boolean", "false"], lang)()).toBe(false);
expect(parse(["to-boolean", "0"], lang)()).toBe(false);
});
});
// Supporting basic if/then/else
["case", true, 1, 2] === 1
["case", false, 1, 2] === 2
["case", false, 1, true, 2, 3] === 2
["case", false, 1, false, 2, 3] === 3
// Match
["match", "target", ["a", "set", "of", "target"], 1, 2] === 1
["match", "foo", ["a", "set", "of", "target"], 1, 2] === 2
["match", "foo", ["a", "set", "of", "target"], 1, ["foo"], 2, 3] === 2
["match", "foo", ["a", "set", "of", "target"], 1, ["bar"], 2, 3] === 3
Using the "fn" and "fn-arg" operators, you can combine with "map"/"reduce"/"filter".
// Map
[
"map",
[1, 2, 3], // Collection
[
"fn", // Build a map function
[
"*",
["fn-arg", 0], // item
["fn-arg", 1], // index
]
]
] === [0, 2, 3]
// Reduce
[
"reduce",
[1, 2, 3], // Collection
[
"fn", // Build a reduce function
[
"*",
["fn-arg", 0], // carry
["fn-arg", 1], // item
]
],
2 // initial value
] === 12
// Filter
[
"filter",
[1, 2, 3],
[
"fn",
[
">=",
2,
["fn-arg", 0]
]
]
] === [2, 3]
// Find
[
"find",
[1, 2, 3],
[
"fn",
[
"<"
2,
["fn-arg", 0]
]
]
] === 3
The optional GIS language enhancements provides language enhancements for working with GIS based scripting requirements.
- Boolean comparisons; geo-within, geo-contains, geo-disjoint, geo-crosses, geo-overlap
yarn add @alpaca-travel/fexp-js @alpaca-travel/fexp-js-lang
yarn add @alpaca-travel/fexp-js-lang-gis
Evaluates an expression without use of compilation (so is therefore slower than compiling).
import { parse } from "@alpaca-travel/fexp-js";
import lang from "@alpaca-travel/fexp-js-lang";
// Simple expression
const expr = ["==", ["get", "foo"], "bar"];
// Prepare function
const fn = parse(expr, lang);
console.log(fn({ foo: "bar" })); // <-- true
Composites langs together to mix in different function support
import { langs } from "@alpaca-travel/fexp-js";
// Lang modules offered
import std from "@alpaca-travel/fexp-js-lang";
import gis from "@alpaca-travel/fexp-js-lang-gis";
// Custom library with your own modues
import myLib from "./my-lib";
// Composite the languages, mixing standard, gis and custom libs
const lang = langs(std, gis, myLib);
// Evaluate now with support for multiple
evaluate(["all", ["my-function", "arg1"], ["==", "foo", "foo"]], lang);
console.log(result); // <-- true
import { langs, parse } from "@alpaca-travel/fexp-js";
import std from "@alpaca-travel/fexp-js-lang";
// Implement a sum function to add resolved values
const sum = ([...args]) => args.reduce((c, t) => c + t);
// Build an expression
const expr = ["sum", 1, 2, 3, 4];
// Add the "sum" function to the standard lang
const lang = langs(std, { sum });
// Compile for execution
const fn = parse(expr, lang);
// Process the compiled function
console.log(fn()); // <-- 10
You can customise the parsing behaviour of your functions by supplying your function with a parse implementation.
// A trivial function
const myMixedLiteralFunction = ([arg0, arg1]) => arg0.contains(arg1);
// Customise the parse behaviour
myMixedLiteralFunction.parse = (args, parser) => {
// Treat the first as a literal, and the second as an expression
return [args[0], parse(args[1])];
};
const parsed = parse(["my-mixed-literal-function", [1, 2, 3], ["get", "foo"]]);
parsed({ foo: 1 });
Note: You can also return a new function as a result, if you want to perform an initialisation on the supplied arguments.
Parsing options can perform typechecking on your expression to evaluate the compatibility of your expressions.
To support typechecking compatibility, you will need to define the argument types and possible return types.
Arguments are only processed when they are accessed. This is for an optimisation so that when using none/any/all etc it can terminate early, without evaluating the remaining expressions.
You functions are provided with the signature fn(args)
.
Args provides access to the args of an expression, as well as context vars (aka stack). The args object is an iterator so you can use similar method signatures to peel off arguments.
const customFn = (args) => {
// Resolved expression args
const [arg0, arg1] = args;
// Runtime args
const { context } = args;
// Accessing the current context vars
// These will contain the initial parse(X) arguments supplied to top level
const {
vars: { arguments: runtimeArguments },
} = context;
};
When your function is invoked, the special vars of "arguments" is assigned the function arguments. In the case of using expressions (using API compiled or evaluate), they are assigned to vars.
const lang = {
// Capture the context and args
['my-function']: (args) => {
const [arg0, arg1] = args;
console.log(arg0, arg1); // Prints "farg1", "farg2"
const { context } = args;
console.log(context); // Prints { vars: { arguments: ["arg1", "arg2"] } }
}
// Execute to capture
const result = parse(["my-function", "farg1", "farg2"], lang);
result("arg1", "arg2");
By executing a function in your expression (e.g. by calling "fn" to create a sub-function), when invoked will contain a new context, and the context vars "arguments" contain the arguments passed to your function. By using "fn-args" (in the standard library), you can access the function arguments by index.
- This package uses lerna
- Builds are done using rollup
$ cd packages/fexp-js
$ yarn && yarn test
$ cd packages/fexp-js
$ yarn && yarn build && yarn benchmark
$ docsify init ./docs
$ docsify serve ./docs