Spaces:
Sleeping
Sleeping
|
Download docs/domain-layer.md from Sid-WC121/visual-notebook: direct link, hf CLI and curl.
- Browser
- Download file 14.5 kB
-
https://huggingface.co/spaces/Sid-WC121/visual-notebook/resolve/main/docs/domain-layer.md
- Command line
-
hf download hf://spaces/Sid-WC121/visual-notebook/docs/domain-layer.md
-
curl -L -o domain-layer.md https://huggingface.co/spaces/Sid-WC121/visual-notebook/resolve/main/docs/domain-layer.md
14.5 kB
| # Domain Layer | |
| This document describes the core domain model of the Visual Notebook backend, including the `DataFrameRef` abstraction, the history tree, the operations registry, the checkpoint system, and validation helpers. | |
| ## DataFrameRef: Unified Wrapper | |
| `DataFrameRef` is the central abstraction in the domain layer. It provides a uniform interface over two very different computation engines -- DuckDB and Polars -- allowing the rest of the application to interact with data without knowing which engine holds it. | |
| ### Dataclass Definition | |
| ```python | |
| @dataclass | |
| class DataFrameRef: | |
| backend: Literal["duckdb", "polars"] | |
| name: str | None = None # DuckDB table name | |
| lf: pl.LazyFrame | None = None # Polars LazyFrame | |
| con: duckdb.DuckDBPyConnection | None = None | |
| source_path: str | None = None # Original CSV path | |
| metadata: dict[str, Any] = field(default_factory=dict) | |
| ``` | |
| The `backend` field is a discriminating literal that determines which code path each method follows. The other fields are populated according to the backend: DuckDB-backed refs set `name` and `con`, while Polars-backed refs set `lf`. | |
| ### Methods | |
| #### `head(n=50, offset=0) -> pl.DataFrame` | |
| Returns a Polars DataFrame containing the first `n` rows starting from `offset`. For DuckDB, this executes `SELECT * FROM "{name}" LIMIT {n} OFFSET {offset}` and converts the result rows into a Polars DataFrame. For Polars, it uses `lf.slice(offset, n).collect()`. | |
| The return type is always `pl.DataFrame` regardless of backend, ensuring a consistent interface for the API layer. | |
| #### `schema() -> list[dict[str, str]]` | |
| Returns column names and types. For DuckDB, it queries `information_schema.columns`. For Polars, it reads `lf.schema`. The return format is a list of dictionaries with `name` and `dtype` keys. | |
| #### `to_polars_df() -> pl.DataFrame` | |
| Materializes the entire dataset as a Polars DataFrame. For DuckDB, this executes a full table scan (`SELECT *`) and converts the result. For Polars, it calls `lf.collect()`. This method should be used with caution on large datasets as it loads all data into memory. | |
| #### `to_lazy() -> pl.LazyFrame` | |
| Returns a Polars LazyFrame representation. For Polars, it returns `lf` directly. For DuckDB, it materializes the full DataFrame and converts it to a LazyFrame via `df.lazy()`. This is used by operations that require the Polars expression API. | |
| #### `row_count() -> int` | |
| Returns the total number of rows. For DuckDB, this executes `SELECT COUNT(*)`. For Polars, it uses `lf.select(pl.len()).collect().item()`. Both approaches are optimized for their respective engines. | |
| #### `materialize_to_duckdb(table_name, con) -> DataFrameRef` | |
| Converts a Polars LazyFrame into a DuckDB table and returns a new `DataFrameRef` with backend set to `"duckdb"`. This is the key method for bridging the two engines: | |
| ```python | |
| df = self.lf.collect() | |
| arrow_tbl = df.to_arrow() | |
| con.register("pl_df", arrow_tbl) | |
| con.execute(f'CREATE OR REPLACE TABLE "{table_name}" AS SELECT * FROM pl_df') | |
| con.unregister("pl_df") | |
| ``` | |
| The transfer uses Apache Arrow as the interchange format. Polars converts its in-memory representation to an Arrow table, which DuckDB registers as a virtual table, then materializes into a native DuckDB table. This path avoids serialization to CSV or JSON, preserving type information and achieving high throughput. | |
| #### `clone() -> DataFrameRef` | |
| Creates a shallow copy of the DataFrameRef. The underlying data (DuckDB table or Polars LazyFrame) is not copied, only the reference. This is used when creating history snapshots that need independent metadata. | |
| ### Why Two Backends | |
| The dual-engine strategy exists because DuckDB and Polars have complementary strengths: | |
| - **DuckDB** excels at SQL-based analytics. Its `SUMMARIZE` command, `read_csv_auto`, `COPY` for export, and window functions are all SQL-native features that would require significant effort to replicate in Polars. DuckDB also handles large datasets that exceed memory by spilling to disk. | |
| - **Polars** excels at programmatic transformations. Its expression API (`pl.col()`, `.filter()`, `.sort()`, `.group_by()`) provides type-safe, composable operations that are easier to maintain and test than dynamically generated SQL strings. Polars' lazy evaluation enables query optimization, and its Rust-based engine provides excellent CPU parallelism. | |
| The architecture keeps DuckDB as the canonical storage and query engine, using Polars only for transformation steps. After each Polars operation, the result is materialized back into DuckDB via `materialize_to_duckdb()`, ensuring that schema inference and chart computation always operate on DuckDB tables. | |
| ## History Tree: Memento Pattern | |
| The history system implements the Memento design pattern with a tree structure that supports linear undo/redo and non-linear branching. | |
| ### State Dataclass | |
| ```python | |
| @dataclass | |
| class State: | |
| state_id: str | |
| ref: DataFrameRef | |
| operation: str | None = None | |
| params: dict[str, Any] = field(default_factory=dict) | |
| metadata: dict[str, Any] = field(default_factory=dict) | |
| parent_id: str | None = None | |
| ``` | |
| Each `State` captures: | |
| - **state_id**: An 8-character hex identifier for quick lookup. | |
| - **ref**: The `DataFrameRef` at this point in the history. Because DuckDB tables are immutable once created (operations create new tables), the ref can be safely shared across states. | |
| - **operation**: The operation key (e.g., `"filter"`, `"sort"`, `"upload"`) that produced this state. | |
| - **params**: The validated parameters used for the operation. | |
| - **parent_id**: The state ID of the parent, enabling tree traversal. | |
| ### History Class | |
| ```python | |
| class History: | |
| states: dict[str, State] # All states by ID | |
| current_id: str | None # Current state pointer | |
| children: dict[str, list[str]] # Parent -> child ID mapping | |
| order: list[str] # Linear order for undo/redo | |
| ``` | |
| #### `commit(ref, operation, params) -> State` | |
| Creates a new `State`, appends it to the history, and updates the current pointer: | |
| 1. Generates a unique `state_id`. | |
| 2. Creates a `State` with the current ID as `parent_id`. | |
| 3. Stores the state in `self.states`. | |
| 4. Adds the state ID to `self.children[parent_id]`. | |
| 5. Appends the state ID to `self.order`. | |
| 6. Sets `self.current_id` to the new state ID. | |
| #### `undo() -> State | None` | |
| Moves the current pointer backward in the linear order: | |
| ```python | |
| idx = self.order.index(self.current_id) | |
| if idx > 0: | |
| self.current_id = self.order[idx - 1] | |
| return self.current | |
| ``` | |
| Returns `None` if already at the first state. The tree structure is preserved; undo does not remove states. | |
| #### `redo() -> State | None` | |
| Moves the current pointer forward in the linear order: | |
| ```python | |
| idx = self.order.index(self.current_id) | |
| if idx < len(self.order) - 1: | |
| self.current_id = self.order[idx + 1] | |
| return self.current | |
| ``` | |
| Returns `None` if already at the last state. | |
| #### `branch(state_id) -> State | None` | |
| Resets the current pointer to an arbitrary state by ID, effectively creating a branch point. The next `commit()` will create a child of this state. This allows users to explore alternate transformation paths from any historical state. | |
| #### `can_undo() / can_redo() -> bool` | |
| Check whether the current state is at the boundary of the linear order. These are used by the frontend to enable or disable undo/redo buttons. | |
| #### `to_tree() -> dict` | |
| Serializes the entire history as a JSON-compatible tree structure for the frontend: | |
| ```python | |
| { | |
| "nodes": [ | |
| {"id": "abc123", "operation": "upload", "params": {}, "parent_id": None, "children": ["def456"], "is_current": False}, | |
| {"id": "def456", "operation": "filter", "params": {"column": "age", "operator": "gt", "value": 30}, "parent_id": "abc123", "children": [], "is_current": True} | |
| ], | |
| "current_id": "def456", | |
| "order": ["abc123", "def456"] | |
| } | |
| ``` | |
| ### Tree Structure vs. Linear Order | |
| The `children` dictionary enables tree visualization in the frontend, showing branching paths. The `order` list provides a simple linear sequence for undo/redo. In practice, most users interact with history linearly, but the tree structure preserves all exploration paths. | |
| ## Operations Registry | |
| The operations registry is a dictionary mapping operation keys to `OperationDef` instances. Each operation is self-contained with its own validation and apply logic. | |
| ### OperationDef Dataclass | |
| ```python | |
| @dataclass | |
| class OperationDef: | |
| key: str # Unique identifier | |
| label: str # Human-readable name | |
| description: str # Short description | |
| param_schema: dict[str, Any] # JSON Schema for parameters | |
| validate: Callable[[dict, list[dict]], dict] # (params, schema) -> validated params | |
| apply: Callable[[DataFrameRef, dict], DataFrameRef] # (ref, params) -> new ref | |
| ``` | |
| The `validate` function receives the raw parameters and the current table schema, and returns validated parameters or raises `ValueError`. The `apply` function receives a `DataFrameRef` and validated parameters, and returns a new `DataFrameRef`. | |
| ### All Seven Operations | |
| | Key | Label | Description | Required Params | | |
| |-----|-------|-------------|-----------------| | |
| | `filter` | Filter | Filter rows by condition | `column`, `operator` | | |
| | `sort` | Sort | Sort by column | `column` | | |
| | `select` | Select Columns | Keep only selected columns | `columns` | | |
| | `rename` | Rename Columns | Rename columns using a mapping | `mapping` | | |
| | `groupby` | Group By | Aggregate data by groups | `group_by`, `aggregations` | | |
| | `fill_nulls` | Fill Nulls | Fill null values using a strategy | (none required) | | |
| | `clip` | Clip Values | Clip values to a range | `column` | | |
| ### Dual-Engine Implementation | |
| Each operation implements both DuckDB and Polars code paths: | |
| **DuckDB path**: Generates SQL statements and executes `CREATE OR REPLACE TABLE` to produce a new table. Each operation creates a uniquely named table (e.g., `_filt_a3b2c1` for filter, `_sort_d4e5f6` for sort) to avoid collisions with existing tables. | |
| **Polars path**: Uses Polars' expression API to build lazy transformations. The resulting `DataFrameRef` has `backend="polars"` and contains the transformed `LazyFrame`. | |
| Example for the filter operation: | |
| ```python | |
| # DuckDB | |
| if ref.backend == "duckdb": | |
| new_name = f"_filt_{uuid.uuid4().hex[:6]}" | |
| sql = f'SELECT * FROM "{ref.name}" WHERE "{column}" = {value!r}' | |
| ref.con.execute(f'CREATE OR REPLACE TABLE "{new_name}" AS {sql}') | |
| return DataFrameRef(backend="duckdb", name=new_name, con=ref.con) | |
| # Polars | |
| elif ref.backend == "polars": | |
| lf = ref.lf.filter(pl.col(column) == value) | |
| return DataFrameRef(backend="polars", lf=lf, source_path=ref.source_path) | |
| ``` | |
| ### Polars Materialization Back to DuckDB | |
| After each operation, the route handler checks if the result is a Polars ref that needs to be materialized: | |
| ```python | |
| if new_ref.backend == "polars" and session.current_ref.backend == "duckdb": | |
| new_table_name = f"_op_{uuid.uuid4().hex[:6]}" | |
| new_ref = new_ref.materialize_to_duckdb(new_table_name, ref.con) | |
| ``` | |
| This ensures that the system always returns to a DuckDB-backed state after each operation, maintaining consistency for downstream queries. | |
| ### Filter Operators | |
| The filter operation supports nine comparison operators: | |
| | Operator | DuckDB SQL | Polars Expression | | |
| |----------|------------|-------------------| | |
| | `eq` | `= value` | `col == value` | | |
| | `neq` | `!= value` | `col != value` | | |
| | `gt` | `> value` | `col > value` | | |
| | `lt` | `< value` | `col < value` | | |
| | `gte` | `>= value` | `col >= value` | | |
| | `lte` | `<= value` | `col <= value` | | |
| | `contains` | `LIKE '%value%'` | `col.cast(Utf8).str.contains(value)` | | |
| | `is_null` | `IS NULL` | `col.is_null()` | | |
| | `is_not_null` | `IS NOT NULL` | `col.is_not_null()` | | |
| ### GroupBy Aggregation Functions | |
| The groupby operation supports five aggregation functions: `mean`, `sum`, `count`, `min`, and `max`. Each is expressed both as a SQL aggregate function and as a Polars expression: | |
| ```python | |
| # DuckDB | |
| agg_parts.append(f'{func}("{col}") AS "{col}_{func}"') | |
| # Polars | |
| if func == "mean": | |
| agg_exprs.append(pl.col(col).mean().alias(f"{col}_mean")) | |
| ``` | |
| ### Fill Nulls Strategies | |
| | Strategy | DuckDB | Polars | | |
| |----------|--------|--------| | |
| | `forward` | `COALESCE(col, LAG(col) OVER (...))` | `lf.fill_null(strategy="forward")` | | |
| | `backward` | (same pattern with LEAD) | `lf.fill_null(strategy="backward")` | | |
| | `mean` | `COALESCE(col, AVG(col) OVER ())` | `lf.fill_null(strategy="mean")` | | |
| | `zero` | `COALESCE(col, 0)` | `lf.fill_null(value=0)` | | |
| | `constant` | `COALESCE(col, 'value')` | `lf.fill_null(value=fill_value)` | | |
| ## Checkpoints | |
| The checkpoint system serializes the session state to disk, allowing users to preserve their work. | |
| ### Save Checkpoint | |
| The `save_checkpoint()` function writes two files to the checkpoint directory: | |
| 1. **JSON metadata** (`checkpoint_YYYYMMDD_HHMMSS.json`): Contains the session ID, timestamp, and the full history tree (nodes, current_id, order). | |
| 2. **Parquet data** (`data_YYYYMMDD_HHMMSS.parquet`): Contains the current table data exported via DuckDB's `COPY ... FORMAT PARQUET`. This provides a compact, typed representation of the data. | |
| The Parquet export is non-critical -- if it fails, the checkpoint still saves the metadata. This ensures that a schema-only or history-only checkpoint is always available. | |
| ### Load Checkpoint | |
| The `load_latest_checkpoint()` function finds the most recent checkpoint JSON file (sorted by filename in reverse order) and returns the parsed metadata. | |
| ### List Checkpoints | |
| The `list_checkpoints()` function returns a list of all checkpoints for a session, sorted from newest to oldest, including the file path, timestamp, and session ID. | |
| ## Validators | |
| The `validators.py` module provides helper functions for operation parameter validation: | |
| | Function | Purpose | | |
| |----------|---------| | |
| | `validate_column_exists(column, schema)` | Returns True if the column name is found in the schema | | |
| | `validate_columns_exist(columns, schema)` | Returns a list of column names that do NOT exist | | |
| | `validate_numeric_column(column, schema)` | Returns True if the column is numeric or geo_lat_lon type | | |
| These helpers are used by the operation `validate` functions to ensure that user-provided column names exist and have appropriate types before the operation is applied. | |