diff --git a/.forceignore b/.forceignore index 7b5b5a7..f492b32 100755 --- a/.forceignore +++ b/.forceignore @@ -9,4 +9,11 @@ package.xml **/.eslintrc.json # LWC Jest -**/__tests__/** \ No newline at end of file +**/__tests__/** + +# Added by Illuminated Cloud +/.illuminatedCloud/ +**/*.ts +**/tsconfig*.json +**/*.tsbuildinfo +**/eslint.config.mjs \ No newline at end of file diff --git a/.gitignore b/.gitignore index fdc3240..8ef4cd9 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,10 @@ temp/ sfdx-source/apex-mocks sfdx-source/apex-common sfdx-source/apex-extensions-examples + +# Added by Illuminated Cloud +/out/ +target/ +/.illuminatedCloud/ +**/tsconfig*.json +**/*.tsbuildinfo \ No newline at end of file diff --git a/sfdx-source/apex-extensions/main/default/classes/formula/fflib_Formula.cls b/sfdx-source/apex-extensions/main/default/classes/formula/fflib_Formula.cls new file mode 100644 index 0000000..cd3f20f --- /dev/null +++ b/sfdx-source/apex-extensions/main/default/classes/formula/fflib_Formula.cls @@ -0,0 +1,411 @@ +/** + * File Name: fflib_Formula + * @description A evaluator for evaluating multiple instances of the {@link FormulaEval.FormulaInstance} class + * + * @author architect ir. Wilhelmus G.J. Velzeboer + * + * Copyright (c), W.G.J. Velzeboer, + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * - Redistributions of source code must retain the above author notice, + * this list of conditions and the following disclaimer. + * - Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * - Neither the name of the author nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL + * THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS + * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY + * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +public with sharing class fflib_Formula +{ + /** + * The structure of the formulas + * + * @example '(1 OR 2) AND 3' + */ + private final String formulaStructure; + + private final Map formulaByNumber; + + /** + * Class constructor + * + * @param formulaStructure A String holding the formula structure e.g. '1 AND (2 OR 3)' + * @param formulaByNumber A Map of the FormulaExpressions by their number + * + * @see fflib_Formula.FormulaExpression + */ + public fflib_Formula(String formulaStructure, Map formulaByNumber) + { + this.formulaByNumber = formulaByNumber; + this.formulaStructure = formulaStructure; + } + + /** + * Evaluates the formula structure and the given formula expressions to a boolean value + * + * @return Returns a boolean of the evaluated result + */ + public Boolean evaluate() + { + return parse( + tokenizer(this.formulaStructure), + formulaByNumber + ) + .evaluate(); + } + + /** + * Parse the next token into an Expression + * + * @param tokens A list of tokens from the tokenizer + * @param formulaByNumber A map of FormulaExpressions by their number + * + * @return Returns an instance of {@link Expression} + * + * @see fflib_Formula.Expression + */ + @TestVisible + private static Expression parse(List tokens, Map formulaByNumber) + { + FormulaTokenType lookAhead = tokens.get(0).tokenType; + + switch on lookAhead + { + when PARENTHESIS_OPEN + { + return parseParenthesizedExpression(tokens, formulaByNumber); + } + when else + { + return parseLogicalOrExpression(tokens, formulaByNumber); + } + } + } + + /** + * Parse a parenthesized expression + * '(' expression ')' + * + * @param tokens The list of tokens still to be processes + * @param formulaByNumber A map of FormulaExpressions by their number + * + * @return Returns the expression within the parenthesis + */ + private static Expression parseParenthesizedExpression( + List tokens, + Map formulaByNumber) + { + eat(tokens, FormulaTokenType.PARENTHESIS_OPEN); + Expression expr = parse(tokens, formulaByNumber); + eat(tokens, FormulaTokenType.PARENTHESIS_CLOSE); + return expr; + } + + /** + * Parse a logical OR expression + * expression + * expression OR expression + * + * @param tokens The list of tokens still to be processes + * @param formulaByNumber A map of FormulaExpressions by their number + * + * @return Returns an {@link fflib_Formula.OrExpression} or the expression itself + */ + private static Expression parseLogicalOrExpression( + List tokens, + Map formulaByNumber) + { + Expression left = parseLogicalAndExpression(tokens, formulaByNumber); + + while (tokens.isEmpty() == false) + { + if (tokens.get(0).tokenType != FormulaTokenType.xOR) break; + + eat(tokens, FormulaTokenType.xOR); + Expression right = parseLogicalAndExpression(tokens, formulaByNumber); + left = new OrExpression(left, right); + } + return left; + } + + /** + * Parse a logic AND expression + * expression + * expression AND primary-expression + * + * @param tokens The list of tokens still to be processes + * @param formulaByNumber A map of FormulaExpressions by their number + * + * @return Returns an {@link fflib_Formula.AndExpression} or the expression itself + */ + private static Expression parseLogicalAndExpression( + List tokens, + Map formulaByNumber) + { + Expression left = parsePrimaryExpression(tokens, formulaByNumber); + + while (tokens.isEmpty() == false) + { + if (tokens.get(0).tokenType != FormulaTokenType.xAND) break; + + eat(tokens, FormulaTokenType.xAND); + Expression right = parsePrimaryExpression(tokens, formulaByNumber); + left = new AndExpression(left, right); + } + + return left; + } + + /** + * Parse a primary expression + * parenthesized-expression + * number-expression + * + * @param tokens The list of tokens still to be processes + * @param formulaByNumber A map of FormulaExpressions by their number + * + * @return Returns an {@link Expression} or a {@link FormulaExpression} + */ + private static Expression parsePrimaryExpression( + List tokens, + Map formulaByNumber) + { + Expression result; + if (tokens.isEmpty()) throw new FormulaEvaluationException('Unexpected end of formula, expected and expression'); + switch on tokens.get(0).tokenType + { + when PARENTHESIS_OPEN + { + result = parseParenthesizedExpression(tokens, formulaByNumber); + } + when FORMULA_NUMBER + { + result = parseNumberExpression(tokens, formulaByNumber); + } + } + return result; + } + + /** + * Parse a number expression + * `^\\d` + * + * @param tokens The list of tokens still to be processes + * @param formulaByNumber A map of FormulaExpressions by their number + * + * @return Returns the corresponding {@link FormulaExpression} from the parsed number + * which is used as key in the given formulaByNumber map + */ + private static Expression parseNumberExpression( + List tokens, + Map formulaByNumber) + { + Token token = eat(tokens, FormulaTokenType.FORMULA_NUMBER); + + Integer i = Integer.valueOf(token.literal); + if (formulaByNumber.containsKey(i) == false) throw new FormulaEvaluationException('Unknown number ' + i); + + return formulaByNumber.get(i); + } + + /** + * Takes first token from the list that must match the given token type + * + * @param tokens The list of tokens still to be processes + * @param tokenType The token type of the first token in the list + * + * @return Returns the first token of the given tokens + * + * @throws FormulaEvaluationException when there are no tokens left + * or when the first token is not of the requested type + */ + private static Token eat(List tokens, FormulaTokenType tokenType) + { + if (tokens.isEmpty()) throw new FormulaEvaluationException('Unexpected end of line, expected ' + tokenType.name()); + else if (tokens.get(0).tokenType !== tokenType) throw new FormulaEvaluationException('Unexpected token \'' + tokens.get(0).literal + '\', expected ' + tokenType.name()); + return tokens.remove(0); + } + + /** + * Tokenizes the given snippet into tokens that can be read by the parser + * + * @param snippet A formula structure e.g. "1 AND 2 OR 3" + * + * @return Returns the list of tokens + * + * @throws FormulaEvaluationException When encountering an unknown token + */ + private static List tokenizer(String snippet) + { + List result = new List(); + + snippet = '(' + snippet + ')'; // Make sure we evaluate the whole snippet + snippet = snippet.replaceAll('\\(', ' ( '); + snippet = snippet.replaceAll('\\)', ' ) '); + + List strings = snippet.split('(\\b|\\s)'); + for (String literal : strings) + { + if (String.isBlank(literal)) + { + continue; // ignore spaces + } + else if (literal.trim() == '(') + { + result.add(new Token(literal, FormulaTokenType.PARENTHESIS_OPEN)); + } + else if (literal.trim() == ')') + { + result.add(new Token(literal, FormulaTokenType.PARENTHESIS_CLOSE)); + } + else if (literal.toUpperCase().trim() == 'OR' || literal.trim() == '||') + { + result.add(new Token(literal, FormulaTokenType.xOR)); + } + else if (literal.toUpperCase().trim() == 'AND' || literal.trim() == '&&') + { + result.add(new Token(literal, FormulaTokenType.xAND)); + } + else + { + try + { + Integer.valueOf(literal); + } + catch (Exception e) + { + throw new FormulaEvaluationException('Invalid token \'' + literal + '\''); + } + result.add(new Token(literal, FormulaTokenType.FORMULA_NUMBER)); + } + } + return result; + } + + @TestVisible + private interface Expression + { + /** + * Evaluate the Expression + * + * @return Returns a boolean value of the evaluated expression + */ + Boolean evaluate(); + } + + /** + * FormulaExpression Class + * + * Used to hold the {@link FormulaEval.FormulaInstance} and the object that should be evaluated + */ + public class FormulaExpression implements Expression + { + private final FormulaEval.FormulaInstance instance; + private final Object objectToEvaluate; + + public FormulaExpression(FormulaEval.FormulaInstance instance, Object objectToEvaluate) + { + this.instance = instance; + this.objectToEvaluate = objectToEvaluate; + } + + public Boolean evaluate() + { + return (Boolean) this.instance.evaluate(this.objectToEvaluate); + } + } + + /** + * AndExpression Class + * + * Used to hold AND expressions e.g. 'expression AND expression' and is able to evaluate the expressions. + * NOTE: the right hand side will only be evaluated if the left hand side returned true + */ + @TestVisible + private class AndExpression implements Expression + { + @TestVisible private final Expression left; + @TestVisible private final Expression right; + + public AndExpression(Expression left, Expression right) + { + this.left = left; + this.right = right; + } + + public Boolean evaluate() + { + return left.evaluate() && right.evaluate(); + } + } + + /** + * OrExpression Class + * + * Used to hold OR expressions e.g. 'expression OR expression' and is able to evaluate the expressions. + * NOTE: the right hand side will only be evaluated if the left hand side returned false + */ + @TestVisible + private class OrExpression implements Expression + { + @TestVisible private final Expression left; + @TestVisible private final Expression right; + + public OrExpression(Expression left, Expression right) + { + this.left = left; + this.right = right; + } + + public Boolean evaluate() + { + return left.evaluate() || right.evaluate(); + } + } + + /** + * Token Class + * + * Tokens are used by the Tokenizer to translate a formula structure into tokens the parser can understand + */ + @TestVisible + private class Token + { + private final String literal; + private final FormulaTokenType tokenType; + + public Token(String literal, FormulaTokenType tokenType) + { + this.literal = literal; + this.tokenType = tokenType; + } + } + + /** + * FormulaTokenType enum + * + * The available token types which can be used in a formula structure + */ + @TestVisible + private enum FormulaTokenType + { + FORMULA_NUMBER, // e.g. '\\d' + xAND, // e.g. 'And', '&&' + xOR, // e.g. 'OR', '||' + PARENTHESIS_OPEN, // e.g. '(' + PARENTHESIS_CLOSE // e.g. ')' + } +} \ No newline at end of file diff --git a/sfdx-source/apex-extensions/main/default/classes/formula/fflib_Formula.cls-meta.xml b/sfdx-source/apex-extensions/main/default/classes/formula/fflib_Formula.cls-meta.xml new file mode 100644 index 0000000..1e7de94 --- /dev/null +++ b/sfdx-source/apex-extensions/main/default/classes/formula/fflib_Formula.cls-meta.xml @@ -0,0 +1,5 @@ + + + 64.0 + Active + diff --git a/sfdx-source/apex-extensions/main/default/classes/formula/fflib_FormulaTest.cls b/sfdx-source/apex-extensions/main/default/classes/formula/fflib_FormulaTest.cls new file mode 100644 index 0000000..3ea5612 --- /dev/null +++ b/sfdx-source/apex-extensions/main/default/classes/formula/fflib_FormulaTest.cls @@ -0,0 +1,258 @@ +/** + * File Name: fflib_FormulaTest + * @description A test class for {@link fflib_Formula} class + * + * @author architect ir. Wilhelmus G.J. Velzeboer + * + * Copyright (c), W.G.J. Velzeboer, + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * - Redistributions of source code must retain the above author notice, + * this list of conditions and the following disclaimer. + * - Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * - Neither the name of the author nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL + * THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS + * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY + * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +@IsTest +private class fflib_FormulaTest +{ + @IsTest + static void itShouldTestSingleFormula() + { + final String formulaStructure = '1'; + fflib_Formula.FormulaExpression expression = generateAccountSiteFormula(); + + final Map formulaByNumber = + new Map + { + 1 => expression + }; + + System.Test.startTest(); + Boolean result = new fflib_Formula(formulaStructure, formulaByNumber).evaluate(); + System.Test.stopTest(); + + System.Assert.isTrue(result, 'Incorrect result for evaluated formula'); + } + + @IsTest + static void itShouldTestSingleFormula_parenthesized() + { + final String formulaStructure = '(1)'; + fflib_Formula.FormulaExpression expression = generateContactCountryFormula(); + + final Map formulaByNumber = + new Map + { + 1 => expression + }; + + System.Test.startTest(); + Boolean result = new fflib_Formula(formulaStructure, formulaByNumber).evaluate(); + System.Test.stopTest(); + + System.Assert.isTrue(result, 'Incorrect result for evaluated formula'); + } + + @IsTest + static void itShouldTestSingleFormula_failing() + { + final String formulaStructure = 'unknown'; + fflib_Formula.FormulaExpression expression = generateAccountSiteFormula(); + + final Map formulaByNumber = + new Map + { + 1 => expression + }; + + System.Test.startTest(); + try + { + new fflib_Formula(formulaStructure, formulaByNumber).evaluate(); + System.Assert.fail('Expected an exception, but got none'); + } + catch (FormulaEvaluationException e) + { + System.Assert.areEqual('Invalid token \'unknown\'', e.getMessage(), 'Incorrect error message'); + } + System.Test.stopTest(); + } + + @IsTest + static void itShouldTestAndFormula() + { + final String formulaStructure = '1 AND 2'; + fflib_Formula.FormulaExpression expr1 = generateAccountSiteFormula(); + fflib_Formula.FormulaExpression expr2 = generateContactCountryFormula(); + + final Map formulaByNumber = + new Map + { + 1 => expr1, + 2 => expr2 + }; + + System.Test.startTest(); + Boolean result = new fflib_Formula(formulaStructure, formulaByNumber).evaluate(); + System.Test.stopTest(); + + System.Assert.isTrue(result, 'Incorrect result for evaluated formula'); + } + + @IsTest + static void itShouldTestOrFormula() + { + final String formulaStructure = '1 || 2'; + fflib_Formula.FormulaExpression expr1 = generateLeadCountryFormula(); + fflib_Formula.FormulaExpression expr2 = generateContactCountryFormula(); + + final Map formulaByNumber = + new Map + { + 1 => expr1, + 2 => expr2 + }; + + System.Test.startTest(); + Boolean result = new fflib_Formula(formulaStructure, formulaByNumber).evaluate(); + System.Test.stopTest(); + + System.Assert.isTrue(result, 'Incorrect result for evaluated formula'); + } + + @IsTest + static void itShouldTestOrFormula_Text() + { + final String formulaStructure = '1 OR 2'; + fflib_Formula.FormulaExpression expr1 = generateLeadCountryFormula(); + fflib_Formula.FormulaExpression expr2 = generateContactCountryFormula(); + + final Map formulaByNumber = + new Map + { + 1 => expr1, + 2 => expr2 + }; + + System.Test.startTest(); + Boolean result = new fflib_Formula(formulaStructure, formulaByNumber).evaluate(); + System.Test.stopTest(); + + System.Assert.isTrue(result, 'Incorrect result for evaluated formula'); + } + + /** + * OR expression + * left = AND Expression + * left = 1 + * right = 2 + * right = 3 + */ + @IsTest + static void itShouldParseAndOrFormula() + { + List tokens = new List + { + new fflib_Formula.Token('1', fflib_Formula.FormulaTokenType.FORMULA_NUMBER), + new fflib_Formula.Token('AND', fflib_Formula.FormulaTokenType.xAND), + new fflib_Formula.Token('2', fflib_Formula.FormulaTokenType.FORMULA_NUMBER), + new fflib_Formula.Token('OR', fflib_Formula.FormulaTokenType.xOR), + new fflib_Formula.Token('3', fflib_Formula.FormulaTokenType.FORMULA_NUMBER) + }; + Map formulaByNumber = new Map + { + 1 => null, + 2 => null, + 3 => null + }; + + System.Test.startTest(); + fflib_Formula.Expression result = fflib_Formula.parse(tokens, formulaByNumber); + System.Test.stopTest(); + + System.Assert.isTrue(result instanceof fflib_Formula.OrExpression, 'Expected an OR expression'); + fflib_Formula.OrExpression orExpression = (fflib_Formula.OrExpression) result; + System.Assert.isTrue(orExpression.left instanceof fflib_Formula.AndExpression, 'Expected an AND expression'); + System.Assert.isNull(orExpression.right, 'Expected null for right hand side in OR expression'); + + fflib_Formula.AndExpression andExpression = (fflib_Formula.AndExpression) orExpression.left; + System.Assert.isNull(andExpression.left, 'Expected null for left hand side in AND expression'); + System.Assert.isNull(andExpression.right, 'Expected null for right hand side in AND expression'); + } + + @IsTest + static void itShouldTestAndOrFormula() + { + final String formulaStructure = '1 AND (2 OR 3)'; + fflib_Formula.FormulaExpression expr1 = generateAccountSiteFormula(); + fflib_Formula.FormulaExpression expr2 = generateContactCountryFormula(); + fflib_Formula.FormulaExpression expr3 = generateLeadCountryFormula(); + + final Map formulaByNumber = + new Map + { + 1 => expr1, + 2 => expr3, + 3 => expr2 + }; + + System.Test.startTest(); + Boolean result = new fflib_Formula(formulaStructure, formulaByNumber).evaluate(); + System.Test.stopTest(); + + System.Assert.isTrue(result, 'Incorrect result for evaluated formula'); + } + + + private static fflib_Formula.FormulaExpression generateAccountSiteFormula() + { + return new fflib_Formula.FormulaExpression( + Formula.builder() + .withReturnType(FormulaEval.FormulaReturnType.Boolean) + .withType(Account.SObjectType) + .withFormula('ISBLANK(Site) = FALSE') + .build(), + new Account(Site = 'Test') + ); + } + + private static fflib_Formula.FormulaExpression generateContactCountryFormula() + { + return new fflib_Formula.FormulaExpression( + Formula.builder() + .withReturnType(FormulaEval.FormulaReturnType.Boolean) + .withType(Contact.SObjectType) + .withFormula('MailingCountry = "Belgium"') + .build(), + new Contact(MailingCountry = 'Belgium') + ); + } + + private static fflib_Formula.FormulaExpression generateLeadCountryFormula() + { + return new fflib_Formula.FormulaExpression( + Formula.builder() + .withReturnType(FormulaEval.FormulaReturnType.Boolean) + .withType(Lead.SObjectType) + .withFormula('Country = "Germany"') + .build(), + new Lead(Country = 'Holland') + ); + } +} \ No newline at end of file diff --git a/sfdx-source/apex-extensions/main/default/classes/formula/fflib_FormulaTest.cls-meta.xml b/sfdx-source/apex-extensions/main/default/classes/formula/fflib_FormulaTest.cls-meta.xml new file mode 100644 index 0000000..1e7de94 --- /dev/null +++ b/sfdx-source/apex-extensions/main/default/classes/formula/fflib_FormulaTest.cls-meta.xml @@ -0,0 +1,5 @@ + + + 64.0 + Active +