| | | 1 | | using System.Globalization; |
| | | 2 | | using System.Text; |
| | | 3 | | |
| | | 4 | | namespace OzonOrdersWeb.WebServices.Orders.Grid; |
| | | 5 | | |
| | | 6 | | /// <summary> |
| | | 7 | | /// Серверная проверка формул вычисляемых столбцов — та же грамматика, что в orders-grid-formula.js: |
| | | 8 | | /// числа, строки, true/false/null, поля, + - * / %, = <> < > <= >=, and/or/not, функции. |
| | | 9 | | /// Ничего не вычисляет: возвращает тип результата и список полей, на которые ссылается формула. |
| | | 10 | | /// </summary> |
| | | 11 | | public static class OrdersGridFormula |
| | | 12 | | { |
| | | 13 | | public const int MaxLength = 500; |
| | | 14 | | /// <summary>Предел узлов AST — ограничивает сложность SQL-выражения при переводе формулы.</summary> |
| | | 15 | | public const int MaxNodes = 120; |
| | | 16 | | |
| | 10 | 17 | | public sealed class FormulaException(string message, int position) : Exception(message) |
| | | 18 | | { |
| | 11 | 19 | | public int Position { get; } = position; |
| | | 20 | | } |
| | | 21 | | |
| | 29 | 22 | | public sealed record Result(string Type, IReadOnlyCollection<string> Refs); |
| | | 23 | | |
| | 8291 | 24 | | private sealed record Token(string Kind, string Value, int Pos, double Number = 0); |
| | | 25 | | |
| | | 26 | | /// <summary>Русские имена функций и ключевых слов — как в orders-grid-formula.js.</summary> |
| | 1 | 27 | | private static readonly Dictionary<string, string> FunctionAliases = new(StringComparer.OrdinalIgnoreCase) |
| | 1 | 28 | | { |
| | 1 | 29 | | ["если"] = "if", ["округл"] = "round", ["модуль"] = "abs", ["мин"] = "min", ["макс"] = "max", ["первоенепустое"] |
| | 1 | 30 | | ["сцепить"] = "concat", ["прописн"] = "upper", ["строчн"] = "lower", ["сжпробелы"] = "trim", ["длстр"] = "len", |
| | 1 | 31 | | ["начинается"] = "startswith", ["заканчивается"] = "endswith", ["текст"] = "text", ["число"] = "number", ["сегод |
| | 1 | 32 | | ["дней"] = "days", ["добдней"] = "adddays", ["год"] = "year", ["месяц"] = "month", ["день"] = "day", ["деньнед"] |
| | 1 | 33 | | }; |
| | | 34 | | |
| | 1 | 35 | | private static readonly Dictionary<string, string> Keywords = new(StringComparer.OrdinalIgnoreCase) |
| | 1 | 36 | | { |
| | 1 | 37 | | ["and"] = "and", ["or"] = "or", ["not"] = "not", ["true"] = "true", ["false"] = "false", ["null"] = "null", |
| | 1 | 38 | | ["и"] = "and", ["или"] = "or", ["не"] = "not", ["истина"] = "true", ["ложь"] = "false" |
| | 1 | 39 | | }; |
| | | 40 | | |
| | 1 | 41 | | private static readonly Dictionary<string, (int Min, int Max, string Ret)> Functions = new(StringComparer.OrdinalIgn |
| | 1 | 42 | | { |
| | 1 | 43 | | ["if"] = (3, 3, "arg1"), ["round"] = (1, 2, "number"), ["abs"] = (1, 1, "number"), ["min"] = (2, 8, "number"), [ |
| | 1 | 44 | | ["coalesce"] = (2, 8, "arg0"), ["isempty"] = (1, 1, "bool"), ["concat"] = (1, 8, "text"), ["upper"] = (1, 1, "te |
| | 1 | 45 | | ["trim"] = (1, 1, "text"), ["len"] = (1, 1, "number"), ["contains"] = (2, 2, "bool"), ["startswith"] = (2, 2, "b |
| | 1 | 46 | | ["text"] = (1, 1, "text"), ["number"] = (1, 1, "number"), ["today"] = (0, 0, "date"), ["date"] = (1, 1, "date"), |
| | 1 | 47 | | ["adddays"] = (2, 2, "date"), ["year"] = (1, 1, "number"), ["month"] = (1, 1, "number"), ["day"] = (1, 1, "numbe |
| | 1 | 48 | | }; |
| | | 49 | | |
| | | 50 | | /// <summary>Разобрать и проверить формулу по каталогу полей (ключ → тип). Бросает <see cref="FormulaException"/>.</ |
| | | 51 | | public static Result Validate(string formula, IReadOnlyDictionary<string, string> fieldTypes) |
| | | 52 | | { |
| | 24 | 53 | | var node = Parse(formula); |
| | 21 | 54 | | return Check(node, fieldTypes); |
| | | 55 | | } |
| | | 56 | | |
| | | 57 | | /// <summary>Только разбор — AST для транслятора в SQL и проверки типов.</summary> |
| | | 58 | | public static Node Parse(string formula) |
| | | 59 | | { |
| | 53 | 60 | | if (string.IsNullOrWhiteSpace(formula)) throw new FormulaException("Пустая формула", 0); |
| | 53 | 61 | | if (formula.Length > MaxLength) throw new FormulaException($"Формула длиннее {MaxLength} символов", MaxLength); |
| | 53 | 62 | | var node = new Parser(Tokenize(formula)).ParseAll(); |
| | 51 | 63 | | if (CountNodes(node) > MaxNodes) throw new FormulaException($"Слишком сложная формула (больше {MaxNodes} операци |
| | 49 | 64 | | return node; |
| | | 65 | | } |
| | | 66 | | |
| | 451 | 67 | | private static int CountNodes(Node node) => node switch |
| | 451 | 68 | | { |
| | 5 | 69 | | Un u => 1 + CountNodes(u.A), |
| | 164 | 70 | | Bin b => 1 + CountNodes(b.L) + CountNodes(b.R), |
| | 35 | 71 | | Call c => 1 + c.Args.Sum(CountNodes), |
| | 247 | 72 | | _ => 1 |
| | 451 | 73 | | }; |
| | | 74 | | |
| | | 75 | | /// <summary>Проверка типов и ссылок по каталогу полей.</summary> |
| | | 76 | | public static Result Check(Node node, IReadOnlyDictionary<string, string> fieldTypes) |
| | | 77 | | { |
| | 21 | 78 | | var refs = new HashSet<string>(StringComparer.OrdinalIgnoreCase); |
| | 21 | 79 | | var type = new Checker(fieldTypes).Check(node, refs); |
| | 15 | 80 | | return new Result(type == "null" ? "any" : type, refs.ToList()); |
| | | 81 | | } |
| | | 82 | | |
| | | 83 | | // ---------- Токенизатор ---------- |
| | | 84 | | |
| | | 85 | | private static List<Token> Tokenize(string src) |
| | | 86 | | { |
| | 53 | 87 | | var tokens = new List<Token>(); |
| | 53 | 88 | | var i = 0; |
| | 737 | 89 | | while (i < src.Length) |
| | | 90 | | { |
| | 686 | 91 | | var c = src[i]; |
| | 932 | 92 | | if (char.IsWhiteSpace(c)) { i++; continue; } |
| | 563 | 93 | | var start = i; |
| | 563 | 94 | | if (char.IsDigit(c) || (c == '.' && i + 1 < src.Length && char.IsDigit(src[i + 1]))) |
| | | 95 | | { |
| | 154 | 96 | | var sb = new StringBuilder(); |
| | 490 | 97 | | while (i < src.Length && (char.IsDigit(src[i]) || src[i] == '.')) sb.Append(src[i++]); |
| | 154 | 98 | | if (i < src.Length && (src[i] == 'e' || src[i] == 'E')) |
| | | 99 | | { |
| | 0 | 100 | | sb.Append(src[i++]); |
| | 0 | 101 | | if (i < src.Length && (src[i] == '+' || src[i] == '-')) sb.Append(src[i++]); |
| | 0 | 102 | | while (i < src.Length && char.IsDigit(src[i])) sb.Append(src[i++]); |
| | | 103 | | } |
| | 154 | 104 | | if (!double.TryParse(sb.ToString(), NumberStyles.Float, CultureInfo.InvariantCulture, out var num)) |
| | 0 | 105 | | throw new FormulaException("Некорректное число", start); |
| | 154 | 106 | | tokens.Add(new Token("num", sb.ToString(), start, num)); |
| | 154 | 107 | | continue; |
| | | 108 | | } |
| | 409 | 109 | | if (c is '"' or '\'') |
| | | 110 | | { |
| | 17 | 111 | | var q = c; |
| | 17 | 112 | | var sb = new StringBuilder(); |
| | 17 | 113 | | i++; |
| | 86 | 114 | | while (i < src.Length && src[i] != q) |
| | | 115 | | { |
| | 69 | 116 | | if (src[i] == '\\' && i + 1 < src.Length) { sb.Append(src[i + 1]); i += 2; continue; } |
| | 69 | 117 | | sb.Append(src[i++]); |
| | | 118 | | } |
| | 18 | 119 | | if (i >= src.Length) throw new FormulaException("Не закрыта кавычка", start); |
| | 16 | 120 | | i++; |
| | 16 | 121 | | tokens.Add(new Token("str", sb.ToString(), start)); |
| | 16 | 122 | | continue; |
| | | 123 | | } |
| | 392 | 124 | | if (c == '[') |
| | | 125 | | { |
| | | 126 | | // [Название поля] — ссылка по русскому названию. |
| | 42 | 127 | | var close = src.IndexOf(']', i + 1); |
| | 42 | 128 | | if (close == -1) throw new FormulaException("Не закрыта скобка «]»", start); |
| | 42 | 129 | | tokens.Add(new Token("id", src.Substring(i + 1, close - i - 1).Trim(), start)); |
| | 42 | 130 | | i = close + 1; |
| | 42 | 131 | | continue; |
| | | 132 | | } |
| | 350 | 133 | | if (char.IsLetter(c) || c == '_') |
| | | 134 | | { |
| | 82 | 135 | | var sb = new StringBuilder(); |
| | 1014 | 136 | | while (i < src.Length && (char.IsLetterOrDigit(src[i]) || src[i] == '_')) sb.Append(src[i++]); |
| | 82 | 137 | | var id = sb.ToString(); |
| | 82 | 138 | | tokens.Add(Keywords.TryGetValue(id, out var kw) ? new Token("kw", kw, start) : new Token("id", id, start |
| | 82 | 139 | | continue; |
| | | 140 | | } |
| | 268 | 141 | | if (i + 1 < src.Length) |
| | | 142 | | { |
| | 239 | 143 | | var two = src.Substring(i, 2); |
| | 478 | 144 | | var mapped = two switch { "<=" => "<=", ">=" => ">=", "<>" => "<>", "!=" => "<>", "==" => "=", "&&" => " |
| | 245 | 145 | | if (mapped != null) { tokens.Add(new Token("op", mapped, start)); i += 2; continue; } |
| | | 146 | | } |
| | 266 | 147 | | if ("+-*/%()<>=,!".Contains(c)) |
| | | 148 | | { |
| | 265 | 149 | | tokens.Add(new Token("op", c == '!' ? "not" : c.ToString(), start)); |
| | 265 | 150 | | i++; |
| | 265 | 151 | | continue; |
| | | 152 | | } |
| | 1 | 153 | | throw new FormulaException($"Недопустимый символ «{c}»", i); |
| | | 154 | | } |
| | | 155 | | |
| | 51 | 156 | | tokens.Add(new Token("eof", string.Empty, src.Length)); |
| | 51 | 157 | | return tokens; |
| | | 158 | | } |
| | | 159 | | |
| | | 160 | | // ---------- Парсер и проверка типов ---------- |
| | | 161 | | |
| | 479 | 162 | | public abstract record Node(int Pos); |
| | 162 | 163 | | public sealed record Num(double Value, int Pos) : Node(Pos); |
| | 25 | 164 | | public sealed record Str(string Value, int Pos) : Node(Pos); |
| | 0 | 165 | | public sealed record Bool(bool Value, int Pos) : Node(Pos); |
| | 1 | 166 | | public sealed record Null(int Pos) : Node(Pos); |
| | 183 | 167 | | public sealed record Field(string Name, int Pos) : Node(Pos); |
| | 22 | 168 | | public sealed record Un(string Op, Node A, int Pos) : Node(Pos); |
| | 601 | 169 | | public sealed record Bin(string Op, Node L, Node R, int Pos) : Node(Pos); |
| | 176 | 170 | | public sealed record Call(string Name, List<Node> Args, int Pos) : Node(Pos); |
| | | 171 | | |
| | 51 | 172 | | private sealed class Parser(List<Token> tokens) |
| | | 173 | | { |
| | | 174 | | private int _p; |
| | 6499 | 175 | | private Token Peek => tokens[_p]; |
| | 559 | 176 | | private Token Next() => tokens[_p++]; |
| | 3188 | 177 | | private bool IsOp(string v) => (Peek.Kind == "op" || Peek.Kind == "kw") && Peek.Value == v; |
| | | 178 | | |
| | | 179 | | public Node ParseAll() |
| | | 180 | | { |
| | 51 | 181 | | var node = Or(); |
| | 50 | 182 | | if (Peek.Kind != "eof") throw new FormulaException("Лишний текст после выражения", Peek.Pos); |
| | 50 | 183 | | return node; |
| | | 184 | | } |
| | | 185 | | |
| | 361 | 186 | | private Node Or() { var l = And(); while (IsOp("or")) { var op = Next(); l = new Bin("or", l, And(), op.Pos); } |
| | 370 | 187 | | private Node And() { var l = Not(); while (IsOp("and")) { var op = Next(); l = new Bin("and", l, Not(), op.Pos); |
| | 260 | 188 | | private Node Not() { if (IsOp("not")) { var op = Next(); return new Un("not", Not(), op.Pos); } return Cmp(); } |
| | | 189 | | private Node Cmp() |
| | | 190 | | { |
| | 124 | 191 | | var l = Add(); |
| | 136 | 192 | | while (IsOp("=") || IsOp("<>") || IsOp("<") || IsOp(">") || IsOp("<=") || IsOp(">=")) |
| | | 193 | | { |
| | 13 | 194 | | var op = Next(); |
| | 13 | 195 | | l = new Bin(op.Value, l, Add(), op.Pos); |
| | | 196 | | } |
| | 123 | 197 | | return l; |
| | | 198 | | } |
| | 799 | 199 | | private Node Add() { var l = Mul(); while (IsOp("+") || IsOp("-")) { var op = Next(); l = new Bin(op.Value, l, M |
| | 850 | 200 | | private Node Mul() { var l = Unary(); while (IsOp("*") || IsOp("/") || IsOp("%")) { var op = Next(); l = new Bin |
| | | 201 | | private Node Unary() |
| | | 202 | | { |
| | 287 | 203 | | if (IsOp("-")) { var op = Next(); return new Un("neg", Unary(), op.Pos); } |
| | 284 | 204 | | if (IsOp("+")) { Next(); return Unary(); } |
| | 284 | 205 | | return Primary(); |
| | | 206 | | } |
| | | 207 | | private Node Primary() |
| | | 208 | | { |
| | 284 | 209 | | var t = Next(); |
| | 284 | 210 | | switch (t.Kind) |
| | | 211 | | { |
| | 154 | 212 | | case "num": return new Num(t.Number, t.Pos); |
| | 16 | 213 | | case "str": return new Str(t.Value, t.Pos); |
| | | 214 | | case "kw": |
| | 1 | 215 | | return t.Value switch |
| | 1 | 216 | | { |
| | 0 | 217 | | "true" or "false" => new Bool(t.Value == "true", t.Pos), |
| | 1 | 218 | | "null" => new Null(t.Pos), |
| | 0 | 219 | | _ => throw new FormulaException($"Неожиданное слово «{t.Value}»", t.Pos) |
| | 1 | 220 | | }; |
| | | 221 | | case "id": |
| | 112 | 222 | | if (IsOp("(")) |
| | | 223 | | { |
| | 35 | 224 | | Next(); |
| | 35 | 225 | | var args = new List<Node>(); |
| | 35 | 226 | | if (!IsOp(")")) |
| | | 227 | | { |
| | 33 | 228 | | args.Add(Or()); |
| | 138 | 229 | | while (IsOp(",")) { Next(); args.Add(Or()); } |
| | | 230 | | } |
| | 35 | 231 | | if (!IsOp(")")) throw new FormulaException("Ожидается «)»", Peek.Pos); |
| | 35 | 232 | | Next(); |
| | 35 | 233 | | var fname = t.Value.ToLowerInvariant(); |
| | 35 | 234 | | return new Call(FunctionAliases.TryGetValue(fname, out var canonical) ? canonical : fname, args, |
| | | 235 | | } |
| | 77 | 236 | | return new Field(t.Value, t.Pos); |
| | 0 | 237 | | case "op" when t.Value == "(": |
| | 0 | 238 | | var e = Or(); |
| | 0 | 239 | | if (!IsOp(")")) throw new FormulaException("Ожидается «)»", Peek.Pos); |
| | 0 | 240 | | Next(); |
| | 0 | 241 | | return e; |
| | 1 | 242 | | case "eof": throw new FormulaException("Формула не закончена", t.Pos); |
| | 0 | 243 | | default: throw new FormulaException($"Неожиданный символ «{t.Value}»", t.Pos); |
| | | 244 | | } |
| | | 245 | | } |
| | | 246 | | |
| | | 247 | | } |
| | | 248 | | |
| | 21 | 249 | | private sealed class Checker(IReadOnlyDictionary<string, string> fieldTypes) |
| | | 250 | | { |
| | | 251 | | public string Check(Node node, ISet<string> refs) |
| | | 252 | | { |
| | | 253 | | switch (node) |
| | | 254 | | { |
| | 15 | 255 | | case Num: return "number"; |
| | 6 | 256 | | case Str: return "text"; |
| | 0 | 257 | | case Bool: return "bool"; |
| | 1 | 258 | | case Null: return "null"; |
| | | 259 | | case Field f: |
| | 33 | 260 | | if (!fieldTypes.TryGetValue(f.Name, out var type)) throw new FormulaException($"Неизвестное или недо |
| | 29 | 261 | | refs.Add(f.Name); |
| | 29 | 262 | | return type; |
| | | 263 | | case Un u: |
| | | 264 | | { |
| | 2 | 265 | | var a = Check(u.A, refs); |
| | 2 | 266 | | if (u.Op == "neg" && !Numeric(a)) throw new FormulaException("Минус применим только к числу", u.Pos) |
| | 2 | 267 | | return u.Op == "neg" ? "number" : "bool"; |
| | | 268 | | } |
| | | 269 | | case Bin b: |
| | | 270 | | { |
| | 21 | 271 | | var l = Check(b.L, refs); |
| | 19 | 272 | | var r = Check(b.R, refs); |
| | 19 | 273 | | switch (b.Op) |
| | | 274 | | { |
| | 6 | 275 | | case "and": case "or": case "=": case "<>": return "bool"; |
| | | 276 | | case "<": case ">": case "<=": case ">=": |
| | 4 | 277 | | if (IsText(l) && IsText(r)) return "bool"; |
| | 4 | 278 | | if (!NumOrDate(l) || !NumOrDate(r)) throw new FormulaException($"Сравнение «{b.Op}» — только |
| | 4 | 279 | | return "bool"; |
| | | 280 | | case "+": |
| | 1 | 281 | | if (IsText(l) || IsText(r)) return "text"; |
| | 1 | 282 | | if (l == "date" && Numeric(r)) return "date"; |
| | 1 | 283 | | if (!Numeric(l) || !Numeric(r)) throw new FormulaException("Сложение — числа или текст", b.P |
| | 1 | 284 | | return "number"; |
| | | 285 | | case "-": |
| | 1 | 286 | | if (l == "date" && r == "date") return "number"; |
| | 2 | 287 | | if (l == "date" && Numeric(r)) return "date"; |
| | 0 | 288 | | if (!Numeric(l) || !Numeric(r)) throw new FormulaException("Вычитание — только числа и даты" |
| | 0 | 289 | | return "number"; |
| | | 290 | | default: |
| | 8 | 291 | | if (!Numeric(l) || !Numeric(r)) throw new FormulaException($"Операция «{b.Op}» — только для |
| | 6 | 292 | | return "number"; |
| | | 293 | | } |
| | | 294 | | } |
| | | 295 | | case Call c: |
| | | 296 | | { |
| | 14 | 297 | | if (!Functions.TryGetValue(c.Name, out var fn)) throw new FormulaException($"Неизвестная функция «{c |
| | 12 | 298 | | if (c.Args.Count < fn.Min || c.Args.Count > fn.Max) |
| | 1 | 299 | | throw new FormulaException($"Функция {c.Name}: ожидается {(fn.Min == fn.Max ? fn.Min.ToString() |
| | 37 | 300 | | var types = c.Args.Select(a => Check(a, refs)).ToList(); |
| | 12 | 301 | | if (c.Name == "if" && types[0] != "bool" && types[0] != "any") throw new FormulaException("Первый ар |
| | 10 | 302 | | return fn.Ret switch |
| | 10 | 303 | | { |
| | 4 | 304 | | "arg1" => types[1] == "null" ? types[2] : types[1], |
| | 3 | 305 | | "arg0" => types.FirstOrDefault(t => t != "null") ?? "any", |
| | 5 | 306 | | _ => fn.Ret |
| | 10 | 307 | | }; |
| | | 308 | | } |
| | | 309 | | } |
| | | 310 | | |
| | 0 | 311 | | throw new FormulaException("Неизвестный узел", node.Pos); |
| | | 312 | | } |
| | | 313 | | |
| | 7 | 314 | | private static bool IsText(string t) => t is "text" or "any"; |
| | 17 | 315 | | private static bool Numeric(string t) => t is "number" or "null" or "any"; |
| | 8 | 316 | | private static bool NumOrDate(string t) => t is "number" or "date" or "null" or "any"; |
| | | 317 | | } |
| | | 318 | | } |