|
| 1 | +using JQL.Net.Exceptions; |
| 2 | +using Newtonsoft.Json.Linq; |
| 3 | + |
| 4 | +namespace JQL.Net.Core; |
| 5 | + |
| 6 | +/// <summary> |
| 7 | +/// Executes a SQL-like query against a JObject |
| 8 | +/// </summary> |
| 9 | +public static class JsonQueryEngine |
| 10 | +{ |
| 11 | + /// <summary> |
| 12 | + /// Executes a SQL-like query against a JObject |
| 13 | + /// </summary> |
| 14 | + /// <param name="request"> |
| 15 | + /// The SQL-like query |
| 16 | + /// </param> |
| 17 | + /// <returns> |
| 18 | + /// The results of the query |
| 19 | + /// </returns> |
| 20 | + /// <exception cref="JsonQueryException"> |
| 21 | + /// Thrown when <paramref name="request" /> is null |
| 22 | + /// </exception> |
| 23 | + public static IEnumerable<JToken> Execute(JsonQueryRequest request) |
| 24 | + { |
| 25 | + if (request.Data == null) |
| 26 | + return Enumerable.Empty<JToken>(); |
| 27 | + |
| 28 | + try |
| 29 | + { |
| 30 | + var fromParts = request.From.Split( |
| 31 | + [" AS ", " as "], |
| 32 | + StringSplitOptions.RemoveEmptyEntries |
| 33 | + ); |
| 34 | + string fromPath = fromParts[0].Trim(); |
| 35 | + string fromAlias = fromParts.Length > 1 ? fromParts[1].Trim() : string.Empty; |
| 36 | + |
| 37 | + JToken? targetToken = GetSourceToken(request.Data, fromPath); |
| 38 | + if (targetToken == null) |
| 39 | + return Enumerable.Empty<JToken>(); |
| 40 | + |
| 41 | + IEnumerable<JToken> query = targetToken is JArray array ? array : [targetToken]; |
| 42 | + |
| 43 | + if (!string.IsNullOrEmpty(fromAlias)) |
| 44 | + { |
| 45 | + query = query.Select(item => |
| 46 | + { |
| 47 | + var wrapped = new JObject { [fromAlias] = item.DeepClone() }; |
| 48 | + if (item is JObject jo) |
| 49 | + wrapped.Merge(jo); |
| 50 | + return (JToken)wrapped; |
| 51 | + }); |
| 52 | + } |
| 53 | + |
| 54 | + if (request.Join != null) |
| 55 | + foreach (var joinStmt in request.Join) |
| 56 | + query = ApplyJoin(request.Data, query, joinStmt); |
| 57 | + |
| 58 | + if (request.Conditions?.Length > 0) |
| 59 | + query = query.Where(item => |
| 60 | + EvaluateConditions(request.Data, item, request.Conditions) |
| 61 | + ); |
| 62 | + |
| 63 | + if (request.GroupBy?.Length > 0) |
| 64 | + { |
| 65 | + query = ProcessGrouping(request.Data, query, request); |
| 66 | + if (request.Having != null) |
| 67 | + query = query.Where(item => |
| 68 | + EvaluateConditions(request.Data, item, request.Having) |
| 69 | + ); |
| 70 | + } |
| 71 | + else if (request.Select?.Length > 0) |
| 72 | + { |
| 73 | + query = query.Select(item => ProjectItem(request.Data, item, request.Select)); |
| 74 | + } |
| 75 | + |
| 76 | + if (request.Order != null) |
| 77 | + query = ApplyOrdering(query, request.Order); |
| 78 | + |
| 79 | + return query; |
| 80 | + } |
| 81 | + catch (Exception ex) |
| 82 | + { |
| 83 | + throw new JsonQueryException( |
| 84 | + "Error executing JSON query. Check inner exception for details.", |
| 85 | + ex |
| 86 | + ); |
| 87 | + } |
| 88 | + } |
| 89 | + |
| 90 | + private static JToken? GetSourceToken(JObject root, string path) |
| 91 | + { |
| 92 | + if (path == "$") |
| 93 | + return root; |
| 94 | + if (path.StartsWith("$.")) |
| 95 | + { |
| 96 | + string cleanPath = path[2..]; |
| 97 | + return root.SelectToken(cleanPath) ?? root[cleanPath]; |
| 98 | + } |
| 99 | + return root.SelectToken(path) ?? root[path]; |
| 100 | + } |
| 101 | + |
| 102 | + private static IEnumerable<JToken> ApplyJoin( |
| 103 | + JObject root, |
| 104 | + IEnumerable<JToken> mainQuery, |
| 105 | + string joinStatement |
| 106 | + ) |
| 107 | + { |
| 108 | + var onParts = joinStatement.Split([" ON ", " on "], StringSplitOptions.RemoveEmptyEntries); |
| 109 | + if (onParts.Length < 2) |
| 110 | + return mainQuery; |
| 111 | + |
| 112 | + var tableParts = onParts[0] |
| 113 | + .Trim() |
| 114 | + .Split([" AS ", " as "], StringSplitOptions.RemoveEmptyEntries); |
| 115 | + string joinTablePath = tableParts[0].Trim(); |
| 116 | + string rightAlias = |
| 117 | + tableParts.Length > 1 ? tableParts[1].Trim() : joinTablePath.Replace("$.", ""); |
| 118 | + |
| 119 | + JToken? rightToken = GetSourceToken(root, joinTablePath); |
| 120 | + var rightArray = rightToken is JArray ja |
| 121 | + ? ja |
| 122 | + : (rightToken != null ? new JArray(rightToken) : null); |
| 123 | + if (rightArray == null) |
| 124 | + return mainQuery; |
| 125 | + |
| 126 | + string fullOnCondition = onParts[1].Trim(); |
| 127 | + |
| 128 | + return mainQuery.Select(leftItem => |
| 129 | + { |
| 130 | + var match = rightArray.FirstOrDefault(rightItem => |
| 131 | + { |
| 132 | + JObject joinContext = new JObject { [rightAlias] = rightItem.DeepClone() }; |
| 133 | + if (leftItem is JObject jo) |
| 134 | + joinContext.Merge(jo); |
| 135 | + |
| 136 | + var joinConditions = fullOnCondition |
| 137 | + .Split( |
| 138 | + [" AND ", " OR ", " and ", " or "], |
| 139 | + StringSplitOptions.RemoveEmptyEntries |
| 140 | + ) |
| 141 | + .Select(c => c.Trim()) |
| 142 | + .ToArray(); |
| 143 | + |
| 144 | + return EvaluateConditions(root, joinContext, joinConditions); |
| 145 | + }); |
| 146 | + |
| 147 | + if (match == null) |
| 148 | + return leftItem; |
| 149 | + |
| 150 | + JObject combined = leftItem is JObject jo ? (JObject)jo.DeepClone() : new JObject(); |
| 151 | + combined[rightAlias] = match.DeepClone(); |
| 152 | + return (JToken)combined; |
| 153 | + }); |
| 154 | + } |
| 155 | + |
| 156 | + private static JToken ProjectItem(JObject root, JToken item, string[] select) |
| 157 | + { |
| 158 | + JObject projectedObj = []; |
| 159 | + foreach (var selection in select) |
| 160 | + { |
| 161 | + var parts = selection.Split([" AS ", " as "], StringSplitOptions.RemoveEmptyEntries); |
| 162 | + string sourceField = parts[0].Trim(); |
| 163 | + string aliasField = |
| 164 | + parts.Length > 1 |
| 165 | + ? parts[1].Trim() |
| 166 | + : ( |
| 167 | + sourceField.Contains('.') |
| 168 | + ? sourceField.Split('.').Last() |
| 169 | + : sourceField.Replace("$.", "") |
| 170 | + ); |
| 171 | + |
| 172 | + projectedObj[aliasField] = GetTokenValue(root, item, sourceField); |
| 173 | + } |
| 174 | + return projectedObj; |
| 175 | + } |
| 176 | + |
| 177 | + private static JToken? GetTokenValue(JObject root, JToken item, string path) |
| 178 | + { |
| 179 | + if (path == "$") |
| 180 | + return root; |
| 181 | + if (path.StartsWith("$.")) |
| 182 | + return root.SelectToken(path[2..]); |
| 183 | + return item.SelectToken(path) ?? item[path]; |
| 184 | + } |
| 185 | + |
| 186 | + private static bool EvaluateConditions(JObject root, JToken item, string[] conditions) |
| 187 | + { |
| 188 | + if (conditions.Length == 0) |
| 189 | + return true; |
| 190 | + bool finalResult = false; |
| 191 | + string currentOperator = "OR"; |
| 192 | + |
| 193 | + foreach (var condition in conditions) |
| 194 | + { |
| 195 | + string upperCond = condition.Trim().ToUpper(); |
| 196 | + if (upperCond is "AND" or "OR") |
| 197 | + { |
| 198 | + currentOperator = upperCond; |
| 199 | + continue; |
| 200 | + } |
| 201 | + |
| 202 | + bool currentCondResult = EvaluateSingleCondition(root, item, condition); |
| 203 | + if (currentOperator == "OR") |
| 204 | + finalResult = finalResult || currentCondResult; |
| 205 | + else if (currentOperator == "AND") |
| 206 | + finalResult = finalResult && currentCondResult; |
| 207 | + } |
| 208 | + return finalResult; |
| 209 | + } |
| 210 | + |
| 211 | + private static bool EvaluateSingleCondition(JObject root, JToken item, string condition) |
| 212 | + { |
| 213 | + var parts = condition.Split(' ', StringSplitOptions.RemoveEmptyEntries); |
| 214 | + if (parts.Length < 3) |
| 215 | + return false; |
| 216 | + |
| 217 | + string property = parts[0]; |
| 218 | + string op = parts[1]; |
| 219 | + string rawValue = parts[2]; |
| 220 | + |
| 221 | + JToken? leftVal = GetTokenValue(root, item, property); |
| 222 | + if (leftVal == null) |
| 223 | + return false; |
| 224 | + |
| 225 | + JToken? rightVal = |
| 226 | + (rawValue.StartsWith('\'') && rawValue.EndsWith('\'')) |
| 227 | + ? rawValue.Trim('\'') |
| 228 | + : GetTokenValue(root, item, rawValue) ?? rawValue; |
| 229 | + |
| 230 | + if (rightVal == null) |
| 231 | + return false; |
| 232 | + |
| 233 | + string s1 = leftVal.ToString(); |
| 234 | + string s2 = rightVal.ToString(); |
| 235 | + |
| 236 | + if (double.TryParse(s1, out double n1) && double.TryParse(s2, out double n2)) |
| 237 | + return op switch |
| 238 | + { |
| 239 | + "==" => n1 == n2, |
| 240 | + "!=" => n1 != n2, |
| 241 | + ">" => n1 > n2, |
| 242 | + "<" => n1 < n2, |
| 243 | + ">=" => n1 >= n2, |
| 244 | + "<=" => n1 <= n2, |
| 245 | + _ => false, |
| 246 | + }; |
| 247 | + |
| 248 | + return op switch |
| 249 | + { |
| 250 | + "==" => s1.Equals(s2, StringComparison.OrdinalIgnoreCase), |
| 251 | + "!=" => !s1.Equals(s2, StringComparison.OrdinalIgnoreCase), |
| 252 | + _ => false, |
| 253 | + }; |
| 254 | + } |
| 255 | + |
| 256 | + private static IEnumerable<JToken> ProcessGrouping( |
| 257 | + JObject root, |
| 258 | + IEnumerable<JToken> query, |
| 259 | + JsonQueryRequest request |
| 260 | + ) |
| 261 | + { |
| 262 | + var groupedData = query.GroupBy(item => |
| 263 | + string.Join( |
| 264 | + "-", |
| 265 | + request.GroupBy!.Select(g => GetTokenValue(root, item, g)?.ToString() ?? "") |
| 266 | + ) |
| 267 | + ); |
| 268 | + |
| 269 | + return groupedData.Select(group => |
| 270 | + { |
| 271 | + JObject resultObj = []; |
| 272 | + foreach (var key in request.GroupBy!) |
| 273 | + { |
| 274 | + string cleanAlias = key.Contains('.') |
| 275 | + ? key.Split('.').Last() |
| 276 | + : key.Replace("$.", ""); |
| 277 | + resultObj[cleanAlias] = GetTokenValue(root, group.First(), key); |
| 278 | + } |
| 279 | + |
| 280 | + if (request.Select != null) |
| 281 | + { |
| 282 | + foreach (var selection in request.Select) |
| 283 | + { |
| 284 | + var parts = selection.Split( |
| 285 | + [" AS ", " as "], |
| 286 | + StringSplitOptions.RemoveEmptyEntries |
| 287 | + ); |
| 288 | + string expression = parts[0].Trim(); |
| 289 | + string alias = |
| 290 | + parts.Length > 1 ? parts[1].Trim() : expression.Replace("$.", ""); |
| 291 | + |
| 292 | + if (expression.Contains('(') && expression.Contains(')')) |
| 293 | + resultObj[alias] = CalculateAggregate(group, expression); |
| 294 | + else if (!request.GroupBy!.Contains(expression)) |
| 295 | + resultObj[alias] = GetTokenValue(root, group.First(), expression); |
| 296 | + } |
| 297 | + } |
| 298 | + return (JToken)resultObj; |
| 299 | + }); |
| 300 | + } |
| 301 | + |
| 302 | + private static IEnumerable<JToken> ApplyOrdering(IEnumerable<JToken> query, string[] order) |
| 303 | + { |
| 304 | + IOrderedEnumerable<JToken>? orderedQuery = null; |
| 305 | + for (int i = 0; i < order.Length; i++) |
| 306 | + { |
| 307 | + string column = order[i]; |
| 308 | + if (i == 0) |
| 309 | + orderedQuery = query.OrderBy(item => item.SelectToken(column) ?? item[column]); |
| 310 | + else |
| 311 | + orderedQuery = orderedQuery!.ThenBy(item => |
| 312 | + item.SelectToken(column) ?? item[column] |
| 313 | + ); |
| 314 | + } |
| 315 | + return orderedQuery ?? query; |
| 316 | + } |
| 317 | + |
| 318 | + private static JToken CalculateAggregate(IEnumerable<JToken> group, string expression) |
| 319 | + { |
| 320 | + var openParen = expression.IndexOf('('); |
| 321 | + var closeParen = expression.IndexOf(')'); |
| 322 | + if (openParen == -1 || closeParen == -1) |
| 323 | + return JValue.CreateNull(); |
| 324 | + |
| 325 | + string func = expression[..openParen].ToUpper(); |
| 326 | + string field = expression.Substring(openParen + 1, closeParen - openParen - 1); |
| 327 | + |
| 328 | + var values = group |
| 329 | + .Select(item => GetTokenValue(null!, item, field)) |
| 330 | + .Where(v => v != null && v.Type != JTokenType.Null); |
| 331 | + if (!values.Any()) |
| 332 | + return 0; |
| 333 | + |
| 334 | + return func switch |
| 335 | + { |
| 336 | + "SUM" => values.Sum(v => (double)v), |
| 337 | + "COUNT" => values.Count(), |
| 338 | + "AVG" => values.Average(v => (double)v), |
| 339 | + "MIN" => values.Min(v => (double)v), |
| 340 | + "MAX" => values.Max(v => (double)v), |
| 341 | + _ => JValue.CreateNull(), |
| 342 | + }; |
| 343 | + } |
| 344 | +} |
0 commit comments