tellaro-query-language 3.0.1

A flexible, human-friendly query language for searching and filtering structured data
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
//! Field mapping system for OpenSearch.
//!
//! This module handles field type detection and intelligent query building
//! based on OpenSearch mappings.

use super::error::{OpenSearchError, Result};
use serde_json::Value as JsonValue;
use std::collections::HashMap;

/// Field types in OpenSearch
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FieldType {
    /// Keyword field (exact match)
    Keyword,
    /// Text field (full-text search)
    Text,
    /// Long integer
    Long,
    /// Double precision floating point
    Double,
    /// Boolean
    Boolean,
    /// Date/datetime
    Date,
    /// IP address
    Ip,
    /// Un-analyzed string, optimised for wildcard/regexp matching.
    ///
    /// A real OpenSearch type, and it was in neither engine's type list.
    /// `parse_field_type` returned `None` for it, which drops the field from
    /// `mappings` entirely — so Rust treated a `wildcard`-mapped field as
    /// UNMAPPED, not as unsupported. That is worse than Python's honest
    /// refusal: an unmapped field is passed through untouched, so `f gt 5`
    /// emitted a `range` OpenSearch cannot answer on this type, and `f cidr`
    /// emitted the silent zero-hit `term` that F31 removes everywhere else.
    Wildcard,
    /// Object (nested JSON)
    Object,
    /// Nested (array of objects)
    Nested,
    /// Geo-point. No string form, no meaningful `range`.
    ///
    /// Had no arm here until tql#210, so `parse_field_type` returned `None` and
    /// `parse_field_definition`'s `?` dropped the field from `mappings`
    /// entirely — the same silent-unmapped failure [`FieldType::Wildcard`]
    /// describes. Measured: `f contains_cs 'M'` returned
    /// `Ok({"wildcard": {"f": "*M*"}})` where Python raised `TQLTypeError`.
    GeoPoint,
    /// 64-bit unsigned integer.
    ///
    /// Deliberately NOT folded into [`FieldType::Long`], and deliberately not
    /// [`FieldType::is_orderable`], so `gt` / `between` are refused here exactly
    /// as Python refuses them. `unsigned_long` and `object` are indistinguishable in that
    /// engine across every operator class in
    /// `docs/developer/engine-verdict-matrix.md`, which computes those rows rather than
    /// asserting them — the earlier "measured across fourteen operators" phrasing here named
    /// no artifact anyone could re-run.
    ///
    /// # The range divergence this does NOT create, and the one it leaves
    ///
    /// An earlier version of this comment said making it orderable "would open a
    /// NEW divergence on `range`". That was false, and measuring it is what
    /// showed the real shape. The divergence is already here and is **four types
    /// wide**: `parse_field_type` folds `short` and `byte` into
    /// [`FieldType::Long`] and `half_float` and `scaled_float` into
    /// [`FieldType::Double`], both orderable, so this crate answers `f gt 5` on
    /// all four while Python raises `TQLUnsupportedOperationError`. Measured on
    /// both engines.
    ///
    /// So the honest statement is narrower than the old one and less flattering:
    /// this variant makes `unsigned_long` the ONE narrow numeric width that
    /// agrees with Python, alongside four that do not. Keeping it is still the
    /// right call — a fix should not add a fifth — but it is not the tidy
    /// parity-preserving choice the old comment claimed.
    ///
    /// The gate on Python's side is **not** `numeric_ops`, which is never reached
    /// for this type. It is the literal `{"integer", "long", "float", "double",
    /// "date"}` set in `FieldMapping.get_field_for_operator`'s range branch (tql#212)
    /// (`src/tql/opensearch_components/field_mapping.py`, `has_numeric_or_date`).
    /// Widening `numeric_ops` would change nothing; that set is what a fix has to
    /// touch. Whether Python should widen it — and close the four-type gap in the
    /// other direction — is a separate decision, not taken here.
    UnsignedLong,
    /// A type this crate has no arm for.
    ///
    /// The point of the variant is that it FAILS INTO the mappings rather than
    /// out of them. Before tql#210 an unrecognised type made `parse_field_type`
    /// return `None`, `parse_field_definition` propagate it through `?`, and the
    /// field vanish from `mappings` — indistinguishable from a field the caller
    /// never supplied mappings for, so every operator was permitted on it and a
    /// `_cs` query emitted a `wildcard` that could never match. Silent zero
    /// hits, no error: the exact failure the `_cs` refusal exists to remove.
    ///
    /// `geo_point` and `unsigned_long` were the two live instances, and both now
    /// have named arms. This variant is here so the NEXT unlisted type — a
    /// `flattened`, a `version`, a `match_only_text` — is visible instead of
    /// invisible. It reports as un-orderable and as no string form, which is the
    /// conservative reading: operators that need a property this crate cannot
    /// confirm are refused rather than answered wrongly.
    Unknown,
}

/// Field mapping information
#[derive(Debug, Clone)]
pub struct FieldMapping {
    /// The field type
    pub field_type: FieldType,
    /// Subfields (e.g., .keyword for text fields)
    pub subfields: HashMap<String, FieldType>,
}

impl FieldType {
    /// The OpenSearch type name, for error messages that name what the field
    /// actually is.
    pub fn as_str(&self) -> &'static str {
        match self {
            FieldType::Keyword => "keyword",
            FieldType::Text => "text",
            FieldType::Long => "long",
            FieldType::Double => "double",
            FieldType::Boolean => "boolean",
            FieldType::Date => "date",
            FieldType::Ip => "ip",
            FieldType::Wildcard => "wildcard",
            FieldType::Object => "object",
            FieldType::Nested => "nested",
            FieldType::GeoPoint => "geo_point",
            FieldType::UnsignedLong => "unsigned_long",
            FieldType::Unknown => "unknown",
        }
    }

    /// Can OpenSearch answer a `range` query against this type meaningfully?
    ///
    /// `keyword` is deliberately excluded: range on a keyword is lexicographic,
    /// which is a legitimate fallback but not what a caller writing `gt 5`
    /// means. It is reached explicitly, after this returns false.
    pub fn is_orderable(&self) -> bool {
        matches!(self, FieldType::Long | FieldType::Double | FieldType::Date)
    }

    /// Is this an un-analyzed string type — one whole value, one indexed term?
    ///
    /// `keyword` and `wildcard` both are. This is the property that exact-match
    /// and whole-value operators actually need, and it is deliberately NOT the
    /// same question as "can OpenSearch range over it": a `range` on a
    /// `wildcard` field is unimplemented and fails the shard
    /// (`query_shard_exception: failed to create query: TODO`, measured on
    /// OpenSearch 2.19.4), whereas a `range` on a `keyword` is a legitimate
    /// lexicographic fallback. Conflating the two is how `wildcard` support
    /// would turn an honest refusal into a runtime error.
    pub fn is_unanalyzed_string(&self) -> bool {
        matches!(self, FieldType::Keyword | FieldType::Wildcard)
    }
}

/// Collection of field mappings for an index
#[derive(Debug, Clone)]
pub struct FieldMappings {
    mappings: HashMap<String, FieldMapping>,
}

/// Operators whose value is matched against the WHOLE stored string.
///
/// Mirrors `WHOLE_VALUE_OPERATORS` in
/// `src/tql/opensearch_components/field_mapping.py`. The two implementations
/// must agree: the Python package is what the backend and live validation use,
/// and a translator that disagrees with the production engine reports behaviour
/// the product does not have.
const WHOLE_VALUE_OPERATORS: &[&str] = &[
    "contains",
    "contains_cs",
    "not_contains",
    "not_contains_cs",
    "startswith",
    "startswith_cs",
    "not_startswith",
    "not_startswith_cs",
    "endswith",
    "endswith_cs",
    "not_endswith",
    "not_endswith_cs",
    "matches",
    "not_matches",
    "regexp",
    "not_regexp",
    "regex",
    "not_regex",
    // `eq_ci` is emitted as a `wildcard`, so it matches a whole indexed TERM
    // and belongs here. Python added it for that reason and Rust did not
    // follow, so on a text+keyword multifield Python resolved `f eq_ci 'a'`
    // onto `f.keyword` and Rust left it on the analyzed `f` — where a wildcard
    // is matched against the analyzer's tokens. Two files that document each
    // other as mirrors had drifted.
    "eq_ci",
];

/// The case-SENSITIVE whole-value operators.
///
/// These are the ones an ANALYZED field cannot answer at all. A standard
/// analyzer lowercases every token it indexes, so a case-preserving comparison
/// against those tokens is not a hard question -- the information it needs is
/// not in the index. `contains` and its `_ci` siblings are fine there: they ask
/// a case-insensitive question, which lowercased tokens can answer.
///
/// Mirrors `CASE_SENSITIVE_WHOLE_VALUE_OPERATORS` in
/// `src/tql/opensearch_components/field_mapping.py`.
const CASE_SENSITIVE_WHOLE_VALUE_OPERATORS: &[&str] = &[
    "contains_cs",
    "not_contains_cs",
    "startswith_cs",
    "not_startswith_cs",
    "endswith_cs",
    "not_endswith_cs",
];

/// The case-SENSITIVE MEMBERSHIP operators.
///
/// `in_cs` asks exactly the question `contains_cs` asks — does this stored
/// value carry THIS case? — and an analyzed field cannot answer it for exactly
/// the same reason: the analyzer lowercased every token it indexed, so the case
/// being asked about is not in the index.
///
/// They are separate from [`CASE_SENSITIVE_WHOLE_VALUE_OPERATORS`] because they
/// are classified as KEYWORD operators, not whole-value ones. That
/// classification is correct — `in_cs` is an exact-match `terms` query, not a
/// substring one — but it routes them through a different branch of
/// [`FieldMappings::get_query_field`], which is how they escaped the `_cs`
/// refusal and kept returning the silent zero it removed everywhere else.
///
/// Mirrors `CASE_SENSITIVE_MEMBERSHIP_OPERATORS` in
/// `src/tql/opensearch_components/field_mapping.py`.
const CASE_SENSITIVE_MEMBERSHIP_OPERATORS: &[&str] = &["in_cs", "not_in_cs"];

impl FieldMappings {
    /// Create an empty field mappings collection
    pub fn new() -> Self {
        Self {
            mappings: HashMap::new(),
        }
    }

    /// Create from OpenSearch index mappings response
    ///
    /// # Arguments
    ///
    /// * `response` - The JSON response from OpenSearch mappings API
    ///
    /// # Example
    ///
    /// ```ignore
    /// let response = client.indices().get_mapping().send().await?;
    /// let mappings = FieldMappings::from_opensearch_response(response)?;
    /// ```
    pub fn from_opensearch_response(response: JsonValue) -> Result<Self> {
        let mut mappings = HashMap::new();

        // Parse OpenSearch mappings response
        // Expected format:
        // {
        //   "index_name": {
        //     "mappings": {
        //       "properties": {
        //         "field_name": {
        //           "type": "text",
        //           "fields": {
        //             "keyword": { "type": "keyword" }
        //           }
        //         }
        //       }
        //     }
        //   }
        // }

        // Handle different response formats
        let properties = if let Some(index_obj) = response.as_object() {
            // Get the first index (usually there's only one)
            if let Some((_index_name, index_data)) = index_obj.iter().next() {
                if let Some(mappings_obj) = index_data.get("mappings") {
                    mappings_obj.get("properties")
                } else {
                    None
                }
            } else {
                None
            }
        } else {
            None
        };

        if let Some(properties) = properties {
            if let Some(props_obj) = properties.as_object() {
                for (field_name, field_def) in props_obj {
                    if let Some(mapping) = Self::parse_field_definition(field_def) {
                        mappings.insert(field_name.clone(), mapping);
                    }
                }
            }
        }

        Ok(Self { mappings })
    }

    /// Build mappings from a `_field_caps` response.
    ///
    /// This is the form to use against an index PATTERN. `from_opensearch_response`
    /// reads `_mapping`, whose response is keyed by concrete index and which that
    /// function reduces by taking `iter().next()` — one arbitrary index, since
    /// `serde_json` maps are `BTreeMap` without `preserve_order`, so lexicographically
    /// first. For `logs-*` that silently answers from one index and hides every
    /// divergence across the rest. `_field_caps` is the API built for the question:
    /// it reports each field once, with every type it holds across the pattern.
    ///
    /// Two shape differences from `_mapping` matter:
    ///
    /// 1. **Multi-fields arrive flattened.** `_mapping` nests `keyword` under
    ///    `host.name`'s `fields`; `_field_caps` emits `host.name` and
    ///    `host.name.keyword` as siblings. They are folded back here, because
    ///    `keyword_form` looks for the keyword subfield and finding none is what
    ///    makes `eq` on an analyzed field compile to a query that cannot match.
    ///    The flattened entry is kept as well, so an explicit
    ///    `host.name.keyword eq '...'` still resolves.
    /// 2. **A field can hold more than one type.** See `resolve_caps_conflict`.
    pub fn from_field_caps_response(response: JsonValue) -> Result<Self> {
        let fields = response
            .get("fields")
            .and_then(|f| f.as_object())
            .ok_or_else(|| {
                OpenSearchError::MappingError(
                    "_field_caps response has no 'fields' object — not a field-caps payload"
                        .to_string(),
                )
            })?;

        // Pass 1: flat name -> type, conflicts resolved.
        let mut flat: HashMap<String, FieldType> = HashMap::new();
        for (name, caps) in fields {
            // Drop OpenSearch's own metadata fields. Measured against a live
            // cluster: `_field_caps` on an index with NO user fields still
            // returns twelve entries (`_index`, `_routing`, `_seq_no`, ...),
            // several with types this crate has no arm for, so they parse as
            // `Unknown`. Keeping them would make the "declared no fields"
            // refusal in `fetch_field_mappings` unreachable — every index looks
            // non-empty — which is the exact silent-success this change exists
            // to remove. A user field cannot start with `_`; that prefix is
            // reserved for metadata.
            if name.starts_with('_') {
                continue;
            }
            let Some(caps_obj) = caps.as_object() else {
                continue;
            };
            if let Some(field_type) = Self::resolve_caps_conflict(name, caps_obj) {
                flat.insert(name.clone(), field_type);
            }
        }

        // Pass 2: fold `parent.sub` into the parent's subfields, where the parent
        // is a real leaf field. The guard on the parent's type is what keeps this
        // from mangling object graphs: in `{"host": {"type": "object"}}` with a
        // child `host.name`, `host.name` is a field in its own right and folding
        // it into `host` would both lose it and invent a subfield that cannot be
        // queried. Only a leaf parent can own a multi-field.
        let mut mappings: HashMap<String, FieldMapping> = HashMap::new();
        for (name, field_type) in &flat {
            mappings.insert(
                name.clone(),
                FieldMapping {
                    field_type: field_type.clone(),
                    subfields: HashMap::new(),
                },
            );
        }
        for (name, field_type) in &flat {
            let Some((parent, leaf)) = name.rsplit_once('.') else {
                continue;
            };
            let Some(parent_type) = flat.get(parent) else {
                continue;
            };
            if matches!(parent_type, FieldType::Object | FieldType::Nested) {
                continue;
            }
            if let Some(parent_mapping) = mappings.get_mut(parent) {
                parent_mapping
                    .subfields
                    .insert(leaf.to_string(), field_type.clone());
            }
        }

        Ok(Self { mappings })
    }

    /// Pick one type for a field that `_field_caps` reports with several.
    ///
    /// A field is `text` in one index of the pattern and `keyword` in another
    /// whenever the pattern spans a mapping change — a template edited between
    /// two daily indices is the ordinary way to get there. This crate's
    /// `FieldMapping` holds ONE type, so something has to be chosen.
    ///
    /// Un-analyzed wins. The operators that consult mappings at all are
    /// overwhelmingly exact-match (`eq`, `ne`, `in`, `any`, ...), and against a
    /// `keyword` index the base field is the right target while against a `text`
    /// index `keyword_form` still has the folded `.keyword` subfield to fall back
    /// on. Choosing `text` instead would aim every such query at an analyzed
    /// field on the indices that have none.
    ///
    /// This is a best answer, not a correct one — no single type is correct for a
    /// genuinely divergent pattern. It is logged at `warn` rather than failing the
    /// query, because refusing here would break every search over a pattern that
    /// merely straddles an old mapping, which is far more common than the query
    /// that the choice actually gets wrong.
    fn resolve_caps_conflict(
        field_name: &str,
        caps: &serde_json::Map<String, JsonValue>,
    ) -> Option<FieldType> {
        let mut types: Vec<FieldType> = caps
            .keys()
            .filter_map(|type_name| Self::parse_field_type(type_name))
            .collect();
        if types.is_empty() {
            return None;
        }
        if types.len() == 1 {
            return Some(types.remove(0));
        }
        let chosen = types
            .iter()
            .find(|t| t.is_unanalyzed_string())
            .cloned()
            .unwrap_or_else(|| types[0].clone());
        tracing::warn!(
            "Field '{}' has conflicting types across the index pattern ({}); \
             using '{}'. Queries on this field may behave differently per index.",
            field_name,
            types
                .iter()
                .map(|t| t.as_str())
                .collect::<Vec<_>>()
                .join(", "),
            chosen.as_str(),
        );
        Some(chosen)
    }

    /// Create from pre-extracted properties (e.g., from index template's mappings.properties)
    ///
    /// This is useful when field mappings have already been extracted from an index template
    /// and don't need the full OpenSearch response wrapper.
    ///
    /// # Arguments
    ///
    /// * `properties` - A HashMap of field names to their type definitions
    ///
    /// # Example
    ///
    /// ```ignore
    /// let properties = template.get_tql_field_mappings(Some(&["event.code", "message"]));
    /// let mappings = FieldMappings::from_properties(properties);
    /// ```
    pub fn from_properties(properties: HashMap<String, JsonValue>) -> Self {
        let mut mappings = HashMap::new();

        for (field_name, field_def) in properties {
            if let Some(mapping) = Self::parse_field_definition(&field_def) {
                mappings.insert(field_name, mapping);
            }
        }

        Self { mappings }
    }

    fn parse_field_definition(field_def: &JsonValue) -> Option<FieldMapping> {
        let field_type_str = field_def.get("type")?.as_str()?;
        let field_type = Self::parse_field_type(field_type_str)?;

        let mut subfields = HashMap::new();

        // Parse subfields if they exist
        if let Some(fields) = field_def.get("fields") {
            if let Some(fields_obj) = fields.as_object() {
                for (subfield_name, subfield_def) in fields_obj {
                    if let Some(subfield_type_str) =
                        subfield_def.get("type").and_then(|v| v.as_str())
                    {
                        if let Some(subfield_type) = Self::parse_field_type(subfield_type_str) {
                            subfields.insert(subfield_name.clone(), subfield_type);
                        }
                    }
                }
            }
        }

        Some(FieldMapping {
            field_type,
            subfields,
        })
    }

    fn parse_field_type(type_str: &str) -> Option<FieldType> {
        match type_str {
            "keyword" => Some(FieldType::Keyword),
            "text" => Some(FieldType::Text),
            "long" | "integer" | "short" | "byte" => Some(FieldType::Long),
            "double" | "float" | "half_float" | "scaled_float" => Some(FieldType::Double),
            "boolean" => Some(FieldType::Boolean),
            "date" => Some(FieldType::Date),
            "ip" => Some(FieldType::Ip),
            "wildcard" => Some(FieldType::Wildcard),
            "object" => Some(FieldType::Object),
            "nested" => Some(FieldType::Nested),
            "geo_point" => Some(FieldType::GeoPoint),
            "unsigned_long" => Some(FieldType::UnsignedLong),
            // NEVER `None` again (tql#210). Returning `None` here does not mark
            // the field unsupported — `parse_field_definition`'s `?` DELETES it,
            // and a deleted field is read downstream as "the caller supplied no
            // mappings", which permits every operator on it. Failing into the
            // mappings as `Unknown` is what makes an unrecognised type visible.
            _ => Some(FieldType::Unknown),
        }
    }

    /// Operators whose value means the WHOLE stored string, so they need an
    /// un-analyzed field. Mirrors Python's `keyword_operators`.
    fn is_keyword_operator(operator: &str) -> bool {
        matches!(
            operator,
            "eq" | "="
                | "ne"
                | "!="
                | "in"
                | "not_in"
                | "exists"
                | "not_exists"
                | "any"
                | "all"
                | "not_any"
                | "not_all"
                // Case-sensitive `in` is exact-match on an un-analyzed field,
                // exactly like `in`. It was in NO operator set, which in Python
                // means `get_field_for_operator` falls through to
                // "Operator '...' is not supported for available field types"
                // and raises — so `f in_cs ['a','b']` translated fine with no
                // mappings and raised the moment mappings were available. Rust
                // leaves an unclassified operator on the base field instead, so
                // the two engines disagreed twice over: on WHICH field, and on
                // whether the query is legal at all.
                | "in_cs"
                | "not_in_cs"
                // The remaining collection operators. `any`/`all`/`not_any`/
                // `not_all` were already here; `none`/`not_none` are the same
                // question and were simply missed.
                | "none"
                | "not_none"
        )
    }

    /// Operators that need a numeric, date, or at minimum un-analyzed field.
    /// Mirrors Python's `range_operators`.
    fn is_range_operator(operator: &str) -> bool {
        matches!(
            operator,
            ">" | ">=" | "<" | "<=" | "gt" | "gte" | "lt" | "lte" | "between" | "not_between"
        )
    }

    /// Name of a subfield of `field` whose type satisfies `pred`, if any.
    fn subfield_of<F>(&self, field: &str, pred: F) -> Option<String>
    where
        F: Fn(&FieldType) -> bool,
    {
        let mapping = self.mappings.get(field)?;
        // Deterministic: a HashMap iteration order would make the emitted query
        // vary between runs for a field carrying two candidate subfields.
        let mut names: Vec<&String> = mapping
            .subfields
            .iter()
            .filter(|(_, ty)| pred(ty))
            .map(|(name, _)| name)
            .collect();
        names.sort();
        names.first().map(|n| format!("{}.{}", field, n))
    }

    /// The field's KEYWORD form: itself when it is already a keyword, else a
    /// `keyword` subfield.
    ///
    /// Deliberately narrow. An earlier revision returned `Some(field)` for any
    /// non-text type, which made `contains` on a `long` and `gt` on an `ip`
    /// resolve happily and emit a query matching nothing — Python raises for
    /// both. "Not analyzed text" is not the same property as "has string
    /// semantics", and conflating them reproduced exactly the silent-zero-hits
    /// behaviour this whole change removes.
    fn keyword_form(&self, field: &str) -> Option<String> {
        let mapping = self.mappings.get(field)?;
        if mapping.field_type == FieldType::Keyword {
            return Some(field.to_string());
        }
        self.subfield_of(field, |t| *t == FieldType::Keyword)
    }

    /// The field's UN-ANALYZED form: `keyword` or `wildcard`, itself or a
    /// subfield. Prefers a true keyword so an existing mapping's emitted DSL
    /// does not change.
    ///
    /// Used by the exact-match and whole-value operator classes, which need
    /// "one whole value, one indexed term" — not "OpenSearch can range over
    /// it". The range arm keeps using `keyword_form`; see
    /// [`FieldType::is_unanalyzed_string`].
    fn unanalyzed_form(&self, field: &str) -> Option<String> {
        if let Some(kw) = self.keyword_form(field) {
            return Some(kw);
        }
        let mapping = self.mappings.get(field)?;
        if mapping.field_type == FieldType::Wildcard {
            return Some(field.to_string());
        }
        self.subfield_of(field, |t| *t == FieldType::Wildcard)
    }

    /// A human-readable inventory of what this field actually offers, for error
    /// messages. Mirrors Python's `available_types`.
    fn available_types(&self, field: &str) -> String {
        let Some(mapping) = self.mappings.get(field) else {
            return "unknown".to_string();
        };
        let mut parts = vec![format!("{}({})", field, mapping.field_type.as_str())];
        let mut subs: Vec<_> = mapping.subfields.iter().collect();
        subs.sort_by_key(|(n, _)| (*n).clone());
        for (name, ty) in subs {
            parts.push(format!("{}.{}({})", field, name, ty.as_str()));
        }
        parts.join(", ")
    }

    /// Resolve the field an operator should actually query, or explain why it
    /// cannot be.
    ///
    /// # Why this returns a Result
    ///
    /// It used to return `String` and always succeed, redirecting to `.keyword`
    /// only for `eq|ne|in|not_in` plus [`WHOLE_VALUE_OPERATORS`] and otherwise
    /// handing back the base field untouched. Measured against the Python
    /// converter across 6 mapping shapes x 14 operators, **42 of 84
    /// combinations disagreed**, in three ways:
    ///
    /// * **Wrong subfield.** `cidr` on a field mapped `text` with an `.ip`
    ///   subfield queried the analyzed text; Python queries `.ip`. Range
    ///   operators queried the analyzed field; Python uses `.keyword`. Both
    ///   return zero hits rather than an error.
    /// * **Silence where Python refuses.** `contains` on a `long`, `gt` on a
    ///   `text` — Python raises `TQLTypeError` / `TQLUnsupportedOperationError`;
    ///   Rust emitted a query that matches nothing. For a query language whose
    ///   purpose is to spare the user from knowing OpenSearch mapping rules,
    ///   silently answering "no results" to an impossible question is the worst
    ///   available behaviour: it is indistinguishable from a quiet network.
    /// * `cidr` had no translation arm at all (tql#198).
    ///
    /// Rust is the production engine — it is what the agent's detection engine
    /// runs — so the divergence meant shipped detection content behaved
    /// differently from everything validated against the Python package.
    ///
    /// The resolution order mirrors
    /// `src/tql/opensearch_components/field_mapping.py::get_field_for_operator`
    /// class for class. Keep them in lockstep; `cross_language_dsl_parity`
    /// fails if they drift.
    pub fn get_query_field(&self, field: &str, operator: &str) -> Result<String> {
        // Unmapped field: nothing to reason about, and refusing would break
        // every query against an index whose mappings we could not fetch.
        // Python behaves the same way (it only consults mappings it has).
        let Some(mapping) = self.mappings.get(field) else {
            return Ok(field.to_string());
        };

        // "Does this document have this field at all" is a question about the
        // field, not any subfield. A `.keyword` subfield carries `ignore_above`
        // (256 by default), so `exists` against it is silently false for longer
        // values; an analyzed subfield is absent when analysis produced no
        // tokens. Both answer "no such field" for a document that plainly has
        // one. Matches Python.
        // `is` / `is_not` are existence questions too (audit finding F30).
        //
        // `is` used to be classified as a keyword operator and resolved to the
        // `.keyword` form, so on a text+keyword multifield `f is null` asked
        // about `f.keyword` while `f is not null` asked about `f`. A value
        // longer than `ignore_above` (256 by default) is indexed into `f` and
        // NOT into `f.keyword`, so a 300-character document came back from BOTH
        // — it read as null and as not-null at once. Measured against a live
        // cluster rather than predicted, and fixed on both sides together
        // because both engines classified it the same way.
        if matches!(operator, "exists" | "not_exists" | "is" | "is_not") {
            return Ok(field.to_string());
        }

        if Self::is_keyword_operator(operator) {
            // Un-analyzed first; an analyzed field is better than failing.
            if let Some(unanalyzed) = self.unanalyzed_form(field) {
                return Ok(unanalyzed);
            }
            // No un-analyzed form and a case-SENSITIVE membership operator
            // against an ANALYZED text field: the field cannot answer at all,
            // exactly as for `contains_cs` and its siblings. The analyzer
            // lowercased every token it indexed, so the case `in_cs` asks about
            // is not in the index — falling back to the analyzed field emits a
            // `terms` clause that can never match a mixed-case value and
            // returns zero hits with no error.
            //
            // Measured live on OpenSearch 2.19.4, an analyzed `text` field
            // holding "A MiXeD Value":
            //
            //     terms ["MiXeD"]            -> 0 hits
            //     terms ["mixed"]  (control) -> 1 hit
            //     must_not(terms ["MiXeD"])  -> 1 hit  (excludes NOTHING)
            //
            // The control is what makes this a measurement rather than an
            // assumption: the token IS there, lowercased, so the miss is about
            // case and not about the field or an empty index. The negated
            // direction is the dangerous one — `not_in_cs` becomes a match-all.
            //
            // Gated on the BASE field being `text` rather than on "no
            // un-analyzed form" alone. A `long`, `date` or `ip` field also has
            // no un-analyzed string form, but it answers `in_cs` perfectly
            // well: case-sensitivity is vacuous for a number. Only an analyzed
            // string field is UNANSWERABLE.
            if CASE_SENSITIVE_MEMBERSHIP_OPERATORS.contains(&operator)
                && mapping.field_type == FieldType::Text
            {
                return Err(OpenSearchError::TypeError {
                    field: field.to_string(),
                    field_type: mapping.field_type.as_str().to_string(),
                    operator: operator.to_string(),
                    suggestion: format!(
                        " '{field}' is analyzed and has no case-preserving form, \
                         so the case this operator asks about is not in the index \
                         -- the analyzer lowercased it. Add a `.keyword` subfield \
                         to '{field}', or use the case-insensitive '{ci}'.",
                        field = field,
                        ci = operator.trim_end_matches("_cs"),
                    ),
                });
            }
            return Ok(field.to_string());
        }

        if WHOLE_VALUE_OPERATORS.contains(&operator) {
            // `wildcard`, `prefix` and `regexp` match a single indexed TERM. On
            // an analyzed field the terms are the analyzer's tokens, so a
            // pattern spanning a token boundary can never match:
            // `email contains '@example.com'` searched ["alice", "example.com"]
            // for `*@example.com*` and found nothing (tql#169).
            if let Some(unanalyzed) = self.unanalyzed_form(field) {
                return Ok(unanalyzed);
            }
            // No un-analyzed form. For a case-SENSITIVE operator that is not
            // "imperfect", it is UNANSWERABLE: the analyzer lowercased every
            // token it indexed, so the case the query asks about is not in the
            // index at all. Falling back to the analyzed field there produces a
            // query that can never match a mixed-case value and returns zero
            // hits with no error -- indistinguishable from "nothing matched",
            // which is the exact confusion TQL exists to remove.
            //
            // Same shape as F31 (`cidr` against a keyword field, where `term`
            // compares the CIDR string literally), and resolved the same way:
            // refuse, and say what would fix it. The operator is not at fault
            // and neither is the value -- the FIELD cannot answer, and only the
            // mapping owner can change that.
            //
            // Only the `_cs` operators refuse. `contains` / `startswith` /
            // `endswith` and `eq_ci` ask case-INSENSITIVE questions, which
            // lowercased tokens answer perfectly well, and `matches` carries its
            // own case handling. Those keep the analyzed-field fallback, where
            // imperfect really does beat impossible.
            if CASE_SENSITIVE_WHOLE_VALUE_OPERATORS.contains(&operator) {
                return Err(OpenSearchError::TypeError {
                    field: field.to_string(),
                    field_type: mapping.field_type.as_str().to_string(),
                    operator: operator.to_string(),
                    suggestion: format!(
                        " '{field}' is analyzed and has no case-preserving form, \
                         so the case this operator asks about is not in the index \
                         -- the analyzer lowercased it. Add a `.keyword` subfield \
                         to '{field}', or use the case-insensitive '{ci}'.",
                        field = field,
                        ci = operator.trim_end_matches("_cs"),
                    ),
                });
            }
            // Text-only: Python falls back to the analyzed field rather than
            // failing, and so do we. Imperfect beats impossible.
            if mapping.field_type == FieldType::Text {
                return Ok(field.to_string());
            }
            // Numeric/date/boolean have no string semantics at all.
            return Err(OpenSearchError::UnsupportedOperation {
                operator: operator.to_string(),
                available_types: self.available_types(field),
            });
        }

        if Self::is_range_operator(operator) {
            if mapping.field_type.is_orderable() {
                return Ok(field.to_string());
            }
            if let Some(sub) = self.subfield_of(field, FieldType::is_orderable) {
                return Ok(sub);
            }
            // OpenSearch does support range on keyword (lexicographic).
            if let Some(keyword) = self.keyword_form(field) {
                return Ok(keyword);
            }
            // Python raises TQLTypeError only when the field has TEXT forms to
            // complain about (`if self.text_fields:`); every other unusable
            // shape reaches its catch-all TQLUnsupportedOperationError. Mirror
            // that split exactly — the two exception types are part of the
            // contract callers match on.
            if mapping.field_type == FieldType::Text {
                return Err(OpenSearchError::TypeError {
                    field: field.to_string(),
                    field_type: mapping.field_type.as_str().to_string(),
                    operator: operator.to_string(),
                    suggestion:
                        " Range operators need a numeric, date, or keyword field; this field is analyzed text."
                            .to_string(),
                });
            }
            return Err(OpenSearchError::UnsupportedOperation {
                operator: operator.to_string(),
                available_types: self.available_types(field),
            });
        }

        if matches!(operator, "cidr" | "not_cidr") {
            // An `ip`-typed field is the only one that matches CIDR notation
            // natively. Prefer it, including as a subfield.
            if mapping.field_type == FieldType::Ip {
                return Ok(field.to_string());
            }
            if let Some(sub) = self.subfield_of(field, |t| *t == FieldType::Ip) {
                return Ok(sub);
            }
            // NO KEYWORD FALLBACK. Both engines used to fall back to the
            // field's keyword form here, "because that is what the reference
            // implementation does" — and the reference implementation was
            // wrong. `term` accepts CIDR notation ONLY on an `ip` field, where
            // OpenSearch expands the prefix; on a `keyword` it is a literal
            // string comparison against the stored address, so
            // `kwip cidr '10.0.0.0/8'` asks whether the field holds the eight
            // characters "10.0.0.0/8". Measured on OpenSearch 2.19.4 against a
            // document holding 10.1.2.3 in both an `ip` and a `keyword` field:
            //
            //   term realip = "10.0.0.0/8"  -> 1 hit   (ip: CIDR expanded)
            //   term kwip   = "10.0.0.0/8"  -> 0 hits  (keyword: literal)
            //   term kwip   = "10.1.2.3"    -> 1 hit   (control)
            //
            // Zero hits and no error is the worst answer available: a detection
            // rule that can never fire is indistinguishable from a rule for an
            // event that never happened. Refusing is loud, and the caller can
            // then map the field as `ip` or add an `ip` subfield — which is the
            // only thing that would have made the query mean what they wrote.
            //
            // An UNMAPPED field is deliberately still allowed through above:
            // with no mappings we cannot tell an `ip` field from a `keyword`
            // one, and refusing would break every query against an index whose
            // mappings could not be fetched.
            //
            // Python's two cidr spellings do not agree with each other: the
            // positive form surfaces TQLTypeError while `not cidr` reaches the
            // catch-all TQLUnsupportedOperationError, on the SAME field. That
            // asymmetry looks like a Python defect rather than a design, but it
            // is observable behaviour that callers may match on, so it is
            // mirrored rather than quietly corrected here. Filed separately;
            // when Python is fixed, this branch collapses to one arm.
            if operator == "cidr" {
                return Err(OpenSearchError::TypeError {
                    field: field.to_string(),
                    field_type: mapping.field_type.as_str().to_string(),
                    operator: operator.to_string(),
                    suggestion: " CIDR matching needs an ip field or an ip subfield; a keyword holding an address is compared literally and can never match a prefix.".to_string(),
                });
            }
            return Err(OpenSearchError::UnsupportedOperation {
                operator: operator.to_string(),
                available_types: self.available_types(field),
            });
        }

        // Unclassified operator: behave as before rather than refusing, so a
        // new operator does not become an outage. `opensearch_operator_
        // translation_coverage` is what stops one arriving unnoticed.
        Ok(field.to_string())
    }

    /// Determine if a field should use term query vs match query
    ///
    /// # Arguments
    ///
    /// * `field` - The field name
    ///
    /// # Returns
    ///
    /// `true` if term query should be used (exact match), `false` for match query
    /// # No production caller
    ///
    /// This is `pub` and nothing in this crate calls it. The live equivalent is
    /// `field_is_mapped` in `query_builder.rs`; the comment there naming
    /// `should_use_term_query` refers to PYTHON's function of that name, not this one.
    ///
    /// Kept rather than deleted because it is published API on a released crate, and its
    /// arms are now deliberate rather than accidental. tql#210 changed its answer without
    /// anyone choosing to: `geo_point` and `unsigned_long` used to be absent from `mappings`
    /// entirely, so they took the `true` default; giving them variants moved them into the
    /// `matches!` and flipped both to `false`. `UnsignedLong` is listed above because a
    /// `term` genuinely is right for an integer. `GeoPoint` and `Unknown` are deliberately
    /// NOT listed -- a geo point is not a scalar you `term`-match, and for a type this crate
    /// cannot parse, "we do not know" is the honest answer and `false` is the conservative one.
    ///
    /// `an_orphan_predicates_arms_are_deliberate` in
    /// `tql/tests/opensearch_case_sensitive_unanswerable.rs` pins all three.
    pub fn should_use_term_query(&self, field: &str) -> bool {
        if let Some(mapping) = self.mappings.get(field) {
            matches!(
                mapping.field_type,
                FieldType::Keyword
                    | FieldType::Long
                    | FieldType::Double
                    | FieldType::Boolean
                    | FieldType::Date
                    | FieldType::Ip
                    | FieldType::Wildcard
                    // Numeric: a `term` is the right shape, same as the other integer widths.
                    // Added explicitly at tql#210 -- before it, `unsigned_long` had no arm in
                    // `parse_field_type`, so the field was absent from `mappings` and fell to
                    // the `true` default below. Introducing the variant silently flipped that
                    // to `false`, and nothing would have noticed: see the note above.
                    | FieldType::UnsignedLong
            )
        } else {
            // Default to term query if we don't know the type
            true
        }
    }

    /// Get the field type for a given field
    pub fn get_field_type(&self, field: &str) -> Option<&FieldType> {
        self.mappings.get(field).map(|m| &m.field_type)
    }

    /// The type of a RESOLVED field path, which may name a subfield.
    ///
    /// `get_field_type` only sees top-level keys, because subfields live inside
    /// their parent's `FieldMapping` rather than as map entries of their own.
    /// So `get_field_type("f.keyword")` is `None` for every multifield in
    /// existence — and a caller that reads that `None` as "unknown, fall back"
    /// gets the base field's type instead of the type it is about to query.
    ///
    /// That is a live defect, not a hypothetical: `supports_case_insensitive_term`
    /// stripped `.keyword` and typed the BASE, so on an `ip` field with a
    /// `keyword` subfield it concluded "ip, no case-insensitivity" while the
    /// query it was building targeted `f.keyword`. `in` is contractually
    /// case-insensitive, so `role in ['ADMIN']` silently missed `admin` on
    /// exactly that mapping shape. Measured live: `term role='ADMIN'` returns
    /// 0 hits case-sensitively and 1 case-insensitively, and `case_insensitive`
    /// is accepted on the `.keyword` subfield of an `ip` field.
    ///
    /// Absence stays absence: an unknown path returns `None` rather than
    /// defaulting to the parent, so callers must decide what to do about not
    /// knowing instead of being handed a plausible wrong answer.
    pub fn resolved_field_type(&self, path: &str) -> Option<&FieldType> {
        if let Some(mapping) = self.mappings.get(path) {
            return Some(&mapping.field_type);
        }
        let (base, sub) = path.rsplit_once('.')?;
        self.mappings.get(base)?.subfields.get(sub)
    }

    /// Add a field mapping
    pub fn add_mapping(&mut self, field: String, mapping: FieldMapping) {
        self.mappings.insert(field, mapping);
    }

    /// Get the number of field mappings
    pub fn len(&self) -> usize {
        self.mappings.len()
    }

    /// Check if there are no field mappings
    pub fn is_empty(&self) -> bool {
        self.mappings.is_empty()
    }
}

impl Default for FieldMappings {
    fn default() -> Self {
        Self::new()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_empty_mappings() {
        let mappings = FieldMappings::new();
        assert_eq!(mappings.get_query_field("test", "eq").unwrap(), "test");
        assert!(mappings.should_use_term_query("test"));
    }

    #[test]
    fn test_text_field_with_keyword() {
        let mut mappings = FieldMappings::new();
        let mut subfields = HashMap::new();
        subfields.insert("keyword".to_string(), FieldType::Keyword);

        mappings.add_mapping(
            "message".to_string(),
            FieldMapping {
                field_type: FieldType::Text,
                subfields,
            },
        );

        // For eq operator, should use .keyword subfield
        assert_eq!(
            mappings.get_query_field("message", "eq").unwrap(),
            "message.keyword"
        );
        // A substring operator also needs the un-analyzed field: a wildcard on
        // the analyzed base matches per token, so anything spanning two tokens
        // is unmatchable. This assertion used to read "message" and pinned the
        // bug (tql#169).
        assert_eq!(
            mappings.get_query_field("message", "contains").unwrap(),
            "message.keyword"
        );
        assert_eq!(
            mappings.get_query_field("message", "matches").unwrap(),
            "message.keyword"
        );
    }

    #[test]
    fn test_keyword_field() {
        let mut mappings = FieldMappings::new();
        mappings.add_mapping(
            "status".to_string(),
            FieldMapping {
                field_type: FieldType::Keyword,
                subfields: HashMap::new(),
            },
        );

        assert_eq!(mappings.get_query_field("status", "eq").unwrap(), "status");
        assert!(mappings.should_use_term_query("status"));
    }
}

#[cfg(test)]
mod field_caps_tests {
    use super::*;
    use serde_json::json;

    /// The defect this whole change exists to remove.
    ///
    /// `host.name` is analyzed `text` with a `keyword` multi-field. `_field_caps`
    /// reports the two as siblings. If they are not folded back together,
    /// `keyword_form` finds no keyword subfield and `host.name eq 'x'` compiles
    /// against the analyzed field — zero hits, no error.
    #[test]
    fn multifield_is_folded_so_eq_resolves_to_keyword() {
        let response = json!({
            "indices": ["logs-2026.09.17"],
            "fields": {
                "host.name": { "text": { "type": "text", "searchable": true, "aggregatable": false } },
                "host.name.keyword": { "keyword": { "type": "keyword", "searchable": true, "aggregatable": true } }
            }
        });
        let mappings = FieldMappings::from_field_caps_response(response).unwrap();
        assert_eq!(
            mappings.get_query_field("host.name", "eq").unwrap(),
            "host.name.keyword"
        );
    }

    /// The flattened entry survives folding, so an explicitly dotted query works.
    #[test]
    fn explicit_keyword_subfield_still_resolves() {
        let response = json!({
            "indices": ["logs-1"],
            "fields": {
                "host.name": { "text": { "type": "text" } },
                "host.name.keyword": { "keyword": { "type": "keyword" } }
            }
        });
        let mappings = FieldMappings::from_field_caps_response(response).unwrap();
        assert_eq!(
            mappings.get_query_field("host.name.keyword", "eq").unwrap(),
            "host.name.keyword"
        );
    }

    /// `_mapping` reads one arbitrary index of a wildcard. `_field_caps` reports
    /// every field across the pattern, so a field present only in the second
    /// index is still known.
    #[test]
    fn fields_from_every_index_in_the_pattern_are_present() {
        let response = json!({
            "indices": ["logs-a", "logs-b"],
            "fields": {
                "only_in_a": { "keyword": { "type": "keyword", "indices": ["logs-a"] } },
                "only_in_b": { "ip": { "type": "ip", "indices": ["logs-b"] } }
            }
        });
        let mappings = FieldMappings::from_field_caps_response(response).unwrap();
        assert_eq!(
            mappings.get_field_type("only_in_a"),
            Some(&FieldType::Keyword)
        );
        assert_eq!(mappings.get_field_type("only_in_b"), Some(&FieldType::Ip));
    }

    /// A field typed differently across the pattern resolves to the un-analyzed
    /// form, because the operators that consult mappings are exact-match.
    ///
    /// The type pair here is chosen so alphabetical order gives the WRONG
    /// answer. `serde_json` maps are `BTreeMap`, so a `text`/`keyword` conflict
    /// sorts `keyword` first and would pass whether or not the un-analyzed
    /// preference existed — a vacuous guard. `ip` sorts before `keyword`, so
    /// only the deliberate preference produces `Keyword` here.
    #[test]
    fn conflicting_types_resolve_to_the_unanalyzed_form() {
        let response = json!({
            "indices": ["logs-old", "logs-new"],
            "fields": {
                "status": {
                    "ip": { "type": "ip", "indices": ["logs-old"] },
                    "keyword": { "type": "keyword", "indices": ["logs-new"] }
                }
            }
        });
        let mappings = FieldMappings::from_field_caps_response(response).unwrap();
        assert_eq!(mappings.get_field_type("status"), Some(&FieldType::Keyword));
        assert_eq!(mappings.get_query_field("status", "eq").unwrap(), "status");
    }

    /// An object parent must not swallow its children. `host` is an object and
    /// `host.name` is a field in its own right; folding it in would both lose
    /// the field and invent an unqueryable subfield.
    #[test]
    fn object_parents_do_not_absorb_their_children() {
        let response = json!({
            "indices": ["logs-1"],
            "fields": {
                "host": { "object": { "type": "object" } },
                "host.name": { "keyword": { "type": "keyword" } }
            }
        });
        let mappings = FieldMappings::from_field_caps_response(response).unwrap();
        assert_eq!(
            mappings.get_field_type("host.name"),
            Some(&FieldType::Keyword)
        );
        assert_eq!(
            mappings.get_query_field("host.name", "eq").unwrap(),
            "host.name"
        );
        // The observable effect of the guard is on the PARENT: without it,
        // `host` acquires a bogus `name` subfield and `host eq '...'` resolves
        // to `host.name`. Asserting only on the child passes either way.
        assert!(
            mappings.mappings.get("host").unwrap().subfields.is_empty(),
            "an object parent must not absorb its children as multi-fields"
        );
        assert_eq!(mappings.get_query_field("host", "eq").unwrap(), "host");
    }

    /// A field with no parent entry is left alone rather than being folded into
    /// a parent that does not exist.
    #[test]
    fn dotted_field_without_a_parent_is_kept_flat() {
        let response = json!({
            "indices": ["logs-1"],
            "fields": { "a.b.c": { "keyword": { "type": "keyword" } } }
        });
        let mappings = FieldMappings::from_field_caps_response(response).unwrap();
        assert_eq!(mappings.get_field_type("a.b.c"), Some(&FieldType::Keyword));
    }

    /// A payload that is not a field-caps response is an error, not an empty
    /// mapping set. Empty mappings read downstream as "no mappings supplied",
    /// which permits every operator on every field.
    #[test]
    fn non_field_caps_payload_is_an_error() {
        assert!(
            FieldMappings::from_field_caps_response(json!({"error": "index_not_found"})).is_err()
        );
        assert!(FieldMappings::from_field_caps_response(json!(null)).is_err());
    }

    /// An unrecognised type fails INTO the mappings as Unknown rather than
    /// vanishing — the same contract `parse_field_type` documents.
    #[test]
    fn unrecognised_type_becomes_unknown_not_absent() {
        let response = json!({
            "indices": ["logs-1"],
            "fields": { "weird": { "match_only_text": { "type": "match_only_text" } } }
        });
        let mappings = FieldMappings::from_field_caps_response(response).unwrap();
        assert_eq!(mappings.get_field_type("weird"), Some(&FieldType::Unknown));
    }

    /// Metadata fields must not count as declared fields.
    ///
    /// Captured from a live cluster: `_field_caps` on an index with no user
    /// fields returns twelve `_`-prefixed entries. If those are kept, every
    /// index looks non-empty and `fetch_field_mappings`' "declared no fields"
    /// refusal can never fire — the silent success this change removes.
    #[test]
    fn metadata_only_index_yields_no_fields() {
        let response = json!({
            "indices": ["fcprobe-empty"],
            "fields": {
                "_index": { "_index": { "type": "_index" } },
                "_routing": { "_routing": { "type": "_routing" } },
                "_seq_no": { "_seq_no": { "type": "_seq_no" } },
                "_doc_count": { "long": { "type": "long" } },
                "_id": { "_id": { "type": "_id" } }
            }
        });
        let mappings = FieldMappings::from_field_caps_response(response).unwrap();
        assert!(
            mappings.is_empty(),
            "metadata-only must read as zero declared fields, or the empty refusal is unreachable"
        );
    }

    /// Real payload captured from OpenSearch 2.x on slot 3, trimmed to the
    /// fields under test. Every branch of the parser is exercised by the shape
    /// the cluster actually sends, not by a shape invented here.
    #[test]
    fn live_captured_payload_resolves_as_expected() {
        let response = json!({
            "indices": ["fcprobe-1", "fcprobe-2"],
            "fields": {
                "_index": { "_index": { "type": "_index", "searchable": true, "aggregatable": true } },
                "host": { "object": { "type": "object", "searchable": false, "aggregatable": false } },
                "host.name": { "text": { "type": "text", "searchable": true, "aggregatable": false } },
                "host.name.keyword": { "keyword": { "type": "keyword", "searchable": true, "aggregatable": true } },
                "status": {
                    "text": { "type": "text", "searchable": true, "aggregatable": false, "indices": ["fcprobe-1"] },
                    "keyword": { "type": "keyword", "searchable": true, "aggregatable": true, "indices": ["fcprobe-2"] }
                },
                "only_in_two": { "ip": { "type": "ip", "searchable": true, "aggregatable": true } }
            }
        });
        let mappings = FieldMappings::from_field_caps_response(response).unwrap();

        // The defect this change exists to fix.
        assert_eq!(
            mappings.get_query_field("host.name", "eq").unwrap(),
            "host.name.keyword"
        );
        // `host` really is an `object` on the wire, so the parent guard is
        // load-bearing against real data, not just a synthetic case.
        assert_eq!(mappings.get_query_field("host", "eq").unwrap(), "host");
        // Present in the second index only — invisible to first-index-wins.
        assert_eq!(mappings.get_field_type("only_in_two"), Some(&FieldType::Ip));
        // A genuine cross-index conflict, as the cluster reports it.
        assert_eq!(mappings.get_field_type("status"), Some(&FieldType::Keyword));
        // Metadata excluded.
        assert_eq!(mappings.get_field_type("_index"), None);
    }

    /// Aggregations need the keyword form too: `| stats ... by host.name` on an
    /// analyzed field buckets tokens or fails on fielddata.
    #[test]
    fn folded_multifield_is_visible_to_aggregation_resolution() {
        let response = json!({
            "indices": ["logs-1"],
            "fields": {
                "host.name": { "text": { "type": "text" } },
                "host.name.keyword": { "keyword": { "type": "keyword" } }
            }
        });
        let mappings = FieldMappings::from_field_caps_response(response).unwrap();
        let mapping = mappings.mappings.get("host.name").unwrap();
        assert_eq!(
            mapping.subfields.get("keyword"),
            Some(&FieldType::Keyword),
            "the keyword subfield must be folded in for aggregation resolution"
        );
    }
}