< Summary

Information
Class: OzonOrdersWeb.WebServices.Orders.Grid.OrdersGridFormula
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/WebServices/Orders/Grid/OrdersGridFormula.cs
Line coverage
91%
Covered lines: 175
Uncovered lines: 17
Coverable lines: 192
Total lines: 318
Line coverage: 91.1%
Branch coverage
80%
Covered branches: 256
Total branches: 320
Branch coverage: 80%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
get_Position()100%11100%
get_Type()100%11100%
get_Kind()100%11100%
.cctor()100%11100%
Validate(...)100%11100%
Parse(...)66.66%66100%
CountNodes(...)100%66100%
Check(...)50%22100%
Tokenize(...)76.41%11110692.15%
get_Pos()100%11100%
get_Value()100%11100%
get_Value()100%11100%
get_Value()100%210%
.ctor(...)100%11100%
get_Name()100%11100%
get_Op()100%11100%
get_Op()100%11100%
get_Name()100%11100%
.ctor(...)100%11100%
get_Peek()100%11100%
Next()100%11100%
IsOp(...)100%44100%
ParseAll()50%22100%
Or()100%22100%
And()100%22100%
Not()100%22100%
Cmp()100%1212100%
Add()100%44100%
Mul()100%66100%
Unary()75%44100%
Primary()68.75%563271.42%
.ctor(...)100%11100%
Check(...)79.46%12611289.74%
IsText(...)100%44100%
Numeric(...)100%66100%
NumOrDate(...)87.5%88100%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/WebServices/Orders/Grid/OrdersGridFormula.cs

#LineLine coverage
 1using System.Globalization;
 2using System.Text;
 3
 4namespace OzonOrdersWeb.WebServices.Orders.Grid;
 5
 6/// <summary>
 7/// Серверная проверка формул вычисляемых столбцов — та же грамматика, что в orders-grid-formula.js:
 8/// числа, строки, true/false/null, поля, + - * / %, = &lt;&gt; &lt; &gt; &lt;= &gt;=, and/or/not, функции.
 9/// Ничего не вычисляет: возвращает тип результата и список полей, на которые ссылается формула.
 10/// </summary>
 11public static class OrdersGridFormula
 12{
 13    public const int MaxLength = 500;
 14    /// <summary>Предел узлов AST — ограничивает сложность SQL-выражения при переводе формулы.</summary>
 15    public const int MaxNodes = 120;
 16
 1017    public sealed class FormulaException(string message, int position) : Exception(message)
 18    {
 1119        public int Position { get; } = position;
 20    }
 21
 2922    public sealed record Result(string Type, IReadOnlyCollection<string> Refs);
 23
 829124    private sealed record Token(string Kind, string Value, int Pos, double Number = 0);
 25
 26    /// <summary>Русские имена функций и ключевых слов — как в orders-grid-formula.js.</summary>
 127    private static readonly Dictionary<string, string> FunctionAliases = new(StringComparer.OrdinalIgnoreCase)
 128    {
 129        ["если"] = "if", ["округл"] = "round", ["модуль"] = "abs", ["мин"] = "min", ["макс"] = "max", ["первоенепустое"]
 130        ["сцепить"] = "concat", ["прописн"] = "upper", ["строчн"] = "lower", ["сжпробелы"] = "trim", ["длстр"] = "len", 
 131        ["начинается"] = "startswith", ["заканчивается"] = "endswith", ["текст"] = "text", ["число"] = "number", ["сегод
 132        ["дней"] = "days", ["добдней"] = "adddays", ["год"] = "year", ["месяц"] = "month", ["день"] = "day", ["деньнед"]
 133    };
 34
 135    private static readonly Dictionary<string, string> Keywords = new(StringComparer.OrdinalIgnoreCase)
 136    {
 137        ["and"] = "and", ["or"] = "or", ["not"] = "not", ["true"] = "true", ["false"] = "false", ["null"] = "null",
 138        ["и"] = "and", ["или"] = "or", ["не"] = "not", ["истина"] = "true", ["ложь"] = "false"
 139    };
 40
 141    private static readonly Dictionary<string, (int Min, int Max, string Ret)> Functions = new(StringComparer.OrdinalIgn
 142    {
 143        ["if"] = (3, 3, "arg1"), ["round"] = (1, 2, "number"), ["abs"] = (1, 1, "number"), ["min"] = (2, 8, "number"), [
 144        ["coalesce"] = (2, 8, "arg0"), ["isempty"] = (1, 1, "bool"), ["concat"] = (1, 8, "text"), ["upper"] = (1, 1, "te
 145        ["trim"] = (1, 1, "text"), ["len"] = (1, 1, "number"), ["contains"] = (2, 2, "bool"), ["startswith"] = (2, 2, "b
 146        ["text"] = (1, 1, "text"), ["number"] = (1, 1, "number"), ["today"] = (0, 0, "date"), ["date"] = (1, 1, "date"),
 147        ["adddays"] = (2, 2, "date"), ["year"] = (1, 1, "number"), ["month"] = (1, 1, "number"), ["day"] = (1, 1, "numbe
 148    };
 49
 50    /// <summary>Разобрать и проверить формулу по каталогу полей (ключ → тип). Бросает <see cref="FormulaException"/>.</
 51    public static Result Validate(string formula, IReadOnlyDictionary<string, string> fieldTypes)
 52    {
 2453        var node = Parse(formula);
 2154        return Check(node, fieldTypes);
 55    }
 56
 57    /// <summary>Только разбор — AST для транслятора в SQL и проверки типов.</summary>
 58    public static Node Parse(string formula)
 59    {
 5360        if (string.IsNullOrWhiteSpace(formula)) throw new FormulaException("Пустая формула", 0);
 5361        if (formula.Length > MaxLength) throw new FormulaException($"Формула длиннее {MaxLength} символов", MaxLength);
 5362        var node = new Parser(Tokenize(formula)).ParseAll();
 5163        if (CountNodes(node) > MaxNodes) throw new FormulaException($"Слишком сложная формула (больше {MaxNodes} операци
 4964        return node;
 65    }
 66
 45167    private static int CountNodes(Node node) => node switch
 45168    {
 569        Un u => 1 + CountNodes(u.A),
 16470        Bin b => 1 + CountNodes(b.L) + CountNodes(b.R),
 3571        Call c => 1 + c.Args.Sum(CountNodes),
 24772        _ => 1
 45173    };
 74
 75    /// <summary>Проверка типов и ссылок по каталогу полей.</summary>
 76    public static Result Check(Node node, IReadOnlyDictionary<string, string> fieldTypes)
 77    {
 2178        var refs = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
 2179        var type = new Checker(fieldTypes).Check(node, refs);
 1580        return new Result(type == "null" ? "any" : type, refs.ToList());
 81    }
 82
 83    // ---------- Токенизатор ----------
 84
 85    private static List<Token> Tokenize(string src)
 86    {
 5387        var tokens = new List<Token>();
 5388        var i = 0;
 73789        while (i < src.Length)
 90        {
 68691            var c = src[i];
 93292            if (char.IsWhiteSpace(c)) { i++; continue; }
 56393            var start = i;
 56394            if (char.IsDigit(c) || (c == '.' && i + 1 < src.Length && char.IsDigit(src[i + 1])))
 95            {
 15496                var sb = new StringBuilder();
 49097                while (i < src.Length && (char.IsDigit(src[i]) || src[i] == '.')) sb.Append(src[i++]);
 15498                if (i < src.Length && (src[i] == 'e' || src[i] == 'E'))
 99                {
 0100                    sb.Append(src[i++]);
 0101                    if (i < src.Length && (src[i] == '+' || src[i] == '-')) sb.Append(src[i++]);
 0102                    while (i < src.Length && char.IsDigit(src[i])) sb.Append(src[i++]);
 103                }
 154104                if (!double.TryParse(sb.ToString(), NumberStyles.Float, CultureInfo.InvariantCulture, out var num))
 0105                    throw new FormulaException("Некорректное число", start);
 154106                tokens.Add(new Token("num", sb.ToString(), start, num));
 154107                continue;
 108            }
 409109            if (c is '"' or '\'')
 110            {
 17111                var q = c;
 17112                var sb = new StringBuilder();
 17113                i++;
 86114                while (i < src.Length && src[i] != q)
 115                {
 69116                    if (src[i] == '\\' && i + 1 < src.Length) { sb.Append(src[i + 1]); i += 2; continue; }
 69117                    sb.Append(src[i++]);
 118                }
 18119                if (i >= src.Length) throw new FormulaException("Не закрыта кавычка", start);
 16120                i++;
 16121                tokens.Add(new Token("str", sb.ToString(), start));
 16122                continue;
 123            }
 392124            if (c == '[')
 125            {
 126                // [Название поля] — ссылка по русскому названию.
 42127                var close = src.IndexOf(']', i + 1);
 42128                if (close == -1) throw new FormulaException("Не закрыта скобка «]»", start);
 42129                tokens.Add(new Token("id", src.Substring(i + 1, close - i - 1).Trim(), start));
 42130                i = close + 1;
 42131                continue;
 132            }
 350133            if (char.IsLetter(c) || c == '_')
 134            {
 82135                var sb = new StringBuilder();
 1014136                while (i < src.Length && (char.IsLetterOrDigit(src[i]) || src[i] == '_')) sb.Append(src[i++]);
 82137                var id = sb.ToString();
 82138                tokens.Add(Keywords.TryGetValue(id, out var kw) ? new Token("kw", kw, start) : new Token("id", id, start
 82139                continue;
 140            }
 268141            if (i + 1 < src.Length)
 142            {
 239143                var two = src.Substring(i, 2);
 478144                var mapped = two switch { "<=" => "<=", ">=" => ">=", "<>" => "<>", "!=" => "<>", "==" => "=", "&&" => "
 245145                if (mapped != null) { tokens.Add(new Token("op", mapped, start)); i += 2; continue; }
 146            }
 266147            if ("+-*/%()<>=,!".Contains(c))
 148            {
 265149                tokens.Add(new Token("op", c == '!' ? "not" : c.ToString(), start));
 265150                i++;
 265151                continue;
 152            }
 1153            throw new FormulaException($"Недопустимый символ «{c}»", i);
 154        }
 155
 51156        tokens.Add(new Token("eof", string.Empty, src.Length));
 51157        return tokens;
 158    }
 159
 160    // ---------- Парсер и проверка типов ----------
 161
 479162    public abstract record Node(int Pos);
 162163    public sealed record Num(double Value, int Pos) : Node(Pos);
 25164    public sealed record Str(string Value, int Pos) : Node(Pos);
 0165    public sealed record Bool(bool Value, int Pos) : Node(Pos);
 1166    public sealed record Null(int Pos) : Node(Pos);
 183167    public sealed record Field(string Name, int Pos) : Node(Pos);
 22168    public sealed record Un(string Op, Node A, int Pos) : Node(Pos);
 601169    public sealed record Bin(string Op, Node L, Node R, int Pos) : Node(Pos);
 176170    public sealed record Call(string Name, List<Node> Args, int Pos) : Node(Pos);
 171
 51172    private sealed class Parser(List<Token> tokens)
 173    {
 174        private int _p;
 6499175        private Token Peek => tokens[_p];
 559176        private Token Next() => tokens[_p++];
 3188177        private bool IsOp(string v) => (Peek.Kind == "op" || Peek.Kind == "kw") && Peek.Value == v;
 178
 179        public Node ParseAll()
 180        {
 51181            var node = Or();
 50182            if (Peek.Kind != "eof") throw new FormulaException("Лишний текст после выражения", Peek.Pos);
 50183            return node;
 184        }
 185
 361186        private Node Or() { var l = And(); while (IsOp("or")) { var op = Next(); l = new Bin("or", l, And(), op.Pos); } 
 370187        private Node And() { var l = Not(); while (IsOp("and")) { var op = Next(); l = new Bin("and", l, Not(), op.Pos);
 260188        private Node Not() { if (IsOp("not")) { var op = Next(); return new Un("not", Not(), op.Pos); } return Cmp(); }
 189        private Node Cmp()
 190        {
 124191            var l = Add();
 136192            while (IsOp("=") || IsOp("<>") || IsOp("<") || IsOp(">") || IsOp("<=") || IsOp(">="))
 193            {
 13194                var op = Next();
 13195                l = new Bin(op.Value, l, Add(), op.Pos);
 196            }
 123197            return l;
 198        }
 799199        private Node Add() { var l = Mul(); while (IsOp("+") || IsOp("-")) { var op = Next(); l = new Bin(op.Value, l, M
 850200        private Node Mul() { var l = Unary(); while (IsOp("*") || IsOp("/") || IsOp("%")) { var op = Next(); l = new Bin
 201        private Node Unary()
 202        {
 287203            if (IsOp("-")) { var op = Next(); return new Un("neg", Unary(), op.Pos); }
 284204            if (IsOp("+")) { Next(); return Unary(); }
 284205            return Primary();
 206        }
 207        private Node Primary()
 208        {
 284209            var t = Next();
 284210            switch (t.Kind)
 211            {
 154212                case "num": return new Num(t.Number, t.Pos);
 16213                case "str": return new Str(t.Value, t.Pos);
 214                case "kw":
 1215                    return t.Value switch
 1216                    {
 0217                        "true" or "false" => new Bool(t.Value == "true", t.Pos),
 1218                        "null" => new Null(t.Pos),
 0219                        _ => throw new FormulaException($"Неожиданное слово «{t.Value}»", t.Pos)
 1220                    };
 221                case "id":
 112222                    if (IsOp("("))
 223                    {
 35224                        Next();
 35225                        var args = new List<Node>();
 35226                        if (!IsOp(")"))
 227                        {
 33228                            args.Add(Or());
 138229                            while (IsOp(",")) { Next(); args.Add(Or()); }
 230                        }
 35231                        if (!IsOp(")")) throw new FormulaException("Ожидается «)»", Peek.Pos);
 35232                        Next();
 35233                        var fname = t.Value.ToLowerInvariant();
 35234                        return new Call(FunctionAliases.TryGetValue(fname, out var canonical) ? canonical : fname, args,
 235                    }
 77236                    return new Field(t.Value, t.Pos);
 0237                case "op" when t.Value == "(":
 0238                    var e = Or();
 0239                    if (!IsOp(")")) throw new FormulaException("Ожидается «)»", Peek.Pos);
 0240                    Next();
 0241                    return e;
 1242                case "eof": throw new FormulaException("Формула не закончена", t.Pos);
 0243                default: throw new FormulaException($"Неожиданный символ «{t.Value}»", t.Pos);
 244            }
 245        }
 246
 247    }
 248
 21249    private sealed class Checker(IReadOnlyDictionary<string, string> fieldTypes)
 250    {
 251        public string Check(Node node, ISet<string> refs)
 252        {
 253            switch (node)
 254            {
 15255                case Num: return "number";
 6256                case Str: return "text";
 0257                case Bool: return "bool";
 1258                case Null: return "null";
 259                case Field f:
 33260                    if (!fieldTypes.TryGetValue(f.Name, out var type)) throw new FormulaException($"Неизвестное или недо
 29261                    refs.Add(f.Name);
 29262                    return type;
 263                case Un u:
 264                {
 2265                    var a = Check(u.A, refs);
 2266                    if (u.Op == "neg" && !Numeric(a)) throw new FormulaException("Минус применим только к числу", u.Pos)
 2267                    return u.Op == "neg" ? "number" : "bool";
 268                }
 269                case Bin b:
 270                {
 21271                    var l = Check(b.L, refs);
 19272                    var r = Check(b.R, refs);
 19273                    switch (b.Op)
 274                    {
 6275                        case "and": case "or": case "=": case "<>": return "bool";
 276                        case "<": case ">": case "<=": case ">=":
 4277                            if (IsText(l) && IsText(r)) return "bool";
 4278                            if (!NumOrDate(l) || !NumOrDate(r)) throw new FormulaException($"Сравнение «{b.Op}» — только
 4279                            return "bool";
 280                        case "+":
 1281                            if (IsText(l) || IsText(r)) return "text";
 1282                            if (l == "date" && Numeric(r)) return "date";
 1283                            if (!Numeric(l) || !Numeric(r)) throw new FormulaException("Сложение — числа или текст", b.P
 1284                            return "number";
 285                        case "-":
 1286                            if (l == "date" && r == "date") return "number";
 2287                            if (l == "date" && Numeric(r)) return "date";
 0288                            if (!Numeric(l) || !Numeric(r)) throw new FormulaException("Вычитание — только числа и даты"
 0289                            return "number";
 290                        default:
 8291                            if (!Numeric(l) || !Numeric(r)) throw new FormulaException($"Операция «{b.Op}» — только для 
 6292                            return "number";
 293                    }
 294                }
 295                case Call c:
 296                {
 14297                    if (!Functions.TryGetValue(c.Name, out var fn)) throw new FormulaException($"Неизвестная функция «{c
 12298                    if (c.Args.Count < fn.Min || c.Args.Count > fn.Max)
 1299                        throw new FormulaException($"Функция {c.Name}: ожидается {(fn.Min == fn.Max ? fn.Min.ToString() 
 37300                    var types = c.Args.Select(a => Check(a, refs)).ToList();
 12301                    if (c.Name == "if" && types[0] != "bool" && types[0] != "any") throw new FormulaException("Первый ар
 10302                    return fn.Ret switch
 10303                    {
 4304                        "arg1" => types[1] == "null" ? types[2] : types[1],
 3305                        "arg0" => types.FirstOrDefault(t => t != "null") ?? "any",
 5306                        _ => fn.Ret
 10307                    };
 308                }
 309            }
 310
 0311            throw new FormulaException("Неизвестный узел", node.Pos);
 312        }
 313
 7314        private static bool IsText(string t) => t is "text" or "any";
 17315        private static bool Numeric(string t) => t is "number" or "null" or "any";
 8316        private static bool NumOrDate(string t) => t is "number" or "date" or "null" or "any";
 317    }
 318}