Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add new rule parser package #65171

Closed
wants to merge 11 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -1925,6 +1925,12 @@
"markdown_source": "../packages/router/README.md",
"parent": "packages"
},
{
"title": "@wordpress/rule-parser",
"slug": "packages-rule-parser",
"markdown_source": "../packages/rule-parser/README.md",
"parent": "packages"
},
{
"title": "@wordpress/scripts",
"slug": "packages-scripts",
Expand Down
13 changes: 13 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@
"@wordpress/reusable-blocks": "file:packages/reusable-blocks",
"@wordpress/rich-text": "file:packages/rich-text",
"@wordpress/router": "file:packages/router",
"@wordpress/rule-parser": "file:packages/rule-parser",
"@wordpress/server-side-render": "file:packages/server-side-render",
"@wordpress/shortcode": "file:packages/shortcode",
"@wordpress/style-engine": "file:packages/style-engine",
Expand Down
1 change: 1 addition & 0 deletions packages/rule-parser/.npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
package-lock=false
5 changes: 5 additions & 0 deletions packages/rule-parser/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<!-- Learn how to maintain this file at https://github.com/WordPress/gutenberg/tree/HEAD/packages#maintaining-changelogs. -->

## Unreleased

Initial release.
180 changes: 180 additions & 0 deletions packages/rule-parser/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
# Rule Parser

This package provides a rule parser to evaluate logical rules into a boolean value.

## Installation

Install the module

```bash
npm install @wordpress/rule-parser --save
```

## API

> [!NOTE]
> This package is only meant to be used by implementors, not direct developers. implementors are then responsible for collecting rules from developers or users, either via UI (like Blocks Visibility), or code (like DataForms), evalute the rules against their provided context, and use the outcome however they see fit.

The package accepts rules in the shape of `[ source, operator, target ]` and can be infinitely nested on a variation of combinators, that resemble `||` (OR, ANY) or `&&` (AND, ALL).

### Examples of rules

1. Simple rule

```js
const rule = [ [ 'user.role', 'is', 'editor' ] ];
```

2. Array of rules

```js
const rules = [
[ 'user.role', 'is', 'editor' ],
[ 'post.categories', 'contains', 'tutorials' ],
];
```

3. Array of rules with explicit combinator. If no combinator is set, `ALL` will be used.

```js
const rules = [
'ALL',
[
[ 'user.role', 'is', 'editor' ],
[ 'post.categories', 'contains', 'tutorials' ],
],
];
```

4. Nested arrays
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice! Thanks for considering these cases.


```JS
const rules = [
'ANY',
[
[ 'user.role', 'is', 'editor' ],
[ 'post.categories', 'contains', 'tutorials' ],
[
'ALL',
[
[ 'user.id', 'in', [ 1, 2, 3 ] ],
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dot notation makes me think we'll be able to access nested properties. For example, user.id would access the property here:

{
    "user": {
        "id": 5
    }
}

But looking at the sample data below, it looks like that might not be the case. Do we want to support dot notation or will consumers be responsible for flattening all their data?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The very initial implementation we had at Checkout operated that way, this added the need to parse things. I think we might want to provide even greater flexibility to consumers, so instead of passing an object of key value, they can also pass an object of keys and functions/loaders, or receive a single loader function and they decide what to do with it.

For now we can probably settle on supporting dot notation out of the box?

[ 'post.blocks', 'not contains', 'core/embed' ]
]
]
]
];
```

### Anatomy of a rule

A rule is made of 3 values, a source, an operator, and a target.

- **Source**: which start as a rawSource (a string) that will be evaluated to a primitive (string, number, boolean) or an array of primitives, evaluation is done by using the context that the implementor provides.
- **Operator**: a function that compares an evaluated source to a target value, this package ships with 10 operator, and provides the ability to alias them as well as providing new operators.
- **Target**: a primitive (string, number, boolean) or an array of primitives (all the same type), targets are static at code/UI level, and should be considered static regardless of session and place (PHP or JS).

Along of combinators, an array of rules can be transformed to a single boolean.

### Usage

```js
import { parser } from "@wordpress/rule-parser";

const context = {
'user.id': 1.
'user.role': 'admin',
'post.categories': [ 'tutorials' ],
'post.blocks': [ 'core/paragraph', 'core/heading', 'woocommerce/checkout' ]
};

const rules = [
'ANY',
[
[ 'user.role', 'is', 'editor' ],
[ 'post.categories', 'contains', 'tutorials' ],
[
'ALL',
[
[ 'user.id', 'in', [ 1, 2, 3 ] ],
[ 'post.blocks', 'not contains', 'core/embed' ]
]
]
]
];

const result = parser( rules, context );

console.log( result ); // true
```

### Operators

The package ships with 10 operators, and provides the ability to alias them as well as providing new operators.

Here's a table of the available operators, their descriptions, examples, and aliases:

| Operator | Description | Example | Aliases |
| -------------- | ------------------------------------------------------------------------- | ----------------------------------------------- | ----------- |
| `is` | Checks if the source is loosly equal to the target | `['user.role', 'is', 'admin']` | `=` |
| `is not` | Checks if the source is not loosly equal to the target | `['user.role', 'is not', 'subscriber']` | `!=` |
| `contains` | Checks if the source array includes the target value | `['post.categories', 'contains', 'tutorials']` | N/A |
| `not contains` | Checks if the source array does not include the target value | `['post.blocks', 'not contains', 'core/embed']` | `!contains` |
| `in` | Checks if the source value is in the target array | `['user.id', 'in', [1, 2, 3]]` | N/A |
| `not in` | Checks if the source value is not in the target array | `['user.id', 'not in', [4, 5, 6]]` | `!in` |
| `greater than` | Checks if the source number is greater than the target number | `['post.comments', 'greater than', 10]` | `>` |
| `less than` | Checks if the source number is less than the target number | `['post.views', 'less than', 100]` | `<` |
| `gte` | Checks if the source number is greater than or equal to the target number | `['post.comments', 'gte', 10]` | `>=` |
| `lte` | Checks if the source number is less than or equal to the target number | `['post.views', 'lte', 100]` | `<=` |

In addition to the built-in operators, the package includes a registry system for custom operators and aliases. This allows implementors to extend or customize the rule system as needed.

```js
import { parser, registry } from '@wordpress/rule-parser';

registry.register( 'between', ( source, target, rule ) => {
if ( typeof source !== 'number' ) {
throw new TypeError( 'Source must be of number' );
}

if ( ! Array.isArray( target ) || target.length !== 2 ) {
throw new TypeError( 'Target must be an array of 2 numbers.' );
}

const [ min, max ] = target;

if ( typeof min !== 'number' || typeof max !== 'number' ) {
throw new TypeError( 'Target must be an array of 2 numbers.' );
}

if ( min >= max ) {
throw new TypeError( 'Min must be less than max.' );
}

return source >= min && source <= max;
} );

registry.alias( '<>', 'between' );
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Love the alias option! ❤️


parser( [ [ 'cart.totals', '<>', [ 50, 100 ] ], { 'cart.totals': 75 } ); // true.
```

### Types

The package ships with a set of types that are used to define the structure of a rule, and the type of the values that are used in a rule.

```ts
import type { RawRule, Rules, EvaluatorFunction } from '@wordpress/rule-parser';

const rule: RawRule = [ 'user.id', 'is', 1 ];
const rules: Rules< RawRule > = [ 'ANY', [ rule ] ];
const customOperator: EvaluatorFunction = ( source, target, rule ) =>
source === target;
```

## Contributing to this package

This is an individual package that's part of the Gutenberg project. The project is organized as a monorepo. It's made up of multiple self-contained software packages, each with a specific purpose. The packages in this monorepo are published to [npm](https://www.npmjs.com/) and used by [WordPress](https://make.wordpress.org/core/) as well as other software projects.

To find out more about contributing to this package or Gutenberg as a whole, please read the project's main [contributor guide](https://github.com/WordPress/gutenberg/tree/HEAD/CONTRIBUTING.md).

<br /><br /><p align="center"><img src="https://s.w.org/style/images/codeispoetry.png?1" alt="Code is Poetry." /></p>
27 changes: 27 additions & 0 deletions packages/rule-parser/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"name": "@wordpress/rule-parser",
"version": "1.0.0-prerelease",
"description": "A rule parser to evaluate logical rules into a boolean value.",
"author": "The WordPress Contributors",
"license": "GPL-2.0-or-later",
"keywords": [
"wordpress"
],
"homepage": "https://github.com/WordPress/gutenberg/tree/HEAD/packages/rule-parser/README.md",
"repository": {
"type": "git",
"url": "https://github.com/WordPress/gutenberg.git",
"directory": "packages/rule-parser"
},
"bugs": {
"url": "https://github.com/WordPress/gutenberg/issues"
},
"sideEffects": "src/evaluators/registry.ts",
"main": "build/index.js",
"module": "build-module/index.js",
"react-native": "src/index",
"types": "build-types",
"publishConfig": {
"access": "public"
}
}
6 changes: 6 additions & 0 deletions packages/rule-parser/src/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/**
* Internal dependencies
*/
import type { Combinator } from './types';

export const combinators: Combinator[] = [ 'ANY', 'ALL' ];
40 changes: 40 additions & 0 deletions packages/rule-parser/src/evaluators/equation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* Internal dependencies
*/
import type { Source, Target, Rule } from '../types';
import { stringifyRule } from '../helpers';

/**
* Evaluates a rule with the 'is' operator.
*
* @param {Source} source The source value.
* @param {Target} target The target value.
* @param {Rule} rule The rule to evaluate.
* @return {boolean} The result of the evaluation.
*/
export function evaluateIs(
source: Source,
target: Target,
rule: Rule
): boolean {
if (
( Array.isArray( source ) && ! Array.isArray( target ) ) ||
( ! Array.isArray( source ) && Array.isArray( target ) )
) {
throw new TypeError(
`Rule ${ stringifyRule(
rule
) } source and target must be both primitives or arrays for operator '${
rule[ 1 ]
}'`
);
}

if ( Array.isArray( source ) && Array.isArray( target ) ) {
return source.every( ( item ) => target.includes( item ) );
}

// Equation supports loose comparison.
// eslint-disable-next-line eqeqeq
return source == target;
}
50 changes: 50 additions & 0 deletions packages/rule-parser/src/evaluators/inclusion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* Internal dependencies
*/
import type { Source, Target, Rule } from '../types';
import { valueType, stringifyRule } from '../helpers';

/**
* Evaluates a rule with the 'in' operator.
*
* @param {Source} source The source value.
* @param {Target} target The target value.
* @param {Rule} rule The rule to evaluate.
* @return {boolean} The result of the evaluation.
*/
export function evaluateInclusion(
source: Source,
target: Target,
rule: Rule
): boolean {
if ( valueType( source ) !== valueType( target ) ) {
throw new TypeError(
`Rule ${ stringifyRule(
rule
) } source and target must be the same type for operator '${
rule[ 1 ]
}'`
);
}

// IN operator only supports a string target if the source is also a string.
if ( ! Array.isArray( target ) ) {
if ( typeof target === 'string' && typeof source === 'string' ) {
return target.includes( source );
}
throw new TypeError(
`Rule ${ stringifyRule(
rule
) } target must be an array for operator '${
rule[ 1 ]
}' or both need to be a string`
);
}

if ( Array.isArray( source ) ) {
return source.every( ( item ) => target.includes( item ) );
}

// Includes kept returning a TS error.
return target.includes( source );
}
1 change: 1 addition & 0 deletions packages/rule-parser/src/evaluators/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { registry } from './registry';
Loading
Loading