@o3r/rules-engine
Version:
This module provides a rule engine that can be executed on your Otter application to customize your application (translations, placeholders and configs) based on a json file generated by your CMS.
366 lines • 19.2 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.RulesEngineExtractor = void 0;
const tslib_1 = require("tslib");
const node_fs_1 = require("node:fs");
const path = tslib_1.__importStar(require("node:path"));
const core_1 = require("@angular-devkit/core");
const extractors_1 = require("@o3r/extractors");
const schematics_1 = require("@o3r/schematics");
const globby_1 = tslib_1.__importDefault(require("globby"));
const rxjs_1 = require("rxjs");
const operators_1 = require("rxjs/operators");
const ts = tslib_1.__importStar(require("typescript"));
const tjs = tslib_1.__importStar(require("typescript-json-schema"));
const rules_engine_extractor_interfaces_1 = require("./rules-engine.extractor.interfaces");
/**
* Extracts rules engine facts and operator from code
*/
class RulesEngineExtractor {
/** Interface of a Fact definition */
static { this.FACT_DEFINITIONS_INTERFACE = 'FactDefinitions'; }
/** Interface of an operator definition */
static { this.OPERATOR_DEFINITIONS_INTERFACES = ['Operator', 'UnaryOperator']; }
/** Interface of an action definition */
static { this.OPERATOR_ACTIONS_INTERFACE = 'RulesEngineAction'; }
/** Reserved fact names that will be filtered out of metadata */
static { this.RESERVED_FACT_NAMES = ['portalFacts']; }
constructor(tsconfigPath, basePath, logger) {
this.basePath = basePath;
this.logger = logger;
/** Instance of the comment parser */
this.commentParser = new extractors_1.ConfigDocParser();
const { config } = ts.readConfigFile(tsconfigPath, (p) => ts.sys.readFile(p));
this.tsconfig = config;
}
/**
* Extract type definition of a type reference into schema file
* @param type Name of the type to extract
* @param sourceFile path to the code source file
*/
async extractTypeRef(type, sourceFile) {
const internalLibFiles = this.tsconfig.extraOptions?.otterSubModuleRefs?.length > 0
? await (0, rxjs_1.lastValueFrom)((0, rxjs_1.zip)(...(this.tsconfig.extraOptions?.otterSubModuleRefs).map((value) => (0, globby_1.default)(value))).pipe((0, operators_1.map)((globFiles) => globFiles.reduce((acc, files) => [
...acc,
...files.map((f) => path.resolve(f))
], []))))
: [];
internalLibFiles.push(sourceFile);
const program = tjs.getProgramFromFiles(internalLibFiles, this.tsconfig.compilerOptions, this.basePath);
const settings = {
required: true,
aliasRef: this.tsconfig.compilerOptions?.paths,
ignoreErrors: true
};
const schema = tjs.generateSchema(program, type, settings);
if (schema?.definitions?.['utils.Date']) {
schema.definitions['utils.Date'] = { type: 'string', format: 'date' };
}
if (schema?.definitions?.['utils.DateTime']) {
schema.definitions['utils.DateTime'] = { type: 'string', format: 'date-time' };
}
return schema;
}
/**
* Extract the type node used to generate the type metadata.
* In the case of an array, it will look for the child node with the relevant information
* @param typeNode
* @private
*/
extractTypeNode(typeNode) {
return ts.isArrayTypeNode(typeNode)
? (ts.isParenthesizedTypeNode(typeNode.elementType) ? typeNode.elementType.type : typeNode.elementType)
: typeNode;
}
/**
* Return the list of types matching the type node. If the type is not supported, it will be replaced with an unknown entry
* @param type
* @param source
* @private
*/
extractSimpleTypesData(type, source) {
if (type.kind === ts.SyntaxKind.UnknownKeyword) {
return ['unknown'];
}
if (type.kind === ts.SyntaxKind.AnyKeyword) {
return rules_engine_extractor_interfaces_1.allSupportedTypes;
}
else {
const typeText = type.getText(source).replace('Date', 'date');
if (typeText === 'SupportedSimpleTypes') {
return rules_engine_extractor_interfaces_1.allDefaultSupportedTypes;
}
else if (typeText === 'dateInput') {
return ['date', 'string', 'number'];
}
return [type.getText(source).replace('Date', 'date')];
}
}
/**
* Return the nbValue number associated to the simple type.
* If it is an array, it will be -1
* If it is an unknown type (or any), it will return 0
* If it is any other type of object it will return 1
* @param type
* @private
*/
getTypeNbValue(type) {
if (type.kind === ts.SyntaxKind.UnknownKeyword || type.kind === ts.SyntaxKind.AnyKeyword) {
return 0;
}
else if (ts.isArrayTypeNode(type)) {
return -1;
}
return 1;
}
/**
* Construct a metadata object that will describe the operator hand type as a list of types and a nbValue describing
* the structure of the operand
* nbValue reflects whether the operand support a single object (1), an array (-1), both (0) or a n-tuple (n)
* @param type
* @param source
* @param nbValue
* @private
*/
extractComplexTypeData(type, source, nbValue) {
if (ts.isUnionTypeNode(type)) {
return type.types.reduce((acc, t) => {
const childTypeNode = this.extractTypeNode(t);
const childNbValue = this.getTypeNbValue(t);
const childType = this.extractSimpleTypesData(childTypeNode, source);
return {
types: Array.from(new Set([...acc.types, ...childType])),
// If union is of type array, it takes precedence over child types
// (any[], string)[] will be considered as an array and no an "anything goes" type
// Else, it will compare between the children and return 0 if the child nb values differ
nbValue: nbValue === -1 ? -1 : (!!acc.nbValue && acc.nbValue !== childNbValue ? 0 : childNbValue)
};
}, { types: [], nbValue: undefined });
}
else if (ts.isTupleTypeNode(type)) {
return {
types: Array.from(new Set(type.elements.flatMap((elementTypeNode) => (ts.isUnionTypeNode(elementTypeNode) ? elementTypeNode.types : [elementTypeNode]).flatMap((typeNode) => this.extractSimpleTypesData(typeNode, source))))),
nbValue: type.elements.length
};
}
return { types: this.extractSimpleTypesData(type, source), nbValue: nbValue };
}
/**
* Extract facts from source code
* @param sourceFile path to the code source file
* @param schemaFolderFullPath full path to the schema folder
* @param schemaFolderRelativePath path to the schema folder from the metadata file
*/
async extractFacts(sourceFile, schemaFolderFullPath, schemaFolderRelativePath) {
const program = ts.createProgram([sourceFile], this.tsconfig);
const source = program.getSourceFile(sourceFile);
const facts = [];
source?.forEachChild((node) => {
if (ts.isInterfaceDeclaration(node) && node.heritageClauses?.some((clause) => clause.types.some((type) => type.getText(source) === RulesEngineExtractor.FACT_DEFINITIONS_INTERFACE))) {
facts.push(...node.members
.filter((memberNode) => ts.isPropertySignature(memberNode))
.filter((prop) => {
const name = prop.name.getText(source);
if (RulesEngineExtractor.RESERVED_FACT_NAMES.includes(name)) {
this.logger.error(`Fact named "${name}" found in ${sourceFile} has a reserved name and will be ignored by the extractor, please consider renaming it.`);
return false;
}
return true;
})
.map(async (prop) => {
const name = prop.name.getText(source);
const description = this.commentParser.parseConfigDocFromNode(source, prop)?.description;
if (!prop.type || (!this.isNativeType(prop.type) && !ts.isTypeReferenceNode(prop.type) && !ts.isUnionTypeNode(prop.type))) {
throw new schematics_1.O3rCliError(`The fact ${name} has an unsupported type "${prop.type?.getText(source) || 'unknown'}" in ${sourceFile}`);
}
let mainType = prop.type;
if (ts.isUnionTypeNode(mainType)) {
const mainTypes = mainType.types.filter((t) => t.kind !== ts.SyntaxKind.NullKeyword && t.kind !== ts.SyntaxKind.UndefinedKeyword
&& !(ts.isLiteralTypeNode(t) && (t.literal.kind === ts.SyntaxKind.NullKeyword || t.literal.kind === ts.SyntaxKind.UndefinedKeyword)));
if (mainTypes.length !== 1) {
throw new schematics_1.O3rCliError(`The fact ${name} has an unsupported union-type "${prop.type?.getText(source) || 'unknown'}" in ${sourceFile}`);
}
mainType = mainTypes[0];
}
const typeName = mainType.getText(source);
const type = ts.isTypeReferenceNode(mainType) && typeName !== 'Date' ? 'object' : typeName.replace('Date', 'date');
let fact;
if (type === 'object') {
fact = {
type: 'object',
name,
description
};
try {
const schema = await this.extractTypeRef(typeName, sourceFile);
if (schema === null) {
this.logger.warn(`Schema of ${typeName} is null.`);
}
else if (schema.type === 'object') {
const schemaFile = `${core_1.strings.dasherize(typeName)}.schema.json`;
await node_fs_1.promises.writeFile(path.resolve(schemaFolderFullPath, schemaFile), JSON.stringify(schema, null, 2));
fact = {
...fact,
schemaFile: path.join(schemaFolderRelativePath, schemaFile).replace(/\\/g, '/')
};
}
else if (schema.type === 'array') {
fact = {
...fact,
type: 'array',
items: schema.items
};
}
else if (schema.enum) {
fact = {
...fact,
type: 'string',
// eslint-disable-next-line @typescript-eslint/no-base-to-string -- convert non-null values to string
enum: schema.enum?.filter((value) => !!value).map((v) => v.toString())
};
}
else {
fact = {
...fact,
type: schema.type
};
}
}
catch {
this.logger.warn(`Error when parsing ${type} in ${sourceFile}`);
}
}
else {
fact = {
type: type,
name,
description
};
}
return fact;
}));
}
});
return Promise.all(facts);
}
/**
* Check if typescript type node is a native type or a list of native element
* @param type
*/
isNativeType(type) {
return [ts.SyntaxKind.BooleanKeyword, ts.SyntaxKind.NumberKeyword, ts.SyntaxKind.StringKeyword].some((typeKind) => typeKind === type.kind || (ts.isArrayTypeNode(type) && type.elementType.kind === typeKind));
}
/**
* Extract operators from source code
* @param sourceFile path to the code source file
*/
extractOperators(sourceFile) {
const program = ts.createProgram([sourceFile], this.tsconfig);
const source = program.getSourceFile(sourceFile);
const operators = [];
source?.forEachChild((node) => {
if (ts.isVariableStatement(node)) {
const operatorDeclarations = node.declarationList.declarations.filter((declaration) => !!declaration.type && ts.isTypeReferenceNode(declaration.type) && RulesEngineExtractor.OPERATOR_DEFINITIONS_INTERFACES.includes(declaration.type.typeName.getText(source)));
if (operatorDeclarations.length === 0) {
return;
}
operatorDeclarations.forEach((declaration) => {
const operatorType = declaration.type.typeName.getText(source);
const commentParsedDeclaration = this.commentParser.parseConfigDocFromNode(source, declaration);
const commentParsedNode = this.commentParser.parseConfigDocFromNode(source, node);
const operator = {
id: declaration.name.getText(source),
description: commentParsedDeclaration?.description || commentParsedNode?.description || '',
display: commentParsedDeclaration?.title || commentParsedNode?.title || '',
leftOperand: {
types: rules_engine_extractor_interfaces_1.allDefaultSupportedTypes,
nbValues: 1
}
};
if (declaration.initializer && ts.isObjectLiteralExpression(declaration.initializer)) {
declaration.initializer.properties.forEach((property) => {
if (ts.isPropertyAssignment(property) && property.name.getText(source) === 'factImplicitDependencies') {
operator.factImplicitDependencies = JSON.parse(property.initializer.getText(source).replace(/'/g, '"'));
}
});
}
if (operatorType === 'Operator') {
operator.rightOperand = {
types: rules_engine_extractor_interfaces_1.allDefaultSupportedTypes,
nbValues: 1
};
}
const operands = ['leftOperand', 'rightOperand'];
declaration.type.typeArguments?.slice(0, 2).forEach((argType, idx) => {
const operand = operands[idx];
const operandObject = operator[operand];
operandObject.nbValues = this.getTypeNbValue(argType);
const typeNode = this.extractTypeNode(argType);
if (typeNode.kind === ts.SyntaxKind.UndefinedKeyword) {
delete operator[operand];
}
else {
const typeData = this.extractComplexTypeData(typeNode, source, operandObject.nbValues);
operandObject.nbValues = typeData.nbValue;
operandObject.types = typeData.types;
if (operandObject.types.some((type) => type !== 'unknown' && !rules_engine_extractor_interfaces_1.allSupportedTypes.includes(type))) {
this.logger.warn(`Operator ${operator.id} has an unsupported type in operand ${operand}: "${operandObject.types.join(', ')}"`);
operandObject.types = ['unknown'];
}
}
});
operators.push(operator);
});
}
});
return Array.from((new Map(operators.map((operator) => [operator.id, operator]))).values());
}
/**
* Not used for the moment, kept for later updates
* @param sourceFile
*/
extractActions(sourceFile) {
const program = ts.createProgram([sourceFile], this.tsconfig);
const source = program.getSourceFile(sourceFile);
const actions = [];
source?.forEachChild((node) => {
if (ts.isClassDeclaration(node)
&& node.heritageClauses?.some((nodeHeritage) => nodeHeritage.types.some((tNode) => tNode.expression.getText(source) === RulesEngineExtractor.OPERATOR_ACTIONS_INTERFACE))) {
let parameters;
node.heritageClauses.forEach((nodeHeritage) => {
nodeHeritage.types
.forEach((tNode) => {
if (tNode.expression.getText(source) === RulesEngineExtractor.OPERATOR_ACTIONS_INTERFACE && tNode.typeArguments) {
tNode.typeArguments
.filter((argNode) => ts.isTypeLiteralNode(argNode))
.forEach((argNode) => argNode.members
.filter((memberNode) => ts.isPropertySignature(memberNode))
.forEach((memberNode) => {
if (memberNode.name && memberNode.type) {
parameters ||= {};
parameters[memberNode.name.getText(source)] = memberNode.type.getText(source);
}
}));
}
});
});
const type = node.members
.filter((methodNode) => ts.isPropertyDeclaration(methodNode))
.find((methodNode) => methodNode.name.getText(source) === 'name')
?.initializer?.getText(source).replace(/^["'](.*)["']$/, '$1');
if (!type) {
this.logger.error(`No type found for ${node.name?.getText(source) || 'unknown class'} in ${sourceFile}. It will be skipped.`);
return;
}
actions.push({
type,
parameters,
description: this.commentParser.parseConfigDocFromNode(source, node)?.description || ''
});
}
});
return actions;
}
}
exports.RulesEngineExtractor = RulesEngineExtractor;
//# sourceMappingURL=rules-engine.extractor.js.map