using System; using System.Collections.Generic; using System.Text; namespace GB5Shared.WorkFlow.WorkFlowEngine { /// /// Evaluates a simple boolean expression string against a facts dictionary. /// /// Supported syntax /// ──────────────── /// Literals : integer, decimal, quoted string ('…' / "…"), true, false, null /// Fields : any identifier / dotted path resolved against /// Operators : == != > < >= <= (comparison) /// && AND (logical and) /// || OR (logical or) /// ! NOT (logical not) /// Grouping : ( … ) /// /// Examples /// ──────── /// "TaskDetailType == 0" /// "TaskDetailType == 0 || TaskDetailType == 1 || TaskDetailType == 2" /// "Amount >= 10000 && CategoryId != 5" /// "!(StatusId == 3)" /// public sealed class WorkflowConditionEvaluator { private readonly IReadOnlyDictionary _facts; public WorkflowConditionEvaluator(IReadOnlyDictionary facts) { _facts = facts ?? throw new ArgumentNullException(nameof(facts)); } // ── Public entry ───────────────────────────────────────────────────────── /// /// Evaluates and returns the boolean result. /// Returns true when is null/empty /// (no condition = always applicable). /// /// /// Thrown when the expression contains an unknown field, unsupported /// operator, or syntax error — with a human-readable message. /// public bool Evaluate(string? expression) { if (string.IsNullOrWhiteSpace(expression)) return true; try { var tokens = Tokenize(expression.Trim()); int pos = 0; bool result = ParseOr(tokens, ref pos); if (pos != tokens.Count) throw new WorkflowConditionException( $"Unexpected token '{tokens[pos]}' at position {pos} in expression: {expression}"); return result; } catch (WorkflowConditionException) { throw; } catch (Exception ex) { throw new WorkflowConditionException( $"Failed to evaluate expression '{expression}': {ex.Message}", ex); } } // ── Recursive-descent parser ────────────────────────────────────────── // OR ::= AND ( ('||' | 'OR') AND )* private bool ParseOr(List tokens, ref int pos) { bool left = ParseAnd(tokens, ref pos); while (pos < tokens.Count && (tokens[pos].Is("||") || tokens[pos].Is("OR"))) { pos++; bool right = ParseAnd(tokens, ref pos); left = left || right; } return left; } // AND ::= NOT ( ('&&' | 'AND') NOT )* private bool ParseAnd(List tokens, ref int pos) { bool left = ParseNot(tokens, ref pos); while (pos < tokens.Count && (tokens[pos].Is("&&") || tokens[pos].Is("AND"))) { pos++; bool right = ParseNot(tokens, ref pos); left = left && right; } return left; } // NOT ::= ('!' | 'NOT') NOT | PRIMARY private bool ParseNot(List tokens, ref int pos) { if (pos < tokens.Count && (tokens[pos].Is("!") || tokens[pos].Is("NOT"))) { pos++; return !ParseNot(tokens, ref pos); } return ParsePrimary(tokens, ref pos); } // PRIMARY ::= '(' OR ')' | COMPARISON private bool ParsePrimary(List tokens, ref int pos) { if (pos < tokens.Count && tokens[pos].Is("(")) { pos++; // consume '(' bool inner = ParseOr(tokens, ref pos); Expect(tokens, ref pos, ")"); return inner; } return ParseComparison(tokens, ref pos); } // COMPARISON ::= VALUE OP VALUE private bool ParseComparison(List tokens, ref int pos) { if (pos + 2 >= tokens.Count) throw new WorkflowConditionException( $"Incomplete comparison expression near token {pos}."); string left = tokens[pos].Value; string op = tokens[pos + 1].Value; string right = tokens[pos + 2].Value; pos += 3; if (!IsComparisonOperator(op)) throw new WorkflowConditionException( $"Unknown comparison operator '{op}'. Supported: == != > < >= <="); // Resolve the LEFT side: field name → fact value string string leftVal = ResolveValue(left); // Resolve the RIGHT side (usually a literal, but can also be a field) string rightVal = IsLiteral(right) ? StripQuotes(right) : ResolveValue(right); return Compare(leftVal, op, rightVal, left, right); } // ── Comparison logic ────────────────────────────────────────────────── private bool Compare(string leftStr, string op, string rightStr, string leftToken, string rightToken) { // Null handling bool leftNull = leftStr == "\0NULL"; bool rightNull = rightStr == "\0NULL"; if (leftNull || rightNull) { return op switch { "==" => leftNull && rightNull, "!=" => !(leftNull && rightNull), _ => false }; } // Numeric comparison (prefer numeric when both parse) if (decimal.TryParse(leftStr, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out decimal leftNum) && decimal.TryParse(rightStr, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out decimal rightNum)) { return op switch { "==" => leftNum == rightNum, "!=" => leftNum != rightNum, ">" => leftNum > rightNum, "<" => leftNum < rightNum, ">=" => leftNum >= rightNum, "<=" => leftNum <= rightNum, _ => throw new WorkflowConditionException($"Unsupported numeric operator '{op}'.") }; } // Boolean comparison if (bool.TryParse(leftStr, out bool leftBool) && bool.TryParse(rightStr, out bool rightBool)) { return op switch { "==" => leftBool == rightBool, "!=" => leftBool != rightBool, _ => throw new WorkflowConditionException($"Unsupported boolean operator '{op}'.") }; } // DateTime comparison if (DateTime.TryParse(leftStr, out DateTime leftDate) && DateTime.TryParse(rightStr, out DateTime rightDate)) { int cmp = DateTime.Compare(leftDate, rightDate); return op switch { "==" => cmp == 0, "!=" => cmp != 0, ">" => cmp > 0, "<" => cmp < 0, ">=" => cmp >= 0, "<=" => cmp <= 0, _ => throw new WorkflowConditionException($"Unsupported date operator '{op}'.") }; } // String comparison (case-insensitive) int strCmp = string.Compare(leftStr, rightStr, StringComparison.OrdinalIgnoreCase); return op switch { "==" => strCmp == 0, "!=" => strCmp != 0, ">" => strCmp > 0, "<" => strCmp < 0, ">=" => strCmp >= 0, "<=" => strCmp <= 0, _ => throw new WorkflowConditionException($"Unsupported string operator '{op}'.") }; } // ── Value resolution ────────────────────────────────────────────────── /// /// Returns a string representation of the value. /// Special sentinel "\0NULL" indicates a null value. /// private string ResolveValue(string token) { // Quoted string literal if ((token.StartsWith("\"") && token.EndsWith("\"")) || (token.StartsWith("'") && token.EndsWith("'"))) return token.Substring(1, token.Length - 2); // Boolean / null literals if (token.Equals("null", StringComparison.OrdinalIgnoreCase)) return "\0NULL"; if (token.Equals("true", StringComparison.OrdinalIgnoreCase)) return "True"; if (token.Equals("false", StringComparison.OrdinalIgnoreCase)) return "False"; // Numeric literal (including negative) if (decimal.TryParse(token, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out _)) return token; // Field lookup (supports dotted paths: "Header.Amount") return LookupFact(token); } private string LookupFact(string field) { // Case-insensitive key lookup foreach (var kv in _facts) { if (string.Equals(kv.Key, field, StringComparison.OrdinalIgnoreCase)) { if (kv.Value is null) return "\0NULL"; return Convert.ToString(kv.Value, System.Globalization.CultureInfo.InvariantCulture) ?? "\0NULL"; } } throw new WorkflowConditionException( $"Field '{field}' not found in workflow facts. " + $"Available fields: {string.Join(", ", _facts.Keys)}"); } // ── Tokeniser ───────────────────────────────────────────────────────── private List Tokenize(string expr) { var tokens = new List(); int i = 0; while (i < expr.Length) { // Skip whitespace if (char.IsWhiteSpace(expr[i])) { i++; continue; } // Two-character operators: == != >= <= && || if (i + 1 < expr.Length) { string two = expr.Substring(i, 2); if (two == "==" || two == "!=" || two == ">=" || two == "<=" || two == "&&" || two == "||") { tokens.Add(new Token(two)); i += 2; continue; } } // Single-char operators / grouping char c = expr[i]; if (c == '>' || c == '<' || c == '!' || c == '(' || c == ')') { tokens.Add(new Token(c.ToString())); i++; continue; } // Negative number literal (-123, -45.6) if (c == '-' && i + 1 < expr.Length && char.IsDigit(expr[i + 1])) { int start = i++; while (i < expr.Length && (char.IsDigit(expr[i]) || expr[i] == '.')) i++; tokens.Add(new Token(expr.Substring(start, i - start))); continue; } // Positive number literal if (char.IsDigit(c)) { int start = i; while (i < expr.Length && (char.IsDigit(expr[i]) || expr[i] == '.')) i++; tokens.Add(new Token(expr.Substring(start, i - start))); continue; } // Quoted string literal ('…' or "…") if (c == '"' || c == '\'') { char quote = c; i++; var sb = new StringBuilder(); sb.Append(quote); while (i < expr.Length && expr[i] != quote) { if (expr[i] == '\\' && i + 1 < expr.Length) { i++; // skip backslash sb.Append(expr[i]); } else { sb.Append(expr[i]); } i++; } sb.Append(quote); if (i < expr.Length) i++; // consume closing quote tokens.Add(new Token(sb.ToString())); continue; } // Identifier / keyword (letters, digits, underscore, dot) if (char.IsLetter(c) || c == '_') { int start = i; while (i < expr.Length && (char.IsLetterOrDigit(expr[i]) || expr[i] == '_' || expr[i] == '.')) i++; tokens.Add(new Token(expr.Substring(start, i - start))); continue; } throw new WorkflowConditionException( $"Unexpected character '{c}' at position {i} in expression: {expr}"); } return tokens; } // ── Helpers ─────────────────────────────────────────────────────────── private static bool IsComparisonOperator(string op) => op == "==" || op == "!=" || op == ">" || op == "<" || op == ">=" || op == "<="; private static bool IsLiteral(string token) { if ((token.StartsWith("\"") && token.EndsWith("\"")) || (token.StartsWith("'") && token.EndsWith("'"))) return true; if (token.Equals("null", StringComparison.OrdinalIgnoreCase) || token.Equals("true", StringComparison.OrdinalIgnoreCase) || token.Equals("false", StringComparison.OrdinalIgnoreCase)) return true; return decimal.TryParse(token, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out _); } private static string StripQuotes(string s) { if (s.Length >= 2 && ((s[0] == '"' && s[^1] == '"') || (s[0] == '\'' && s[^1] == '\''))) return s.Substring(1, s.Length - 2); return s; } private static void Expect(List tokens, ref int pos, string expected) { if (pos >= tokens.Count || !tokens[pos].Is(expected)) throw new WorkflowConditionException( $"Expected '{expected}' but found '{(pos < tokens.Count ? tokens[pos].Value : "EOF")}'."); pos++; } // ── Inner types ─────────────────────────────────────────────────────── private sealed class Token { public string Value { get; } public Token(string value) => Value = value; public bool Is(string v) => string.Equals(Value, v, StringComparison.OrdinalIgnoreCase); } } /// /// Thrown when a workflow condition expression cannot be parsed or evaluated. /// Contains a clear, user-facing message describing the exact problem. /// public sealed class WorkflowConditionException : Exception { public WorkflowConditionException(string message) : base(message) { } public WorkflowConditionException(string message, Exception inner) : base(message, inner) { } } }