tellaro-query-language 0.2.0__py3-none-any.whl → 0.2.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {tellaro_query_language-0.2.0.dist-info → tellaro_query_language-0.2.2.dist-info}/METADATA +24 -1
- {tellaro_query_language-0.2.0.dist-info → tellaro_query_language-0.2.2.dist-info}/RECORD +27 -27
- tql/core.py +225 -54
- tql/core_components/opensearch_operations.py +415 -99
- tql/core_components/stats_operations.py +11 -1
- tql/evaluator.py +39 -2
- tql/evaluator_components/special_expressions.py +25 -6
- tql/evaluator_components/value_comparison.py +31 -3
- tql/mutator_analyzer.py +640 -242
- tql/mutators/__init__.py +5 -1
- tql/mutators/dns.py +76 -53
- tql/mutators/security.py +101 -100
- tql/mutators/string.py +74 -0
- tql/opensearch_components/field_mapping.py +9 -3
- tql/opensearch_components/lucene_converter.py +12 -0
- tql/opensearch_components/query_converter.py +134 -25
- tql/opensearch_mappings.py +2 -2
- tql/opensearch_stats.py +170 -39
- tql/parser.py +92 -37
- tql/parser_components/ast_builder.py +37 -1
- tql/parser_components/field_extractor.py +9 -1
- tql/parser_components/grammar.py +32 -8
- tql/post_processor.py +489 -31
- tql/stats_evaluator.py +170 -12
- {tellaro_query_language-0.2.0.dist-info → tellaro_query_language-0.2.2.dist-info}/LICENSE +0 -0
- {tellaro_query_language-0.2.0.dist-info → tellaro_query_language-0.2.2.dist-info}/WHEEL +0 -0
- {tellaro_query_language-0.2.0.dist-info → tellaro_query_language-0.2.2.dist-info}/entry_points.txt +0 -0
tql/parser.py
CHANGED
|
@@ -150,6 +150,17 @@ class TQLParser:
|
|
|
150
150
|
if field_mutators:
|
|
151
151
|
result["field_mutators"] = field_mutators
|
|
152
152
|
return result
|
|
153
|
+
else:
|
|
154
|
+
# This is field | mutator without operator (e.g., field | lowercase)
|
|
155
|
+
# Treat as field exists with mutator for output transformation
|
|
156
|
+
result = {
|
|
157
|
+
"type": "comparison",
|
|
158
|
+
"field": field_name,
|
|
159
|
+
"type_hint": type_hint,
|
|
160
|
+
"operator": "exists",
|
|
161
|
+
"field_mutators": field_mutators,
|
|
162
|
+
}
|
|
163
|
+
return result
|
|
153
164
|
# Single item, unwrap it
|
|
154
165
|
return self._build_ast(parsed[0])
|
|
155
166
|
elif len(parsed) >= 2 and isinstance(parsed[0], str) and parsed[0].lower() == "stats":
|
|
@@ -196,31 +207,45 @@ class TQLParser:
|
|
|
196
207
|
|
|
197
208
|
return result
|
|
198
209
|
|
|
199
|
-
# Check for
|
|
200
|
-
|
|
201
|
-
|
|
210
|
+
# Check for NOT operator first (before field | mutator check)
|
|
211
|
+
elif isinstance(first, str) and (first.lower() == "not" or first == "!"):
|
|
212
|
+
# Unary logical operator (NOT or !)
|
|
213
|
+
return {"type": "unary_op", "operator": "not", "operand": self._build_ast(second)}
|
|
214
|
+
|
|
215
|
+
# Check for field | mutator without operator
|
|
216
|
+
# This happens when we have a field with mutator(s) as the last element
|
|
217
|
+
elif isinstance(first, str) and isinstance(second, list):
|
|
202
218
|
# This could be field | mutator structure
|
|
203
|
-
|
|
204
|
-
if
|
|
219
|
+
# Check if second is a mutator structure (either ['mutator'] or ['mutator', [...params...]])
|
|
220
|
+
if len(second) >= 1 and isinstance(second[0], str):
|
|
221
|
+
mutator_name = second[0]
|
|
205
222
|
# Build a typed_field from these components
|
|
206
223
|
typed_field = [first, second]
|
|
207
224
|
field_name, type_hint, field_mutators = self.ast_builder.extract_field_info(typed_field)
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
225
|
+
|
|
226
|
+
if mutator_name.lower() in ["is_private", "is_global"]:
|
|
227
|
+
# This is field | is_private or field | is_global without operator
|
|
228
|
+
# Default to eq true
|
|
229
|
+
result = {
|
|
230
|
+
"type": "comparison",
|
|
231
|
+
"field": field_name,
|
|
232
|
+
"type_hint": type_hint,
|
|
233
|
+
"operator": "eq",
|
|
234
|
+
"value": "true",
|
|
235
|
+
}
|
|
236
|
+
else:
|
|
237
|
+
# This is field | mutator without operator (e.g., field | lowercase)
|
|
238
|
+
# Treat as field exists with mutator for output transformation
|
|
239
|
+
result = {
|
|
240
|
+
"type": "comparison",
|
|
241
|
+
"field": field_name,
|
|
242
|
+
"type_hint": type_hint,
|
|
243
|
+
"operator": "exists",
|
|
244
|
+
}
|
|
245
|
+
|
|
217
246
|
if field_mutators:
|
|
218
247
|
result["field_mutators"] = field_mutators
|
|
219
248
|
return result
|
|
220
|
-
|
|
221
|
-
elif isinstance(first, str) and (first.lower() == "not" or first == "!"):
|
|
222
|
-
# Unary logical operator (NOT or !)
|
|
223
|
-
return {"type": "unary_op", "operator": "not", "operand": self._build_ast(second)}
|
|
224
249
|
elif isinstance(second, str) and (second.lower() == "exists" or second.lower() == "!exists"):
|
|
225
250
|
# Unary comparison operation (field exists or !exists)
|
|
226
251
|
field_name, type_hint, mutators = self.ast_builder.extract_field_info(first)
|
|
@@ -249,25 +274,37 @@ class TQLParser:
|
|
|
249
274
|
# Fallback to treating as unary logical operator
|
|
250
275
|
return {"type": "unary_op", "operator": first.lower(), "operand": self._build_ast(second)}
|
|
251
276
|
elif len(parsed) >= 3:
|
|
252
|
-
# Check if this is a field with multiple mutators
|
|
253
|
-
if isinstance(parsed[0], str) and all(
|
|
277
|
+
# Check if this is a field with multiple mutators
|
|
278
|
+
if isinstance(parsed[0], str) and all(
|
|
279
|
+
isinstance(item, list) and len(item) >= 1 and isinstance(item[0], str) for item in parsed[1:]
|
|
280
|
+
):
|
|
254
281
|
# This looks like field | mutator1 | mutator2 | ...
|
|
255
282
|
last_mutator_list = parsed[-1]
|
|
256
|
-
if (
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
and last_mutator_list[0].lower() in ["is_private", "is_global"]
|
|
260
|
-
):
|
|
261
|
-
# This is a field with mutators ending in is_private/is_global
|
|
262
|
-
# Build the typed_field structure and default to eq true
|
|
283
|
+
if len(last_mutator_list) >= 1 and isinstance(last_mutator_list[0], str):
|
|
284
|
+
# This is a field with mutators
|
|
285
|
+
# Build the typed_field structure
|
|
263
286
|
field_name, type_hint, field_mutators = self.ast_builder.extract_field_info(parsed)
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
287
|
+
|
|
288
|
+
# Check if last mutator is is_private/is_global
|
|
289
|
+
last_mutator_name = last_mutator_list[0].lower()
|
|
290
|
+
if last_mutator_name in ["is_private", "is_global"]:
|
|
291
|
+
# Default to eq true for these special mutators
|
|
292
|
+
result = {
|
|
293
|
+
"type": "comparison",
|
|
294
|
+
"field": field_name,
|
|
295
|
+
"type_hint": type_hint,
|
|
296
|
+
"operator": "eq",
|
|
297
|
+
"value": "true",
|
|
298
|
+
}
|
|
299
|
+
else:
|
|
300
|
+
# For other mutators, treat as field exists
|
|
301
|
+
result = {
|
|
302
|
+
"type": "comparison",
|
|
303
|
+
"field": field_name,
|
|
304
|
+
"type_hint": type_hint,
|
|
305
|
+
"operator": "exists",
|
|
306
|
+
}
|
|
307
|
+
|
|
271
308
|
if field_mutators:
|
|
272
309
|
result["field_mutators"] = field_mutators
|
|
273
310
|
return result
|
|
@@ -1370,10 +1407,28 @@ class TQLParser:
|
|
|
1370
1407
|
else:
|
|
1371
1408
|
i += 1
|
|
1372
1409
|
|
|
1373
|
-
# Process group by fields
|
|
1410
|
+
# Process group by fields and visualization hint
|
|
1374
1411
|
while i < len(parsed):
|
|
1375
|
-
if isinstance(parsed[i], str)
|
|
1376
|
-
|
|
1412
|
+
if isinstance(parsed[i], str):
|
|
1413
|
+
if parsed[i] == "=>":
|
|
1414
|
+
# Visualization hint found
|
|
1415
|
+
i += 1
|
|
1416
|
+
if i < len(parsed) and isinstance(parsed[i], str):
|
|
1417
|
+
result["viz_hint"] = parsed[i].lower()
|
|
1418
|
+
break
|
|
1419
|
+
elif parsed[i] not in ["by", ","]:
|
|
1420
|
+
# This is a simple field name without bucket size - normalize to dict format
|
|
1421
|
+
result["group_by"].append({"field": parsed[i], "bucket_size": None})
|
|
1422
|
+
elif isinstance(parsed[i], list):
|
|
1423
|
+
# This is a group by field with optional bucket size
|
|
1424
|
+
if len(parsed[i]) >= 1:
|
|
1425
|
+
# Check for "top N" specification
|
|
1426
|
+
if len(parsed[i]) >= 3 and parsed[i][1].lower() == "top":
|
|
1427
|
+
field_spec = {"field": parsed[i][0], "bucket_size": int(parsed[i][2])}
|
|
1428
|
+
result["group_by"].append(field_spec)
|
|
1429
|
+
else:
|
|
1430
|
+
# No bucket size specified - normalize to dict format
|
|
1431
|
+
result["group_by"].append({"field": parsed[i][0], "bucket_size": None})
|
|
1377
1432
|
i += 1
|
|
1378
1433
|
|
|
1379
1434
|
return result
|
|
@@ -6,7 +6,7 @@ from typing import Any, Dict, List, Tuple, Union
|
|
|
6
6
|
class ASTBuilder:
|
|
7
7
|
"""Builds Abstract Syntax Tree from parsed TQL expressions."""
|
|
8
8
|
|
|
9
|
-
def extract_field_info(self, field_spec: Any) -> Tuple[str, Union[str, None], List[Dict[str, Any]]]:
|
|
9
|
+
def extract_field_info(self, field_spec: Any) -> Tuple[str, Union[str, None], List[Dict[str, Any]]]: # noqa: C901
|
|
10
10
|
"""Extract field name, optional type hint, and mutators from field specification.
|
|
11
11
|
|
|
12
12
|
Args:
|
|
@@ -48,7 +48,25 @@ class ASTBuilder:
|
|
|
48
48
|
params = []
|
|
49
49
|
for param in item[1]:
|
|
50
50
|
if isinstance(param, list) and len(param) == 2:
|
|
51
|
+
# Named parameter: [name, value]
|
|
51
52
|
params.append(param)
|
|
53
|
+
elif isinstance(param, str):
|
|
54
|
+
# Positional parameter - handle based on mutator type
|
|
55
|
+
mutator_name = item[0].lower()
|
|
56
|
+
if mutator_name == "split":
|
|
57
|
+
params.append(["delimiter", param])
|
|
58
|
+
elif mutator_name == "replace":
|
|
59
|
+
# For replace, first positional is 'find', second is 'replace'
|
|
60
|
+
if not params or all(p[0] != "find" for p in params):
|
|
61
|
+
params.append(["find", param])
|
|
62
|
+
elif all(p[0] != "replace" for p in params):
|
|
63
|
+
params.append(["replace", param])
|
|
64
|
+
else:
|
|
65
|
+
# Too many positional params
|
|
66
|
+
params.append(["_positional", param])
|
|
67
|
+
else:
|
|
68
|
+
# For other mutators, use first positional as unnamed param
|
|
69
|
+
params.append(["_positional", param])
|
|
52
70
|
if params:
|
|
53
71
|
mutator_dict["params"] = params
|
|
54
72
|
mutators.append(mutator_dict)
|
|
@@ -113,7 +131,25 @@ class ASTBuilder:
|
|
|
113
131
|
params = []
|
|
114
132
|
for param in item[1]:
|
|
115
133
|
if isinstance(param, list) and len(param) == 2:
|
|
134
|
+
# Named parameter: [name, value]
|
|
116
135
|
params.append(param)
|
|
136
|
+
elif isinstance(param, str):
|
|
137
|
+
# Positional parameter - handle based on mutator type
|
|
138
|
+
mutator_name = item[0].lower()
|
|
139
|
+
if mutator_name == "split":
|
|
140
|
+
params.append(["delimiter", param])
|
|
141
|
+
elif mutator_name == "replace":
|
|
142
|
+
# For replace, first positional is 'find', second is 'replace'
|
|
143
|
+
if not params or all(p[0] != "find" for p in params):
|
|
144
|
+
params.append(["find", param])
|
|
145
|
+
elif all(p[0] != "replace" for p in params):
|
|
146
|
+
params.append(["replace", param])
|
|
147
|
+
else:
|
|
148
|
+
# Too many positional params
|
|
149
|
+
params.append(["_positional", param])
|
|
150
|
+
else:
|
|
151
|
+
# For other mutators, use first positional as unnamed param
|
|
152
|
+
params.append(["_positional", param])
|
|
117
153
|
if params:
|
|
118
154
|
mutator_dict["params"] = params
|
|
119
155
|
mutators.append(mutator_dict)
|
|
@@ -109,4 +109,12 @@ class FieldExtractor:
|
|
|
109
109
|
# Collect fields from group by
|
|
110
110
|
if "group_by" in stats_node:
|
|
111
111
|
for field in stats_node["group_by"]:
|
|
112
|
-
|
|
112
|
+
if isinstance(field, dict) and "field" in field:
|
|
113
|
+
# Normalized format: {"field": "name", "bucket_size": N|None}
|
|
114
|
+
fields.add(field["field"])
|
|
115
|
+
elif isinstance(field, str):
|
|
116
|
+
# Legacy format: just field name (for backward compatibility)
|
|
117
|
+
fields.add(field)
|
|
118
|
+
else:
|
|
119
|
+
# Handle any other format gracefully
|
|
120
|
+
fields.add(str(field))
|
tql/parser_components/grammar.py
CHANGED
|
@@ -4,6 +4,7 @@ from pyparsing import (
|
|
|
4
4
|
CaselessKeyword,
|
|
5
5
|
Forward,
|
|
6
6
|
Group,
|
|
7
|
+
Literal,
|
|
7
8
|
)
|
|
8
9
|
from pyparsing import Optional as PyparsingOptional
|
|
9
10
|
from pyparsing import (
|
|
@@ -122,12 +123,20 @@ class TQLGrammar:
|
|
|
122
123
|
"""Set up mutator definitions."""
|
|
123
124
|
# Define mutators
|
|
124
125
|
self.mutator_name = oneOf(
|
|
125
|
-
"lowercase uppercase trim split nslookup geoip_lookup geo "
|
|
126
|
+
"lowercase uppercase trim split replace nslookup geoip_lookup geo "
|
|
126
127
|
"length refang defang b64encode b64decode urldecode "
|
|
127
|
-
"any all avg average max min sum is_private is_global"
|
|
128
|
+
"any all none avg average max min sum is_private is_global "
|
|
129
|
+
"count unique first last",
|
|
128
130
|
caseless=True,
|
|
129
131
|
)
|
|
130
|
-
|
|
132
|
+
# Mutator parameters can be either named (key=value) or positional (just value)
|
|
133
|
+
# Named parameters: key=value where value can be string literal, list, identifier, or number
|
|
134
|
+
self.mutator_named_param = Group(
|
|
135
|
+
self.identifier + Suppress("=") + (self.string_literal | self.list_literal | self.identifier | self.number)
|
|
136
|
+
)
|
|
137
|
+
# Positional parameters can be strings (quoted or unquoted), numbers, or identifiers
|
|
138
|
+
self.mutator_positional_param = self.string_literal | self.number | self.identifier
|
|
139
|
+
self.mutator_param = self.mutator_named_param | self.mutator_positional_param
|
|
131
140
|
self.mutator_params = Group(Suppress("(") + delimitedList(self.mutator_param) + Suppress(")"))
|
|
132
141
|
self.mutator = Group(Suppress("|") + self.mutator_name + PyparsingOptional(self.mutator_params))
|
|
133
142
|
self.mutator_chain = ZeroOrMore(self.mutator)
|
|
@@ -406,17 +415,32 @@ class TQLGrammar:
|
|
|
406
415
|
# Multiple aggregations separated by commas
|
|
407
416
|
self.agg_list = delimitedList(self.agg_with_alias)
|
|
408
417
|
|
|
409
|
-
# Group by fields
|
|
410
|
-
self.
|
|
418
|
+
# Group by fields with optional "top N" for each field
|
|
419
|
+
self.top_kw = CaselessKeyword("top")
|
|
420
|
+
self.group_by_field_with_bucket = Group(self.field_name + PyparsingOptional(self.top_kw + self.number))
|
|
421
|
+
self.group_by_fields = delimitedList(self.group_by_field_with_bucket)
|
|
422
|
+
|
|
423
|
+
# Visualization hint: => chart_type
|
|
424
|
+
self.viz_arrow = Literal("=>")
|
|
425
|
+
self.viz_types = oneOf(
|
|
426
|
+
"bar barchart line area pie donut scatter heatmap treemap sunburst "
|
|
427
|
+
"table number gauge map grouped_bar stacked_bar nested_pie nested_donut chord",
|
|
428
|
+
caseless=True,
|
|
429
|
+
)
|
|
430
|
+
self.viz_hint = PyparsingOptional(self.viz_arrow + self.viz_types)
|
|
411
431
|
|
|
412
|
-
# Complete stats expression: | stats agg_functions [by group_fields]
|
|
432
|
+
# Complete stats expression: | stats agg_functions [by group_fields] [=> viz_type]
|
|
413
433
|
self.stats_expr_with_pipe = Group(
|
|
414
|
-
Suppress("|")
|
|
434
|
+
Suppress("|")
|
|
435
|
+
+ self.stats_kw
|
|
436
|
+
+ self.agg_list
|
|
437
|
+
+ PyparsingOptional(self.by_kw + self.group_by_fields)
|
|
438
|
+
+ self.viz_hint
|
|
415
439
|
)
|
|
416
440
|
|
|
417
441
|
# Stats expression without pipe (for standalone use)
|
|
418
442
|
self.stats_expr_no_pipe = Group(
|
|
419
|
-
self.stats_kw + self.agg_list + PyparsingOptional(self.by_kw + self.group_by_fields)
|
|
443
|
+
self.stats_kw + self.agg_list + PyparsingOptional(self.by_kw + self.group_by_fields) + self.viz_hint
|
|
420
444
|
)
|
|
421
445
|
|
|
422
446
|
# Combined stats expression (with or without pipe)
|