Skip to main content

drain_flow/
query.rs

1// Copyright Nicholas Harring. All rights reserved.
2//
3// This program is free software: you can redistribute it and/or modify it under
4// the terms of the Server Side Public License, version 1, as published by MongoDB, Inc.
5// This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
6// without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
7// See the Server Side Public License for more details. You should have received a copy of the
8// Server Side Public License along with this program.
9// If not, see <http://www.mongodb.com/licensing/server-side-public-license>.
10
11//! # Log Querying System
12//!
13//! This module provides structures and functions for querying log data.
14//! The central component is the [`LogStore`], which acts as an abstraction layer
15//! over a log data source, represented by an implementation of the [`Drain`](crate::drains::api::Drain) trait.
16//!
17//! It also defines LogQL (Log Query Language) related structures like [`LogQlQuery`],
18//! [`StreamSelector`], and [`LineFilter`] to enable structured querying of logs.
19
20use crate::drains::api::Drain;
21use crate::log_group::LogGroup;
22use crate::record::Record;
23use chrono::{DateTime, Utc};
24// Removed HashMap import as log_groups field is removed
25use uuid::Uuid; // Added for function return type and usage
26
27/// A generic log data store that interacts with a log source via the [`Drain`] trait.
28///
29/// `LogStore` provides an interface to query log groups and records. It is generic
30/// over `D: Drain`, meaning it can work with any concrete drain implementation
31/// that provides log data (e.g., in-memory drains, file-based drains).
32///
33/// Log groups are fetched dynamically from the underlying drain when queried.
34#[derive(Debug, Clone)]
35pub struct LogStore<D: Drain> {
36    drain: D,
37}
38
39impl<D: Drain> LogStore<D> {
40    /// Creates a new `LogStore` with the given drain.
41    ///
42    /// # Arguments
43    ///
44    /// * `drain` - An instance of a type implementing the `Drain` trait, which will
45    ///   serve as the source of log data for this store.
46    ///
47    /// # Returns
48    ///
49    /// A new `LogStore` instance.
50    pub fn new(drain: D) -> Self {
51        Self { drain }
52    }
53
54    /// Retrieves a specific `LogGroup` by its ID.
55    ///
56    /// This method queries the underlying drain for all its log groups and then
57    /// searches for the one with the matching ID.
58    ///
59    /// # Arguments
60    ///
61    /// * `id` - The `Uuid` of the `LogGroup` to retrieve.
62    ///
63    /// # Returns
64    ///
65    /// An `Option<LogGroup>` containing the found log group, or `None` if no
66    /// group with the specified ID exists in the drain. The `LogGroup` is returned by value.
67    pub fn get_log_group_by_id(&self, id: Uuid) -> Option<LogGroup> {
68        self.drain
69            .collect_log_groups()
70            .into_iter()
71            .find(|lg| lg.id == id)
72    }
73
74    /// Retrieves all `LogGroup`s that fall within a specified time range.
75    ///
76    /// This method queries the underlying drain for all its log groups and then
77    /// filters them based on their timestamp.
78    ///
79    /// # Arguments
80    ///
81    /// * `start_time` - The `DateTime<Utc>` marking the beginning of the time range (inclusive).
82    /// * `end_time` - The `DateTime<Utc>` marking the end of the time range (inclusive).
83    ///
84    /// # Returns
85    ///
86    /// A `Vec<LogGroup>` containing all log groups whose timestamp is within the
87    /// specified range. The `LogGroup`s are returned by value.
88    pub fn get_log_groups_in_range(
89        &self,
90        start_time: DateTime<Utc>,
91        end_time: DateTime<Utc>,
92    ) -> Vec<LogGroup> {
93        self.drain
94            .collect_log_groups()
95            .into_iter()
96            .filter(|log_group| {
97                let timestamp = log_group.get_time();
98                timestamp >= start_time && timestamp <= end_time
99            })
100            .collect()
101    }
102}
103
104// Define LogQL structures
105/// Represents a selector for log streams in LogQL queries.
106#[derive(Debug, Clone)]
107pub enum StreamSelector {
108    /// Selects log groups by a list of their unique identifiers.
109    LogGroupIds(Vec<Uuid>),
110}
111
112/// Represents a line filter for LogQL queries.
113#[derive(Debug, Clone)]
114pub struct LineFilter {
115    /// The substring that log lines must contain to match the filter.
116    pub contains: String,
117}
118
119/// Represents a LogQL query, combining a stream selector and an optional line filter.
120#[derive(Debug, Clone)]
121pub struct LogQlQuery {
122    /// The mechanism for selecting log streams (e.g., by group IDs).
123    pub selector: StreamSelector,
124    /// An optional filter to apply to the content of log lines.
125    pub filter: Option<LineFilter>,
126}
127
128/// Specifies the source of records for a query, either by a direct ID or a LogQL query.
129#[derive(Debug, Clone)]
130pub enum QuerySource {
131    /// Specifies a single log group by its unique identifier.
132    ById(Uuid),
133    /// Specifies records to be fetched using a full LogQL query.
134    ByLogQl(LogQlQuery),
135}
136
137/// Executes a LogQL query against the provided `LogStore`.
138///
139/// This function processes a [`LogQlQuery`], retrieving records from the specified
140/// log groups and applying any defined filters. It is generic over `D: Drain`
141/// because `LogStore` is generic.
142///
143/// Due to lifetime considerations with the `Drain` trait (which returns owned `LogGroup`s),
144/// this function returns a `Vec<Record>` (i.e., cloned, owned records) rather than references.
145///
146/// # Arguments
147///
148/// * `log_store` - A reference to the `LogStore` instance to query.
149/// * `query` - A reference to the `LogQlQuery` defining the selection and filtering criteria.
150///
151/// # Returns
152///
153/// A `Vec<Record>` containing all records that match the query criteria.
154pub fn execute_logql_query<D: Drain>(log_store: &LogStore<D>, query: &LogQlQuery) -> Vec<Record> {
155    let mut records_batch: Vec<Record> = Vec::new();
156
157    let collected_groups = log_store.drain.collect_log_groups();
158
159    match &query.selector {
160        StreamSelector::LogGroupIds(group_ids) => {
161            for group_id in group_ids {
162                if let Some(log_group) = collected_groups.iter().find(|lg| lg.id == *group_id) {
163                    records_batch.push(log_group.base_record().clone()); // Clone base record
164                    records_batch.extend(log_group.examples().iter().cloned()); // Clone example records
165                }
166            }
167        }
168    }
169
170    if let Some(filter) = &query.filter {
171        records_batch.retain(|record| record.to_string().contains(&filter.contains));
172    }
173
174    records_batch
175}
176
177/// Aggregates log records based on a query source and a time range.
178///
179/// This function retrieves records either by a specific log group ID or by a LogQL query,
180/// and then filters these records to include only those within the specified time range.
181/// It is generic over `D: Drain` due to its use of `LogStore`.
182///
183/// Similar to `execute_logql_query`, this function returns `Vec<Record>` (cloned records)
184/// to manage lifetimes correctly with data sourced from the `Drain`.
185///
186/// # Arguments
187///
188/// * `log_store` - A reference to the `LogStore` instance.
189/// * `query_source` - A [`QuerySource`] enum indicating whether to fetch records by ID or by a LogQL query.
190/// * `start_time` - The `DateTime<Utc>` start of the aggregation range.
191/// * `end_time` - The `DateTime<Utc>` end of the aggregation range.
192///
193/// # Returns
194///
195/// A `Vec<Record>` containing all records that match the query source and fall within the time range.
196pub fn query_log_range_aggregation<D: Drain>(
197    log_store: &LogStore<D>,
198    query_source: QuerySource,
199    start_time: DateTime<Utc>,
200    end_time: DateTime<Utc>,
201) -> Vec<Record> {
202    let initial_records: Vec<Record> = match query_source {
203        QuerySource::ById(group_id) => {
204            log_store.get_log_group_by_id(group_id).map_or_else(
205                Vec::new, // If group not found, return empty vec
206                |log_group| {
207                    // If group found, collect its records (cloned)
208                    let mut records = vec![log_group.base_record().clone()];
209                    records.extend(log_group.examples().iter().cloned());
210                    records
211                },
212            )
213        }
214        QuerySource::ByLogQl(logql_query) => {
215            execute_logql_query(log_store, &logql_query) // Already returns Vec<Record>
216        }
217    };
218
219    initial_records
220        .into_iter()
221        .filter(|record| {
222            // record is now Record, not &Record
223            record.uid.get_timestamp().is_some_and(|ts| {
224                let (secs_u64, nanos) = ts.to_unix();
225                let secs_i64 = secs_u64 as i64;
226                if let Some(timestamp) = DateTime::from_timestamp(secs_i64, nanos) {
227                    timestamp >= start_time && timestamp <= end_time
228                } else {
229                    false
230                }
231            })
232        })
233        .collect()
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239    use chrono::Duration as ChronoDuration;
240    use chrono::Utc; // Keep Utc, add TimeZone for later proptest use
241    use proptest::collection::vec as prop_vec; // Added for proptest
242    use proptest::prelude::*; // Added for proptest
243    use proptest::sample::subsequence; // Added for subsequence
244    use std::collections::{HashMap, HashSet}; // Added for proptest, HashMap for mock drain
245    use std::thread::sleep;
246    use std::time::Duration as StdDuration;
247    use uuid::{Uuid, Version}; // Added for proptest
248                               // Removed unused import: use crate::drains::simple::SingleLayer;
249
250    // Helper to create a record and get its timestamp
251    fn get_record_timestamp(record: &Record) -> Option<DateTime<Utc>> {
252        record.uid.get_timestamp().and_then(|ts| {
253            let (secs_u64, nanos) = ts.to_unix();
254            let secs_i64 = secs_u64 as i64;
255            DateTime::from_timestamp(secs_i64, nanos)
256        })
257    }
258
259    // --- Proptest Strategies ---
260
261    // Strategy for Record content
262    fn arb_record_content() -> impl Strategy<Value = String> {
263        prop_vec(r"[a-zA-Z0-9]+", 1..10usize) // Generate 1 to 9 words
264            .prop_map(|words| words.join(" "))
265    }
266
267    // Strategy for Record
268    fn arb_record() -> impl Strategy<Value = Record> {
269        arb_record_content().prop_map(Record::new)
270    }
271
272    // Strategy for LineFilter
273    fn arb_line_filter() -> impl Strategy<Value = LineFilter> {
274        "[a-zA-Z0-9]{1,10}".prop_map(|s| LineFilter { contains: s })
275    }
276
277    // Strategy for Option<LineFilter>
278    fn arb_optional_line_filter() -> impl Strategy<Value = Option<LineFilter>> {
279        prop_oneof![Just(None), arb_line_filter().prop_map(Some),]
280    }
281
282    // Strategy for DateTime<Utc>
283    // Note: ChronoDuration is already aliased in the existing test module
284    fn arb_datetime_utc() -> impl Strategy<Value = DateTime<Utc>> {
285        (0i64..3600 * 24 * 30) // Offset in seconds (e.g., up to 30 days in the past from now)
286            .prop_map(|offset_secs| Utc::now() - ChronoDuration::seconds(offset_secs))
287    }
288
289    // Strategy for LogGroup
290    fn arb_log_group() -> impl Strategy<Value = LogGroup> {
291        (arb_record(), prop_vec(arb_record(), 0..5usize)).prop_map(
292            |(base_record, example_records)| {
293                let mut group = LogGroup::new(base_record);
294                for rec in example_records {
295                    group.add_example(rec);
296                }
297                group
298            },
299        )
300    }
301
302    // This will be used to construct LogStore and a list of its valid Uuids for selectors
303    fn arb_log_store_data() -> impl Strategy<Value = (Vec<LogGroup>, Vec<Uuid>)> {
304        prop_vec(arb_log_group(), 0..10usize) // 0 to 10 log groups
305            .prop_map(|groups| {
306                let group_ids = groups.iter().map(|g| g.id).collect::<Vec<Uuid>>();
307                (groups, group_ids)
308            })
309    }
310
311    // Strategy for StreamSelector
312    fn arb_stream_selector(valid_ids: Vec<Uuid>) -> impl Strategy<Value = StreamSelector> {
313        if valid_ids.is_empty() {
314            return Just(StreamSelector::LogGroupIds(Vec::new())).boxed();
315        }
316        // Strategy to pick a subsequence of valid_ids
317        let id_subset_strategy = subsequence(valid_ids.clone(), 0..valid_ids.len())
318            .prop_map(StreamSelector::LogGroupIds);
319
320        // Strategy to generate some random Uuids (potentially not in the store)
321        let random_ids_strategy = prop_vec(Just(Uuid::new_v4()), 0..3usize) // 0 to 3 random UUIDs
322            .prop_map(StreamSelector::LogGroupIds);
323
324        prop_oneof![
325            id_subset_strategy,
326            random_ids_strategy,
327            // Mix of valid and random
328            (
329                subsequence(valid_ids.clone(), 0..valid_ids.len()),
330                prop_vec(Just(Uuid::new_v4()), 0..2usize)
331            )
332                .prop_map(|(mut subset, mut random_guids)| {
333                    subset.append(&mut random_guids);
334                    StreamSelector::LogGroupIds(subset)
335                })
336        ]
337        .boxed()
338    }
339
340    // Strategy for LogQlQuery
341    fn arb_logql_query(valid_ids: Vec<Uuid>) -> impl Strategy<Value = LogQlQuery> {
342        (arb_stream_selector(valid_ids), arb_optional_line_filter())
343            .prop_map(|(selector, filter)| LogQlQuery { selector, filter })
344    }
345
346    // Combined strategy for log groups and a query based on those groups
347    fn arb_log_groups_and_query() -> impl Strategy<Value = (Vec<LogGroup>, LogQlQuery)> {
348        arb_log_store_data()
349            .prop_flat_map(|(groups, group_ids)| (Just(groups), arb_logql_query(group_ids)))
350    }
351
352    // Combined strategy for log groups, query, and time range
353    fn arb_log_groups_query_and_times(
354    ) -> impl Strategy<Value = (Vec<LogGroup>, LogQlQuery, DateTime<Utc>, DateTime<Utc>)> {
355        arb_log_store_data().prop_flat_map(|(groups, group_ids)| {
356            (
357                Just(groups),
358                arb_logql_query(group_ids),
359                arb_datetime_utc(),
360                arb_datetime_utc(),
361            )
362        })
363    }
364
365    #[test]
366    fn test_record_uuid_timestamp_extraction() {
367        let record = Record::new("Test record for UUID and timestamp".to_string());
368        assert_eq!(record.uid.get_version(), Some(Version::Mac)); // v1 UUID
369
370        let timestamp = get_record_timestamp(&record);
371        assert!(
372            timestamp.is_some(),
373            "Timestamp should be extractable from record UID"
374        );
375    }
376
377    #[test]
378    fn test_log_group_timestamp_extraction() {
379        let now = Utc::now();
380        sleep(StdDuration::from_millis(10)); // Ensure time moves forward a bit
381        let record = Record::new("Test record for LogGroup timestamp".to_string());
382        let log_group = LogGroup::new(record);
383        sleep(StdDuration::from_millis(10)); // Ensure time moves forward a bit
384        let after_creation = Utc::now();
385
386        let group_time = log_group.get_time();
387
388        // Looser check: group_time should be between when we started and finished creation.
389        // More precise check might require controlling Uuid::new_v1 which is complex.
390        assert!(
391            group_time > now && group_time < after_creation,
392            "LogGroup time {:?} should be between {:?} and {:?}",
393            group_time,
394            now,
395            after_creation
396        );
397        // Also check it's reasonably close to now (e.g., within a few seconds)
398        assert!(
399            (Utc::now() - group_time).num_seconds() < 5,
400            "LogGroup time should be recent"
401        );
402    }
403
404    // Mock Drain for testing LogStore
405    #[derive(Clone)] // Added Clone
406    struct MockDrain {
407        groups: HashMap<Uuid, LogGroup>,
408    }
409
410    impl MockDrain {
411        fn new() -> Self {
412            Self {
413                groups: HashMap::new(),
414            }
415        }
416
417        #[allow(dead_code)] // This method is used in tests that might be temporarily commented out
418        fn add_group(&mut self, group: LogGroup) {
419            self.groups.insert(group.id, group);
420        }
421    }
422
423    impl Drain for MockDrain {
424        fn process_line(&mut self, _line: String) -> anyhow::Result<bool> {
425            // Not used in these LogStore tests
426            Ok(false)
427        }
428
429        fn collect_log_groups(&self) -> Vec<LogGroup> {
430            self.groups.values().cloned().collect()
431        }
432    }
433
434    #[test]
435    fn test_log_store_new() {
436        let mock_drain = MockDrain::new();
437        let store = LogStore::new(mock_drain);
438        // Basic check: new store with an empty drain should yield no groups.
439        // Further checks depend on how LogStore interacts with Drain,
440        // e.g., if it immediately collects groups or does so on demand.
441        // For now, just ensuring it can be created.
442        assert!(
443            store
444                .get_log_groups_in_range(Utc::now(), Utc::now())
445                .is_empty(),
446            "New LogStore with empty drain should have no groups in range"
447        );
448    }
449
450    #[test]
451    fn test_log_store_get_by_id() {
452        let record1 = Record::new("Record 1 for get_by_id".to_string());
453        let group1 = LogGroup::new(record1);
454        let id1 = group1.id;
455
456        let mut mock_drain = MockDrain::new();
457        mock_drain.add_group(group1.clone()); // Clone group1 as it's used later
458        let store = LogStore::new(mock_drain);
459
460        let found_group = store.get_log_group_by_id(id1);
461        assert!(found_group.is_some(), "Should find existing group by ID");
462        assert_eq!(found_group.unwrap().id, id1);
463
464        let non_existent_id = Uuid::new_v4();
465        let not_found_group = store.get_log_group_by_id(non_existent_id);
466        assert!(
467            not_found_group.is_none(),
468            "Should return None for non-existent group ID"
469        );
470    }
471
472    #[test]
473    fn test_log_store_get_in_range() {
474        let mut mock_drain = MockDrain::new();
475        let base_time = Utc::now();
476
477        let r1 = Record::new("g1".to_string());
478        let g1 = LogGroup::new(r1);
479        let time1 = g1.get_time();
480        mock_drain.add_group(g1.clone());
481        sleep(StdDuration::from_millis(100)); // Ensure timestamps are distinct
482
483        let r2 = Record::new("g2".to_string());
484        let g2 = LogGroup::new(r2);
485        let time2 = g2.get_time();
486        mock_drain.add_group(g2.clone());
487        sleep(StdDuration::from_millis(100));
488
489        let r3 = Record::new("g3".to_string());
490        let g3 = LogGroup::new(r3);
491        let time3 = g3.get_time();
492        mock_drain.add_group(g3.clone());
493
494        let store = LogStore::new(mock_drain);
495
496        assert!(time1 < time2, "time1 should be less than time2");
497        assert!(time2 < time3, "time2 should be less than time3");
498
499        let all_groups = store.get_log_groups_in_range(time1, time3);
500        assert_eq!(
501            all_groups.len(),
502            3,
503            "Should find all 3 groups. Found: {:?}",
504            all_groups.iter().map(|g| g.id).collect::<Vec<_>>()
505        );
506
507        let some_groups_middle = store.get_log_groups_in_range(
508            time1 + ChronoDuration::milliseconds(50),
509            time3 - ChronoDuration::milliseconds(50),
510        );
511        assert_eq!(some_groups_middle.len(), 1, "Should find 1 group (g2)");
512        assert_eq!(some_groups_middle[0].id, g2.id);
513
514        let some_groups_first_two = store.get_log_groups_in_range(time1, time2);
515        assert_eq!(
516            some_groups_first_two.len(),
517            2,
518            "Should find 2 groups (g1, g2)"
519        );
520
521        let no_groups_before = store.get_log_groups_in_range(
522            base_time - ChronoDuration::seconds(10),
523            base_time - ChronoDuration::seconds(5),
524        );
525        assert_eq!(
526            no_groups_before.len(),
527            0,
528            "Should find no groups (range before all)"
529        );
530
531        let no_groups_after = store.get_log_groups_in_range(
532            time3 + ChronoDuration::seconds(5),
533            time3 + ChronoDuration::seconds(10),
534        );
535        assert_eq!(
536            no_groups_after.len(),
537            0,
538            "Should find no groups (range after all)"
539        );
540
541        let exact_match_g2 = store.get_log_groups_in_range(time2, time2);
542        assert_eq!(
543            exact_match_g2.len(),
544            1,
545            "Should find g2 with exact time match"
546        );
547        assert_eq!(exact_match_g2[0].id, g2.id);
548
549        let edge_start = store.get_log_groups_in_range(time1, time1);
550        assert_eq!(
551            edge_start.len(),
552            1,
553            "Should find g1 when it's exactly on start_time"
554        );
555        assert_eq!(edge_start[0].id, g1.id);
556
557        let edge_end = store.get_log_groups_in_range(time3, time3);
558        assert_eq!(
559            edge_end.len(),
560            1,
561            "Should find g3 when it's exactly on end_time"
562        );
563        assert_eq!(edge_end[0].id, g3.id);
564    }
565
566    #[test]
567    fn test_query_log_range_aggregation() {
568        let mut mock_drain = MockDrain::new();
569        let record_template = Record::new("Log group for aggregation test".to_string());
570        let mut log_group = LogGroup::new(record_template);
571        let group_id = log_group.id;
572        let base_time = Utc::now();
573
574        let ex_r1 = Record::new("Example 1".to_string());
575        let time1 = get_record_timestamp(&ex_r1).unwrap();
576        log_group.add_example(ex_r1);
577        sleep(StdDuration::from_millis(50));
578
579        let ex_r2 = Record::new("Example 2".to_string());
580        let time2 = get_record_timestamp(&ex_r2).unwrap();
581        log_group.add_example(ex_r2);
582        sleep(StdDuration::from_millis(50));
583
584        let ex_r3 = Record::new("Example 3".to_string());
585        let time3 = get_record_timestamp(&ex_r3).unwrap();
586        log_group.add_example(ex_r3);
587
588        mock_drain.add_group(log_group);
589        let store = LogStore::new(mock_drain);
590
591        let non_existent_id = Uuid::new_v4();
592        let results_not_found = query_log_range_aggregation(
593            &store, // LogStore<MockDrain>
594            QuerySource::ById(non_existent_id),
595            base_time,  // DateTime<Utc>
596            Utc::now(), // DateTime<Utc>
597        );
598        assert!(
599            results_not_found.is_empty(),
600            "Should return empty for non-existent group ID"
601        );
602
603        let results_none_in_range = query_log_range_aggregation(
604            &store,
605            QuerySource::ById(group_id),
606            base_time - ChronoDuration::seconds(10),
607            base_time - ChronoDuration::seconds(5),
608        );
609        assert!(
610            results_none_in_range.is_empty(),
611            "Should return empty if no records in time range"
612        );
613
614        let results_some_in_range = query_log_range_aggregation(
615            &store,
616            QuerySource::ById(group_id),
617            time1 + ChronoDuration::milliseconds(25), // start_time
618            time3 - ChronoDuration::milliseconds(25), // end_time
619        );
620        assert_eq!(
621            results_some_in_range.len(),
622            1,
623            "Should find 1 record in the middle of the range"
624        );
625        assert_eq!(
626            get_record_timestamp(&results_some_in_range[0]).unwrap(), // Added &
627            time2
628        );
629
630        let results_all_in_range =
631            query_log_range_aggregation(&store, QuerySource::ById(group_id), time1, time3);
632        assert_eq!(
633            results_all_in_range.len(),
634            3,
635            "Should find all 3 records in the range"
636        );
637
638        let results_edge_start =
639            query_log_range_aggregation(&store, QuerySource::ById(group_id), time1, time1);
640        assert_eq!(
641            results_edge_start.len(),
642            1,
643            "Should find record 1 when it is exactly on start_time"
644        );
645        assert_eq!(get_record_timestamp(&results_edge_start[0]).unwrap(), time1); // Added &
646
647        let results_edge_end =
648            query_log_range_aggregation(&store, QuerySource::ById(group_id), time3, time3);
649        assert_eq!(
650            results_edge_end.len(),
651            1,
652            "Should find record 3 when it is exactly on end_time"
653        );
654        assert_eq!(get_record_timestamp(&results_edge_end[0]).unwrap(), time3); // Added &
655
656        let results_first_two =
657            query_log_range_aggregation(&store, QuerySource::ById(group_id), time1, time2);
658        assert_eq!(results_first_two.len(), 2, "Should find first two records");
659    }
660
661    // --- Property Tests ---
662    // Helper function to create LogStore<MockDrain> from Vec<LogGroup>
663    fn log_store_from_mock_groups(groups: Vec<LogGroup>) -> LogStore<MockDrain> {
664        let mut drain = MockDrain::new();
665        for group in groups {
666            drain.add_group(group);
667        }
668        LogStore::new(drain)
669    }
670
671    proptest! {
672        #[test]
673        fn prop_execute_logql_query(
674            (log_groups_vec, query) in arb_log_groups_and_query()
675        ) {
676            // Use the helper to create LogStore with MockDrain
677            let log_store = log_store_from_mock_groups(log_groups_vec.clone());
678            let results = execute_logql_query(&log_store, &query);
679
680            let selected_group_ids_set: HashSet<Uuid> = match &query.selector {
681                StreamSelector::LogGroupIds(ids) => ids.iter().cloned().collect(),
682            };
683
684            for record_in_result in &results {
685                let mut record_belongs_to_a_selected_group = false;
686                for group in &log_groups_vec { // Use log_groups_vec here
687                    if selected_group_ids_set.contains(&group.id) && (group.base_record().uid == record_in_result.uid || group.examples().iter().any(|ex| ex.uid == record_in_result.uid)) {
688                        record_belongs_to_a_selected_group = true;
689                        break;
690                    }
691                }
692                prop_assert!(record_belongs_to_a_selected_group, "Record {} from results (content: '{}') does not belong to any selected group. Selected groups: {:?}", record_in_result.uid, record_in_result.to_string(), selected_group_ids_set);
693
694                if let Some(filter) = &query.filter {
695                    prop_assert!(record_in_result.to_string().contains(&filter.contains),
696                                 "Record {} from results (content: '{}') does not match filter '{}'", record_in_result.uid, record_in_result.to_string(), filter.contains);
697                }
698            }
699
700            for group in &log_groups_vec { // Use log_groups_vec here
701                if selected_group_ids_set.contains(&group.id) {
702                    let records_to_check = group.examples().clone();
703                    for original_record in records_to_check {
704                        let matches_filter = query.filter.as_ref().is_none_or(|f| original_record.to_string().contains(&f.contains));
705                        if matches_filter {
706                            prop_assert!(results.iter().any(|res_rec| res_rec.uid == original_record.uid),
707                                         "Original record {} (content: '{}') from group {} matches filter but not found in results. Filter: {:?}", original_record.uid, original_record.to_string(), group.id, query.filter.as_ref().map(|f| &f.contains));
708                        } else {
709                            prop_assert!(!results.iter().any(|res_rec| res_rec.uid == original_record.uid),
710                                         "Original record {} (content: '{}') from group {} does NOT match filter but IS found in results. Filter: {:?}", original_record.uid, original_record.to_string(), group.id, query.filter.as_ref().map(|f| &f.contains));
711                        }
712                    }
713                }
714            }
715        }
716    }
717
718    proptest! {
719        #[test]
720        fn prop_query_log_range_aggregation_with_logql(
721            (log_groups_vec, query, time1, time2) in arb_log_groups_query_and_times()
722        ) {
723            let log_store = log_store_from_mock_groups(log_groups_vec.clone()); // Use helper
724            let (start_time, end_time) = if time1 <= time2 { (time1, time2) } else { (time2, time1) };
725            let results = query_log_range_aggregation(&log_store, QuerySource::ByLogQl(query.clone()), start_time, end_time);
726
727            let selected_group_ids_set: HashSet<Uuid> = match &query.selector {
728                StreamSelector::LogGroupIds(ids) => ids.iter().cloned().collect(),
729            };
730
731            for record_in_result in &results {
732                let mut record_belongs_to_a_selected_group = false;
733                for group in &log_groups_vec { // Use log_groups_vec here
734                    if selected_group_ids_set.contains(&group.id) && (group.base_record().uid == record_in_result.uid || group.examples().iter().any(|ex| ex.uid == record_in_result.uid)) {
735                        record_belongs_to_a_selected_group = true;
736                        break;
737                    }
738                }
739                prop_assert!(record_belongs_to_a_selected_group, "Record {} from results (content: '{}') does not belong to selected group. Selected: {:?}", record_in_result.uid, record_in_result.to_string(), selected_group_ids_set);
740
741                if let Some(filter) = &query.filter {
742                    prop_assert!(record_in_result.to_string().contains(&filter.contains),
743                                 "Record {} (content: '{}') does not match filter '{}'", record_in_result.uid, record_in_result.to_string(), filter.contains);
744                }
745
746                let record_time = get_record_timestamp(record_in_result).expect("Record in results should have a timestamp");
747                prop_assert!(record_time >= start_time && record_time <= end_time,
748                             "Record time {:?} for {} (content: '{}') is outside range [{:?}, {:?}]", record_time, record_in_result.uid, record_in_result.to_string(), start_time, end_time);
749            }
750
751            for group in &log_groups_vec { // Use log_groups_vec here
752                if selected_group_ids_set.contains(&group.id) {
753                    let mut records_to_check = group.examples().clone();
754                    records_to_check.push(group.base_record().clone());
755
756                    for original_record in records_to_check {
757                        let matches_filter = query.filter.as_ref().is_none_or(|f| original_record.to_string().contains(&f.contains));
758                        let record_time_option = get_record_timestamp(&original_record);
759                        prop_assert!(record_time_option.is_some(), "Original record {} (content: '{}') must have a timestamp for time range check.", original_record.uid, original_record.to_string());
760                        let record_time = record_time_option.unwrap();
761                        let matches_time = record_time >= start_time && record_time <= end_time;
762
763                        if matches_filter && matches_time {
764                            prop_assert!(results.iter().any(|res_rec| res_rec.uid == original_record.uid),
765                                         "Original record {} (content: '{}', time: {:?}) from group {} matches filter and time but not found in results. Query: {:?}, Filter: {:?}, Time Range: [{:?}, {:?}]",
766                                         original_record.uid, original_record.to_string(), record_time, group.id, query.selector, query.filter.as_ref().map(|f| &f.contains), start_time, end_time);
767                        } else {
768                             prop_assert!(!results.iter().any(|res_rec| res_rec.uid == original_record.uid),
769                                         "Original record {} (content: '{}', time: {:?}) from group {} (matches_filter: {}, matches_time: {}) found in results but should not be. Query: {:?}, Filter: {:?}, Time Range: [{:?}, {:?}]",
770                                         original_record.uid, original_record.to_string(), record_time, group.id, matches_filter, matches_time, query.selector, query.filter.as_ref().map(|f| &f.contains), start_time, end_time);
771                        }
772                    }
773                }
774            }
775        }
776    }
777}