-
Notifications
You must be signed in to change notification settings - Fork 4.2k
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
Add new rule parser package #65171
Changes from all commits
07258ee
ae733c9
2fec361
81631f4
c0ba826
9f727dd
b1f746b
6e3f312
8379857
9bca316
4e0e9ff
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
package-lock=false |
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. |
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 | ||
|
||
```JS | ||
const rules = [ | ||
'ANY', | ||
[ | ||
[ 'user.role', 'is', 'editor' ], | ||
[ 'post.categories', 'contains', 'tutorials' ], | ||
[ | ||
'ALL', | ||
[ | ||
[ 'user.id', 'in', [ 1, 2, 3 ] ], | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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": 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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' ); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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> |
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" | ||
} | ||
} |
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' ]; |
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; | ||
} |
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 ); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export { registry } from './registry'; |
There was a problem hiding this comment.
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.