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.
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 is_private/is_global without operator (defaults to eq true)
200
- # This happens when we have a field with is_private/is_global as the last mutator
201
- elif isinstance(first, str) and isinstance(second, list) and len(second) == 1:
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
- mutator_name = second[0] if isinstance(second[0], str) else None
204
- if mutator_name and mutator_name.lower() in ["is_private", "is_global"]:
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
- # This is field | is_private or field | is_global without operator
209
- # Default to eq true
210
- result = {
211
- "type": "comparison",
212
- "field": field_name,
213
- "type_hint": type_hint,
214
- "operator": "eq",
215
- "value": "true",
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 ending in is_private/is_global
253
- if isinstance(parsed[0], str) and all(isinstance(item, list) and len(item) == 1 for item in parsed[1:]):
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
- len(last_mutator_list) == 1
258
- and isinstance(last_mutator_list[0], str)
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
- result = {
265
- "type": "comparison",
266
- "field": field_name,
267
- "type_hint": type_hint,
268
- "operator": "eq",
269
- "value": "true",
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) and parsed[i] not in ["by", ","]:
1376
- result["group_by"].append(parsed[i])
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
- fields.add(field)
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))
@@ -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
- self.mutator_param = Group(self.identifier + Suppress("=") + (self.string_literal | self.list_literal))
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.group_by_fields = delimitedList(self.field_name)
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("|") + self.stats_kw + self.agg_list + PyparsingOptional(self.by_kw + self.group_by_fields)
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)