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