Skip to main content

nautilus_persistence/backend/parquet/catalog/
query.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Parquet catalog query paths and typed query wrappers.
17
18#![expect(
19    clippy::missing_errors_doc,
20    clippy::used_underscore_binding,
21    reason = "query methods forward DataFusion errors and underscore fields mirror SQL aliases"
22)]
23
24use nautilus_model::instruments::NautilusInstrumentType;
25use nautilus_serialization::arrow::{
26    catalog_identifier_from_metadata, instrument::decode_instrument_any_batch,
27    record_batch_with_identifier_column,
28};
29
30use super::{
31    ArrowSchemaProvider, Bar, CustomDataDecoder, Data, DecodeDataFromRecordBatch,
32    DecodeTypedFromRecordBatch, FundingRateUpdate, HasCatalogDataType, HasTsInit, HashMap,
33    INSTRUMENT_PATH_PREFIXES, InstrumentAny, InstrumentClose, NautilusDataType, OptionGreeks,
34    OrderBookDelta, OrderBookDepth, ParquetDataCatalog, Path, QuoteTick, RecordBatch, TradeTick,
35    UnixNanos, build_query, catalog_record_batch_to_display, datafusion,
36    decode_object_store_segment, extract_bar_type_instrument_id, extract_identifier_from_path,
37    extract_sql_safe_filename, filter_instruments_for_request_range, instrument_path_prefix,
38    is_monotonically_increasing_by_init, make_object_store_path, make_sql_safe_identifier,
39    parquet_data_path_prefix, parse_filename_timestamps, query_intersects_filename,
40    read_parquet_from_object_store, read_parquet_schema_from_object_store,
41    session::{MergedPages, TypedPages, decode_typed_pages},
42    urisafe_instrument_id,
43};
44use crate::{
45    catalog::types::{
46        CatalogDataType, custom_data_read_prefixes, custom_type_name,
47        parquet_catalog_data_type_path_prefixes, parquet_catalog_data_type_table_stem,
48    },
49    common::arrow::{empty_display_batch_with_identifier, validate_catalog_schema},
50};
51
52impl ParquetDataCatalog {
53    /// Queries one data family through the existing row iterator API.
54    pub fn query<T>(
55        &mut self,
56        identifiers: Option<Vec<String>>,
57        start: Option<UnixNanos>,
58        end: Option<UnixNanos>,
59        where_clause: Option<&str>,
60        files: Option<Vec<String>>,
61        optimize_file_loading: bool,
62    ) -> anyhow::Result<crate::backend::session::QueryResult>
63    where
64        T: DecodeTypedFromRecordBatch
65            + HasCatalogDataType
66            + HasTsInit
67            + Into<Data>
68            + Send
69            + 'static,
70    {
71        self.query_typed_pages::<T>(
72            identifiers,
73            start,
74            end,
75            where_clause,
76            files,
77            optimize_file_loading,
78        )
79        .map(crate::backend::session::QueryResult::from_typed_pages)
80    }
81
82    /// Queries instruments from the catalog.
83    ///
84    /// Instruments are stored under v1-compatible concrete instrument type folders:
85    /// `data/{instrument_type}/{instrument_id}/`.
86    ///
87    /// # Parameters
88    ///
89    /// - `instrument_ids`: Optional list of instrument IDs to filter by. If `None`, queries all instruments.
90    ///
91    /// # Returns
92    ///
93    /// Returns a vector of `InstrumentAny` instances, or an error if the operation fails.
94    ///
95    /// # Errors
96    ///
97    /// Returns an error if:
98    /// - File discovery fails.
99    /// - File reading fails.
100    /// - Data deserialization fails.
101    ///
102    /// # Examples
103    ///
104    /// ```rust,no_run
105    /// use nautilus_model::instruments::InstrumentAny;
106    /// use nautilus_persistence::backend::parquet::catalog::ParquetDataCatalog;
107    ///
108    /// let mut catalog = ParquetDataCatalog::new(
109    ///     std::path::Path::new("/tmp/nautilus_data"),
110    ///     None,
111    ///     None,
112    ///     None,
113    ///     None,
114    /// );
115    ///
116    /// // Query all instruments
117    /// let instruments = catalog.query_instruments(None)?;
118    ///
119    /// // Query specific instruments
120    /// let instrument_ids = vec!["EUR/USD.SIM".to_string()];
121    /// let instruments = catalog.query_instruments(Some(&instrument_ids))?;
122    /// # Ok::<(), anyhow::Error>(())
123    /// ```
124    pub fn query_instruments(
125        &self,
126        instrument_ids: Option<&[String]>,
127    ) -> anyhow::Result<Vec<InstrumentAny>> {
128        self.query_instruments_filtered(instrument_ids, None, None)
129    }
130
131    /// Queries instruments from the catalog with optional timestamp filtering.
132    ///
133    /// This reads all matching parquet files under
134    /// `data/{instrument_type}/{instrument_id}/`, decodes the records back to
135    /// `InstrumentAny`, and filters them by `ts_init` when a range is provided.
136    pub fn query_instruments_filtered(
137        &self,
138        instrument_ids: Option<&[String]>,
139        _start: Option<UnixNanos>,
140        end: Option<UnixNanos>,
141    ) -> anyhow::Result<Vec<InstrumentAny>> {
142        let instrument_files = self.discover_instrument_files(instrument_ids, end, None)?;
143        self.decode_instrument_files(instrument_files, _start, end)
144    }
145
146    /// Queries instruments from the catalog with optional timestamp and SQL filtering.
147    ///
148    /// When `where_clause` is provided, the predicate is applied through DataFusion
149    /// before instrument records are decoded.
150    pub fn query_instruments_filtered_with_where(
151        &mut self,
152        instrument_ids: Option<&[String]>,
153        start: Option<UnixNanos>,
154        end: Option<UnixNanos>,
155        where_clause: Option<&str>,
156    ) -> anyhow::Result<Vec<InstrumentAny>> {
157        self.query_instruments_filtered_with_where_and_type(
158            instrument_ids,
159            start,
160            end,
161            where_clause,
162            None,
163        )
164    }
165
166    pub fn query_instruments_filtered_with_where_and_type(
167        &mut self,
168        instrument_ids: Option<&[String]>,
169        start: Option<UnixNanos>,
170        end: Option<UnixNanos>,
171        where_clause: Option<&str>,
172        instrument_type: Option<&NautilusInstrumentType>,
173    ) -> anyhow::Result<Vec<InstrumentAny>> {
174        let Some(where_clause) = where_clause else {
175            let instrument_files =
176                self.discover_instrument_files(instrument_ids, end, instrument_type)?;
177            return self.decode_instrument_files(instrument_files, start, end);
178        };
179
180        self.session.clear_registered_tables();
181        self.register_remote_object_store()?;
182
183        let mut all_instruments = Vec::new();
184        let instrument_files =
185            self.discover_instrument_files(instrument_ids, end, instrument_type)?;
186
187        for (index, file_path) in instrument_files.into_iter().enumerate() {
188            let object_path = self.to_object_path_parsed(&file_path)?;
189            let (_, builder_schema) = self.execute_async(|| async {
190                read_parquet_from_object_store(self.object_store.clone(), &object_path).await
191            })?;
192            validate_catalog_schema(&builder_schema)?;
193            let metadata: std::collections::HashMap<String, String> =
194                builder_schema.metadata().clone();
195            let target_schema = InstrumentAny::get_schema(Some(metadata.clone()));
196
197            let table_name = format!(
198                "instruments_{}_{}",
199                index,
200                extract_sql_safe_filename(&file_path)
201            );
202            let query = build_query(&table_name, start, end, Some(where_clause));
203            let resolved_path = self.resolve_path_for_datafusion(&file_path);
204            let batches = self.session.collect_parquet_files_batches(
205                &table_name,
206                vec![resolved_path],
207                Some(&query),
208            )?;
209
210            for batch in batches {
211                let batch = datafusion::cast_record_batch_to_schema(&batch, &target_schema)?;
212                all_instruments.extend(decode_instrument_any_batch(&metadata, &batch)?);
213            }
214        }
215
216        Ok(filter_instruments_for_request_range(
217            all_instruments,
218            start,
219            end,
220        ))
221    }
222
223    /// Discovers instrument parquet files under `data/{instrument_type}/{instrument_id}/`,
224    /// filtered by instrument IDs and an optional `end` timestamp, sorted by path.
225    fn discover_instrument_files(
226        &self,
227        instrument_ids: Option<&[String]>,
228        end: Option<UnixNanos>,
229        instrument_type: Option<&NautilusInstrumentType>,
230    ) -> anyhow::Result<Vec<String>> {
231        let base_dir = make_object_store_path(&self.base_path, ["data"]);
232        let end_u64 = end.map(|ts| ts.as_u64());
233        let list_result = self.list_objects(&base_dir)?;
234
235        let mut instrument_files = Vec::new();
236
237        for object in list_result {
238            let path_str = object.location.to_string();
239            if !path_str.ends_with(".parquet") {
240                continue;
241            }
242
243            let path_parts: Vec<&str> = path_str.split('/').collect();
244            let Some(data_index) = path_parts.iter().position(|part| *part == "data") else {
245                continue;
246            };
247            let Some(type_dir) = path_parts.get(data_index + 1) else {
248                continue;
249            };
250
251            let type_dir = decode_object_store_segment(type_dir);
252            if !is_parquet_instrument_type_prefix(&type_dir)
253                || instrument_type.is_some_and(|value| instrument_path_prefix(value) != type_dir)
254            {
255                continue;
256            }
257
258            if path_parts.len() < data_index + 4 {
259                continue;
260            }
261
262            let instrument_id_dir = decode_object_store_segment(path_parts[path_parts.len() - 2]);
263
264            if let Some(ids) = instrument_ids
265                && !ids
266                    .iter()
267                    .map(|id| urisafe_instrument_id(id))
268                    .any(|x| x.as_str() == urisafe_instrument_id(&instrument_id_dir))
269            {
270                continue;
271            }
272
273            let include_file = if path_str.ends_with("/instrument.parquet") {
274                true
275            } else if let Some((file_start, _)) = parse_filename_timestamps(&path_str) {
276                end_u64.is_none_or(|end| file_start <= end)
277            } else {
278                // Include files with nonstandard names rather than silently dropping
279                // instruments written by external or older tooling.
280                log::warn!(
281                    "Including instrument file with unparsable interval filename: {path_str}"
282                );
283                true
284            };
285
286            if include_file {
287                instrument_files.push(path_str);
288            }
289        }
290
291        instrument_files.sort();
292        Ok(instrument_files)
293    }
294
295    fn decode_instrument_files(
296        &self,
297        instrument_files: Vec<String>,
298        start: Option<UnixNanos>,
299        end: Option<UnixNanos>,
300    ) -> anyhow::Result<Vec<InstrumentAny>> {
301        let mut instruments = Vec::new();
302
303        for file_path in instrument_files {
304            let object_path = self.to_object_path_parsed(&file_path)?;
305            let (batches, builder_schema) = self.execute_async(|| async {
306                read_parquet_from_object_store(self.object_store.clone(), &object_path).await
307            })?;
308            validate_catalog_schema(&builder_schema)?;
309            let metadata = builder_schema.metadata().clone();
310            let target_schema = InstrumentAny::get_schema(Some(metadata.clone()));
311
312            for batch in batches {
313                let batch = datafusion::cast_record_batch_to_schema(&batch, &target_schema)?;
314                instruments.extend(decode_instrument_any_batch(&metadata, &batch)?);
315            }
316        }
317
318        Ok(filter_instruments_for_request_range(
319            instruments,
320            start,
321            end,
322        ))
323    }
324
325    /// Queries typed data from the catalog and returns results as a strongly-typed vector.
326    ///
327    /// This is a convenience method that wraps the generic `query` method and automatically
328    /// collects and converts the results into a vector of the specific data type. It handles
329    /// the type conversion from the generic [`Data`] enum to the concrete type `T`.
330    ///
331    /// # Type Parameters
332    ///
333    /// - `T`: The specific data type to query and return. Must implement required traits for
334    ///   deserialization, cataloging, and conversion from the [`Data`] enum.
335    ///
336    /// # Parameters
337    ///
338    /// - `identifiers`: Optional list of identifiers to filter by. Can be `instrument_id` strings (e.g., "EUR/USD.SIM")
339    ///   or `bar_type` strings (e.g., "EUR/USD.SIM-1-MINUTE-LAST-EXTERNAL"). If `None`, queries all identifiers.
340    ///   For bars, partial matching is supported (e.g., "EUR/USD.SIM" will match "EUR/USD.SIM-1-MINUTE-LAST-EXTERNAL").
341    /// - `start`: Optional start timestamp for filtering (inclusive). If `None`, queries from the beginning.
342    /// - `end`: Optional end timestamp for filtering (inclusive). If `None`, queries to the end.
343    /// - `where_clause`: Optional SQL WHERE clause for additional filtering. Use standard SQL syntax
344    ///   with column names matching the Parquet schema (e.g., "`bid_price` > 1.2000", "volume > 1000").
345    ///
346    /// # Returns
347    ///
348    /// Returns a vector of the specific data type `T`, sorted by timestamp. The vector will be
349    /// empty if no data matches the query criteria.
350    ///
351    /// # Errors
352    ///
353    /// Returns an error if:
354    /// - The underlying query execution fails.
355    /// - Data type conversion fails.
356    /// - Object store access fails.
357    /// - Invalid WHERE clause syntax is provided.
358    ///
359    /// # Performance Considerations
360    ///
361    /// - Use specific instrument IDs and time ranges to minimize data scanning.
362    /// - WHERE clauses are pushed down to Parquet readers when possible.
363    /// - Results are automatically sorted by timestamp during collection.
364    /// - Memory usage scales with the amount of data returned.
365    ///
366    /// # Examples
367    ///
368    /// ```rust,no_run
369    /// use nautilus_core::UnixNanos;
370    /// use nautilus_model::data::{Bar, QuoteTick, TradeTick};
371    /// use nautilus_persistence::backend::parquet::catalog::ParquetDataCatalog;
372    ///
373    /// let mut catalog = ParquetDataCatalog::new(
374    ///     std::path::Path::new("/tmp/nautilus_data"),
375    ///     None,
376    ///     None,
377    ///     None,
378    ///     None,
379    /// );
380    ///
381    /// // Query all quotes for a specific instrument
382    /// let quotes: Vec<QuoteTick> = catalog.query_typed_data(
383    ///     Some(vec!["EUR/USD.SIM".to_string()]),
384    ///     None,
385    ///     None,
386    ///     None,
387    ///     None,
388    ///     true,
389    /// )?;
390    ///
391    /// // Query trades within a specific time range
392    /// let trades: Vec<TradeTick> = catalog.query_typed_data(
393    ///     Some(vec!["BTC/USD.SIM".to_string()]),
394    ///     Some(UnixNanos::from(1609459200000000000)),
395    ///     Some(UnixNanos::from(1609545600000000000)),
396    ///     None,
397    ///     None,
398    ///     true,
399    /// )?;
400    ///
401    /// // Query bars with volume filter (using instrument_id - partial match for bar_type)
402    /// let bars: Vec<Bar> = catalog.query_typed_data(
403    ///     Some(vec!["AAPL.NASDAQ".to_string()]),
404    ///     None,
405    ///     None,
406    ///     Some("volume > 1000000"),
407    ///     None,
408    ///     true,
409    /// )?;
410    ///
411    /// // Query bars with specific bar_type
412    /// let bars: Vec<Bar> = catalog.query_typed_data(
413    ///     Some(vec!["AAPL.NASDAQ-1-MINUTE-LAST-EXTERNAL".to_string()]),
414    ///     None,
415    ///     None,
416    ///     None,
417    ///     None,
418    ///     true,
419    /// )?;
420    ///
421    /// // Query multiple instruments with price filter
422    /// let quotes: Vec<QuoteTick> = catalog.query_typed_data(
423    ///     Some(vec!["EUR/USD.SIM".to_string(), "GBP/USD.SIM".to_string()]),
424    ///     None,
425    ///     None,
426    ///     Some("bid_price > 1.2000 AND ask_price < 1.3000"),
427    ///     None,
428    ///     true,
429    /// )?;
430    /// # Ok::<(), anyhow::Error>(())
431    /// ```
432    pub fn query_typed_data<T>(
433        &mut self,
434        identifiers: Option<Vec<String>>,
435        start: Option<UnixNanos>,
436        end: Option<UnixNanos>,
437        where_clause: Option<&str>,
438        files: Option<Vec<String>>,
439        optimize_file_loading: bool,
440    ) -> anyhow::Result<Vec<T>>
441    where
442        T: DecodeTypedFromRecordBatch + HasCatalogDataType + HasTsInit,
443    {
444        self.query_typed::<T>(
445            identifiers,
446            start,
447            end,
448            where_clause,
449            files,
450            optimize_file_loading,
451        )
452    }
453
454    pub(super) fn query_typed_pages<T>(
455        &mut self,
456        identifiers: Option<Vec<String>>,
457        start: Option<UnixNanos>,
458        end: Option<UnixNanos>,
459        where_clause: Option<&str>,
460        files: Option<Vec<String>>,
461        optimize_file_loading: bool,
462    ) -> anyhow::Result<TypedPages<T>>
463    where
464        T: DecodeTypedFromRecordBatch + HasCatalogDataType + HasTsInit + Send + 'static,
465    {
466        self.clear_session_tables();
467        self.register_remote_object_store()?;
468        let data_type = T::catalog_data_type();
469        let files = match files {
470            Some(files) => files,
471            None => self.query_files(&CatalogDataType::Data(data_type), identifiers, start, end)?,
472        };
473        let paths = if optimize_file_loading {
474            parent_directories(&files)
475                .into_iter()
476                .map(|directory| self.resolve_directory_for_datafusion(&directory))
477                .collect::<Vec<_>>()
478        } else {
479            files
480                .iter()
481                .map(|file| self.resolve_path_for_datafusion(file))
482                .collect()
483        };
484        let mut sources = Vec::with_capacity(paths.len());
485        for (index, path) in paths.into_iter().enumerate() {
486            let table = format!("parquet_{index}");
487            let sql = build_query(&table, start, end, where_clause);
488            let stream = self
489                .session
490                .parquet_files_batch_stream(&table, vec![path], Some(&sql))?;
491            let pages = decode_typed_pages::<T>(stream);
492            sources.push(
493                Box::new(datafusion::BlockingBatchStream::from_stream_with_runtime(
494                    pages,
495                    &self.session.runtime,
496                )) as TypedPages<T>,
497            );
498        }
499        Ok(Box::new(MergedPages::new(sources, self.batch_size)))
500    }
501
502    /// Queries typed records that are not represented by the [`Data`] enum.
503    pub fn query_typed<T>(
504        &mut self,
505        identifiers: Option<Vec<String>>,
506        start: Option<UnixNanos>,
507        end: Option<UnixNanos>,
508        where_clause: Option<&str>,
509        files: Option<Vec<String>>,
510        optimize_file_loading: bool,
511    ) -> anyhow::Result<Vec<T>>
512    where
513        T: DecodeTypedFromRecordBatch + HasCatalogDataType + HasTsInit,
514    {
515        self.clear_session_tables();
516
517        self.register_remote_object_store()?;
518
519        let data_type = T::catalog_data_type();
520        let path_prefix = parquet_data_path_prefix(&data_type);
521
522        let files_list = if let Some(files) = files {
523            files
524        } else {
525            self.query_files(
526                &CatalogDataType::Data(data_type.clone()),
527                identifiers,
528                start,
529                end,
530            )?
531        };
532
533        let mut all_records = Vec::new();
534
535        if optimize_file_loading {
536            for directory in parent_directories(&files_list) {
537                let identifier = dir_identifier(&directory);
538                let safe_sql_identifier = make_sql_safe_identifier(&identifier);
539                let table_name = format!("{}_{}", path_prefix.as_ref(), safe_sql_identifier);
540                let query = build_query(&table_name, start, end, where_clause);
541                let resolved_path = self.resolve_directory_for_datafusion(&directory);
542                let batches = self.session.collect_parquet_files_batches(
543                    &table_name,
544                    vec![resolved_path],
545                    Some(&query),
546                )?;
547
548                all_records.extend(self.convert_record_batches_to_typed::<T>(batches)?);
549            }
550        } else {
551            for file_uri in &files_list {
552                let identifier = extract_identifier_from_path(file_uri).ok_or_else(|| {
553                    anyhow::anyhow!("Cannot extract identifier from path '{file_uri}'")
554                })?;
555                let safe_sql_identifier = make_sql_safe_identifier(identifier);
556                let safe_filename = extract_sql_safe_filename(file_uri);
557                let table_name = format!(
558                    "{}_{}_{}",
559                    path_prefix.as_ref(),
560                    safe_sql_identifier,
561                    safe_filename
562                );
563                let query = build_query(&table_name, start, end, where_clause);
564                let resolved_path = self.resolve_path_for_datafusion(file_uri);
565                let batches = self.session.collect_parquet_files_batches(
566                    &table_name,
567                    vec![resolved_path],
568                    Some(&query),
569                )?;
570
571                all_records.extend(self.convert_record_batches_to_typed::<T>(batches)?);
572            }
573        }
574
575        if !is_monotonically_increasing_by_init(&all_records) {
576            all_records.sort_by_key(HasTsInit::ts_init);
577        }
578
579        Ok(all_records)
580    }
581
582    /// Queries raw catalog Arrow record batches for any supported record table.
583    ///
584    /// # Errors
585    ///
586    /// Returns an error if file discovery or DataFusion query execution fails.
587    pub fn query_record_batches(
588        &mut self,
589        data_type: &CatalogDataType,
590        identifier: Option<String>,
591        start: Option<UnixNanos>,
592        end: Option<UnixNanos>,
593        where_clause: Option<&str>,
594        optimize_file_loading: bool,
595    ) -> anyhow::Result<Vec<RecordBatch>> {
596        self.clear_session_tables();
597        self.register_remote_object_store()?;
598
599        let identifiers = identifier.map(|value| vec![value]);
600        let files_list = self.query_files(data_type, identifiers, start, end)?;
601        let mut record_batches = Vec::new();
602        let table_prefix =
603            make_sql_safe_identifier(&parquet_catalog_data_type_table_stem(data_type));
604
605        if optimize_file_loading {
606            // Deterministic registration order so equal-ts_init tie order is reproducible.
607            for (index, directory) in parent_directories(&files_list).into_iter().enumerate() {
608                let table_name = format!("{table_prefix}_{index}");
609                let query = build_query(&table_name, start, end, where_clause);
610                let resolved_path = self.resolve_directory_for_datafusion(&directory);
611                record_batches.extend(self.session.collect_parquet_files_batches(
612                    &table_name,
613                    vec![resolved_path],
614                    Some(&query),
615                )?);
616            }
617        } else {
618            for (index, file_uri) in files_list.iter().enumerate() {
619                let table_name = format!("{table_prefix}_{index}");
620                let query = build_query(&table_name, start, end, where_clause);
621                let resolved_path = self.resolve_path_for_datafusion(file_uri);
622                record_batches.extend(self.session.collect_parquet_files_batches(
623                    &table_name,
624                    vec![resolved_path],
625                    Some(&query),
626                )?);
627            }
628        }
629
630        Ok(record_batches)
631    }
632
633    /// Queries raw catalog batches and converts them to display-friendly Arrow batches.
634    ///
635    /// # Errors
636    ///
637    /// Returns an error if file discovery, DataFusion query execution, or catalog display
638    /// conversion fails.
639    pub fn query_display_record_batches(
640        &mut self,
641        data_type: &NautilusDataType,
642        identifiers: Option<Vec<String>>,
643        start: Option<UnixNanos>,
644        end: Option<UnixNanos>,
645        where_clause: Option<&str>,
646        optimize_file_loading: bool,
647    ) -> anyhow::Result<Vec<RecordBatch>> {
648        self.clear_session_tables();
649        self.register_remote_object_store()?;
650
651        let data_path_prefix = parquet_data_path_prefix(data_type);
652        let files_list = self.query_files(
653            &CatalogDataType::Data(data_type.clone()),
654            identifiers,
655            start,
656            end,
657        )?;
658        let mut display_batches = Vec::new();
659        let table_prefix = make_sql_safe_identifier(data_path_prefix.as_ref());
660
661        if optimize_file_loading {
662            // Deterministic registration order so equal-ts_init tie order is reproducible.
663            for (index, directory) in parent_directories(&files_list).into_iter().enumerate() {
664                let path_identifier = display_identifier(data_type, &directory);
665                let table_name = format!("{table_prefix}_{index}");
666                let query = build_query(&table_name, start, end, where_clause);
667                let resolved_path = self.resolve_directory_for_datafusion(&directory);
668                let batches = self.session.collect_parquet_files_batches(
669                    &table_name,
670                    vec![resolved_path],
671                    Some(&query),
672                )?;
673
674                for batch in batches {
675                    let identifier =
676                        display_batch_identifier(data_type, &batch, path_identifier.as_deref());
677                    let batch = record_batch_with_identifier_column(batch, identifier.as_deref())?;
678                    let metadata = batch.schema().metadata().clone();
679                    display_batches.push(catalog_record_batch_to_display(
680                        data_type, &metadata, &batch,
681                    )?);
682                }
683            }
684        } else {
685            for (index, file_uri) in files_list.iter().enumerate() {
686                let directory = Path::new(file_uri)
687                    .parent()
688                    .ok_or_else(|| anyhow::anyhow!("Cannot extract directory from '{file_uri}'"))?
689                    .to_string_lossy();
690                let path_identifier = display_identifier(data_type, &directory);
691                let table_name = format!("{table_prefix}_{index}");
692                let query = build_query(&table_name, start, end, where_clause);
693                let resolved_path = self.resolve_path_for_datafusion(file_uri);
694                let batches = self.session.collect_parquet_files_batches(
695                    &table_name,
696                    vec![resolved_path],
697                    Some(&query),
698                )?;
699
700                for batch in batches {
701                    let identifier =
702                        display_batch_identifier(data_type, &batch, path_identifier.as_deref());
703                    let batch = record_batch_with_identifier_column(batch, identifier.as_deref())?;
704                    let metadata = batch.schema().metadata().clone();
705                    display_batches.push(catalog_record_batch_to_display(
706                        data_type, &metadata, &batch,
707                    )?);
708                }
709            }
710        }
711
712        if display_batches.is_empty() {
713            display_batches.push(empty_display_batch_with_identifier(data_type)?);
714        }
715        Ok(display_batches)
716    }
717
718    /// Queries concrete catalog identifiers for matching data rows.
719    pub fn query_identifiers(
720        &mut self,
721        data_type: &CatalogDataType,
722        identifiers: Option<Vec<String>>,
723        start: Option<UnixNanos>,
724        end: Option<UnixNanos>,
725        where_clause: Option<&str>,
726        _optimize_file_loading: bool,
727    ) -> anyhow::Result<Vec<String>> {
728        self.clear_session_tables();
729        self.register_remote_object_store()?;
730
731        let files_list = self.query_files(data_type, identifiers, start, end)?;
732        let table_prefix =
733            make_sql_safe_identifier(&parquet_catalog_data_type_table_stem(data_type));
734        let mut identifiers = Vec::new();
735
736        for (index, directory) in parent_directories(&files_list).into_iter().enumerate() {
737            let identifier = dir_identifier(&directory);
738            let table_name = format!("{table_prefix}_{index}_identifier_check");
739            let query = format!(
740                "{} LIMIT 1",
741                build_query(&table_name, start, end, where_clause)
742            );
743            let resolved_path = self.resolve_directory_for_datafusion(&directory);
744            let batches = self.session.collect_parquet_files_batches(
745                &table_name,
746                vec![resolved_path],
747                Some(&query),
748            )?;
749
750            if batches.iter().any(|batch| batch.num_rows() != 0) {
751                identifiers.push(decode_object_store_segment(&identifier));
752            }
753        }
754
755        identifiers.sort();
756        identifiers.dedup();
757        Ok(identifiers)
758    }
759
760    /// Queries custom data dynamically by type name.
761    ///
762    /// This method allows querying custom data types without compile-time knowledge of the type.
763    /// It uses dynamic schema decoding based on the type name stored in metadata.
764    ///
765    /// # Parameters
766    ///
767    /// - `type_name`: The name of the custom data type to query.
768    /// - `identifiers`: Optional list of instrument identifiers to filter by.
769    /// - `start`: Optional start timestamp for filtering.
770    /// - `end`: Optional end timestamp for filtering.
771    /// - `where_clause`: Optional SQL WHERE clause for additional filtering.
772    /// - `files`: Optional list of specific files to query.
773    /// - `_optimize_file_loading`: Whether to optimize file loading (currently unused).
774    ///
775    /// # Returns
776    ///
777    /// Returns a vector of `Data` enum variants containing the custom data.
778    ///
779    /// # Errors
780    ///
781    /// Returns an error if:
782    /// - File discovery fails.
783    /// - Data decoding fails.
784    /// - Query execution fails.
785    #[expect(clippy::too_many_arguments)]
786    pub fn query_custom_data_dynamic(
787        &mut self,
788        type_name: &str,
789        identifiers: Option<&[String]>,
790        start: Option<UnixNanos>,
791        end: Option<UnixNanos>,
792        where_clause: Option<&str>,
793        files: Option<Vec<String>>,
794        _optimize_file_loading: bool,
795    ) -> anyhow::Result<Vec<Data>> {
796        self.clear_session_tables();
797
798        self.register_remote_object_store()?;
799
800        let files = if let Some(f) = files {
801            f.into_iter()
802                .map(|p| self.to_object_path(&p).map(|op| op.to_string()))
803                .collect::<anyhow::Result<Vec<_>>>()?
804        } else {
805            self.list_parquet_files_with_criteria(
806                &CatalogDataType::Data(NautilusDataType::Custom {
807                    type_name: type_name.to_string(),
808                }),
809                identifiers,
810                start,
811                end,
812            )?
813        };
814
815        if files.is_empty() {
816            return Ok(Vec::new());
817        }
818
819        // Use CustomDataDecoder for all custom data. Pass type_name so decode can look up
820        // the type when Parquet/DataFusion does not preserve schema metadata. Callers must
821        // ensure Rust custom types are registered via ensure_custom_data_registered::<T>().
822        let mut lookup_metadata = HashMap::new();
823        lookup_metadata.insert("type_name".to_string(), type_name.to_string());
824        let registered_schema = CustomDataDecoder::get_schema(Some(lookup_metadata.clone()));
825        registered_schema.field_with_name("ts_init").map_err(|_| {
826            anyhow::anyhow!(
827                "custom data type '{type_name}' is not registered with an Arrow schema containing ts_init; \
828                 call ensure_custom_data_registered::<T>() before querying"
829            )
830        })?;
831
832        let mut all_data = Vec::new();
833
834        for file in files {
835            let object_path = self.to_object_path_parsed(&file)?;
836            let mut decode_metadata = self.execute_async(|| async {
837                let schema =
838                    read_parquet_schema_from_object_store(self.object_store.clone(), &object_path)
839                        .await?;
840                validate_catalog_schema(&schema)?;
841                Ok::<HashMap<String, String>, anyhow::Error>(schema.metadata().clone())
842            })?;
843            decode_metadata.extend(lookup_metadata.clone());
844            let identifier = extract_identifier_from_path(&file)
845                .ok_or_else(|| anyhow::anyhow!("Cannot extract identifier from path '{file}'"))?;
846            // Distinguish canonical and legacy files sharing an identifier and
847            // filename, since DataFusion skips re-registering a table name.
848            let (data_cls, _) = self.extract_data_cls_and_identifier_from_path(&file)?;
849            let layout_tag = make_sql_safe_identifier(data_cls.as_deref().unwrap_or("custom"));
850            let safe_type_name = make_sql_safe_identifier(type_name);
851            let safe_sql_identifier = make_sql_safe_identifier(identifier);
852            let safe_filename = extract_sql_safe_filename(&file);
853            let table_name = format!(
854                "custom_{safe_type_name}_{layout_tag}_{safe_sql_identifier}_{safe_filename}"
855            );
856            let resolved_path = self.resolve_path_for_datafusion(&file);
857            let sql_query = build_query(&table_name, start, end, where_clause);
858
859            // Use schemaless registration so DataFusion preserves the parquet file's
860            // schema metadata (e.g. `bar_type`) on output batches, since the
861            // explicit-schema variant strips per-batch metadata that decoders rely on.
862            let batches = self.session.collect_parquet_files_batches(
863                &table_name,
864                vec![resolved_path],
865                Some(&sql_query),
866            )?;
867
868            for batch in batches {
869                all_data.extend(CustomDataDecoder::decode_data_batch(
870                    &decode_metadata,
871                    batch,
872                )?);
873            }
874        }
875        all_data.sort_by_key(HasTsInit::ts_init);
876        Ok(all_data)
877    }
878
879    /// Queries all Parquet files for a specific data type and optional instrument IDs.
880    ///
881    /// This method finds all Parquet files that match the specified criteria and returns
882    /// their full URIs. The files are filtered by data type, instrument IDs (if provided),
883    /// and timestamp range (if provided).
884    ///
885    /// # Parameters
886    ///
887    /// - `data_type`: The stored family to read.
888    /// - `identifiers`: Optional list of identifiers to filter by. Can be `instrument_id` strings
889    ///   (e.g., "EUR/USD.SIM") or `bar_type` strings (e.g., "EUR/USD.SIM-1-MINUTE-LAST-EXTERNAL").
890    ///   For bars, partial matching is supported.
891    /// - `start`: Optional start timestamp to filter files by their time range.
892    /// - `end`: Optional end timestamp to filter files by their time range.
893    ///
894    /// # Returns
895    ///
896    /// Returns a vector of file URI strings that match the query criteria,
897    /// or an error if the query fails.
898    ///
899    /// # Errors
900    ///
901    /// Returns an error if:
902    /// - The directory path cannot be constructed.
903    /// - Object store listing operations fail.
904    /// - URI reconstruction fails.
905    ///
906    /// # Examples
907    ///
908    /// ```rust,no_run
909    /// use nautilus_core::UnixNanos;
910    /// use nautilus_model::data::NautilusDataType;
911    /// use nautilus_persistence::backend::parquet::catalog::ParquetDataCatalog;
912    ///
913    /// let mut catalog = ParquetDataCatalog::new(
914    ///     std::path::Path::new("/tmp/nautilus_data"),
915    ///     None,
916    ///     None,
917    ///     None,
918    ///     None,
919    /// );
920    ///
921    /// // Query all quote files
922    /// let files = catalog.query_files(&NautilusDataType::QuoteTick.into(), None, None, None)?;
923    ///
924    /// // Query trade files for specific instruments within a time range
925    /// let files = catalog.query_files(
926    ///     &NautilusDataType::TradeTick.into(),
927    ///     Some(vec!["BTC/USD.SIM".to_string(), "ETH/USD.SIM".to_string()]),
928    ///     Some(UnixNanos::from(1609459200000000000)),
929    ///     Some(UnixNanos::from(1609545600000000000)),
930    /// )?;
931    /// # Ok::<(), anyhow::Error>(())
932    /// ```
933    pub fn query_files(
934        &self,
935        data_type: &CatalogDataType,
936        identifiers: Option<Vec<String>>,
937        start: Option<UnixNanos>,
938        end: Option<UnixNanos>,
939    ) -> anyhow::Result<Vec<String>> {
940        // Take the identifiers once so every prefix shares them without cloning per directory.
941        let identifiers = identifiers.map(Vec::into_boxed_slice);
942
943        if let Some(type_name) = custom_type_name(data_type) {
944            let mut files = Vec::new();
945            for prefix in custom_data_read_prefixes(type_name) {
946                files.extend(self.query_prefix_files(
947                    prefix.as_ref(),
948                    identifiers.as_deref(),
949                    start,
950                    end,
951                )?);
952            }
953
954            files.sort();
955            files.dedup();
956            return Ok(files);
957        }
958
959        let mut files = Vec::new();
960        for data_cls in parquet_catalog_data_type_path_prefixes(data_type) {
961            files.extend(self.query_prefix_files(
962                data_cls.as_ref(),
963                identifiers.as_deref(),
964                start,
965                end,
966            )?);
967        }
968        files.sort();
969
970        Ok(files)
971    }
972
973    fn query_prefix_files(
974        &self,
975        data_cls: &str,
976        identifiers: Option<&[String]>,
977        start: Option<UnixNanos>,
978        end: Option<UnixNanos>,
979    ) -> anyhow::Result<Vec<String>> {
980        let mut files = Vec::new();
981
982        let start_u64 = start.map(|s| s.as_u64());
983        let end_u64 = end.map(|e| e.as_u64());
984
985        let base_dir = self.make_path(data_cls, None)?;
986
987        // Use recursive listing to match Python's glob behavior
988        let list_result = self.list_objects(&base_dir)?;
989
990        let mut file_paths: Vec<String> = list_result
991            .into_iter()
992            .filter_map(|object| {
993                let path_str = object.location.to_string();
994                if path_str.ends_with(".parquet") {
995                    Some(path_str)
996                } else {
997                    None
998                }
999            })
1000            .collect();
1001
1002        // Apply identifier filtering if provided
1003        if let Some(identifiers) = identifiers {
1004            let safe_identifiers: Vec<String> = identifiers
1005                .iter()
1006                .map(|id| urisafe_instrument_id(id))
1007                .collect();
1008
1009            // Exact match by default for instrument_ids or bar_types
1010            let exact_match_file_paths: Vec<String> = file_paths
1011                .iter()
1012                .filter(|file_path| {
1013                    // Extract the directory name (second to last path component)
1014                    let path_parts: Vec<&str> = file_path.split('/').collect();
1015                    if path_parts.len() >= 2 {
1016                        let dir_name =
1017                            decode_object_store_segment(path_parts[path_parts.len() - 2]);
1018                        safe_identifiers.iter().any(|safe_id| safe_id == &dir_name)
1019                    } else {
1020                        false
1021                    }
1022                })
1023                .cloned()
1024                .collect();
1025
1026            if exact_match_file_paths.is_empty() && is_parquet_bar_prefix(data_cls) {
1027                file_paths.retain(|file_path| {
1028                    let path_parts: Vec<&str> = file_path.split('/').collect();
1029                    if path_parts.len() >= 2 {
1030                        let dir_name =
1031                            decode_object_store_segment(path_parts[path_parts.len() - 2]);
1032
1033                        if let Some(bar_instrument_id) = extract_bar_type_instrument_id(&dir_name) {
1034                            safe_identifiers.iter().any(|id| id == bar_instrument_id)
1035                        } else {
1036                            false
1037                        }
1038                    } else {
1039                        false
1040                    }
1041                });
1042            } else {
1043                file_paths = exact_match_file_paths;
1044            }
1045        }
1046
1047        // Apply timestamp filtering
1048        file_paths.retain(|file_path| query_intersects_filename(file_path, start_u64, end_u64));
1049
1050        for file_path in file_paths {
1051            files.push(self.path_for_query_list(&file_path));
1052        }
1053
1054        Ok(files)
1055    }
1056
1057    pub fn quote_ticks(
1058        &mut self,
1059        instrument_ids: Option<Vec<String>>,
1060        start: Option<UnixNanos>,
1061        end: Option<UnixNanos>,
1062    ) -> anyhow::Result<Vec<QuoteTick>> {
1063        self.query_typed_data::<QuoteTick>(instrument_ids, start, end, None, None, true)
1064    }
1065
1066    /// Queries trade tick data for the specified instrument(s) and time range.
1067    pub fn trade_ticks(
1068        &mut self,
1069        instrument_ids: Option<Vec<String>>,
1070        start: Option<UnixNanos>,
1071        end: Option<UnixNanos>,
1072    ) -> anyhow::Result<Vec<TradeTick>> {
1073        self.query_typed_data::<TradeTick>(instrument_ids, start, end, None, None, true)
1074    }
1075
1076    /// Queries bar data for the specified instrument(s) and time range.
1077    pub fn bars(
1078        &mut self,
1079        instrument_ids: Option<Vec<String>>,
1080        start: Option<UnixNanos>,
1081        end: Option<UnixNanos>,
1082    ) -> anyhow::Result<Vec<Bar>> {
1083        self.query_typed_data::<Bar>(instrument_ids, start, end, None, None, true)
1084    }
1085
1086    /// Queries order book delta data for the specified instrument(s) and time range.
1087    pub fn order_book_deltas(
1088        &mut self,
1089        instrument_ids: Option<Vec<String>>,
1090        start: Option<UnixNanos>,
1091        end: Option<UnixNanos>,
1092    ) -> anyhow::Result<Vec<OrderBookDelta>> {
1093        self.query_typed_data::<OrderBookDelta>(instrument_ids, start, end, None, None, true)
1094    }
1095
1096    /// Queries order book depth data for the specified instrument(s) and time range.
1097    pub fn order_book_depths(
1098        &mut self,
1099        instrument_ids: Option<Vec<String>>,
1100        start: Option<UnixNanos>,
1101        end: Option<UnixNanos>,
1102    ) -> anyhow::Result<Vec<OrderBookDepth>> {
1103        self.query_typed_data::<OrderBookDepth>(instrument_ids, start, end, None, None, true)
1104    }
1105
1106    /// Queries funding rate updates for the specified instrument(s) and time range.
1107    pub fn funding_rates(
1108        &mut self,
1109        instrument_ids: Option<Vec<String>>,
1110        start: Option<UnixNanos>,
1111        end: Option<UnixNanos>,
1112    ) -> anyhow::Result<Vec<FundingRateUpdate>> {
1113        self.query_typed::<FundingRateUpdate>(instrument_ids, start, end, None, None, true)
1114    }
1115
1116    /// Queries instrument close data for the specified instrument(s) and time range.
1117    pub fn instrument_closes(
1118        &mut self,
1119        instrument_ids: Option<Vec<String>>,
1120        start: Option<UnixNanos>,
1121        end: Option<UnixNanos>,
1122    ) -> anyhow::Result<Vec<InstrumentClose>> {
1123        self.query_typed_data::<InstrumentClose>(instrument_ids, start, end, None, None, true)
1124    }
1125
1126    /// Queries option greeks data for the specified instrument(s) and time range.
1127    pub fn option_greeks(
1128        &mut self,
1129        instrument_ids: Option<Vec<String>>,
1130        start: Option<UnixNanos>,
1131        end: Option<UnixNanos>,
1132    ) -> anyhow::Result<Vec<OptionGreeks>> {
1133        self.query_typed_data::<OptionGreeks>(instrument_ids, start, end, None, None, true)
1134    }
1135
1136    /// Queries any instrument data for the specified instrument(s) and time range.
1137    pub fn instruments(
1138        &self,
1139        instrument_ids: Option<&[String]>,
1140        start: Option<UnixNanos>,
1141        end: Option<UnixNanos>,
1142    ) -> anyhow::Result<Vec<InstrumentAny>> {
1143        self.query_instruments_filtered(instrument_ids, start, end)
1144    }
1145
1146    /// Retrieves a list of file paths for a given data type.
1147    ///
1148    /// This method constructs a path pattern to find all parquet files
1149    /// associated with the specified data type in the catalog's directory structure.
1150    ///
1151    /// # Parameters
1152    ///
1153    /// - `data_type`: The stored family to read.
1154    ///
1155    /// # Returns
1156    ///
1157    /// Returns a vector of file paths matching the data type, or an error if the operation fails.
1158    ///
1159    /// # Errors
1160    ///
1161    /// Returns an error if:
1162    /// - Object store listing operations fail.
1163    /// - Directory access is denied.
1164    ///
1165    /// # Examples
1166    ///
1167    /// ```rust,no_run
1168    /// use nautilus_model::data::NautilusDataType;
1169    /// use nautilus_persistence::backend::parquet::catalog::ParquetDataCatalog;
1170    ///
1171    /// let mut catalog = ParquetDataCatalog::new(
1172    ///     std::path::Path::new("/tmp/nautilus_data"),
1173    ///     None,
1174    ///     None,
1175    ///     None,
1176    ///     None,
1177    /// );
1178    /// let files = catalog.get_file_list_from_data_cls(&NautilusDataType::QuoteTick.into())?;
1179    ///
1180    /// for file in files {
1181    ///     println!("Found file: {}", file);
1182    /// }
1183    /// # Ok::<(), anyhow::Error>(())
1184    /// ```
1185    pub fn get_file_list_from_data_cls(
1186        &self,
1187        data_type: &CatalogDataType,
1188    ) -> anyhow::Result<Vec<String>> {
1189        if let Some(type_name) = custom_type_name(data_type) {
1190            let mut file_paths = Vec::new();
1191            for prefix in custom_data_read_prefixes(type_name) {
1192                file_paths.extend(self.prefix_file_list(prefix.as_ref())?);
1193            }
1194
1195            file_paths.sort();
1196            file_paths.dedup();
1197            return Ok(file_paths);
1198        }
1199
1200        let mut file_paths = Vec::new();
1201        for data_cls in parquet_catalog_data_type_path_prefixes(data_type) {
1202            file_paths.extend(self.prefix_file_list(data_cls.as_ref())?);
1203        }
1204
1205        Ok(file_paths)
1206    }
1207
1208    fn prefix_file_list(&self, data_cls: &str) -> anyhow::Result<Vec<String>> {
1209        let base_dir = self.make_path(data_cls, None)?;
1210
1211        let list_result = self.list_objects(&base_dir)?;
1212
1213        let file_paths: Vec<String> = list_result
1214            .into_iter()
1215            .filter_map(|object| {
1216                let path_str = object.location.to_string();
1217                if path_str.ends_with(".parquet") {
1218                    Some(path_str)
1219                } else {
1220                    None
1221                }
1222            })
1223            .collect();
1224
1225        Ok(file_paths)
1226    }
1227
1228    /// Filters a list of file paths based on identifiers and time range.
1229    ///
1230    /// This method filters the provided file paths by:
1231    /// 1. Matching identifiers (exact match for instruments, prefix match for bars)
1232    /// 2. Intersecting with the specified time range
1233    ///
1234    /// # Parameters
1235    ///
1236    /// - `data_type`: The stored family to read.
1237    /// - `file_paths`: List of file paths to filter.
1238    /// - `identifiers`: Optional list of identifiers to match against file paths.
1239    /// - `start`: Optional start timestamp for filtering.
1240    /// - `end`: Optional end timestamp for filtering.
1241    ///
1242    /// # Returns
1243    ///
1244    /// Returns a filtered vector of file paths that match the criteria.
1245    ///
1246    /// # Notes
1247    ///
1248    /// For Bar data types, if exact identifier matching fails, the function attempts
1249    /// partial matching by checking if the file's identifier starts with the provided identifier
1250    /// followed by a dash (to match bar type patterns).
1251    ///
1252    /// # Examples
1253    ///
1254    /// ```rust,no_run
1255    /// use nautilus_core::UnixNanos;
1256    /// use nautilus_model::data::NautilusDataType;
1257    /// use nautilus_persistence::backend::parquet::catalog::ParquetDataCatalog;
1258    ///
1259    /// let mut catalog = ParquetDataCatalog::new(
1260    ///     std::path::Path::new("/tmp/nautilus_data"),
1261    ///     None,
1262    ///     None,
1263    ///     None,
1264    ///     None,
1265    /// );
1266    /// let all_files = catalog.get_file_list_from_data_cls(&NautilusDataType::QuoteTick.into())?;
1267    ///
1268    /// let filtered = catalog.filter_files(
1269    ///     &NautilusDataType::QuoteTick.into(),
1270    ///     all_files,
1271    ///     Some(vec!["EUR/USD.SIM".to_string()]),
1272    ///     Some(UnixNanos::from(1609459200000000000)),
1273    ///     Some(UnixNanos::from(1609545600000000000)),
1274    /// )?;
1275    /// # Ok::<(), anyhow::Error>(())
1276    /// ```
1277    pub fn filter_files(
1278        &self,
1279        data_type: &CatalogDataType,
1280        file_paths: Vec<String>,
1281        identifiers: Option<Vec<String>>,
1282        start: Option<UnixNanos>,
1283        end: Option<UnixNanos>,
1284    ) -> anyhow::Result<Vec<String>> {
1285        let has_bar_prefix = parquet_catalog_data_type_path_prefixes(data_type)
1286            .iter()
1287            .any(|data_cls| is_parquet_bar_prefix(data_cls.as_ref()));
1288        let mut filtered_paths = file_paths;
1289
1290        // Apply identifier filtering if provided
1291        if let Some(identifiers) = identifiers {
1292            let safe_identifiers: Vec<String> = identifiers
1293                .iter()
1294                .map(|id| urisafe_instrument_id(id))
1295                .collect();
1296
1297            // Extract directory names from file paths
1298            let file_safe_identifiers: Vec<String> = filtered_paths
1299                .iter()
1300                .map(|file_path| {
1301                    let path_parts: Vec<&str> = file_path.split('/').collect();
1302                    if path_parts.len() >= 2 {
1303                        decode_object_store_segment(path_parts[path_parts.len() - 2])
1304                    } else {
1305                        String::new()
1306                    }
1307                })
1308                .collect();
1309
1310            // Exact match by default for instrument_ids or bar_types
1311            let exact_match_file_paths: Vec<String> = filtered_paths
1312                .iter()
1313                .enumerate()
1314                .filter_map(|(i, file_path)| {
1315                    let dir_name = &file_safe_identifiers[i];
1316                    if safe_identifiers.iter().any(|safe_id| safe_id == dir_name) {
1317                        Some(file_path.clone())
1318                    } else {
1319                        None
1320                    }
1321                })
1322                .collect();
1323
1324            if exact_match_file_paths.is_empty() && has_bar_prefix {
1325                // Partial match of instrument_ids in bar_types for bars
1326                filtered_paths.retain(|file_path| {
1327                    let path_parts: Vec<&str> = file_path.split('/').collect();
1328                    if path_parts.len() >= 2 {
1329                        let dir_name =
1330                            decode_object_store_segment(path_parts[path_parts.len() - 2]);
1331                        safe_identifiers
1332                            .iter()
1333                            .any(|safe_id| dir_name.starts_with(&format!("{safe_id}-")))
1334                    } else {
1335                        false
1336                    }
1337                });
1338            } else {
1339                filtered_paths = exact_match_file_paths;
1340            }
1341        }
1342
1343        // Apply timestamp filtering
1344        let start_u64 = start.map(|s| s.as_u64());
1345        let end_u64 = end.map(|e| e.as_u64());
1346        filtered_paths.retain(|file_path| query_intersects_filename(file_path, start_u64, end_u64));
1347
1348        Ok(filtered_paths)
1349    }
1350}
1351
1352fn is_parquet_instrument_type_prefix(prefix: &str) -> bool {
1353    INSTRUMENT_PATH_PREFIXES.contains(&prefix)
1354}
1355
1356pub(super) fn is_parquet_bar_prefix(data_cls: &str) -> bool {
1357    data_cls == parquet_data_path_prefix(&NautilusDataType::Bar).as_ref()
1358}
1359
1360/// Returns the sorted, deduplicated parent directories (everything except the filename)
1361/// of the given file URIs.
1362fn parent_directories(files: &[String]) -> Vec<String> {
1363    let mut directories: Vec<String> = files
1364        .iter()
1365        .filter_map(|file_uri| {
1366            Path::new(file_uri)
1367                .parent()
1368                .map(|path| path.to_string_lossy().to_string())
1369        })
1370        .collect();
1371    directories.sort();
1372    directories.dedup();
1373    directories
1374}
1375
1376/// Extracts the identifier from a directory path (last component).
1377fn dir_identifier(directory: &str) -> String {
1378    directory
1379        .rsplit('/')
1380        .next()
1381        .unwrap_or("unknown")
1382        .to_string()
1383}
1384
1385fn display_identifier(data_type: &NautilusDataType, directory: &str) -> Option<String> {
1386    let identifier = dir_identifier(directory);
1387    let is_unpartitioned_custom = matches!(data_type, NautilusDataType::Custom { .. })
1388        && Path::new(directory)
1389            .ends_with(Path::new("data").join(parquet_data_path_prefix(data_type).as_ref()));
1390
1391    (!is_unpartitioned_custom).then(|| decode_object_store_segment(&identifier))
1392}
1393
1394fn display_batch_identifier(
1395    data_type: &NautilusDataType,
1396    batch: &RecordBatch,
1397    path_identifier: Option<&str>,
1398) -> Option<String> {
1399    if matches!(data_type, NautilusDataType::Custom { .. }) {
1400        path_identifier.map(str::to_string)
1401    } else {
1402        catalog_identifier_from_metadata(batch.schema().metadata())
1403            .or_else(|| path_identifier.map(str::to_string))
1404    }
1405}