Skip to main content

datui_lib/view/
view_apply.rs

1//! Saved views: the list, the save form, matching a view to a dataset, and replaying
2//! its steps with a rollback when its rows fail.
3
4use crate::app::jobs::{Answer, Job};
5use crate::app::modals::filter_modal::FilterStatement;
6use crate::table::DataTableState;
7use crate::view::SavedView;
8use crate::widgets::view_modal::{FormFocus, ViewModalMode, ViewRow};
9use crate::{
10    App, QueryRun, Replayed, RunOrigin, active_query_settings, view, view_settings_of, widgets,
11};
12use color_eyre::Result;
13use polars::prelude::DataFrame;
14
15/// Saved views, and the one applied to the dataset on screen.
16pub struct SavedViews {
17    pub(crate) manager: crate::view::Views,
18    pub(crate) active_id: Option<String>, // ID of currently applied view
19}
20
21impl SavedViews {
22    /// A new dataset is on screen with no view applied.
23    pub(crate) fn reset_for_dataset(&mut self) {
24        self.active_id = None;
25    }
26}
27
28impl App {
29    /// Open the views list for the dataset on screen, scored against it.
30    pub(crate) fn open_view_list(&mut self) {
31        if self.view_dataset().is_none() {
32            return;
33        }
34        self.view_modal.table_state.select(Some(0));
35        self.refresh_view_list();
36        self.view_modal.mode = ViewModalMode::List;
37        self.open_overlay(crate::Overlay::View);
38    }
39
40    /// Rebuild the list from the store, scored against the open dataset; the selection
41    /// stays near where it was.
42    pub(crate) fn refresh_view_list(&mut self) {
43        let (Some(state), Some(dataset)) = (&self.data_table_state, self.view_dataset()) else {
44            return;
45        };
46        let rows: Vec<ViewRow> = self
47            .views
48            .manager
49            .find_relevant_views(dataset, state.source_schema())
50            .into_iter()
51            .map(|(view, score)| {
52                let reason = view::match_reason(&view, dataset, state.source_schema());
53                ViewRow {
54                    view,
55                    score,
56                    reason,
57                }
58            })
59            .collect();
60        self.view_modal.broken_views = self.views.manager.broken_views.clone();
61        let selected = self.view_modal.table_state.selected().unwrap_or(0);
62        self.view_modal.table_state.select(if rows.is_empty() {
63            None
64        } else {
65            Some(selected.min(rows.len() - 1))
66        });
67        self.view_modal.rows = rows;
68    }
69
70    /// Open the save-view form prefilled from the open dataset: a recognizable name,
71    /// this file's paths and patterns as criteria, and schema match on (the criterion
72    /// that carries the view to similar tables).
73    pub(crate) fn open_save_view_form(&mut self) {
74        self.view_modal
75            .enter_create_mode(self.display.history_limit, &self.theme);
76
77        let query = self.data_table_state.as_ref().and_then(|state| {
78            let (query, sql_query, fuzzy_query) = active_query_settings(
79                state.get_active_query(),
80                state.get_active_sql_query(),
81                state.get_active_fuzzy_query(),
82            );
83            sql_query.or(fuzzy_query).or(query)
84        });
85        self.view_modal.name_input.suggest(
86            self.views
87                .manager
88                .suggest_name(self.path.as_deref(), query.as_deref()),
89        );
90
91        // Data piped in has no file to pin; its columns are what match it.
92        if let Some(path) = self.path.as_ref().filter(|_| !self.reads_stdin()) {
93            // Pin this file: its absolute path or URL, its path relative to a working directory
94            // above it, and glob suggestions.
95            let absolute_path = view::exact_location(path);
96            self.view_modal
97                .exact_path_input
98                .suggest(absolute_path.to_string_lossy());
99            if let Some(relative) = view::relative_location(path) {
100                self.view_modal.relative_path_input.suggest(relative);
101            }
102
103            // A path pattern from the absolute path: a bare relative name's parent is "", and
104            // ""/*.parquet would match every parquet file anywhere. Use the path's own
105            // separator, or a Windows path never fits.
106            if let Some(parent) = absolute_path.parent()
107                && let Some(parent_str) = parent.to_str()
108                && !parent_str.is_empty()
109                && let Some(ext) = absolute_path.extension()
110            {
111                let separator = if crate::cloud::source::is_remote_url(path) {
112                    '/'
113                } else {
114                    std::path::MAIN_SEPARATOR
115                };
116                self.view_modal.path_pattern_input.suggest(format!(
117                    "{}{separator}*.{}",
118                    parent_str.trim_end_matches(separator),
119                    ext.to_string_lossy()
120                ));
121            }
122
123            // A filename pattern with digit runs wildcarded: sales_2024.csv matches
124            // sales_2025.csv.
125            if let Some(filename) = path.file_name()
126                && let Some(filename_str) = filename.to_str()
127            {
128                use regex::Regex;
129                let pattern = match Regex::new(r"\d+") {
130                    Ok(re) => re.replace_all(filename_str, "*").to_string(),
131                    Err(_) => filename_str.to_string(),
132                };
133                self.view_modal.filename_pattern_input.suggest(pattern);
134            }
135        }
136
137        self.view_modal.table = self.view_table().map(str::to_string);
138
139        // Schema match starts on: applying to similar tables is what views are for, and
140        // columns are the only criterion that says similar.
141        if let Some(ref state) = self.data_table_state
142            && !state.source_schema().is_empty()
143        {
144            self.view_modal.schema_match_enabled = true;
145        }
146    }
147
148    /// Validate and save the form (a new view or the edited one). A failed save keeps
149    /// the form open.
150    pub(crate) fn save_view_form(&mut self) {
151        self.view_modal.name_error = None;
152        let name = self.view_modal.name_input.value().trim().to_string();
153        if name.is_empty() {
154            self.view_modal.name_error = Some("name is required".to_string());
155            self.view_modal.form_focus = FormFocus::Name;
156            return;
157        }
158        let renaming_to_taken = match &self.view_modal.editing_view_id {
159            None => self.views.manager.view_exists(&name),
160            Some(id) => self
161                .views
162                .manager
163                .get_view_by_name(&name)
164                .is_some_and(|other| other.id != *id),
165        };
166        if renaming_to_taken {
167            self.view_modal.name_error = Some("name already exists".to_string());
168            self.view_modal.form_focus = FormFocus::Name;
169            return;
170        }
171
172        let non_empty = |input: &widgets::text_input::TextInput| {
173            let value = input.value().trim();
174            (!value.is_empty()).then(|| value.to_string())
175        };
176        let match_criteria = view::MatchCriteria {
177            exact_path: non_empty(&self.view_modal.exact_path_input).map(std::path::PathBuf::from),
178            relative_path: non_empty(&self.view_modal.relative_path_input),
179            path_pattern: non_empty(&self.view_modal.path_pattern_input),
180            filename_pattern: non_empty(&self.view_modal.filename_pattern_input),
181            // The columns the view's settings run on, not the query's output: the next file is
182            // matched as loaded.
183            schema_columns: if self.view_modal.schema_match_enabled {
184                self.data_table_state.as_ref().map(|state| {
185                    state
186                        .source_schema()
187                        .iter_names()
188                        .map(|s| s.to_string())
189                        .collect()
190                })
191            } else {
192                None
193            },
194            schema_types: None,
195            table: self.view_modal.table.clone(),
196        };
197        let description = {
198            let value = self.view_modal.description_input.value();
199            (!value.is_empty()).then(|| value.to_string())
200        };
201
202        let saved = if let Some(editing_id) = self.view_modal.editing_view_id.clone() {
203            let Some(mut view) = self.views.manager.get_view_by_id(&editing_id).cloned() else {
204                return;
205            };
206            view.name = name;
207            view.description = description;
208            let stored_schema = view.match_criteria.schema_columns.take();
209            view.match_criteria = match_criteria;
210            let editing_the_active_view =
211                self.views.active_id.as_deref() == Some(editing_id.as_str());
212            // Editing an unapplied view must not swap its matched columns for the open table's;
213            // the toggle still drops the criterion, and the active view follows its table.
214            if !editing_the_active_view
215                && self.view_modal.schema_match_enabled
216                && stored_schema.is_some()
217            {
218                view.match_criteria.schema_columns = stored_schema;
219            }
220            // The settings follow the table only for the view dressing it; editing an unapplied
221            // view changes only its name, description and matching.
222            if editing_the_active_view && let Some(state) = &self.data_table_state {
223                view.settings = view_settings_of(state);
224                view.settings.chart = self.saved_chart();
225            }
226            match self.views.manager.update_view(&view) {
227                Ok(()) => true,
228                Err(e) => {
229                    // Deleted elsewhere, it has left the list; otherwise the form stays to retry.
230                    if self.views.manager.get_view_by_id(&editing_id).is_none() {
231                        self.refresh_view_list();
232                        self.view_modal.exit_form();
233                    }
234                    self.error_modal.show(format!("Error saving view: {e}"));
235                    return;
236                }
237            }
238        } else {
239            self.create_view_from_current_state(name, description, match_criteria)
240                .is_ok()
241        };
242        if saved {
243            self.refresh_view_list();
244            self.view_modal.exit_form();
245        }
246    }
247
248    /// The selected view's score breakdown, for the list's `i` popup.
249    pub(crate) fn view_score_details(&self) -> Option<(String, String)> {
250        let state = self.data_table_state.as_ref()?;
251        let path = self.view_dataset()?;
252        let idx = self.view_modal.table_state.selected()?;
253        let row = self.view_modal.rows.get(idx)?;
254        let view = &row.view;
255
256        let exact_path_match = view::exact_path_matches(&view.match_criteria, path);
257        let relative_path_match = view::relative_path_matches(&view.match_criteria, path);
258        let file_cols: std::collections::HashSet<&str> = state
259            .source_schema()
260            .iter_names()
261            .map(|s| s.as_str())
262            .collect();
263        let exact_schema_match =
264            view.match_criteria
265                .schema_columns
266                .as_ref()
267                .is_some_and(|required| {
268                    let required: std::collections::HashSet<&str> =
269                        required.iter().map(|s| s.as_str()).collect();
270                    required.is_subset(&file_cols) && file_cols.len() == required.len()
271                });
272
273        let mut details = format!("Total score: {:.1}\n\n", row.score);
274        if exact_path_match && exact_schema_match {
275            details.push_str("Exact path + exact schema: 2000.0\n");
276        } else if exact_path_match {
277            details.push_str("Exact path: 1000.0\n");
278        } else if relative_path_match && exact_schema_match {
279            details.push_str("Relative path + exact schema: 1950.0\n");
280        } else if relative_path_match {
281            details.push_str("Relative path: 950.0\n");
282        } else if exact_schema_match {
283            details.push_str("Exact schema: 900.0\n");
284        } else {
285            if view::path_pattern_matches(&view.match_criteria, path) {
286                details.push_str("Path pattern match: 50.0+\n");
287            }
288            if view::filename_pattern_matches(&view.match_criteria, path) {
289                details.push_str("Filename pattern match: 30.0+\n");
290            }
291            if let Some(required_cols) = &view.match_criteria.schema_columns {
292                let matching_count = required_cols
293                    .iter()
294                    .filter(|col| file_cols.contains(col.as_str()))
295                    .count();
296                if matching_count > 0 {
297                    details.push_str(&format!(
298                        "Partial schema match: {:.1} ({} columns)\n",
299                        matching_count as f64 * 2.0,
300                        matching_count
301                    ));
302                }
303            }
304        }
305        if view.usage_count > 0 {
306            details.push_str(&format!(
307                "Usage count: {:.1}\n",
308                (view.usage_count.min(10) as f64) * 1.0
309            ));
310        }
311        if let Some(last_used) = view.last_used
312            && let Ok(duration) = std::time::SystemTime::now().duration_since(last_used)
313        {
314            let days_since = duration.as_secs() / 86400;
315            if days_since <= 7 {
316                details.push_str("Recent usage: 5.0\n");
317            } else if days_since <= 30 {
318                details.push_str("Recent usage: 2.0\n");
319            }
320        }
321        Some((format!("Score: {}", view.name), details))
322    }
323
324    /// Start applying `view`. Planning its steps reads nothing; a step that cannot plan
325    /// fails here, changing nothing. The pivot and first rows are read in the
326    /// background and the view installed when in; a failure there restores the
327    /// previous view.
328    pub(crate) fn apply_view(&mut self, view: &SavedView) -> Result<()> {
329        self.apply_view_with(view, None)
330    }
331
332    /// [`Self::apply_view`] for a view applied because its criteria fit (`why`): a flash
333    /// names it and the reason once its rows are in.
334    pub(crate) fn apply_matched_view(
335        &mut self,
336        view: &SavedView,
337        why: view::MatchReason,
338    ) -> Result<()> {
339        self.apply_view_with(view, Some(why))
340    }
341
342    fn apply_view_with(&mut self, view: &SavedView, why: Option<view::MatchReason>) -> Result<()> {
343        self.jobs.supersede(|job| matches!(job, Job::ViewPivot(_)));
344        if let Some(saved) = &view.settings.sample {
345            return self.apply_sampled_view(view, saved, why);
346        }
347        let Some(state) = self.data_table_state.as_mut() else {
348            return Ok(());
349        };
350        match state.try_transition(|s| Self::replay_view(s, &view.settings, None))? {
351            (Replayed::Planned, rollback) => {
352                self.view_planned(view, rollback, why);
353                Ok(())
354            }
355            (Replayed::Pivot(job), rollback) => {
356                // The table stays as it is while the pivot is read.
357                state.roll_back(rollback);
358                // Past any load-ahead for the view on screen, whose rows must not land in its
359                // replacement.
360                self.jobs.try_advance();
361                let pivot_view = Job::ViewPivot(Box::new((view.clone(), why)));
362                self.spawn_job(pivot_view, Some(Self::APPLYING_VIEW), move |_| {
363                    let pivoted = job
364                        .run()
365                        .map_err(|e| crate::error_display::user_message_from_report(&e, None))?;
366                    Ok(Answer::ViewPivoted(pivoted))
367                });
368                Ok(())
369            }
370        }
371    }
372
373    /// The view's steps are planned over `rollback`, the replaced view: mark it applied
374    /// and read its first rows. A failure before they are in restores `rollback` and
375    /// its applied mark.
376    pub(crate) fn view_planned(
377        &mut self,
378        view: &SavedView,
379        rollback: crate::table::ViewRollback,
380        why: Option<view::MatchReason>,
381    ) {
382        if let Some(path) = &self.path {
383            use crate::logging::LogFailure;
384            self.views
385                .manager
386                .record_use(&view.id, path)
387                .or_log("record a view's use");
388        }
389        let previous = self.views.active_id.replace(view.id.clone());
390        self.restore_view_chart(view.settings.chart.as_ref());
391        let Some(state) = self.data_table_state.as_ref() else {
392            return;
393        };
394        self.prompt.query_running = Some(QueryRun {
395            origin: RunOrigin::View {
396                previous,
397                matched: why.map(|why| (view.name.clone(), why)),
398            },
399            frame: state.len_generation(),
400            rollback,
401            counts: self.counting.markers(),
402            rows: None,
403        });
404        if !self.spawn_async_collect(Self::APPLYING_VIEW) {
405            // Nothing to read: the view has no rows. Applied on open, it was the open's last
406            // step.
407            if let Some(why) = why {
408                self.flash_view_applied(&view.name, why);
409            }
410            self.prompt.query_running = None;
411            self.busy = false;
412            self.status_message = None;
413            self.first_rows_settled();
414        }
415    }
416
417    /// Whether a view is being applied at the table (its pivot or first rows are read).
418    pub(crate) fn view_applying(&self) -> bool {
419        if !self.is_busy() || !self.in_normal_table_view() {
420            return false;
421        }
422        let pivot = self
423            .jobs
424            .current(|job| matches!(job, Job::ViewPivot(_)))
425            .is_some();
426        let rows = self.prompt.query_running.as_ref().is_some_and(|run| {
427            matches!(run.origin, RunOrigin::View { .. })
428                && self
429                    .data_table_state
430                    .as_ref()
431                    .is_some_and(|state| state.len_generation() == run.frame)
432        });
433        pivot || rows
434    }
435
436    /// Stop applying a view and keep the previous one; the bump drops the worker's
437    /// answer.
438    pub(crate) fn cancel_view(&mut self) {
439        self.jobs.advance();
440        self.screen_generation = self.screen_generation.wrapping_add(1);
441        if let Some(run) = self.take_query_run() {
442            self.roll_back_query_run(run);
443        }
444        // A collect for the view, queued behind a worker, would read it after all.
445        self.forget_the_rows_read();
446        self.read_after_view_rollback();
447        self.flash_note("View cancelled".to_string());
448    }
449
450    /// A view's pivot could not be read or planned: the view before it stays.
451    pub(crate) fn view_pivot_failed(&mut self, message: &str) {
452        self.error_modal
453            .show(format!("Error applying view: {message}"));
454        self.read_after_view_rollback();
455    }
456
457    /// The view before a failed or cancelled one is back: read its rows if none are on
458    /// hand, else stop being busy.
459    pub(crate) fn read_after_view_rollback(&mut self) {
460        self.busy = false;
461        self.status_message = None;
462        if !self.spawn_async_collect(Self::LOADING_BUFFER) {
463            self.first_rows_settled();
464        }
465    }
466
467    /// Run a view's steps on `state` in build order. With a pivot or melt: query,
468    /// filters and sort under it, the reshape, then query, filters and sort on its
469    /// result. Without: query, filters, sort. Column order last. Stops at the first
470    /// failing step, and at a pivot unless `pivoted` holds it.
471    pub(crate) fn replay_view(
472        state: &mut DataTableState,
473        settings: &view::ViewSettings,
474        pivoted: Option<DataFrame>,
475    ) -> Result<Replayed> {
476        if settings.pivot.is_some() || settings.melt.is_some() {
477            if let Some(source) = &settings.reshape_source {
478                Self::replay_query(
479                    state,
480                    source.sql_query.as_deref(),
481                    source.query.as_deref(),
482                    source.fuzzy_query.as_deref(),
483                )?;
484                Self::replay_filters_and_sort(
485                    state,
486                    &source.filters,
487                    &source.sort_columns,
488                    source.sort_directions(),
489                )?;
490            }
491            let reshaped = match (&settings.pivot, &settings.melt, pivoted) {
492                (Some(spec), _, Some(pivoted)) => state.install_pivot(spec, pivoted),
493                (Some(spec), _, None) => {
494                    Self::check_plan(state)?;
495                    return Ok(Replayed::Pivot(Box::new(state.plan_pivot(spec))));
496                }
497                (None, Some(spec), _) => state.melt(spec),
498                (None, None, _) => Ok(()),
499            };
500            reshaped.map_err(|e| {
501                color_eyre::eyre::eyre!(
502                    "{}",
503                    crate::error_display::user_message_from_report(&e, None)
504                )
505            })?;
506        }
507        Self::replay_query(
508            state,
509            settings.sql_query.as_deref(),
510            settings.query.as_deref(),
511            settings.fuzzy_query.as_deref(),
512        )?;
513        // Before the filters, which may compare in the types it gives.
514        if !settings.columns.is_empty() {
515            state.set_column_changes(&settings.columns);
516        }
517        Self::replay_filters_and_sort(
518            state,
519            &settings.filters,
520            &settings.sort_columns,
521            settings.sort_directions(),
522        )?;
523        if !settings.column_order.is_empty() {
524            state.set_column_order(settings.column_order.clone());
525            state.set_locked_columns(settings.locked_columns_count);
526        }
527        Self::check_plan(state)?;
528        Ok(Replayed::Planned)
529    }
530
531    /// Whether the frame the steps so far built can be read, by its plan alone.
532    fn check_plan(state: &DataTableState) -> Result<()> {
533        state.check_plan().map_err(|e| {
534            color_eyre::eyre::eyre!("{}", crate::error_display::user_message_from_polars(&e))
535        })
536    }
537
538    /// A view's query: SQL or q (at most one is stored), then a Text query.
539    fn replay_query(
540        state: &mut DataTableState,
541        sql: Option<&str>,
542        dsl: Option<&str>,
543        fuzzy: Option<&str>,
544    ) -> Result<()> {
545        let stated = |q: Option<&str>| q.filter(|q| !q.trim().is_empty()).map(str::to_string);
546        if let Some(sql) = stated(sql) {
547            state.sql_query(sql);
548        } else if let Some(query) = stated(dsl) {
549            state.query(query);
550        }
551        if state.error().is_none()
552            && let Some(fuzzy) = stated(fuzzy)
553        {
554            state.fuzzy_search(fuzzy);
555        }
556        match state.error().cloned() {
557            Some(error) => Err(color_eyre::eyre::eyre!(
558                "{}",
559                crate::error_display::user_message_from_polars(&error)
560            )),
561            None => Ok(()),
562        }
563    }
564
565    /// A view's sidebar filters, then its sort.
566    fn replay_filters_and_sort(
567        state: &mut DataTableState,
568        filters: &[FilterStatement],
569        sort_columns: &[String],
570        descending: Vec<bool>,
571    ) -> Result<()> {
572        if !filters.is_empty() {
573            state.filter(filters.to_vec());
574            if let Some(error) = state.error().cloned() {
575                return Err(color_eyre::eyre::eyre!("{}", error));
576            }
577        }
578        if !sort_columns.is_empty() {
579            state.sort_by(sort_columns.to_vec(), descending);
580            if let Some(error) = state.error().cloned() {
581                return Err(color_eyre::eyre::eyre!("{}", error));
582            }
583        }
584        Ok(())
585    }
586
587    pub fn create_view_from_current_state(
588        &mut self,
589        name: String,
590        description: Option<String>,
591        match_criteria: view::MatchCriteria,
592    ) -> Result<view::SavedView> {
593        let settings = match &self.data_table_state {
594            Some(state) => view::ViewSettings {
595                chart: self.saved_chart(),
596                ..view_settings_of(state)
597            },
598            None => view::ViewSettings {
599                chart: None,
600                sample: None,
601                query: None,
602                sql_query: None,
603                fuzzy_query: None,
604                filters: Vec::new(),
605                sort_columns: Vec::new(),
606                sort_descending: Vec::new(),
607                sort_ascending: true,
608                column_order: Vec::new(),
609                locked_columns_count: 0,
610                pivot: None,
611                melt: None,
612                reshape_source: None,
613                columns: Vec::new(),
614            },
615        };
616
617        self.views
618            .manager
619            .create_view(name, description, match_criteria, settings)
620    }
621}