Skip to content

Commit 5f6ad43

Browse files
Merge pull request #2 from fadhly-permata/dev
Dev
2 parents 715f9d5 + 5ca7724 commit 5f6ad43

File tree

6 files changed

+551
-0
lines changed

6 files changed

+551
-0
lines changed

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
bin
2+
bin/*
3+
obj
4+
obj/*
5+
JQL.Net.sln

Core/JsonQueryEngine.cs

Lines changed: 344 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,344 @@
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+
}

Exceptions/JsonQueryException.cs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
namespace JQL.Net.Exceptions;
2+
3+
/// <summary>
4+
/// An exception that is thrown when a SQL-like query is invalid.
5+
/// </summary>
6+
public class JsonQueryException : Exception
7+
{
8+
/// <summary>
9+
/// Initializes a new instance of the <see cref="JsonQueryException" /> class.
10+
/// </summary>
11+
/// <param name="message">
12+
/// The message that describes the error.
13+
/// </param>
14+
public JsonQueryException(string message)
15+
: base(message) { }
16+
17+
/// <summary>
18+
/// Initializes a new instance of the <see cref="JsonQueryException" /> class.
19+
/// </summary>
20+
/// <param name="message">
21+
/// The message that describes the error.
22+
/// </param>
23+
/// <param name="innerException">
24+
/// The exception that is the cause of the current exception.
25+
/// </param>
26+
public JsonQueryException(string message, Exception innerException)
27+
: base(message, innerException) { }
28+
}

0 commit comments

Comments
 (0)