merged pull request

This commit is contained in:
seanpmaxwell
2021-05-14 11:14:50 -07:00
3 changed files with 466 additions and 407 deletions

View File

@@ -1,212 +1,237 @@
# TROOL - a spreadsheet rule engine for NodeJS/TypeScript
<h3>Get rules out of the code so non-engineers can make updates over time!</h3>
<br>
## Features
- Manage rules in a business spreadsheet format.
- Heavily inspired by Java's KnowledgeBase Library.
- Allows use of `Import` objects so values can be reused. These can be passed dynamically through
the code or hardcoded in the spreadsheet.
- GitHub Repo contains fully-functional sample project so you can start practicing right away.
- Fully type-safe :)
## Requirements
- The spreadsheet must be exported as a _.csv_ before usage.
- Can be used directly with JavaScript (ES5 and ES6) but documentation is in TypeScript.
## Screenshot
<img alt='fullOverview' src='https://github.com/seanpmaxwell/trool/raw/master/fullOverview.png' border='0'>
## Quick Start
- install `$ npm install --save trool`
- Open Excel, LibreOffice Calc, or some other spreadsheet tool of your choice.
- A **Fact** is an instance-object or array of instance-objects, which you want to update based on
conditions that may change over time. Create at least one decision-table on the spreadsheet so you
can update a fact.
- You must follow the format closely for setting up a decision-table. Trool may throw errors if
you do not set things up correctly. The guide contains all the details for setting up a decision-table.
You can look at the screen-shot above if you want a quick glimpse on what decision-tables look like.
- Export your spreadsheet as a CSV file. The rules for formatting the csv are the same as they are
for the `csvtojson` library. That's what Trool uses internally to convert the csv to a JSON object.
- Create a new NodeJS program (preferably with TypeScript) and import the `trool` library at the top.
```typescript
import Trool from 'trool';
class PriceCalculator {
```
- To use Trool you must call two methods `init()` and `applyRules()`. The first one takes in your
facts array and the path to the CSV file. `init()` is asynchronous so make sure to use `async/await`
with it. `applyRules()` returns the update facts and must be called after `init()`.
- `init()` has two optional params `showLogs` and `imports`. If you want access to the decision-tables
for some reason after `init()` is called, there is the `decisionTables` getter on the trool instance.
- The facts and the imports must be wrapped in holder objects, with the key being the name of the
fact/import to use in the spreadsheet and the value being the actual fact or import. The `imports`
param is optional because you may only want to use ones specified in the spreadsheet or have no need for any.
```typescript
public async calcTotalPrice(): Promise<void> {
const factsHolder = {
Visitors: [new Visitor(), new Visitor()],
Tickets: new Ticket(),
};
const importsHolder = {
VisitorTypes: {
ADULT: 'Adult',
CHILD: 'Child',
},
};
try {
const facts = this.setupFactsHolder(visitors, ticketOpt);
const trool = new Trool();
await trool.init(csvFilePath, factsHolder, true, importsHolder);
const updatedFacts = trool.applyRules();
totalPrice = this.addUpEachTicketPrice(updatedFacts);
// Access decision tables
cinfo(trool.decisionTables);
} catch (err) {
console.error(err.message);
}
}
```
- The `updatedFacts` variable in the previous snippet will contain all the same key/value pairs and
arrays in the same order as the `factsHolder` that was passed in.
<br>
## Guide
**Important! When you setup your decision-tables and imports there are some rules to follow the in order
for your tables/imports to be properly loaded into memory. Strict formatting is enforced for readability
purposes**.
**Decision-Tables:**
- All decision-tables must start with a cell containing the text `Table: "Fact Name"`. A table without a
fact name or with a fact name that does not exist on the facts-holder will throw an error. If you create
2 tables that have the same fact-name, the second table will overwrite all the changes from the first.
- A table will end when it reaches an empty row, the end of the file, or the start of a new table
or import. For readability, you should terminate all tables with an empty row.
- The first 2 rows on a decision-table are for specifying the conditions and the actions. If all conditions
are true, then the actions will execute. After the start cell (the cell with `Table: "Fact Name"`) you
must specify at least 1 condition and 1 action.
- Specifying Condition and Action columns must be done by putting `'Condition'` or `'Action'`, at the
top of each column. These are case sensitive so make sure to capitalize the values. All conditions
must come before all actions and you cannot have anything other than `'Condition'` or `'Action'` at
the top of your table columns.
- The condition must be a statement which evaluates to `true` or `false`. The left side of the statement
must be a method or getter on the fact's instance-object and the right side must be `$param`. The operator
must be an existing JavaScript comparator such as `==` or `<=`. The values in the rows below will replace
`$param`. For example, suppose I want to get the age of a visitor for an app which calculates ticket prices. I
would need to create a TypeScript getter (`get age(): number {}`) or a method like `getAge() {}` to
fetch the visitor's age and compare it to the parameter value.
- Actions are methods on a fact which will execute if all the conditions evaluate to true. Unlike conditions,
you can have multiple params passed in. The action must be a method or a TypeScript setter function on
the fact or else Trool will throw an error. The number of params in the action columns' cells below
must match the number or `$param` strings or else Trool will throw an error.
- All rows on a decision-table, except for the first 2, are referred to as _rules_. A rule works by evaluating
a list of conditions against cell values which, if they all evaluate to true, will execute the specified
actions. A rule must start with a rule name and can be anything but cannot be blank.
- For each cell on the rule, if it is a condition column, the cell value will replace the `$param` value
and evaluate the cell as `true` or `false`. An empty cell will automatically be evaluated as `true`. If
any cell evaluates to `false`, that rule will fail and the decision-table will go on the next rule.
- While on a rule's action column, each param specified must be separated by a comma. If no params
are specified (the cell is blank), the rule will skip that action column.
- Whew that was a lot! Now that we've gone over the rules for creating tables, let's look at an
example in detail. In the following snippet, we see an example on a decision-table and the fact `Tickets`.
<img alt='sampleTable' src='https://github.com/seanpmaxwell/trool/raw/master/sampleTable.png' border='0'>
- To update this fact you needs to make sure the `Tickets` property exists on the facts-holder when it
gets passed to `applyRules()`. If `Tickets` is an array, the decision-table will get applied to each
Ticket instance in the array.
- The table has one condition and one action. There's also 2 rules: `Set Price - Regular` and `Set Price - Season`.
Look at the operations for the condition and action. On the left side of each operation
we can see the properties `option` and `price`. This means that each Ticket instance object passed in must have
getters/setters for the `option` and `price` properties or else an error will be thrown.
- The first rule `Set Price - Regular` will take the value for `option` and check and see if its value
is equal to the string `"Regular"`. If so, it will apply the action column to the fact. The setter for
`price` will be called and the value `70` will be passed in. The exact same sequence of events will take
place for the next rule `Set Price - Season`. In other words, if the Ticket option is `"Season"`, the price
will be `600`, if the option is `"Regular"`, the price will be `70`.
- And that's how Trool works! If you need to change the price for a Regular or Seasonal ticket over
time without bugging your engineers, just have someone else make updates to the spreadsheet :)
<br>
**Imports:**
- For large complicated spreadsheets you might want to reuse certain values. Suppose for Visitors who
might be buying these tickets the maximum age for a child is `18`. One might need to reuse this value
for multiple rules/tables and if it's updated in once place, it needs to be updated everywhere. For
example, the maximum age for a child might change from `18` to `15` or something like that. This is where
imports come in handy. An import basically sets up a simple JSON object that you can access in your tables.
Imports can be created in the spreadsheet or passed through `applyRules()`.
- Trool iterates the entire spreadsheet and first looks for all the imports, then it goes back through
and initializes all the decision-tables. So the ordering of your tables/imports does not matter.
For cleanliness I recommend keeping them separated.
- All imports must begin with the cell `Import: "Import Name"`. If you pass an import in the imports holder
(via `applyRules()`) that has a key matching an import name in the spreadsheet, you will get the warning:
`!!WARNING!! The spreadsheet is using an import name already passed via the imports object. The spreadsheet
will overwrite the import: "Import Name"`.
- The quick-start had an example of passing imports through `applyRules()`. Let's look at an example of
an import hardcoded in the spreadsheet.
<img alt='importExample' src='https://github.com/seanpmaxwell/trool/raw/master/importExample.png' border='0'>
- With this import, each table will have access to an object named `TicketTypes` and all of its properties.
If you were to place `TicketTypes.SEASON` in a cell for the operation `option == $param`,
the Ticket object would call the `option` getter and pass `"SEASON"` as the value.
- When using imports through `applyRules()`, you don't have to necessary use an object as a property
and could have it as a primitive. VisitorTypes itself could be a string or number. I don't
recommend using imports this way though; it could be confusing in a collaborative environment.
- One more thing, you cannot use nested properties on imports: i.e. `Import.key.key` This is intentional,
it would lead to a very message spreadsheet.
<br>
**Special Notes:**
- In Trool spreadsheets, `==` under the hood is actually using `===`.
- The values you can pass through cells are strings, numbers, true, false, and null. Don't use objects
or undefined. Via imports, you could actually use an object as a `$param` value, but don't do it. This
could be confusing for non-engineers. Stick with primitives. Create extra getters and setters when dealing
with multiple values.
- Import property name rules are the same as for JavaScript keys. That means alphanumeric, underscores,
and dashes. Anything other than characters will throw an error.
# TROOL - a spreadsheet rule engine for NodeJS/TypeScript
<h3>Get rules out of the code so non-engineers can make updates over time!</h3>
<br>
## Features
- Manage rules in a business spreadsheet format.
- Heavily inspired by Java's KnowledgeBase Library.
- Allows use of `Import` objects so values can be reused. These can be passed dynamically through
the code or hardcoded in the spreadsheet.
- GitHub Repo contains fully-functional sample project so you can start practicing right away.
- Fully type-safe :)
## Requirements
- The spreadsheet must be exported as a _.csv_ before usage.
- Can be used directly with JavaScript (ES5 and ES6) but documentation is in TypeScript.
## Screenshot
<img alt='fullOverview' src='https://github.com/seanpmaxwell/trool/raw/master/fullOverview.png' border='0'>
## Quick Start
- install `$ npm install --save trool`
- Open Excel, LibreOffice Calc, or some other spreadsheet tool of your choice.
- A **Fact** is an instance-object or array of instance-objects, which you want to update based on
conditions that may change over time. Create at least one decision-table on the spreadsheet so you
can update a fact.
- You must follow the format closely for setting up a decision-table. Trool may throw errors if
you do not set things up correctly. The guide contains all the details for setting up a decision-table.
You can look at the screen-shot above if you want a quick glimpse on what decision-tables look like.
- Export your spreadsheet as a CSV file. The rules for formatting the csv are the same as they are
for the `csvtojson` library. That's what Trool uses internally to convert the csv to a JSON object.
- Create a new NodeJS program (preferably with TypeScript) and import the `trool` library at the top.
```typescript
import Trool from 'trool';
class PriceCalculator {
```
- To use Trool you must call two methods `init()` and `applyRules()`. The first one takes in your
facts array and the path to the CSV file. `init()` is asynchronous so make sure to use `async/await`
with it. `applyRules()` returns the update facts and must be called after `init()`.
- `init()` has two optional params `showLogs` and `imports`. If you want access to the decision-tables
for some reason after `init()` is called, there is the `decisionTables` getter on the trool instance.
- The facts and the imports must be wrapped in holder objects, with the key being the name of the
fact/import to use in the spreadsheet and the value being the actual fact or import. The `imports`
param is optional because you may only want to use ones specified in the spreadsheet or have no need for any.
```typescript
public async calcTotalPrice(): Promise<void> {
const factsHolder = {
Visitors: [new Visitor(), new Visitor()],
Tickets: new Ticket(),
};
const importsHolder = {
VisitorTypes: {
ADULT: 'Adult',
CHILD: 'Child',
},
};
try {
const facts = this.setupFactsHolder(visitors, ticketOpt);
const trool = new Trool();
await trool.init(csvFilePath, factsHolder, true, importsHolder);
const updatedFacts = trool.applyRules();
totalPrice = this.addUpEachTicketPrice(updatedFacts);
// Access decision tables
cinfo(trool.decisionTables);
} catch (err) {
console.error(err.message);
}
}
```
- The `updatedFacts` variable in the previous snippet will contain all the same key/value pairs and
arrays in the same order as the `factsHolder` that was passed in.
<br>
## Guide
**Important! When you setup your decision-tables and imports there are some rules to follow the in order
for your tables/imports to be properly loaded into memory. Strict formatting is enforced for readability
purposes**.
**Decision-Tables:**
- All decision-tables must start with a cell containing the text `Table: "Fact Name"`. A table without a
fact name or with a fact name that does not exist on the facts-holder will throw an error. If you create
2 tables that have the same fact-name, the second table will overwrite all the changes from the first.
- A table will end when it reaches an empty row, the end of the file, or the start of a new table
or import. For readability, you should terminate all tables with an empty row.
- The first 2 rows on a decision-table are for specifying the conditions and the actions. If all conditions
are true, then the actions will execute. After the start cell (the cell with `Table: "Fact Name"`) you
must specify at least 1 condition and 1 action.
- Specifying Condition and Action columns must be done by putting `'Condition'` or `'Action'`, at the
top of each column. These are case sensitive so make sure to capitalize the values. All conditions
must come before all actions and you cannot have anything other than `'Condition'` or `'Action'` at
the top of your table columns.
- The condition must be a statement which evaluates to `true` or `false`. The left side of the statement
must be a method or getter on the fact's instance-object and the right side must be `$param`. The operator
must be an existing JavaScript comparator such as `==` or `<=`. The values in the rows below will replace
`$param`. For example, suppose I want to get the age of a visitor for an app which calculates ticket prices. I
would need to create a TypeScript getter (`get age(): number {}`) or a method like `getAge() {}` to
fetch the visitor's age and compare it to the parameter value.
- Actions are methods on a fact which will execute if all the conditions evaluate to true. Unlike conditions,
you can have multiple params passed in. The action must be a method or a TypeScript setter function on
the fact or else Trool will throw an error. The number of params in the action columns' cells below
must match the number or `$param` strings or else Trool will throw an error.
- All rows on a decision-table, except for the first 2, are referred to as _rules_. A rule works by evaluating
a list of conditions against cell values which, if they all evaluate to true, will execute the specified
actions. A rule must start with a rule name and can be anything but cannot be blank.
- For each cell on the rule, if it is a condition column, the cell value will replace the `$param` value
and evaluate the cell as `true` or `false`. An empty cell will automatically be evaluated as `true`. If
any cell evaluates to `false`, that rule will fail and the decision-table will go on the next rule.
- While on a rule's action column, each param specified must be separated by a comma. If no params
are specified (the cell is blank), the rule will skip that action column.
- Whew that was a lot! Now that we've gone over the rules for creating tables, let's look at an
example in detail. In the following snippet, we see an example on a decision-table and the fact `Tickets`.
<img alt='sampleTable' src='https://github.com/seanpmaxwell/trool/raw/master/sampleTable.png' border='0'>
- To update this fact you needs to make sure the `Tickets` property exists on the facts-holder when it
gets passed to `applyRules()`. If `Tickets` is an array, the decision-table will get applied to each
Ticket instance in the array.
- The table has one condition and one action. There's also 2 rules: `Set Price - Regular` and `Set Price - Season`.
Look at the operations for the condition and action. On the left side of each operation
we can see the properties `option` and `price`. This means that each Ticket instance object passed in must have
getters/setters for the `option` and `price` properties or else an error will be thrown.
- The first rule `Set Price - Regular` will take the value for `option` and check and see if its value
is equal to the string `"Regular"`. If so, it will apply the action column to the fact. The setter for
`price` will be called and the value `70` will be passed in. The exact same sequence of events will take
place for the next rule `Set Price - Season`. In other words, if the Ticket option is `"Season"`, the price
will be `600`, if the option is `"Regular"`, the price will be `70`.
- And that's how Trool works! If you need to change the price for a Regular or Seasonal ticket over
time without bugging your engineers, just have someone else make updates to the spreadsheet :)
<br>
**Imports:**
- For large complicated spreadsheets you might want to reuse certain values. Suppose for Visitors who
might be buying these tickets the maximum age for a child is `18`. One might need to reuse this value
for multiple rules/tables and if it's updated in once place, it needs to be updated everywhere. For
example, the maximum age for a child might change from `18` to `15` or something like that. This is where
imports come in handy. An import basically sets up a simple JSON object that you can access in your tables.
Imports can be created in the spreadsheet or passed through `applyRules()`.
- Trool iterates the entire spreadsheet and first looks for all the imports, then it goes back through
and initializes all the decision-tables. So the ordering of your tables/imports does not matter.
For cleanliness I recommend keeping them separated.
- All imports must begin with the cell `Import: "Import Name"`. If you pass an import in the imports holder
(via `applyRules()`) that has a key matching an import name in the spreadsheet, you will get the warning:
`!!WARNING!! The spreadsheet is using an import name already passed via the imports object. The spreadsheet
will overwrite the import: "Import Name"`.
- The quick-start had an example of passing imports through `applyRules()`. Let's look at an example of
an import hardcoded in the spreadsheet.
<img alt='importExample' src='https://github.com/seanpmaxwell/trool/raw/master/importExample.png' border='0'>
- With this import, each table will have access to an object named `TicketTypes` and all of its properties.
If you were to place `TicketTypes.SEASON` in a cell for the operation `option == $param`,
the Ticket object would call the `option` getter and pass `"SEASON"` as the value.
- When using imports through `applyRules()`, you don't have to necessary use an object as a property
and could have it as a primitive. VisitorTypes itself could be a string or number. I don't
recommend using imports this way though; it could be confusing in a collaborative environment.
- One more thing, you cannot use nested properties on imports: i.e. `Import.key.key` This is intentional,
it would lead to a very message spreadsheet.
<br>
**Special Notes:**
- In Trool spreadsheets, `==` under the hood is actually using `===`.
- The values you can pass through cells are strings, numbers, true, false, and null. Don't use objects
or undefined. Via imports, you could actually use an object as a `$param` value, but don't do it. This
could be confusing for non-engineers. Stick with primitives. Create extra getters and setters when dealing
with multiple values.
- Import property name rules are the same as for JavaScript keys. That means alphanumeric, underscores,
and dashes. Anything other than characters will throw an error.
- In case you don't have local access to your rules file, or can't otherwise pass a file path, you can use
`initFromString()` and pass the content of the CSV file as a string instead:
```typescript
const factsHolder = {
Visitors: [new Visitor(), new Visitor()],
Tickets: new Ticket(),
};
const importsHolder = {
VisitorTypes: {
ADULT: 'Adult',
CHILD: 'Child',
},
};
try {
const s3 = new AWS.S3();
const data = await s3.getObject({ Bucket: bucketName, Key: bucketKey });
const facts = this.setupFactsHolder(visitors, ticketOpt);
const trool = new Trool();
await trool.initFromString(data.Body, factsHolder, true, importsHolder);
```

View File

@@ -1,194 +1,228 @@
/**
* Main class for the
*
* created by Sean Maxwell Mar 2, 2019
*/
import csvToJson from 'csvtojson';
import { IFactsHolder, IImportsHolder, IRow, Logger, parseCell } from './shared';
import DecisionTable from './DecisionTable';
import TableErrs from './TableErrs';
class Trool {
private readonly NO_TABLES_WARN = 'No decision tables found';
private readonly IMPORT_START_ERR = 'Import start format error for';
private readonly IMPORT_PROP_ERR = 'Import property can only be alpha-numeric and underscores ';
private readonly UPDATE_START_MSG = ' DecisionTables found. Applying table logic to facts.';
private readonly IMPORT_NAME_WARN = '!!WARNING!! The spreadsheet is using an import name ' +
'already passed via the imports object. The spreadsheet will overwrite the import: ';
private logger: Logger = new Logger();
private _decisionTables: DecisionTable[] = [];
/**
* Create the decision tables.
*
* @param filePath
* @param facts
* @param imports
* @param showLogs
*/
public async init(
filePath: string,
facts: IFactsHolder,
showLogs?: boolean,
imports?: IImportsHolder,
) {
try {
this.logger = new Logger(showLogs);
const jsonArr = await csvToJson().fromFile(filePath);
const allImports = this.setupImports(jsonArr, imports || {});
this._decisionTables = this.getTables(jsonArr, facts, allImports);
} catch (err) {
throw err;
}
}
/**
* Apply rules of all the decision-tables. Return the updated facts.
*/
public applyRules(): IFactsHolder {
const tableCount = this._decisionTables.length;
if (tableCount === 0) {
this.logger.warn(this.NO_TABLES_WARN);
return {};
} else {
this.logger.log(tableCount + this.UPDATE_START_MSG);
}
const updatedFacts: IFactsHolder = {};
for (let i = 0; i < tableCount; i++) {
const table = this._decisionTables[i];
updatedFacts[table.factName] = table.updateFacts();
}
return updatedFacts;
}
/**
* Return the decision-tables to user.
*/
public get decisionTables(): DecisionTable[] {
return this._decisionTables;
}
/*********************************************************************************************
* Add Imports from Spreadsheet
********************************************************************************************/
/**
* Setup imports from CSV file.
*
* @param jsonArr
* @param imports
*/
private setupImports(
jsonArr: IRow[],
imports: IImportsHolder,
): IImportsHolder {
let importName = '';
let newImportObj: any = {};
for (let i = 0; i < jsonArr.length; i++) {
const firstCell = jsonArr[i].field1.trim();
if (firstCell.startsWith('Import:')) {
importName = this.getImportName(firstCell, imports);
} else if (!!importName) {
if (!/^[a-zA-Z0-9-_]+$/.test(firstCell)) {
throw Error(this.IMPORT_PROP_ERR + firstCell);
}
newImportObj[firstCell] = parseCell(jsonArr[i].field2, imports);
if (this.isLastRow(jsonArr, i)) {
imports[importName] = newImportObj;
importName = '';
newImportObj = {};
}
}
}
return imports;
}
private getImportName(firstCell: string, imports: IImportsHolder): string {
const firstCellArr = firstCell.split(' ');
if (firstCellArr.length !== 2) {
throw Error(this.IMPORT_START_ERR + ` '${firstCell}'`);
}
const importName = firstCellArr[1];
if (imports.hasOwnProperty(importName)) {
this.logger.warn(this.IMPORT_NAME_WARN + importName);
}
return importName;
}
/*********************************************************************************************
* Setup Decision Tables
********************************************************************************************/
/**
* Setup the decision tables from the csv file.
*
* @param jsonArr
* @param facts
* @param imports
*/
private getTables(
jsonArr: IRow[],
facts: IFactsHolder,
imports: IImportsHolder,
): DecisionTable[] {
const decisionTables: DecisionTable[] = [];
let startCellArr: null | string[] = null;
let tableStart = -1;
for (let i = 0; i < jsonArr.length; i++) {
const firstCol = jsonArr[i].field1.trim();
if (firstCol.startsWith('Table:')) {
tableStart = i;
startCellArr = firstCol.split(' ');
} else if (startCellArr && this.isLastRow(jsonArr, i)) {
const showLogs = this.logger.showLogs;
const id = decisionTables.length + 1;
const tableRows = jsonArr.slice(tableStart, i + 1);
const factArr = this.getFacts(startCellArr, id, facts);
const table = new DecisionTable(id, startCellArr[1], showLogs);
table.initTable(tableRows, factArr, imports);
decisionTables.push(table);
tableStart = -1;
startCellArr = null;
}
}
return decisionTables;
}
private getFacts(
startCellArr: string[],
id: number,
facts: IFactsHolder,
): Array<InstanceType<any>> {
const tableErrs = new TableErrs(id);
if (startCellArr.length !== 2) {
throw Error(tableErrs.startCell);
} else if (!facts[startCellArr[1]]) {
throw Error(tableErrs.factFalsey);
}
const factArr = facts[startCellArr[1]];
return (factArr instanceof Array) ? factArr : [factArr];
}
/*********************************************************************************************
* Helpers
********************************************************************************************/
private isLastRow(jsonArr: IRow[], idx: number): boolean {
const nextCell = jsonArr[idx + 1] ? jsonArr[idx + 1].field1.trim() : '';
return !nextCell || nextCell.startsWith('Table:') || nextCell.startsWith('Import:');
}
}
export default Trool;
/**
* Main class for the
*
* created by Sean Maxwell Mar 2, 2019
*/
import csvToJson from 'csvtojson';
import { IFactsHolder, IImportsHolder, IRow, Logger, parseCell } from './shared';
import DecisionTable from './DecisionTable';
import TableErrs from './TableErrs';
class Trool {
private readonly NO_TABLES_WARN = 'No decision tables found';
private readonly IMPORT_START_ERR = 'Import start format error for';
private readonly IMPORT_PROP_ERR = 'Import property can only be alpha-numeric and underscores ';
private readonly UPDATE_START_MSG = ' DecisionTables found. Applying table logic to facts.';
private readonly IMPORT_NAME_WARN = '!!WARNING!! The spreadsheet is using an import name ' +
'already passed via the imports object. The spreadsheet will overwrite the import: ';
private logger: Logger = new Logger();
private _decisionTables: DecisionTable[] = [];
/**
* Initialize the engine.
*
* @param filePath
* @param facts
* @param imports
* @param showLogs
*/
public async init(
filePath: string,
facts: IFactsHolder,
showLogs?: boolean,
imports?: IImportsHolder,
) {
const jsonArr = await csvToJson().fromFile(filePath);
this.createDecisionTables(jsonArr, facts, showLogs, imports);
}
/**
* Initialize the engine with the CSV file content passed as string.
*
* @param fileContent
* @param facts
* @param imports
* @param showLogs
*/
public async initFromString(
fileContent: string,
facts: IFactsHolder,
showLogs?: boolean,
imports?: IImportsHolder,
) {
const jsonArr = await csvToJson().fromString(fileContent);
this.createDecisionTables(jsonArr, facts, showLogs, imports);
}
/**
* Create the decision tables.
*
* @param filePath
* @param facts
* @param imports
* @param showLogs
*/
private createDecisionTables(
jsonArr: IRow[],
facts: IFactsHolder,
showLogs?: boolean,
imports?: IImportsHolder,
) {
this.logger = new Logger(showLogs);
const allImports = this.setupImports(jsonArr, imports || {});
this._decisionTables = this.getTables(jsonArr, facts, allImports);
}
/**
* Apply rules of all the decision-tables. Return the updated facts.
*/
public applyRules(): IFactsHolder {
const tableCount = this._decisionTables.length;
if (tableCount === 0) {
this.logger.warn(this.NO_TABLES_WARN);
return {};
} else {
this.logger.log(tableCount + this.UPDATE_START_MSG);
}
const updatedFacts: IFactsHolder = {};
for (let i = 0; i < tableCount; i++) {
const table = this._decisionTables[i];
updatedFacts[table.factName] = table.updateFacts();
}
return updatedFacts;
}
/**
* Return the decision-tables to user.
*/
public get decisionTables(): DecisionTable[] {
return this._decisionTables;
}
/*********************************************************************************************
* Add Imports from Spreadsheet
********************************************************************************************/
/**
* Setup imports from CSV file.
*
* @param jsonArr
* @param imports
*/
private setupImports(
jsonArr: IRow[],
imports: IImportsHolder,
): IImportsHolder {
let importName = '';
let newImportObj: any = {};
for (let i = 0; i < jsonArr.length; i++) {
const firstCell = jsonArr[i].field1.trim();
if (firstCell.startsWith('Import:')) {
importName = this.getImportName(firstCell, imports);
} else if (!!importName) {
if (!/^[a-zA-Z0-9-_]+$/.test(firstCell)) {
throw Error(this.IMPORT_PROP_ERR + firstCell);
}
newImportObj[firstCell] = parseCell(jsonArr[i].field2, imports);
if (this.isLastRow(jsonArr, i)) {
imports[importName] = newImportObj;
importName = '';
newImportObj = {};
}
}
}
return imports;
}
private getImportName(firstCell: string, imports: IImportsHolder): string {
const firstCellArr = firstCell.split(' ');
if (firstCellArr.length !== 2) {
throw Error(this.IMPORT_START_ERR + ` '${firstCell}'`);
}
const importName = firstCellArr[1];
if (imports.hasOwnProperty(importName)) {
this.logger.warn(this.IMPORT_NAME_WARN + importName);
}
return importName;
}
/*********************************************************************************************
* Setup Decision Tables
********************************************************************************************/
/**
* Setup the decision tables from the csv file.
*
* @param jsonArr
* @param facts
* @param imports
*/
private getTables(
jsonArr: IRow[],
facts: IFactsHolder,
imports: IImportsHolder,
): DecisionTable[] {
const decisionTables: DecisionTable[] = [];
let startCellArr: null | string[] = null;
let tableStart = -1;
for (let i = 0; i < jsonArr.length; i++) {
const firstCol = jsonArr[i].field1.trim();
if (firstCol.startsWith('Table:')) {
tableStart = i;
startCellArr = firstCol.split(' ');
} else if (startCellArr && this.isLastRow(jsonArr, i)) {
const showLogs = this.logger.showLogs;
const id = decisionTables.length + 1;
const tableRows = jsonArr.slice(tableStart, i + 1);
const factArr = this.getFacts(startCellArr, id, facts);
const table = new DecisionTable(id, startCellArr[1], showLogs);
table.initTable(tableRows, factArr, imports);
decisionTables.push(table);
tableStart = -1;
startCellArr = null;
}
}
return decisionTables;
}
private getFacts(
startCellArr: string[],
id: number,
facts: IFactsHolder,
): Array<InstanceType<any>> {
const tableErrs = new TableErrs(id);
if (startCellArr.length !== 2) {
throw Error(tableErrs.startCell);
} else if (!facts[startCellArr[1]]) {
throw Error(tableErrs.factFalsey);
}
const factArr = facts[startCellArr[1]];
return (factArr instanceof Array) ? factArr : [factArr];
}
/*********************************************************************************************
* Helpers
********************************************************************************************/
private isLastRow(jsonArr: IRow[], idx: number): boolean {
const nextCell = jsonArr[idx + 1] ? jsonArr[idx + 1].field1.trim() : '';
return !nextCell || nextCell.startsWith('Table:') || nextCell.startsWith('Import:');
}
}
export default Trool;

View File

@@ -1,6 +1,6 @@
{
"name": "trool",
"version": "1.1.0",
"version": "1.1.1",
"description": "a rule engine for NodeJS written in TypeScript",
"main": "./lib/index.js",
"typings": "./lib/index.d.ts",