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) { }
}
}