polars_view/data_filter.rs
1use crate::{
2 Arguments, DEFAULT_OVERRIDE_REGEX, DEFAULT_QUERY, FileExtension, PathExtension,
3 PolarsViewError, PolarsViewResult, UniqueElements, sql_commands,
4};
5use egui::{
6 Align, CollapsingHeader, Color32, DragValue, Frame, Grid, Layout, Stroke, TextEdit, Ui, Vec2,
7};
8use polars::{io::RowIndex, prelude::*};
9use regex::Regex;
10use tokio::task::spawn_blocking;
11
12use std::{
13 fmt::Debug,
14 fs::File,
15 num::NonZero,
16 path::{Path, PathBuf},
17 sync::Arc,
18};
19
20// --- Constants ---
21
22/// Static string listing common values treated as null/missing during CSV parsing.
23/// The `r#""#` syntax denotes a raw string literal, avoiding the need to escape quotes.
24pub static NULL_VALUES: &str = r#""", <N/D>"#;
25
26/// Default delimiter used for CSV parsing if not specified or detected.
27/// Using `&'static str` for common, immutable delimiters saves memory allocation.
28pub static DEFAULT_CSV_DELIMITER: &str = ";";
29
30/// Default name for the row number column if added.
31pub const DEFAULT_INDEX_COLUMN_NAME: &str = "Row Number";
32
33/// Default regex
34const DEFAULT_NORM_REGEX: &str = "^Val.*$";
35
36/// Default drop regex
37const DEFAULT_DROP_REGEX: &str = "^Temp.*$";
38
39/// Default starting offset for the row index column (e.g., 1 for 1-based).
40const DEFAULT_INDEX_COLUMN_OFFSET: u32 = 1;
41
42const DEFAULT_INFER_SCHEMA_ROWS: usize = 200;
43
44// Prevent potential infinite loops (e.g., schema keeps changing).
45pub const MAX_ATTEMPTS: u32 = 1000;
46
47// --- DataFilter Struct ---
48
49/// Holds configuration parameters related to **loading and querying** data.
50///
51/// This struct focuses on settings that define how data is initially read from a file
52/// and transformed via SQL queries or basic processing like null column removal.
53///
54/// Instances are created from `Arguments`, updated by the UI in `render_query`, and passed
55/// to `DataFrameContainer::load_data`. Changes here typically trigger a data reload/requery.
56#[derive(Debug, Clone, PartialEq)] // PartialEq allows simple change detection
57pub struct DataFilter {
58 /// The canonical, absolute path to the data file.
59 pub absolute_path: PathBuf,
60 /// The name assigned to the loaded DataFrame for use in SQL queries.
61 pub table_name: String,
62 /// The character used to separate columns in a CSV file.
63 pub csv_delimiter: String,
64 /// Read data from file
65 pub read_data_from_file: bool,
66 /// The schema (column names and data types) of the most recently loaded DataFrame.
67 /// Used by `sql_commands` for generating relevant examples.
68 pub schema: Arc<Schema>,
69 /// Maximum rows to scan for schema inference (CSV, JSON, NDJson).
70 pub infer_schema_rows: usize,
71 /// Flag to control removal of all-null columns after loading/querying.
72 pub exclude_null_cols: bool,
73 /// Comma-separated string of values to interpret as nulls during CSV parsing.
74 pub null_values: String,
75
76 /// Regex patterns matching columns to force read as String type.
77 ///
78 /// List of column names to force reading as String, overriding inference.
79 /// Useful for columns with large IDs/keys that look numeric.
80 pub force_string_patterns: Option<String>,
81
82 /// Flag indicating if the `query` should be executed during the next `load_data`.
83 /// Set by `render_query` if relevant UI fields change or the Apply button is clicked.
84 pub apply_sql: bool,
85 /// The SQL query string entered by the user.
86 pub query: String,
87
88 // --- NEW FIELDS for Index Column ---
89 /// Flag indicating if a row index column should be added.
90 pub add_row_index: bool,
91 /// The desired name for the row index column (will be checked for uniqueness later).
92 pub index_column_name: String,
93 /// The starting value for the row index column (e.g., 0 or 1).
94 pub index_column_offset: u32,
95 // --- END NEW FIELDS ---
96
97 // --- Normalize Columns ---
98 /// Flag indicating whether string columns will be normalized.
99 pub normalize: bool,
100 /// Regex pattern to select string columns.
101 pub normalize_regex: String,
102
103 // --- Drop Columns ---
104 pub drop: bool,
105 pub drop_regex: String,
106}
107
108impl Default for DataFilter {
109 /// Creates default `DataFilter` with sensible initial values.
110 fn default() -> Self {
111 DataFilter {
112 absolute_path: PathBuf::new(),
113 table_name: "AllData".to_string(),
114 csv_delimiter: DEFAULT_CSV_DELIMITER.to_string(),
115 read_data_from_file: true,
116 schema: Schema::default().into(),
117 infer_schema_rows: DEFAULT_INFER_SCHEMA_ROWS,
118 exclude_null_cols: false,
119 null_values: NULL_VALUES.to_string(),
120
121 force_string_patterns: DEFAULT_OVERRIDE_REGEX.map(ToString::to_string),
122
123 apply_sql: false,
124 query: DEFAULT_QUERY.to_string(),
125
126 // --- NEW DEFAULTS ---
127 add_row_index: false, // Default to false
128 index_column_name: DEFAULT_INDEX_COLUMN_NAME.to_string(),
129 index_column_offset: DEFAULT_INDEX_COLUMN_OFFSET,
130 // --- END NEW DEFAULTS ---
131
132 // --- NEW FIELDS for Normalize Columns ---
133 normalize: false,
134 normalize_regex: DEFAULT_NORM_REGEX.to_string(),
135 // --- END NEW FIELDS ---
136 drop: false,
137 drop_regex: DEFAULT_DROP_REGEX.to_string(),
138 }
139 }
140}
141
142// --- Methods ---
143
144impl DataFilter {
145 /// Creates a new `DataFilter` instance configured from command-line `Arguments`.
146 /// This is typically called once at application startup in `main.rs`.
147 ///
148 /// ### Arguments
149 /// * `args`: Parsed command-line arguments (`crate::Arguments`).
150 ///
151 /// ### Returns
152 /// A `PolarsViewResult` containing the configured `DataFilter` or an error
153 /// (e.g., if the path cannot be canonicalized).
154 pub fn new(args: &Arguments) -> PolarsViewResult<Self> {
155 let absolute_path = match &args.path {
156 Some(p) => p.canonicalize()?, // Se houver path, tenta tornar absoluto
157 None => PathBuf::new(), // Se não houver, inicia vazio
158 };
159
160 // Determine apply_sql state from the CLI argument
161 let apply_sql = args.query.is_some();
162 let query = args
163 .query
164 .clone()
165 .unwrap_or_else(|| DEFAULT_QUERY.to_string()); // Use CLI arg or default
166
167 // Determine normalization state from the CLI argument
168 let normalize = args.regex.is_some();
169 let normalize_regex = args
170 .regex
171 .clone()
172 .unwrap_or_else(|| DEFAULT_NORM_REGEX.to_string()); // Use CLI arg or default
173
174 // Use or_else: takes a closure executed only if the first option is None.
175 // This avoids the .to_string() for the default unless actually needed.
176 let force_string_patterns = args
177 .force_string_patterns // This is Option<String>
178 .clone() // Clone the Option<String> from args if needed later, otherwise maybe take ownership
179 .or(DEFAULT_OVERRIDE_REGEX.map(ToString::to_string)); // Use CLI arg or default
180
181 Ok(DataFilter {
182 absolute_path,
183 table_name: args.table_name.clone(),
184 csv_delimiter: args.delimiter.clone(),
185
186 apply_sql, // Directly set based on CLI argument presence
187 query, // Directly set based on CLI argument value (or default)
188
189 exclude_null_cols: args.exclude_null_cols,
190 null_values: args.null_values.clone(), // Use user-provided nulls.
191
192 force_string_patterns,
193
194 normalize, // Directly set based on CLI argument presence
195 normalize_regex, // Directly set based on CLI argument value (or default)
196 ..Default::default() // Use defaults for `schema`, `infer_schema_rows`.
197 })
198 }
199
200 /// Sets the data source path, canonicalizing it.
201 pub fn set_path(&mut self, path: &Path) -> PolarsViewResult<()> {
202 self.absolute_path = path.canonicalize()?;
203 tracing::debug!("absolute_path set to: {:#?}", self.absolute_path);
204 Ok(())
205 }
206
207 /// Gets the file extension from `absolute_path` in lowercase.
208 pub fn get_extension(&self) -> Option<String> {
209 self.absolute_path.extension_as_lowercase()
210 }
211
212 /// Determines the configuration for an optional row index column by resolving a unique name
213 /// against the provided schema.
214 ///
215 /// If `self.add_row_index` is true, this method finds a unique name based on
216 /// `self.index_column_name` and the provided `schema`, returning a `Some(RowIndex)`.
217 /// If the name resolution fails, it returns the specific PolarsError.
218 /// If `self.add_row_index` is false, it returns `Ok(None)`.
219 ///
220 /// ### Arguments
221 /// * `schema`: The schema against which the index column name should be checked for uniqueness.
222 /// This should be the schema of the DataFrame *before* adding the index column.
223 ///
224 /// ### Returns
225 /// `PolarsResult<Option<RowIndex>>`: Ok(Some) if config is resolved, Ok(None) if disabled, Err if resolution fails.
226 pub fn get_row_index(&self, schema: &Schema) -> PolarsResult<Option<RowIndex>> {
227 // Check the main feature flag
228 if !self.add_row_index {
229 tracing::trace!("Row index addition disabled in filter.");
230 return Ok(None); // Feature disabled, return None config
231 }
232
233 // Feature is enabled. Resolve a unique name using the helper.
234 let unique_name = resolve_unique_column_name(
235 // Use the helper function
236 &self.index_column_name, // Base name
237 schema, // Schema to check uniqueness against
238 )?; // Propagate potential error from unique name resolution
239
240 // If we successfully resolved a unique name, return the full RowIndex config
241 let index_offset = self.index_column_offset;
242 Ok(Some(RowIndex {
243 name: unique_name,
244 offset: index_offset,
245 }))
246 }
247
248 /// Determines the `FileExtension` and orchestrates loading the DataFrame using the appropriate Polars reader.
249 /// This method centralizes the file-type-specific loading logic. Called by `DataFrameContainer::load_data`.
250 ///
251 /// **Important:** It mutates `self` by potentially updating `csv_delimiter` if automatic
252 /// detection during `read_csv_data` finds a different working delimiter than initially configured.
253 ///
254 /// ### Returns
255 /// A `PolarsViewResult` containing a tuple: `(DataFrame, FileExtension)` on success,
256 /// or a `PolarsViewError` (e.g., `FileType`, `CsvParsing`) on failure.
257 pub async fn get_df_and_extension(&mut self) -> PolarsViewResult<(DataFrame, FileExtension)> {
258 // Determine the file extension type using the helper from `extension.rs`.
259 let extension = FileExtension::from_path(&self.absolute_path);
260
261 // Match on the determined extension to call the correct reader function.
262 let (df, detected_delimiter) = match &extension {
263 FileExtension::Csv => self.read_csv_data().await?,
264 FileExtension::Json => self.read_json_data().await?,
265 FileExtension::NDJson => self.read_ndjson_data().await?,
266 FileExtension::Parquet => self.read_parquet_data().await?,
267 // Handle unsupported or missing extensions with specific errors.
268 FileExtension::Unknown(ext) => {
269 return Err(PolarsViewError::FileType(format!(
270 "Unsupported extension: `{}` for file: `{}`",
271 ext,
272 self.absolute_path.display()
273 )));
274 }
275 FileExtension::Missing => {
276 return Err(PolarsViewError::FileType(format!(
277 "Missing extension for file: `{}`",
278 self.absolute_path.display()
279 )));
280 }
281 };
282
283 // If reading a CSV successfully detected a working delimiter, update the filters state.
284 // This ensures the UI reflects the delimiter actually used.
285 if let Some(byte) = detected_delimiter {
286 self.csv_delimiter = (byte as char).to_string();
287 }
288
289 tracing::debug!(
290 "fn get_df_and_extension(): Successfully loaded DataFrame with extension: {:?}",
291 extension
292 );
293
294 Ok((df, extension)) // Return the loaded DataFrame and the detected extension.
295 }
296
297 // --- Data Reading Helper Methods ---
298
299 /// Reads a standard JSON file into a Polars DataFrame.
300 /// Configures the reader using settings from `self` (e.g., `infer_schema_rows`).
301 ///
302 /// ### Returns
303 /// A `PolarsViewResult` containing `(DataFrame, None)` (delimiter is not applicable to JSON).
304 async fn read_json_data(&self) -> PolarsViewResult<(DataFrame, Option<u8>)> {
305 tracing::debug!("Reading JSON data from: {}", self.absolute_path.display());
306 let file = File::open(&self.absolute_path)?;
307 let infer_schema_rows_for_task = self.infer_schema_rows;
308
309 // Execute the blocking read operation on a separate thread
310 let df = execute_polars_blocking(move || {
311 JsonReader::new(file)
312 .infer_schema_len(NonZero::new(infer_schema_rows_for_task))
313 .finish()
314 })
315 .await?;
316
317 tracing::debug!("JSON read complete. Shape: {:?}", df.shape());
318
319 Ok((df, None))
320 }
321
322 /// Reads a Newline-Delimited JSON (NDJson / JSON Lines) file into a Polars DataFrame.
323 /// Uses `LazyJsonLineReader` for potentially better performance/memory usage on large files.
324 ///
325 /// ### Returns
326 /// A `PolarsViewResult` containing `(DataFrame, None)`.
327 async fn read_ndjson_data(&self) -> PolarsViewResult<(DataFrame, Option<u8>)> {
328 tracing::debug!("Reading NDJSON data from: {}", self.absolute_path.display());
329
330 // Clone data from self needed for the task closure.
331 let pl_ref_path = PlRefPath::try_from_path(&self.absolute_path)?;
332 let infer_schema_rows_for_task = self.infer_schema_rows;
333
334 // *** Use the helper function ***
335 let df = execute_polars_blocking(move || {
336 // 'move' captures pl_ref_path, infer_schema_rows_for_task
337 // This code runs on the blocking thread.
338 let lazyframe = LazyJsonLineReader::new(pl_ref_path) // Use cloned path
339 .low_memory(false) // Option to optimize for memory.
340 .with_infer_schema_length(NonZero::new(infer_schema_rows_for_task))
341 .with_ignore_errors(true)
342 .finish()?; // Returns PolarsResult<LazyFrame> (this finish() isn't the main blocking part)
343
344 // Collect the lazy frame - THIS IS THE BLOCKING PART
345 lazyframe.with_streaming(true).collect() // Returns PolarsResult<DataFrame>
346 })
347 .await?; // await the helper function
348
349 tracing::debug!("NDJSON read complete. Shape: {:?}", df.shape());
350 Ok((df, None))
351 }
352
353 /// Reads an Apache Parquet file into a Polars DataFrame.
354 ///
355 /// ### Returns
356 /// A `PolarsViewResult` containing `(DataFrame, None)`.
357 async fn read_parquet_data(&self) -> PolarsViewResult<(DataFrame, Option<u8>)> {
358 tracing::debug!(
359 "Reading Parquet data from: {}",
360 self.absolute_path.display()
361 );
362
363 // Clone data from self needed for the task closure.
364 let pl_ref_path = PlRefPath::try_from_path(&self.absolute_path)?;
365 let args = ScanArgsParquet {
366 // ScanArgsParquet should be Send
367 low_memory: false, // Configure scan arguments as needed.
368 ..Default::default()
369 };
370
371 let df = execute_polars_blocking(move || {
372 // Use `LazyFrame::scan_parquet` for efficient scanning.
373 let lazyframe = LazyFrame::scan_parquet(pl_ref_path, args)?; // Returns PolarsResult<LazyFrame>
374
375 // Collect into an eager DataFrame - THIS IS THE BLOCKING/COMPUTE PART.
376 lazyframe.with_streaming(true).collect() // Returns PolarsResult<DataFrame>
377 })
378 .await?; // await the helper function
379
380 tracing::debug!("Parquet read complete. Shape: {:?}", df.shape());
381
382 Ok((df, None))
383 }
384
385 /// Reads a CSV file, attempting automatic delimiter detection if the initial one fails.
386 /// Iterates through common delimiters and tries reading a small chunk first for efficiency.
387 ///
388 /// ### Returns
389 /// A `PolarsViewResult` containing `(DataFrame, Option<u8>)` where `Option<u8>` is the
390 /// *successfully used* delimiter byte. Returns `Err(PolarsViewError::CsvParsing)` if
391 /// no common delimiter works.
392 async fn read_csv_data(&self) -> PolarsViewResult<(DataFrame, Option<u8>)> {
393 // Get the currently configured separator byte. Error if invalid (e.g., empty string).
394 let initial_separator = self.get_csv_separator()?;
395
396 // List of common delimiters to try, starting with the configured one.
397 let mut delimiters_to_try = vec![initial_separator, b',', b';', b'|', b'\t', b':'];
398 // Remove duplicates if the initial separator is already in the common list.
399 delimiters_to_try.unique();
400 tracing::debug!(
401 "Attempting CSV read. Delimiters to try: {:?}",
402 delimiters_to_try
403 .iter()
404 .map(|&b| b as char)
405 .collect::<Vec<_>>()
406 );
407
408 // Look at the next element of the iterator without consuming it.
409 let mut iterator = delimiters_to_try.iter().peekable();
410
411 // Iterate through the potential delimiters.
412 while let Some(&delimiter) = iterator.next() {
413 // If peek() returns None, it means the current item was the last one
414 let is_last_element = iterator.peek().is_none();
415
416 // 1. Quick Check: Try reading only a small number of rows (NROWS_CHECK).
417 // This fails fast if the delimiter is fundamentally wrong (e.g., results in 1 column).
418 if let Ok(schema) = self
419 .attempt_csv_parse_structure(delimiter, is_last_element)
420 .await
421 {
422 // 2. Full Read: If the quick check passed, attempt to read the entire file.
423 tracing::debug!(
424 "Trying to read full CSV file with delimiter: '{}'",
425 delimiter as char
426 );
427 match self.attempt_read_csv(delimiter, &schema).await {
428 Ok(lazyframe) => {
429 // Success! Return the DataFrame and the delimiter that worked.
430 tracing::info!(
431 "Successfully read CSV with delimiter: '{}'",
432 delimiter as char
433 );
434
435 // Execute the lazy plan and collect into an eager DataFrame on a blocking thread
436 let df = execute_polars_blocking(move || {
437 lazyframe.with_streaming(true).collect()
438 })
439 .await?;
440
441 tracing::debug!("Data collection complete. Shape: {:?}", df.shape());
442 return Ok((df, Some(delimiter)));
443 }
444 Err(e) => {
445 // Full read failed even after quick check passed. Log and try next delimiter.
446 tracing::warn!(
447 "Full CSV read failed with delimiter '{}' after quick check passed: {}",
448 delimiter as char,
449 e
450 );
451 continue; // Try the next delimiter.
452 }
453 }
454 }
455 // If quick check fails, implicitly try the next delimiter.
456 }
457
458 // If all delimiters failed, return a parsing error.
459 let msg = format!(
460 "Failed to read CSV '{}' with common delimiters. Check format or specify delimiter.",
461 self.absolute_path.display()
462 );
463 let error = PolarsViewError::CsvParsing(msg);
464 tracing::error!("{}", error);
465 Err(error)
466 }
467
468 /// Retrieves the CSV separator byte from the `csv_delimiter` String configuration.
469 ///
470 /// ### Returns
471 /// `Ok(u8)` containing the first byte, or `Err(PolarsViewError::InvalidDelimiter)`
472 /// if the string is empty or contains multi-byte characters (only first byte is used).
473 fn get_csv_separator(&self) -> PolarsViewResult<u8> {
474 self.csv_delimiter
475 .as_bytes() // Convert String to byte slice.
476 .first() // Get the first byte.
477 .copied() // Copy the byte out of the Option<&u8>.
478 // Map `None` (empty string) to an InvalidDelimiter error.
479 .ok_or_else(|| PolarsViewError::InvalidDelimiter(self.csv_delimiter.clone()))
480 }
481
482 /// Attempts to parse the CSV structure from the initial chunk of the file
483 /// using a specific delimiter and validates the result.
484 async fn attempt_csv_parse_structure(
485 &self,
486 delimiter: u8,
487 is_last_element: bool,
488 ) -> PolarsViewResult<Arc<Schema>> {
489 // Constant: Number of data rows to ask Polars to read *after* the header
490 // during this quick probe. 100 is a common heuristic to get enough
491 // context without reading the whole file.
492 const ROW_LIMIT: usize = 100;
493
494 tracing::debug!(
495 "Trying to parse CSV with delimiter: '{}'",
496 delimiter as char,
497 );
498
499 let file_path = &self.absolute_path;
500
501 // Perform a partial read from the file using the given delimiter.
502 let data_frame = read_csv_partial_from_path(delimiter, ROW_LIMIT, file_path).await?;
503
504 // **Basic Validation**: Check resulting width (important for delimiter detection loops)
505 // it's highly likely the delimiter was incorrect. Return an error early.
506 // This check is crucial for the delimiter detection loop in `read_csv_data`.
507 let min_expected_cols_on_success = if self.add_row_index { 2 } else { 1 }; // Assumes index is added *later*
508
509 if data_frame.width() <= min_expected_cols_on_success && !is_last_element {
510 tracing::warn!(
511 "CSV read with delimiter '{}' resulted in {} columns (expected > {}). Assuming incorrect delimiter.",
512 delimiter as char,
513 data_frame.width(),
514 min_expected_cols_on_success
515 );
516 // Return a specific error type or message indicating a likely delimiter issue.
517 return Err(PolarsViewError::CsvParsing(format!(
518 "Delimiter '{}' likely incorrect (resulted in {} columns)",
519 delimiter as char,
520 data_frame.width()
521 )));
522 }
523
524 tracing::debug!(
525 "CSV read successful with delimiter '{}'. Final shape (rows, columns): {:?}",
526 delimiter as char,
527 data_frame.shape()
528 );
529
530 Ok(data_frame.schema().clone())
531 }
532
533 async fn attempt_read_csv(
534 &self,
535 delimiter: u8,
536 previous_scheme: &Arc<Schema>,
537 ) -> PolarsViewResult<LazyFrame> {
538 tracing::debug!(
539 "Attempting CSV read with delimiter: '{}'",
540 delimiter as char,
541 );
542
543 /*
544 When LazyCsvReader tries to determine the data types (with_infer_schema_length()), it looks at the first N rows.
545 If your problematic column contains only digits in those initial rows,
546 Polars will likely infer a numeric type (like Int64, UInt64, or Float64).
547
548 However, a standard 64-bit integer or float cannot represent an arbitrarily long sequence of digits (like a 44-digit key).
549 When the reader later encounters these huge numbers and tries to parse them into the inferred numeric type, the parsing fails.
550 with_ignore_errors(true), instead of stopping, Polars replaces the unparseable value with null.
551 If all values in that column exceed the capacity of the inferred numeric type, the entire column becomes null, appearing "empty".
552
553 Solution:
554 .with_dtype_overwrite(dtypes_opt)
555 */
556
557 let mut dtypes_opt: Option<Arc<Schema>> = None;
558
559 if let Some(force_string_patterns) = &self.force_string_patterns {
560 // Build dtype overrides using the dedicated function
561 // Pass the actual headers and the configured regex patterns.
562 let override_schema = build_dtype_override_schema(
563 previous_scheme,
564 force_string_patterns, // Get patterns from self
565 )?; // Propagate potential regex compilation errors
566
567 // Convert the resulting Schema into Option<Arc<Schema>>.
568 if !override_schema.is_empty() {
569 dtypes_opt = Some(Arc::new(override_schema));
570 };
571 }
572
573 let pl_ref_path = PlRefPath::try_from_path(&self.absolute_path)?;
574
575 // Configure the LazyCsvReader using settings from `self`.
576 let lazyframe = LazyCsvReader::new(pl_ref_path)
577 .with_low_memory(false) // Can be set to true for lower memory usage at cost of speed.
578 .with_encoding(CsvEncoding::LossyUtf8) // Gracefully handle potential encoding errors.
579 .with_has_header(true) // Assume a header row.
580 .with_try_parse_dates(true) // Attempt automatic date parsing.
581 .with_separator(delimiter) // Use the specified delimiter.
582 .with_infer_schema_length(Some(self.infer_schema_rows)) // Use filter setting for inference.
583 .with_dtype_overwrite(dtypes_opt)
584 .with_ignore_errors(true) // Rows with parsing errors become nulls instead of stopping the read.
585 .with_missing_is_null(true) // Treat missing fields as null.
586 .with_null_values(None) // Apply fn replace_values_with_null()
587 .with_n_rows(None) // Apply row limit if specified.
588 .with_decimal_comma(false) // If files use ',' as decimal separator.
589 .with_row_index(None) // Apply fn add_row_index_column()
590 .with_rechunk(true) // Rechunk the memory to contiguous chunks when parsing is done.
591 .finish()?; // Finalize configuration and create the LazyFrame.
592
593 Ok(lazyframe)
594 }
595
596 /// Parses the comma-separated `null_values` string into a `Vec<&str>`,
597 /// removing surrounding double quotes if present.
598 ///
599 /// Logic:
600 /// 1. Splits the input string (`self.null_values`) by commas.
601 /// 2. Iterates through each resulting substring (`s`).
602 /// 3. For each substring:
603 /// a. Trims leading and trailing whitespace.
604 /// b. Checks if the `trimmed` string has at least 2 characters AND starts with `"` AND ends with `"`.
605 /// c. If true, returns a slice (`&str`) representing the content *between* the quotes.
606 /// Example: `"\"\""` becomes `""`, `" N/A "` becomes `"N/A"`, `" " "` becomes `" "`.
607 /// d. If false (no surrounding quotes), returns a slice (`&str`) of the `trimmed` string itself.
608 /// Example: `<N/D>` remains `<N/D>`, ` NA ` becomes `NA`.
609 /// 4. Collects all the resulting string slices into a `Vec<&str>`.
610 ///
611 /// Example Input: `"\"\", \" \", <N/D>, NA "`
612 /// Example Output: `vec!["", " ", "<N/D>", "NA"]`
613 pub fn parse_null_values(&self) -> Vec<&str> {
614 self.null_values
615 .split(',') // 1. Split the string by commas.
616 .map(|s| {
617 // For each part resulting from the split:
618 // 3a. Trim leading/trailing whitespace.
619 let trimmed = s.trim();
620 // 3b. Check if it's quoted (length >= 2, starts/ends with ").
621 if trimmed.len() >= 2 && trimmed.starts_with('"') && trimmed.ends_with('"') {
622 // 3c. If quoted, return the slice between the quotes.
623 trimmed[1..trimmed.len() - 1].trim()
624 } else {
625 // 3d. If not quoted, return the trimmed slice directly.
626 trimmed
627 }
628 })
629 .collect() // 4. Collect the processed slices into a vector.
630 }
631
632 // --- UI Rendering Methods ---
633
634 /// Renders the UI widgets for configuring data filters within the "Query" collapsing header.
635 /// This function is called by `layout.rs::render_side_panel`.
636 ///
637 /// **Crucially, it takes `&mut self`. Widgets modify `self` directly.**
638 /// It compares the state of `self` *before* and *after* rendering the widgets.
639 /// If any change occurred (user typed in a field, clicked a checkbox), it returns
640 /// `Some(self.clone())` containing the *modified* state. Otherwise, it returns `None`.
641 ///
642 /// The `layout.rs` code uses this return value:
643 /// - If `Some(new_filters)`, it triggers an asynchronous `DataFrameContainer::load_data` call.
644 /// - If `None`, no user change was detected in this frame, so no action is taken.
645 ///
646 /// It also sets `self.apply_sql = true` if any changes are detected, ensuring the SQL
647 /// query is re-applied upon reload.
648 ///
649 /// ### Arguments
650 /// * `ui`: The `egui::Ui` context for drawing the widgets.
651 ///
652 /// ### Returns
653 /// * `Some(DataFilter)`: If any filter setting was changed by the user in this frame.
654 /// * `None`: If no changes were detected.
655 pub fn render_query(&mut self, ui: &mut Ui) -> Option<DataFilter> {
656 // Clone the state *before* rendering UI widgets to detect changes later.
657 let filters_before_render = self.clone();
658 let mut result = None;
659
660 let width_min = 450.0; // Minimum width for the grid area.
661
662 // Use a grid layout for label-input pairs.
663 let grid = Grid::new("data_query_grid")
664 .num_columns(2)
665 .spacing([10.0, 20.0]) // Horizontal and vertical spacing.
666 .striped(true); // Alternating row backgrounds.
667
668 // Allocate UI space for the grid.
669 ui.allocate_ui_with_layout(
670 Vec2::new(ui.available_width(), ui.available_height()), // Occupy available width.
671 Layout::top_down(Align::LEFT),
672 |ui| {
673 grid.show(ui, |ui| {
674 ui.set_min_width(width_min);
675
676 // --- Render Individual Filter Widgets ---
677 // Each `render_*` method takes `&mut self` and `ui`.
678
679 self.render_add_row_number(ui);
680
681 self.render_exclude_null_cols(ui);
682
683 self.render_exclude_columns(ui);
684
685 self.render_normalize_numbers(ui);
686
687 self.render_null_values(ui);
688
689 // Input for schema inference length (only for relevant file types).
690 if matches!(
691 self.get_extension().as_deref(), // Get extension as &str
692 Some("csv" | "json" | "ndjson") // Check if it's one of these
693 ) {
694 self.render_schema_length_input(ui);
695 }
696
697 // CSV-specific settings: delimiter.
698 if self.get_extension().as_deref() == Some("csv") {
699 self.render_csv_delimiter(ui);
700 }
701
702 // Input for table name used in SQL.
703 self.render_table_name_input(ui);
704
705 // Multiline input for the SQL query.
706 self.render_sql_query_input(ui);
707
708 // --- Change Detection & Apply Button ---
709
710 // Compare current state `self` with the state before rendering.
711 if *self != filters_before_render {
712 // Mark that SQL needs to be (re-)applied.
713 self.apply_sql = true;
714 tracing::debug!("Change detected in DataFilter UI.");
715 }
716
717 if (self.csv_delimiter != filters_before_render.csv_delimiter)
718 || (self.infer_schema_rows != filters_before_render.infer_schema_rows)
719 {
720 self.read_data_from_file = true;
721 }
722
723 // Add the "Apply SQL commands" button.
724 ui.label(""); // For alignment.
725 ui.with_layout(Layout::top_down(Align::Center), |ui| {
726 if ui.button("Apply SQL commands").clicked() {
727 if self.apply_sql {
728 // Result contains DataFilter after editing some fields
729 result = Some(self.clone());
730 }
731
732 tracing::debug!("Apply SQL commands: {}", self.apply_sql);
733 }
734 });
735 ui.end_row();
736 }); // End grid.show
737 }, // End allocate_ui_with_layout
738 ); // End allocation
739
740 // Display the SQL examples section (collapsible).
741 self.render_sql_examples(ui);
742
743 result // Return the potentially updated filters.
744 }
745
746 // --- Helper Rendering Methods ---
747
748 fn render_add_row_number(&mut self, ui: &mut Ui) {
749 // --- Row 1: Feature Checkbox ---
750 ui.label("Add Row Number:");
751 // The checkbox directly modifies `self.add_row_index`
752 ui.checkbox(&mut self.add_row_index, "")
753 .on_hover_text("Add a new column that counts the rows (first column).");
754 ui.end_row();
755
756 // --- Conditional Configuration Inputs ---
757 // These rows are only added to the grid if the checkbox is checked.
758 if self.add_row_index {
759 // --- Index Name Input ---
760 // Use simple indentation in the label for visual structure
761 ui.label("\tName:");
762 let name_edit =
763 TextEdit::singleline(&mut self.index_column_name).desired_width(f32::INFINITY); // Use available width in the grid cell
764 ui.add(name_edit)
765 .on_hover_text("Name for the new index column (uniqueness checked later).");
766 ui.end_row();
767
768 // --- Index Offset Input ---
769 // Use simple indentation
770 ui.label("\tOffset:");
771 let offset_drag = DragValue::new(&mut self.index_column_offset)
772 .speed(1) // Increment by 1
773 .range(0..=u32::MAX); // Allow 0-based or 1-based commonly
774 ui.add(offset_drag)
775 .on_hover_text("Starting value for the index (e.g., 0 or 1).");
776 ui.end_row();
777 }
778 // No 'else' needed. If add_row_index is false, these rows are simply skipped.
779 }
780
781 /// Renders the checkbox for the "Remove Null Cols" option.
782 /// Modifies `self.exclude_null_cols` directly.
783 fn render_exclude_null_cols(&mut self, ui: &mut Ui) {
784 ui.label("Exclude Null Cols:");
785 ui.checkbox(&mut self.exclude_null_cols, "")
786 .on_hover_text("Remove columns containing only null values.");
787 ui.end_row();
788 }
789
790 fn render_exclude_columns(&mut self, ui: &mut Ui) {
791 // --- Row 1: Feature Checkbox ---
792 ui.label("Remove Columns:");
793 ui.checkbox(&mut self.drop, "")
794 .on_hover_text("Remove columns whose names match the specified regex pattern.");
795 ui.end_row();
796
797 // --- Conditional Configuration Inputs ---
798 // These rows are only added to the grid if the checkbox is checked.
799 if self.drop {
800 // --- Regex Input ---
801 // Use simple indentation in the label for visual structure
802 ui.label("\tRegex:");
803 let name_edit = TextEdit::singleline(&mut self.drop_regex).desired_width(f32::INFINITY); // Use available width in the grid cell
804 ui.add(name_edit).on_hover_text(
805 "Enter the regex pattern to identify columns to drop by name.\n\n\
806 Format Requirements:\n\
807 - Use `*` to drop ALL columns.\n\
808 - Use `^YourPattern$` to match the entire column name.\n \
809 (Must start with `^` and end with `$`).\n\n\
810 Regex Examples:\n\
811 - `^Temp.*$` (Matches columns starting with 'Temp')\n\
812 - `^Value B$` (Matches the exact column named 'Value B')\n\
813 - `^(ID|Key|Index)$` (Matches 'ID', 'Key', or 'Index' exactly)\n\
814 - `^.*_OLD$` (Matches columns ending with '_OLD')\n\n\
815 (Invalid regex syntax or format will cause errors.)",
816 );
817 ui.end_row();
818 }
819 }
820
821 fn render_normalize_numbers(&mut self, ui: &mut Ui) {
822 // --- Row 1: Feature Checkbox ---
823 ui.label("Normalize Columns:");
824 ui.checkbox(&mut self.normalize, "").on_hover_text(
825 "Normalize Euro-style number strings in selected column names (via regex) to Float64.\n\
826 Example: '1.234,56' (String) to '1234.56' (Float64).",
827 );
828 ui.end_row();
829
830 // --- Conditional Configuration Inputs ---
831 // These rows are only added to the grid if the checkbox is checked.
832 if self.normalize {
833 // --- Regex Input ---
834 // Use simple indentation in the label for visual structure
835 ui.label("\tRegex:");
836 let name_edit =
837 TextEdit::singleline(&mut self.normalize_regex).desired_width(f32::INFINITY); // Use available width in the grid cell
838 ui.add(name_edit).on_hover_text(
839 r#"
840Enter a regex pattern to select String columns by name.
841
842Rules:
843- Use '*' for ALL String columns (caution!).
844- Use '^PATTERN$' for specific names (matches entire name).
845
846Example Columns:
847Row Number, Value1, Value2, ValueA, Valor, Total, SubTotal, Last Info
848
849Example Patterns:
8501. To select 'Value1', 'Value2':
851 ^Value\d$
852
8532. To select 'Value1', 'Value2', 'ValueA':
854 ^Value.*$
855
8563. To select 'Value1', 'Value2', 'ValueA', 'Valor':
857 ^Val.*$
858
8594. To select 'Value1', 'Value2', 'ValueA', 'Valor', 'Total', 'SubTotal':
860 ^(Val|.*Total).*$
861
8625. To select only 'Last Info' (note the space):
863 ^Last Info$
864
865(Applies only to columns that Polars identifies as String type.)"#,
866 );
867 ui.end_row();
868 }
869 }
870
871 /// Renders the `TextEdit` widget for specifying custom null values.
872 /// Modifies `self.null_values` directly based on user input.
873 fn render_null_values(&mut self, ui: &mut Ui) {
874 // Null Values Input Label
875 ui.label("Null Values:");
876
877 // Single-line text edit widget bound to the `self.null_values` string.
878 let null_values_edit =
879 TextEdit::singleline(&mut self.null_values).desired_width(f32::INFINITY); // Take available horizontal space.
880
881 // Add the widget to the UI and set its hover text.
882 ui.add(null_values_edit).on_hover_text(
883 "Comma-separated values to interpret as null during loading.\n\
884 Leading/trailing whitespace for each value is automatically trimmed.",
885 );
886
887 // End the row in the parent Grid layout.
888 ui.end_row();
889 }
890
891 /// Renders the `DragValue` widget for setting `infer_schema_rows`.
892 /// Modifies `self.infer_schema_rows` directly.
893 fn render_schema_length_input(&mut self, ui: &mut Ui) {
894 ui.label("Infer Rows:");
895 ui.add(
896 DragValue::new(&mut self.infer_schema_rows)
897 .speed(1) // Increment/decrement speed.
898 .range(0..=usize::MAX), // 0: No inference
899 )
900 .on_hover_text(
901 "Number of rows to scan for inferring data types (CSV/JSON)\n0: No inference",
902 );
903 ui.end_row();
904 }
905
906 /// Renders the `TextEdit` widgets for CSV-specific settings: delimiter.
907 /// Modifies `self.csv_delimiter` directly.
908 fn render_csv_delimiter(&mut self, ui: &mut Ui) {
909 // CSV Delimiter Input
910 ui.label("CSV Delimiter:");
911 let csv_delimiter_edit = TextEdit::singleline(&mut self.csv_delimiter)
912 .char_limit(1) // Restrict to a single character.
913 .desired_width(f32::INFINITY);
914 ui.add(csv_delimiter_edit)
915 .on_hover_text("Enter the single character CSV delimiter");
916 ui.end_row();
917 }
918
919 /// Renders the `TextEdit` widget for the SQL table name.
920 /// Modifies `self.table_name` directly.
921 fn render_table_name_input(&mut self, ui: &mut Ui) {
922 ui.label("SQL Table Name:");
923 let table_name_edit =
924 TextEdit::singleline(&mut self.table_name).desired_width(f32::INFINITY);
925 ui.add(table_name_edit)
926 .on_hover_text("Name of the table to use in SQL queries (e.g., FROM TableName)");
927 ui.end_row();
928 }
929
930 /*
931 /// Renders the multiline `TextEdit` widget for the SQL query.
932 /// Modifies `self.query` directly.
933 fn render_sql_query_input(&mut self, ui: &mut Ui) {
934 ui.label("SQL Query:");
935 let query_edit = TextEdit::multiline(&mut self.query)
936 .desired_width(f32::INFINITY)
937 // Set a reasonable initial height for the multiline input.
938 .desired_rows(4);
939 ui.add(query_edit)
940 .on_hover_text("Enter SQL query to filter/transform data (uses Polars SQL syntax)");
941 ui.end_row();
942 }
943 */
944
945 /// Renders tabbed SQL examples and the editable query input `self.query`.
946 /// Handles selecting examples and editing the query. Tabs will wrap if needed.
947 /// ### Logic
948 /// 1. Generate SQL examples via `sql_commands` using `self.schema`.
949 /// 2. Manage selected tab index using `egui::Memory`.
950 /// 3. Render **wrapping horizontal tabs** for examples using `ui.horizontal_wrapped`.
951 /// 4. On tab click: update index, copy example to `self.query`.
952 /// 5. Render multiline `TextEdit` bound to `&mut self.query`.
953 ///
954 /// Note: Actual *triggering* of reload happens in `render_query` based on overall state change detection or Apply click.
955 fn render_sql_query_input(&mut self, ui: &mut Ui) {
956 ui.label("SQL Query:"); // Label for the whole section
957 ui.vertical(|ui| {
958 // Group the examples and editor vertically
959 // Configure minimum width for the vertical group if needed
960 ui.set_min_width(300.0);
961
962 // 1. Generate examples based on the current schema
963 let examples = sql_commands(&self.schema);
964 if examples.is_empty() {
965 // If no schema or examples, just show the editor
966 ui.add(
967 TextEdit::multiline(&mut self.query)
968 .desired_width(f32::INFINITY)
969 .desired_rows(8) // Slightly more rows if no examples
970 .font(egui::TextStyle::Monospace),
971 );
972 return; // Skip rendering examples if none exist
973 }
974
975 // 2. Get/Set selected tab index from Memory for persistence
976 let tab_id = ui.id().with("sql_query_tab_index");
977 let mut selected_tab_index =
978 ui.memory_mut(|mem| *mem.data.get_persisted_mut_or_default::<usize>(tab_id));
979
980 // Clamp selected index to be valid (in case number of examples changes)
981 selected_tab_index = selected_tab_index.min(examples.len().saturating_sub(1));
982
983 // 3. Render Tabs using horizontal_wrapped
984 ui.separator();
985 ui.label("Examples:"); // Label for the tabs
986
987 // Use horizontal_wrapped to lay out tabs, wrapping them onto new lines
988 ui.horizontal_wrapped(|ui| {
989 // Iterate through examples to create selectable labels (tabs)
990 for i in 0..examples.len() {
991 let is_selected = selected_tab_index == i;
992 let tab_name = format!("{}", i + 1); // Simple number for the tab
993
994 // Create the selectable label (acting as a tab)
995 let resp = ui
996 .selectable_label(is_selected, tab_name)
997 // Show the first line of the SQL query as hover text
998 .on_hover_text(
999 examples
1000 .get(i) // Safely get the example string
1001 .and_then(|s| s.lines().next()) // Get the first line
1002 .unwrap_or(""), // Default to empty string if error/empty
1003 );
1004
1005 // 4. Handle tab click logic
1006 if resp.clicked() && !is_selected {
1007 selected_tab_index = i; // Update the selected index
1008
1009 // Update the main query editor text with the clicked example
1010 if let Some(example_query) = examples.get(i) {
1011 self.query = example_query.clone(); // Set editor text
1012 // Change is detected by render_query comparing before/after state
1013 tracing::debug!(
1014 "Switched SQL Query tab to Example {}, query text updated.",
1015 i + 1
1016 );
1017 }
1018
1019 // Store the newly selected index back into egui's memory
1020 ui.memory_mut(|mem| mem.data.insert_persisted(tab_id, selected_tab_index));
1021 }
1022 }
1023 }); // End horizontal_wrapped
1024
1025 ui.separator(); // Separator between tabs and editor
1026
1027 // 5. Render the ACTIVE query editor below the tabs
1028 ui.add(
1029 TextEdit::multiline(&mut self.query)
1030 .desired_width(f32::INFINITY) // Take full available width
1031 .desired_rows(6) // Set preferred number of visible lines
1032 .font(egui::TextStyle::Monospace), // Use a monospace font for SQL
1033 )
1034 .on_hover_text(
1035 "Enter SQL query (Polars SQL).\n\
1036 Click Example tabs above.\n\
1037 Changes trigger reload on Apply/focus change.",
1038 );
1039 }); // End vertical group
1040 ui.end_row(); // End the row in the parent Grid layout
1041 }
1042
1043 /// Renders the collapsible section displaying SQL command examples.
1044 /// Uses `sql_commands` to generate examples relevant to the current `self.schema`.
1045 fn render_sql_examples(&self, ui: &mut Ui) {
1046 CollapsingHeader::new("SQL Command Examples")
1047 .default_open(false)
1048 .show(ui, |ui| {
1049 // Tip about quoting identifiers.
1050 let quoting_tip = "Tip: Use double quotes (\") or backticks (`) around column names with spaces or special characters (e.g., \"Column Name\" or `Column Name`).";
1051 ui.label(quoting_tip);
1052
1053 // Frame around the examples.
1054 Frame::default()
1055 .stroke(Stroke::new(1.0_f32, Color32::GRAY))
1056 .outer_margin(2.0)
1057 .inner_margin(10.0)
1058 .show(ui, |ui| {
1059 // Link to Polars SQL documentation.
1060 ui.vertical_centered(|ui| {
1061 let polars_sql_url = "https://docs.pola.rs/api/python/stable/reference/sql/index.html";
1062 ui.hyperlink_to("Polars SQL Reference", polars_sql_url).on_hover_text(polars_sql_url);
1063 });
1064 ui.separator();
1065
1066 // Generate and display SQL examples based on the current schema.
1067 // The `sql_commands` function (in `sqls.rs`) dynamically creates these.
1068 let examples = sql_commands(&self.schema);
1069 let mut ex_num = Vec::new();
1070 for (index, example) in examples.iter().enumerate() {
1071 ex_num.push(format!("Example {count}:\n{example}", count = index + 1));
1072 }
1073
1074 // Make the examples selectable for easy copying.
1075 ui.add(egui::Label::new(ex_num.join("\n\n")).selectable(true));
1076 });
1077 });
1078 }
1079}
1080
1081/// Reads a CSV file from the specified path using Polars, applying given options
1082/// and limiting the number of data rows read.
1083///
1084/// This function configures a Polars CsvReader with specific parsing and reading
1085/// options and executes the read operation directly from the file path.
1086/// It's suitable for getting the schema (when `infer_schema_length(Some(0))`) or
1087/// reading a limited number of initial data rows (`with_n_rows`).
1088///
1089/// ### Configuration Behavior:
1090/// - `has_header(true)`: Assumes the file has a header row for column names.
1091/// - `infer_schema_length(Some(0))`: Instructs Polars to infer column names from the
1092/// header row *only*, using default types (typically String), without using data
1093/// rows to guess types. If combined with `n_rows > 0`, it reads data
1094/// rows but ignores their content for type inference.
1095/// - `with_n_rows(n_rows)`: Limits the number of *data* rows parsed after the header.
1096/// - `ignore_errors(true)`: Skips rows/fields with parsing errors rather than stopping.
1097/// - `missing_is_null(true)`: Treats empty fields (`""`) as null values.
1098pub async fn read_csv_partial_from_path(
1099 delimiter: u8,
1100 n_rows: usize,
1101 path: &Path,
1102) -> PolarsViewResult<DataFrame> {
1103 tracing::debug!("Read a CSV file using Polars limited to {} rows.", n_rows,);
1104
1105 // 1. Define the CSV parsing options.
1106 let csv_parse_options = CsvParseOptions::default()
1107 .with_encoding(CsvEncoding::LossyUtf8) // Handle potentially non-strict UTF8
1108 .with_missing_is_null(true) // Treat empty fields as nulls
1109 .with_separator(delimiter); // Set the chosen delimiter
1110
1111 // 2. Define the main CSV reading options.
1112 let csv_read_options = CsvReadOptions::default()
1113 .with_parse_options(csv_parse_options) // Apply the parsing sub-options
1114 .with_has_header(true) // File has a header row
1115 .with_infer_schema_length(Some(0)) // Number of rows to use for schema inference (0 means header only)
1116 .with_ignore_errors(true) // Allow skipping rows/fields that fail to parse
1117 .with_n_rows(Some(n_rows)) // Limits the number of rows to read.
1118 .try_into_reader_with_file_path(Some(path.to_path_buf()))?;
1119
1120 // 3. Execute the blocking read operation on a separate thread
1121 let df = execute_polars_blocking(move || csv_read_options.finish()).await?;
1122
1123 tracing::debug!("Partial CSV read complete. Shape: {:?}", df.shape());
1124 Ok(df)
1125}
1126
1127/// Builds a Polars Schema specifying DataType::String overrides for columns
1128/// whose names match a given regex pattern or wildcard.
1129///
1130/// This function creates a schema intended for the `with_dtypes` option
1131/// of Polars readers (like `LazyCsvReader`), ensuring specific columns are treated
1132/// as text regardless of their inferred type.
1133fn build_dtype_override_schema(
1134 input_schema: &Arc<Schema>,
1135 regex_pattern: &str,
1136) -> PolarsViewResult<Schema> {
1137 let mut overrides_schema = Schema::default(); // Initialize the resulting schema
1138
1139 // --- Handle Wildcard Case ("*") ---
1140 // If the pattern is "*", override ALL columns to String.
1141 if regex_pattern.trim() == "*" {
1142 tracing::debug!(
1143 "Wildcard pattern '{regex_pattern}' provided. Overriding all columns to String."
1144 );
1145 return Ok(input_schema.as_ref().clone()); // Return the fully populated override schema
1146 }
1147
1148 // --- Handle Specific Regex Pattern Case ---
1149 // If it's not a wildcard, compile the regex pattern.
1150
1151 // Validate the required ^...$ format *before* compiling
1152 if !(regex_pattern.starts_with('^') && regex_pattern.ends_with('$')) {
1153 return Err(PolarsViewError::InvalidRegexPattern(
1154 regex_pattern.to_string(),
1155 ));
1156 }
1157
1158 // Attempt to compile the regex
1159 let compiled_regex = match Regex::new(regex_pattern) {
1160 Ok(re) => re,
1161 Err(e) => {
1162 // Return specific error for invalid syntax
1163 return Err(PolarsViewError::InvalidRegexSyntax {
1164 pattern: regex_pattern.to_string(),
1165 error: e.to_string(),
1166 });
1167 }
1168 };
1169
1170 // Check this compiled regex against each actual header name.
1171 for col_name in input_schema.iter_names() {
1172 if compiled_regex.is_match(col_name) {
1173 // Insert the override into the schema.
1174 overrides_schema.insert(col_name.clone(), DataType::String);
1175 }
1176 }
1177
1178 // Log the final outcome for debugging purposes.
1179 if !overrides_schema.is_empty() {
1180 tracing::debug!(
1181 override_cols = ?overrides_schema.iter_names().collect::<Vec<_>>(),
1182 "Pattern '{}' matched {} columns: ",
1183 regex_pattern,
1184 overrides_schema.len()
1185 );
1186 } else {
1187 tracing::debug!("Provided regex patterns did not match any header columns.");
1188 }
1189
1190 Ok(overrides_schema) // Return the successfully built schema (might be empty)
1191}
1192
1193/// Helper function to find a unique column name based on a base name and schema.
1194/// Appends suffixes "_1", "_2", etc., if the base name conflicts with existing column names.
1195fn resolve_unique_column_name(base_name: &str, schema: &Schema) -> PolarsResult<PlSmallStr> {
1196 // Check if the base name is available first (most common case)
1197 if schema.get(base_name).is_none() {
1198 tracing::debug!("Base name '{}' is available.", base_name);
1199 return Ok(base_name.into());
1200 }
1201
1202 // Base name conflicts, generate alternative names with suffixes.
1203 tracing::debug!(
1204 "Base name '{}' conflicts. Searching unique name.",
1205 base_name
1206 );
1207 let mut suffix_counter = 1u32;
1208 loop {
1209 let candidate_name = format!("{base_name}_{suffix_counter}");
1210
1211 if schema.get(&candidate_name).is_none() {
1212 // Found a unique name
1213 tracing::debug!("Found unique name: '{}'.", candidate_name);
1214 return Ok(candidate_name.into()); // Return the unique name
1215 }
1216
1217 // Safety check for potential overflow and limit attempts
1218 suffix_counter = suffix_counter.checked_add(1).unwrap_or(MAX_ATTEMPTS); // If overflow, go to max attempts
1219
1220 // Prevent infinite loops. Return error if a unique name cannot be found after max attempts.
1221 if suffix_counter >= MAX_ATTEMPTS {
1222 let msg = format!(
1223 "Failed to find a unique column name starting with '{base_name}' after {MAX_ATTEMPTS} attempts."
1224 );
1225 tracing::error!("{}", msg);
1226 return Err(PolarsError::ComputeError(msg.into()));
1227 }
1228 }
1229}
1230
1231/// Executes a potentially blocking Polars operation on a separate Tokio blocking thread.
1232///
1233/// Wraps the closure `op` which is expected to return a `PolarsResult<T>`,
1234/// runs it with `spawn_blocking`, awaits the result, and maps both the
1235/// `JoinError` and the inner `PolarsError` to `PolarsViewError`.
1236///
1237/// ### Arguments
1238/// * `op`: A closure that performs the blocking work and returns `PolarsResult<T>`.
1239/// It must be `Send` and have a `'static` lifetime, meaning it must
1240/// take ownership of or only use data that can be moved across threads
1241/// and lives for the duration of the program (or the task).
1242///
1243/// ### Returns
1244/// A `PolarsViewResult<T>` containing the result of the operation `T` on success,
1245/// or a mapped `PolarsViewError` if the spawned task fails (`TokioJoin`) or
1246/// the Polars operation itself fails (`Polars`).
1247async fn execute_polars_blocking<T, F>(op: F) -> PolarsViewResult<T>
1248where
1249 // F is the type of the closure
1250 F: FnOnce() -> Result<T, PolarsError> + Send + 'static, // The closure trait bounds
1251 // T is the success type returned by the closure (e.g., DataFrame)
1252 T: Debug + Send + 'static, // The success type must be Send and have static lifetime
1253 // PolarsError: Debug,
1254{
1255 // Spawn the blocking task
1256 let result_from_task = spawn_blocking(op).await; // Result<Result<T, PolarsError>, JoinError>
1257
1258 // Map JoinError to PolarsViewError::TokioJoin
1259 let polars_result = result_from_task.map_err(PolarsViewError::from)?; // Requires PolarsViewError::from(JoinError)
1260
1261 // Map PolarsError to PolarsViewError::Polars
1262 let final_result = polars_result.map_err(PolarsViewError::from)?; // Requires PolarsViewError::from(PolarsError)
1263
1264 Ok(final_result) // Return the successfully extracted value or the mapped PolarsError
1265}
1266
1267//----------------------------------------------------------------------------//
1268// Tests //
1269//----------------------------------------------------------------------------//
1270
1271/// Run tests with:
1272/// cargo test -- --show-output tests_override_columns
1273#[cfg(test)]
1274mod tests_override_columns {
1275 use super::*;
1276 use std::{fs::File, io::Write};
1277 use tempfile::NamedTempFile;
1278
1279 // --- Test Setup Helper (Unchanged conceptually, ensure path and error mapping ok) ---
1280 fn setup_test_csv(
1281 content: &str, // CSV content as string
1282 delimiter: char,
1283 force_string_patterns: Option<String>, // Regex Columns to configure for override
1284 ) -> PolarsViewResult<(NamedTempFile, DataFilter)> {
1285 let temp_file = NamedTempFile::new()?;
1286 let file_path = temp_file.path().to_path_buf();
1287
1288 // Write content to the temp file
1289 let mut file = File::create(&file_path)?;
1290 file.write_all(content.as_bytes())?;
1291 file.flush()?; // Ensure data is written
1292
1293 // Create DataFilter using struct update syntax (Clippy Fix)
1294 let filter = DataFilter {
1295 absolute_path: file_path, // Set specific value
1296 force_string_patterns, // Set specific value
1297 csv_delimiter: delimiter.to_string(), // Set specific value
1298 ..Default::default() // Fill the rest with defaults
1299 };
1300
1301 Ok((temp_file, filter))
1302 }
1303
1304 // --- Test Case 1: Override applied correctly ---
1305 #[tokio::test] // Requires tokio features in dev-dependencies
1306 async fn test_csv_read_with_override_success() -> PolarsViewResult<()> {
1307 println!("\n--- Test: Override Applied Successfully ---");
1308 // 1. Define CSV Content with large numbers AS TEXT
1309 let csv_content = "\
1310long_id;value;text
131112345678901234567890123456789012345678901234;10.5;abc
131298765432109876543210987654321098765432109876;20.0;def
131312345;30.7;ghi";
1314 // No need for df_input - the csv_content is the direct input representation
1315 println!("Input CSV Content:\n{csv_content}\n");
1316
1317 // 2. Define Expected Output DataFrame (long_id is String)
1318 let df_expected = df!(
1319 "long_id" => &[
1320 "12345678901234567890123456789012345678901234",
1321 "98765432109876543210987654321098765432109876",
1322 "12345"
1323 ],
1324 "value" => &[10.5, 20.0, 30.7],
1325 "text" => &["abc", "def", "ghi"]
1326 )
1327 .expect("Failed to create expected DataFrame");
1328 println!("Expected DF (After Read):\n{df_expected}");
1329
1330 // 3. Setup: Use helper to create CSV and Filter WITH the override
1331 let delimiter = ';';
1332 let col_regex = "^long_id$".to_string();
1333 let (_temp_file, filter) = // Keep _temp_file handle!
1334 setup_test_csv(csv_content, delimiter, Some(col_regex))?;
1335
1336 let schema = filter
1337 .attempt_csv_parse_structure(delimiter as u8, false)
1338 .await?;
1339 println!("schema: {schema:#?}");
1340
1341 // 4. Execute the function under test
1342 let lazyframe = filter.attempt_read_csv(delimiter as u8, &schema).await?;
1343 println!("get lazyframe");
1344
1345 // Execute the lazy plan and collect into an eager DataFrame
1346 // *** WRAP COLLECT IN SPAWN_BLOCKING ***
1347 let df_output =
1348 execute_polars_blocking(move || lazyframe.with_streaming(true).collect()).await?;
1349
1350 println!("Output DF (Actual Read):\n{df_output}");
1351
1352 // 5. Assertions
1353 assert_eq!(
1354 df_output.schema().get("long_id"),
1355 Some(&DataType::String),
1356 "Schema Check Failed: 'long_id' should be DataType::String"
1357 );
1358 assert_eq!(
1359 df_output.schema().get("value"),
1360 Some(&DataType::Float64),
1361 "Schema Check Failed: 'value' should be DataType::Float64"
1362 );
1363 assert_eq!(
1364 df_output.schema().get("text"),
1365 Some(&DataType::String),
1366 "Schema Check Failed: 'text' should be DataType::String"
1367 );
1368 assert_eq!(
1369 df_output, df_expected,
1370 "Content Check Failed: Output DF does not match expected DF"
1371 );
1372
1373 Ok(())
1374 }
1375
1376 // --- Test Case 2: Override *not* applied (expect nulls) ---
1377 #[tokio::test] // Requires tokio features in dev-dependencies
1378 async fn test_csv_read_without_override_yields_nulls() -> PolarsViewResult<()> {
1379 println!("\n--- Test: No Override Applied (Expect Nulls) ---");
1380 // 1. Define CSV Content (same large numbers AS TEXT)
1381 let csv_content = "\
1382long_id;value;text
138312345678901234567890123456789012345678901234;10.5;abc
138498765432109876543210987654321098765432109876;20.0;def";
1385 println!("Input CSV Content:\n{csv_content}\n");
1386
1387 // 2. Define Expected Output Pattern (long_id should be all nulls)
1388 let df_expected_pattern = df!(
1389 "long_id" => Series::new_null("long_id".into(), 2).cast(&DataType::Int64)?, // Series of 2 nulls
1390 "value" => &[10.5, 20.0],
1391 "text" => &["abc", "def"]
1392 )
1393 .expect("Failed to create expected pattern DataFrame");
1394 println!("Expected DF Pattern (After Read, note long_id nulls):\n{df_expected_pattern}");
1395
1396 // 3. Setup: Use helper with an EMPTY override list
1397 let delimiter = ';';
1398 let col_regex = "^Col Name$".to_string();
1399 let (_temp_file, filter) = setup_test_csv(csv_content, delimiter, Some(col_regex))?;
1400
1401 let schema = filter
1402 .attempt_csv_parse_structure(delimiter as u8, false)
1403 .await?;
1404 println!("schema: {schema:#?}");
1405
1406 // 4. Execute the function under test
1407 let lazyframe = filter.attempt_read_csv(delimiter as u8, &schema).await?;
1408 println!("get lazyframe");
1409
1410 // Execute the lazy plan and collect into an eager DataFrame
1411 // *** WRAP COLLECT IN SPAWN_BLOCKING ***
1412 let df_output = spawn_blocking(move || lazyframe.with_streaming(true).collect())
1413 .await
1414 .map_err(PolarsViewError::from)? // Convert Tokio JoinError to PolarsViewError
1415 .map_err(PolarsViewError::from)?; // Convert PolarsError to PolarsViewError
1416
1417 println!("Output DF (Actual Read):\n{df_output}");
1418
1419 // 5. Assertions
1420 let long_id_col = df_output.column("long_id")?;
1421 assert!(
1422 long_id_col.is_null().all(), // Verify ALL values are null
1423 "Content Check Failed: 'long_id' column should be all nulls without override. Type: {:?}, Null count: {}",
1424 long_id_col.dtype(),
1425 long_id_col.null_count()
1426 );
1427 // Verify other columns match the pattern
1428 assert_eq!(
1429 df_output.column("value")?,
1430 df_expected_pattern.column("value")?
1431 );
1432 assert_eq!(
1433 df_output.column("text")?,
1434 df_expected_pattern.column("text")?
1435 );
1436
1437 Ok(())
1438 }
1439}