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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: tellaro-query-language
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: A flexible, human-friendly query language for searching and filtering structured data
5
5
  Home-page: https://github.com/tellaro/tellaro-query-language
6
6
  License: MIT
@@ -24,6 +24,7 @@ Requires-Dist: opensearch-dsl (>=2.1.0,<3.0.0) ; extra == "opensearch"
24
24
  Requires-Dist: opensearch-py (>=2.4.2,<3.0.0) ; extra == "opensearch"
25
25
  Requires-Dist: pyparsing (>=3.2.1,<4.0.0)
26
26
  Requires-Dist: setuptools (>=80.0.0,<81.0.0)
27
+ Requires-Dist: urllib3 (>=2.5.0,<3.0.0)
27
28
  Project-URL: Documentation, https://github.com/tellaro/tellaro-query-language/tree/main/docs
28
29
  Project-URL: Repository, https://github.com/tellaro/tellaro-query-language
29
30
  Description-Content-Type: text/markdown
@@ -217,6 +218,28 @@ poetry run tests
217
218
 
218
219
  **Note**: The development setup uses `python-dotenv` to load OpenSearch credentials from `.env` files for integration testing. This is NOT required when using TQL as a package - see the [Package Usage Guide](docs/package-usage-guide.md) for production configuration patterns.
219
220
 
221
+ ### TQL Playground
222
+
223
+ The repository includes an interactive web playground for testing TQL queries:
224
+
225
+ ```bash
226
+ # Navigate to the playground directory
227
+ cd playground
228
+
229
+ # Start with Docker (recommended)
230
+ docker-compose up
231
+
232
+ # Or start with OpenSearch included
233
+ docker-compose --profile opensearch up
234
+ ```
235
+
236
+ Access the playground at:
237
+ - Frontend: http://localhost:5173
238
+ - API: http://localhost:8000
239
+ - API Docs: http://localhost:8000/docs
240
+
241
+ The playground uses your local TQL source code, so any changes you make are immediately reflected. See [playground/README.md](playground/README.md) for more details.
242
+
220
243
  ### File Operations
221
244
 
222
245
  ```python
@@ -4,53 +4,53 @@ tql/cache/__init__.py,sha256=GIzIEMZUZEYJj72sAhuVLEG-OJEKUG2srUWNM3Ix-T8,213
4
4
  tql/cache/base.py,sha256=0b-8uyh3JltayGmXQI45snTqsM5sQu9u0KcNvZIRa-I,687
5
5
  tql/cache/memory.py,sha256=ibcmQSAxNvqCy6DksbU7gLu6UArYp1u3fW-oLubxtV0,2056
6
6
  tql/cache/redis.py,sha256=ZU_IsVDvpSYpNvPfnZ4iulJDODpEGx3c4dkXLzPzPVc,2309
7
- tql/core.py,sha256=fctqzGO1bt6_6pH1-j8mROXTdzPzRfW-v59cYMbLKI4,41947
7
+ tql/core.py,sha256=bMPrcuutY-1yvC-4M7w2y1JxNitMyBSpxPfg8ohjO60,48406
8
8
  tql/core_components/README.md,sha256=Rm7w4UHdQ0vPBEFybE5b62IOvSA5Nzq2GRvtBHOapmc,3068
9
9
  tql/core_components/__init__.py,sha256=v8BBybPlqV7dkVY9mw1mblvqyAFJZ7Pf_bEc-jAL7FI,643
10
10
  tql/core_components/file_operations.py,sha256=Jr0kkxz_OP2KHOAsIr7KMtYe_lbu8LuBUySt2LQbjJw,3925
11
- tql/core_components/opensearch_operations.py,sha256=lmS8VdwnqkbqgYv2f9tygD-ixODWLUZ_d1H9SHyV_sI,38268
12
- tql/core_components/stats_operations.py,sha256=zAfDhVOFFPMrRIMw6Qtjxbobbdi7ao_HuHBCcVc3BGY,7579
11
+ tql/core_components/opensearch_operations.py,sha256=KvmK1FnkGZFBjBysH_sDjzIRnyUcNn7wzLzuRr1rBlg,54264
12
+ tql/core_components/stats_operations.py,sha256=aqTGAqIFvR6EkSbJEd0qft8Ldy8uiTrK2XI9o5bZUOs,8014
13
13
  tql/core_components/validation_operations.py,sha256=_VPXh0HABBjsXF99jFT7B6-5QAPsADOCy6poinGrxeE,22454
14
- tql/evaluator.py,sha256=kjqf2rkjdfPhrX2gyNtiBpdnBpob3LFkTuvplLtc-Ww,15542
14
+ tql/evaluator.py,sha256=_JYr-wK3F1wvBoNGIBiAEaP6Ot1g2qxZ4lOjPdOqvDk,17698
15
15
  tql/evaluator_components/README.md,sha256=c59yf2au34yPhrru7JWgGop_ORteB6w5vfMhsac8j3k,3882
16
16
  tql/evaluator_components/__init__.py,sha256=DourRUSYXWPnCghBFj7W0YfMeymT3X8YTDCwnLIyP1c,535
17
17
  tql/evaluator_components/field_access.py,sha256=BuXvL9jlv4H77neT70Vh7_qokmzs-d4EbSDA2FB1IT0,6435
18
- tql/evaluator_components/special_expressions.py,sha256=rC1klZRA6cXLeS14xBi5LYYMi0V1qU3yg6_B4etRRBs,12139
19
- tql/evaluator_components/value_comparison.py,sha256=A-RJ_52FLvsMO0ciEhJEdfhfFsoE4c3viNk7qYMFILI,15636
18
+ tql/evaluator_components/special_expressions.py,sha256=K6M5pW4Re2kEqxfxj9sc7I_M1tU3pn6LKJ2AfjHeciA,12917
19
+ tql/evaluator_components/value_comparison.py,sha256=pL7-hxdNbzJ53DrTSiDdd7KYbVLChuNwFRLjG7P_1KM,17939
20
20
  tql/exceptions.py,sha256=hatIixXci6p57J9RrkfdvmKM_2i-JKb8ViL2kU4z7a8,5550
21
21
  tql/geoip_normalizer.py,sha256=tvie-5xevJEeLp2KmjoXDjYdND8AvyVE7lCO8qgUzGY,10486
22
- tql/mutator_analyzer.py,sha256=nCP64sVmEVV_zhpACfuTVTcuRV2NmypGbkWgS2taWOs,38039
23
- tql/mutators/__init__.py,sha256=zuzv6OMu2kVveDI85mu-6FA_-CZHsJEnIBIgtsjfXgY,6798
22
+ tql/mutator_analyzer.py,sha256=OzI7t3C4H0IJOonpywE5LWz2cm5Dco5xnp2RTQOiSWg,55638
23
+ tql/mutators/__init__.py,sha256=eTK8sRw4KXXnTZTn5ETIqwcaIek5rSUIVyZsxTwNNHA,6966
24
24
  tql/mutators/base.py,sha256=4Ze_x1sTO11OILXfcF2XN7ttyHcZ4gwn96UXFMMaC6M,2523
25
- tql/mutators/dns.py,sha256=Rmn6Kv-BmhfhMSYJ4GrkNmYms5rmpcxuG6leNxJspOQ,13387
25
+ tql/mutators/dns.py,sha256=1IKgHolFLRMR4TOgK0AiLjz5vDtFiqO328mVF4Vzk3s,14428
26
26
  tql/mutators/encoding.py,sha256=yt12BJrHAIJfBesP8VOSfVlvJqB1yOmEeT_8QDPvNN8,7985
27
27
  tql/mutators/geo.py,sha256=fFQSg_Li3KjFKS3TI26yDzrDpWsmC3MfmgcsxYoQMgM,14507
28
28
  tql/mutators/list.py,sha256=949ZrKKhL4INkH2Od8bq7Ey80kFX_23PEfRKueG82cU,7084
29
29
  tql/mutators/network.py,sha256=1lZpmKt1GoTfNxiXUmSXkTwJIzPQZnQEgU7ojpBSm3A,5458
30
- tql/mutators/security.py,sha256=BE5x1IFCjq85UD22Q2sgSue_xXwhCk0umvf91a1XqJY,8202
31
- tql/mutators/string.py,sha256=8CzqZ2G5vpDeaEQ_jSA3NRidfUqfOrkUT9o2cmUUAEA,6288
30
+ tql/mutators/security.py,sha256=XyWuPxgpCi-igHKmkbP0_0V-evOc3FG2a1igY8rQRX4,9256
31
+ tql/mutators/string.py,sha256=0pIqIcpbRjUvGexsVV2lREPrJAAlIA2INhjvssLEKxw,9180
32
32
  tql/opensearch.py,sha256=J7LhfVJfaXEWtyZqVDqNZaeIbIcUYJr2cQtfKzdyIhM,3362
33
33
  tql/opensearch_components/README.md,sha256=gt-qLmmach8Kh7-QwLZmoAxxIL79XIG1EDqJum8PMZE,3756
34
34
  tql/opensearch_components/__init__.py,sha256=_zIZY8Fns7mkEcY6w2p9FNRBXtEmmPFFJEcFRfrVyXA,514
35
- tql/opensearch_components/field_mapping.py,sha256=N4r7VkzNeXjIhNDt2cfnd1LbkvaS_9O298PIDAcR_Hw,17569
36
- tql/opensearch_components/lucene_converter.py,sha256=ke1cMCeGVf5UorjwhN3y9x-GO-AHJ7FmbuTPSNMxYJo,12570
37
- tql/opensearch_components/query_converter.py,sha256=QodPaRJf5fI_DpMzRGINFIdpmcZZ_3Umlw1h33zsrZE,36858
38
- tql/opensearch_mappings.py,sha256=zJCCdMrxK7mswrkxd5LiOhunQ9GIJNZdhktVoGXgVgk,11529
39
- tql/opensearch_stats.py,sha256=h-hA2EZ-sc4S1zxr7jaInmonfrgTXoGBbb9sOYurdFE,17823
40
- tql/parser.py,sha256=MnY1Gxm8vl2Uw2tU2CyFB26uE3TSeUx6i7APtlwh7Fk,75727
35
+ tql/opensearch_components/field_mapping.py,sha256=fj388cKVyDXLJKi8giSiGHL9zg4cFRzy0VJ6nIsppSo,18102
36
+ tql/opensearch_components/lucene_converter.py,sha256=OvYTZHNBktPGow1fsVm4TMlvxHSmWrnqo42lFZNxXTo,13175
37
+ tql/opensearch_components/query_converter.py,sha256=vLoBqv7W3ntqUH6hcuT4PDJkGkAGSQCxMvAWC482c0g,41971
38
+ tql/opensearch_mappings.py,sha256=sVLlQlE3eGD7iNNZ_m4F4j5GVzQAJhZyCqDKYRhLRh8,11531
39
+ tql/opensearch_stats.py,sha256=aMV__jtlfogGBnFucsNPazORro2mYTz_C_w9uxOqsMI,24384
40
+ tql/parser.py,sha256=9kewX4IbBL3W5hbq9Xhi4BGrQ4QaoWqz9AJV0Yuf9YA,78665
41
41
  tql/parser_components/README.md,sha256=lvQX72ckq2zyotGs8QIHHCIFqaA7bOHwkP44wU8Zoiw,2322
42
42
  tql/parser_components/__init__.py,sha256=zBwHBMPJyHSBbaOojf6qTrJYjJg5A6tPUE8nHFdRiQs,521
43
- tql/parser_components/ast_builder.py,sha256=-pbcYhZNoRm0AnjmJRAAlXLCAwHfauchTpX_6KO0plE,6793
43
+ tql/parser_components/ast_builder.py,sha256=erHoeKAMzobswoRIXB9xcsZbzQ5-2ZwaYfQgRWoUAa8,9653
44
44
  tql/parser_components/error_analyzer.py,sha256=qlCD9vKyW73aeKQYI33P1OjIWSJ3LPd08wuN9cis2fU,4012
45
- tql/parser_components/field_extractor.py,sha256=TumeuUo2c5gPYVbTPsmU43C3TJFC8chAAWERu5v_Q3c,4182
46
- tql/parser_components/grammar.py,sha256=lSvjABvEBaH29-ad-_UGD4WmofdNwC_pO2OKQJ_It-U,19309
47
- tql/post_processor.py,sha256=-vA2wgbuLij2FVnj5I9HDHtw5bKj9Cu3EE9mtoeSWk8,28859
45
+ tql/parser_components/field_extractor.py,sha256=eUEkmiYWX2OexanFqhHeX8hcIkRlfIcgMB667e0HRYs,4629
46
+ tql/parser_components/grammar.py,sha256=h58RBshZHXgbP1EmNwmf7dny-fgVloNg-qN4Rivross,20599
47
+ tql/post_processor.py,sha256=MZOJzuWTL2qdvu-AUNMryYF2D-piv8rYH5vCcrLt5-A,50069
48
48
  tql/scripts.py,sha256=VOr5vCjIvKlW36kwvJx7JGFIRM16IJZlbJcWlBexBtk,3728
49
- tql/stats_evaluator.py,sha256=lOEbICFuP0krZZqEjREz37xlpm35_P6eRgkHVgJLNI4,15703
49
+ tql/stats_evaluator.py,sha256=OQZuNLwLHAtWrwAh3utdtr1fQt3tftCs6L-1G1NQCGQ,22318
50
50
  tql/stats_transformer.py,sha256=MT-4rDWZSySgn4Fuq9H0c-mvwFYLM6FqWpPv2rHX-rE,7588
51
51
  tql/validators.py,sha256=e9MlX-zQ_O3M8YP8vXyMjKU8iiJMTh6mMK0iv0_4gTY,3771
52
- tellaro_query_language-0.2.0.dist-info/LICENSE,sha256=zRhQ85LnW55fWgAjQctckwQ67DX5Jmt64lq343ThZFU,1063
53
- tellaro_query_language-0.2.0.dist-info/METADATA,sha256=c3rHLr71I1LgwoDt0rypKbIScua6k2SVa_ozNHpiOg4,15109
54
- tellaro_query_language-0.2.0.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
55
- tellaro_query_language-0.2.0.dist-info/entry_points.txt,sha256=H43APfGBMsZkKsUCnFTaqprQPW-Kce2yz2qsBL3dZrw,164
56
- tellaro_query_language-0.2.0.dist-info/RECORD,,
52
+ tellaro_query_language-0.2.2.dist-info/LICENSE,sha256=zRhQ85LnW55fWgAjQctckwQ67DX5Jmt64lq343ThZFU,1063
53
+ tellaro_query_language-0.2.2.dist-info/METADATA,sha256=QsCXKY_0aHeMorc4PepJ84ViZbTK53suxILmr868Lkk,15740
54
+ tellaro_query_language-0.2.2.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
55
+ tellaro_query_language-0.2.2.dist-info/entry_points.txt,sha256=H43APfGBMsZkKsUCnFTaqprQPW-Kce2yz2qsBL3dZrw,164
56
+ tellaro_query_language-0.2.2.dist-info/RECORD,,
tql/core.py CHANGED
@@ -212,8 +212,10 @@ class TQL:
212
212
  if query_type == "stats_expr":
213
213
  # This is a pure stats query like "| stats count()"
214
214
  stats_result = self.stats(data, query)
215
+ # Get viz_hint from the parsed AST
216
+ viz_hint = ast.get("viz_hint")
215
217
  # Convert to execute_opensearch format
216
- result["stats"] = self._convert_stats_result(stats_result)
218
+ result["stats"] = self._convert_stats_result(stats_result, viz_hint)
217
219
  result["total"] = len(records)
218
220
  return result
219
221
 
@@ -232,7 +234,9 @@ class TQL:
232
234
 
233
235
  # Apply stats to filtered data
234
236
  stats_result = self.stats_evaluator.evaluate_stats(filtered_records, stats_ast)
235
- result["stats"] = self._convert_stats_result(stats_result)
237
+ # Get viz_hint from the stats AST
238
+ viz_hint = stats_ast.get("viz_hint")
239
+ result["stats"] = self._convert_stats_result(stats_result, viz_hint)
236
240
  result["total"] = len(filtered_records)
237
241
 
238
242
  # Include filtered documents if size > 0
@@ -241,40 +245,63 @@ class TQL:
241
245
 
242
246
  return result
243
247
 
244
- # Handle regular filter queries
245
- matched_records = []
246
- has_enrichments = False
248
+ # Handle regular filter queries with mutator analysis
249
+ # Analyze the query for mutators
250
+ from .mutator_analyzer import MutatorAnalyzer
251
+ from .post_processor import QueryPostProcessor
252
+
253
+ analyzer = MutatorAnalyzer(field_mappings=self.field_mappings)
254
+ analysis_result = analyzer.analyze_ast(ast, context="in_memory")
247
255
 
256
+ # Use the optimized AST for evaluation
257
+ optimized_ast = analysis_result.optimized_ast
258
+
259
+ # First pass: collect all matching records using optimized AST
260
+ matched_records = []
248
261
  for record in records:
249
- # Check if record matches
250
- if self.evaluator._evaluate_node(ast, record, self._simple_mappings):
251
- # Apply any mutators to enrich the record
252
- enriched_record = self._apply_mutators_to_record(ast, record)
253
- matched_records.append(enriched_record)
254
- # Check if enrichments were added
255
- if not has_enrichments and enriched_record is not record:
256
- has_enrichments = True
262
+ # Check if record matches using the optimized AST (without array operators)
263
+ if self.evaluator._evaluate_node(optimized_ast, record, self._simple_mappings):
264
+ matched_records.append(record)
265
+
266
+ # Apply post-processing if needed
267
+ if analysis_result.post_processing_requirements:
268
+ processor = QueryPostProcessor()
269
+
270
+ # Apply mutators/enrichments
271
+ processed_records = processor.process_results(
272
+ matched_records, analysis_result.post_processing_requirements, track_enrichments=save_enrichment
273
+ )
274
+
275
+ # Apply filters (for array operators like any/all/none)
276
+ filtered_records = processor.filter_results(processed_records, analysis_result.post_processing_requirements)
277
+
278
+ matched_records = filtered_records
279
+ result["post_processing_applied"] = True
280
+
281
+ # Add post-processing stats
282
+ result["post_processing_stats"] = {
283
+ "documents_retrieved": len(processed_records),
284
+ "documents_returned": len(filtered_records),
285
+ "documents_filtered": len(processed_records) - len(filtered_records),
286
+ }
257
287
 
258
288
  # Set result data
259
289
  result["total"] = len(matched_records)
260
290
  if size > 0:
261
291
  result["results"] = matched_records[:size]
262
292
 
263
- # Check if post-processing (mutators) were applied
264
- if has_enrichments:
265
- result["post_processing_applied"] = True
293
+ # Update health status based on analysis
294
+ result["health_status"] = analysis_result.health_status
295
+ result["health_reasons"] = [
296
+ reason.get("reason", reason.get("description", "")) for reason in analysis_result.health_reasons
297
+ ]
266
298
 
267
299
  # Save enrichments if requested
268
- if save_enrichment and has_enrichments and source_file:
269
- # For file sources, update all records (not just matches)
270
- all_enriched = []
271
- for record in records:
272
- enriched_record = self._apply_mutators_to_record(ast, record)
273
- all_enriched.append(enriched_record)
274
-
275
- # Save based on file type
276
- if source_file.lower().endswith(".json"):
277
- self.file_ops.save_enrichments_to_json(source_file, all_enriched)
300
+ if save_enrichment and result.get("post_processing_applied") and source_file:
301
+ # For file sources, we would need to re-process all records with enrichments
302
+ # This is a complex operation that would require applying mutators to all records
303
+ # For now, we'll skip this functionality in the in-memory implementation
304
+ pass
278
305
 
279
306
  return result
280
307
 
@@ -370,6 +397,38 @@ class TQL:
370
397
  elif node_type == "unary_op":
371
398
  stats["logical_operators"].add("not")
372
399
  traverse_ast(node.get("operand"), depth + 1)
400
+ elif node_type == "query_with_stats":
401
+ # Traverse into the filter part to find mutators and fields
402
+ filter_node = node.get("filter")
403
+ if filter_node:
404
+ traverse_ast(filter_node, depth + 1)
405
+ # Also traverse the stats part
406
+ stats_node = node.get("stats")
407
+ if stats_node:
408
+ traverse_ast(stats_node, depth + 1)
409
+ elif node_type == "stats_expr":
410
+ # Check aggregations for any fields or mutators
411
+ aggregations = node.get("aggregations", [])
412
+ for agg in aggregations:
413
+ if isinstance(agg, dict):
414
+ field = agg.get("field")
415
+ if field and field != "*":
416
+ stats["fields"].add(field)
417
+ # Check for field mutators in aggregations
418
+ if agg.get("field_mutators"):
419
+ stats["has_mutators"] = True
420
+ elif node_type == "geo_expr":
421
+ # Geo expressions always have mutators
422
+ field = node.get("field")
423
+ if field:
424
+ stats["fields"].add(field)
425
+ stats["has_mutators"] = True
426
+ elif node_type == "nslookup_expr":
427
+ # NSLookup expressions always have mutators
428
+ field = node.get("field")
429
+ if field:
430
+ stats["fields"].add(field)
431
+ stats["has_mutators"] = True
373
432
 
374
433
  traverse_ast(ast)
375
434
 
@@ -450,7 +509,7 @@ class TQL:
450
509
  index: Optional[str] = None,
451
510
  query: Optional[str] = None,
452
511
  size: int = 500,
453
- search_after: Optional[List[Any]] = None,
512
+ from_: int = 0,
454
513
  sort: Optional[List[Dict[str, Any]]] = None,
455
514
  timestamp_field: str = "@timestamp",
456
515
  time_range: Optional[Dict[str, str]] = None,
@@ -506,24 +565,6 @@ class TQL:
506
565
  # Remove parameters that the new implementation doesn't understand
507
566
  filtered_kwargs = {k: v for k, v in kwargs.items() if k not in ["save_enrichment", "opensearch_client"]}
508
567
 
509
- # Check for backward compatibility - if from_ is in kwargs, it's old usage
510
- if "from_" in kwargs:
511
- # Old style pagination - convert to search_after with default sort
512
- from_ = kwargs.pop("from_")
513
- if from_ > 0 and not sort:
514
- # Need a sort for proper pagination
515
- sort = [{"_id": "asc"}]
516
- # Note: We can't directly convert from_ to search_after without previous results
517
- # This is a limitation of moving to search_after
518
- if from_ > 0:
519
- import warnings
520
-
521
- warnings.warn(
522
- "from_ parameter is deprecated. Use search_after with sort for efficient pagination.",
523
- DeprecationWarning,
524
- stacklevel=2,
525
- )
526
-
527
568
  # Add the supported parameters
528
569
  filtered_kwargs.update(
529
570
  {
@@ -532,7 +573,7 @@ class TQL:
532
573
  "scan_all": scan_all,
533
574
  "scroll_size": scroll_size,
534
575
  "scroll_timeout": scroll_timeout,
535
- "search_after": search_after,
576
+ "from_": from_,
536
577
  "sort": sort,
537
578
  }
538
579
  )
@@ -1001,22 +1042,140 @@ class TQL:
1001
1042
  Returns:
1002
1043
  Enriched record (may be same as input if no enrichments)
1003
1044
  """
1004
- # For now, return the original record
1005
- # TODO: Implement mutator application for enrichment # noqa: W0511
1006
- return record
1045
+ # Check if we need to apply mutators
1046
+ if not self._has_output_mutators(ast):
1047
+ return record
1048
+
1049
+ # Deep copy to avoid modifying original
1050
+ import copy
1051
+
1052
+ enriched_record = copy.deepcopy(record)
1053
+
1054
+ # Apply mutators from AST nodes
1055
+ self._apply_node_mutators(ast, enriched_record)
1056
+
1057
+ return enriched_record
1058
+
1059
+ def _has_output_mutators(self, ast: Dict[str, Any]) -> bool:
1060
+ """Check if AST contains mutators that should transform output.
1061
+
1062
+ Args:
1063
+ ast: Query AST
1064
+
1065
+ Returns:
1066
+ True if output mutators are present
1067
+ """
1068
+ if isinstance(ast, dict):
1069
+ node_type = ast.get("type")
1070
+
1071
+ # Check for field mutators with exists operator (output transformation)
1072
+ if node_type == "comparison" and ast.get("operator") == "exists" and ast.get("field_mutators"):
1073
+ return True
1074
+
1075
+ # Recursively check child nodes
1076
+ if node_type == "logical_op":
1077
+ left = ast.get("left", {})
1078
+ right = ast.get("right", {})
1079
+ return self._has_output_mutators(left) or self._has_output_mutators(right)
1080
+ elif node_type == "unary_op":
1081
+ operand = ast.get("operand", {})
1082
+ return self._has_output_mutators(operand)
1083
+
1084
+ return False
1085
+
1086
+ def _apply_node_mutators(self, ast: Dict[str, Any], record: Dict[str, Any]) -> None:
1087
+ """Apply mutators from AST nodes to the record.
1088
+
1089
+ Args:
1090
+ ast: Query AST
1091
+ record: Record to modify (in-place)
1092
+ """
1093
+ if not isinstance(ast, dict):
1094
+ return
1095
+
1096
+ node_type = ast.get("type")
1097
+
1098
+ # Apply mutators for exists operator (output transformation)
1099
+ if node_type == "comparison" and ast.get("operator") == "exists" and ast.get("field_mutators"):
1100
+ field_name = ast["field"]
1101
+ field_mutators = ast["field_mutators"]
1102
+
1103
+ # Get field value
1104
+ field_value = self._get_nested_field(record, field_name)
1105
+
1106
+ if field_value is not None:
1107
+ # Apply mutators
1108
+ from .mutators import apply_mutators
1109
+
1110
+ mutated_value = apply_mutators(field_value, field_mutators, field_name, record)
1111
+
1112
+ # Update record with mutated value
1113
+ self._set_nested_field(record, field_name, mutated_value)
1114
+
1115
+ # Recursively process child nodes
1116
+ elif node_type == "logical_op":
1117
+ self._apply_node_mutators(ast.get("left", {}), record)
1118
+ self._apply_node_mutators(ast.get("right", {}), record)
1119
+ elif node_type == "unary_op":
1120
+ self._apply_node_mutators(ast.get("operand", {}), record)
1121
+
1122
+ def _get_nested_field(self, record: Dict[str, Any], field_path: str) -> Any:
1123
+ """Get value from nested field path.
1124
+
1125
+ Args:
1126
+ record: Record dictionary
1127
+ field_path: Dot-separated field path
1128
+
1129
+ Returns:
1130
+ Field value or None if not found
1131
+ """
1132
+ parts = field_path.split(".")
1133
+ current = record
1134
+
1135
+ for part in parts:
1136
+ if isinstance(current, dict) and part in current:
1137
+ current = current[part]
1138
+ else:
1139
+ return None
1140
+
1141
+ return current
1142
+
1143
+ def _set_nested_field(self, record: Dict[str, Any], field_path: str, value: Any) -> None:
1144
+ """Set value in nested field path.
1145
+
1146
+ Args:
1147
+ record: Record dictionary to modify
1148
+ field_path: Dot-separated field path
1149
+ value: Value to set
1150
+ """
1151
+ parts = field_path.split(".")
1152
+ current = record
1007
1153
 
1008
- def _convert_stats_result(self, stats_result: Dict[str, Any]) -> Dict[str, Any]:
1154
+ # Navigate to parent of target field
1155
+ for part in parts[:-1]:
1156
+ if part not in current:
1157
+ current[part] = {}
1158
+ current = current[part]
1159
+
1160
+ # Set the value
1161
+ if len(parts) > 0:
1162
+ current[parts[-1]] = value
1163
+
1164
+ def _convert_stats_result(self, stats_result: Dict[str, Any], viz_hint: Optional[str] = None) -> Dict[str, Any]:
1009
1165
  """Convert stats result from query() format to execute_opensearch format.
1010
1166
 
1011
1167
  Args:
1012
1168
  stats_result: Result from stats() or query_stats() method
1169
+ viz_hint: Optional visualization hint from the query
1013
1170
 
1014
1171
  Returns:
1015
1172
  Stats result in execute_opensearch format
1016
1173
  """
1017
1174
  # Map the stats evaluator format to execute_opensearch format
1175
+ result = {}
1176
+
1018
1177
  if stats_result.get("type") == "simple_aggregation":
1019
- return {
1178
+ result = {
1020
1179
  "type": "stats",
1021
1180
  "operation": stats_result["function"],
1022
1181
  "field": stats_result["field"],
@@ -1024,10 +1183,22 @@ class TQL:
1024
1183
  }
1025
1184
  elif stats_result.get("type") == "multiple_aggregations":
1026
1185
  # For multiple aggregations, return the results dict
1027
- return {"type": "stats_multiple", "results": stats_result["results"]}
1186
+ result = {"type": "stats_multiple", "results": stats_result["results"]}
1028
1187
  elif stats_result.get("type") == "grouped_aggregation":
1029
1188
  # For grouped aggregations
1030
- return {"type": "stats_grouped", "group_by": stats_result["group_by"], "results": stats_result["results"]}
1189
+ result = {
1190
+ "type": "stats_grouped",
1191
+ "group_by": stats_result["group_by"],
1192
+ "results": stats_result["results"],
1193
+ "operation": stats_result.get("function", "count"),
1194
+ "field": stats_result.get("field", "*"),
1195
+ }
1031
1196
  else:
1032
1197
  # Return as-is if format is unknown
1033
- return stats_result
1198
+ result = stats_result
1199
+
1200
+ # Add visualization hint if provided
1201
+ if viz_hint:
1202
+ result["viz_hint"] = viz_hint
1203
+
1204
+ return result