|
49 | 49 | from sqlmesh.utils.errors import ( |
50 | 50 | PythonModelEvalError, |
51 | 51 | NodeAuditsErrors, |
| 52 | + SQLMeshError, |
52 | 53 | format_destructive_change_msg, |
53 | 54 | format_additive_change_msg, |
54 | 55 | ) |
|
64 | 65 | from sqlmesh.core.table_diff import TableDiff, RowDiff, SchemaDiff |
65 | 66 | from sqlmesh.core.config.connection import ConnectionConfig |
66 | 67 | from sqlmesh.core.state_sync import Versions |
| 68 | + from sqlmesh.core.engine_adapter.shared import QueryHistoryRecord |
| 69 | + from sqlmesh.core.environment import Environment |
67 | 70 |
|
68 | 71 | LayoutWidget = t.TypeVar("LayoutWidget", bound=t.Union[widgets.VBox, widgets.HBox]) |
69 | 72 |
|
@@ -227,6 +230,19 @@ def print_environments(self, environments_summary: t.List[EnvironmentSummary]) - |
227 | 230 | def show_intervals(self, snapshot_intervals: t.Dict[Snapshot, SnapshotIntervals]) -> None: |
228 | 231 | """Show ready intervals""" |
229 | 232 |
|
| 233 | + @abc.abstractmethod |
| 234 | + def select_plan(self, environments: t.List[Environment]) -> t.Tuple[str, Environment]: |
| 235 | + """Prompts the user to select a plan to inspect from the given environments.""" |
| 236 | + |
| 237 | + @abc.abstractmethod |
| 238 | + def show_history( |
| 239 | + self, |
| 240 | + records: t.List[QueryHistoryRecord], |
| 241 | + plan_id: str, |
| 242 | + environment: t.Optional[Environment] = None, |
| 243 | + ) -> None: |
| 244 | + """Show the query engine's history of everything SQLMesh ran for a plan.""" |
| 245 | + |
230 | 246 |
|
231 | 247 | class DifferenceConsole(abc.ABC): |
232 | 248 | """Console for displaying environment differences""" |
@@ -879,6 +895,19 @@ def print_environments(self, environments_summary: t.List[EnvironmentSummary]) - |
879 | 895 | def show_intervals(self, snapshot_intervals: t.Dict[Snapshot, SnapshotIntervals]) -> None: |
880 | 896 | pass |
881 | 897 |
|
| 898 | + def select_plan(self, environments: t.List[Environment]) -> t.Tuple[str, Environment]: |
| 899 | + raise SQLMeshError( |
| 900 | + "Cannot select a plan interactively with this console; pass plan_id explicitly." |
| 901 | + ) |
| 902 | + |
| 903 | + def show_history( |
| 904 | + self, |
| 905 | + records: t.List[QueryHistoryRecord], |
| 906 | + plan_id: str, |
| 907 | + environment: t.Optional[Environment] = None, |
| 908 | + ) -> None: |
| 909 | + pass |
| 910 | + |
882 | 911 | def show_linter_violations( |
883 | 912 | self, violations: t.List[RuleViolation], model: Model, is_error: bool = False |
884 | 913 | ) -> None: |
@@ -918,6 +947,40 @@ def make_progress_bar( |
918 | 947 | ) |
919 | 948 |
|
920 | 949 |
|
| 950 | +_SQL_OPERATION_KEYWORDS = ( |
| 951 | + "CREATE OR REPLACE TABLE", |
| 952 | + "CREATE OR REPLACE VIEW", |
| 953 | + "CREATE TABLE", |
| 954 | + "CREATE VIEW", |
| 955 | + "CREATE SCHEMA", |
| 956 | + "INSERT", |
| 957 | + "MERGE", |
| 958 | + "UPDATE", |
| 959 | + "DELETE", |
| 960 | + "ALTER", |
| 961 | + "DROP", |
| 962 | + "SELECT", |
| 963 | +) |
| 964 | + |
| 965 | + |
| 966 | +def _sql_operation(sql: str) -> str: |
| 967 | + """Best-effort extraction of the leading SQL keyword(s) for display, e.g. "CREATE TABLE". |
| 968 | +
|
| 969 | + Strips SQLMesh's leading correlation id comment first. This is a simple prefix match, not a |
| 970 | + real SQL parser - good enough for a debugging table, not for anything that needs to be exact. |
| 971 | + """ |
| 972 | + text = " ".join(sql.strip().split()) |
| 973 | + if text.startswith("/*"): |
| 974 | + end = text.find("*/") |
| 975 | + if end != -1: |
| 976 | + text = text[end + 2 :].strip() |
| 977 | + upper = text.upper() |
| 978 | + for keyword in _SQL_OPERATION_KEYWORDS: |
| 979 | + if upper.startswith(keyword): |
| 980 | + return keyword |
| 981 | + return upper.split(" ", 1)[0] if upper else "UNKNOWN" |
| 982 | + |
| 983 | + |
921 | 984 | class TerminalConsole(Console): |
922 | 985 | """A rich based implementation of the console.""" |
923 | 986 |
|
@@ -2722,6 +2785,89 @@ def show_intervals(self, snapshot_intervals: t.Dict[Snapshot, SnapshotIntervals] |
2722 | 2785 | if incomplete.children: |
2723 | 2786 | self._print(incomplete) |
2724 | 2787 |
|
| 2788 | + def select_plan(self, environments: t.List[Environment]) -> t.Tuple[str, Environment]: |
| 2789 | + if not environments: |
| 2790 | + raise SQLMeshError("No environments found in state to inspect.") |
| 2791 | + |
| 2792 | + ordered = sorted(environments, key=lambda e: e.finalized_ts or 0, reverse=True) |
| 2793 | + |
| 2794 | + labels = [] |
| 2795 | + for env in ordered: |
| 2796 | + applied = time_like_to_str(env.finalized_ts) if env.finalized_ts else "in progress" |
| 2797 | + labels.append( |
| 2798 | + f"{env.name} plan {env.plan_id[:8]} applied {applied} " |
| 2799 | + f"({len(env.snapshots)} models)" |
| 2800 | + ) |
| 2801 | + |
| 2802 | + response = self._prompt( |
| 2803 | + "\n".join([f"[{i + 1}] {label}" for i, label in enumerate(labels)]), |
| 2804 | + show_choices=False, |
| 2805 | + choices=[f"{i + 1}" for i in range(len(ordered))], |
| 2806 | + ) |
| 2807 | + chosen = ordered[int(response) - 1] |
| 2808 | + return chosen.plan_id, chosen |
| 2809 | + |
| 2810 | + def show_history( |
| 2811 | + self, |
| 2812 | + records: t.List[QueryHistoryRecord], |
| 2813 | + plan_id: str, |
| 2814 | + environment: t.Optional[Environment] = None, |
| 2815 | + ) -> None: |
| 2816 | + succeeded = sum(1 for r in records if r.status == "success") |
| 2817 | + failed = sum(1 for r in records if r.status == "failed") |
| 2818 | + running = sum(1 for r in records if r.status == "running") |
| 2819 | + |
| 2820 | + env_label = f" · {environment.name}" if environment is not None else "" |
| 2821 | + header = ( |
| 2822 | + f"[b]History · plan {plan_id[:8]}{env_label}[/b] · " |
| 2823 | + f"{len(records)} queries · {succeeded} ✓ {failed} ✗ {running} running" |
| 2824 | + ) |
| 2825 | + |
| 2826 | + if not records: |
| 2827 | + self.log_status_update( |
| 2828 | + f"{header}\n[yellow]No queries found for this plan in the engine's history " |
| 2829 | + "(the plan id may be incorrect, it may have aged out of the history window, " |
| 2830 | + "or nothing ran).[/yellow]" |
| 2831 | + ) |
| 2832 | + return |
| 2833 | + |
| 2834 | + glyph = { |
| 2835 | + "success": "[green]✓[/green]", |
| 2836 | + "failed": "[red]✗[/red]", |
| 2837 | + "running": "[yellow]…[/yellow]", |
| 2838 | + } |
| 2839 | + table = Table(title=header) |
| 2840 | + table.add_column("Time", no_wrap=True) |
| 2841 | + table.add_column("Status", no_wrap=True) |
| 2842 | + table.add_column("Operation", no_wrap=True) |
| 2843 | + table.add_column("Duration", justify="right", no_wrap=True) |
| 2844 | + table.add_column("Bytes/Rows", justify="right", no_wrap=True) |
| 2845 | + |
| 2846 | + for record in records: |
| 2847 | + started = record.started_at.strftime("%H:%M:%S") if record.started_at else "-" |
| 2848 | + duration = f"{record.duration_ms} ms" if record.duration_ms is not None else "-" |
| 2849 | + if record.bytes_processed is not None: |
| 2850 | + size = f"{record.bytes_processed:,} bytes" |
| 2851 | + elif record.rows is not None: |
| 2852 | + size = f"{record.rows:,} rows" |
| 2853 | + else: |
| 2854 | + size = "-" |
| 2855 | + table.add_row( |
| 2856 | + started, |
| 2857 | + glyph.get(record.status, record.status), |
| 2858 | + _sql_operation(record.sql), |
| 2859 | + duration, |
| 2860 | + size, |
| 2861 | + ) |
| 2862 | + if record.error: |
| 2863 | + table.add_row("", "", f"[red]{record.error}[/red]", "", "") |
| 2864 | + |
| 2865 | + self._print(table) |
| 2866 | + if failed: |
| 2867 | + self.log_status_update( |
| 2868 | + f"[red]{failed} failed[/red] · re-run with `-o failures.sql` to export the SQL." |
| 2869 | + ) |
| 2870 | + |
2725 | 2871 | def print_connection_config(self, config: ConnectionConfig, title: str = "Connection") -> None: |
2726 | 2872 | tree = Tree(f"[b]{title}:[/b]") |
2727 | 2873 | tree.add(f"Type: [bold cyan]{config.type_}[/bold cyan]") |
|
0 commit comments