diff --git a/CHANGELOG.md b/CHANGELOG.md index a913c3619..8b3ccda8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ ## [Unreleased] +### Added + +- New `TABULATE` clause renders a query result as a table instead of a + plot. Tables are rendered via new `html` writer. `TABULATE` supports the + following clauses: + - `LABEL` sets (sub)titles, captions and overrides the display of column + labels. + - `SPAN` groups columns under a shared header cell (spanner). + - `FORMAT` configures how a column's cells are displayed. +- `Writer::write_table` renders a resolved table, taking the new public + `TableLayout` — the table's cells bundled with their column and row + metadata. + ### Changed - The reader/dialect layer was refactored around a central dialect registry with @@ -16,9 +29,6 @@ the data carried microsecond timestamps (#569). - Extent queries are now merged and ordered portably, fixing schema resolution on MySQL and ClickHouse (#569). - -### Fixed - - Fixed a parser bug that interpreted comment characters inside string literals as initializing a comment (#555). - Fixed a bug in stat_aggregate prevented transposed layers from properly diff --git a/CLAUDE.md b/CLAUDE.md index a4d83652f..3f12d2e69 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,7 +48,7 @@ For details — module layout, traits, where extension points live — see [`src ## Building -**Prerequisite: `tree-sitter-cli`.** Any Rust build regenerates the parser from `grammar.js` via `tree-sitter-ggsql`'s build script, which runs `tree-sitter generate` and **fails if `tree-sitter-cli` is not on `PATH`**. Install it once with `npm install -g tree-sitter-cli`. To build against the committed `tree-sitter-ggsql/src/parser.c` without the CLI (e.g. if you're not touching the grammar), set `GGSQL_SKIP_GENERATE=1`. +**Prerequisite: `tree-sitter-cli`.** Any Rust build regenerates the parser from `grammar.js` via `tree-sitter-ggsql`'s build script, which runs `tree-sitter generate` and **fails if `tree-sitter-cli` is not on `PATH`**. Install it once with `npm install -g tree-sitter-cli`. To build against a pre-generated `tree-sitter-ggsql/src/parser.c` without the CLI (e.g. if you're not touching the grammar), set `GGSQL_SKIP_GENERATE=1`. ```sh # Rust workspace (default members: tree-sitter-ggsql, src, ggsql-cli, ggsql-jupyter) diff --git a/doc/_quarto.yml b/doc/_quarto.yml index 83ff52fa1..948f1083d 100644 --- a/doc/_quarto.yml +++ b/doc/_quarto.yml @@ -53,6 +53,15 @@ website: href: syntax/clause/project.qmd - text: "`LABEL`" href: syntax/clause/label.qmd + - text: "---" + - text: "`TABULATE`" + href: syntax/clause/tabulate.qmd + - text: "`LABEL`" + href: syntax/clause/label-tabulate.qmd + - text: "`SPAN`" + href: syntax/clause/span.qmd + - text: "`FORMAT`" + href: syntax/clause/format.qmd - text: Gallery href: gallery/index.qmd - href: faq.qmd @@ -92,7 +101,7 @@ website: contents: - text: Overview href: syntax/index.qmd - - section: "Main clauses" + - section: "Visualise clauses" contents: - text: "`VISUALISE`" href: syntax/clause/visualise.qmd @@ -108,6 +117,16 @@ website: href: syntax/clause/project.qmd - text: "`LABEL`" href: syntax/clause/label.qmd + - section: "Tabulate clauses" + contents: + - text: "`TABULATE`" + href: syntax/clause/tabulate.qmd + - text: "`LABEL`" + href: syntax/clause/label-tabulate.qmd + - text: "`SPAN`" + href: syntax/clause/span.qmd + - text: "`FORMAT`" + href: syntax/clause/format.qmd - section: Layers contents: - section: Types diff --git a/doc/ggsql.xml b/doc/ggsql.xml index 26955ec78..922711c7d 100644 --- a/doc/ggsql.xml +++ b/doc/ggsql.xml @@ -87,17 +87,8 @@ DESC - - - DRAW - PLACE - SCALE - PROJECT - FACET - LABEL - VISUALISE - VISUALIZE - + @@ -110,6 +101,8 @@ RENAMING TO VIA + OVER + UNDER @@ -397,6 +390,7 @@ + @@ -444,6 +438,7 @@ + @@ -484,6 +479,7 @@ + @@ -523,6 +519,7 @@ + @@ -569,6 +566,7 @@ + @@ -612,6 +610,7 @@ + @@ -654,6 +653,9 @@ + + + @@ -676,6 +678,123 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -691,6 +810,7 @@ + diff --git a/doc/syntax/clause/format.qmd b/doc/syntax/clause/format.qmd new file mode 100644 index 000000000..735f8d2fe --- /dev/null +++ b/doc/syntax/clause/format.qmd @@ -0,0 +1,161 @@ +--- +title: "Configure column display using `FORMAT`" +--- + +The `FORMAT` clause is used to tweak the formatting and structure of one or more whole columns. +It is the workhorse for column-focussed changes: value formatting, display properties and stub design. + +## Clause syntax + +```ggsql +FORMAT [BODY | STUB] , ... + SETTING => , ... + RENAMING => , ... +``` +## Target + +``` +[BODY | STUB] +``` + +The target directly following the `FORMAT` clause determines what kind of column is being formatted. +The optional target defaults to `BODY` which targets cells in the table body. +`FORMAT` can be used without spelling out a target, in which case it is interpreted as a `BODY` target. +The target can be set to `STUB`, which will move the column to the left of the table to serve as row labels. + +```ggsql +-- Same as `FORMAT BODY foo, bar` +FORMAT foo, bar + +-- Designate column as stub +FORMAT STUB foo +``` + +## Column selection + +```ggsql +, ... +``` + +The `FORMAT` clause requires a comma-separated list of columns to format. +In addition to column names, it can use [`SPAN`](span.qmd) identifiers as well to target the spanner's columns. + +```ggsql +-- Targeting regular columns +FORMAT foo, bar + +-- Targeting spanner id: bar, baz and qux become targeted +SPAN foo OVER bar, baz +FORMAT foo, qux +``` + +## Settings + +```ggsql +SETTING => , ... +``` + +It takes a typical `SETTING` clause with key-value pairs. +It currently takes the following parameters: + +* `hjust`: numeric value between 0-1 or string, for horizontal justification of cell content. + Strings can be `'left'`, `'right'`', `'centre'`, or `'center'`. + By default content is left-aligned for strings, and right-aligned for numbers or stub columns. +* `width`: a unit as a string giving the number of pixels (e.g. `'100px'`) or a percentage (`'20%'`). + Sets the display width of the column. + +## Renaming + +```ggsql +RENAMING => , ... +``` + +The `RENAMING` clause is *the* place to format the cell contents. +It works the same as the namesake in the [`SCALE`](scale.qmd#renaming) clause. +Generally, it works not unlike the [`LABEL`](label-tabulate.qmd) clause in the sense that you specify a cell's content on the left and a replacement value on the right. +Alternatively, you can use a formatter with the `* => '...'` syntax. +Specifically see the [break formatting](scale.qmd#break-formatting) section for a list of formatters. + +```ggsql +-- Replace a specific piece of content +FORMAT species RENAMING 'adelie' => 'Pygoscelis adeliae' + +-- Suppress/hide a specific piece of content +FORMAT species RENAMING 'adelie' => null + +-- Format all cells in the column as lower-case +FORMAT species RENAMING * => '{:lower}' +``` + +## Examples + +Declare a stub column. + +```{ggsql} +CREATE TEMP TABLE my_data AS SELECT * FROM ggsql:penguins LIMIT 5 + +TABULATE * FROM my_data + FORMAT STUB island +``` + +Stubheads, the column labels for stub columns, are hidden by default. +To display these, you can use the `LABEL` clause. + +```{ggsql} +TABULATE * FROM my_data + FORMAT STUB island + LABEL island => 'Island' +``` + +Stub columns cannot be targetted by spans. The boundary between stub and body is un-spannable. + +```{ggsql} +#| error: true +TABULATE * FROM my_data + FORMAT STUB island + SPAN "My label" OVER island, species +``` + +When a column is targeted by multiple `FORMAT` clauses, the last one wins. +Note that the `island` column is not in the stub. + +```{ggsql} +TABULATE * FROM my_data + FORMAT STUB island + FORMAT BODY island +``` + +Control content alignment. + +```{ggsql} +TABULATE * FROM my_data + FORMAT sex + SETTING hjust => 'right' +``` + +Set the width of columns. + +```{ggsql} +TABULATE * FROM my_data + FORMAT species, island + SETTING width => '150px' +``` + +Replacing specific pieces of content. + +```{ggsql} +TABULATE * FROM my_data + FORMAT body_mass, sex + RENAMING + null => '?', + 'male' => '♂', + 'female' => '♀' +``` + +Using a formatter to target all cells in a column. + +```{ggsql} +TABULATE * FROM my_data + FORMAT body_mass + RENAMING * => '{:num %.0f}g' +``` \ No newline at end of file diff --git a/doc/syntax/clause/label-tabulate.qmd b/doc/syntax/clause/label-tabulate.qmd new file mode 100644 index 000000000..7ec263f32 --- /dev/null +++ b/doc/syntax/clause/label-tabulate.qmd @@ -0,0 +1,81 @@ +--- +title: "Define different titles with `LABEL`" +--- + +::: {.callout-note} +This page is about the `TABULATE` usage of the `LABEL` clause. +For the `VISUALISE` usage, see [this page](label.qmd). +::: + +The `LABEL` clause is a straightforward clause allowing you to override the default naming of column labels, spans, (sub)titles and captions. +Unlike `VISUALISE`'s usage, there is no automatic labelling: a column with no label keeps its name as header. + +## Clause syntax +The `LABEL` clause requires a comma-separated list of key-value pairs + +```ggsql +LABEL => , ... +``` + +On the left hand side of the arrow, the following keys can be used: + +* Column names as they occur in the data +* Reserved keywords for special labels: + * `title` as the main title + * `subtitle` as the subtitle + * `caption` as the table caption +* Spanner IDs declared by `SPAN` clauses. + +On the right hand side of the arrow, one can use a string to give new label, or `null` to hide the label. + +## Rich text +The support of rich text in tables is planned but not yet implemented. + +## Examples + +Unlike using aliases in `TABULATE`, the `LABEL` clause let's you change column labels without changing column order + +```{ggsql} +CREATE TEMP TABLE my_data AS SELECT * FROM ggsql:penguins LIMIT 5 + +TABULATE * FROM my_data + LABEL bill_len => 'Bill length', bill_dep => 'Bill depth' +``` + +Hiding labels. + +```{ggsql} +TABULATE * FROM my_data + LABEL island => null, species => null +``` + +Adding titles to a table. + +```{ggsql} +TABULATE * FROM my_data + LABEL + title => 'Penguins!', + subtitle => 'Structural size measurements', + caption => 'Source: Palmer station, Antarctica' +``` + +When column names and reserved keywords compete for a label, the reserved keyword wins. + +```{ggsql} +-- data has column named 'title' +CREATE TEMP TABLE title_data AS +SELECT * FROM (VALUES (1, 'a'), (2, 'b'), (3, 'c')) AS t(id, title) + +TABULATE * FROM title_data + LABEL + title => 'Overrules column' +``` + +You can target an aliased column instead. + +```{ggsql} +TABULATE *, title AS title_col FROM title_data + LABEL + title => 'Main title', + title_col => 'Column title' +``` \ No newline at end of file diff --git a/doc/syntax/clause/label.qmd b/doc/syntax/clause/label.qmd index 619394d92..766f3d4b8 100644 --- a/doc/syntax/clause/label.qmd +++ b/doc/syntax/clause/label.qmd @@ -2,6 +2,11 @@ title: "Define different titles with `LABEL`" --- +::: {.callout-note} +This page is about the `VISUALISE` usage of the `LABEL` clause. +For the `TABULATE` usage, see [this page](label-tabulate.qmd). +::: + The `LABEL` clause is one of the simpler clauses in ggsql and allows you to override the default naming of axes and legends, as well as adding titles to the plot. ## Clause syntax @@ -34,8 +39,8 @@ Axes and legends get an automatic label from the mapping. The logic is as follow 1. Start from the global mapping and then go through the layer mappings as they appear. 2. Collect mappings for each aesthetic in the order they appear 3. For each aesthetic determine the label based on the following priority: - 1. An explicitly provided one with `LABEL` - 2. The name of the first appearing column mapping - 3. The name of the aesthetic + 1. An explicitly provided one with `LABEL` + 2. The name of the first appearing column mapping + 3. The name of the aesthetic -This means that constants mapped to an aesthetic doesn't contribute to the derived label, only data columns, and if all else fails the name of the aesthetic will be used. +This means that constants mapped to an aesthetic don't contribute to the derived label, only data columns, and if all else fails the name of the aesthetic will be used. diff --git a/doc/syntax/clause/span.qmd b/doc/syntax/clause/span.qmd new file mode 100644 index 000000000..aa1d990a6 --- /dev/null +++ b/doc/syntax/clause/span.qmd @@ -0,0 +1,132 @@ +--- +title: "Group columns with a spanner label using `SPAN`" +--- + +Columns can be grouped and will display a group label using spanners. +These are helpful in controlling the visual layout of a table. +Spanners are extra table cells in the heading that stretch on top of the column labels. +`SPAN` can be repeated to stack multiple spanners. + +## Clause syntax +There are two equivalent forms for convenience: + +```ggsql +SPAN OVER , ... + SETTING => , ... +``` + +or alternatively + +```ggsql +SPAN , ... UNDER + SETTING => , ... +``` + +## Identifier + +```ggsql + OVER +-- or +UNDER +``` + +The span identifier is mandatory and will be used as the spanner label by default. +The identifier can be these values: + +* An unquoted identifier +* An identifier with double-quotes or backtick-quotes, depending on the backend +* `null` + +A spanner can also be labelled using the [`LABEL`](label-tabulate.qmd) clause, using the span identifier as key. +This is the preferred method if the label contains exotic symbols that are not allowed in identifiers. + +A spanner identifier can **not** be the same as a column name. +If you want to display the same label as a column name, you can use the [`LABEL`](label-tabulate.qmd) clause too. + +Giving `null` as identifier makes an anonymous spanner for the side-effect of ordering columns. +Anonymous spanners do not render labels. + +## Column selection + +```ggsql +, ... +``` + +The list of comma-separated columns to be grouped can be declared after `OVER` or before `UNDER`. +In addition, the span identifiers of previously declared spans can be used as shorthand. +By default, columns will be relocated in the table to next to other group members. + +## Settings + +```ggsql +SETTING => , ... +``` + +The `SETTING` sub-clause currently understands two parameters. + +* `gather` (default: `true`) is a boolean and will move columns to sit next to other group members. + Columns will move to the first-listed group member, preserving the rest of the relative column order. + This can be set to `false` to leave columns in place. +* `level` is an integer that can be used to pin a spanner to a particular row, where `1` is closest to column labels. + The default is to automatically assign a level to avoid a clash with other spanners that share columns. + +## Examples + +Simple spanner across two columns. + +```{ggsql} +CREATE TEMP TABLE my_data AS SELECT * FROM ggsql:penguins LIMIT 5 + +TABULATE * FROM my_data + SPAN Bill OVER bill_len, bill_dep +``` + +By default, disjoint spanners are displayed on the same level. + +```{ggsql} +TABULATE * FROM my_data + SPAN Bill OVER bill_len, bill_dep + SPAN "My label" OVER sex, year +``` + +Setting a custom row level for a span using `SETTING`. + +```{ggsql} +TABULATE * FROM my_data + SPAN Bill OVER bill_len, bill_dep + SPAN "My label" OVER sex, year + SETTING level => 2 +``` + +Creating a stacked spanner. +Note the use of the `Bill` span identifier in the second spanner to include `bill_len` and `bill_dep`. + +```{ggsql} +TABULATE * FROM my_data + SPAN Bill OVER bill_len, bill_dep + SPAN Lengths OVER Bill, flipper_len +``` + +The `LABEL` clause can be used for complex labels. + +```{ggsql} +TABULATE * FROM my_data + SPAN Bill OVER bill_len, bill_dep + LABEL Bill => '🐧 bills' +``` + +Anonymous spanners can be used to reorder columns. + +```{ggsql} +TABULATE * FROM my_data + SPAN null OVER species, island, sex, year +``` + +Automatic grouping can be turned off using the `gather` setting. +Note that spanners may become fragmented if columns don't form a contiguous block. + +```{ggsql} +TABULATE * FROM my_data + SPAN "My label" OVER species, sex + SETTING gather => false +``` \ No newline at end of file diff --git a/doc/syntax/clause/tabulate.qmd b/doc/syntax/clause/tabulate.qmd new file mode 100644 index 000000000..98db11897 --- /dev/null +++ b/doc/syntax/clause/tabulate.qmd @@ -0,0 +1,102 @@ +--- +title: "Initialise a table with `TABULATE`" +--- + +The `TABULATE` clause marks the beginning of a ggsql table declaration, like `VISUALISE` does for ggsql plots. +As such it demarcates where the regular query ends and the table construction query starts. +Everything that comes before the `TABULATE` clause is considered a standard SQL query that is sent to the backend. +If that standard SQL ends with a `SELECT` query, this will automatically be added as the data for the table. + +Currently, the only writer supporting the `TABULATE` clause is the HTML writer. + +## Clause syntax +The `TABULATE` clause requires a column selection and optionally a data source. + +```ggsql +TABULATE , ... | * FROM +``` + +## Column selection + +```ggsql +, ... | * +``` + +After `TABULATE` you can list columns separated by commas to display in the table. +The order in which you select columns affects the display-order in the output. +The columns can be selected in (a mixture of) one of three forms: + +* `` reuses the column name as header label +* ` AS ` sets an alias for a column +* `*` selects all (remaining) columns, in the source's order + +Without the `*` wildcard, only the listed columns are kept. +The wildcard can be combined with explicit columns to partially order columns. +When a column is renamed using the `AS` keyword, other table clauses --- like `LABEL`, `SPAN` and `FORMAT` --- use the *new* name. +It is discouraged to name column `title`, `subtitle` or `captions` as these are keywords in the [`LABEL`](label-tabulate.qmd) clause. + +```ggsql +-- Select all columns +TABULATE * + +-- Select 3 specific columns +TABULATE foo, bar, baz + +-- Renaming columns +TABULATE foo AS bar, baz AS qux + +-- Fix position of first and last columns +TABULATE foo, *, baz +``` + +## Data source + +```ggsql +FROM +``` + +The `TABULATE` clause can have a `FROM` clause that defines the source of the data. +Alternatively data can be implied from any terminal `SELECT` clause in the preceding SQL query. +Having both a terminal `SELECT` clause and using `FROM` is an error. +The data received via `FROM` can take two forms: + +* Bare **Table** or **CTE** that is available in the backend, either as CTE defined in the preceding SQL query or as a proper table in the database. +* A **File path** as (single quoted) string, that points to a file that can be read by the backend. + +```ggsql +-- Use table as data source +TABULATE * FROM my_table + +-- Use file as data source +TABULATE * FROM 'my_dataset.csv' + +-- With preceding SQL +SELECT foo, bar, baz FROM my_table +TABULATE * +``` + +## Examples + +Plain table from data + +```{ggsql} +CREATE TEMP TABLE my_data AS SELECT * FROM ggsql:penguins LIMIT 5 + +TABULATE * FROM my_data +``` + +Reordering columns + +```{ggsql} +TABULATE island, *, species FROM my_data +``` + +Renaming columns on the fly. For order-preserving renames, see [`LABEL`](label-tabulate.qmd) clause. + +```{ggsql} +TABULATE + bill_len AS length, + bill_dep AS depth, + * + FROM my_data +``` diff --git a/doc/syntax/index.qmd b/doc/syntax/index.qmd index 9bef90778..9d66a2c17 100644 --- a/doc/syntax/index.qmd +++ b/doc/syntax/index.qmd @@ -2,7 +2,7 @@ title: "Syntax" --- -## Main clauses +## Visualisation clauses ggsql augments the standard SQL syntax with a number of new clauses to describe a visualisation. Apart from `VISUALISE` needing to be the first, the order of these clauses is arbitrary, though grouping e.g. all `DRAW` clauses will lead to queries that are easier to reason about: - [`VISUALISE`](clause/visualise.qmd) initiates the visualisation part of the query @@ -13,6 +13,13 @@ ggsql augments the standard SQL syntax with a number of new clauses to describe - [`PROJECT`](clause/project.qmd) is used for selecting the coordinate system to use - [`LABEL`](clause/label.qmd) is used to manually add titles to the plot or the various axes and legends +## Table clauses + +- [`TABULATE`](clause/tabulate.qmd) initialises the table part of the query +- [`LABEL`](clause/label-tabulate.qmd) is used to manually change column labels and titles +- [`SPAN`](clause/span.qmd) can be used to group columns and display a label across them +- [`FORMAT`](clause/format.qmd) declares formatting for columns + ## Layers There are many different layers to choose from when visualising your data. Some are straightforward translations of your data into visual marks such as a point layer, while others perform more or less complicated calculations like e.g. the histogram layer. A layer is selected by providing the layer name after the `DRAW` clause diff --git a/doc/vendor/SKILL.md b/doc/vendor/SKILL.md index f80edf179..967448065 100644 --- a/doc/vendor/SKILL.md +++ b/doc/vendor/SKILL.md @@ -29,6 +29,8 @@ Optional SQL part + required `VISUALISE`/`VISUALIZE` part. DRAW bar ``` +Use `TABULATE` instead of `VISUALISE` to render the result as a table (see below). A trailing SELECT combined with `VISUALISE FROM` / `TABULATE FROM` in the same query is a parse error — pick one pattern. + **Data sources** (in `VISUALISE ... FROM` or `DRAW ... MAPPING ... FROM`): unquoted table/CTE (`FROM sales`), quoted file path (`FROM 'data.csv'`), built-in dataset (`FROM ggsql:penguins`, `FROM ggsql:airquality`). ## VISUALISE clause @@ -143,6 +145,65 @@ LABEL title => 'Sales by Region', x => 'Date', y => 'Revenue (USD)', fill => nul --- +## TABULATE clause + +Renders the query result as a table instead of a plot — the plain-table counterpart to VISUALISE. Only the HTML writer supports tables. Both data patterns from VISUALISE apply (`SELECT ... TABULATE *` or `TABULATE ... FROM `). + +TABULATE , ... | * + FROM +LABEL => , ... +SPAN OVER , ... + SETTING => , ... +FORMAT [BODY | STUB] , ... + SETTING => , ... + RENAMING => , ... + +`LABEL`, `SPAN` and `FORMAT` may each appear multiple times, in any order. + +### Column selection + +- `` — keep as-is; ` AS ` — rename; `*` — all columns in source order +- Order controls display order; `*` can be mixed with named columns: `TABULATE foo, *, baz` +- A rename changes the actual column name, so `LABEL`/`SPAN`/`FORMAT` refer to the new name + +### LABEL (table) + +Keyed by column name, not aesthetic. Reserved keys: `title`, `subtitle`, `caption`. `null` blanks a header; if all headers are blank the header row is omitted. Also labels spanners via their SPAN id: `LABEL q1 => 'Q1 Totals'`. No automatic labelling and no markdown — a column without a `LABEL` keeps its name. + +### SPAN + +Groups columns under one spanner cell in a header row above the column labels. + +```ggsql +SPAN Q1 OVER jan, feb, mar +SPAN apr, may, jun UNDER Q2 -- equivalent form +``` + +- Id is mandatory: identifier (quote for spaces) or `null` (anonymous, blank cell — useful to reorder columns) +- A later SPAN's OVER list may reference an earlier SPAN id to include its columns +- Ids must be unique and cannot collide with column names; a SPAN cannot include a stub column +- Settings: `gather` (boolean, default `true` — move columns next to the group), `level` (integer header row, `1` = closest to column labels; default auto-assigned to avoid clashes) + +### FORMAT + +Configures cell display and column placement. + +- Target: `BODY` (default) or `STUB` (move column to row-label position; stub headers are blank unless labelled). If two FORMAT clauses name the same column, the later one wins. +- A SPAN id may stand in for its columns: `FORMAT Q1, price SETTING hjust => 'right'` +- Settings: `hjust` (`'left'`/`'right'`/`'centre'`/`'center'` or 0–1; default right for numeric/stub, left otherwise), `width` (`'100px'` or `'20%'`) +- `RENAMING` works like SCALE's: direct replacement (`'adelie' => 'Pygoscelis adeliae'`), `null` on the right suppresses the cell, unquoted `null` on the left matches missing values, and `* => '...'` formats every value with a template (`{:lower}`, `{:num %.0f}`, etc.) + +```ggsql +SELECT * FROM ggsql:penguins LIMIT 5 +TABULATE * + LABEL title => 'Penguins!', bill_len => 'Length', bill_dep => 'Depth' + SPAN Bill OVER bill_len, bill_dep + FORMAT STUB species + FORMAT body_mass SETTING hjust => 'right' RENAMING null => '-', * => '{:num %.0f}g' +``` + +--- + ## Layer types - **point** — required x, y; optional size, colour, stroke, fill, opacity, shape. diff --git a/ggsql-cli/CLAUDE.md b/ggsql-cli/CLAUDE.md index cf78d13b4..f9defa7c6 100644 --- a/ggsql-cli/CLAUDE.md +++ b/ggsql-cli/CLAUDE.md @@ -87,7 +87,7 @@ The macOS codesign step uses [`/entitlements.plist`](../entitlements.plist) at t ## Features ```toml -default = ["duckdb", "sqlite", "vegalite", "parquet", "builtin-data", "odbc", "svg", "pdf", "hep"] +default = ["duckdb", "sqlite", "vegalite", "html", "parquet", "builtin-data", "odbc", "svg", "pdf", "hep"] ``` Each feature passes through to `ggsql/`. A writer feature gates only its own row's render function in `writers.rs`; the row itself is always present. @@ -124,7 +124,7 @@ open target/visual-test/index.html Four properties are worth preserving when changing it: -- **One reader per source file, cells in document order.** Doc pages build a table in one cell and plot it in the next, so per-cell isolation would break the corpus. A cell with no `VISUALISE` (`validate(..).has_visual()` is false) runs as setup through `execute_sql`. +- **One reader per source file, cells in document order.** Doc pages build a table in one cell and plot it in the next, so per-cell isolation would break the corpus. A cell with no `VISUALISE`/`TABULATE` (`validate(..).has_spec()` is false) runs as setup through `execute_sql`. - **Cells run in their own page's directory**, as Quarto runs them, so a query reading `FROM 'minard_troops.csv'` finds the CSV sitting beside the `.qmd`. The report and its `assets/` are resolved to an absolute path up front, since they outlive that switch. - **Nothing aborts the run.** Execution errors, render errors and *panics* inside a writer are captured per cell (`capture`), so one report surfaces every problem in the corpus at once. This is the point of the tool — a run that stops at the first failure tells you almost nothing. - **Renders are files, specs are inline.** PNGs are written to `assets/`; Vega-Lite specs are embedded in `"), - "<script>alert('xss')</script>" - ); - } - /// A render backend with no GPU, so tests are fast and identical /// everywhere. The SVG path it leaves is the one that always works. fn backend() -> PlotBackend { diff --git a/ggsql-jupyter/src/executor.rs b/ggsql-jupyter/src/executor.rs index 40dcf2ef8..12972bd6c 100644 --- a/ggsql-jupyter/src/executor.rs +++ b/ggsql-jupyter/src/executor.rs @@ -12,16 +12,17 @@ use anyhow::Result; use ggsql::{ reader::{ connection::{extract_odbc_value, reader_from_uri}, - Reader, Spec, + Reader, ResolvedPlot, ResolvedSpec, }, validate::validate, + writer::{HtmlWriter, Writer}, DataFrame, }; /// A resolved plot has to reach a render thread, so the design rests on this. const _: () = { fn assert_send() {} - let _ = assert_send::; + let _ = assert_send::; }; /// Result of executing a ggsql query @@ -33,14 +34,17 @@ pub enum ExecutionResult { /// /// Not pre-rendered: the format depends on where the output is going, and /// once a plot comm is open it is asked again on every resize. Boxed because - /// a `Spec` carries the post-stat DataFrames and dwarfs the other variants. - Visualization(Box), + /// a `ResolvedPlot` carries the post-stat DataFrames and dwarfs the other + /// variants. + Visualization(Box), + /// TABULATE query, already rendered as an HTML table via `HtmlWriter`. + Table { html: String }, /// Connection changed via meta-command ConnectionChanged { display_name: String }, } -// `Spec` is neither `Debug` nor `Clone`, so this summarises rather than -// deriving. What a log wants from a result is its shape and size anyway. +// `ResolvedPlot` is neither `Debug` nor `Clone`, so this summarises rather +// than deriving. What a log wants from a result is its shape and size anyway. impl std::fmt::Debug for ExecutionResult { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -56,6 +60,10 @@ impl std::fmt::Debug for ExecutionResult { .field("layers", &metadata.layer_count) .finish() } + Self::Table { html } => f + .debug_struct("Table") + .field("html_len", &html.len()) + .finish(), Self::ConnectionChanged { display_name } => f .debug_struct("ConnectionChanged") .field("display_name", display_name) @@ -282,7 +290,7 @@ impl QueryExecutor { let validated = validate(code)?; // 2. Check if there's a visualization - if !validated.has_visual() { + if !validated.has_spec() { // Pure SQL query - execute directly and return DataFrame. let df = self.reader.execute_sql(code)?; tracing::info!( @@ -296,15 +304,38 @@ impl QueryExecutor { // 3. Execute ggsql query using reader let spec = self.reader.execute(code)?; - tracing::info!( - "Query executed: {} rows, {} layers", - spec.metadata().rows, - spec.metadata().layer_count - ); + // 4. A table is rendered here, since a static HTML table needs no + // choice about where the output is going. A plot is not: choosing a + // format is the display layer's job, because only it knows that. + match spec { + ResolvedSpec::Table(table) => { + tracing::info!( + "Query executed: {} rows, {} cols", + table.nrow(), + table.ncol() + ); + for warning in table.warnings() { + tracing::warn!("{}", warning.message); + } + + let html = HtmlWriter::new().write_table(table.layout())?; + tracing::debug!("Generated HTML table: {} chars", html.len()); + + Ok(ExecutionResult::Table { html }) + } + ResolvedSpec::Plot(plot) => { + tracing::info!( + "Query executed: {} rows, {} layers", + plot.metadata().rows, + plot.metadata().layer_count + ); + for warning in plot.warnings() { + tracing::warn!("{}", warning.message); + } - // 4. Hand back the resolved plot. Choosing a format is the display - // layer's job, because only it knows where the output is going. - Ok(ExecutionResult::Visualization(Box::new(spec))) + Ok(ExecutionResult::Visualization(plot)) + } + } } } @@ -321,6 +352,21 @@ mod tests { assert!(matches!(result, ExecutionResult::Visualization(_))); } + #[test] + fn test_tabulate() { + let mut executor = QueryExecutor::new().unwrap(); + let code = "SELECT 1 AS x, 2 AS y TABULATE *"; + let result = executor.execute(code).unwrap(); + + match result { + ExecutionResult::Table { html } => { + assert!(html.contains("data-quarto-bootstrap=\"false\" class=\"ggsql_table\">")); + assert!(html.contains(">x")); + } + other => panic!("expected Table, got {other:?}"), + } + } + #[test] fn test_pure_sql() { let mut executor = QueryExecutor::new().unwrap(); @@ -401,7 +447,7 @@ mod tests { ) .unwrap(); assert_eq!(executor.reader_uri(), "duckdb://memory"); - assert!(matches!(result, ExecutionResult::Visualization { .. })); + assert!(matches!(result, ExecutionResult::Visualization(_))); } #[test] @@ -423,7 +469,7 @@ mod tests { let result = executor .execute("-- @uncache\nSELECT 1 AS x, 2 AS y VISUALISE x, y DRAW point") .unwrap(); - assert!(matches!(result, ExecutionResult::Visualization { .. })); + assert!(matches!(result, ExecutionResult::Visualization(_))); } #[test] diff --git a/ggsql-jupyter/src/kernel.rs b/ggsql-jupyter/src/kernel.rs index 369061d74..4f957cc73 100644 --- a/ggsql-jupyter/src/kernel.rs +++ b/ggsql-jupyter/src/kernel.rs @@ -11,7 +11,7 @@ use crate::message::{ConnectionInfo, JupyterMessage, MessageHeader}; use crate::plot::comm::{PlotMetadata, RenderParams, RpcError}; use crate::plot::{PlotBackend, RenderOutcome, RenderTicket}; use anyhow::Result; -use ggsql::reader::Spec; +use ggsql::reader::ResolvedPlot; use hmac::{Hmac, Mac}; use serde_json::{json, Value}; use sha2::Sha256; @@ -38,7 +38,7 @@ pub struct KernelServer { /// Taken out of `self` before the event loop, because `select!` cannot /// borrow `self` mutably for this arm while the other arms do the same. render_outcomes: Option>, - /// Open plot comms, metadata only — each plot's `Spec` lives on the render + /// Open plot comms, metadata only — each plot's `ResolvedPlot` lives on the render /// thread, keeping the retained `DataFrame`s off the async task. plot_comms: HashMap, /// Comm ids in the order they were opened, for oldest-first eviction. @@ -937,7 +937,7 @@ impl KernelServer { /// from the `_recentExecutions` map that message populates. async fn open_plot_comm( &mut self, - spec: Box, + spec: Box, code: &str, parent: &JupyterMessage, ) -> Result<()> { @@ -1136,7 +1136,7 @@ impl KernelServer { // An error rather than `result: null`, so a future Positron method // fails visibly instead of being satisfied with garbage. `show` and // `update` belong here: they mean "re-fetch this figure", but a - // `Spec` is immutable per execution, so re-running a cell opens a + // `ResolvedPlot` is immutable per execution, so re-running a cell opens a // new comm as the R and matplotlib backends do. Do not add them. other => { let e = RpcError::MethodNotFound(format!("the plot comm has no '{other}' method")); diff --git a/ggsql-jupyter/src/plot/backend.rs b/ggsql-jupyter/src/plot/backend.rs index e72482f6d..20f64936d 100644 --- a/ggsql-jupyter/src/plot/backend.rs +++ b/ggsql-jupyter/src/plot/backend.rs @@ -28,7 +28,7 @@ use std::collections::HashMap; use std::sync::mpsc::{self, Receiver, Sender}; use anyhow::{anyhow, Result}; -use ggsql::reader::Spec; +use ggsql::reader::ResolvedPlot; use super::{Format, RenderRequest, RenderTicket}; @@ -56,7 +56,10 @@ pub struct RenderOutcome { enum Job { /// Keep `spec` so the plot can be re-drawn at a new size without re-running /// the query. Its `DataFrame`s stay here rather than on the async task. - Store { comm_id: String, spec: Box }, + Store { + comm_id: String, + spec: Box, + }, /// Forget a stored plot, because its comm closed or it was evicted. Forget { comm_id: String }, /// Re-render a stored plot and answer `reply` directly. @@ -79,7 +82,7 @@ enum Job { /// Render a plot we were handed and will not keep, answering `reply` /// directly. The one-shot path, for a static output bundle. RenderOnce { - spec: Box, + spec: Box, request: RenderRequest, reply: Sender>>, }, @@ -181,7 +184,7 @@ impl PlotBackend { /// /// Returns an error if the render thread has stopped, or if the render /// itself failed. - pub fn render_once(&self, spec: Box, request: RenderRequest) -> Result> { + pub fn render_once(&self, spec: Box, request: RenderRequest) -> Result> { let (reply, answer) = mpsc::channel(); self.jobs .send(Job::RenderOnce { @@ -196,7 +199,7 @@ impl PlotBackend { } /// Keep a plot so its comm can re-render it at any size. - pub fn store(&self, comm_id: String, spec: Box) { + pub fn store(&self, comm_id: String, spec: Box) { let _ = self.jobs.send(Job::Store { comm_id, spec }); } @@ -291,7 +294,7 @@ fn render_loop( // The retained plots. They live here rather than beside the comm state so // the post-stat `DataFrame`s stay off the async task entirely. - let mut stored: HashMap> = HashMap::new(); + let mut stored: HashMap> = HashMap::new(); while let Ok(job) = inbox.recv() { match job { @@ -359,7 +362,13 @@ fn warm_up(renderer: Option<&mut Renderer>) { let spec = match ggsql::reader::connection::reader_from_uri("duckdb://memory") .and_then(|reader| reader.execute(QUERY)) { - Ok(spec) => spec, + Ok(spec) => match spec.into_plot() { + Some(plot) => plot, + None => { + tracing::debug!("renderer warm-up skipped: not a plot"); + return; + } + }, Err(e) => { tracing::debug!("renderer warm-up skipped: {e}"); return; @@ -438,7 +447,7 @@ fn comm_namespace(comm_id: &str) -> String { /// `id_namespace` prefixes the ids the SVG writer generates; the other formats /// have no such thing and ignore it. fn render_one( - spec: &Spec, + spec: &ResolvedPlot, request: &RenderRequest, renderer: Option<&mut Renderer>, id_namespace: &str, @@ -514,7 +523,7 @@ mod tests { /// A plot with a colour scale, so the SVG carries a gradient — the ids that /// collide are the ones a legend gradient defines and references. - fn a_spec() -> Box { + fn a_spec() -> Box { use ggsql::reader::{DuckDBReader, Reader}; let query = "SELECT * FROM (VALUES (1,2,10),(2,3,50),(3,1,90)) t(x,y,c) \ VISUALISE x AS x, y AS y, c AS color DRAW point"; @@ -522,6 +531,8 @@ mod tests { DuckDBReader::from_connection_string("duckdb://memory") .unwrap() .execute(query) + .unwrap() + .into_plot() .unwrap(), ) } diff --git a/ggsql-wasm/src/lib.rs b/ggsql-wasm/src/lib.rs index 7f4daa203..93ab72aea 100644 --- a/ggsql-wasm/src/lib.rs +++ b/ggsql-wasm/src/lib.rs @@ -6,7 +6,7 @@ use ggsql::array_util::value_to_string; use ggsql::naming::DATA_PREFIX; use ggsql::reader::sqlite::SqliteReader; use ggsql::reader::Reader; -use ggsql::reader::Spec; +use ggsql::reader::ResolvedPlot; use ggsql::validate::validate; use ggsql::writer::SvgWriter; use ggsql::DataFrame; @@ -253,13 +253,16 @@ impl GgsqlContext { let spec = reader .execute(query) .map_err(|e| JsValue::from_str(&format!("Execute error: {:?}", e)))?; + let spec = spec.into_plot().ok_or_else(|| { + JsValue::from_str("TABULATE queries are not supported in the browser playground") + })?; Ok(GgsqlPlot { spec }) } /// Check whether a query contains a VISUALISE clause pub fn has_visual(&self, query: &str) -> bool { match validate(query) { - Ok(v) => v.has_visual(), + Ok(v) => v.has_spec(), Err(_) => false, } } @@ -398,7 +401,7 @@ impl GgsqlContext { /// query — see [`GgsqlContext::execute`]. #[wasm_bindgen] pub struct GgsqlPlot { - spec: Spec, + spec: ResolvedPlot, } #[wasm_bindgen] diff --git a/src/CLAUDE.md b/src/CLAUDE.md index 8fe6d0466..2a5736467 100644 --- a/src/CLAUDE.md +++ b/src/CLAUDE.md @@ -6,7 +6,7 @@ For ggsql language semantics, see [`/doc/syntax/`](../doc/syntax/). For Vega-Lit ## Entry points -- **`lib.rs`** — library root. Declares modules, re-exports the headline types (`Plot`, `Layer`, `Geom`, `Scale`, `Mappings`, `AestheticValue`, `DataSource`, `Facet`, `FacetLayout`, `SqlExpression`, `DataFrame`), and defines `GgsqlError` + `Result`. +- **`lib.rs`** — library root. Declares modules, re-exports the headline types (`Plot`, `Layer`, `Geom`, `Scale`, `Mappings`, `AestheticValue`, `DataSource`, `Facet`, `FacetLayout`, `SqlExpression`, `DataFrame`, plus the table types `Table`, `SelectionItem`, `Spanner`, `ColumnSection`, `Format` and the resolved-layout types `TableLayout`, `TableRow`, `TableColumn`, `TableCell`, `TableCellKind`, `TableClass`), and defines `GgsqlError` + `Result`. ## Module map @@ -16,15 +16,19 @@ src/ ├── array_util.rs, compute.rs Arrow array helpers ├── dataframe.rs DataFrame wrapper around arrow RecordBatch ├── format.rs Label/number/date formatting +├── labels.rs Labels from LABEL clauses, shared by plot/ and table/ ├── naming.rs Internal column-name conventions (__ggsql_*) +├── params.rs SETTING-parameter machinery: values, constraints, validation +├── spec.rs Spec: parse-time result of one VISUALISE/TABULATE statement (Plot or Table) ├── util.rs String helpers (and_list, or_list, …) ├── validate.rs validate(): syntax + semantic checks without SQL execution ├── fonts.rs Font registration, for hosts with no font database │ -├── parser/ Tree-sitter integration → typed AST (Plot) +├── parser/ Tree-sitter integration → typed AST (Spec: Plot or Table) ├── plot/ AST: Plot, Layer, Geom, Scale, Facet, Projection, Mappings (see plot/CLAUDE.md) +├── table/ AST for TABULATE, parallel to plot/ (source, column selection, labels, spans, formats) ├── reader/ Reader trait + drivers (DuckDB, SQLite, ODBC, Snowflake, …) -├── execute/ Pipeline that turns Plot + Reader → executed Spec +├── execute/ Pipeline that turns Plot/Table + Reader → ResolvedPlot/ResolvedTable ├── writer/ Writer trait + Vega-Lite implementation (see writer/vegalite/CLAUDE.md) ├── data/ Bundled sample datasets (penguins, airquality) └── doc/ API.md — public Rust API reference @@ -32,9 +36,9 @@ src/ ### `parser/` -- `mod.rs` exposes `parse_query()` which builds a `Vec` from a query string. -- `source_tree.rs` is the parse-once wrapper: holds the tree-sitter `Tree`, source text, and language; offers a declarative query API (`find_node`, `find_text`, …) plus lazy `extract_sql()` / `extract_visualise()` extractors. It also handles the `VISUALISE FROM ` shorthand by injecting `SELECT * FROM `. -- `builder.rs` walks the CST and produces typed `Plot` values. This is where new grammar nodes become `Plot` fields. +- `mod.rs` exposes `parse_query()` which builds a `Vec` from a query string — one `Spec` per `VISUALISE`/`TABULATE` statement, in source order (`Spec::Table` for `TABULATE`, including its `LABEL`/`SPAN`/`FORMAT` clauses). +- `source_tree.rs` is the parse-once wrapper: holds the tree-sitter `Tree`, source text, and language; offers a declarative query API (`find_node`, `find_text`, …) plus lazy `extract_sql()` / `extract_spec()` extractors (the latter covers both `VISUALISE` and `TABULATE`). It also handles the `VISUALISE FROM ` shorthand by injecting `SELECT * FROM `. +- `builder.rs` walks the CST and produces typed `Spec` values (`Plot`, boxed for size, or `Table`). This is where new grammar nodes become `Plot`/`Table` fields. - `sql.rs` extracts structure from SQL fragments over the parse tree. Grammar lives in [`/tree-sitter-ggsql/`](../tree-sitter-ggsql/) — when adding syntax, edit `grammar.js`, regenerate, then teach `builder.rs` about the new nodes. @@ -50,7 +54,7 @@ Grammar lives in [`/tree-sitter-ggsql/`](../tree-sitter-ggsql/) — when adding | `odbc.rs` | ODBC | `odbc` (default) | | `cache.rs` | `CachingReader` — wraps any primary `Reader` with an in-memory cache | `duckdb` or `sqlite` | | `connection.rs` | Connection-string parsing for all of the above | — | -| `spec.rs` | `Spec` type returned by `execute()`, plus DataFrame conversion | — | +| `spec.rs` | `ResolvedSpec` (`ResolvedPlot` | `ResolvedTable`) returned by `execute()`, plus DataFrame conversion | — | | `data.rs` | Bundled sample datasets — the `ggsql:` builtins | `builtin-data` | `SqlDialect` trait in `mod.rs` lets each driver supply its own type names, information-schema queries, and spatial helper methods (`sql_st_transform`, `sql_geometry_to_wkb`, `sql_geometry_bbox`, `sql_ensure_geometry`, `sql_select_replace`, `sql_spatial_setup`). @@ -59,7 +63,7 @@ Grammar lives in [`/tree-sitter-ggsql/`](../tree-sitter-ggsql/) — when adding ### `execute/` -The pipeline that takes a parsed `Plot` plus a `Reader` and produces a fully-resolved `Spec` (typed data per layer, scales resolved, casts applied). Submodules: +The pipeline that takes a parsed `Plot` (or `Table`) plus a `Reader` and produces a `ResolvedPlot` (or `ResolvedTable`) — typed data per layer, scales resolved, casts applied; for tables, cells laid out with spans, stub and formats. Submodules: - `mod.rs` — top-level `prepare_data_with_reader()` and validation glue. - `cte.rs` — CTE extraction / materialization for shared subqueries. @@ -68,12 +72,14 @@ The pipeline that takes a parsed `Plot` plus a `Reader` and produces a fully-res - `layer.rs` — per-layer SQL building, transforms, stat application. - `scale.rs` — scale resolution, type coercion, out-of-bounds handling. - `position.rs` — position adjustment (stack/dodge/jitter) at execution time. +- `table/` — `ResolvedTable` construction: cell layout, spanner resolution, stub handling, formatting. ### `writer/` `Writer` trait in `mod.rs` (associated `Output` type so writers can return text or bytes, and `from_options` for configuration a frontend collects as key–value pairs — `options.rs`'s `WriterOptions`, parsed from the CLI's `--writer-option`). Two families: - **Vega-Lite** (`vegalite` feature, default) — emits Vega-Lite JSON. Deep-dive: [`writer/vegalite/CLAUDE.md`](writer/vegalite/CLAUDE.md). +- **HTML** (`html` feature, default) — `HtmlWriter` renders a resolved `Table` (a `TABULATE` query) as a bare ``; the only writer that supports tables at all. Plot-only otherwise (`vegalite` and the hephaestus writers below). - **The renderer-backed writers** (seven of them; `svg`, `pdf` and `hep` default, the four raster ones not) — all live in `writer/hephaestus/`, named after the renderer they wrap; that name is internal, and the module is private so only the writers, `Canvas` and `RasterRenderer` are public. They share their whole pipeline — `Canvas` for configuration, `compose` for the plot composition, then either `raster` for pixels or `vector` for drawing commands — and differ only in what they do with the result. Deep-dive (architecture + known gaps): [`writer/hephaestus/CLAUDE.md`](writer/hephaestus/CLAUDE.md). | Feature | Default | Writer | Output | GPU | @@ -106,13 +112,13 @@ Sufficiently large to have its own [`plot/CLAUDE.md`](plot/CLAUDE.md). It holds ### `doc/` -Just `API.md` — the public Rust API reference for `Reader::execute`, `Writer::render`, `validate`, `Spec`, `Validated`, `Metadata`. End-user docs live in `/doc/`, not here. +Just `API.md` — the public Rust API reference for `Reader::execute`, `Writer::render`, `validate`, `ResolvedSpec`, `Validated`, `Metadata`. End-user docs live in `/doc/`, not here. ## Public API quick reference Two-stage pipeline: -1. **`reader.execute(query)`** → `Spec` (parses, runs SQL, resolves mappings, applies stats). +1. **`reader.execute(query)`** → `ResolvedSpec` — `ResolvedPlot` or `ResolvedTable` (parses, runs SQL, resolves mappings, applies stats). 2. **`writer.render(&spec)`** → output (Vega-Lite JSON for `VegaLiteWriter`). `validate(query)` performs syntax + semantic checks without touching a reader. @@ -131,6 +137,7 @@ Defined in `Cargo.toml`: | `parquet` | ✓ | Parquet support in readers/data | | `spatial` | ✓ | Spatial/geometry support (geozero for WKT↔GeoJSON) | | `vegalite` | ✓ | Vega-Lite writer | +| `html` | ✓ | `HtmlWriter` — renders a `TABULATE` query as a bare `
` | | `graphics` | — | *Internal.* The shared plot-composition layer; no GPU | | `raster` | — | *Internal.* `graphics` + the GPU rasteriser (wgpu/vello-hybrid) | | `png` | — | PNG writer (`raster`; genuinely 1.88+, excluded from the MSRV check) | diff --git a/src/Cargo.toml b/src/Cargo.toml index 56fa6974f..b383e8a33 100644 --- a/src/Cargo.toml +++ b/src/Cargo.toml @@ -87,7 +87,7 @@ ureq = "3" adbc_driver_manager = "0.23" [features] -default = ["adbc", "duckdb", "sqlite", "vegalite", "parquet", "builtin-data", "odbc", "spatial", "svg", "pdf", "hep"] +default = ["adbc", "duckdb", "sqlite", "vegalite", "html", "parquet", "builtin-data", "odbc", "spatial", "svg", "pdf", "hep"] duckdb = ["dep:duckdb"] parquet = ["dep:parquet"] sqlite = ["dep:rusqlite"] @@ -95,6 +95,9 @@ adbc = ["dep:adbc_core", "dep:adbc_driver_manager"] odbc = ["dep:toml_edit", "dep:libloading"] spatial = ["dep:geozero", "rusqlite?/load_extension"] vegalite = [] +# Pure string formatting, no extra dependencies — like `vegalite`, gated only +# so a minimal build can drop it, not because it needs anything to compile. +html = [] # Internal, enabled by the writer features below rather than named directly. # `graphics` is the shared plot-composition layer; `raster` adds the GPU # rasteriser on top of it. Splitting them is what lets a vector-only build skip @@ -148,4 +151,4 @@ webfonts = ["graphics", "dep:wuff"] builtin-data = [] all-readers = ["duckdb", "sqlite", "odbc"] -all-writers = ["vegalite", "png", "jpeg", "tiff", "webp", "svg", "pdf", "hep"] +all-writers = ["vegalite", "html", "png", "jpeg", "tiff", "webp", "svg", "pdf", "hep"] diff --git a/src/doc/API.md b/src/doc/API.md index 1c8e8cbb1..2752b5806 100644 --- a/src/doc/API.md +++ b/src/doc/API.md @@ -4,7 +4,7 @@ This document provides a comprehensive reference for the ggsql public API. ## Overview -- **Stage 1: `reader.execute()`** - Parse query, execute SQL, resolve mappings, create Spec +- **Stage 1: `reader.execute()`** - Parse query, execute SQL, resolve mappings, create a ResolvedSpec (a resolved Plot or Table) - **Stage 2: `writer.render()`** - Generate output (Vega-Lite JSON, SVG, PDF, PNG, …) ### API Functions @@ -12,7 +12,7 @@ This document provides a comprehensive reference for the ggsql public API. | Function | Use Case | | ------------------ | ---------------------------------------------------- | | `reader.execute()` | Main entry point - full visualization pipeline | -| `writer.render()` | Generate output from Spec | +| `writer.render()` | Generate output from a ResolvedSpec (Plot or Table) | | `validate()` | Validate syntax + semantics, inspect query structure | --- @@ -22,10 +22,10 @@ This document provides a comprehensive reference for the ggsql public API. ### `Reader::execute` ```rust -fn execute(&self, query: &str) -> Result +fn execute(&self, query: &str) -> Result ``` -Execute a ggsql query for visualization. This is the main entry point - a default method on the Reader trait. +Execute a ggsql query for visualization or tabulation. This is the main entry point - a required method on the Reader trait. `ResolvedSpec` is `Plot(Box)` or `Table(Box)`, depending on whether the query used `VISUALISE` or `TABULATE`; `.as_plot()` / `.as_table()` (or the consuming `.into_plot()` / `.into_table()`) narrow it. **What happens during execution:** @@ -43,7 +43,7 @@ Execute a ggsql query for visualization. This is the main entry point - a defaul **Returns:** -- `Ok(Spec)` - Ready for rendering +- `Ok(ResolvedSpec)` - Ready for rendering - `Err(GgsqlError)` - Parse, validation, or execution error **Example:** @@ -57,11 +57,14 @@ let spec = reader.execute( "SELECT x, y FROM data VISUALISE x, y DRAW point" )?; -// Access metadata -println!("Rows: {}", spec.metadata().rows); -println!("Columns: {:?}", spec.metadata().columns); +// Access metadata (Plot-specific) +if let Some(plot) = spec.as_plot() { + println!("Rows: {}", plot.metadata().rows); + println!("Columns: {:?}", plot.metadata().columns); +} -// Render to Vega-Lite +// Render to Vega-Lite — dispatches to write_plot/write_table based on +// which variant `spec` is let writer = VegaLiteWriter::new(); let result = writer.render(&spec)?; ``` @@ -118,7 +121,7 @@ if !validated.valid() { } // Inspect query structure -if validated.has_visual() { +if validated.has_spec() { println!("SQL: {}", validated.sql()); println!("Visual: {}", validated.visual()); } @@ -150,7 +153,7 @@ pub struct Validated { | Method | Signature | Description | | ------------ | -------------------------------------------- | ---------------------------------- | -| `has_visual` | `fn has_visual(&self) -> bool` | Whether query contains VISUALISE | +| `has_spec` | `fn has_spec(&self) -> bool` | Whether query contains a Spec (VISUALISE or TABULATE) | | `sql` | `fn sql(&self) -> &str` | The SQL portion (before VISUALISE) | | `visual` | `fn visual(&self) -> &str` | The VISUALISE portion (raw text) | | `tree` | `fn tree(&self) -> Option<&Tree>` | CST for advanced inspection | @@ -171,7 +174,7 @@ if !validated.valid() { } // Inspect query structure -assert!(validated.has_visual()); +assert!(validated.has_spec()); assert_eq!(validated.sql(), "SELECT 1 as x"); assert!(validated.visual().starts_with("VISUALISE")); @@ -183,13 +186,16 @@ if let Some(tree) = validated.tree() { --- -### `Spec` +### `ResolvedPlot` -Result of executing a ggsql query, ready for rendering. +Result of executing a `VISUALISE` query, ready for rendering. `reader.execute()` +returns this wrapped in `ResolvedSpec::Plot(Box)`; get here via +`spec.as_plot()` (or the consuming `spec.into_plot()`). #### Rendering -Use `writer.render(&spec)` to generate output. +Pass the `ResolvedSpec` itself to `writer.render()` — it dispatches to +`write_plot`/`write_table` depending on which variant it is. **Example:** @@ -265,8 +271,6 @@ if let Some(df) = spec.stat_data(1) { | ----------- | ----------------------------------------------- | -------------------------------- | | `sql` | `fn sql(&self) -> &str` | Main SQL query that was executed | | `visual` | `fn visual(&self) -> &str` | Raw VISUALISE text | -| `layer_sql` | `fn layer_sql(&self, i: usize) -> Option<&str>` | Layer filter/source query | -| `stat_sql` | `fn stat_sql(&self, i: usize) -> Option<&str>` | Stat transform query | **Example:** @@ -274,16 +278,6 @@ if let Some(df) = spec.stat_data(1) { // Main query println!("SQL: {}", spec.sql()); println!("Visual: {}", spec.visual()); - -// Per-layer queries -for i in 0..spec.layer_count() { - if let Some(sql) = spec.layer_sql(i) { - println!("Layer {} filter: {}", i, sql); - } - if let Some(sql) = spec.stat_sql(i) { - println!("Layer {} stat: {}", i, sql); - } -} ``` #### Warnings Method @@ -297,9 +291,9 @@ for i in 0..spec.layer_count() { ```rust let spec = reader.execute(query)?; -// Check for warnings -if !spec.warnings().is_empty() { - for warning in spec.warnings() { +// Check for warnings (Plot-specific) +if let Some(plot) = spec.as_plot() { + for warning in plot.warnings() { eprintln!("Warning: {}", warning.message); } } @@ -397,13 +391,19 @@ pub trait Writer { fn from_options(options: &WriterOptions) -> Result where Self: Sized; /// Render a plot specification and its data to the output format - fn write(&self, spec: &Plot, data: &HashMap) -> Result; + fn write_plot(&self, spec: &Plot, data: &HashMap) -> Result; + + /// Check whether a plot can be rendered by this writer, without rendering it + fn validate_plot(&self, spec: &Plot) -> Result<()>; - /// Check whether a spec can be rendered by this writer, without rendering it - fn validate(&self, spec: &Plot) -> Result<()>; + /// Render a resolved table's layout — its cells bundled with their + /// column and row metadata. Defaults to an "unsupported" error; + /// only `HtmlWriter` overrides it as of this writing. + fn write_table(&self, layout: &TableLayout) -> Result { .. } - /// Render a prepared `Spec` from `reader.execute()` — the usual entry point - fn render(&self, spec: &Spec) -> Result; + /// Render a `ResolvedSpec` from `reader.execute()` — the usual entry point. + /// Dispatches to `write_plot`/`write_table` depending on the variant. + fn render(&self, spec: &ResolvedSpec) -> Result; } ``` diff --git a/src/execute/cte.rs b/src/execute/cte.rs index 2dc200b51..c84db97f8 100644 --- a/src/execute/cte.rs +++ b/src/execute/cte.rs @@ -468,7 +468,7 @@ pub fn transform_global_sql( let viz_from_query = source_tree .find_text( &root, - r#"(visualise_statement (visualise_from source: (_) @source))"#, + r#"(visualise_statement (single_source_from source: (_) @source))"#, ) .map(|table| { let q = format!("SELECT * FROM {}", table); @@ -525,7 +525,7 @@ pub fn has_executable_sql(source_tree: &SourceTree) -> bool { // Check for VISUALISE FROM (which injects SELECT * FROM ) let visualise_from = r#" (visualise_statement - (visualise_from) @from) + (single_source_from) @from) "#; if source_tree.find_node(&root, visualise_from).is_some() { return true; @@ -756,7 +756,7 @@ mod tests { fn register(&self, _name: &str, _df: crate::DataFrame, _replace: bool) -> Result<()> { Ok(()) } - fn execute(&self, _query: &str) -> Result { + fn execute(&self, _query: &str) -> Result { unreachable!() } fn caches_sources(&self) -> bool { diff --git a/src/execute/mod.rs b/src/execute/mod.rs index 0a9d3bee0..b9c9cba5e 100644 --- a/src/execute/mod.rs +++ b/src/execute/mod.rs @@ -9,6 +9,8 @@ //! - `casting`: Type requirements determination and casting logic //! - `layer`: Layer query building, data transforms, and stat application //! - `scale`: Scale creation, resolution, type coercion, and OOB handling +//! - `table`: Table (TABULATE) resolution, with its own `spanner` (`TABULATE +//! SPAN`) and `format` (`TABULATE FORMAT`) submodules mod casting; mod cte; @@ -17,11 +19,13 @@ mod layer; mod position; mod scale; mod schema; +mod table; // Re-export public API pub use casting::TypeRequirement; pub use cte::CteDefinition; pub use schema::TypeInfo; +pub use table::resolve_table_with_reader; use crate::naming; use crate::parser; @@ -30,7 +34,7 @@ use crate::plot::facet::{resolve_properties as resolve_facet_properties, FacetDa use crate::plot::layer::is_transposed; use crate::plot::projection::resolve_projection_properties; use crate::plot::{AestheticValue, Layer, Scale, ScaleTypeKind, Schema}; -use crate::{DataFrame, DataSource, GgsqlError, Plot, Result}; +use crate::{DataFrame, DataSource, GgsqlError, Plot, Result, Spec}; use std::collections::{HashMap, HashSet}; use crate::reader::Reader; @@ -1091,6 +1095,19 @@ pub struct PreparedData { pub visual: String, } +/// Execute setup statements (INSTALL, LOAD, SET, etc.) ahead of the main +/// query. Shared by the Plot and Table pipelines (`prepare_data_with_reader` +/// and `table::resolve_table_with_reader`). Structured DML (CREATE, INSERT, +/// UPDATE, DELETE) is out of scope here — both pipelines run it separately +/// via `cte::extract_side_effects`. +fn execute_setup_statements(source_tree: &parser::SourceTree, reader: &dyn Reader) -> Result<()> { + let root = source_tree.root(); + for stmt in source_tree.find_texts(&root, "(sql_statement (other_sql_statement) @stmt)") { + reader.execute_sql(&stmt)?; + } + Ok(()) +} + /// Build data map from a query using a Reader /// /// This is the main entry point for preparing visualization data from a ggsql query. @@ -1109,19 +1126,33 @@ pub fn prepare_data_with_reader(query: &str, reader: &dyn Reader) -> Result = parser::build_ast(&source_tree)? + .into_iter() + .filter_map(Spec::into_plot) + .collect(); if specs.is_empty() { return Err(GgsqlError::ValidationError( @@ -1129,12 +1160,7 @@ pub fn prepare_data_with_reader(query: &str, reader: &dyn Reader) -> Result Result { + let message = e.to_string(); + assert!(message.contains("TABULATE")); + assert!(message.contains("Reader::execute()")); + } + Ok(_) => panic!("expected an error for a TABULATE-only query"), + } + } + #[cfg(feature = "duckdb")] #[test] fn test_prepare_data_layer_source() { diff --git a/src/execute/table/format.rs b/src/execute/table/format.rs new file mode 100644 index 000000000..511fc1b56 --- /dev/null +++ b/src/execute/table/format.rs @@ -0,0 +1,308 @@ +//! `TABULATE FORMAT` resolution: `setup_formats` validates `SETTING` +//! parameters and reshapes the per-clause formats into one `Format` per +//! column, `apply_formats` replaces a column's values with their display +//! text (`RENAMING`), and `resolve_column_properties` resolves a column's +//! `SETTING` display properties. + +use std::collections::HashMap; + +use arrow::datatypes::DataType; + +use crate::array_util::{new_str_array, value_to_string}; +use crate::plot::{ParameterValue, Parameters}; +use crate::table::standardise_hjust; +use crate::{DataFrame, Format, GgsqlError, Result, Spanner}; + +/// Validate every FORMAT's `SETTING` parameters, then reshape `formats` +/// into one `Format` per column it covers. `spans` must already be resolved +/// (`Table::resolve_spanners`) — a name matching a SPAN id expands to that +/// span's columns. +pub(super) fn setup_formats( + df: &DataFrame, + formats: &[Format], + spans: &[Spanner], +) -> Result> { + for (idx, format) in formats.iter().enumerate() { + format + .validate_settings() + .map_err(|e| GgsqlError::ValidationError(format!("FORMAT {}: {}", idx + 1, e)))?; + } + + // One `Format` per column; a later clause wins over an earlier one + // naming the same column. + let mut resolved = HashMap::new(); + for format in formats { + for name in &format.columns { + // A SPAN id never collides with a real column name + // (`Spanner::validate_columns`), so this lookup is unambiguous. + let columns: Vec<&String> = match spans.iter().find(|span| &span.id == name) { + Some(span) => span.columns.iter().collect(), + None => vec![name], + }; + for column in columns { + if df.column(column).is_err() { + return Err(GgsqlError::ValidationError(format!( + "FORMAT references unknown column '{column}'" + ))); + } + let mut format = format.clone(); + // The map key names the column instead. + format.columns = Vec::new(); + resolved.insert(column.clone(), format); + } + } + } + Ok(resolved) +} + +/// Replace every column in `df` named in `formats` with its resolved +/// display text. A column whose `Format` has no `RENAMING` at all +/// (`value_template` is the default `"{}"` and `value_mapping` is empty) is +/// left untouched. +pub(super) fn apply_formats( + df: &DataFrame, + formats: &HashMap, +) -> Result { + let mut df = df.clone(); + for (name, format) in formats { + let has_renaming = format.value_template != "{}" + || format.value_mapping.as_ref().is_some_and(|m| !m.is_empty()); + if !has_renaming { + // Skip the per-row formatting pass for a column nobody asked to + // reformat — the common `FORMAT ... SETTING ...` case. + continue; + } + + let array = df + .column(name) + .expect("setup_formats already checked this column exists") + .clone(); + let resolved = crate::format::resolve_column_values( + &array, + &format.value_template, + &format.value_mapping, + ) + .map_err(|e| GgsqlError::ValidationError(format!("FORMAT column '{name}': {e}")))?; + + let display: Vec> = resolved + .into_iter() + .enumerate() + .map(|(row, value)| Some(value.unwrap_or_else(|| value_to_string(&array, row)))) + .collect(); + let display_refs: Vec> = display.iter().map(|s| s.as_deref()).collect(); + + df = df.with_column(name, new_str_array(display_refs))?; + } + + Ok(df) +} + +/// Resolve `SETTING` properties for one column: `format`'s own settings (if +/// any), with `hjust` standardised to a number (see `standardise_hjust`) — +/// an absent setting defaults from the column's role: right for a STUB or +/// numeric column, left otherwise. +pub(super) fn resolve_column_properties(dtype: &DataType, format: Option<&Format>) -> Parameters { + let mut properties = format.map(|f| f.settings.clone()).unwrap_or_default(); + + let is_stub = format.is_some_and(|f| f.target.is_stub()); + let hjust = if let Some(value) = properties.get("hjust").and_then(standardise_hjust) { + value + } else if is_stub || dtype.is_numeric() { + 1.0 + } else { + 0.0 + }; + properties.insert("hjust".to_string(), ParameterValue::Number(hjust)); + + properties +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::array_util::value_to_string; + use crate::df; + + fn format_with(template: &str, mapping: Option>>) -> Format { + Format { + columns: Vec::new(), + target: crate::ColumnSection::Body, + settings: Parameters::new(), + value_mapping: mapping, + value_template: template.to_string(), + } + } + + fn column_content(df: &DataFrame, name: &str) -> Vec { + let array = df.column(name).unwrap(); + (0..df.height()) + .map(|row| value_to_string(array, row)) + .collect() + } + + #[test] + fn setup_formats_lets_a_later_clause_win_and_clears_columns() { + let frame = df! { "price" => vec![1.0f64] }.unwrap(); + let formats = vec![ + Format { + columns: vec!["price".to_string()], + ..format_with("{:num %.0f}", None) + }, + Format { + columns: vec!["price".to_string()], + ..format_with("{:num %.2f}", None) + }, + ]; + + let resolved = setup_formats(&frame, &formats, &[]).unwrap(); + + let price = resolved.get("price").unwrap(); + assert_eq!(price.value_template, "{:num %.2f}"); + assert!(price.columns.is_empty()); + } + + #[test] + fn setup_formats_expands_a_span_id_into_its_columns() { + let frame = df! { "a" => vec![1.0f64], "b" => vec![2.0f64] }.unwrap(); + let formats = vec![Format { + columns: vec!["G".to_string()], + ..format_with("{:num %.2f}", None) + }]; + let spans = vec![Spanner { + id: "G".to_string(), + label: Some("G".to_string()), + columns: vec!["a".to_string(), "b".to_string()], + settings: Parameters::new(), + }]; + + let resolved = setup_formats(&frame, &formats, &spans).unwrap(); + + assert_eq!(resolved.get("a").unwrap().value_template, "{:num %.2f}"); + assert_eq!(resolved.get("b").unwrap().value_template, "{:num %.2f}"); + } + + #[test] + fn setup_formats_errors_on_an_unknown_column() { + let frame = df! { "price" => vec![1.0f64] }.unwrap(); + let formats = vec![Format { + columns: vec!["typo".to_string()], + ..format_with("{}", None) + }]; + + let error = setup_formats(&frame, &formats, &[]).unwrap_err(); + assert!(matches!(error, GgsqlError::ValidationError(msg) + if msg.contains("FORMAT references unknown column 'typo'"))); + } + + #[test] + fn leaves_a_column_untouched_when_its_format_has_no_renaming() { + let frame = df! { "price" => vec![1.0f64, 2.0] }.unwrap(); + let formats = HashMap::from([("price".to_string(), format_with("{}", None))]); + + let result = apply_formats(&frame, &formats).unwrap(); + + assert_eq!(column_content(&result, "price"), vec!["1", "2"]); + } + + #[test] + fn applies_a_wildcard_template() { + let frame = df! { "price" => vec![1.0f64, 2.0] }.unwrap(); + let formats = HashMap::from([("price".to_string(), format_with("${:num %.2f}", None))]); + + let result = apply_formats(&frame, &formats).unwrap(); + + assert_eq!(column_content(&result, "price"), vec!["$1.00", "$2.00"]); + } + + #[test] + fn explicit_mapping_wins_over_the_template() { + let frame = df! { "price" => vec![0.0f64, 5.0] }.unwrap(); + let mapping = Some(HashMap::from([("0".to_string(), Some("-".to_string()))])); + let formats = HashMap::from([("price".to_string(), format_with("{:num %.2f}", mapping))]); + + let result = apply_formats(&frame, &formats).unwrap(); + + assert_eq!(column_content(&result, "price"), vec!["-", "5.00"]); + } + + #[test] + fn a_null_with_no_explicit_entry_still_falls_back_to_value_to_string() { + let frame = df! { "price" => vec![Some(1.0f64), None] }.unwrap(); + let formats = HashMap::from([("price".to_string(), format_with("{:num %.2f}", None))]); + + let result = apply_formats(&frame, &formats).unwrap(); + + assert_eq!(column_content(&result, "price"), vec!["1.00", "null"]); + } + + fn format_with_hjust(hjust: ParameterValue) -> Format { + let mut settings = Parameters::new(); + settings.insert("hjust".to_string(), hjust); + Format { + columns: Vec::new(), + target: crate::ColumnSection::Body, + settings, + value_mapping: None, + value_template: "{}".to_string(), + } + } + + #[test] + fn resolve_column_properties_defaults_hjust_by_dtype_when_absent() { + assert_eq!( + resolve_column_properties(&DataType::Float64, None).get("hjust"), + Some(&ParameterValue::Number(1.0)) + ); + assert_eq!( + resolve_column_properties(&DataType::Utf8, None).get("hjust"), + Some(&ParameterValue::Number(0.0)) + ); + } + + #[test] + fn resolve_column_properties_defaults_a_stub_column_to_right_regardless_of_dtype() { + let format = Format { + columns: Vec::new(), + target: crate::ColumnSection::Stub, + settings: Parameters::new(), + value_mapping: None, + value_template: "{}".to_string(), + }; + + assert_eq!( + resolve_column_properties(&DataType::Utf8, Some(&format)).get("hjust"), + Some(&ParameterValue::Number(1.0)) + ); + } + + #[test] + fn resolve_column_properties_standardises_keywords_to_numbers() { + let format = format_with_hjust(ParameterValue::String("left".to_string())); + assert_eq!( + resolve_column_properties(&DataType::Utf8, Some(&format)).get("hjust"), + Some(&ParameterValue::Number(0.0)) + ); + + let format = format_with_hjust(ParameterValue::String("centre".to_string())); + assert_eq!( + resolve_column_properties(&DataType::Utf8, Some(&format)).get("hjust"), + Some(&ParameterValue::Number(0.5)) + ); + + let format = format_with_hjust(ParameterValue::String("right".to_string())); + assert_eq!( + resolve_column_properties(&DataType::Utf8, Some(&format)).get("hjust"), + Some(&ParameterValue::Number(1.0)) + ); + } + + #[test] + fn resolve_column_properties_keeps_an_explicit_number_unchanged() { + let format = format_with_hjust(ParameterValue::Number(0.25)); + + assert_eq!( + resolve_column_properties(&DataType::Float64, Some(&format)).get("hjust"), + Some(&ParameterValue::Number(0.25)) + ); + } +} diff --git a/src/execute/table/layout.rs b/src/execute/table/layout.rs new file mode 100644 index 000000000..f77902bac --- /dev/null +++ b/src/execute/table/layout.rs @@ -0,0 +1,741 @@ +//! Table-cell resolution: turns already-resolved columns, spans and a +//! `FORMAT`-applied `DataFrame` into the `TableLayout` `ResolvedTable` +//! holds, assembling parts with the `create_*` helpers from +//! `crate::table::layout`. + +use std::collections::HashMap; + +use super::format::resolve_column_properties; +use super::stub::{create_row_labels, create_stubhead}; +use crate::array_util::value_to_string; +use crate::plot::Labels; +use crate::table::{ + compose_header, create_caption, create_column_labels, create_heading, create_spanners, + move_stub_columns, reorder_table_columns, TableCell, TableClass, TableColumn, TableLayout, + TableRow, +}; +use crate::{DataFrame, Format, Result, Spanner}; + +/// Resolve a table's columns: one `TableColumn` per `DataFrame` column +/// (labelled, with `formats`' resolved `SETTING` properties), reordered for +/// any `gather`-ing spanner and then for STUB columns. `spans` must already +/// be resolved (`Table::resolve_spanners`). +pub(super) fn setup_columns( + df: &DataFrame, + spans: &[Spanner], + labels: &Labels, + formats: &HashMap, +) -> Vec { + let columns = create_table_columns(df, labels, formats); + let columns = reorder_table_columns(columns, spans); + move_stub_columns(columns) +} + +/// Build the resolved `TableLayout` for a table from its already-resolved +/// `columns` and already-`FORMAT`-applied `df` — spanner rows, column +/// labels, and body, composed together and validated. +pub(super) fn build_cells( + df: &DataFrame, + columns: Vec, + spans: &[Spanner], + title: Option<&str>, + subtitle: Option<&str>, + caption: Option<&str>, +) -> Result { + let spanners = create_spanners(&columns, spans); + // Every label empty means `LABEL col => NULL` (or `=> ''`) on every + // column, since an unlabeled column keeps its (non-empty) name — omit + // the row rather than render blank header cells. + let column_labels = if columns.iter().all(|c| c.label.is_empty()) { + TableLayout::new(columns.clone(), Vec::new(), Vec::new()) + } else { + let mut label_cells = create_stubhead(&columns); + label_cells.extend(create_column_labels(&columns)); + TableLayout::new( + columns.clone(), + vec![TableRow { + classes: vec![TableClass::ColHeadingRow], + ..TableRow::header() + }], + label_cells, + ) + }; + let header = compose_header(spanners, column_labels); + let heading = create_heading(title, subtitle, &columns); + let header = heading.rowbind(header); + let mut body_cells = create_row_labels(df, &columns); + body_cells.extend(create_body(df, &columns)); + let table_body = TableLayout::new( + columns.clone(), + vec![TableRow::body(); df.height()], + body_cells, + ); + let layout = header.rowbind(table_body); + let caption_layout = create_caption(caption, &columns); + let layout = layout.rowbind(caption_layout); + + layout.validate()?; + + Ok(layout) +} + +/// Build one `TableColumn` per `DataFrame` column, in the `DataFrame`'s own +/// order — `reorder_table_columns` is what may reorder this list +/// afterward, not this function. `labels` is the one authority for an +/// explicit column label; `formats` (already reshaped to one `Format` per +/// column by `setup_formats`) is the one authority for its properties. +fn create_table_columns( + df: &DataFrame, + labels: &Labels, + formats: &HashMap, +) -> Vec { + let mut columns = Vec::new(); + for name in df.get_column_names() { + // Not stored on TableColumn: nothing needs it once `properties` + // (which may default from it) is resolved. + let dtype = df + .column(&name) + .expect("name comes from df's own columns") + .data_type(); + let format = formats.get(&name); + let properties = resolve_column_properties(dtype, format); + let target = format.map(|f| f.target).unwrap_or_default(); + let label = match labels.labels.get(&name) { + // A stub head is a row-label header, not a data column header — + // default it blank rather than to the column's own name. + None if target.is_stub() => String::new(), + None => name.clone(), + Some(None) => String::new(), + Some(Some(label)) => label.clone(), + }; + columns.push(TableColumn { + name, + label, + properties, + target, + }); + } + columns +} + +/// Build one `Body` cell per `DataFrame` value, numbered from `top == 0`, in +/// `columns`' order. Looks each column up in `df` **by name**, not position, +/// since `columns` may already be reordered relative to `df`. Skips every +/// STUB column — `create_row_labels` builds those. +fn create_body(df: &DataFrame, columns: &[TableColumn]) -> Vec { + let mut cells = Vec::new(); + + for (index, column) in columns.iter().enumerate() { + if column.is_stub() { + continue; + } + + // Looked up once per column, outside the row loop: `DataFrame::column` + // is an `O(ncol)` scan over the schema, so doing this per row instead + // would cost `O(nrow * ncol)` lookups rather than `O(ncol)`. + let array = df + .column(&column.name) + .expect("TableColumn.name always names a column of df"); + + for row in 0..df.height() { + cells.push( + TableCell::new_body(row, index, value_to_string(array, row)) + .with_properties(column.properties.clone()), + ); + } + } + + cells +} + +#[cfg(test)] +mod tests { + use super::super::format::{apply_formats, setup_formats}; + use super::*; + use crate::df; + use crate::plot::{ParameterValue, Parameters}; + use crate::table::{extract_heading_labels, TableCellKind}; + use crate::{ColumnSection, GgsqlError, Spanner, Table}; + + /// Runs the same steps `resolve_table_with_reader` does, minus the + /// `Reader`/SQL execution — lets a test build a `Table`'s resolved cells + /// directly against a `df!()`-built `DataFrame`. + fn resolve_layout(df: &DataFrame, table: &Table) -> Result { + let mut labels = table.labels.clone(); + let (title, subtitle, caption) = extract_heading_labels(&mut labels); + let column_names = df.get_column_names(); + let spans = table + .resolve_spanners(&labels, Some(&column_names)) + .map_err(GgsqlError::ValidationError)?; + let formats = setup_formats(df, &table.formats, &spans)?; + let columns = setup_columns(df, &spans, &labels, &formats); + let df = apply_formats(df, &formats)?; + build_cells( + &df, + columns, + &spans, + title.as_deref(), + subtitle.as_deref(), + caption.as_deref(), + ) + } + + /// `resolve_layout`, for the (common) tests that only care about cells. + fn resolve_cells(df: &DataFrame, table: &Table) -> Result> { + resolve_layout(df, table).map(|layout| layout.cells) + } + + fn column(name: &str, label: &str) -> TableColumn { + TableColumn { + name: name.to_string(), + label: label.to_string(), + properties: Parameters::new(), + target: ColumnSection::Body, + } + } + + fn stub_column(name: &str) -> TableColumn { + TableColumn { + target: ColumnSection::Stub, + ..column(name, name) + } + } + + #[test] + fn create_table_columns_resolves_default_suppress_and_override() { + let frame = df! { + "id" => vec![1i32], + "name" => vec!["a".to_string()], + "extra" => vec![true], + } + .unwrap(); + + let mut labels = Labels::default(); + labels + .labels + .insert("id".to_string(), Some("ID".to_string())); + labels.labels.insert("name".to_string(), None); + // "extra" has no entry at all: no LABEL clause mentioned it. + + let columns = create_table_columns(&frame, &labels, &HashMap::new()); + + assert_eq!(columns[0].name, "id"); + assert_eq!(columns[0].label, "ID"); // overridden + assert_eq!(columns[1].label, ""); // explicitly suppressed + assert_eq!(columns[2].label, "extra"); // absent: kept as-is + } + + #[test] + fn create_table_columns_defaults_a_stub_column_to_a_blank_label_but_label_can_override() { + let frame = df! { + "region" => vec!["north".to_string()], + } + .unwrap(); + let formats = HashMap::from([( + "region".to_string(), + Format { + columns: vec!["region".to_string()], + target: ColumnSection::Stub, + settings: Parameters::new(), + value_mapping: None, + value_template: "{}".to_string(), + }, + )]); + + let columns = create_table_columns(&frame, &Labels::default(), &formats); + assert_eq!(columns[0].label, ""); + + let mut labels = Labels::default(); + labels + .labels + .insert("region".to_string(), Some("Region".to_string())); + let columns = create_table_columns(&frame, &labels, &formats); + assert_eq!(columns[0].label, "Region"); + } + + fn spanner_with(columns: &[&str], label: Option<&str>, settings: Parameters) -> Spanner { + Spanner { + id: label.unwrap_or("").to_string(), + label: label.map(str::to_string), + columns: columns.iter().map(|s| s.to_string()).collect(), + settings, + } + } + + fn spanner(columns: &[&str]) -> Spanner { + spanner_with(columns, Some(""), Parameters::new()) + } + + fn spanner_with_setting(columns: &[&str], key: &str, value: ParameterValue) -> Spanner { + let mut settings = Parameters::new(); + settings.insert(key.to_string(), value); + spanner_with(columns, Some(""), settings) + } + + fn labeled_spanner(columns: &[&str], label: &str) -> Spanner { + spanner_with(columns, Some(label), Parameters::new()) + } + + #[test] + fn create_body_numbers_rows_from_zero() { + let frame = df! { + "id" => vec![1i32, 2], + "name" => vec!["a".to_string(), "b".to_string()], + } + .unwrap(); + let columns = create_table_columns(&frame, &Labels::default(), &HashMap::new()); + + let body = create_body(&frame, &columns); + + assert_eq!(body.len(), 4); + assert!(body.iter().all(|cell| cell.kind == TableCellKind::Body)); + // Column 0 ("id"): both rows, before column 1 starts — cells are + // pushed column-major, not row-major (see create_body's inline + // comment on why `array` is looked up once per column). + assert_eq!(body[0].top, 0); + assert_eq!(body[0].bottom, 0); + assert_eq!(body[0].left, 0); + assert_eq!(body[0].content, "1"); + assert_eq!(body[1].top, 1); + assert_eq!(body[1].bottom, 1); + assert_eq!(body[1].left, 0); + assert_eq!(body[1].content, "2"); + // Column 1 ("name") + assert_eq!(body[2].top, 0); + assert_eq!(body[2].left, 1); + assert_eq!(body[2].content, "a"); + assert_eq!(body[3].top, 1); + assert_eq!(body[3].left, 1); + assert_eq!(body[3].content, "b"); + } + + #[test] + fn create_body_looks_up_columns_by_name_not_position() { + // `columns` reordered relative to `frame`'s own column order — + // `create_body` must follow `columns`, not `df`'s raw position, for + // spanner-driven reordering to actually reach the body. + let frame = df! { + "id" => vec![1i32], + "name" => vec!["a".to_string()], + } + .unwrap(); + let columns = vec![column("name", "name"), column("id", "id")]; + + let body = create_body(&frame, &columns); + + assert_eq!(body[0].content, "a"); // "name" column, placed first + assert_eq!(body[1].content, "1"); // "id" column, placed second + } + + #[test] + fn create_body_skips_stub_columns() { + let frame = df! { + "region" => vec!["north".to_string()], + "sales" => vec![1i32], + } + .unwrap(); + let columns = vec![stub_column("region"), column("sales", "sales")]; + + let body = create_body(&frame, &columns); + + assert_eq!(body.len(), 1); + assert_eq!(body[0].content, "1"); + assert_eq!(body[0].left, 1); + } + + #[test] + fn build_cells_composes_spanners_labels_and_body_together() { + let frame = df! { + "a" => vec![1i32], + "b" => vec![2i32], + } + .unwrap(); + let mut table = Table::new(); + table.spans = vec![labeled_spanner(&["a", "b"], "G")]; + + let cells = resolve_cells(&frame, &table).unwrap(); + + let spanner_cell = cells + .iter() + .find(|c| c.kind == TableCellKind::Spanner) + .unwrap(); + assert_eq!(spanner_cell.top, 0); + assert_eq!(spanner_cell.left, 0); + assert_eq!(spanner_cell.right, 1); + assert_eq!(spanner_cell.content, "G"); + + assert!(cells + .iter() + .filter(|c| c.kind == TableCellKind::ColumnLabel) + .all(|c| c.top == 1)); + assert!(cells + .iter() + .filter(|c| c.kind == TableCellKind::Body) + .all(|c| c.top == 2)); + } + + #[test] + fn build_cells_overrides_a_spanners_default_label_via_labels_clause() { + let frame = df! { + "a" => vec![1i32], + "b" => vec![2i32], + } + .unwrap(); + let mut table = Table::new(); + table.spans = vec![labeled_spanner(&["a", "b"], "g")]; + table + .labels + .labels + .insert("g".to_string(), Some("Group".to_string())); + + let cells = resolve_cells(&frame, &table).unwrap(); + + let spanner_cell = cells + .iter() + .find(|c| c.kind == TableCellKind::Spanner) + .unwrap(); + assert_eq!(spanner_cell.content, "Group"); + } + + #[test] + fn build_cells_suppresses_a_named_spanners_cell_via_labels_null() { + let frame = df! { + "a" => vec![1i32], + "b" => vec![2i32], + } + .unwrap(); + let mut table = Table::new(); + table.spans = vec![labeled_spanner(&["a", "b"], "g")]; + table.labels.labels.insert("g".to_string(), None); + + let cells = resolve_cells(&frame, &table).unwrap(); + + assert!(!cells.iter().any(|c| c.kind == TableCellKind::Spanner)); + } + + #[test] + fn build_cells_records_style_classes() { + let frame = df! { + "a" => vec![1i32], + "b" => vec![2i32], + } + .unwrap(); + let mut table = Table::new(); + table.spans = vec![labeled_spanner(&["a", "b"], "G")]; + + let cells = resolve_cells(&frame, &table).unwrap(); + + assert!(cells + .iter() + .filter(|c| c.kind == TableCellKind::ColumnLabel) + .all(|c| c.classes == [TableClass::ColHeading])); + assert!(cells + .iter() + .filter(|c| c.kind == TableCellKind::Body) + .all(|c| c.classes == [TableClass::Row])); + // Spanner cells carry both spanner classes, centred by default. + assert!(cells + .iter() + .filter(|c| c.kind == TableCellKind::Spanner) + .all(|c| c.classes + == [ + TableClass::Spanner, + TableClass::SpannerLabel, + TableClass::AlignCenter + ])); + } + + #[test] + fn build_cells_applies_a_formats_renaming_to_body_cells() { + let frame = df! { + "price" => vec![0.0f64, 5.0], + } + .unwrap(); + let mut table = Table::new(); + table.formats = vec![crate::Format { + columns: vec!["price".to_string()], + target: crate::ColumnSection::Body, + settings: Parameters::new(), + value_mapping: Some(std::collections::HashMap::from([( + "0".to_string(), + Some("-".to_string()), + )])), + value_template: "${:num %.2f}".to_string(), + }]; + + let cells = resolve_cells(&frame, &table).unwrap(); + + let mut body_cells: Vec<_> = cells + .iter() + .filter(|c| c.kind == TableCellKind::Body) + .collect(); + body_cells.sort_by_key(|c| c.top); + assert_eq!(body_cells[0].content, "-"); + assert_eq!(body_cells[1].content, "$5.00"); + } + + #[test] + fn build_cells_defaults_hjust_by_dtype_on_label_and_body_cells() { + let frame = df! { + "price" => vec![1.0f64], + "name" => vec!["a".to_string()], + } + .unwrap(); + let table = Table::new(); + + let cells = resolve_cells(&frame, &table).unwrap(); + + let hjust = |left: usize, kind: TableCellKind| { + cells + .iter() + .find(|c| c.left == left && c.kind == kind) + .unwrap() + .properties + .get("hjust") + .cloned() + }; + assert_eq!( + hjust(0, TableCellKind::ColumnLabel), + Some(ParameterValue::Number(1.0)) + ); + assert_eq!( + hjust(0, TableCellKind::Body), + Some(ParameterValue::Number(1.0)) + ); + assert_eq!( + hjust(1, TableCellKind::Body), + Some(ParameterValue::Number(0.0)) + ); + } + + #[test] + fn build_cells_reorders_columns_for_a_spanner_before_building_labels_and_body() { + let frame = df! { + "a" => vec![1i32], + "x" => vec![9i32], + "b" => vec![2i32], + } + .unwrap(); + let mut table = Table::new(); + table.spans = vec![labeled_spanner(&["a", "b"], "G")]; + + let cells = resolve_cells(&frame, &table).unwrap(); + + let mut label_cells: Vec<_> = cells + .iter() + .filter(|c| c.kind == TableCellKind::ColumnLabel) + .collect(); + label_cells.sort_by_key(|c| c.left); + let label_order: Vec<&str> = label_cells.iter().map(|c| c.content.as_str()).collect(); + assert_eq!(label_order, vec!["a", "b", "x"]); + + let mut body_cells: Vec<_> = cells + .iter() + .filter(|c| c.kind == TableCellKind::Body) + .collect(); + body_cells.sort_by_key(|c| c.left); + let body_order: Vec<&str> = body_cells.iter().map(|c| c.content.as_str()).collect(); + assert_eq!(body_order, vec!["1", "2", "9"]); + } + + #[test] + fn build_cells_surfaces_a_genuine_spanner_overlap_as_an_error() { + // (a,b) auto-assigns level 1; (b,c) is explicitly pinned to level 1 + // too, and neither reordering nor level assignment resolves that — + // build_cells should surface this via TableLayout::validate, not + // silently produce a broken layout. + let frame = df! { + "a" => vec![1i32], + "b" => vec![2i32], + "c" => vec![3i32], + } + .unwrap(); + let mut table = Table::new(); + table.spans = vec![ + spanner(&["a", "b"]), + spanner_with_setting(&["b", "c"], "level", ParameterValue::Number(1.0)), + ]; + + assert!(resolve_cells(&frame, &table).is_err()); + } + + #[test] + fn build_cells_omits_the_column_label_row_when_every_label_is_null() { + let frame = df! { + "id" => vec![1i32], + "name" => vec!["a".to_string()], + } + .unwrap(); + let mut table = Table::new(); + table.labels.labels.insert("id".to_string(), None); + table.labels.labels.insert("name".to_string(), None); + + let cells = resolve_cells(&frame, &table).unwrap(); + + assert!(!cells.iter().any(|c| c.kind == TableCellKind::ColumnLabel)); + assert!(cells + .iter() + .filter(|c| c.kind == TableCellKind::Body) + .all(|c| c.top == 0)); + } + + #[test] + fn build_cells_stacks_title_and_subtitle_above_the_column_labels() { + let frame = df! { + "id" => vec![1i32], + } + .unwrap(); + let mut table = Table::new(); + table + .labels + .labels + .insert("title".to_string(), Some("Title".to_string())); + table + .labels + .labels + .insert("subtitle".to_string(), Some("Subtitle".to_string())); + + let cells = resolve_cells(&frame, &table).unwrap(); + + let title = cells + .iter() + .find(|c| c.kind == TableCellKind::Title) + .unwrap(); + let subtitle = cells + .iter() + .find(|c| c.kind == TableCellKind::Subtitle) + .unwrap(); + let label = cells + .iter() + .find(|c| c.kind == TableCellKind::ColumnLabel) + .unwrap(); + let body = cells + .iter() + .find(|c| c.kind == TableCellKind::Body) + .unwrap(); + + assert_eq!( + (title.top, title.bottom, title.left, title.right), + (0, 0, 0, 0) + ); + assert_eq!(title.classes, [TableClass::Title]); + assert_eq!((subtitle.top, subtitle.bottom), (1, 1)); + assert_eq!(subtitle.classes, [TableClass::Subtitle]); + assert_eq!(label.top, 2); + assert_eq!(body.top, 3); + } + + #[test] + fn build_cells_omits_a_heading_row_for_an_unset_title_or_subtitle() { + let frame = df! { + "id" => vec![1i32], + } + .unwrap(); + let mut table = Table::new(); + table + .labels + .labels + .insert("subtitle".to_string(), Some("Subtitle only".to_string())); + + let cells = resolve_cells(&frame, &table).unwrap(); + + assert!(!cells.iter().any(|c| c.kind == TableCellKind::Title)); + let subtitle = cells + .iter() + .find(|c| c.kind == TableCellKind::Subtitle) + .unwrap(); + // No title row above it, so subtitle takes row 0 itself. + assert_eq!(subtitle.top, 0); + } + + #[test] + fn build_cells_places_the_caption_after_the_body() { + let frame = df! { + "id" => vec![1i32, 2i32], + } + .unwrap(); + let mut table = Table::new(); + table + .labels + .labels + .insert("caption".to_string(), Some("Source: test".to_string())); + + let cells = resolve_cells(&frame, &table).unwrap(); + + let caption = cells + .iter() + .find(|c| c.kind == TableCellKind::Caption) + .unwrap(); + let max_other_bottom = cells + .iter() + .filter(|c| c.kind != TableCellKind::Caption) + .map(|c| c.bottom) + .max() + .unwrap(); + assert_eq!(caption.top, max_other_bottom + 1); + assert_eq!(caption.left, 0); + assert_eq!(caption.right, 0); + assert_eq!(caption.classes, [TableClass::Caption]); + } + + #[test] + fn resolve_layout_marks_heading_and_col_heading_rows() { + let frame = df! { + "id" => vec![1i32], + } + .unwrap(); + let mut table = Table::new(); + table + .labels + .labels + .insert("title".to_string(), Some("Title".to_string())); + table + .labels + .labels + .insert("subtitle".to_string(), Some("Subtitle".to_string())); + + let layout = resolve_layout(&frame, &table).unwrap(); + + // Title (row 0), Subtitle (row 1), ColumnLabel (row 2), Body (row 3). + assert_eq!(layout.rows.len(), 4); + assert_eq!(layout.rows[0].classes, vec![TableClass::Heading]); + assert_eq!(layout.rows[1].classes, vec![TableClass::Heading]); + assert_eq!(layout.rows[2].classes, vec![TableClass::ColHeadingRow]); + assert!(layout.rows[3].classes.is_empty()); + // Title/Subtitle/ColumnLabel are all header rows; only the body row + // isn't. + assert!(layout.rows[0].is_header); + assert!(layout.rows[1].is_header); + assert!(layout.rows[2].is_header); + assert!(!layout.rows[3].is_header); + } + + #[test] + fn reserved_label_keys_always_win_over_a_same_named_column() { + // A column literally named "title" can't get a header override via + // LABEL — the reserved key always wins, and the column just keeps + // its own name, exactly as if no LABEL entry existed for it. + let frame = df! { + "title" => vec![1i32], + } + .unwrap(); + let mut table = Table::new(); + table + .labels + .labels + .insert("title".to_string(), Some("The Table's Title".to_string())); + + let cells = resolve_cells(&frame, &table).unwrap(); + + let heading = cells + .iter() + .find(|c| c.kind == TableCellKind::Title) + .unwrap(); + assert_eq!(heading.content, "The Table's Title"); + let column_label = cells + .iter() + .find(|c| c.kind == TableCellKind::ColumnLabel) + .unwrap(); + assert_eq!(column_label.content, "title"); + } +} diff --git a/src/execute/table/mod.rs b/src/execute/table/mod.rs new file mode 100644 index 000000000..85d008be7 --- /dev/null +++ b/src/execute/table/mod.rs @@ -0,0 +1,485 @@ +//! Table resolution: turns a TABULATE query + Reader into a ResolvedTable. +//! +//! A Table has no layers, so there's no per-layer CTE materialization, scale +//! resolution, or facet handling here — just the one data query, plus +//! resolving that data into positioned `TableCell`s. `FORMAT STUB` lives in +//! `stub`, `FORMAT` values and settings in `format`, and cell/row grid +//! construction in `layout`; the `TableLayout` assembly and `SPAN` resolution +//! it composes live in `crate::table`. + +mod format; +mod layout; +mod stub; + +use format::{apply_formats, setup_formats}; +use layout::{build_cells, setup_columns}; + +use crate::table::extract_heading_labels; + +use super::cte::split_with_query; +use crate::naming; +use crate::parser::{self, SourceTree}; +use crate::reader::SqlDialect; +use crate::reader::{Reader, ResolvedTable}; +use crate::sql; +use crate::validate::{validate, ValidationWarning}; +use crate::{DataFrame, GgsqlError, Result, SelectionItem, Spec}; + +/// Resolve a TABULATE query into a `ResolvedTable`. +/// +/// Takes the *first* `Table` spec found in the query — several TABULATE +/// statements, or a mix of VISUALISE and TABULATE, aren't disambiguated +/// further. Setup (INSTALL, LOAD, SET) and side-effect DML (CREATE, INSERT, +/// UPDATE, DELETE) ahead of a TABULATE run here. +pub fn resolve_table_with_reader(query: &str, reader: &dyn Reader) -> Result { + let validated = validate(query)?; + let warnings: Vec = validated.warnings().to_vec(); + + let source_tree = SourceTree::new(query)?; + source_tree.validate()?; + + let table = parser::build_ast(&source_tree)? + .into_iter() + .find_map(Spec::into_table) + .ok_or_else(|| GgsqlError::ValidationError("No table specification found".to_string()))?; + let mut labels = table.labels.clone(); + let (title, subtitle, caption) = extract_heading_labels(&mut labels); + + super::execute_setup_statements(&source_tree, reader)?; + + // Run structured DML (CREATE, INSERT, UPDATE, DELETE) so any table it + // creates exists before the TABULATE query reads it. + for stmt in super::cte::extract_side_effects(&source_tree) { + reader.execute_sql(&stmt)?; + } + + let sql = + build_table_sql(&source_tree, &table.selection, reader.dialect()).ok_or_else(|| { + GgsqlError::ValidationError( + "TABULATE has no data source: add a FROM, or a SQL query before it".to_string(), + ) + })?; + + let df = resolve_selection(reader.execute_sql(&sql)?, &table.selection)?; + + let column_names = df.get_column_names(); + let spans = table + .resolve_spanners(&labels, Some(&column_names)) + .map_err(GgsqlError::ValidationError)?; + // The shape both create_table_columns (SETTING) and apply_formats + // (RENAMING) read from. Resolved after spans so a FORMAT column entry + // can name a SPAN id in place of the columns it covers. + let formats = setup_formats(&df, &table.formats, &spans)?; + let columns = setup_columns(&df, &spans, &labels, &formats); + let df = apply_formats(&df, &formats)?; + let layout = build_cells( + &df, + columns, + &spans, + title.as_deref(), + subtitle.as_deref(), + caption.as_deref(), + )?; + + Ok(ResolvedTable::new(layout, sql, warnings)) +} + +/// Resolves a mixed selection like `TABULATE foo, bar AS Baz, *`: named +/// columns are kept only at the position they were written (a rename +/// included), the wildcard contributes the rest of the source columns. +/// Selections without both a wildcard and a named column pass through +/// unchanged. Duplicate output column names are an error. +fn resolve_selection(df: DataFrame, selection: &[SelectionItem]) -> Result { + let n_named_items = selection + .iter() + .filter(|item| matches!(item, SelectionItem::Column { .. })) + .count(); + let n_wildcards = selection.len() - n_named_items; + + let df = if n_named_items == 0 || n_wildcards == 0 { + df + } else { + let names = df.get_column_names(); + let wildcard_width = names + .len() + .checked_sub(n_named_items) + .filter(|remaining| remaining % n_wildcards == 0) + .map(|remaining| remaining / n_wildcards) + .ok_or_else(|| { + GgsqlError::ValidationError( + "TABULATE selection resolved an unexpected number of SQL columns".to_string(), + ) + })?; + + // A column item — bare or renamed — already surfaces its source + // column once, at its written position; drop the wildcard's copy of + // that same source column so a rename doesn't show it twice. A + // constant column (`'x' AS label`, no source) has nothing to drop. + let claimed_sources: std::collections::HashSet<&str> = selection + .iter() + .filter_map(|item| match item { + SelectionItem::Column { + source: Some(source), + .. + } => Some(source.as_str()), + _ => None, + }) + .collect(); + + let mut columns: Vec<(String, arrow::array::ArrayRef)> = Vec::new(); + let mut cursor = 0usize; + for item in selection { + match item { + SelectionItem::Column { name, .. } => { + columns.push((name.clone(), df.inner().column(cursor).clone())); + cursor += 1; + } + SelectionItem::Wildcard => { + for (idx, name) in names.iter().enumerate().skip(cursor).take(wildcard_width) { + if !claimed_sources.contains(name.as_str()) { + columns.push((name.clone(), df.inner().column(idx).clone())); + } + } + cursor += wildcard_width; + } + } + } + DataFrame::new(columns)? + }; + + // Downstream steps look columns up by name, so a duplicate output name + // would silently shadow rather than error. + let mut seen = std::collections::HashSet::new(); + if let Some(dup) = df + .get_column_names() + .into_iter() + .find(|name| !seen.insert(name.clone())) + { + return Err(GgsqlError::ValidationError(format!( + "TABULATE selection produces the column '{dup}' more than once: give each occurrence a distinct name" + ))); + } + + Ok(df) +} + +/// The selection as a SQL select list (e.g. `foo, bar AS Baz, *`). +fn selection_sql(selection: &[SelectionItem]) -> String { + selection + .iter() + .map(|item| match item { + SelectionItem::Wildcard => "*", + SelectionItem::Column { sql, .. } => sql, + }) + .collect::>() + .join(", ") +} + +/// Builds the SQL a `TABULATE` query executes, folding `selection` in as +/// the outer projection. Table-side counterpart to +/// `execute::cte::transform_global_sql`, without CTE-rewriting or +/// cache-staging. +fn build_table_sql( + source_tree: &SourceTree, + selection: &[SelectionItem], + dialect: &dyn SqlDialect, +) -> Option { + let wildcard_only = matches!(selection, [SelectionItem::Wildcard]); + let root = source_tree.root(); + + // A WITH...SELECT tail, or a plain trailing SELECT. + let select_sql = split_with_query(source_tree) + .map(|(_, select)| select) + .or_else(|| source_tree.find_text(&root, "(sql_statement (select_statement) @select)")); + + if let Some(select_sql) = select_sql { + return Some(if wildcard_only { + select_sql + } else { + wrap_selection(dialect, selection, &select_sql) + }); + } + + // No trailing SELECT: fall back to TABULATE FROM . + let first_stmt = source_tree.first_stmt(&root)?; + let from_source = source_tree.find_text( + &first_stmt, + r#"(tabulate_statement (single_source_from source: (_) @source))"#, + ); + + if let Some(source) = from_source { + return Some(format!("SELECT {} FROM {source}", selection_sql(selection))); + } + + // Neither: e.g. a bare DuckDB-style `FROM t`. The text may carry a + // leading setup-statement prefix (INSTALL/LOAD/SET), which a non-"*" + // selection then wraps into an invalid subquery — a known gap. + let fallback = source_tree.extract_sql()?; + Some(if wildcard_only { + fallback + } else { + wrap_selection(dialect, selection, &fallback) + }) +} + +/// `SELECT FROM ()`, aliased as the dialect requires and +/// with a leading WITH hoisted out where the dialect forbids CTEs in a +/// derived table. +fn wrap_selection(dialect: &dyn SqlDialect, selection: &[SelectionItem], query: &str) -> String { + sql::select_from( + dialect, + &selection_sql(selection), + sql::FromItem::Query(query), + naming::TABLE_ALIAS, + ) +} + +#[cfg(test)] +#[cfg(feature = "duckdb")] +mod integration_tests { + use super::*; + use crate::reader::DuckDBReader; + use crate::table::TableCellKind; + + fn reader_with_sales() -> DuckDBReader { + let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); + reader + .execute_sql("CREATE TABLE sales AS SELECT * FROM (VALUES (1, 'a'), (2, 'b'), (3, 'c')) AS t(id, name)") + .unwrap(); + reader + } + + #[test] + fn test_tabulate_from() { + let reader = reader_with_sales(); + let resolved = resolve_table_with_reader("TABULATE * FROM sales", &reader).unwrap(); + + assert_eq!(resolved.sql(), "SELECT * FROM sales"); + // 3 data rows + 1 column-label row: nrow() is the whole grid, not + // just the data rows. + assert_eq!(resolved.nrow(), 4); + assert_eq!(resolved.ncol(), 2); + } + + #[test] + fn test_tabulate_selection_picks_and_renames_columns() { + let reader = reader_with_sales(); + + let picked = + resolve_table_with_reader("TABULATE name, id AS Number FROM sales", &reader).unwrap(); + assert_eq!(picked.sql(), "SELECT name, id AS Number FROM sales"); + assert_eq!(picked.ncol(), 2); + } + + #[test] + fn test_tabulate_selection_wildcard_with_rename_does_not_duplicate_the_source_column() { + let reader = reader_with_sales(); + + // sales is (id, name): `id AS Number` renames `id`, so the + // wildcard's own copy of `id` is dropped rather than shown twice. + let trailing = + resolve_table_with_reader("TABULATE *, id AS Number FROM sales", &reader).unwrap(); + assert_eq!(trailing.sql(), "SELECT *, id AS Number FROM sales"); + assert_eq!(trailing.ncol(), 2); + let order: Vec<_> = trailing.columns().iter().map(|c| c.name.as_str()).collect(); + assert_eq!(order, ["name", "Number"]); + + let leading = + resolve_table_with_reader("TABULATE id AS Number, * FROM sales", &reader).unwrap(); + assert_eq!(leading.ncol(), 2); + let order: Vec<_> = leading.columns().iter().map(|c| c.name.as_str()).collect(); + assert_eq!(order, ["Number", "name"]); + } + + #[test] + fn test_tabulate_selection_wildcard_dedupes_named_columns() { + let reader = reader_with_sales(); + + let resolved = resolve_table_with_reader("TABULATE name, * FROM sales", &reader).unwrap(); + assert_eq!(resolved.sql(), "SELECT name, * FROM sales"); + // sales is (id, name): the wildcard's second `name` is dropped. + assert_eq!(resolved.ncol(), 2); + + let trailing = resolve_table_with_reader("TABULATE *, name FROM sales", &reader).unwrap(); + assert_eq!(trailing.ncol(), 2); + // The named column keeps its written position. + let order: Vec<_> = trailing.columns().iter().map(|c| c.name.as_str()).collect(); + assert_eq!(order, ["id", "name"]); + } + + #[test] + fn test_tabulate_selection_duplicate_alias_without_wildcard_errors() { + let reader = reader_with_sales(); + match resolve_table_with_reader("TABULATE id AS x, name AS x FROM sales", &reader) { + Ok(_) => panic!("expected a duplicate output column to error"), + Err(GgsqlError::ValidationError(msg)) => assert!(msg.contains("'x'")), + Err(e) => panic!("expected a ValidationError, got {e:?}"), + } + } + + #[test] + fn test_tabulate_selection_rename_colliding_with_wildcard_column_name_errors() { + let reader = reader_with_sales(); + match resolve_table_with_reader("TABULATE id AS name, * FROM sales", &reader) { + Ok(_) => panic!("expected a duplicate output column to error"), + Err(GgsqlError::ValidationError(msg)) => assert!(msg.contains("'name'")), + Err(e) => panic!("expected a ValidationError, got {e:?}"), + } + } + + #[test] + fn test_tabulate_from_table_created_by_preceding_create() { + let reader = reader_with_sales(); + let resolved = resolve_table_with_reader( + "CREATE TEMP TABLE my_data AS SELECT * FROM sales LIMIT 2 TABULATE * FROM my_data", + &reader, + ) + .unwrap(); + // 2 data rows + 1 column-label row. + assert_eq!(resolved.nrow(), 3); + } + + #[test] + fn test_tabulate_selection_with_no_tabulate_from() { + // StubReader with the ANSI dialect pins the aliasing assertion to a + // known dialect rather than whatever DuckDB happens to use. + let (reader, _log) = + crate::reader::test_support::StubReader::new(&crate::reader::AnsiDialect); + + let no_from = + resolve_table_with_reader("SELECT * FROM sales TABULATE name, id AS Number", &reader) + .unwrap(); + assert_eq!( + no_from.sql(), + "SELECT name, id AS Number FROM (SELECT * FROM sales) AS \"__table__\"" + ); + assert_eq!(no_from.ncol(), 2); + } + + #[test] + fn test_bare_tabulate_uses_preceding_select() { + let reader = reader_with_sales(); + + let from_only = resolve_table_with_reader("TABULATE * FROM sales", &reader).unwrap(); + let select_then_tabulate = + resolve_table_with_reader("SELECT * FROM sales TABULATE *", &reader).unwrap(); + + assert_eq!(from_only.sql(), select_then_tabulate.sql()); + assert_eq!(from_only.nrow(), select_then_tabulate.nrow()); + } + + #[test] + fn test_tabulate_with_no_source_errors() { + let reader = reader_with_sales(); + let result = resolve_table_with_reader("TABULATE *", &reader); + assert!(result.is_err()); + } + + #[test] + fn test_tabulate_does_not_borrow_a_later_visualise_from() { + // A source-less TABULATE followed by an unrelated VISUALISE FROM must + // still error "no data source", not silently resolve against the + // VISUALISE's FROM. + let reader = reader_with_sales(); + let result = + resolve_table_with_reader("TABULATE * VISUALISE FROM sales DRAW point", &reader); + assert!(result.is_err()); + } + + #[test] + fn test_tabulate_label_applies_under_the_tab_clause_wrapper() { + // label_clause is nested under tab_clause in the grammar (so LABEL + // and SPAN can appear in any order) — build_tabulate_statement must + // unwrap it, not match "label_clause" as a direct child. + let reader = reader_with_sales(); + let resolved = + resolve_table_with_reader("TABULATE * FROM sales LABEL id => 'ID'", &reader).unwrap(); + + let label_cell = resolved + .cells() + .iter() + .find(|c| c.kind == TableCellKind::ColumnLabel && c.left == 0) + .unwrap(); + assert_eq!(label_cell.content, "ID"); + } + + #[test] + fn test_tabulate_format_stub_moves_the_column_to_the_front() { + let reader = reader_with_sales(); + let resolved = + resolve_table_with_reader("TABULATE * FROM sales FORMAT STUB name", &reader).unwrap(); + + let columns = resolved.columns(); + assert_eq!(columns[0].name, "name"); + assert_eq!(columns[0].target, crate::ColumnSection::Stub); + assert_eq!(columns[1].name, "id"); + assert_eq!(columns[1].target, crate::ColumnSection::Body); + + let stub_cell = resolved + .cells() + .iter() + .find(|c| c.kind == TableCellKind::StubRowLabel) + .unwrap(); + assert_eq!(stub_cell.left, 0); + let stub_head = resolved + .cells() + .iter() + .find(|c| c.kind == TableCellKind::StubHead) + .unwrap(); + assert_eq!(stub_head.left, 0); + } + + #[test] + fn test_tabulate_format_stub_head_is_blank_by_default_but_label_can_override() { + let reader = reader_with_sales(); + let resolved = + resolve_table_with_reader("TABULATE * FROM sales FORMAT STUB name", &reader).unwrap(); + let stub_head = resolved + .cells() + .iter() + .find(|c| c.kind == TableCellKind::StubHead) + .unwrap(); + assert_eq!(stub_head.content, ""); + + let resolved = resolve_table_with_reader( + "TABULATE * FROM sales FORMAT STUB name LABEL name => 'Name'", + &reader, + ) + .unwrap(); + let stub_head = resolved + .cells() + .iter() + .find(|c| c.kind == TableCellKind::StubHead) + .unwrap(); + assert_eq!(stub_head.content, "Name"); + } + + #[test] + fn test_tabulate_span_over_a_stub_column_errors() { + let reader = reader_with_sales(); + let result = resolve_table_with_reader( + "TABULATE * FROM sales FORMAT STUB name SPAN G OVER name, id", + &reader, + ); + + assert!(result.is_err()); + } + + #[test] + fn test_tabulate_span_unknown_column_errors_even_with_gather_disabled() { + // Table::resolve_spanners checks every OVER entry regardless of + // gather, so this errors even though gather_columns itself (the + // only place that also checks column existence) is skipped here. + let reader = reader_with_sales(); + match resolve_table_with_reader( + "TABULATE * FROM sales SPAN G OVER nope, id SETTING gather => false", + &reader, + ) { + Ok(_) => panic!("expected an unknown OVER column to error"), + Err(GgsqlError::ValidationError(msg)) => { + assert!(msg.contains("SPAN references unknown column 'nope'")) + } + Err(e) => panic!("expected a ValidationError, got {e:?}"), + } + } +} diff --git a/src/execute/table/stub.rs b/src/execute/table/stub.rs new file mode 100644 index 000000000..3a0f427ea --- /dev/null +++ b/src/execute/table/stub.rs @@ -0,0 +1,117 @@ +//! `TABULATE FORMAT STUB` resolution: building the `StubHead`/`StubRowLabel` +//! cells. + +use crate::array_util::value_to_string; +use crate::table::{TableCell, TableColumn}; +use crate::DataFrame; + +/// Build one `StubHead` cell per stub column, numbered from `top == 0`. +/// Skips every non-stub column. +pub(crate) fn create_stubhead(columns: &[TableColumn]) -> Vec { + columns + .iter() + .enumerate() + .filter(|(_, column)| column.is_stub()) + .map(|(index, column)| { + TableCell::new_stub_head(index, column.label.clone()) + .with_properties(column.properties.clone()) + }) + .collect() +} + +/// Build one `StubRowLabel` cell per `DataFrame` value in every stub +/// column, numbered from `top == 0`. Skips every non-stub column. +pub(crate) fn create_row_labels(df: &DataFrame, columns: &[TableColumn]) -> Vec { + let mut cells = Vec::new(); + + for (index, column) in columns.iter().enumerate() { + if !column.is_stub() { + continue; + } + + let array = df + .column(&column.name) + .expect("TableColumn.name always names a column of df"); + + for row in 0..df.height() { + cells.push( + TableCell::new_stub_row_label(row, index, value_to_string(array, row)) + .with_properties(column.properties.clone()), + ); + } + } + + cells +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::df; + use crate::plot::Parameters; + use crate::table::TableCellKind; + use crate::ColumnSection; + + fn column(name: &str, label: &str) -> TableColumn { + TableColumn { + name: name.to_string(), + label: label.to_string(), + properties: Parameters::new(), + target: ColumnSection::Body, + } + } + + fn stub_column(name: &str) -> TableColumn { + TableColumn { + target: ColumnSection::Stub, + ..column(name, name) + } + } + + #[test] + fn create_stubhead_only_builds_stub_columns() { + let columns = vec![stub_column("region"), column("sales", "sales")]; + + let labels = create_stubhead(&columns); + + assert_eq!(labels.len(), 1); + assert_eq!(labels[0].kind, TableCellKind::StubHead); + assert_eq!(labels[0].left, 0); + assert_eq!(labels[0].content, "region"); + } + + #[test] + fn create_stubhead_is_empty_with_no_stub_columns() { + let columns = vec![column("id", "id")]; + + assert!(create_stubhead(&columns).is_empty()); + } + + #[test] + fn create_row_labels_only_builds_stub_columns() { + let frame = df! { + "region" => vec!["north".to_string(), "south".to_string()], + "sales" => vec![1i32, 2i32], + } + .unwrap(); + let columns = vec![stub_column("region"), column("sales", "sales")]; + + let labels = create_row_labels(&frame, &columns); + + assert_eq!(labels.len(), 2); + assert!(labels + .iter() + .all(|cell| cell.kind == TableCellKind::StubRowLabel)); + assert_eq!(labels[0].content, "north"); + assert_eq!(labels[0].left, 0); + assert_eq!(labels[1].content, "south"); + } + + #[test] + fn create_row_labels_is_empty_with_no_stub_columns() { + let frame = df! { "sales" => vec![1i32] }.unwrap(); + let columns = vec![column("sales", "sales")]; + + assert!(create_row_labels(&frame, &columns).is_empty()); + } +} diff --git a/src/format.rs b/src/format.rs index d0d9df1ef..24118daf6 100644 --- a/src/format.rs +++ b/src/format.rs @@ -8,11 +8,14 @@ //! - `{:time %fmt}` - DateTime strftime format (e.g., `{:time %b %Y}` -> "Jan 2024") //! - `{:num %fmt}` - Number printf format (e.g., `{:num %.2f}` -> "25.50") +use arrow::array::{Array, ArrayRef}; +use arrow::datatypes::DataType; use chrono::{NaiveDate, NaiveDateTime, NaiveTime}; use regex::Regex; use std::collections::HashMap; use std::sync::OnceLock; +use crate::array_util::{as_f64, as_str, cast_array}; use crate::plot::ArrayElement; /// Placeholder types supported in label templates @@ -154,6 +157,15 @@ fn format_number_with_spec(value: &str, fmt: &str) -> String { value.to_string() } +/// Default template: passes a value through unchanged. Shared `serde` +/// default for every `*_template` field using this module's placeholder +/// syntax (`Scale::label_template`, `Format::value_template`) — their +/// default can't just be `String::default()`, since `""` would blank every +/// value instead of passing it through. +pub(crate) fn default_template() -> String { + "{}".to_string() +} + /// Apply a label template to an array of break values. /// /// Each break value is formatted using the template string. @@ -191,7 +203,14 @@ pub fn apply_label_template( .any(|p| matches!(p.placeholder, Placeholder::Plain)) || placeholders.is_empty(); let numeric_precision = if has_plain { - compute_numeric_precision(breaks) + let numbers: Vec = breaks + .iter() + .filter_map(|e| match e { + ArrayElement::Number(n) => Some(*n), + _ => None, + }) + .collect(); + compute_numeric_precision(&numbers) } else { None }; @@ -217,20 +236,14 @@ pub fn apply_label_template( result } -/// Determine the number of decimal places needed to display numeric breaks -/// consistently. Based on the algorithm from R's `scales::precision()`: -/// uses the smallest inter-break difference to derive display precision. -/// Returns `None` if there are fewer than 2 numeric breaks or all are integers. -fn compute_numeric_precision(breaks: &[ArrayElement]) -> Option { +/// Determine the number of decimal places needed to display numeric values +/// consistently. Based on the algorithm from R's `scales::precision()`: uses +/// the smallest inter-value difference to derive display precision. Returns +/// `None` if there are fewer than 2 numbers or all are integers. +fn compute_numeric_precision(numbers: &[f64]) -> Option { let tol = f64::EPSILON.sqrt(); - let mut numbers: Vec = breaks - .iter() - .filter_map(|e| match e { - ArrayElement::Number(n) => Some(*n), - _ => None, - }) - .collect(); + let mut numbers: Vec = numbers.to_vec(); if numbers.len() < 2 { return None; @@ -287,19 +300,39 @@ pub fn format_dataframe_column( column_name: &str, template: &str, ) -> Result { - use crate::array_util::{as_f64, as_str, cast_array, new_str_array}; - use arrow::array::Array; - use arrow::datatypes::DataType; + use crate::array_util::new_str_array; // Get the column let column = df .column(column_name) .map_err(|e| format!("Column '{}' not found: {}", column_name, e))?; - // Step 1: Convert entire column to strings - let string_values: Vec> = if let Ok(str_col) = as_str(column) { + let string_values = column_to_strings(column).map_err(|e| { + format!("{e} in column '{column_name}'. Try string or numeric types instead.") + })?; + + // Step 2: Apply formatting template to all string values + let placeholders = parse_placeholders(template); + let formatted_owned: Vec> = string_values + .into_iter() + .map(|opt| opt.map(|s| format_value(&s, template, &placeholders, None))) + .collect(); + + let formatted_refs: Vec> = + formatted_owned.iter().map(|opt| opt.as_deref()).collect(); + let formatted_col = new_str_array(formatted_refs); + + // Replace column in DataFrame + df.with_column(column_name, formatted_col) + .map_err(|e| format!("Failed to replace column: {}", e)) +} + +/// Convert an Arrow column to one `String` per row (`None` for a null), +/// supporting string and numeric columns. +fn column_to_strings(column: &ArrayRef) -> Result>, String> { + if let Ok(str_col) = as_str(column) { // String column (includes temporal data auto-converted to ISO format) - (0..str_col.len()) + Ok((0..str_col.len()) .map(|i| { if str_col.is_null(i) { None @@ -307,14 +340,14 @@ pub fn format_dataframe_column( Some(str_col.value(i).to_string()) } }) - .collect() + .collect()) } else if let Ok(cast) = cast_array(column, &DataType::Float64) { // Numeric column - use shared format_number helper for clean integer formatting use crate::plot::format_number; let f64_col = as_f64(&cast).map_err(|e| format!("Failed to cast column to f64: {}", e))?; - (0..f64_col.len()) + Ok((0..f64_col.len()) .map(|i| { if f64_col.is_null(i) { None @@ -322,29 +355,74 @@ pub fn format_dataframe_column( Some(format_number(f64_col.value(i))) } }) - .collect() + .collect()) } else { - return Err(format!( - "Formatting doesn't support type {:?} in column '{}'. Try string or numeric types instead.", - column.data_type(), - column_name - )); - }; + Err(format!( + "Formatting doesn't support type {:?}", + column.data_type() + )) + } +} - // Step 2: Apply formatting template to all string values - let placeholders = parse_placeholders(template); - let formatted_owned: Vec> = string_values - .into_iter() - .map(|opt| opt.map(|s| format_value(&s, template, &placeholders, None))) - .collect(); +/// Look up `key` in `mapping`. `Some(_)` (possibly empty, for an explicit +/// `=> NULL` suppression) means the mapping resolves this key outright; +/// `None` means there is no entry for it at all. +fn resolve_override( + mapping: &Option>>, + key: &str, +) -> Option { + match mapping.as_ref().and_then(|m| m.get(key)) { + Some(Some(display)) => Some(display.clone()), + Some(None) => Some(String::new()), + None => None, + } +} - let formatted_refs: Vec> = - formatted_owned.iter().map(|opt| opt.as_deref()).collect(); - let formatted_col = new_str_array(formatted_refs); +/// Resolve one display value per row of a column, for `TABULATE FORMAT`'s +/// `RENAMING` clause: an explicit `mapping` entry wins outright (including +/// suppressing a cell to blank text via `=> NULL`); every other value gets +/// `template` applied, except a null cell with no explicit `"null"` entry, +/// which is left unresolved. +/// +/// `Ok(None)` at a given row means "no resolution, use the caller's own +/// default content for that row"; `Ok(Some(s))` (possibly empty) is the +/// resolved display text. +pub fn resolve_column_values( + column: &ArrayRef, + template: &str, + mapping: &Option>>, +) -> Result>, String> { + let string_values = column_to_strings(column)?; - // Replace column in DataFrame - df.with_column(column_name, formatted_col) - .map_err(|e| format!("Failed to replace column: {}", e)) + let placeholders = parse_placeholders(template); + let has_plain = placeholders + .iter() + .any(|p| matches!(p.placeholder, Placeholder::Plain)) + || placeholders.is_empty(); + let numeric_precision = if has_plain { + let numbers: Vec = string_values + .iter() + .filter_map(|v| v.as_deref().and_then(|s| s.parse::().ok())) + .collect(); + compute_numeric_precision(&numbers) + } else { + None + }; + + Ok(string_values + .into_iter() + .map(|value| match value { + None => resolve_override(mapping, "null"), + Some(raw) => resolve_override(mapping, &raw).or_else(|| { + Some(format_value( + &raw, + template, + &placeholders, + numeric_precision, + )) + }), + }) + .collect()) } /// Format a single value using template and parsed placeholders @@ -629,4 +707,59 @@ mod tests { assert_eq!(result.get("0.05"), Some(&Some("0.05".to_string()))); assert_eq!(result.get("0.1"), Some(&Some("0.10".to_string()))); } + + #[test] + fn resolve_column_values_applies_template_to_unmapped_values() { + use crate::array_util::new_str_array; + + let column = new_str_array(vec![Some("a"), Some("b")]); + let result = resolve_column_values(&column, "[{}]", &None).unwrap(); + + assert_eq!( + result, + vec![Some("[a]".to_string()), Some("[b]".to_string())] + ); + } + + #[test] + fn resolve_column_values_explicit_mapping_wins_over_template() { + use crate::array_util::new_f64_array; + + let column = new_f64_array(vec![Some(0.0), Some(5.0)]); + let mapping = Some(HashMap::from([("0".to_string(), Some("-".to_string()))])); + let result = resolve_column_values(&column, "{:num %.2f}", &mapping).unwrap(); + + assert_eq!( + result, + vec![Some("-".to_string()), Some("5.00".to_string())] + ); + } + + #[test] + fn resolve_column_values_null_with_explicit_entry_is_resolved() { + use crate::array_util::new_f64_array; + + let column = new_f64_array(vec![None, Some(1.0)]); + let mapping = Some(HashMap::from([("null".to_string(), None)])); // => NULL suppresses + + let result = resolve_column_values(&column, "{:num %.0f}", &mapping).unwrap(); + + assert_eq!(result[0], Some(String::new())); // suppressed, not templated + assert_eq!(result[1], Some("1".to_string())); + } + + #[test] + fn resolve_column_values_null_without_an_explicit_entry_is_left_unresolved() { + use crate::array_util::new_f64_array; + + let column = new_f64_array(vec![None, Some(1.0)]); + + let result = resolve_column_values(&column, "{:num %.2f}", &None).unwrap(); + + // Caller falls back to its own default rendering for this row, the + // same way an unmapped null break is left untouched by + // apply_label_template. + assert_eq!(result[0], None); + assert_eq!(result[1], Some("1.00".to_string())); + } } diff --git a/src/labels.rs b/src/labels.rs new file mode 100644 index 000000000..8ae889967 --- /dev/null +++ b/src/labels.rs @@ -0,0 +1,32 @@ +//! Text labels from `LABEL` clauses, shared by plots and tables. +//! +//! A `LABEL` clause maps a name — an aesthetic for `VISUALISE`, a column or +//! spanner id for `TABULATE` — to display text. `None` suppresses the label. + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Text labels (from `LABEL` clause) +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +pub struct Labels { + /// Label assignments (name → text, None = suppress) + pub labels: HashMap>, +} + +impl Labels { + /// Merge another clause's labels into this one; later entries win. + pub fn merge(&mut self, other: Labels) { + self.labels.extend(other.labels); + } + + /// Look up a name: `None` if there is no entry, `Some(None)` if the + /// label is suppressed, `Some(Some(text))` for an explicit label. + pub fn lookup(&self, name: &str) -> Option> { + self.labels.get(name).map(|v| v.as_deref()) + } + + /// Whether a name has an entry (set or suppressed). + pub fn contains(&self, name: &str) -> bool { + self.labels.contains_key(name) + } +} diff --git a/src/lib.rs b/src/lib.rs index 7ce82d492..c859f9bf5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -39,10 +39,14 @@ pub mod array_util; pub mod compute; pub mod dataframe; pub mod format; +pub mod labels; pub mod naming; +pub mod params; pub mod parser; pub mod plot; +pub mod spec; pub mod sql; +pub mod table; pub mod util; pub mod reader; @@ -69,6 +73,10 @@ pub use plot::{ SqlExpression, }; +// Re-export the parse-time Plot/Table result and the Table stub +pub use spec::Spec; +pub use table::{ColumnSection, Format, SelectionItem, Spanner, Table}; + // Re-export aesthetic classification utilities pub use plot::aesthetic::{ is_position_aesthetic, AestheticContext, MATERIAL_AESTHETICS, POSITION_SUFFIXES, @@ -84,6 +92,11 @@ pub use util::{and_list, and_list_quoted, or_list, or_list_quoted}; // DataFrame abstraction (wraps Arrow RecordBatch) pub use dataframe::DataFrame; +// Re-export the resolved table layout Writer::write_table needs — the +// Table-side counterpart to DataFrame, not to the plot:: AST vocabulary +// above, since Table has no specification vocabulary of its own yet. +pub use table::{TableCell, TableCellKind, TableClass, TableColumn, TableLayout, TableRow}; + /// Main library error type #[derive(thiserror::Error, Debug)] pub enum GgsqlError { @@ -170,7 +183,7 @@ mod integration_tests { // Generate Vega-Lite JSON let writer = VegaLiteWriter::new(); - let json_str = writer.write(&spec, &wrap_data(df)).unwrap(); + let json_str = writer.write_plot(&spec, &wrap_data(df)).unwrap(); let vl_spec: serde_json::Value = serde_json::from_str(&json_str).unwrap(); // CRITICAL ASSERTION: x-axis should be automatically inferred as "temporal" @@ -230,7 +243,7 @@ mod integration_tests { // Generate Vega-Lite JSON let writer = VegaLiteWriter::new(); - let json_str = writer.write(&spec, &wrap_data(df)).unwrap(); + let json_str = writer.write_plot(&spec, &wrap_data(df)).unwrap(); let vl_spec: serde_json::Value = serde_json::from_str(&json_str).unwrap(); // x-axis should be automatically inferred as "temporal" @@ -288,7 +301,7 @@ mod integration_tests { // Generate Vega-Lite JSON let writer = VegaLiteWriter::new(); - let json_str = writer.write(&spec, &wrap_data(df)).unwrap(); + let json_str = writer.write_plot(&spec, &wrap_data(df)).unwrap(); let vl_spec: serde_json::Value = serde_json::from_str(&json_str).unwrap(); // Types should be inferred as quantitative @@ -343,7 +356,7 @@ mod integration_tests { spec.transform_aesthetics_to_internal(); let writer = VegaLiteWriter::new(); - let json_str = writer.write(&spec, &wrap_data(df)).unwrap(); + let json_str = writer.write_plot(&spec, &wrap_data(df)).unwrap(); let vl_spec: serde_json::Value = serde_json::from_str(&json_str).unwrap(); // Check null handling in JSON @@ -380,7 +393,7 @@ mod integration_tests { spec.transform_aesthetics_to_internal(); let writer = VegaLiteWriter::new(); - let json_str = writer.write(&spec, &wrap_data(df)).unwrap(); + let json_str = writer.write_plot(&spec, &wrap_data(df)).unwrap(); let vl_spec: serde_json::Value = serde_json::from_str(&json_str).unwrap(); // String columns should be inferred as nominal @@ -438,7 +451,7 @@ mod integration_tests { spec.transform_aesthetics_to_internal(); let writer = VegaLiteWriter::new(); - let json_str = writer.write(&spec, &wrap_data(df)).unwrap(); + let json_str = writer.write_plot(&spec, &wrap_data(df)).unwrap(); let vl_spec: serde_json::Value = serde_json::from_str(&json_str).unwrap(); // x-axis should be temporal @@ -486,7 +499,7 @@ mod integration_tests { spec.transform_aesthetics_to_internal(); let writer = VegaLiteWriter::new(); - let json_str = writer.write(&spec, &wrap_data(df)).unwrap(); + let json_str = writer.write_plot(&spec, &wrap_data(df)).unwrap(); let vl_spec: serde_json::Value = serde_json::from_str(&json_str).unwrap(); // Check values are preserved @@ -544,7 +557,7 @@ mod integration_tests { spec.transform_aesthetics_to_internal(); let writer = VegaLiteWriter::new(); - let json_str = writer.write(&spec, &wrap_data(df)).unwrap(); + let json_str = writer.write_plot(&spec, &wrap_data(df)).unwrap(); let vl_spec: serde_json::Value = serde_json::from_str(&json_str).unwrap(); // All integer types should be quantitative @@ -613,7 +626,9 @@ mod integration_tests { // Generate Vega-Lite let writer = VegaLiteWriter::new(); - let json_str = writer.write(&prepared.specs[0], &prepared.data).unwrap(); + let json_str = writer + .write_plot(&prepared.specs[0], &prepared.data) + .unwrap(); let vl_spec: serde_json::Value = serde_json::from_str(&json_str).unwrap(); // Verify we have two layers @@ -771,7 +786,7 @@ mod integration_tests { // Verify the spec has the facet configuration assert!( prepared.specs[0].facet.is_some(), - "Spec should have facet configuration" + "ResolvedPlot should have facet configuration" ); } @@ -794,7 +809,9 @@ mod integration_tests { // Render to Vega-Lite let writer = VegaLiteWriter::new(); - let json_str = writer.write(&prepared.specs[0], &prepared.data).unwrap(); + let json_str = writer + .write_plot(&prepared.specs[0], &prepared.data) + .unwrap(); let vl_spec: serde_json::Value = serde_json::from_str(&json_str).unwrap(); // Find the point annotation layer (should be second layer) @@ -913,7 +930,9 @@ mod integration_tests { // Generate Vega-Lite and verify it works let writer = VegaLiteWriter::new(); - let json_str = writer.write(&prepared.specs[0], &prepared.data).unwrap(); + let json_str = writer + .write_plot(&prepared.specs[0], &prepared.data) + .unwrap(); let vl_spec: serde_json::Value = serde_json::from_str(&json_str).unwrap(); // Both layers should have stroke field-mapped to prefixed aesthetic-named column @@ -1036,7 +1055,9 @@ mod integration_tests { let prepared = execute::prepare_data_with_reader(query, &reader).unwrap(); let writer = VegaLiteWriter::new(); - let json_str = writer.write(&prepared.specs[0], &prepared.data).unwrap(); + let json_str = writer + .write_plot(&prepared.specs[0], &prepared.data) + .unwrap(); let vl_spec: serde_json::Value = serde_json::from_str(&json_str).unwrap(); let layers = vl_spec["layer"].as_array().unwrap(); @@ -1072,7 +1093,9 @@ mod integration_tests { let prepared = execute::prepare_data_with_reader(query, &reader).unwrap(); let writer = VegaLiteWriter::new(); - let json_str = writer.write(&prepared.specs[0], &prepared.data).unwrap(); + let json_str = writer + .write_plot(&prepared.specs[0], &prepared.data) + .unwrap(); let vl_spec: serde_json::Value = serde_json::from_str(&json_str).unwrap(); assert_eq!(vl_spec["layer"][0]["mark"]["type"], "geoshape"); @@ -1119,7 +1142,9 @@ mod integration_tests { let prepared = execute::prepare_data_with_reader(&query, &reader).unwrap(); let writer = VegaLiteWriter::new(); - let json_str = writer.write(&prepared.specs[0], &prepared.data).unwrap(); + let json_str = writer + .write_plot(&prepared.specs[0], &prepared.data) + .unwrap(); let vl_spec: serde_json::Value = serde_json::from_str(&json_str).unwrap(); let data = vl_spec["data"]["values"].as_array().unwrap(); @@ -1247,7 +1272,9 @@ mod integration_tests { let prepared = execute::prepare_data_with_reader(query, &reader).unwrap(); let writer = VegaLiteWriter::new(); - let json_str = writer.write(&prepared.specs[0], &prepared.data).unwrap(); + let json_str = writer + .write_plot(&prepared.specs[0], &prepared.data) + .unwrap(); let vl_spec: serde_json::Value = serde_json::from_str(&json_str).unwrap(); let data = vl_spec["data"]["values"].as_array().unwrap(); @@ -1279,7 +1306,9 @@ mod integration_tests { let prepared = execute::prepare_data_with_reader(query, &reader).unwrap(); let writer = VegaLiteWriter::new(); - let json_str = writer.write(&prepared.specs[0], &prepared.data).unwrap(); + let json_str = writer + .write_plot(&prepared.specs[0], &prepared.data) + .unwrap(); let vl_spec: serde_json::Value = serde_json::from_str(&json_str).unwrap(); let data = vl_spec["data"]["values"].as_array().unwrap(); diff --git a/src/naming.rs b/src/naming.rs index 6f30b0bb6..660a8ffdb 100644 --- a/src/naming.rs +++ b/src/naming.rs @@ -91,6 +91,9 @@ pub const CACHE_META_TABLE: &str = concatcp!(GGSQL_PREFIX, "cache_meta", GGSQL_S /// Alias for schema extraction queries pub const SCHEMA_ALIAS: &str = concatcp!(GGSQL_SUFFIX, "schema", GGSQL_SUFFIX); +/// Alias for the derived table a TABULATE selection wraps its source in +pub const TABLE_ALIAS: &str = concatcp!(GGSQL_SUFFIX, "table", GGSQL_SUFFIX); + /// Column name for densification group ID. /// Used by geoms that expand geometry (tile, segment, ribbon, rule) to group /// densified vertices back into their original feature. @@ -296,6 +299,27 @@ pub fn aesthetic_column(aesthetic: &str) -> String { format!("{}{}{}", AES_PREFIX, aesthetic, GGSQL_SUFFIX) } +/// Generate an id for an anonymous `TABULATE SPAN` (`SPAN NULL OVER +/// ...`). Nothing in the query can reference this text, so any fresh value +/// works; the `__ggsql_*` convention just keeps it out of the way of a real +/// column name, the same guarantee every other name here gives SQL. +/// +/// # Example +/// ``` +/// use ggsql::naming; +/// let id = naming::anonymous_span_id(); +/// assert!(id.starts_with("__ggsql_span_")); +/// assert!(id.ends_with("__")); +/// ``` +pub fn anonymous_span_id() -> String { + format!( + "{}span_{}{}", + GGSQL_PREFIX, + Uuid::new_v4().simple(), + GGSQL_SUFFIX + ) +} + // ============================================================================ // SQL Quoting // ============================================================================ diff --git a/src/params.rs b/src/params.rs new file mode 100644 index 000000000..78f6bc035 --- /dev/null +++ b/src/params.rs @@ -0,0 +1,2186 @@ +//! Setting-parameter types shared across ggsql specifications. +//! +//! These model the `SETTING` clause machinery: parameter values, their +//! constraints, and validation. They are used by the plot AST, the table +//! AST, and the writers that consume both. + +use crate::naming; +use crate::reader::SqlDialect; +use chrono::{DateTime, Datelike, NaiveDate, NaiveDateTime, NaiveTime, Timelike}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +// ============================================================================= +// Array Element Type (for coercion) +// ============================================================================= + +/// Type of an ArrayElement value, used for type inference and coercion. +/// +/// This enum represents the semantic type of values in a scale's input range, +/// allowing discrete scales to infer the target type from their domain values. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ArrayElementType { + String, + Number, + Boolean, + Date, + DateTime, + Time, +} + +/// Value for geom parameters (also used for literals) +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum ParameterValue { + String(String), + Number(f64), + Boolean(bool), + Array(Vec), + /// Null value to explicitly opt out of a setting + Null, +} + +/// A collection of named parameter values, typically from `SET` clauses. +pub type Parameters = HashMap; + +impl std::fmt::Display for ParameterValue { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ParameterValue::String(s) => write!(f, "'{}'", s), + ParameterValue::Number(n) => write!(f, "{}", n), + ParameterValue::Boolean(b) => write!(f, "{}", b), + ParameterValue::Array(arr) => { + write!(f, "[")?; + for (i, elem) in arr.iter().enumerate() { + if i > 0 { + write!(f, ", ")?; + } + match elem { + ArrayElement::String(s) => write!(f, "'{}'", s)?, + ArrayElement::Number(n) => write!(f, "{}", n)?, + ArrayElement::Boolean(b) => write!(f, "{}", b)?, + ArrayElement::Null => write!(f, "null")?, + ArrayElement::Date(d) => write!(f, "'{}'", d)?, + ArrayElement::DateTime(dt) => write!(f, "'{}'", dt)?, + ArrayElement::Time(t) => write!(f, "'{}'", t)?, + } + } + write!(f, "]") + } + ParameterValue::Null => write!(f, "null"), + } + } +} + +/// Format number for display (remove trailing zeros for integers) +pub fn format_number(n: f64) -> String { + if n.fract() == 0.0 { + format!("{:.0}", n) + } else { + n.to_string() + } +} + +/// Elements in arrays (shared type for property values) +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum ArrayElement { + String(String), + Number(f64), + Boolean(bool), + /// Null placeholder for partial input range inference (e.g., SCALE x FROM (0, null)) + Null, + /// Date value (days since Unix epoch 1970-01-01) + Date(i32), + /// DateTime value (microseconds since Unix epoch) + DateTime(i64), + /// Time value (nanoseconds since midnight) + Time(i64), +} + +/// Days from CE to Unix epoch (1970-01-01) +const UNIX_EPOCH_CE_DAYS: i32 = 719163; + +/// Convert days-since-epoch to ISO date string +fn date_to_iso_string(days: i32) -> String { + NaiveDate::from_num_days_from_ce_opt(days + UNIX_EPOCH_CE_DAYS) + .map(|d| d.format("%Y-%m-%d").to_string()) + .unwrap_or_else(|| days.to_string()) +} + +/// Convert microseconds-since-epoch to ISO datetime string +fn datetime_to_iso_string(micros: i64) -> String { + DateTime::from_timestamp_micros(micros) + .map(|dt| dt.format("%Y-%m-%dT%H:%M:%S").to_string()) + .unwrap_or_else(|| micros.to_string()) +} + +/// Convert nanoseconds-since-midnight to ISO time string +fn time_to_iso_string(nanos: i64) -> String { + let secs = (nanos / 1_000_000_000) as u32; + let nano_part = (nanos % 1_000_000_000) as u32; + NaiveTime::from_num_seconds_from_midnight_opt(secs, nano_part) + .map(|t| t.format("%H:%M:%S").to_string()) + .unwrap_or_else(|| format!("{}ns", nanos)) +} + +/// Get type name for error messages +fn target_type_name(t: ArrayElementType) -> &'static str { + match t { + ArrayElementType::String => "string", + ArrayElementType::Number => "number", + ArrayElementType::Boolean => "boolean", + ArrayElementType::Date => "date", + ArrayElementType::DateTime => "datetime", + ArrayElementType::Time => "time", + } +} + +impl ArrayElement { + /// Get the type of this element. + /// + /// Returns None for Null values. + pub fn element_type(&self) -> Option { + match self { + Self::String(_) => Some(ArrayElementType::String), + Self::Number(_) => Some(ArrayElementType::Number), + Self::Boolean(_) => Some(ArrayElementType::Boolean), + Self::Date(_) => Some(ArrayElementType::Date), + Self::DateTime(_) => Some(ArrayElementType::DateTime), + Self::Time(_) => Some(ArrayElementType::Time), + Self::Null => None, + } + } + + /// Infer the dominant type from a collection of ArrayElements. + /// + /// Used by discrete scales to determine the target type from their input range. + /// Nulls are ignored. Returns None if all values are null or the slice is empty. + /// + /// If multiple types are present, uses priority: Boolean > Number > Date > DateTime > Time > String + /// (Boolean is highest because it's most specific; String is lowest as it's the fallback) + pub fn infer_type(values: &[ArrayElement]) -> Option { + let mut found_bool = false; + let mut found_number = false; + let mut found_date = false; + let mut found_datetime = false; + let mut found_time = false; + let mut found_string = false; + + for elem in values { + match elem { + Self::Boolean(_) => found_bool = true, + Self::Number(_) => found_number = true, + Self::Date(_) => found_date = true, + Self::DateTime(_) => found_datetime = true, + Self::Time(_) => found_time = true, + Self::String(_) => found_string = true, + Self::Null => {} + } + } + + // Priority order: most specific to least specific + if found_bool { + Some(ArrayElementType::Boolean) + } else if found_number { + Some(ArrayElementType::Number) + } else if found_date { + Some(ArrayElementType::Date) + } else if found_datetime { + Some(ArrayElementType::DateTime) + } else if found_time { + Some(ArrayElementType::Time) + } else if found_string { + Some(ArrayElementType::String) + } else { + None + } + } + + /// Coerce this element to the target type. + /// + /// Returns Ok with the coerced value, or Err with a description if coercion is impossible. + /// + /// Coercion paths: + /// - String → Boolean: "true"/"false"/"yes"/"no"/"1"/"0" (case-insensitive) + /// - String → Number: parse as f64 + /// - String → Date/DateTime/Time: parse ISO format + /// - Number → Boolean: 0 = false, non-zero = true + /// - Number → String: format as string + /// - Number → Date: interpret as days since Unix epoch + /// - Number → DateTime: interpret as microseconds since Unix epoch + /// - Number → Time: interpret as nanoseconds since midnight + /// - Boolean → Number: false = 0, true = 1 + /// - Boolean → String: "true"/"false" + /// - Null → any: stays Null + pub fn coerce_to(&self, target: ArrayElementType) -> Result { + // Already the right type? + if self.element_type() == Some(target) { + return Ok(self.clone()); + } + + // Null stays Null + if matches!(self, Self::Null) { + return Ok(Self::Null); + } + + match (self, target) { + // String → Boolean + (Self::String(s), ArrayElementType::Boolean) => match s.to_lowercase().as_str() { + "true" | "yes" | "1" => Ok(Self::Boolean(true)), + "false" | "no" | "0" => Ok(Self::Boolean(false)), + _ => Err(format!("Cannot coerce string '{}' to boolean", s)), + }, + + // String → Number + (Self::String(s), ArrayElementType::Number) => s + .parse::() + .map(Self::Number) + .map_err(|_| format!("Cannot coerce string '{}' to number", s)), + + // String → Date + (Self::String(s), ArrayElementType::Date) => { + Self::from_date_string(s).ok_or_else(|| { + format!("Cannot coerce string '{}' to date (expected YYYY-MM-DD)", s) + }) + } + + // String → DateTime + (Self::String(s), ArrayElementType::DateTime) => Self::from_datetime_string(s) + .ok_or_else(|| format!("Cannot coerce string '{}' to datetime", s)), + + // String → Time + (Self::String(s), ArrayElementType::Time) => Self::from_time_string(s) + .ok_or_else(|| format!("Cannot coerce string '{}' to time (expected HH:MM:SS)", s)), + + // String → String (identity, already handled above but for completeness) + (Self::String(s), ArrayElementType::String) => Ok(Self::String(s.clone())), + + // Number → Boolean + (Self::Number(n), ArrayElementType::Boolean) => Ok(Self::Boolean(*n != 0.0)), + + // Number → String + (Self::Number(n), ArrayElementType::String) => Ok(Self::String(format_number(*n))), + + // Number → Date (days since epoch) + (Self::Number(n), ArrayElementType::Date) => Ok(Self::Date(*n as i32)), + + // Number → DateTime (microseconds since epoch) + (Self::Number(n), ArrayElementType::DateTime) => Ok(Self::DateTime(*n as i64)), + + // Number → Time (nanoseconds since midnight) + (Self::Number(n), ArrayElementType::Time) => Ok(Self::Time(*n as i64)), + + // Boolean → Number + (Self::Boolean(b), ArrayElementType::Number) => { + Ok(Self::Number(if *b { 1.0 } else { 0.0 })) + } + + // Boolean → String + (Self::Boolean(b), ArrayElementType::String) => Ok(Self::String(b.to_string())), + + // Boolean → temporal types: not supported + (Self::Boolean(_), ArrayElementType::Date) + | (Self::Boolean(_), ArrayElementType::DateTime) + | (Self::Boolean(_), ArrayElementType::Time) => Err(format!( + "Cannot coerce boolean to {}", + target_type_name(target) + )), + + // Date → String + (Self::Date(d), ArrayElementType::String) => Ok(Self::String(date_to_iso_string(*d))), + + // Date → Number (days since epoch) + (Self::Date(d), ArrayElementType::Number) => Ok(Self::Number(*d as f64)), + + // DateTime → String + (Self::DateTime(dt), ArrayElementType::String) => { + Ok(Self::String(datetime_to_iso_string(*dt))) + } + + // DateTime → Number (microseconds since epoch) + (Self::DateTime(dt), ArrayElementType::Number) => Ok(Self::Number(*dt as f64)), + + // Time → String + (Self::Time(t), ArrayElementType::String) => Ok(Self::String(time_to_iso_string(*t))), + + // Time → Number (nanoseconds since midnight) + (Self::Time(t), ArrayElementType::Number) => Ok(Self::Number(*t as f64)), + + // Temporal → Boolean: not supported + (Self::Date(_), ArrayElementType::Boolean) + | (Self::DateTime(_), ArrayElementType::Boolean) + | (Self::Time(_), ArrayElementType::Boolean) => { + Err(format!("Cannot coerce {} to boolean", self.type_name())) + } + + // Cross-temporal conversions: not supported (lossy) + (Self::Date(_), ArrayElementType::DateTime) + | (Self::Date(_), ArrayElementType::Time) + | (Self::DateTime(_), ArrayElementType::Date) + | (Self::DateTime(_), ArrayElementType::Time) + | (Self::Time(_), ArrayElementType::Date) + | (Self::Time(_), ArrayElementType::DateTime) => Err(format!( + "Cannot coerce {} to {}", + self.type_name(), + target_type_name(target) + )), + + // Identity cases (already handled by early return, but needed for exhaustiveness) + (Self::Number(n), ArrayElementType::Number) => Ok(Self::Number(*n)), + (Self::Boolean(b), ArrayElementType::Boolean) => Ok(Self::Boolean(*b)), + (Self::Date(d), ArrayElementType::Date) => Ok(Self::Date(*d)), + (Self::DateTime(dt), ArrayElementType::DateTime) => Ok(Self::DateTime(*dt)), + (Self::Time(t), ArrayElementType::Time) => Ok(Self::Time(*t)), + + // Null cases are handled at the top + (Self::Null, _) => Ok(Self::Null), + } + } + + /// Homogenize a slice of array elements to a common type. + /// + /// Infers the target type from all elements, then attempts to coerce all elements + /// to that type. If coercion fails (e.g., string + number), falls back to String type. + /// + /// Returns a new vector with homogenized elements. + pub fn homogenize(values: &[Self]) -> Vec { + // Infer target type from all elements + let Some(target_type) = Self::infer_type(values) else { + // All nulls or empty array - return cloned as-is + return values.to_vec(); + }; + + // Try to coerce all elements to the inferred type + let coerced: Result, _> = values + .iter() + .map(|elem| elem.coerce_to(target_type)) + .collect(); + + match coerced { + Ok(coerced_arr) => coerced_arr, + Err(_) => { + // Coercion failed - fall back to String type + values + .iter() + .map(|elem| { + elem.coerce_to(ArrayElementType::String) + .unwrap_or(Self::Null) + }) + .collect() + } + } + } + + /// Convert this element to a SQL literal string. + /// + /// Used for generating SQL expressions from literal values. + pub fn to_sql(&self, dialect: &dyn SqlDialect) -> String { + match self { + Self::String(s) => naming::quote_literal(s), + Self::Number(n) => n.to_string(), + Self::Boolean(b) => dialect.sql_boolean_literal(*b), + Self::Date(d) => dialect.sql_date_literal(*d), + Self::DateTime(dt) => dialect.sql_datetime_literal(*dt), + Self::Time(t) => dialect.sql_time_literal(*t), + Self::Null => "NULL".to_string(), + } + } + + /// Get the type name for error messages. + fn type_name(&self) -> &'static str { + match self { + Self::String(_) => "string", + Self::Number(_) => "number", + Self::Boolean(_) => "boolean", + Self::Date(_) => "date", + Self::DateTime(_) => "datetime", + Self::Time(_) => "time", + Self::Null => "null", + } + } + + /// Convert to f64 for numeric calculations + pub fn to_f64(&self) -> Option { + match self { + Self::Number(n) => Some(*n), + Self::Date(d) => Some(*d as f64), + Self::DateTime(dt) => Some(*dt as f64), + Self::Time(t) => Some(*t as f64), + _ => None, + } + } + + /// Parse ISO date string "YYYY-MM-DD" to Date variant + pub fn from_date_string(s: &str) -> Option { + NaiveDate::parse_from_str(s, "%Y-%m-%d") + .ok() + .map(|d| Self::Date(d.num_days_from_ce() - UNIX_EPOCH_CE_DAYS)) + } + + /// Parse ISO datetime string to DateTime variant + /// + /// Supports timezone-aware formats: + /// - RFC3339: `2024-01-15T10:30:00Z`, `2024-01-15T10:30:00+05:30` + /// - With offset: `2024-01-15T10:30:00+0530` + /// + /// And timezone-naive formats (interpreted as UTC): + /// - `2024-01-15T10:30:00`, `2024-01-15T10:30:00.123` + /// - `2024-01-15 10:30:00` + pub fn from_datetime_string(s: &str) -> Option { + // Try RFC3339/ISO8601 with timezone first (e.g., "2024-01-15T10:30:00Z", "2024-01-15T10:30:00+05:30") + if let Ok(dt) = DateTime::parse_from_rfc3339(s) { + return Some(Self::DateTime(dt.timestamp_micros())); + } + + // Try formats with explicit timezone offset (non-RFC3339 variants) + for fmt in &[ + "%Y-%m-%dT%H:%M:%S%.f%:z", // 2024-01-15T10:30:00.123+05:30 + "%Y-%m-%dT%H:%M:%S%:z", // 2024-01-15T10:30:00+05:30 + "%Y-%m-%dT%H:%M:%S%.f%z", // 2024-01-15T10:30:00.123+0530 + "%Y-%m-%dT%H:%M:%S%z", // 2024-01-15T10:30:00+0530 + "%Y-%m-%d %H:%M:%S%:z", // 2024-01-15 10:30:00+05:30 + "%Y-%m-%d %H:%M:%S%z", // 2024-01-15 10:30:00+0530 + ] { + if let Ok(dt) = DateTime::parse_from_str(s, fmt) { + return Some(Self::DateTime(dt.timestamp_micros())); + } + } + + // Fall back to naive (timezone-unaware), assumed UTC + for fmt in &[ + "%Y-%m-%dT%H:%M:%S%.f", + "%Y-%m-%dT%H:%M:%S", + "%Y-%m-%d %H:%M:%S", + ] { + if let Ok(dt) = NaiveDateTime::parse_from_str(s, fmt) { + return Some(Self::DateTime(dt.and_utc().timestamp_micros())); + } + } + None + } + + /// Parse ISO time string "HH:MM:SS[.sss]" to Time variant + pub fn from_time_string(s: &str) -> Option { + for fmt in &["%H:%M:%S%.f", "%H:%M:%S", "%H:%M"] { + if let Ok(t) = NaiveTime::parse_from_str(s, fmt) { + // Convert to nanoseconds since midnight + let nanos = + t.num_seconds_from_midnight() as i64 * 1_000_000_000 + t.nanosecond() as i64; + return Some(Self::Time(nanos)); + } + } + None + } + + /// Try to parse string as temporal type (Date/DateTime/Time). + /// + /// If this is a String variant, attempts to parse it as a temporal type + /// in order of specificity: DateTime > Date > Time. + /// If parsing succeeds, returns the temporal variant. Otherwise returns self unchanged. + /// + /// For non-String variants, returns self unchanged. + /// + /// # Example + /// ```ignore + /// let elem = ArrayElement::String("1973-06-01".to_string()); + /// let parsed = elem.try_as_temporal(); + /// // parsed is now ArrayElement::Date(...) + /// ``` + pub fn try_as_temporal(self) -> Self { + if let Self::String(ref s) = self { + // Try DateTime first (most specific) + if let Some(dt) = Self::from_datetime_string(s) { + return dt; + } + // Try Date + if let Some(d) = Self::from_date_string(s) { + return d; + } + // Try Time + if let Some(t) = Self::from_time_string(s) { + return t; + } + } + // Fall back to original value if not a string or parsing failed + self + } + + /// Convert to string for HashMap keys and display + pub fn to_key_string(&self) -> String { + match self { + Self::String(s) => s.clone(), + Self::Number(n) => format_number(*n), + Self::Boolean(b) => b.to_string(), + Self::Null => "null".to_string(), + Self::Date(d) => date_to_iso_string(*d), + Self::DateTime(dt) => datetime_to_iso_string(*dt), + Self::Time(t) => time_to_iso_string(*t), + } + } + + /// Convert to a serde_json::Value + pub fn to_json(&self) -> serde_json::Value { + match self { + ArrayElement::String(s) => serde_json::Value::String(s.clone()), + ArrayElement::Number(n) => serde_json::json!(n), + ArrayElement::Boolean(b) => serde_json::Value::Bool(*b), + ArrayElement::Null => serde_json::Value::Null, + // Temporal types serialize as ISO strings for JSON + ArrayElement::Date(d) => serde_json::Value::String(date_to_iso_string(*d)), + ArrayElement::DateTime(dt) => serde_json::Value::String(datetime_to_iso_string(*dt)), + ArrayElement::Time(t) => serde_json::Value::String(time_to_iso_string(*t)), + } + } + + /// Convert Date (days since epoch) to ISO string "YYYY-MM-DD" + pub fn date_to_iso(days: i32) -> String { + date_to_iso_string(days) + } + + /// Convert DateTime (microseconds since epoch) to ISO string + pub fn datetime_to_iso(micros: i64) -> String { + datetime_to_iso_string(micros) + } + + /// Convert Time (nanoseconds since midnight) to ISO string "HH:MM:SS" + pub fn time_to_iso(nanos: i64) -> String { + time_to_iso_string(nanos) + } +} + +impl ParameterValue { + /// Convert to a serde_json::Value + pub fn to_json(&self) -> serde_json::Value { + match self { + ParameterValue::String(s) => serde_json::Value::String(s.clone()), + ParameterValue::Number(n) => serde_json::json!(n), + ParameterValue::Boolean(b) => serde_json::Value::Bool(*b), + ParameterValue::Array(arr) => { + serde_json::Value::Array(arr.iter().map(|e| e.to_json()).collect()) + } + ParameterValue::Null => serde_json::Value::Null, + } + } + + /// Check if this is a null value + pub fn is_null(&self) -> bool { + matches!(self, ParameterValue::Null) + } + + /// Try to extract as a string value + pub fn as_str(&self) -> Option<&str> { + match self { + ParameterValue::String(s) => Some(s), + _ => None, + } + } + + /// Try to extract as a number value + pub fn as_number(&self) -> Option { + match self { + ParameterValue::Number(n) => Some(*n), + _ => None, + } + } + + /// Try to extract as a boolean value + pub fn as_bool(&self) -> Option { + match self { + ParameterValue::Boolean(b) => Some(*b), + _ => None, + } + } + + /// Try to extract as an array value + pub fn as_array(&self) -> Option<&[ArrayElement]> { + match self { + ParameterValue::Array(arr) => Some(arr), + _ => None, + } + } + + /// Convert this parameter value to a SQL literal string. + /// + /// Only supports scalar values (String, Number, Boolean, Null). + /// Arrays are handled separately in annotation layer VALUES clause generation. + pub fn to_sql(&self, dialect: &dyn SqlDialect) -> String { + match self { + ParameterValue::String(s) => naming::quote_literal(s), + ParameterValue::Number(n) => n.to_string(), + ParameterValue::Boolean(b) => dialect.sql_boolean_literal(*b), + ParameterValue::Array(_) => { + panic!("ParameterValue::to_sql() does not support arrays. Arrays in annotation layers should be handled via VALUES clause generation.") + } + ParameterValue::Null => "NULL".to_string(), + } + } + + /// Convert a scalar ParameterValue to an ArrayElement. + /// + /// Panics if called on an Array variant. + fn to_array_element(&self) -> ArrayElement { + match self { + ParameterValue::Number(num) => ArrayElement::Number(*num), + ParameterValue::String(s) => ArrayElement::String(s.clone()), + ParameterValue::Boolean(b) => ArrayElement::Boolean(*b), + ParameterValue::Null => ArrayElement::Null, + ParameterValue::Array(_) => panic!("Cannot convert Array to single ArrayElement"), + } + } + + /// Recycle this value to a target array length. + /// + /// - Scalars (String, Number, Boolean, Null) are converted to arrays with n copies + /// - Arrays of length 1 are recycled to n copies of that element + /// - Arrays of target length are returned as-is (after homogenization) + /// - Arrays of other lengths produce an error + pub fn rep(self, n: usize) -> Result { + match self { + // Arrays: homogenize types if mixed, then recycle if needed + ParameterValue::Array(arr) => { + if arr.len() == 1 { + // Recycle the single element + let element = arr[0].clone(); + Ok(ParameterValue::Array(vec![element; n])) + } else if arr.len() == n { + // Already correct length - homogenize for type consistency + let arr = ArrayElement::homogenize(&arr); + Ok(ParameterValue::Array(arr)) + } else { + // Mismatched length - shouldn't happen if validation passed + Err(crate::GgsqlError::InternalError(format!( + "Attempted to recycle array of length {} to length {} (should have been caught earlier)", + arr.len(), + n + ))) + } + } + // Scalars: convert to ArrayElement and replicate n times + scalar => { + let elem = scalar.to_array_element(); + Ok(ParameterValue::Array(vec![elem; n])) + } + } + } +} + +// ============================================================================= +// Default Property Types (Shared by Coord, Scale, and Geom traits) +// ============================================================================= + +/// Default value for a property parameter +/// +/// Used by traits to declare both allowed property names and their default values +/// in a single declaration, avoiding the need to keep two separate implementations +/// in sync. +#[derive(Debug, Clone)] +pub enum DefaultParamValue { + String(&'static str), + Number(f64), + Boolean(bool), + Null, +} + +// ============================================================================= +// Parameter Constraint Types +// ============================================================================= + +/// Constraint state for a specific type +#[derive(Debug, Clone, Copy)] +pub enum TypeConstraint { + /// Type not allowed for this parameter + Forbidden, + /// Type allowed, any value accepted + Any, + /// Type allowed with specific constraints + Constrained(T), +} + +/// Constraint for Number values +#[derive(Debug, Clone, Copy)] +pub struct NumberConstraint { + pub min: Option, + pub max: Option, + pub min_exclusive: bool, + pub max_exclusive: bool, + pub whole: bool, +} + +impl NumberConstraint { + /// No constraints on value + pub const fn unconstrained() -> Self { + Self { + min: None, + max: None, + min_exclusive: false, + max_exclusive: false, + whole: false, + } + } + + /// Minimum value (inclusive) + pub const fn min(min: f64) -> Self { + Self { + min: Some(min), + max: None, + min_exclusive: false, + max_exclusive: false, + whole: false, + } + } + + /// Minimum value (exclusive) + pub const fn min_exclusive(min: f64) -> Self { + Self { + min: Some(min), + max: None, + min_exclusive: true, + max_exclusive: false, + whole: false, + } + } + + /// Value within range (inclusive on both ends) + pub const fn range(min: f64, max: f64) -> Self { + Self { + min: Some(min), + max: Some(max), + min_exclusive: false, + max_exclusive: false, + whole: false, + } + } + + /// Whole number (integer) with minimum value (inclusive) + pub const fn count(min: f64) -> Self { + Self { + min: Some(min), + max: None, + min_exclusive: false, + max_exclusive: false, + whole: true, + } + } + + /// Whole number (integer) within range (inclusive on both ends) + pub const fn count_range(min: f64, max: f64) -> Self { + Self { + min: Some(min), + max: Some(max), + min_exclusive: false, + max_exclusive: false, + whole: true, + } + } +} + +/// Constraint for String values +#[derive(Debug, Clone, Copy)] +pub struct StringConstraint { + /// String must be one of these values (empty = any string allowed) + pub allowed_values: &'static [&'static str], + /// String must be a number immediately followed by one of these units + /// (empty = no restriction) — for a CSS-style measurement like + /// `width => '20%'`/`'240px'`, where the numeric part is unbounded but + /// the unit isn't. Checked by stripping a candidate unit and parsing + /// what's left as `f64`, not just a suffix match, so `'pxpx'` or a bare + /// `'%'` are rejected too. + pub numeric_units: &'static [&'static str], +} + +impl StringConstraint { + /// Any string allowed (empty slices = no restriction) + pub const fn unconstrained() -> Self { + Self { + allowed_values: &[], + numeric_units: &[], + } + } + + /// String must be one of the specified values + pub const fn one_of(values: &'static [&'static str]) -> Self { + Self { + allowed_values: values, + numeric_units: &[], + } + } + + /// String must be a number immediately followed by one of the given units + pub const fn numeric_with_unit(units: &'static [&'static str]) -> Self { + Self { + allowed_values: &[], + numeric_units: units, + } + } +} + +/// Element constraint - specifies type AND value constraints for array elements +#[derive(Debug, Clone, Copy)] +pub enum ArrayElementConstraint { + /// Any element type allowed, no constraints + Any, + /// Must be numbers, with optional value constraint + Number(NumberConstraint), + /// Must be strings, with optional value constraint + String(StringConstraint), + /// Must be booleans + Boolean, +} + +/// Constraint for Array values +#[derive(Debug, Clone, Copy)] +pub struct ArrayConstraint { + pub element: ArrayElementConstraint, + pub min_len: Option, + pub max_len: Option, + pub allow_null_elements: bool, +} + +impl ArrayConstraint { + /// Array of numbers with value constraint + pub const fn of_numbers(constraint: NumberConstraint) -> Self { + Self { + element: ArrayElementConstraint::Number(constraint), + min_len: None, + max_len: None, + allow_null_elements: false, + } + } + + /// Array of numbers with exact length and value constraint + pub const fn of_numbers_len(constraint: NumberConstraint, len: usize) -> Self { + Self { + element: ArrayElementConstraint::Number(constraint), + min_len: Some(len), + max_len: Some(len), + allow_null_elements: false, + } + } + + /// Array of strings with value constraint + pub const fn of_strings(constraint: StringConstraint) -> Self { + Self { + element: ArrayElementConstraint::String(constraint), + min_len: None, + max_len: None, + allow_null_elements: false, + } + } + + /// Array of strings with exact length and value constraint + #[allow(dead_code)] + pub const fn of_strings_len(constraint: StringConstraint, len: usize) -> Self { + Self { + element: ArrayElementConstraint::String(constraint), + min_len: Some(len), + max_len: Some(len), + allow_null_elements: false, + } + } + + /// Any element types allowed (including nulls) + #[allow(dead_code)] + pub const fn any_elements() -> Self { + Self { + element: ArrayElementConstraint::Any, + min_len: None, + max_len: None, + allow_null_elements: true, + } + } + + /// Builder method to allow null elements + #[allow(dead_code)] + pub const fn with_null_elements(mut self) -> Self { + self.allow_null_elements = true; + self + } +} + +/// Validation constraint for a parameter value. +/// +/// Each field specifies whether a type is forbidden, allowed (any value), or constrained. +/// Used by `ParamDefinition` to specify validation rules for parameters. +#[derive(Debug, Clone, Copy)] +pub struct ParamConstraint { + pub number: TypeConstraint, + pub string: TypeConstraint, + pub boolean: TypeConstraint<()>, + pub array: TypeConstraint, + pub allow_null: bool, +} + +impl ParamConstraint { + /// All types allowed, no constraints (default) + pub const fn unconstrained() -> Self { + Self { + number: TypeConstraint::Any, + string: TypeConstraint::Any, + boolean: TypeConstraint::Any, + array: TypeConstraint::Any, + allow_null: true, + } + } + + /// Number only with constraint + pub const fn number(constraint: NumberConstraint) -> Self { + Self { + number: TypeConstraint::Constrained(constraint), + string: TypeConstraint::Forbidden, + boolean: TypeConstraint::Forbidden, + array: TypeConstraint::Forbidden, + allow_null: true, + } + } + + /// Number only, any value + #[allow(dead_code)] + pub const fn number_any() -> Self { + Self { + number: TypeConstraint::Any, + string: TypeConstraint::Forbidden, + boolean: TypeConstraint::Forbidden, + array: TypeConstraint::Forbidden, + allow_null: true, + } + } + + /// String only with enum constraint + pub const fn string_option(values: &'static [&'static str]) -> Self { + Self { + number: TypeConstraint::Forbidden, + string: TypeConstraint::Constrained(StringConstraint::one_of(values)), + boolean: TypeConstraint::Forbidden, + array: TypeConstraint::Forbidden, + allow_null: true, + } + } + + /// String only, constrained to a number immediately followed by one of + /// the given units — for a CSS-style measurement like + /// `width => '20%'`/`'240px'`. + pub const fn string_numeric_with_unit(units: &'static [&'static str]) -> Self { + Self { + number: TypeConstraint::Forbidden, + string: TypeConstraint::Constrained(StringConstraint::numeric_with_unit(units)), + boolean: TypeConstraint::Forbidden, + array: TypeConstraint::Forbidden, + allow_null: true, + } + } + + /// String only + pub const fn string() -> Self { + Self { + number: TypeConstraint::Forbidden, + string: TypeConstraint::Any, + boolean: TypeConstraint::Forbidden, + array: TypeConstraint::Forbidden, + allow_null: true, + } + } + + /// Boolean only + pub const fn boolean() -> Self { + Self { + number: TypeConstraint::Forbidden, + string: TypeConstraint::Forbidden, + boolean: TypeConstraint::Any, + array: TypeConstraint::Forbidden, + allow_null: true, + } + } + + /// Number or Array of numbers - for `expand` parameter + pub const fn number_or_numeric_array(num: NumberConstraint, arr: ArrayConstraint) -> Self { + Self { + number: TypeConstraint::Constrained(num), + string: TypeConstraint::Forbidden, + boolean: TypeConstraint::Forbidden, + array: TypeConstraint::Constrained(arr), + allow_null: true, + } + } + + /// Number, Array of numbers, or String (any) - for `breaks` parameter + pub const fn number_or_array_or_string(num: NumberConstraint, arr: ArrayConstraint) -> Self { + Self { + number: TypeConstraint::Constrained(num), + string: TypeConstraint::Any, // Any string (temporal interval validated elsewhere) + boolean: TypeConstraint::Forbidden, + array: TypeConstraint::Constrained(arr), + allow_null: true, + } + } + + /// String enum or Number within a range - for parameters like `hjust` + /// that accept either a named keyword or a continuous value. + pub const fn string_option_or_number( + values: &'static [&'static str], + num: NumberConstraint, + ) -> Self { + Self { + number: TypeConstraint::Constrained(num), + string: TypeConstraint::Constrained(StringConstraint::one_of(values)), + boolean: TypeConstraint::Forbidden, + array: TypeConstraint::Forbidden, + allow_null: true, + } + } + + /// String enum or Array of strings from same enum - for `free` parameter + #[allow(dead_code)] + pub const fn string_or_string_array(values: &'static [&'static str]) -> Self { + Self { + number: TypeConstraint::Forbidden, + string: TypeConstraint::Constrained(StringConstraint::one_of(values)), + boolean: TypeConstraint::Forbidden, + array: TypeConstraint::Constrained(ArrayConstraint::of_strings( + StringConstraint::one_of(values), + )), + allow_null: true, + } + } + + /// Any string or array of any strings (with null elements allowed) — for + /// the `aggregate` parameter. Per-entry vocabulary is checked downstream + /// by `stat_aggregate::parse_aggregate_param`; this constraint just pins + /// the structural shape (string / array-of-strings / null). + pub const fn string_or_string_array_unconstrained() -> Self { + Self { + number: TypeConstraint::Forbidden, + string: TypeConstraint::Any, + boolean: TypeConstraint::Forbidden, + array: TypeConstraint::Constrained(ArrayConstraint { + element: ArrayElementConstraint::String(StringConstraint::unconstrained()), + min_len: None, + max_len: None, + allow_null_elements: true, + }), + allow_null: true, + } + } + + /// Builder method to disallow null values + #[allow(dead_code)] + pub const fn required(mut self) -> Self { + self.allow_null = false; + self + } + + // ========================================================================= + // Convenience constructors + // ========================================================================= + + /// Create a constraint requiring a minimum value (inclusive) + pub const fn number_min(min: f64) -> Self { + Self::number(NumberConstraint::min(min)) + } + + /// Create a constraint requiring a minimum value (exclusive) + pub const fn number_min_exclusive(min: f64) -> Self { + Self::number(NumberConstraint::min_exclusive(min)) + } + + /// Create a constraint requiring a value within a range (inclusive on both ends) + pub const fn number_range(min: f64, max: f64) -> Self { + Self::number(NumberConstraint::range(min, max)) + } + + /// Create a constraint requiring a whole number (integer) with minimum value + pub const fn count(min: f64) -> Self { + Self::number(NumberConstraint::count(min)) + } + + /// Create a constraint requiring a whole number (integer) within a range + pub const fn count_range(min: f64, max: f64) -> Self { + Self::number(NumberConstraint::count_range(min, max)) + } +} + +/// Validate a parameter value against a constraint. +/// +/// Returns Ok(()) if the value passes validation, or Err with a descriptive +/// error message if validation fails. +pub fn validate_parameter( + param_name: &str, + value: &ParameterValue, + constraint: &ParamConstraint, +) -> Result<(), String> { + match value { + ParameterValue::Number(n) => match &constraint.number { + TypeConstraint::Forbidden => { + Err(type_not_allowed_error(param_name, "Number", constraint)) + } + TypeConstraint::Any => Ok(()), + TypeConstraint::Constrained(c) => validate_number(param_name, *n, c), + }, + ParameterValue::String(s) => match &constraint.string { + TypeConstraint::Forbidden => { + Err(type_not_allowed_error(param_name, "String", constraint)) + } + TypeConstraint::Any => Ok(()), + TypeConstraint::Constrained(c) => validate_string(param_name, s, c), + }, + ParameterValue::Boolean(_) => match &constraint.boolean { + TypeConstraint::Forbidden => { + Err(type_not_allowed_error(param_name, "Boolean", constraint)) + } + TypeConstraint::Any | TypeConstraint::Constrained(()) => Ok(()), + }, + ParameterValue::Array(arr) => match &constraint.array { + TypeConstraint::Forbidden => { + Err(type_not_allowed_error(param_name, "Array", constraint)) + } + TypeConstraint::Any => Ok(()), + TypeConstraint::Constrained(c) => validate_array(param_name, arr, c), + }, + ParameterValue::Null => { + if constraint.allow_null { + Ok(()) + } else { + Err(format!( + "Parameter '{}' is required (cannot be null)", + param_name + )) + } + } + } +} + +fn validate_number(name: &str, n: f64, c: &NumberConstraint) -> Result<(), String> { + // Check whole number constraint first + if c.whole && n.fract() != 0.0 { + return Err(format!("'{}' should be a whole number, not {}", name, n)); + } + if let Some(min) = c.min { + let ok = if c.min_exclusive { n > min } else { n >= min }; + if !ok { + let op = if c.min_exclusive { ">" } else { ">=" }; + return Err(format!("'{}' should be {} {}, not {}", name, op, min, n)); + } + } + if let Some(max) = c.max { + let ok = if c.max_exclusive { n < max } else { n <= max }; + if !ok { + let op = if c.max_exclusive { "<" } else { "<=" }; + return Err(format!("'{}' should be {} {}, not {}", name, op, max, n)); + } + } + Ok(()) +} + +fn validate_string(name: &str, s: &str, c: &StringConstraint) -> Result<(), String> { + if !c.allowed_values.is_empty() && !c.allowed_values.contains(&s) { + return Err(format!( + "'{}' should be {}, not '{}'", + name, + crate::or_list_quoted(c.allowed_values, '\''), + s + )); + } + if !c.numeric_units.is_empty() { + let has_valid_unit = c.numeric_units.iter().any(|unit| { + s.strip_suffix(unit) + .is_some_and(|prefix| prefix.parse::().is_ok()) + }); + if !has_valid_unit { + return Err(format!( + "'{}' should be a number followed by {}, not '{}'", + name, + crate::or_list_quoted(c.numeric_units, '\''), + s + )); + } + } + Ok(()) +} + +fn validate_array(name: &str, arr: &[ArrayElement], c: &ArrayConstraint) -> Result<(), String> { + // Length validation + let len = arr.len(); + match (c.min_len, c.max_len) { + // Exact length required + (Some(min), Some(max)) if min == max && len != min => { + return Err(format!( + "Parameter '{}' array must have exactly {} element(s) (got {})", + name, min, len + )); + } + // Range constraint (both bounds, not equal) + (Some(min), Some(max)) if len < min || len > max => { + return Err(format!( + "Parameter '{}' array must have between {} and {} element(s) (got {})", + name, min, max, len + )); + } + // One-sided constraints + (Some(min), None) if len < min => { + return Err(format!( + "Parameter '{}' array must have at least {} element(s) (got {})", + name, min, len + )); + } + (None, Some(max)) if len > max => { + return Err(format!( + "Parameter '{}' array must have at most {} element(s) (got {})", + name, max, len + )); + } + _ => {} + } + // Element type and value validation - collect all errors + let mut errors: Vec = Vec::new(); + for (i, elem) in arr.iter().enumerate() { + match (&c.element, elem) { + // Handle null elements + (_, ArrayElement::Null) if c.allow_null_elements => {} + (_, ArrayElement::Null) => { + errors.push(format!("'{}[{}]' cannot be null", name, i)); + } + // Any element type allowed + (ArrayElementConstraint::Any, _) => {} + // Number elements + (ArrayElementConstraint::Number(nc), ArrayElement::Number(n)) => { + if let Err(e) = validate_number(&format!("{}[{}]", name, i), *n, nc) { + errors.push(e); + } + } + (ArrayElementConstraint::Number(_), _) => { + errors.push(format!("'{}[{}]' must be a number", name, i)); + } + // String elements + (ArrayElementConstraint::String(sc), ArrayElement::String(s)) => { + // Only validate if allowed_values is non-empty (empty = any string) + if !sc.allowed_values.is_empty() { + if let Err(e) = validate_string(&format!("{}[{}]", name, i), s, sc) { + errors.push(e); + } + } + } + (ArrayElementConstraint::String(_), _) => { + errors.push(format!("'{}[{}]' must be a string", name, i)); + } + // Boolean elements + (ArrayElementConstraint::Boolean, ArrayElement::Boolean(_)) => {} + (ArrayElementConstraint::Boolean, _) => { + errors.push(format!("'{}[{}]' must be a boolean", name, i)); + } + } + } + if errors.is_empty() { + Ok(()) + } else { + Err(format!( + "Parameter '{}' has invalid elements:\n— {}", + name, + errors.join("\n— ") + )) + } +} + +fn type_not_allowed_error(name: &str, got: &str, c: &ParamConstraint) -> String { + let mut allowed = Vec::new(); + if !matches!(c.number, TypeConstraint::Forbidden) { + allowed.push("Number"); + } + if !matches!(c.string, TypeConstraint::Forbidden) { + allowed.push("String"); + } + if !matches!(c.boolean, TypeConstraint::Forbidden) { + allowed.push("Boolean"); + } + if !matches!(c.array, TypeConstraint::Forbidden) { + allowed.push("Array"); + } + format!( + "'{}' should be {}, not {}", + name, + crate::or_list(&allowed), + got + ) +} + +/// Property definition: name, default value, and validation constraint. +/// +/// Used by `CoordTrait`, `ScaleTypeTrait`, and `GeomTrait` to declare their +/// allowed properties and default values in a single place. +#[derive(Debug, Clone)] +pub struct ParamDefinition { + pub name: &'static str, + pub default: DefaultParamValue, + pub constraint: ParamConstraint, +} + +impl ParamDefinition { + /// Convert the default value to a ParameterValue, if not Null + pub fn to_parameter_value(&self) -> Option { + match &self.default { + DefaultParamValue::String(s) => Some(ParameterValue::String(s.to_string())), + DefaultParamValue::Number(n) => Some(ParameterValue::Number(*n)), + DefaultParamValue::Boolean(b) => Some(ParameterValue::Boolean(*b)), + DefaultParamValue::Null => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_date_from_string() { + let elem = ArrayElement::from_date_string("2024-01-15").unwrap(); + assert!(matches!(elem, ArrayElement::Date(_))); + assert_eq!(elem.to_key_string(), "2024-01-15"); + } + + #[test] + fn test_date_from_string_roundtrip() { + // Test that parsing and converting back produces the same date + let original = "2024-06-30"; + let elem = ArrayElement::from_date_string(original).unwrap(); + assert_eq!(elem.to_key_string(), original); + } + + #[test] + fn test_datetime_from_string() { + let elem = ArrayElement::from_datetime_string("2024-01-15T10:30:00").unwrap(); + assert!(matches!(elem, ArrayElement::DateTime(_))); + assert!(elem.to_key_string().starts_with("2024-01-15T10:30:00")); + } + + #[test] + fn test_datetime_from_string_with_space() { + let elem = ArrayElement::from_datetime_string("2024-01-15 10:30:00").unwrap(); + assert!(matches!(elem, ArrayElement::DateTime(_))); + } + + #[test] + fn test_datetime_from_string_with_z() { + // UTC timezone indicator + let elem = ArrayElement::from_datetime_string("2024-01-15T10:30:00Z").unwrap(); + assert!(matches!(elem, ArrayElement::DateTime(_))); + assert_eq!(elem.to_key_string(), "2024-01-15T10:30:00"); + } + + #[test] + fn test_datetime_from_string_with_positive_offset() { + // +05:30 offset (e.g., India Standard Time) + // 10:30 IST = 05:00 UTC + let elem = ArrayElement::from_datetime_string("2024-01-15T10:30:00+05:30").unwrap(); + assert!(matches!(elem, ArrayElement::DateTime(_))); + assert_eq!(elem.to_key_string(), "2024-01-15T05:00:00"); + } + + #[test] + fn test_datetime_from_string_with_negative_offset() { + // -08:00 offset (e.g., Pacific Standard Time) + // 10:30 PST = 18:30 UTC + let elem = ArrayElement::from_datetime_string("2024-01-15T10:30:00-08:00").unwrap(); + assert!(matches!(elem, ArrayElement::DateTime(_))); + assert_eq!(elem.to_key_string(), "2024-01-15T18:30:00"); + } + + #[test] + fn test_datetime_from_string_with_zero_offset() { + // Explicit +00:00 (same as Z) + let elem = ArrayElement::from_datetime_string("2024-01-15T10:30:00+00:00").unwrap(); + assert!(matches!(elem, ArrayElement::DateTime(_))); + assert_eq!(elem.to_key_string(), "2024-01-15T10:30:00"); + } + + #[test] + fn test_datetime_from_string_with_fractional_and_tz() { + // Fractional seconds with timezone + let elem = ArrayElement::from_datetime_string("2024-01-15T10:30:00.123Z").unwrap(); + assert!(matches!(elem, ArrayElement::DateTime(_))); + } + + #[test] + fn test_time_from_string() { + let elem = ArrayElement::from_time_string("14:30:00").unwrap(); + assert!(matches!(elem, ArrayElement::Time(_))); + assert_eq!(elem.to_key_string(), "14:30:00"); + } + + #[test] + fn test_time_from_string_with_millis() { + let elem = ArrayElement::from_time_string("14:30:00.123").unwrap(); + assert!(matches!(elem, ArrayElement::Time(_))); + } + + #[test] + fn test_time_from_string_short() { + let elem = ArrayElement::from_time_string("14:30").unwrap(); + assert!(matches!(elem, ArrayElement::Time(_))); + assert_eq!(elem.to_key_string(), "14:30:00"); + } + + #[test] + fn test_date_to_f64() { + // 2024-01-15 is roughly 19738 days since epoch (1970-01-01) + let elem = ArrayElement::from_date_string("2024-01-15").unwrap(); + let days = elem.to_f64().unwrap(); + // Verify the date is in a reasonable range + assert!(days > 19000.0 && days < 20000.0); + } + + #[test] + fn test_time_to_f64() { + let elem = ArrayElement::from_time_string("12:00:00").unwrap(); + let nanos = elem.to_f64().unwrap(); + // 12 hours = 12 * 60 * 60 * 1_000_000_000 nanoseconds + assert_eq!(nanos, 43_200_000_000_000.0); + } + + #[test] + fn test_date_to_json() { + let elem = ArrayElement::from_date_string("2024-01-15").unwrap(); + let json = elem.to_json(); + assert_eq!(json, serde_json::json!("2024-01-15")); + } + + #[test] + fn test_datetime_to_json() { + let elem = ArrayElement::from_datetime_string("2024-01-15T10:30:00").unwrap(); + let json = elem.to_json(); + // Datetime serializes as ISO string + assert!(json.is_string()); + assert!(json.as_str().unwrap().starts_with("2024-01-15T10:30:00")); + } + + #[test] + fn test_time_to_json() { + let elem = ArrayElement::from_time_string("14:30:00").unwrap(); + let json = elem.to_json(); + assert_eq!(json, serde_json::json!("14:30:00")); + } + + #[test] + fn test_number_to_f64() { + let elem = ArrayElement::Number(42.5); + assert_eq!(elem.to_f64(), Some(42.5)); + } + + #[test] + fn test_string_to_f64_returns_none() { + let elem = ArrayElement::String("hello".to_string()); + assert_eq!(elem.to_f64(), None); + } + + #[test] + fn test_to_key_string_number_integer() { + let elem = ArrayElement::Number(25.0); + assert_eq!(elem.to_key_string(), "25"); + } + + #[test] + fn test_to_key_string_number_decimal() { + let elem = ArrayElement::Number(25.5); + assert_eq!(elem.to_key_string(), "25.5"); + } + + #[test] + fn test_invalid_date_returns_none() { + assert!(ArrayElement::from_date_string("not-a-date").is_none()); + assert!(ArrayElement::from_date_string("2024/01/15").is_none()); + } + + #[test] + fn test_invalid_time_returns_none() { + assert!(ArrayElement::from_time_string("not-a-time").is_none()); + assert!(ArrayElement::from_time_string("25:00:00").is_none()); + } + + // ============================================================================= + // ArrayElementType tests + // ============================================================================= + + #[test] + fn test_element_type() { + assert_eq!( + ArrayElement::String("hello".to_string()).element_type(), + Some(ArrayElementType::String) + ); + assert_eq!( + ArrayElement::Number(42.0).element_type(), + Some(ArrayElementType::Number) + ); + assert_eq!( + ArrayElement::Boolean(true).element_type(), + Some(ArrayElementType::Boolean) + ); + assert_eq!( + ArrayElement::Date(100).element_type(), + Some(ArrayElementType::Date) + ); + assert_eq!( + ArrayElement::DateTime(1000000).element_type(), + Some(ArrayElementType::DateTime) + ); + assert_eq!( + ArrayElement::Time(1000000000).element_type(), + Some(ArrayElementType::Time) + ); + assert_eq!(ArrayElement::Null.element_type(), None); + } + + #[test] + fn test_infer_type_boolean() { + let values = vec![ArrayElement::Boolean(true), ArrayElement::Boolean(false)]; + assert_eq!( + ArrayElement::infer_type(&values), + Some(ArrayElementType::Boolean) + ); + } + + #[test] + fn test_infer_type_number() { + let values = vec![ArrayElement::Number(1.0), ArrayElement::Number(2.0)]; + assert_eq!( + ArrayElement::infer_type(&values), + Some(ArrayElementType::Number) + ); + } + + #[test] + fn test_infer_type_string() { + let values = vec![ + ArrayElement::String("a".to_string()), + ArrayElement::String("b".to_string()), + ]; + assert_eq!( + ArrayElement::infer_type(&values), + Some(ArrayElementType::String) + ); + } + + #[test] + fn test_infer_type_date() { + let values = vec![ArrayElement::Date(100), ArrayElement::Date(200)]; + assert_eq!( + ArrayElement::infer_type(&values), + Some(ArrayElementType::Date) + ); + } + + #[test] + fn test_infer_type_with_nulls() { + let values = vec![ + ArrayElement::Null, + ArrayElement::Boolean(true), + ArrayElement::Null, + ]; + assert_eq!( + ArrayElement::infer_type(&values), + Some(ArrayElementType::Boolean) + ); + } + + #[test] + fn test_infer_type_all_nulls() { + let values = vec![ArrayElement::Null, ArrayElement::Null]; + assert_eq!(ArrayElement::infer_type(&values), None); + } + + #[test] + fn test_infer_type_empty() { + let values: Vec = vec![]; + assert_eq!(ArrayElement::infer_type(&values), None); + } + + #[test] + fn test_infer_type_priority_boolean_over_string() { + // If there are mixed types, Boolean has priority over String + let values = vec![ + ArrayElement::Boolean(true), + ArrayElement::String("hello".to_string()), + ]; + assert_eq!( + ArrayElement::infer_type(&values), + Some(ArrayElementType::Boolean) + ); + } + + #[test] + fn test_infer_type_priority_number_over_string() { + let values = vec![ + ArrayElement::Number(42.0), + ArrayElement::String("hello".to_string()), + ]; + assert_eq!( + ArrayElement::infer_type(&values), + Some(ArrayElementType::Number) + ); + } + + // ============================================================================= + // coerce_to tests + // ============================================================================= + + #[test] + fn test_coerce_string_to_boolean_true() { + let elem = ArrayElement::String("true".to_string()); + let result = elem.coerce_to(ArrayElementType::Boolean).unwrap(); + assert_eq!(result, ArrayElement::Boolean(true)); + + // Also test case insensitivity + let elem = ArrayElement::String("TRUE".to_string()); + let result = elem.coerce_to(ArrayElementType::Boolean).unwrap(); + assert_eq!(result, ArrayElement::Boolean(true)); + } + + #[test] + fn test_coerce_string_to_boolean_false() { + let elem = ArrayElement::String("false".to_string()); + let result = elem.coerce_to(ArrayElementType::Boolean).unwrap(); + assert_eq!(result, ArrayElement::Boolean(false)); + + let elem = ArrayElement::String("no".to_string()); + let result = elem.coerce_to(ArrayElementType::Boolean).unwrap(); + assert_eq!(result, ArrayElement::Boolean(false)); + } + + #[test] + fn test_coerce_string_to_boolean_error() { + let elem = ArrayElement::String("maybe".to_string()); + let result = elem.coerce_to(ArrayElementType::Boolean); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .contains("Cannot coerce string 'maybe' to boolean")); + } + + #[test] + fn test_coerce_string_to_number() { + let elem = ArrayElement::String("42.5".to_string()); + let result = elem.coerce_to(ArrayElementType::Number).unwrap(); + assert_eq!(result, ArrayElement::Number(42.5)); + } + + #[test] + fn test_coerce_string_to_number_error() { + let elem = ArrayElement::String("not a number".to_string()); + let result = elem.coerce_to(ArrayElementType::Number); + assert!(result.is_err()); + } + + #[test] + fn test_coerce_string_to_date() { + let elem = ArrayElement::String("2024-01-15".to_string()); + let result = elem.coerce_to(ArrayElementType::Date).unwrap(); + assert!(matches!(result, ArrayElement::Date(_))); + assert_eq!(result.to_key_string(), "2024-01-15"); + } + + #[test] + fn test_coerce_string_to_date_error() { + let elem = ArrayElement::String("not-a-date".to_string()); + let result = elem.coerce_to(ArrayElementType::Date); + assert!(result.is_err()); + } + + #[test] + fn test_coerce_number_to_boolean() { + let elem = ArrayElement::Number(1.0); + let result = elem.coerce_to(ArrayElementType::Boolean).unwrap(); + assert_eq!(result, ArrayElement::Boolean(true)); + + let elem = ArrayElement::Number(0.0); + let result = elem.coerce_to(ArrayElementType::Boolean).unwrap(); + assert_eq!(result, ArrayElement::Boolean(false)); + } + + #[test] + fn test_coerce_number_to_string() { + let elem = ArrayElement::Number(42.5); + let result = elem.coerce_to(ArrayElementType::String).unwrap(); + assert_eq!(result, ArrayElement::String("42.5".to_string())); + + // Integer format + let elem = ArrayElement::Number(42.0); + let result = elem.coerce_to(ArrayElementType::String).unwrap(); + assert_eq!(result, ArrayElement::String("42".to_string())); + } + + #[test] + fn test_coerce_boolean_to_number() { + let elem = ArrayElement::Boolean(true); + let result = elem.coerce_to(ArrayElementType::Number).unwrap(); + assert_eq!(result, ArrayElement::Number(1.0)); + + let elem = ArrayElement::Boolean(false); + let result = elem.coerce_to(ArrayElementType::Number).unwrap(); + assert_eq!(result, ArrayElement::Number(0.0)); + } + + #[test] + fn test_coerce_boolean_to_string() { + let elem = ArrayElement::Boolean(true); + let result = elem.coerce_to(ArrayElementType::String).unwrap(); + assert_eq!(result, ArrayElement::String("true".to_string())); + } + + #[test] + fn test_coerce_null_stays_null() { + let elem = ArrayElement::Null; + let result = elem.coerce_to(ArrayElementType::Boolean).unwrap(); + assert_eq!(result, ArrayElement::Null); + + let result = elem.coerce_to(ArrayElementType::Number).unwrap(); + assert_eq!(result, ArrayElement::Null); + } + + #[test] + fn test_coerce_same_type_identity() { + let elem = ArrayElement::Boolean(true); + let result = elem.coerce_to(ArrayElementType::Boolean).unwrap(); + assert_eq!(result, ArrayElement::Boolean(true)); + + let elem = ArrayElement::Number(42.0); + let result = elem.coerce_to(ArrayElementType::Number).unwrap(); + assert_eq!(result, ArrayElement::Number(42.0)); + } + + #[test] + fn test_coerce_date_to_string() { + let elem = ArrayElement::from_date_string("2024-01-15").unwrap(); + let result = elem.coerce_to(ArrayElementType::String).unwrap(); + assert_eq!(result, ArrayElement::String("2024-01-15".to_string())); + } + + #[test] + fn test_coerce_cross_temporal_not_supported() { + let elem = ArrayElement::Date(100); + let result = elem.coerce_to(ArrayElementType::DateTime); + assert!(result.is_err()); + + let elem = ArrayElement::DateTime(100000); + let result = elem.coerce_to(ArrayElementType::Date); + assert!(result.is_err()); + } + + // ========================================================================= + // ParamConstraint validation tests + // ========================================================================= + + #[test] + fn test_number_constraint_accepts_valid() { + let constraint = ParamConstraint::number_min(0.0); + assert!(validate_parameter("test", &ParameterValue::Number(5.0), &constraint).is_ok()); + assert!(validate_parameter("test", &ParameterValue::Number(0.0), &constraint).is_ok()); + } + + #[test] + fn test_number_constraint_rejects_invalid() { + let constraint = ParamConstraint::number_min(0.0); + let result = validate_parameter("test", &ParameterValue::Number(-1.0), &constraint); + assert!(result.is_err()); + assert!(result.unwrap_err().contains(">= 0")); + } + + #[test] + fn test_number_constraint_rejects_wrong_type() { + let constraint = ParamConstraint::number_min(0.0); + let result = validate_parameter( + "test", + &ParameterValue::String("hello".to_string()), + &constraint, + ); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("should be Number")); + } + + #[test] + fn test_count_constraint_accepts_whole() { + let constraint = ParamConstraint::count(1.0); + assert!(validate_parameter("bins", &ParameterValue::Number(5.0), &constraint).is_ok()); + assert!(validate_parameter("bins", &ParameterValue::Number(1.0), &constraint).is_ok()); + assert!(validate_parameter("bins", &ParameterValue::Number(100.0), &constraint).is_ok()); + } + + #[test] + fn test_count_constraint_rejects_fractional() { + let constraint = ParamConstraint::count(1.0); + let result = validate_parameter("bins", &ParameterValue::Number(5.5), &constraint); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("whole number")); + } + + #[test] + fn test_count_constraint_rejects_below_min() { + let constraint = ParamConstraint::count(1.0); + let result = validate_parameter("bins", &ParameterValue::Number(0.0), &constraint); + assert!(result.is_err()); + assert!(result.unwrap_err().contains(">= 1")); + } + + #[test] + fn test_string_option_accepts_valid() { + let constraint = ParamConstraint::string_option(&["a", "b", "c"]); + assert!(validate_parameter( + "test", + &ParameterValue::String("a".to_string()), + &constraint + ) + .is_ok()); + } + + #[test] + fn test_string_option_rejects_invalid() { + let constraint = ParamConstraint::string_option(&["a", "b", "c"]); + let result = validate_parameter( + "test", + &ParameterValue::String("d".to_string()), + &constraint, + ); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("not 'd'")); + } + + #[test] + fn test_string_option_rejects_wrong_type() { + let constraint = ParamConstraint::string_option(&["a", "b", "c"]); + let result = validate_parameter("test", &ParameterValue::Number(1.0), &constraint); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("should be String")); + } + + #[test] + fn test_string_numeric_with_unit_accepts_a_number_with_an_allowed_unit() { + let constraint = ParamConstraint::string_numeric_with_unit(&["px", "%"]); + assert!(validate_parameter( + "width", + &ParameterValue::String("20%".to_string()), + &constraint + ) + .is_ok()); + assert!(validate_parameter( + "width", + &ParameterValue::String("240px".to_string()), + &constraint + ) + .is_ok()); + } + + #[test] + fn test_string_numeric_with_unit_rejects_a_non_numeric_prefix() { + let constraint = ParamConstraint::string_numeric_with_unit(&["px", "%"]); + let result = validate_parameter( + "width", + &ParameterValue::String("wide%".to_string()), + &constraint, + ); + assert!(result.is_err()); + } + + #[test] + fn test_string_numeric_with_unit_rejects_a_disallowed_unit() { + let constraint = ParamConstraint::string_numeric_with_unit(&["px", "%"]); + let result = validate_parameter( + "width", + &ParameterValue::String("20em".to_string()), + &constraint, + ); + assert!(result.is_err()); + } + + #[test] + fn test_string_numeric_with_unit_rejects_a_bare_unit() { + let constraint = ParamConstraint::string_numeric_with_unit(&["px", "%"]); + let result = validate_parameter( + "width", + &ParameterValue::String("%".to_string()), + &constraint, + ); + assert!(result.is_err()); + } + + #[test] + fn test_boolean_accepts_valid() { + let constraint = ParamConstraint::boolean(); + assert!(validate_parameter("reverse", &ParameterValue::Boolean(true), &constraint).is_ok()); + assert!( + validate_parameter("reverse", &ParameterValue::Boolean(false), &constraint).is_ok() + ); + } + + #[test] + fn test_boolean_rejects_wrong_type() { + let constraint = ParamConstraint::boolean(); + let result = validate_parameter( + "reverse", + &ParameterValue::String("true".to_string()), + &constraint, + ); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("should be Boolean")); + } + + #[test] + fn test_multi_type_number_or_array_accepts_number() { + let constraint = ParamConstraint::number_or_numeric_array( + NumberConstraint::min(0.0), + ArrayConstraint::of_numbers_len(NumberConstraint::unconstrained(), 2), + ); + assert!(validate_parameter("expand", &ParameterValue::Number(0.05), &constraint).is_ok()); + } + + #[test] + fn test_multi_type_number_or_array_accepts_array() { + let constraint = ParamConstraint::number_or_numeric_array( + NumberConstraint::min(0.0), + ArrayConstraint::of_numbers_len(NumberConstraint::min(0.0), 2), + ); + let arr = + ParameterValue::Array(vec![ArrayElement::Number(0.05), ArrayElement::Number(10.0)]); + assert!(validate_parameter("expand", &arr, &constraint).is_ok()); + } + + #[test] + fn test_multi_type_number_or_array_rejects_wrong_array_length() { + let constraint = ParamConstraint::number_or_numeric_array( + NumberConstraint::min(0.0), + ArrayConstraint::of_numbers_len(NumberConstraint::min(0.0), 2), + ); + let arr = ParameterValue::Array(vec![ArrayElement::Number(0.05)]); + let result = validate_parameter("expand", &arr, &constraint); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("exactly 2")); + } + + #[test] + fn test_multi_type_number_or_array_rejects_string() { + let constraint = ParamConstraint::number_or_numeric_array( + NumberConstraint::min(0.0), + ArrayConstraint::of_numbers_len(NumberConstraint::min(0.0), 2), + ); + let result = validate_parameter( + "expand", + &ParameterValue::String("0.05".to_string()), + &constraint, + ); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("should be Number or Array")); + } + + #[test] + fn test_multi_type_number_or_array_validates_element_values() { + let constraint = ParamConstraint::number_or_numeric_array( + NumberConstraint::min(0.0), + ArrayConstraint::of_numbers_len(NumberConstraint::min(0.0), 2), + ); + let arr = ParameterValue::Array(vec![ + ArrayElement::Number(0.05), + ArrayElement::Number(-10.0), // Invalid: negative + ]); + let result = validate_parameter("expand", &arr, &constraint); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(err.contains("expand[1]")); + assert!(err.contains(">= 0")); + } + + #[test] + fn test_breaks_constraint_accepts_number() { + let constraint = ParamConstraint::number_or_array_or_string( + NumberConstraint::min(1.0), + ArrayConstraint::of_numbers(NumberConstraint::unconstrained()), + ); + assert!(validate_parameter("breaks", &ParameterValue::Number(10.0), &constraint).is_ok()); + } + + #[test] + fn test_breaks_constraint_accepts_array() { + let constraint = ParamConstraint::number_or_array_or_string( + NumberConstraint::min(1.0), + ArrayConstraint::of_numbers(NumberConstraint::unconstrained()), + ); + let arr = ParameterValue::Array(vec![ + ArrayElement::Number(0.0), + ArrayElement::Number(25.0), + ArrayElement::Number(50.0), + ]); + assert!(validate_parameter("breaks", &arr, &constraint).is_ok()); + } + + #[test] + fn test_breaks_constraint_accepts_string() { + let constraint = ParamConstraint::number_or_array_or_string( + NumberConstraint::min(1.0), + ArrayConstraint::of_numbers(NumberConstraint::unconstrained()), + ); + assert!(validate_parameter( + "breaks", + &ParameterValue::String("1 month".to_string()), + &constraint + ) + .is_ok()); + } + + #[test] + fn test_breaks_constraint_rejects_boolean() { + let constraint = ParamConstraint::number_or_array_or_string( + NumberConstraint::min(1.0), + ArrayConstraint::of_numbers(NumberConstraint::unconstrained()), + ); + let result = validate_parameter("breaks", &ParameterValue::Boolean(true), &constraint); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .contains("should be Number, String, or Array")); + } + + #[test] + fn test_breaks_constraint_validates_number_value() { + let constraint = ParamConstraint::number_or_array_or_string( + NumberConstraint::min(1.0), + ArrayConstraint::of_numbers(NumberConstraint::unconstrained()), + ); + let result = validate_parameter("breaks", &ParameterValue::Number(0.0), &constraint); + assert!(result.is_err()); + assert!(result.unwrap_err().contains(">= 1")); + } + + #[test] + fn test_array_element_type_validation() { + let constraint = ParamConstraint::number_or_numeric_array( + NumberConstraint::min(0.0), + ArrayConstraint::of_numbers(NumberConstraint::unconstrained()), + ); + let arr = ParameterValue::Array(vec![ + ArrayElement::Number(1.0), + ArrayElement::String("fifty".to_string()), + ArrayElement::Number(100.0), + ]); + let result = validate_parameter("breaks", &arr, &constraint); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("'breaks[1]' must be a number")); + } + + #[test] + fn test_null_allowed_by_default() { + let constraint = ParamConstraint::number_min(1.0); + assert!(validate_parameter("test", &ParameterValue::Null, &constraint).is_ok()); + } + + #[test] + fn test_null_rejected_when_required() { + let constraint = ParamConstraint::number_min(1.0).required(); + let result = validate_parameter("test", &ParameterValue::Null, &constraint); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("required")); + } + + #[test] + fn test_array_null_elements_rejected_by_default() { + let constraint = ParamConstraint::number_or_numeric_array( + NumberConstraint::min(0.0), + ArrayConstraint::of_numbers(NumberConstraint::unconstrained()), + ); + let arr = ParameterValue::Array(vec![ + ArrayElement::Number(1.0), + ArrayElement::Null, + ArrayElement::Number(3.0), + ]); + let result = validate_parameter("values", &arr, &constraint); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("'values[1]' cannot be null")); + } + + #[test] + fn test_string_or_string_array_accepts_string() { + let constraint = ParamConstraint::string_or_string_array(&["x", "y"]); + assert!(validate_parameter( + "free", + &ParameterValue::String("x".to_string()), + &constraint + ) + .is_ok()); + } + + #[test] + fn test_string_or_string_array_accepts_array() { + let constraint = ParamConstraint::string_or_string_array(&["x", "y"]); + let arr = ParameterValue::Array(vec![ + ArrayElement::String("x".to_string()), + ArrayElement::String("y".to_string()), + ]); + assert!(validate_parameter("free", &arr, &constraint).is_ok()); + } + + #[test] + fn test_string_or_string_array_validates_array_elements() { + let constraint = ParamConstraint::string_or_string_array(&["x", "y"]); + let arr = ParameterValue::Array(vec![ + ArrayElement::String("x".to_string()), + ArrayElement::String("z".to_string()), + ]); + let result = validate_parameter("free", &arr, &constraint); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("not 'z'")); + } + #[test] + fn test_homogenize_mixed_number_string() { + let arr = vec![ + ArrayElement::Number(1.0), + ArrayElement::String("foo".to_string()), + ]; + + let homogenized = ArrayElement::homogenize(&arr); + + // Should fall back to String type since "foo" can't be coerced to Number + assert_eq!(homogenized.len(), 2); + assert!(matches!(homogenized[0], ArrayElement::String(_))); + assert!(matches!(homogenized[1], ArrayElement::String(_))); + + if let ArrayElement::String(s) = &homogenized[0] { + assert_eq!(s, "1"); + } + if let ArrayElement::String(s) = &homogenized[1] { + assert_eq!(s, "foo"); + } + } + + #[test] + fn test_try_as_temporal_date() { + let elem = ArrayElement::String("1973-06-01".to_string()); + let parsed = elem.try_as_temporal(); + assert!(matches!(parsed, ArrayElement::Date(_))); + assert_eq!(parsed.to_key_string(), "1973-06-01"); + } + + #[test] + fn test_try_as_temporal_datetime() { + let elem = ArrayElement::String("2024-03-17T14:30:00".to_string()); + let parsed = elem.try_as_temporal(); + assert!(matches!(parsed, ArrayElement::DateTime(_))); + } + + #[test] + fn test_try_as_temporal_time() { + let elem = ArrayElement::String("14:30:00".to_string()); + let parsed = elem.try_as_temporal(); + assert!(matches!(parsed, ArrayElement::Time(_))); + } + + #[test] + fn test_try_as_temporal_non_temporal_string() { + let elem = ArrayElement::String("not a date".to_string()); + let parsed = elem.try_as_temporal(); + assert!(matches!(parsed, ArrayElement::String(_))); + assert_eq!(parsed.to_key_string(), "not a date"); + } + + #[test] + fn test_try_as_temporal_non_string() { + // Non-string elements should pass through unchanged + let elem = ArrayElement::Number(42.0); + let parsed = elem.try_as_temporal(); + assert!(matches!(parsed, ArrayElement::Number(_))); + + let elem = ArrayElement::Boolean(true); + let parsed = elem.try_as_temporal(); + assert!(matches!(parsed, ArrayElement::Boolean(_))); + } +} diff --git a/src/parser/builder.rs b/src/parser/builder.rs index dc7cb9bc4..a1b05d3c1 100644 --- a/src/parser/builder.rs +++ b/src/parser/builder.rs @@ -8,7 +8,7 @@ use crate::plot::layer::geom::Geom; use crate::plot::projection::resolve_coord; use crate::plot::scale::{color_to_hex, is_color_aesthetic, is_user_facet_aesthetic, Transform}; use crate::plot::*; -use crate::{GgsqlError, Result}; +use crate::{ColumnSection, Format, GgsqlError, Result, SelectionItem, Spanner, Spec, Table}; use std::collections::HashMap; use tree_sitter::Node; @@ -45,6 +45,20 @@ fn parse_string_node(node: &Node, source: &SourceTree) -> String { process_escape_sequences(unquoted) } +/// Strip one pair of surrounding backticks/double quotes from a quoted +/// identifier (a bare identifier passes through unchanged) — not via +/// `trim_matches`, which would also eat the other quote character if it +/// appears unescaped at the edges of the content. +fn parse_identifier_node(node: &Node, source: &SourceTree) -> String { + let text = source.get_text(node); + for quote in ['`', '"'] { + if let Some(inner) = text.strip_prefix(quote).and_then(|s| s.strip_suffix(quote)) { + return inner.to_string(); + } + } + text +} + /// Process escape sequences in a string (e.g., \n, \t, \\, \') fn process_escape_sequences(s: &str) -> String { let mut result = String::with_capacity(s.len()); @@ -194,7 +208,7 @@ fn parse_literal_value(node: &Node, source: &SourceTree) -> Result Result> { +pub fn build_ast(source: &SourceTree) -> Result> { let root = source.root(); // Check if root is a query node @@ -216,22 +230,51 @@ pub fn build_ast(source: &SourceTree) -> Result> { false }; - // Find all visualise_statement nodes - let query = "(visualise_statement) @viz"; - let viz_nodes = source.find_nodes(&root, query); + // A single alternation query visits the tree once and yields + // visualise_statement/tabulate_statement nodes already in document order, + // so they arrive correctly interleaved (e.g. `VISUALISE ... TABULATE ...`). + let stmt_nodes = source.find_nodes( + &root, + r#" + [ + (visualise_statement) @stmt + (tabulate_statement) @stmt + ] + "#, + ); let mut specs = Vec::new(); - for viz_node in viz_nodes { - let spec = build_visualise_statement(&viz_node, source)?; - - // Validate VISUALISE FROM usage - if spec.source.is_some() && last_is_select { - return Err(GgsqlError::ParseError( - "Cannot use VISUALISE FROM when the last SQL statement is SELECT. \ - Use either 'SELECT ... VISUALISE' or remove the SELECT and use \ - 'VISUALISE FROM ...'." - .to_string(), - )); + for stmt_node in stmt_nodes { + // Build the spec, then check the shared "FROM after a trailing + // SELECT" restriction once for whichever kind it is — VISUALISE FROM + // and TABULATE FROM both forbid it, differing only in keyword. + let (has_from, keyword, spec) = match stmt_node.kind() { + "visualise_statement" => { + let plot = build_visualise_statement(&stmt_node, source)?; + ( + plot.source.is_some(), + "VISUALISE", + Spec::Plot(Box::new(plot)), + ) + } + "tabulate_statement" => { + let table = build_tabulate_statement(&stmt_node, source)?; + (table.source.is_some(), "TABULATE", Spec::Table(table)) + } + other => { + return Err(GgsqlError::InternalError(format!( + "Unexpected top-level statement kind: '{}'", + other + ))); + } + }; + + if has_from && last_is_select { + return Err(GgsqlError::ParseError(format!( + "Cannot use {keyword} FROM when the last SQL statement is SELECT. \ + Use either 'SELECT ... {keyword}' or remove the SELECT and use \ + '{keyword} FROM ...'." + ))); } specs.push(spec); @@ -239,7 +282,7 @@ pub fn build_ast(source: &SourceTree) -> Result> { if specs.is_empty() { return Err(GgsqlError::ParseError( - "No VISUALISE statements found in query".to_string(), + "No VISUALISE or TABULATE statements found in query".to_string(), )); } @@ -266,7 +309,7 @@ fn build_visualise_statement(node: &Node, source: &SourceTree) -> Result { // Handle standalone wildcard (*) mapping spec.global_mappings.wildcard = true; } - "visualise_from" => { + "single_source_from" => { if let Some(source_node) = child.child_by_field_name("source") { spec.source = Some(parse_data_source(&source_node, source)); } @@ -313,6 +356,196 @@ fn build_visualise_statement(node: &Node, source: &SourceTree) -> Result { Ok(spec) } +/// Build the selection items of a column_selection node in written order +fn build_selection_items(node: &Node, source: &SourceTree) -> Result> { + let mut items = Vec::new(); + for elem in source.find_nodes(node, "(mapping_element) @elem") { + let child = elem.child(0).ok_or_else(|| { + GgsqlError::ParseError("Invalid mapping_element: missing child".to_string()) + })?; + let item = match child.kind() { + "wildcard_mapping" => SelectionItem::Wildcard, + "implicit_mapping" | "identifier" => { + let name = naming::unquote_ident(&source.get_text(&child)); + SelectionItem::Column { + sql: source.get_text(&elem), + source: Some(name.clone()), + name, + } + } + "explicit_mapping" => { + let (name_node, value_node) = extract_name_value_nodes(&child, "column selection")?; + let value_child = value_node.child(0).ok_or_else(|| { + GgsqlError::ParseError( + "Invalid column selection item: missing value".to_string(), + ) + })?; + let source_column = match value_child.kind() { + "column_reference" => { + Some(naming::unquote_ident(&source.get_text(&value_child))) + } + "literal_value" => None, + _ => { + return Err(GgsqlError::ParseError(format!( + "Invalid column selection value type: {}", + value_child.kind() + ))) + } + }; + SelectionItem::Column { + sql: source.get_text(&elem), + name: naming::unquote_ident(&source.get_text(&name_node)), + source: source_column, + } + } + _ => { + return Err(GgsqlError::ParseError(format!( + "Invalid column selection item: {}", + child.kind() + ))) + } + }; + items.push(item); + } + Ok(items) +} + +/// Build a single Table from a tabulate_statement node +fn build_tabulate_statement(node: &Node, source: &SourceTree) -> Result
{ + let mut table = Table::new(); + + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + match child.kind() { + "column_selection" => { + table.selection = build_selection_items(&child, source)?; + } + "single_source_from" => { + if let Some(source_node) = child.child_by_field_name("source") { + table.source = Some(parse_data_source(&source_node, source)); + } + } + "tab_clause" => { + process_tab_clause(&child, source, &mut table)?; + } + _ => {} + } + } + + Ok(table) +} + +/// Process a table clause node +fn process_tab_clause(node: &Node, source: &SourceTree, table: &mut Table) -> Result<()> { + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + match child.kind() { + "label_clause" => { + let new_labels = build_labels(&child, source)?; + table.labels.merge(new_labels); + } + "span_clause" => { + table.spans.push(build_span_clause(&child, source)?); + } + "format_clause" => { + table.formats.push(build_format_clause(&child, source)?); + } + _ => {} + } + } + + Ok(()) +} + +/// Build a Spanner from a span_clause node: SPAN id OVER col, ... [SETTING ...] +fn build_span_clause(node: &Node, source: &SourceTree) -> Result { + let id_node = node + .child_by_field_name("id") + .ok_or_else(|| GgsqlError::ParseError("Missing 'id' field in SPAN clause".to_string()))?; + // `id` is unquoted like every other column reference, so it compares + // equal to a later OVER/LABEL reference. `label` is used only for display. + let (id, label) = match id_node.kind() { + "identifier" => ( + naming::unquote_ident(&source.get_text(&id_node)), + Some(parse_identifier_node(&id_node, source)), + ), + "null_literal" => (naming::anonymous_span_id(), None), + _ => { + return Err(GgsqlError::ParseError(format!( + "SPAN id must be an identifier or null, got: {}", + id_node.kind() + ))); + } + }; + + let columns_node = source + .find_node(node, "(column_list) @cols") + .ok_or_else(|| GgsqlError::ParseError("Missing columns in SPAN clause".to_string()))?; + let columns = parse_column_list(&columns_node, source)?; + + let settings = match source.find_node(node, "(setting_clause) @s") { + Some(setting_node) => parse_setting_clause(&setting_node, source)?, + None => Parameters::new(), + }; + + Ok(Spanner { + id, + label, + columns, + settings, + }) +} + +/// Build a Format from a format_clause node: FORMAT [BODY|STUB] col, ... [SETTING ...] [RENAMING ...] +fn build_format_clause(node: &Node, source: &SourceTree) -> Result { + let target = parse_format_target_identifier(node, source)?; + + let columns_node = source + .find_node(node, "(column_list) @cols") + .ok_or_else(|| GgsqlError::ParseError("Missing columns in FORMAT clause".to_string()))?; + let columns = parse_column_list(&columns_node, source)?; + + let settings = match source.find_node(node, "(setting_clause) @s") { + Some(setting_node) => parse_setting_clause(&setting_node, source)?, + None => Parameters::new(), + }; + + let mut value_mapping = None; + let mut value_template = "{}".to_string(); + if let Some(renaming_node) = source.find_node(node, "(renaming_clause) @r") { + let (mappings, template) = parse_renaming_clause(&renaming_node, source)?; + if !mappings.is_empty() { + value_mapping = Some(mappings); + } + value_template = template; + } + + Ok(Format { + columns, + target, + settings, + value_mapping, + value_template, + }) +} + +/// Parse a FORMAT clause's optional target identifier (BODY, STUB), missing +/// meaning BODY by default. +fn parse_format_target_identifier(node: &Node, source: &SourceTree) -> Result { + let Some(target_node) = source.find_node(node, "(format_target_identifier) @t") else { + return Ok(ColumnSection::Body); + }; + + match source.get_text(&target_node).to_lowercase().as_str() { + "body" => Ok(ColumnSection::Body), + "stub" => Ok(ColumnSection::Stub), + text => Err(GgsqlError::ParseError(format!( + "Unknown FORMAT target: '{}'. Valid targets: body, stub", + text + ))), + } +} + /// Process a visualization clause node fn process_viz_clause(node: &Node, source: &SourceTree, spec: &mut Plot) -> Result<()> { let mut cursor = node.walk(); @@ -338,14 +571,9 @@ fn process_viz_clause(node: &Node, source: &SourceTree, spec: &mut Plot) -> Resu } "label_clause" => { let new_labels = build_labels(&child, source)?; - // Merge with existing labels if any - if let Some(ref mut existing_labels) = spec.labels { - for (key, value) in new_labels.labels { - existing_labels.labels.insert(key, value); - } - } else { - spec.labels = Some(new_labels); - } + spec.labels + .get_or_insert_with(Labels::default) + .merge(new_labels); } _ => { // Unknown clause type @@ -590,15 +818,12 @@ fn parse_parameter_assignment( /// Identifiers follow the same convention as MAPPING columns: stored /// unquoted, re-quoted per dialect at SQL emission. fn parse_partition_clause(node: &Node, source: &SourceTree) -> Result> { - let query = r#" - (partition_columns - (identifier) @col) - "#; - Ok(source - .find_texts(node, query) - .iter() - .map(|t| naming::unquote_ident(t)) - .collect()) + let columns_node = source + .find_node(node, "(column_list) @cols") + .ok_or_else(|| { + GgsqlError::ParseError("Missing columns in PARTITION BY clause".to_string()) + })?; + parse_column_list(&columns_node, source) } /// Parse a filter_clause: FILTER @@ -709,9 +934,9 @@ fn build_scale(node: &Node, source: &SourceTree) -> Result { // Reuse existing setting_clause parser properties = parse_setting_clause(&child, source)?; } - "scale_renaming_clause" => { + "renaming_clause" => { // Parse RENAMING 'A' => 'Alpha', 'B' => 'Beta', * => '{} units' - let (mappings, template) = parse_scale_renaming_clause(&child, source)?; + let (mappings, template) = parse_renaming_clause(&child, source)?; if !mappings.is_empty() { label_mapping = Some(mappings); } @@ -836,7 +1061,7 @@ fn parse_scale_via_clause(node: &Node, source: &SourceTree) -> Result /// Returns a tuple of: /// - HashMap where: Key = original value, Value = Some(label) or None for suppressed labels /// - Template string for wildcard mappings (* => '...'), defaults to "{}" -fn parse_scale_renaming_clause( +fn parse_renaming_clause( node: &Node, source: &SourceTree, ) -> Result<(HashMap>, String)> { @@ -917,9 +1142,9 @@ fn build_facet(node: &Node, source: &SourceTree) -> Result { "facet_by" => { next_vars_are_cols = true; } - "facet_vars" => { + "column_list" => { // Parse list of variable names - let vars = parse_facet_vars(&child, source)?; + let vars = parse_column_list(&child, source)?; if next_vars_are_cols { column_vars = vars; } else { @@ -953,10 +1178,14 @@ fn build_facet(node: &Node, source: &SourceTree) -> Result { }) } -/// Parse facet variables from a facet_vars node -fn parse_facet_vars(node: &Node, source: &SourceTree) -> Result> { - let query = "(identifier) @var"; - Ok(source.find_texts(node, query)) +/// Parse identifier texts out of a column_list node +fn parse_column_list(node: &Node, source: &SourceTree) -> Result> { + let query = "(identifier) @col"; + Ok(source + .find_texts(node, query) + .iter() + .map(|c| naming::unquote_ident(c)) + .collect()) } // ============================================================================ @@ -1134,7 +1363,7 @@ fn build_labels(node: &Node, source: &SourceTree) -> Result { let (name_node, value_node) = extract_name_value_nodes(&label_node, "label assignment")?; // Parse label type (name) - let label_type = source.get_text(&name_node); + let label_type = naming::unquote_ident(&source.get_text(&name_node)); // Parse label value (string or null) let label_value = match value_node.kind() { @@ -1223,9 +1452,224 @@ mod tests { let source = SourceTree::new(query)?; source.validate()?; + Ok(build_ast(&source)? + .into_iter() + .filter_map(Spec::into_plot) + .collect()) + } + + /// Like `parse_test_query`, but keeps `Table` specs instead of filtering + /// them out — for tests that need to see the raw `Spec` variants. + fn parse_test_specs(query: &str) -> Result> { + let source = SourceTree::new(query)?; + source.validate()?; build_ast(&source) } + // ======================================== + // TABULATE Tests + // ======================================== + + #[test] + fn test_tabulate_bare() { + let specs = parse_test_specs("SELECT 1 TABULATE *").unwrap(); + assert_eq!(specs.len(), 1); + let table = specs[0].as_table().expect("expected a Table spec"); + assert!(table.source.is_none()); + } + + #[test] + fn test_tabulate_from() { + let specs = parse_test_specs("TABULATE * FROM sales").unwrap(); + assert_eq!(specs.len(), 1); + let table = specs[0].as_table().expect("expected a Table spec"); + assert!(matches!(table.source, Some(DataSource::Identifier(ref name)) if name == "sales")); + } + + #[test] + fn test_tabulate_from_file_path() { + let specs = parse_test_specs("TABULATE * FROM 'data.csv'").unwrap(); + assert_eq!(specs.len(), 1); + let table = specs[0].as_table().expect("expected a Table spec"); + assert!(matches!(table.source, Some(DataSource::FilePath(ref path)) if path == "data.csv")); + } + + #[test] + fn test_tabulate_from_after_select_errors() { + // Mirrors VISUALISE FROM's own "last statement is SELECT" restriction. + let result = parse_test_specs("SELECT 1 TABULATE * FROM sales"); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Cannot use TABULATE FROM")); + } + + #[test] + fn test_visualise_then_tabulate_interleaved() { + let specs = parse_test_specs("SELECT 1 AS x VISUALISE x DRAW point TABULATE *").unwrap(); + assert_eq!(specs.len(), 2); + assert!(matches!(specs[0], Spec::Plot(_))); + assert!(matches!(specs[1], Spec::Table(_))); + } + + #[test] + fn test_tabulate_span_basic() { + let specs = parse_test_specs("TABULATE * FROM sales SPAN `Pretty Name` OVER foo, bar, baz") + .unwrap(); + let table = specs[0].as_table().expect("expected a Table spec"); + + assert_eq!(table.spans.len(), 1); + assert_eq!(table.spans[0].id, "Pretty Name"); + assert_eq!(table.spans[0].label, Some("Pretty Name".to_string())); + assert_eq!(table.spans[0].columns, vec!["foo", "bar", "baz"]); + assert!(table.spans[0].settings.is_empty()); + } + + #[test] + fn test_tabulate_span_quoted_identifiers_are_unquoted() { + let specs = parse_test_specs( + "TABULATE * FROM sales SPAN \"Total Sales\" OVER `gross amount`, \"net amount\"", + ) + .unwrap(); + let table = specs[0].as_table().expect("expected a Table spec"); + + assert_eq!(table.spans[0].id, "Total Sales"); + assert_eq!(table.spans[0].label, Some("Total Sales".to_string())); + assert_eq!(table.spans[0].columns, vec!["gross amount", "net amount"]); + } + + #[test] + fn test_tabulate_span_columns_first_under_matches_id_first_over() { + let over = parse_test_specs("TABULATE * FROM sales SPAN `Pretty Name` OVER foo, bar, baz") + .unwrap(); + let under = + parse_test_specs("TABULATE * FROM sales SPAN foo, bar, baz UNDER `Pretty Name`") + .unwrap(); + + let over_table = over[0].as_table().expect("expected a Table spec"); + let under_table = under[0].as_table().expect("expected a Table spec"); + + assert_eq!(over_table.spans, under_table.spans); + } + + #[test] + fn test_tabulate_span_null_id_gets_an_anonymous_generated_id() { + let specs = parse_test_specs("TABULATE * FROM sales SPAN NULL OVER foo, bar").unwrap(); + let table = specs[0].as_table().expect("expected a Table spec"); + + assert!(!table.spans[0].id.is_empty()); + assert_eq!(table.spans[0].label, None); + } + + #[test] + fn test_tabulate_span_with_setting() { + let specs = + parse_test_specs("TABULATE * FROM sales SPAN W OVER foo SETTING width => '40%'") + .unwrap(); + let table = specs[0].as_table().expect("expected a Table spec"); + + assert_eq!( + table.spans[0].settings.get("width"), + Some(&ParameterValue::String("40%".to_string())) + ); + } + + #[test] + fn test_tabulate_repeated_label_clauses_merge_rather_than_overwrite() { + let specs = parse_test_specs("TABULATE * FROM sales LABEL id => 'ID' LABEL name => 'Name'") + .unwrap(); + let table = specs[0].as_table().expect("expected a Table spec"); + + assert_eq!(table.labels.labels.get("id"), Some(&Some("ID".to_string()))); + assert_eq!( + table.labels.labels.get("name"), + Some(&Some("Name".to_string())) + ); + } + + #[test] + fn test_tabulate_multiple_spans_and_label_in_any_order() { + let specs = parse_test_specs( + "TABULATE * FROM sales LABEL id => 'ID' SPAN A OVER foo, bar SPAN B OVER baz", + ) + .unwrap(); + let table = specs[0].as_table().expect("expected a Table spec"); + + assert_eq!(table.labels.labels.get("id"), Some(&Some("ID".to_string()))); + assert_eq!(table.spans.len(), 2); + assert_eq!(table.spans[0].label, Some("A".to_string())); + assert_eq!(table.spans[0].columns, vec!["foo", "bar"]); + assert_eq!(table.spans[1].label, Some("B".to_string())); + assert_eq!(table.spans[1].columns, vec!["baz"]); + } + + #[test] + fn test_tabulate_format_basic_has_no_settings_or_renaming() { + let specs = parse_test_specs("TABULATE * FROM sales FORMAT foo, bar").unwrap(); + let table = specs[0].as_table().expect("expected a Table spec"); + + assert_eq!(table.formats.len(), 1); + assert_eq!(table.formats[0].columns, vec!["foo", "bar"]); + assert_eq!(table.formats[0].target, ColumnSection::Body); + assert!(table.formats[0].settings.is_empty()); + assert_eq!(table.formats[0].value_mapping, None); + assert_eq!(table.formats[0].value_template, "{}"); + } + + #[test] + fn test_tabulate_format_stub_target() { + let specs = parse_test_specs("TABULATE * FROM sales FORMAT STUB region").unwrap(); + let table = specs[0].as_table().expect("expected a Table spec"); + + assert_eq!(table.formats[0].columns, vec!["region"]); + assert_eq!(table.formats[0].target, ColumnSection::Stub); + } + + #[test] + fn test_tabulate_format_explicit_body_target() { + let specs = parse_test_specs("TABULATE * FROM sales FORMAT BODY region").unwrap(); + let table = specs[0].as_table().expect("expected a Table spec"); + + assert_eq!(table.formats[0].columns, vec!["region"]); + assert_eq!(table.formats[0].target, ColumnSection::Body); + } + + #[test] + fn test_tabulate_format_with_setting_and_renaming() { + let specs = parse_test_specs( + "TABULATE * FROM sales FORMAT price SETTING width => '20%' \ + RENAMING null => '-', * => '{:num %.2f}'", + ) + .unwrap(); + let table = specs[0].as_table().expect("expected a Table spec"); + + assert_eq!(table.formats[0].columns, vec!["price"]); + assert_eq!( + table.formats[0].settings.get("width"), + Some(&ParameterValue::String("20%".to_string())) + ); + assert_eq!( + table.formats[0].value_mapping, + Some(HashMap::from([("null".to_string(), Some("-".to_string()))])) + ); + assert_eq!(table.formats[0].value_template, "{:num %.2f}"); + } + + #[test] + fn test_tabulate_multiple_format_clauses_produce_separate_formats() { + let specs = parse_test_specs( + "TABULATE * FROM sales FORMAT foo RENAMING * => '{:num %.0f}' FORMAT bar", + ) + .unwrap(); + let table = specs[0].as_table().expect("expected a Table spec"); + + assert_eq!(table.formats.len(), 2); + assert_eq!(table.formats[0].columns, vec!["foo"]); + assert_eq!(table.formats[0].value_template, "{:num %.0f}"); + assert_eq!(table.formats[1].columns, vec!["bar"]); + } + // ======================================== // PROJECT Property Validation Tests // ======================================== @@ -3515,7 +3959,7 @@ mod tests { let source = make_source("VISUALISE FROM sales DRAW bar"); let root = source.root(); - let query = "(visualise_from source: (_) @source)"; + let query = "(single_source_from source: (_) @source)"; let from_node = source.find_node(&root, query).unwrap(); let parsed = parse_data_source(&from_node, &source); diff --git a/src/parser/mod.rs b/src/parser/mod.rs index a9171c64f..15bab19db 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -19,7 +19,7 @@ the visualization specification into a typed AST. ```rust # use ggsql::parser::parse_query; -# use ggsql::Geom; +# use ggsql::{Geom, Spec}; # fn main() -> Result<(), Box> { let query = r#" SELECT date, revenue, region FROM sales WHERE year = 2024 @@ -31,14 +31,17 @@ let query = r#" let specs = parse_query(query)?; assert_eq!(specs.len(), 1); -assert_eq!(specs[0].layers.len(), 1); -assert_eq!(specs[0].layers[0].geom, Geom::line()); +let Spec::Plot(plot) = &specs[0] else { + panic!("expected a Plot"); +}; +assert_eq!(plot.layers.len(), 1); +assert_eq!(plot.layers[0].geom, Geom::line()); # Ok(()) # } ``` */ -use crate::{Plot, Result}; +use crate::{Result, Spec}; pub mod builder; pub mod source_tree; @@ -55,7 +58,7 @@ pub use sql::{ /// /// Takes a complete ggsql query (SQL + VISUALISE) and returns a vector of /// parsed specifications (one per VISUALISE statement). -pub fn parse_query(query: &str) -> Result> { +pub fn parse_query(query: &str) -> Result> { // Parse the full query and create SourceTree let source_tree = SourceTree::new(query)?; @@ -72,7 +75,17 @@ pub fn parse_query(query: &str) -> Result> { mod tests { use super::*; use crate::plot::ParameterValue; - use crate::{AestheticValue, DataSource, Geom}; + use crate::{AestheticValue, DataSource, Geom, Plot}; + + /// Test helper: `parse_query`, then unwrap every result down to its `Plot`. + /// These tests predate the `Table` variant and only ever exercise VISUALISE + /// queries, so every `Spec` here is a `Spec::Plot`. + fn parse_query_plots(query: &str) -> Result> { + Ok(parse_query(query)? + .into_iter() + .filter_map(Spec::into_plot) + .collect()) + } #[test] fn test_simple_query_parsing() { @@ -82,7 +95,7 @@ mod tests { DRAW point "#; - let result = parse_query(query); + let result = parse_query_plots(query); assert!(result.is_ok(), "Failed to parse simple query: {:?}", result); let specs = result.unwrap(); @@ -115,7 +128,7 @@ mod tests { DRAW point MAPPING z AS y, 'value' AS color "#; - let specs = parse_query(query).unwrap(); + let specs = parse_query_plots(query).unwrap(); assert_eq!(specs.len(), 1); assert_eq!(specs[0].layers.len(), 2); // First layer is line, second layer is point @@ -140,7 +153,7 @@ mod tests { DRAW bar MAPPING x AS x, y AS y "#; - let specs = parse_query(query).unwrap(); + let specs = parse_query_plots(query).unwrap(); assert_eq!(specs.len(), 2); assert_eq!(specs[0].layers.len(), 1); assert_eq!(specs[1].layers.len(), 1); @@ -154,7 +167,7 @@ mod tests { DRAW point "#; - let specs = parse_query(query).unwrap(); + let specs = parse_query_plots(query).unwrap(); assert_eq!(specs.len(), 1); assert_eq!(specs[0].layers[0].geom, Geom::point()); } @@ -171,7 +184,7 @@ mod tests { DRAW point "#; - let specs = parse_query(query).unwrap(); + let specs = parse_query_plots(query).unwrap(); assert_eq!(specs.len(), 3); assert_eq!(specs[0].layers.len(), 1); assert_eq!(specs[1].layers.len(), 1); @@ -186,7 +199,7 @@ mod tests { DRAW point MAPPING x AS x, y AS y "#; - let specs = parse_query(query).unwrap(); + let specs = parse_query_plots(query).unwrap(); assert_eq!(specs.len(), 1); assert!(specs[0].global_mappings.is_empty()); } @@ -202,7 +215,7 @@ mod tests { DRAW bar MAPPING x AS x, y AS y "#; - let specs = parse_query(query).unwrap(); + let specs = parse_query_plots(query).unwrap(); assert_eq!(specs.len(), 2); // First viz should have layers and labels @@ -226,7 +239,7 @@ mod tests { DRAW bar MAPPING x AS x, y AS y "#; - let specs = parse_query(query).unwrap(); + let specs = parse_query_plots(query).unwrap(); assert_eq!(specs.len(), 3); assert_eq!(specs[0].layers[0].geom, Geom::line()); assert_eq!(specs[1].layers[0].geom, Geom::point()); @@ -249,7 +262,7 @@ mod tests { DRAW point MAPPING date AS x, revenue AS y "#; - let specs = parse_query(query).unwrap(); + let specs = parse_query_plots(query).unwrap(); assert_eq!(specs.len(), 3); // Plot with 2 layers, scale, labels @@ -288,7 +301,7 @@ mod tests { source_tree ); - let specs = parse_query(query).unwrap(); + let specs = parse_query_plots(query).unwrap(); assert_eq!(specs.len(), 1); } @@ -300,7 +313,7 @@ mod tests { DRAW point "#; - let specs = parse_query(query).unwrap(); + let specs = parse_query_plots(query).unwrap(); assert_eq!(specs.len(), 1); assert!(specs[0].global_mappings.wildcard); assert!(specs[0].global_mappings.aesthetics.is_empty()); @@ -313,7 +326,7 @@ mod tests { DRAW line "#; - let specs = parse_query(query).unwrap(); + let specs = parse_query_plots(query).unwrap(); assert_eq!(specs.len(), 1); let mapping = &specs[0].global_mappings; assert!(!mapping.wildcard); @@ -339,7 +352,7 @@ mod tests { DRAW point "#; - let specs = parse_query(query).unwrap(); + let specs = parse_query_plots(query).unwrap(); assert_eq!(specs.len(), 1); let mapping = &specs[0].global_mappings; assert!(!mapping.wildcard); @@ -363,7 +376,7 @@ mod tests { DRAW point "#; - let specs = parse_query(query).unwrap(); + let specs = parse_query_plots(query).unwrap(); assert_eq!(specs.len(), 1); let mapping = &specs[0].global_mappings; assert!(!mapping.wildcard); @@ -390,7 +403,7 @@ mod tests { DRAW bar "#; - let specs = parse_query(query).unwrap(); + let specs = parse_query_plots(query).unwrap(); assert_eq!(specs.len(), 1); assert_eq!( specs[0].source, @@ -406,7 +419,7 @@ mod tests { DRAW point "#; - let specs = parse_query(query).unwrap(); + let specs = parse_query_plots(query).unwrap(); assert_eq!(specs.len(), 1); assert_eq!( specs[0].source, @@ -423,7 +436,7 @@ mod tests { PLACE text SETTING x => 5, y => 10, label => 'Hello' "#; - let result = parse_query(query); + let result = parse_query_plots(query); assert!(result.is_ok(), "Failed to parse PLACE clause: {:?}", result); let specs = result.unwrap(); diff --git a/src/parser/source_tree.rs b/src/parser/source_tree.rs index 32109f9f5..bc599df5c 100644 --- a/src/parser/source_tree.rs +++ b/src/parser/source_tree.rs @@ -49,12 +49,13 @@ impl<'a> SourceTree<'a> { let has_sql_from = self .find_node(&root, "(sql_statement (from_statement) @stmt)") .is_some(); - let has_viz_from = self - .find_node(&root, "(visualise_statement (visualise_from) @t)") - .is_some(); - if has_sql_from && has_viz_from { + // single_source_from is shared by visualise_statement and + // tabulate_statement, so this covers both `FROM a VISUALISE FROM b` + // and `FROM a TABULATE FROM b` in one check. + let has_stmt_from = self.find_node(&root, "(single_source_from) @t").is_some(); + if has_sql_from && has_stmt_from { return Err(GgsqlError::ParseError( - "VISUALISE has two FROM clauses (one before VISUALISE and one after). \ + "Query has two FROM clauses (one before VISUALISE/TABULATE and one after). \ Use only one." .to_string(), )); @@ -133,7 +134,29 @@ impl<'a> SourceTree<'a> { .collect() } - /// Extract the SQL portion of the query (before VISUALISE). + /// The first VISUALISE or TABULATE statement node, whichever comes first. + /// `None` if the query has neither. + /// + /// This is the statement `Reader::execute()`'s dispatch actually resolves + /// — it always acts on the first top-level `Spec`, of whichever kind — so + /// it is also the only statement `extract_sql`'s FROM-injection should + /// ever look inside. `pub(crate)` for `execute::table::build_table_sql`, + /// which scopes its own `TABULATE FROM` lookup the same way. + pub(crate) fn first_stmt<'b>(&self, root: &Node<'b>) -> Option> { + let viz = self.find_node(root, "(visualise_statement) @viz"); + let tab = self.find_node(root, "(tabulate_statement) @tab"); + match (viz, tab) { + (Some(a), Some(b)) => Some(if a.start_byte() <= b.start_byte() { + a + } else { + b + }), + (Some(a), None) | (None, Some(a)) => Some(a), + (None, None) => None, + } + } + + /// Extract the SQL portion of the query (before VISUALISE/TABULATE). /// /// Two rewrites happen here so the returned SQL is always something a /// plain SQL reader can execute: @@ -142,25 +165,27 @@ impl<'a> SourceTree<'a> { /// `from_statement`. Each such statement is rewritten by prepending /// `SELECT * ` — so `FROM sales VISUALISE …` becomes /// `SELECT * FROM sales`. - /// - `VISUALISE FROM `: the FROM appears on the VISUALISE clause. - /// We append `SELECT * FROM ` to the SQL so the reader sees an - /// executable query. + /// - `VISUALISE FROM ` / `TABULATE FROM `: the FROM + /// appears on the statement itself rather than as SQL. We append + /// `SELECT * FROM ` to the SQL so the reader sees an + /// executable query. Plots and tables share this extraction exactly — + /// the only difference between them is that a table has no per-layer + /// granular sources to also account for. /// - /// Returns `None` if there's no SQL portion and no VISUALISE FROM to - /// inject. The ambiguous double-FROM case (`FROM a VISUALISE FROM b …`) - /// is rejected in `SourceTree::new`, so any tree reaching here has at - /// most one of the two FROMs. + /// Returns `None` if there's no SQL portion and no FROM (on either + /// VISUALISE or TABULATE) to inject. The ambiguous double-FROM case + /// (`FROM a VISUALISE FROM b …`) is rejected in `SourceTree::new`, so any + /// tree reaching here has at most one of the two FROMs. pub fn extract_sql(&self) -> Option { let root = self.root(); - // Check if there's any VISUALISE statement - if self - .find_node(&root, "(visualise_statement) @viz") - .is_none() - { - // No VISUALISE at all - return entire source as SQL + // The statement `Reader::execute()` will actually resolve — the only + // one a FROM belonging to some *other*, later statement should ever + // be allowed to feed into. + let Some(first_stmt) = self.first_stmt(&root) else { + // Neither VISUALISE nor TABULATE at all - return entire source as SQL return Some(self.source.to_string()); - } + }; // Find sql_portion node and extract its text let sql_portion_node = self.find_node(&root, "(sql_portion) @sql"); @@ -191,17 +216,31 @@ impl<'a> SourceTree<'a> { } } - // VISUALISE FROM : append "SELECT * FROM ". - let viz_from = self.find_text( - &root, + // VISUALISE FROM / TABULATE FROM : append + // "SELECT * FROM ". Explicitly anchored to both statement + // kinds (rather than a bare `(single_source_from …)`) so this can't + // silently start matching some unrelated future use of + // single_source_from — today it's only ever a child of one of these + // two, but this doesn't rely on that staying true. + // + // Scoped to `first_stmt` specifically, not `root`: a query can have + // several VISUALISE/TABULATE statements (interleaved, even), and only + // the first one is ever actually resolved. Searching the whole tree + // here would let a *later* statement's FROM leak into the one being + // resolved — e.g. `TABULATE VISUALISE FROM sales DRAW point` would + // silently pick up the VISUALISE's `FROM sales` for the source-less + // TABULATE instead of correctly reporting no data source. + let stmt_from = self.find_text( + &first_stmt, r#" - (visualise_statement - (visualise_from - source: (_) @source)) + [ + (visualise_statement (single_source_from source: (_) @source)) + (tabulate_statement (single_source_from source: (_) @source)) + ] "#, ); - if let Some(from_identifier) = viz_from { + if let Some(from_identifier) = stmt_from { let result = if sql_text.trim().is_empty() { format!("SELECT * FROM {}", from_identifier) } else { @@ -218,20 +257,16 @@ impl<'a> SourceTree<'a> { } } - /// Extract the VISUALISE portion of the query (from first VISUALISE onwards) - /// - /// Returns the raw text of all VISUALISE statements - pub fn extract_visualise(&self) -> Option { + /// Extract the `Spec` portion of the query — the VISUALISE/TABULATE + /// statement(s), from whichever comes first onwards. + pub fn extract_spec(&self) -> Option { let root = self.root(); - // Find byte offset of first VISUALISE - let viz_start = self - .find_node(&root, "(visualise_statement) @viz") - .map(|node| node.start_byte())?; + let spec_start = self.first_stmt(&root)?.start_byte(); - // Extract viz text from first VISUALISE onwards - let viz_text = &self.source[viz_start..]; - Some(viz_text.trim().to_string()) + // Extract spec text from first VISUALISE/TABULATE onwards + let spec_text = &self.source[spec_start..]; + Some(spec_text.trim().to_string()) } } @@ -247,7 +282,7 @@ mod tests { let sql = tree.extract_sql().unwrap(); assert_eq!(sql, "SELECT * FROM data"); - let viz = tree.extract_visualise().unwrap(); + let viz = tree.extract_spec().unwrap(); assert!(viz.starts_with("VISUALISE")); assert!(viz.contains("DRAW point")); } @@ -260,7 +295,7 @@ mod tests { let sql = tree.extract_sql().unwrap(); assert_eq!(sql, "SELECT * FROM data"); - let viz = tree.extract_visualise().unwrap(); + let viz = tree.extract_spec().unwrap(); assert!(viz.starts_with("visualise")); } @@ -284,7 +319,7 @@ mod tests { let sql = tree.extract_sql().unwrap(); assert_eq!(sql, query); - let viz = tree.extract_visualise(); + let viz = tree.extract_spec(); assert!(viz.is_none()); } @@ -297,10 +332,44 @@ mod tests { // Should inject SELECT * FROM mtcars assert_eq!(sql, "SELECT * FROM mtcars"); - let viz = tree.extract_visualise().unwrap(); + let viz = tree.extract_spec().unwrap(); assert!(viz.starts_with("VISUALISE FROM mtcars")); } + #[test] + fn test_extract_sql_tabulate_from_matches_bare_select() { + // TABULATE FROM and SELECT * FROM TABULATE should + // extract to the identical SQL, the same equivalence VISUALISE FROM + // already has with a bare SELECT. + let from_only = SourceTree::new("TABULATE * FROM ggsql:penguins").unwrap(); + let select_then_tabulate = + SourceTree::new("SELECT * FROM ggsql:penguins TABULATE *").unwrap(); + + assert_eq!( + from_only.extract_sql().unwrap(), + select_then_tabulate.extract_sql().unwrap() + ); + assert_eq!( + from_only.extract_sql().unwrap(), + "SELECT * FROM ggsql:penguins" + ); + } + + #[test] + fn test_extract_sql_does_not_borrow_a_later_statements_from() { + // A source-less TABULATE followed by an unrelated VISUALISE FROM must + // not pick up the VISUALISE's FROM — extract_sql is scoped to the + // first statement (the one Reader::execute() actually resolves), not + // the whole tree. + let tree = SourceTree::new("TABULATE * VISUALISE FROM sales DRAW point").unwrap(); + assert_eq!(tree.extract_sql(), None); + + // Same in the other order: a source-less VISUALISE must not borrow a + // later TABULATE's FROM either. + let tree = SourceTree::new("VISUALISE DRAW point TABULATE * FROM sales").unwrap(); + assert_eq!(tree.extract_sql(), None); + } + #[test] fn test_extract_sql_visualise_from_jinja_ref() { let query = "VISUALISE FROM {{ ref('fct_orders') }} DRAW point MAPPING x AS x, y AS y"; @@ -309,7 +378,7 @@ mod tests { let sql = tree.extract_sql().unwrap(); assert_eq!(sql, "SELECT * FROM {{ ref('fct_orders') }}"); - let viz = tree.extract_visualise().unwrap(); + let viz = tree.extract_spec().unwrap(); assert!(viz.starts_with("VISUALISE FROM {{ ref('fct_orders') }}")); } @@ -324,7 +393,7 @@ mod tests { assert!(sql.contains("WITH cte AS (SELECT * FROM x)")); assert!(sql.contains("SELECT * FROM cte")); - let viz = tree.extract_visualise().unwrap(); + let viz = tree.extract_spec().unwrap(); assert!(viz.starts_with("VISUALISE FROM cte")); } @@ -337,7 +406,7 @@ mod tests { assert!(sql.contains("CREATE TABLE x AS SELECT 1;")); assert!(sql.contains("SELECT * FROM x")); - let viz = tree.extract_visualise().unwrap(); + let viz = tree.extract_spec().unwrap(); assert!(viz.starts_with("VISUALISE FROM x")); // Without semicolon, the visualise statement should also be recognised @@ -348,7 +417,7 @@ mod tests { assert!(sql2.contains("CREATE TABLE x AS SELECT 1")); assert!(sql2.contains("SELECT * FROM x")); - let viz2 = tree2.extract_visualise().unwrap(); + let viz2 = tree2.extract_spec().unwrap(); assert!(viz2.starts_with("VISUALISE FROM x")); } @@ -360,7 +429,7 @@ mod tests { let sql = tree.extract_sql().unwrap(); assert!(sql.contains("INSERT")); - let viz = tree.extract_visualise().unwrap(); + let viz = tree.extract_spec().unwrap(); assert!(viz.contains("DRAW")); } @@ -387,7 +456,7 @@ mod tests { let sql = tree.extract_sql().unwrap(); assert!(sql.contains("SELECT * FROM mtcars")); - let viz = tree.extract_visualise().unwrap(); + let viz = tree.extract_spec().unwrap(); assert!(viz.starts_with("VISUALISE")); } @@ -537,7 +606,7 @@ mod tests { // Should inject SELECT * FROM 'mtcars.csv' with quotes preserved assert_eq!(sql, "SELECT * FROM 'mtcars.csv'"); - let viz = tree.extract_visualise().unwrap(); + let viz = tree.extract_spec().unwrap(); assert!(viz.starts_with("VISUALISE FROM 'mtcars.csv'")); } @@ -551,7 +620,7 @@ mod tests { // Should inject SELECT * FROM "data/sales.parquet" with quotes preserved assert_eq!(sql, r#"SELECT * FROM "data/sales.parquet""#); - let viz = tree.extract_visualise().unwrap(); + let viz = tree.extract_spec().unwrap(); assert!(viz.starts_with(r#"VISUALISE FROM "data/sales.parquet""#)); } diff --git a/src/plot/CLAUDE.md b/src/plot/CLAUDE.md index 3add30935..5e326e44a 100644 --- a/src/plot/CLAUDE.md +++ b/src/plot/CLAUDE.md @@ -6,18 +6,21 @@ For ggsql language semantics, see [`/doc/syntax/`](../../doc/syntax/). This file ## Top-level types -Defined in `main.rs` (the `Plot` struct + `Labels`) and `types.rs` (input/value types). All are re-exported through `mod.rs`. +Defined in `main.rs` (the `Plot` struct) and `types.rs` (input/value types). All are re-exported through `mod.rs`. +The `SETTING`-parameter machinery (`ParameterValue`, `Parameters`, `ArrayElement`, the constraint types, `validate_parameter`) lives one level up in [`../params.rs`](../params.rs), shared with `table/`; `types.rs` re-exports it, so `plot::` paths keep working. +`Labels` lives in [`../labels.rs`](../labels.rs), likewise shared with `table/` and re-exported from `main.rs`. | Type | Where | Role | | --- | --- | --- | | `Plot` | `main.rs` | Root spec: `global_mappings`, optional `source`, `layers`, `scales`, optional `facet`, `project`, `labels`, plus a derived `aesthetic_context`. | -| `Labels` | `main.rs` | Title/subtitle/axis/aesthetic labels merged from all `LABEL` clauses. | +| `Labels` | `../labels.rs` | Title/subtitle/axis/aesthetic labels merged from all `LABEL` clauses. Shared with `table/`. | | `Mappings` | `types.rs` | Unified aesthetic mapping (`wildcard: bool` + `aesthetics: HashMap`); used for both global and per-layer mappings. | | `AestheticValue` | `types.rs` | One side of a mapping: column reference, literal, annotation literal, or null. | -| `ParameterValue` | `types.rs` | Value used in `SETTING` clauses: string / number / boolean / array. | +| `ParameterValue` | `../params.rs` | Value used in `SETTING` clauses: string / number / boolean / array. | | `DataSource` | `types.rs` | `Identifier`, `FilePath`, or `Annotation` (PLACE) — the right-hand side of `FROM`. | | `SqlExpression` | `types.rs` | Captured raw SQL fragment (e.g. for `FILTER`). | -| `Schema` / `ColumnInfo` / `ArrayElement` | `types.rs` | Schema info computed from data; carries dtype, discreteness, min/max. | +| `Schema` / `ColumnInfo` | `types.rs` | Schema info computed from data; carries dtype, discreteness, min/max. | +| `ArrayElement` | `../params.rs` | Element of an array-valued parameter or range; date/time-aware. | | `AestheticContext` | `aesthetic.rs` | Coord-aware mapping of user aesthetic names (`x`, `y`) to internal names (`pos1`, `pos2`). Computed once and stored on `Plot` so executor and writers agree. | `aesthetic.rs` also exports the position-aesthetic predicates (`is_position_aesthetic`, `MATERIAL_AESTHETICS`, `POSITION_SUFFIXES`) that the rest of the pipeline reaches for. @@ -27,8 +30,8 @@ Defined in `main.rs` (the `Plot` struct + `Labels`) and `types.rs` (input/value ``` plot/ ├── aesthetic.rs Aesthetic naming + classification (position/facet/material) -├── main.rs Plot, Labels -├── types.rs Mappings, AestheticValue, DataSource, Schema, ArrayElement, … +├── main.rs Plot +├── types.rs Mappings, AestheticValue, DataSource, Schema, … ├── facet/ FACET clause ├── layer/ DRAW clause (layers) │ ├── geom/ one file per layer type (point, line, bar, …) diff --git a/src/plot/layer/geom/density.rs b/src/plot/layer/geom/density.rs index 1b215aace..a491a86fb 100644 --- a/src/plot/layer/geom/density.rs +++ b/src/plot/layer/geom/density.rs @@ -1173,7 +1173,11 @@ mod tests { let spec = reader.execute(query).expect("Query should execute"); // Get the stat-transformed data for layer 0 - let df = spec.stat_data(0).expect("Layer 0 should have stat data"); + let df = spec + .as_plot() + .unwrap() + .stat_data(0) + .expect("Layer 0 should have stat data"); println!("\nActual columns in stat_data: {:?}", df.get_column_names()); println!("Number of rows: {}", df.height()); diff --git a/src/plot/main.rs b/src/plot/main.rs index 3c0b1ffa6..9202c5af9 100644 --- a/src/plot/main.rs +++ b/src/plot/main.rs @@ -17,6 +17,8 @@ //! └─ labels: Option (optional, merged from LABEL clauses) //! ``` +pub use crate::labels::Labels; + use crate::naming; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -73,13 +75,6 @@ pub struct Plot { pub aesthetic_context: Option, } -/// Text labels (from LABELS clause) -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct Labels { - /// Label assignments (label type → text, None = suppress) - pub labels: HashMap>, -} - // Manual PartialEq implementation (aesthetic_context is derived, not compared) impl PartialEq for Plot { fn eq(&self, other: &Self) -> bool { @@ -238,12 +233,7 @@ impl Plot { let aesthetic_ctx = self.get_aesthetic_context(); // Ensure Labels struct exists - if self.labels.is_none() { - self.labels = Some(Labels { - labels: HashMap::new(), - }); - } - let labels = self.labels.as_mut().unwrap(); + let labels = self.labels.get_or_insert_with(Labels::default); // Two passes: first primaries, then variants // This ensures primaries always get priority regardless of HashMap iteration order @@ -261,7 +251,7 @@ impl Plot { } // Skip if label already set (user-specified or from earlier) - if labels.labels.contains_key(primary) { + if labels.contains(primary) { continue; } diff --git a/src/plot/scale/types.rs b/src/plot/scale/types.rs index 3d9786ea4..fcd6006dc 100644 --- a/src/plot/scale/types.rs +++ b/src/plot/scale/types.rs @@ -9,11 +9,6 @@ use super::super::types::{ArrayElement, ParameterValue, Parameters}; use super::scale_type::ScaleType; use super::transform::Transform; -/// Default label template - passes through values unchanged -fn default_label_template() -> String { - "{}".to_string() -} - /// One bin of a resolved binned scale: the edges bounding it and the text that /// names it. See [`Scale::binned_bins`]. #[derive(Debug, Clone, PartialEq)] @@ -78,7 +73,7 @@ pub struct Scale { /// Default is "{}" which passes through the value unchanged. /// The `{}` placeholder is replaced with each value at resolution time. /// Example: "{} units" -> {"0": "0 units", "25": "25 units", ...} - #[serde(default = "default_label_template")] + #[serde(default = "crate::format::default_template")] pub label_template: String, } diff --git a/src/plot/types.rs b/src/plot/types.rs index 41c511f03..edb61eddf 100644 --- a/src/plot/types.rs +++ b/src/plot/types.rs @@ -4,30 +4,11 @@ //! settings, and values. These are the building blocks used in AST types //! to capture what the user specified in their query. -use crate::naming; -use crate::reader::SqlDialect; +pub use crate::params::*; + use arrow::datatypes::DataType; -use chrono::{DateTime, Datelike, NaiveDate, NaiveDateTime, NaiveTime, Timelike}; use serde::{Deserialize, Serialize}; -use std::collections::{BTreeMap, HashMap}; - -// ============================================================================= -// Array Element Type (for coercion) -// ============================================================================= - -/// Type of an ArrayElement value, used for type inference and coercion. -/// -/// This enum represents the semantic type of values in a scale's input range, -/// allowing discrete scales to infer the target type from their domain values. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ArrayElementType { - String, - Number, - Boolean, - Date, - DateTime, - Time, -} +use std::collections::BTreeMap; // ============================================================================= // Schema Types (derived from input data) @@ -357,641 +338,6 @@ impl DefaultAestheticValue { } } -/// Value for geom parameters (also used for literals) -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub enum ParameterValue { - String(String), - Number(f64), - Boolean(bool), - Array(Vec), - /// Null value to explicitly opt out of a setting - Null, -} - -/// A collection of named parameter values, typically from `SET` clauses. -pub type Parameters = HashMap; - -impl std::fmt::Display for ParameterValue { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ParameterValue::String(s) => write!(f, "'{}'", s), - ParameterValue::Number(n) => write!(f, "{}", n), - ParameterValue::Boolean(b) => write!(f, "{}", b), - ParameterValue::Array(arr) => { - write!(f, "[")?; - for (i, elem) in arr.iter().enumerate() { - if i > 0 { - write!(f, ", ")?; - } - match elem { - ArrayElement::String(s) => write!(f, "'{}'", s)?, - ArrayElement::Number(n) => write!(f, "{}", n)?, - ArrayElement::Boolean(b) => write!(f, "{}", b)?, - ArrayElement::Null => write!(f, "null")?, - ArrayElement::Date(d) => write!(f, "'{}'", d)?, - ArrayElement::DateTime(dt) => write!(f, "'{}'", dt)?, - ArrayElement::Time(t) => write!(f, "'{}'", t)?, - } - } - write!(f, "]") - } - ParameterValue::Null => write!(f, "null"), - } - } -} - -/// Elements in arrays (shared type for property values) -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub enum ArrayElement { - String(String), - Number(f64), - Boolean(bool), - /// Null placeholder for partial input range inference (e.g., SCALE x FROM (0, null)) - Null, - /// Date value (days since Unix epoch 1970-01-01) - Date(i32), - /// DateTime value (microseconds since Unix epoch) - DateTime(i64), - /// Time value (nanoseconds since midnight) - Time(i64), -} - -/// Days from CE to Unix epoch (1970-01-01) -const UNIX_EPOCH_CE_DAYS: i32 = 719163; - -/// Convert days-since-epoch to ISO date string -fn date_to_iso_string(days: i32) -> String { - NaiveDate::from_num_days_from_ce_opt(days + UNIX_EPOCH_CE_DAYS) - .map(|d| d.format("%Y-%m-%d").to_string()) - .unwrap_or_else(|| days.to_string()) -} - -/// Convert microseconds-since-epoch to ISO datetime string -fn datetime_to_iso_string(micros: i64) -> String { - DateTime::from_timestamp_micros(micros) - .map(|dt| dt.format("%Y-%m-%dT%H:%M:%S").to_string()) - .unwrap_or_else(|| micros.to_string()) -} - -/// Convert nanoseconds-since-midnight to ISO time string -fn time_to_iso_string(nanos: i64) -> String { - let secs = (nanos / 1_000_000_000) as u32; - let nano_part = (nanos % 1_000_000_000) as u32; - NaiveTime::from_num_seconds_from_midnight_opt(secs, nano_part) - .map(|t| t.format("%H:%M:%S").to_string()) - .unwrap_or_else(|| format!("{}ns", nanos)) -} - -/// Format number for display (remove trailing zeros for integers) -pub fn format_number(n: f64) -> String { - if n.fract() == 0.0 { - format!("{:.0}", n) - } else { - n.to_string() - } -} - -/// Get type name for error messages -fn target_type_name(t: ArrayElementType) -> &'static str { - match t { - ArrayElementType::String => "string", - ArrayElementType::Number => "number", - ArrayElementType::Boolean => "boolean", - ArrayElementType::Date => "date", - ArrayElementType::DateTime => "datetime", - ArrayElementType::Time => "time", - } -} - -impl ArrayElement { - /// Get the type of this element. - /// - /// Returns None for Null values. - pub fn element_type(&self) -> Option { - match self { - Self::String(_) => Some(ArrayElementType::String), - Self::Number(_) => Some(ArrayElementType::Number), - Self::Boolean(_) => Some(ArrayElementType::Boolean), - Self::Date(_) => Some(ArrayElementType::Date), - Self::DateTime(_) => Some(ArrayElementType::DateTime), - Self::Time(_) => Some(ArrayElementType::Time), - Self::Null => None, - } - } - - /// Infer the dominant type from a collection of ArrayElements. - /// - /// Used by discrete scales to determine the target type from their input range. - /// Nulls are ignored. Returns None if all values are null or the slice is empty. - /// - /// If multiple types are present, uses priority: Boolean > Number > Date > DateTime > Time > String - /// (Boolean is highest because it's most specific; String is lowest as it's the fallback) - pub fn infer_type(values: &[ArrayElement]) -> Option { - let mut found_bool = false; - let mut found_number = false; - let mut found_date = false; - let mut found_datetime = false; - let mut found_time = false; - let mut found_string = false; - - for elem in values { - match elem { - Self::Boolean(_) => found_bool = true, - Self::Number(_) => found_number = true, - Self::Date(_) => found_date = true, - Self::DateTime(_) => found_datetime = true, - Self::Time(_) => found_time = true, - Self::String(_) => found_string = true, - Self::Null => {} - } - } - - // Priority order: most specific to least specific - if found_bool { - Some(ArrayElementType::Boolean) - } else if found_number { - Some(ArrayElementType::Number) - } else if found_date { - Some(ArrayElementType::Date) - } else if found_datetime { - Some(ArrayElementType::DateTime) - } else if found_time { - Some(ArrayElementType::Time) - } else if found_string { - Some(ArrayElementType::String) - } else { - None - } - } - - /// Coerce this element to the target type. - /// - /// Returns Ok with the coerced value, or Err with a description if coercion is impossible. - /// - /// Coercion paths: - /// - String → Boolean: "true"/"false"/"yes"/"no"/"1"/"0" (case-insensitive) - /// - String → Number: parse as f64 - /// - String → Date/DateTime/Time: parse ISO format - /// - Number → Boolean: 0 = false, non-zero = true - /// - Number → String: format as string - /// - Number → Date: interpret as days since Unix epoch - /// - Number → DateTime: interpret as microseconds since Unix epoch - /// - Number → Time: interpret as nanoseconds since midnight - /// - Boolean → Number: false = 0, true = 1 - /// - Boolean → String: "true"/"false" - /// - Null → any: stays Null - pub fn coerce_to(&self, target: ArrayElementType) -> Result { - // Already the right type? - if self.element_type() == Some(target) { - return Ok(self.clone()); - } - - // Null stays Null - if matches!(self, Self::Null) { - return Ok(Self::Null); - } - - match (self, target) { - // String → Boolean - (Self::String(s), ArrayElementType::Boolean) => match s.to_lowercase().as_str() { - "true" | "yes" | "1" => Ok(Self::Boolean(true)), - "false" | "no" | "0" => Ok(Self::Boolean(false)), - _ => Err(format!("Cannot coerce string '{}' to boolean", s)), - }, - - // String → Number - (Self::String(s), ArrayElementType::Number) => s - .parse::() - .map(Self::Number) - .map_err(|_| format!("Cannot coerce string '{}' to number", s)), - - // String → Date - (Self::String(s), ArrayElementType::Date) => { - Self::from_date_string(s).ok_or_else(|| { - format!("Cannot coerce string '{}' to date (expected YYYY-MM-DD)", s) - }) - } - - // String → DateTime - (Self::String(s), ArrayElementType::DateTime) => Self::from_datetime_string(s) - .ok_or_else(|| format!("Cannot coerce string '{}' to datetime", s)), - - // String → Time - (Self::String(s), ArrayElementType::Time) => Self::from_time_string(s) - .ok_or_else(|| format!("Cannot coerce string '{}' to time (expected HH:MM:SS)", s)), - - // String → String (identity, already handled above but for completeness) - (Self::String(s), ArrayElementType::String) => Ok(Self::String(s.clone())), - - // Number → Boolean - (Self::Number(n), ArrayElementType::Boolean) => Ok(Self::Boolean(*n != 0.0)), - - // Number → String - (Self::Number(n), ArrayElementType::String) => Ok(Self::String(format_number(*n))), - - // Number → Date (days since epoch) - (Self::Number(n), ArrayElementType::Date) => Ok(Self::Date(*n as i32)), - - // Number → DateTime (microseconds since epoch) - (Self::Number(n), ArrayElementType::DateTime) => Ok(Self::DateTime(*n as i64)), - - // Number → Time (nanoseconds since midnight) - (Self::Number(n), ArrayElementType::Time) => Ok(Self::Time(*n as i64)), - - // Boolean → Number - (Self::Boolean(b), ArrayElementType::Number) => { - Ok(Self::Number(if *b { 1.0 } else { 0.0 })) - } - - // Boolean → String - (Self::Boolean(b), ArrayElementType::String) => Ok(Self::String(b.to_string())), - - // Boolean → temporal types: not supported - (Self::Boolean(_), ArrayElementType::Date) - | (Self::Boolean(_), ArrayElementType::DateTime) - | (Self::Boolean(_), ArrayElementType::Time) => Err(format!( - "Cannot coerce boolean to {}", - target_type_name(target) - )), - - // Date → String - (Self::Date(d), ArrayElementType::String) => Ok(Self::String(date_to_iso_string(*d))), - - // Date → Number (days since epoch) - (Self::Date(d), ArrayElementType::Number) => Ok(Self::Number(*d as f64)), - - // DateTime → String - (Self::DateTime(dt), ArrayElementType::String) => { - Ok(Self::String(datetime_to_iso_string(*dt))) - } - - // DateTime → Number (microseconds since epoch) - (Self::DateTime(dt), ArrayElementType::Number) => Ok(Self::Number(*dt as f64)), - - // Time → String - (Self::Time(t), ArrayElementType::String) => Ok(Self::String(time_to_iso_string(*t))), - - // Time → Number (nanoseconds since midnight) - (Self::Time(t), ArrayElementType::Number) => Ok(Self::Number(*t as f64)), - - // Temporal → Boolean: not supported - (Self::Date(_), ArrayElementType::Boolean) - | (Self::DateTime(_), ArrayElementType::Boolean) - | (Self::Time(_), ArrayElementType::Boolean) => { - Err(format!("Cannot coerce {} to boolean", self.type_name())) - } - - // Cross-temporal conversions: not supported (lossy) - (Self::Date(_), ArrayElementType::DateTime) - | (Self::Date(_), ArrayElementType::Time) - | (Self::DateTime(_), ArrayElementType::Date) - | (Self::DateTime(_), ArrayElementType::Time) - | (Self::Time(_), ArrayElementType::Date) - | (Self::Time(_), ArrayElementType::DateTime) => Err(format!( - "Cannot coerce {} to {}", - self.type_name(), - target_type_name(target) - )), - - // Identity cases (already handled by early return, but needed for exhaustiveness) - (Self::Number(n), ArrayElementType::Number) => Ok(Self::Number(*n)), - (Self::Boolean(b), ArrayElementType::Boolean) => Ok(Self::Boolean(*b)), - (Self::Date(d), ArrayElementType::Date) => Ok(Self::Date(*d)), - (Self::DateTime(dt), ArrayElementType::DateTime) => Ok(Self::DateTime(*dt)), - (Self::Time(t), ArrayElementType::Time) => Ok(Self::Time(*t)), - - // Null cases are handled at the top - (Self::Null, _) => Ok(Self::Null), - } - } - - /// Homogenize a slice of array elements to a common type. - /// - /// Infers the target type from all elements, then attempts to coerce all elements - /// to that type. If coercion fails (e.g., string + number), falls back to String type. - /// - /// Returns a new vector with homogenized elements. - pub fn homogenize(values: &[Self]) -> Vec { - // Infer target type from all elements - let Some(target_type) = Self::infer_type(values) else { - // All nulls or empty array - return cloned as-is - return values.to_vec(); - }; - - // Try to coerce all elements to the inferred type - let coerced: Result, _> = values - .iter() - .map(|elem| elem.coerce_to(target_type)) - .collect(); - - match coerced { - Ok(coerced_arr) => coerced_arr, - Err(_) => { - // Coercion failed - fall back to String type - values - .iter() - .map(|elem| { - elem.coerce_to(ArrayElementType::String) - .unwrap_or(Self::Null) - }) - .collect() - } - } - } - - /// Convert this element to a SQL literal string. - /// - /// Used for generating SQL expressions from literal values. - pub fn to_sql(&self, dialect: &dyn SqlDialect) -> String { - match self { - Self::String(s) => naming::quote_literal(s), - Self::Number(n) => n.to_string(), - Self::Boolean(b) => dialect.sql_boolean_literal(*b), - Self::Date(d) => dialect.sql_date_literal(*d), - Self::DateTime(dt) => dialect.sql_datetime_literal(*dt), - Self::Time(t) => dialect.sql_time_literal(*t), - Self::Null => "NULL".to_string(), - } - } - - /// Get the type name for error messages. - fn type_name(&self) -> &'static str { - match self { - Self::String(_) => "string", - Self::Number(_) => "number", - Self::Boolean(_) => "boolean", - Self::Date(_) => "date", - Self::DateTime(_) => "datetime", - Self::Time(_) => "time", - Self::Null => "null", - } - } - - /// Convert to f64 for numeric calculations - pub fn to_f64(&self) -> Option { - match self { - Self::Number(n) => Some(*n), - Self::Date(d) => Some(*d as f64), - Self::DateTime(dt) => Some(*dt as f64), - Self::Time(t) => Some(*t as f64), - _ => None, - } - } - - /// Parse ISO date string "YYYY-MM-DD" to Date variant - pub fn from_date_string(s: &str) -> Option { - NaiveDate::parse_from_str(s, "%Y-%m-%d") - .ok() - .map(|d| Self::Date(d.num_days_from_ce() - UNIX_EPOCH_CE_DAYS)) - } - - /// Parse ISO datetime string to DateTime variant - /// - /// Supports timezone-aware formats: - /// - RFC3339: `2024-01-15T10:30:00Z`, `2024-01-15T10:30:00+05:30` - /// - With offset: `2024-01-15T10:30:00+0530` - /// - /// And timezone-naive formats (interpreted as UTC): - /// - `2024-01-15T10:30:00`, `2024-01-15T10:30:00.123` - /// - `2024-01-15 10:30:00` - pub fn from_datetime_string(s: &str) -> Option { - // Try RFC3339/ISO8601 with timezone first (e.g., "2024-01-15T10:30:00Z", "2024-01-15T10:30:00+05:30") - if let Ok(dt) = DateTime::parse_from_rfc3339(s) { - return Some(Self::DateTime(dt.timestamp_micros())); - } - - // Try formats with explicit timezone offset (non-RFC3339 variants) - for fmt in &[ - "%Y-%m-%dT%H:%M:%S%.f%:z", // 2024-01-15T10:30:00.123+05:30 - "%Y-%m-%dT%H:%M:%S%:z", // 2024-01-15T10:30:00+05:30 - "%Y-%m-%dT%H:%M:%S%.f%z", // 2024-01-15T10:30:00.123+0530 - "%Y-%m-%dT%H:%M:%S%z", // 2024-01-15T10:30:00+0530 - "%Y-%m-%d %H:%M:%S%:z", // 2024-01-15 10:30:00+05:30 - "%Y-%m-%d %H:%M:%S%z", // 2024-01-15 10:30:00+0530 - ] { - if let Ok(dt) = DateTime::parse_from_str(s, fmt) { - return Some(Self::DateTime(dt.timestamp_micros())); - } - } - - // Fall back to naive (timezone-unaware), assumed UTC - for fmt in &[ - "%Y-%m-%dT%H:%M:%S%.f", - "%Y-%m-%dT%H:%M:%S", - "%Y-%m-%d %H:%M:%S", - ] { - if let Ok(dt) = NaiveDateTime::parse_from_str(s, fmt) { - return Some(Self::DateTime(dt.and_utc().timestamp_micros())); - } - } - None - } - - /// Parse ISO time string "HH:MM:SS[.sss]" to Time variant - pub fn from_time_string(s: &str) -> Option { - for fmt in &["%H:%M:%S%.f", "%H:%M:%S", "%H:%M"] { - if let Ok(t) = NaiveTime::parse_from_str(s, fmt) { - // Convert to nanoseconds since midnight - let nanos = - t.num_seconds_from_midnight() as i64 * 1_000_000_000 + t.nanosecond() as i64; - return Some(Self::Time(nanos)); - } - } - None - } - - /// Try to parse string as temporal type (Date/DateTime/Time). - /// - /// If this is a String variant, attempts to parse it as a temporal type - /// in order of specificity: DateTime > Date > Time. - /// If parsing succeeds, returns the temporal variant. Otherwise returns self unchanged. - /// - /// For non-String variants, returns self unchanged. - /// - /// # Example - /// ```ignore - /// let elem = ArrayElement::String("1973-06-01".to_string()); - /// let parsed = elem.try_as_temporal(); - /// // parsed is now ArrayElement::Date(...) - /// ``` - pub fn try_as_temporal(self) -> Self { - if let Self::String(ref s) = self { - // Try DateTime first (most specific) - if let Some(dt) = Self::from_datetime_string(s) { - return dt; - } - // Try Date - if let Some(d) = Self::from_date_string(s) { - return d; - } - // Try Time - if let Some(t) = Self::from_time_string(s) { - return t; - } - } - // Fall back to original value if not a string or parsing failed - self - } - - /// Convert to string for HashMap keys and display - pub fn to_key_string(&self) -> String { - match self { - Self::String(s) => s.clone(), - Self::Number(n) => format_number(*n), - Self::Boolean(b) => b.to_string(), - Self::Null => "null".to_string(), - Self::Date(d) => date_to_iso_string(*d), - Self::DateTime(dt) => datetime_to_iso_string(*dt), - Self::Time(t) => time_to_iso_string(*t), - } - } - - /// Convert to a serde_json::Value - pub fn to_json(&self) -> serde_json::Value { - match self { - ArrayElement::String(s) => serde_json::Value::String(s.clone()), - ArrayElement::Number(n) => serde_json::json!(n), - ArrayElement::Boolean(b) => serde_json::Value::Bool(*b), - ArrayElement::Null => serde_json::Value::Null, - // Temporal types serialize as ISO strings for JSON - ArrayElement::Date(d) => serde_json::Value::String(date_to_iso_string(*d)), - ArrayElement::DateTime(dt) => serde_json::Value::String(datetime_to_iso_string(*dt)), - ArrayElement::Time(t) => serde_json::Value::String(time_to_iso_string(*t)), - } - } - - /// Convert Date (days since epoch) to ISO string "YYYY-MM-DD" - pub fn date_to_iso(days: i32) -> String { - date_to_iso_string(days) - } - - /// Convert DateTime (microseconds since epoch) to ISO string - pub fn datetime_to_iso(micros: i64) -> String { - datetime_to_iso_string(micros) - } - - /// Convert Time (nanoseconds since midnight) to ISO string "HH:MM:SS" - pub fn time_to_iso(nanos: i64) -> String { - time_to_iso_string(nanos) - } -} - -impl ParameterValue { - /// Convert to a serde_json::Value - pub fn to_json(&self) -> serde_json::Value { - match self { - ParameterValue::String(s) => serde_json::Value::String(s.clone()), - ParameterValue::Number(n) => serde_json::json!(n), - ParameterValue::Boolean(b) => serde_json::Value::Bool(*b), - ParameterValue::Array(arr) => { - serde_json::Value::Array(arr.iter().map(|e| e.to_json()).collect()) - } - ParameterValue::Null => serde_json::Value::Null, - } - } - - /// Check if this is a null value - pub fn is_null(&self) -> bool { - matches!(self, ParameterValue::Null) - } - - /// Try to extract as a string value - pub fn as_str(&self) -> Option<&str> { - match self { - ParameterValue::String(s) => Some(s), - _ => None, - } - } - - /// Try to extract as a number value - pub fn as_number(&self) -> Option { - match self { - ParameterValue::Number(n) => Some(*n), - _ => None, - } - } - - /// Try to extract as a boolean value - pub fn as_bool(&self) -> Option { - match self { - ParameterValue::Boolean(b) => Some(*b), - _ => None, - } - } - - /// Try to extract as an array value - pub fn as_array(&self) -> Option<&[ArrayElement]> { - match self { - ParameterValue::Array(arr) => Some(arr), - _ => None, - } - } - - /// Convert this parameter value to a SQL literal string. - /// - /// Only supports scalar values (String, Number, Boolean, Null). - /// Arrays are handled separately in annotation layer VALUES clause generation. - pub fn to_sql(&self, dialect: &dyn SqlDialect) -> String { - match self { - ParameterValue::String(s) => naming::quote_literal(s), - ParameterValue::Number(n) => n.to_string(), - ParameterValue::Boolean(b) => dialect.sql_boolean_literal(*b), - ParameterValue::Array(_) => { - panic!("ParameterValue::to_sql() does not support arrays. Arrays in annotation layers should be handled via VALUES clause generation.") - } - ParameterValue::Null => "NULL".to_string(), - } - } - - /// Convert a scalar ParameterValue to an ArrayElement. - /// - /// Panics if called on an Array variant. - fn to_array_element(&self) -> ArrayElement { - match self { - ParameterValue::Number(num) => ArrayElement::Number(*num), - ParameterValue::String(s) => ArrayElement::String(s.clone()), - ParameterValue::Boolean(b) => ArrayElement::Boolean(*b), - ParameterValue::Null => ArrayElement::Null, - ParameterValue::Array(_) => panic!("Cannot convert Array to single ArrayElement"), - } - } - - /// Recycle this value to a target array length. - /// - /// - Scalars (String, Number, Boolean, Null) are converted to arrays with n copies - /// - Arrays of length 1 are recycled to n copies of that element - /// - Arrays of target length are returned as-is (after homogenization) - /// - Arrays of other lengths produce an error - pub fn rep(self, n: usize) -> Result { - match self { - // Arrays: homogenize types if mixed, then recycle if needed - ParameterValue::Array(arr) => { - if arr.len() == 1 { - // Recycle the single element - let element = arr[0].clone(); - Ok(ParameterValue::Array(vec![element; n])) - } else if arr.len() == n { - // Already correct length - homogenize for type consistency - let arr = ArrayElement::homogenize(&arr); - Ok(ParameterValue::Array(arr)) - } else { - // Mismatched length - shouldn't happen if validation passed - Err(crate::GgsqlError::InternalError(format!( - "Attempted to recycle array of length {} to length {} (should have been caught earlier)", - arr.len(), - n - ))) - } - } - // Scalars: convert to ArrayElement and replicate n times - scalar => { - let elem = scalar.to_array_element(); - Ok(ParameterValue::Array(vec![elem; n])) - } - } - } -} - // ============================================================================= // SQL Expression Type // ============================================================================= @@ -1052,1420 +398,3 @@ impl SqlExpression { self.0 } } - -// ============================================================================= -// Default Property Types (Shared by Coord, Scale, and Geom traits) -// ============================================================================= - -/// Default value for a property parameter -/// -/// Used by traits to declare both allowed property names and their default values -/// in a single declaration, avoiding the need to keep two separate implementations -/// in sync. -#[derive(Debug, Clone)] -pub enum DefaultParamValue { - String(&'static str), - Number(f64), - Boolean(bool), - Null, -} - -// ============================================================================= -// Parameter Constraint Types -// ============================================================================= - -/// Constraint state for a specific type -#[derive(Debug, Clone, Copy)] -pub enum TypeConstraint { - /// Type not allowed for this parameter - Forbidden, - /// Type allowed, any value accepted - Any, - /// Type allowed with specific constraints - Constrained(T), -} - -/// Constraint for Number values -#[derive(Debug, Clone, Copy)] -pub struct NumberConstraint { - pub min: Option, - pub max: Option, - pub min_exclusive: bool, - pub max_exclusive: bool, - pub whole: bool, -} - -impl NumberConstraint { - /// No constraints on value - pub const fn unconstrained() -> Self { - Self { - min: None, - max: None, - min_exclusive: false, - max_exclusive: false, - whole: false, - } - } - - /// Minimum value (inclusive) - pub const fn min(min: f64) -> Self { - Self { - min: Some(min), - max: None, - min_exclusive: false, - max_exclusive: false, - whole: false, - } - } - - /// Minimum value (exclusive) - pub const fn min_exclusive(min: f64) -> Self { - Self { - min: Some(min), - max: None, - min_exclusive: true, - max_exclusive: false, - whole: false, - } - } - - /// Value within range (inclusive on both ends) - pub const fn range(min: f64, max: f64) -> Self { - Self { - min: Some(min), - max: Some(max), - min_exclusive: false, - max_exclusive: false, - whole: false, - } - } - - /// Whole number (integer) with minimum value (inclusive) - pub const fn count(min: f64) -> Self { - Self { - min: Some(min), - max: None, - min_exclusive: false, - max_exclusive: false, - whole: true, - } - } - - /// Whole number (integer) within range (inclusive on both ends) - pub const fn count_range(min: f64, max: f64) -> Self { - Self { - min: Some(min), - max: Some(max), - min_exclusive: false, - max_exclusive: false, - whole: true, - } - } -} - -/// Constraint for String values -#[derive(Debug, Clone, Copy)] -pub struct StringConstraint { - /// String must be one of these values (empty = any string allowed) - pub allowed_values: &'static [&'static str], -} - -impl StringConstraint { - /// Any string allowed (empty slice = no restriction) - pub const fn unconstrained() -> Self { - Self { - allowed_values: &[], - } - } - - /// String must be one of the specified values - pub const fn one_of(values: &'static [&'static str]) -> Self { - Self { - allowed_values: values, - } - } -} - -/// Element constraint - specifies type AND value constraints for array elements -#[derive(Debug, Clone, Copy)] -pub enum ArrayElementConstraint { - /// Any element type allowed, no constraints - Any, - /// Must be numbers, with optional value constraint - Number(NumberConstraint), - /// Must be strings, with optional value constraint - String(StringConstraint), - /// Must be booleans - Boolean, -} - -/// Constraint for Array values -#[derive(Debug, Clone, Copy)] -pub struct ArrayConstraint { - pub element: ArrayElementConstraint, - pub min_len: Option, - pub max_len: Option, - pub allow_null_elements: bool, -} - -impl ArrayConstraint { - /// Array of numbers with value constraint - pub const fn of_numbers(constraint: NumberConstraint) -> Self { - Self { - element: ArrayElementConstraint::Number(constraint), - min_len: None, - max_len: None, - allow_null_elements: false, - } - } - - /// Array of numbers with exact length and value constraint - pub const fn of_numbers_len(constraint: NumberConstraint, len: usize) -> Self { - Self { - element: ArrayElementConstraint::Number(constraint), - min_len: Some(len), - max_len: Some(len), - allow_null_elements: false, - } - } - - /// Array of strings with value constraint - pub const fn of_strings(constraint: StringConstraint) -> Self { - Self { - element: ArrayElementConstraint::String(constraint), - min_len: None, - max_len: None, - allow_null_elements: false, - } - } - - /// Array of strings with exact length and value constraint - #[allow(dead_code)] - pub const fn of_strings_len(constraint: StringConstraint, len: usize) -> Self { - Self { - element: ArrayElementConstraint::String(constraint), - min_len: Some(len), - max_len: Some(len), - allow_null_elements: false, - } - } - - /// Any element types allowed (including nulls) - #[allow(dead_code)] - pub const fn any_elements() -> Self { - Self { - element: ArrayElementConstraint::Any, - min_len: None, - max_len: None, - allow_null_elements: true, - } - } - - /// Builder method to allow null elements - #[allow(dead_code)] - pub const fn with_null_elements(mut self) -> Self { - self.allow_null_elements = true; - self - } -} - -/// Validation constraint for a parameter value. -/// -/// Each field specifies whether a type is forbidden, allowed (any value), or constrained. -/// Used by `ParamDefinition` to specify validation rules for parameters. -#[derive(Debug, Clone, Copy)] -pub struct ParamConstraint { - pub number: TypeConstraint, - pub string: TypeConstraint, - pub boolean: TypeConstraint<()>, - pub array: TypeConstraint, - pub allow_null: bool, -} - -impl ParamConstraint { - /// All types allowed, no constraints (default) - pub const fn unconstrained() -> Self { - Self { - number: TypeConstraint::Any, - string: TypeConstraint::Any, - boolean: TypeConstraint::Any, - array: TypeConstraint::Any, - allow_null: true, - } - } - - /// Number only with constraint - pub const fn number(constraint: NumberConstraint) -> Self { - Self { - number: TypeConstraint::Constrained(constraint), - string: TypeConstraint::Forbidden, - boolean: TypeConstraint::Forbidden, - array: TypeConstraint::Forbidden, - allow_null: true, - } - } - - /// Number only, any value - #[allow(dead_code)] - pub const fn number_any() -> Self { - Self { - number: TypeConstraint::Any, - string: TypeConstraint::Forbidden, - boolean: TypeConstraint::Forbidden, - array: TypeConstraint::Forbidden, - allow_null: true, - } - } - - /// String only with enum constraint - pub const fn string_option(values: &'static [&'static str]) -> Self { - Self { - number: TypeConstraint::Forbidden, - string: TypeConstraint::Constrained(StringConstraint::one_of(values)), - boolean: TypeConstraint::Forbidden, - array: TypeConstraint::Forbidden, - allow_null: true, - } - } - - /// String only - pub const fn string() -> Self { - Self { - number: TypeConstraint::Forbidden, - string: TypeConstraint::Any, - boolean: TypeConstraint::Forbidden, - array: TypeConstraint::Forbidden, - allow_null: true, - } - } - - /// Boolean only - pub const fn boolean() -> Self { - Self { - number: TypeConstraint::Forbidden, - string: TypeConstraint::Forbidden, - boolean: TypeConstraint::Any, - array: TypeConstraint::Forbidden, - allow_null: true, - } - } - - /// Number or Array of numbers - for `expand` parameter - pub const fn number_or_numeric_array(num: NumberConstraint, arr: ArrayConstraint) -> Self { - Self { - number: TypeConstraint::Constrained(num), - string: TypeConstraint::Forbidden, - boolean: TypeConstraint::Forbidden, - array: TypeConstraint::Constrained(arr), - allow_null: true, - } - } - - /// Number, Array of numbers, or String (any) - for `breaks` parameter - pub const fn number_or_array_or_string(num: NumberConstraint, arr: ArrayConstraint) -> Self { - Self { - number: TypeConstraint::Constrained(num), - string: TypeConstraint::Any, // Any string (temporal interval validated elsewhere) - boolean: TypeConstraint::Forbidden, - array: TypeConstraint::Constrained(arr), - allow_null: true, - } - } - - /// String enum or Array of strings from same enum - for `free` parameter - #[allow(dead_code)] - pub const fn string_or_string_array(values: &'static [&'static str]) -> Self { - Self { - number: TypeConstraint::Forbidden, - string: TypeConstraint::Constrained(StringConstraint::one_of(values)), - boolean: TypeConstraint::Forbidden, - array: TypeConstraint::Constrained(ArrayConstraint::of_strings( - StringConstraint::one_of(values), - )), - allow_null: true, - } - } - - /// Any string or array of any strings (with null elements allowed) — for - /// the `aggregate` parameter. Per-entry vocabulary is checked downstream - /// by `stat_aggregate::parse_aggregate_param`; this constraint just pins - /// the structural shape (string / array-of-strings / null). - pub const fn string_or_string_array_unconstrained() -> Self { - Self { - number: TypeConstraint::Forbidden, - string: TypeConstraint::Any, - boolean: TypeConstraint::Forbidden, - array: TypeConstraint::Constrained(ArrayConstraint { - element: ArrayElementConstraint::String(StringConstraint::unconstrained()), - min_len: None, - max_len: None, - allow_null_elements: true, - }), - allow_null: true, - } - } - - /// Builder method to disallow null values - #[allow(dead_code)] - pub const fn required(mut self) -> Self { - self.allow_null = false; - self - } - - // ========================================================================= - // Convenience constructors - // ========================================================================= - - /// Create a constraint requiring a minimum value (inclusive) - pub const fn number_min(min: f64) -> Self { - Self::number(NumberConstraint::min(min)) - } - - /// Create a constraint requiring a minimum value (exclusive) - pub const fn number_min_exclusive(min: f64) -> Self { - Self::number(NumberConstraint::min_exclusive(min)) - } - - /// Create a constraint requiring a value within a range (inclusive on both ends) - pub const fn number_range(min: f64, max: f64) -> Self { - Self::number(NumberConstraint::range(min, max)) - } - - /// Create a constraint requiring a whole number (integer) with minimum value - pub const fn count(min: f64) -> Self { - Self::number(NumberConstraint::count(min)) - } - - /// Create a constraint requiring a whole number (integer) within a range - pub const fn count_range(min: f64, max: f64) -> Self { - Self::number(NumberConstraint::count_range(min, max)) - } -} - -/// Validate a parameter value against a constraint. -/// -/// Returns Ok(()) if the value passes validation, or Err with a descriptive -/// error message if validation fails. -pub fn validate_parameter( - param_name: &str, - value: &ParameterValue, - constraint: &ParamConstraint, -) -> Result<(), String> { - match value { - ParameterValue::Number(n) => match &constraint.number { - TypeConstraint::Forbidden => { - Err(type_not_allowed_error(param_name, "Number", constraint)) - } - TypeConstraint::Any => Ok(()), - TypeConstraint::Constrained(c) => validate_number(param_name, *n, c), - }, - ParameterValue::String(s) => match &constraint.string { - TypeConstraint::Forbidden => { - Err(type_not_allowed_error(param_name, "String", constraint)) - } - TypeConstraint::Any => Ok(()), - TypeConstraint::Constrained(c) => validate_string(param_name, s, c), - }, - ParameterValue::Boolean(_) => match &constraint.boolean { - TypeConstraint::Forbidden => { - Err(type_not_allowed_error(param_name, "Boolean", constraint)) - } - TypeConstraint::Any | TypeConstraint::Constrained(()) => Ok(()), - }, - ParameterValue::Array(arr) => match &constraint.array { - TypeConstraint::Forbidden => { - Err(type_not_allowed_error(param_name, "Array", constraint)) - } - TypeConstraint::Any => Ok(()), - TypeConstraint::Constrained(c) => validate_array(param_name, arr, c), - }, - ParameterValue::Null => { - if constraint.allow_null { - Ok(()) - } else { - Err(format!( - "Parameter '{}' is required (cannot be null)", - param_name - )) - } - } - } -} - -fn validate_number(name: &str, n: f64, c: &NumberConstraint) -> Result<(), String> { - // Check whole number constraint first - if c.whole && n.fract() != 0.0 { - return Err(format!("'{}' should be a whole number, not {}", name, n)); - } - if let Some(min) = c.min { - let ok = if c.min_exclusive { n > min } else { n >= min }; - if !ok { - let op = if c.min_exclusive { ">" } else { ">=" }; - return Err(format!("'{}' should be {} {}, not {}", name, op, min, n)); - } - } - if let Some(max) = c.max { - let ok = if c.max_exclusive { n < max } else { n <= max }; - if !ok { - let op = if c.max_exclusive { "<" } else { "<=" }; - return Err(format!("'{}' should be {} {}, not {}", name, op, max, n)); - } - } - Ok(()) -} - -fn validate_string(name: &str, s: &str, c: &StringConstraint) -> Result<(), String> { - // Empty allowed_values = unconstrained (any string) - if c.allowed_values.is_empty() { - return Ok(()); - } - if !c.allowed_values.contains(&s) { - return Err(format!( - "'{}' should be {}, not '{}'", - name, - crate::or_list_quoted(c.allowed_values, '\''), - s - )); - } - Ok(()) -} - -fn validate_array(name: &str, arr: &[ArrayElement], c: &ArrayConstraint) -> Result<(), String> { - // Length validation - let len = arr.len(); - match (c.min_len, c.max_len) { - // Exact length required - (Some(min), Some(max)) if min == max && len != min => { - return Err(format!( - "Parameter '{}' array must have exactly {} element(s) (got {})", - name, min, len - )); - } - // Range constraint (both bounds, not equal) - (Some(min), Some(max)) if len < min || len > max => { - return Err(format!( - "Parameter '{}' array must have between {} and {} element(s) (got {})", - name, min, max, len - )); - } - // One-sided constraints - (Some(min), None) if len < min => { - return Err(format!( - "Parameter '{}' array must have at least {} element(s) (got {})", - name, min, len - )); - } - (None, Some(max)) if len > max => { - return Err(format!( - "Parameter '{}' array must have at most {} element(s) (got {})", - name, max, len - )); - } - _ => {} - } - // Element type and value validation - collect all errors - let mut errors: Vec = Vec::new(); - for (i, elem) in arr.iter().enumerate() { - match (&c.element, elem) { - // Handle null elements - (_, ArrayElement::Null) if c.allow_null_elements => {} - (_, ArrayElement::Null) => { - errors.push(format!("'{}[{}]' cannot be null", name, i)); - } - // Any element type allowed - (ArrayElementConstraint::Any, _) => {} - // Number elements - (ArrayElementConstraint::Number(nc), ArrayElement::Number(n)) => { - if let Err(e) = validate_number(&format!("{}[{}]", name, i), *n, nc) { - errors.push(e); - } - } - (ArrayElementConstraint::Number(_), _) => { - errors.push(format!("'{}[{}]' must be a number", name, i)); - } - // String elements - (ArrayElementConstraint::String(sc), ArrayElement::String(s)) => { - // Only validate if allowed_values is non-empty (empty = any string) - if !sc.allowed_values.is_empty() { - if let Err(e) = validate_string(&format!("{}[{}]", name, i), s, sc) { - errors.push(e); - } - } - } - (ArrayElementConstraint::String(_), _) => { - errors.push(format!("'{}[{}]' must be a string", name, i)); - } - // Boolean elements - (ArrayElementConstraint::Boolean, ArrayElement::Boolean(_)) => {} - (ArrayElementConstraint::Boolean, _) => { - errors.push(format!("'{}[{}]' must be a boolean", name, i)); - } - } - } - if errors.is_empty() { - Ok(()) - } else { - Err(format!( - "Parameter '{}' has invalid elements:\n— {}", - name, - errors.join("\n— ") - )) - } -} - -fn type_not_allowed_error(name: &str, got: &str, c: &ParamConstraint) -> String { - let mut allowed = Vec::new(); - if !matches!(c.number, TypeConstraint::Forbidden) { - allowed.push("Number"); - } - if !matches!(c.string, TypeConstraint::Forbidden) { - allowed.push("String"); - } - if !matches!(c.boolean, TypeConstraint::Forbidden) { - allowed.push("Boolean"); - } - if !matches!(c.array, TypeConstraint::Forbidden) { - allowed.push("Array"); - } - format!( - "'{}' should be {}, not {}", - name, - crate::or_list(&allowed), - got - ) -} - -/// Property definition: name, default value, and validation constraint. -/// -/// Used by `CoordTrait`, `ScaleTypeTrait`, and `GeomTrait` to declare their -/// allowed properties and default values in a single place. -#[derive(Debug, Clone)] -pub struct ParamDefinition { - pub name: &'static str, - pub default: DefaultParamValue, - pub constraint: ParamConstraint, -} - -impl ParamDefinition { - /// Convert the default value to a ParameterValue, if not Null - pub fn to_parameter_value(&self) -> Option { - match &self.default { - DefaultParamValue::String(s) => Some(ParameterValue::String(s.to_string())), - DefaultParamValue::Number(n) => Some(ParameterValue::Number(*n)), - DefaultParamValue::Boolean(b) => Some(ParameterValue::Boolean(*b)), - DefaultParamValue::Null => None, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_date_from_string() { - let elem = ArrayElement::from_date_string("2024-01-15").unwrap(); - assert!(matches!(elem, ArrayElement::Date(_))); - assert_eq!(elem.to_key_string(), "2024-01-15"); - } - - #[test] - fn test_date_from_string_roundtrip() { - // Test that parsing and converting back produces the same date - let original = "2024-06-30"; - let elem = ArrayElement::from_date_string(original).unwrap(); - assert_eq!(elem.to_key_string(), original); - } - - #[test] - fn test_datetime_from_string() { - let elem = ArrayElement::from_datetime_string("2024-01-15T10:30:00").unwrap(); - assert!(matches!(elem, ArrayElement::DateTime(_))); - assert!(elem.to_key_string().starts_with("2024-01-15T10:30:00")); - } - - #[test] - fn test_datetime_from_string_with_space() { - let elem = ArrayElement::from_datetime_string("2024-01-15 10:30:00").unwrap(); - assert!(matches!(elem, ArrayElement::DateTime(_))); - } - - #[test] - fn test_datetime_from_string_with_z() { - // UTC timezone indicator - let elem = ArrayElement::from_datetime_string("2024-01-15T10:30:00Z").unwrap(); - assert!(matches!(elem, ArrayElement::DateTime(_))); - assert_eq!(elem.to_key_string(), "2024-01-15T10:30:00"); - } - - #[test] - fn test_datetime_from_string_with_positive_offset() { - // +05:30 offset (e.g., India Standard Time) - // 10:30 IST = 05:00 UTC - let elem = ArrayElement::from_datetime_string("2024-01-15T10:30:00+05:30").unwrap(); - assert!(matches!(elem, ArrayElement::DateTime(_))); - assert_eq!(elem.to_key_string(), "2024-01-15T05:00:00"); - } - - #[test] - fn test_datetime_from_string_with_negative_offset() { - // -08:00 offset (e.g., Pacific Standard Time) - // 10:30 PST = 18:30 UTC - let elem = ArrayElement::from_datetime_string("2024-01-15T10:30:00-08:00").unwrap(); - assert!(matches!(elem, ArrayElement::DateTime(_))); - assert_eq!(elem.to_key_string(), "2024-01-15T18:30:00"); - } - - #[test] - fn test_datetime_from_string_with_zero_offset() { - // Explicit +00:00 (same as Z) - let elem = ArrayElement::from_datetime_string("2024-01-15T10:30:00+00:00").unwrap(); - assert!(matches!(elem, ArrayElement::DateTime(_))); - assert_eq!(elem.to_key_string(), "2024-01-15T10:30:00"); - } - - #[test] - fn test_datetime_from_string_with_fractional_and_tz() { - // Fractional seconds with timezone - let elem = ArrayElement::from_datetime_string("2024-01-15T10:30:00.123Z").unwrap(); - assert!(matches!(elem, ArrayElement::DateTime(_))); - } - - #[test] - fn test_time_from_string() { - let elem = ArrayElement::from_time_string("14:30:00").unwrap(); - assert!(matches!(elem, ArrayElement::Time(_))); - assert_eq!(elem.to_key_string(), "14:30:00"); - } - - #[test] - fn test_time_from_string_with_millis() { - let elem = ArrayElement::from_time_string("14:30:00.123").unwrap(); - assert!(matches!(elem, ArrayElement::Time(_))); - } - - #[test] - fn test_time_from_string_short() { - let elem = ArrayElement::from_time_string("14:30").unwrap(); - assert!(matches!(elem, ArrayElement::Time(_))); - assert_eq!(elem.to_key_string(), "14:30:00"); - } - - #[test] - fn test_date_to_f64() { - // 2024-01-15 is roughly 19738 days since epoch (1970-01-01) - let elem = ArrayElement::from_date_string("2024-01-15").unwrap(); - let days = elem.to_f64().unwrap(); - // Verify the date is in a reasonable range - assert!(days > 19000.0 && days < 20000.0); - } - - #[test] - fn test_time_to_f64() { - let elem = ArrayElement::from_time_string("12:00:00").unwrap(); - let nanos = elem.to_f64().unwrap(); - // 12 hours = 12 * 60 * 60 * 1_000_000_000 nanoseconds - assert_eq!(nanos, 43_200_000_000_000.0); - } - - #[test] - fn test_date_to_json() { - let elem = ArrayElement::from_date_string("2024-01-15").unwrap(); - let json = elem.to_json(); - assert_eq!(json, serde_json::json!("2024-01-15")); - } - - #[test] - fn test_datetime_to_json() { - let elem = ArrayElement::from_datetime_string("2024-01-15T10:30:00").unwrap(); - let json = elem.to_json(); - // Datetime serializes as ISO string - assert!(json.is_string()); - assert!(json.as_str().unwrap().starts_with("2024-01-15T10:30:00")); - } - - #[test] - fn test_time_to_json() { - let elem = ArrayElement::from_time_string("14:30:00").unwrap(); - let json = elem.to_json(); - assert_eq!(json, serde_json::json!("14:30:00")); - } - - #[test] - fn test_number_to_f64() { - let elem = ArrayElement::Number(42.5); - assert_eq!(elem.to_f64(), Some(42.5)); - } - - #[test] - fn test_string_to_f64_returns_none() { - let elem = ArrayElement::String("hello".to_string()); - assert_eq!(elem.to_f64(), None); - } - - #[test] - fn test_to_key_string_number_integer() { - let elem = ArrayElement::Number(25.0); - assert_eq!(elem.to_key_string(), "25"); - } - - #[test] - fn test_to_key_string_number_decimal() { - let elem = ArrayElement::Number(25.5); - assert_eq!(elem.to_key_string(), "25.5"); - } - - #[test] - fn test_invalid_date_returns_none() { - assert!(ArrayElement::from_date_string("not-a-date").is_none()); - assert!(ArrayElement::from_date_string("2024/01/15").is_none()); - } - - #[test] - fn test_invalid_time_returns_none() { - assert!(ArrayElement::from_time_string("not-a-time").is_none()); - assert!(ArrayElement::from_time_string("25:00:00").is_none()); - } - - // ============================================================================= - // ArrayElementType tests - // ============================================================================= - - #[test] - fn test_element_type() { - assert_eq!( - ArrayElement::String("hello".to_string()).element_type(), - Some(ArrayElementType::String) - ); - assert_eq!( - ArrayElement::Number(42.0).element_type(), - Some(ArrayElementType::Number) - ); - assert_eq!( - ArrayElement::Boolean(true).element_type(), - Some(ArrayElementType::Boolean) - ); - assert_eq!( - ArrayElement::Date(100).element_type(), - Some(ArrayElementType::Date) - ); - assert_eq!( - ArrayElement::DateTime(1000000).element_type(), - Some(ArrayElementType::DateTime) - ); - assert_eq!( - ArrayElement::Time(1000000000).element_type(), - Some(ArrayElementType::Time) - ); - assert_eq!(ArrayElement::Null.element_type(), None); - } - - #[test] - fn test_infer_type_boolean() { - let values = vec![ArrayElement::Boolean(true), ArrayElement::Boolean(false)]; - assert_eq!( - ArrayElement::infer_type(&values), - Some(ArrayElementType::Boolean) - ); - } - - #[test] - fn test_infer_type_number() { - let values = vec![ArrayElement::Number(1.0), ArrayElement::Number(2.0)]; - assert_eq!( - ArrayElement::infer_type(&values), - Some(ArrayElementType::Number) - ); - } - - #[test] - fn test_infer_type_string() { - let values = vec![ - ArrayElement::String("a".to_string()), - ArrayElement::String("b".to_string()), - ]; - assert_eq!( - ArrayElement::infer_type(&values), - Some(ArrayElementType::String) - ); - } - - #[test] - fn test_infer_type_date() { - let values = vec![ArrayElement::Date(100), ArrayElement::Date(200)]; - assert_eq!( - ArrayElement::infer_type(&values), - Some(ArrayElementType::Date) - ); - } - - #[test] - fn test_infer_type_with_nulls() { - let values = vec![ - ArrayElement::Null, - ArrayElement::Boolean(true), - ArrayElement::Null, - ]; - assert_eq!( - ArrayElement::infer_type(&values), - Some(ArrayElementType::Boolean) - ); - } - - #[test] - fn test_infer_type_all_nulls() { - let values = vec![ArrayElement::Null, ArrayElement::Null]; - assert_eq!(ArrayElement::infer_type(&values), None); - } - - #[test] - fn test_infer_type_empty() { - let values: Vec = vec![]; - assert_eq!(ArrayElement::infer_type(&values), None); - } - - #[test] - fn test_infer_type_priority_boolean_over_string() { - // If there are mixed types, Boolean has priority over String - let values = vec![ - ArrayElement::Boolean(true), - ArrayElement::String("hello".to_string()), - ]; - assert_eq!( - ArrayElement::infer_type(&values), - Some(ArrayElementType::Boolean) - ); - } - - #[test] - fn test_infer_type_priority_number_over_string() { - let values = vec![ - ArrayElement::Number(42.0), - ArrayElement::String("hello".to_string()), - ]; - assert_eq!( - ArrayElement::infer_type(&values), - Some(ArrayElementType::Number) - ); - } - - // ============================================================================= - // coerce_to tests - // ============================================================================= - - #[test] - fn test_coerce_string_to_boolean_true() { - let elem = ArrayElement::String("true".to_string()); - let result = elem.coerce_to(ArrayElementType::Boolean).unwrap(); - assert_eq!(result, ArrayElement::Boolean(true)); - - // Also test case insensitivity - let elem = ArrayElement::String("TRUE".to_string()); - let result = elem.coerce_to(ArrayElementType::Boolean).unwrap(); - assert_eq!(result, ArrayElement::Boolean(true)); - } - - #[test] - fn test_coerce_string_to_boolean_false() { - let elem = ArrayElement::String("false".to_string()); - let result = elem.coerce_to(ArrayElementType::Boolean).unwrap(); - assert_eq!(result, ArrayElement::Boolean(false)); - - let elem = ArrayElement::String("no".to_string()); - let result = elem.coerce_to(ArrayElementType::Boolean).unwrap(); - assert_eq!(result, ArrayElement::Boolean(false)); - } - - #[test] - fn test_coerce_string_to_boolean_error() { - let elem = ArrayElement::String("maybe".to_string()); - let result = elem.coerce_to(ArrayElementType::Boolean); - assert!(result.is_err()); - assert!(result - .unwrap_err() - .contains("Cannot coerce string 'maybe' to boolean")); - } - - #[test] - fn test_coerce_string_to_number() { - let elem = ArrayElement::String("42.5".to_string()); - let result = elem.coerce_to(ArrayElementType::Number).unwrap(); - assert_eq!(result, ArrayElement::Number(42.5)); - } - - #[test] - fn test_coerce_string_to_number_error() { - let elem = ArrayElement::String("not a number".to_string()); - let result = elem.coerce_to(ArrayElementType::Number); - assert!(result.is_err()); - } - - #[test] - fn test_coerce_string_to_date() { - let elem = ArrayElement::String("2024-01-15".to_string()); - let result = elem.coerce_to(ArrayElementType::Date).unwrap(); - assert!(matches!(result, ArrayElement::Date(_))); - assert_eq!(result.to_key_string(), "2024-01-15"); - } - - #[test] - fn test_coerce_string_to_date_error() { - let elem = ArrayElement::String("not-a-date".to_string()); - let result = elem.coerce_to(ArrayElementType::Date); - assert!(result.is_err()); - } - - #[test] - fn test_coerce_number_to_boolean() { - let elem = ArrayElement::Number(1.0); - let result = elem.coerce_to(ArrayElementType::Boolean).unwrap(); - assert_eq!(result, ArrayElement::Boolean(true)); - - let elem = ArrayElement::Number(0.0); - let result = elem.coerce_to(ArrayElementType::Boolean).unwrap(); - assert_eq!(result, ArrayElement::Boolean(false)); - } - - #[test] - fn test_coerce_number_to_string() { - let elem = ArrayElement::Number(42.5); - let result = elem.coerce_to(ArrayElementType::String).unwrap(); - assert_eq!(result, ArrayElement::String("42.5".to_string())); - - // Integer format - let elem = ArrayElement::Number(42.0); - let result = elem.coerce_to(ArrayElementType::String).unwrap(); - assert_eq!(result, ArrayElement::String("42".to_string())); - } - - #[test] - fn test_coerce_boolean_to_number() { - let elem = ArrayElement::Boolean(true); - let result = elem.coerce_to(ArrayElementType::Number).unwrap(); - assert_eq!(result, ArrayElement::Number(1.0)); - - let elem = ArrayElement::Boolean(false); - let result = elem.coerce_to(ArrayElementType::Number).unwrap(); - assert_eq!(result, ArrayElement::Number(0.0)); - } - - #[test] - fn test_coerce_boolean_to_string() { - let elem = ArrayElement::Boolean(true); - let result = elem.coerce_to(ArrayElementType::String).unwrap(); - assert_eq!(result, ArrayElement::String("true".to_string())); - } - - #[test] - fn test_coerce_null_stays_null() { - let elem = ArrayElement::Null; - let result = elem.coerce_to(ArrayElementType::Boolean).unwrap(); - assert_eq!(result, ArrayElement::Null); - - let result = elem.coerce_to(ArrayElementType::Number).unwrap(); - assert_eq!(result, ArrayElement::Null); - } - - #[test] - fn test_coerce_same_type_identity() { - let elem = ArrayElement::Boolean(true); - let result = elem.coerce_to(ArrayElementType::Boolean).unwrap(); - assert_eq!(result, ArrayElement::Boolean(true)); - - let elem = ArrayElement::Number(42.0); - let result = elem.coerce_to(ArrayElementType::Number).unwrap(); - assert_eq!(result, ArrayElement::Number(42.0)); - } - - #[test] - fn test_coerce_date_to_string() { - let elem = ArrayElement::from_date_string("2024-01-15").unwrap(); - let result = elem.coerce_to(ArrayElementType::String).unwrap(); - assert_eq!(result, ArrayElement::String("2024-01-15".to_string())); - } - - #[test] - fn test_coerce_cross_temporal_not_supported() { - let elem = ArrayElement::Date(100); - let result = elem.coerce_to(ArrayElementType::DateTime); - assert!(result.is_err()); - - let elem = ArrayElement::DateTime(100000); - let result = elem.coerce_to(ArrayElementType::Date); - assert!(result.is_err()); - } - - // ========================================================================= - // ParamConstraint validation tests - // ========================================================================= - - #[test] - fn test_number_constraint_accepts_valid() { - let constraint = ParamConstraint::number_min(0.0); - assert!(validate_parameter("test", &ParameterValue::Number(5.0), &constraint).is_ok()); - assert!(validate_parameter("test", &ParameterValue::Number(0.0), &constraint).is_ok()); - } - - #[test] - fn test_number_constraint_rejects_invalid() { - let constraint = ParamConstraint::number_min(0.0); - let result = validate_parameter("test", &ParameterValue::Number(-1.0), &constraint); - assert!(result.is_err()); - assert!(result.unwrap_err().contains(">= 0")); - } - - #[test] - fn test_number_constraint_rejects_wrong_type() { - let constraint = ParamConstraint::number_min(0.0); - let result = validate_parameter( - "test", - &ParameterValue::String("hello".to_string()), - &constraint, - ); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("should be Number")); - } - - #[test] - fn test_count_constraint_accepts_whole() { - let constraint = ParamConstraint::count(1.0); - assert!(validate_parameter("bins", &ParameterValue::Number(5.0), &constraint).is_ok()); - assert!(validate_parameter("bins", &ParameterValue::Number(1.0), &constraint).is_ok()); - assert!(validate_parameter("bins", &ParameterValue::Number(100.0), &constraint).is_ok()); - } - - #[test] - fn test_count_constraint_rejects_fractional() { - let constraint = ParamConstraint::count(1.0); - let result = validate_parameter("bins", &ParameterValue::Number(5.5), &constraint); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("whole number")); - } - - #[test] - fn test_count_constraint_rejects_below_min() { - let constraint = ParamConstraint::count(1.0); - let result = validate_parameter("bins", &ParameterValue::Number(0.0), &constraint); - assert!(result.is_err()); - assert!(result.unwrap_err().contains(">= 1")); - } - - #[test] - fn test_string_option_accepts_valid() { - let constraint = ParamConstraint::string_option(&["a", "b", "c"]); - assert!(validate_parameter( - "test", - &ParameterValue::String("a".to_string()), - &constraint - ) - .is_ok()); - } - - #[test] - fn test_string_option_rejects_invalid() { - let constraint = ParamConstraint::string_option(&["a", "b", "c"]); - let result = validate_parameter( - "test", - &ParameterValue::String("d".to_string()), - &constraint, - ); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("not 'd'")); - } - - #[test] - fn test_string_option_rejects_wrong_type() { - let constraint = ParamConstraint::string_option(&["a", "b", "c"]); - let result = validate_parameter("test", &ParameterValue::Number(1.0), &constraint); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("should be String")); - } - - #[test] - fn test_boolean_accepts_valid() { - let constraint = ParamConstraint::boolean(); - assert!(validate_parameter("reverse", &ParameterValue::Boolean(true), &constraint).is_ok()); - assert!( - validate_parameter("reverse", &ParameterValue::Boolean(false), &constraint).is_ok() - ); - } - - #[test] - fn test_boolean_rejects_wrong_type() { - let constraint = ParamConstraint::boolean(); - let result = validate_parameter( - "reverse", - &ParameterValue::String("true".to_string()), - &constraint, - ); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("should be Boolean")); - } - - #[test] - fn test_multi_type_number_or_array_accepts_number() { - let constraint = ParamConstraint::number_or_numeric_array( - NumberConstraint::min(0.0), - ArrayConstraint::of_numbers_len(NumberConstraint::unconstrained(), 2), - ); - assert!(validate_parameter("expand", &ParameterValue::Number(0.05), &constraint).is_ok()); - } - - #[test] - fn test_multi_type_number_or_array_accepts_array() { - let constraint = ParamConstraint::number_or_numeric_array( - NumberConstraint::min(0.0), - ArrayConstraint::of_numbers_len(NumberConstraint::min(0.0), 2), - ); - let arr = - ParameterValue::Array(vec![ArrayElement::Number(0.05), ArrayElement::Number(10.0)]); - assert!(validate_parameter("expand", &arr, &constraint).is_ok()); - } - - #[test] - fn test_multi_type_number_or_array_rejects_wrong_array_length() { - let constraint = ParamConstraint::number_or_numeric_array( - NumberConstraint::min(0.0), - ArrayConstraint::of_numbers_len(NumberConstraint::min(0.0), 2), - ); - let arr = ParameterValue::Array(vec![ArrayElement::Number(0.05)]); - let result = validate_parameter("expand", &arr, &constraint); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("exactly 2")); - } - - #[test] - fn test_multi_type_number_or_array_rejects_string() { - let constraint = ParamConstraint::number_or_numeric_array( - NumberConstraint::min(0.0), - ArrayConstraint::of_numbers_len(NumberConstraint::min(0.0), 2), - ); - let result = validate_parameter( - "expand", - &ParameterValue::String("0.05".to_string()), - &constraint, - ); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("should be Number or Array")); - } - - #[test] - fn test_multi_type_number_or_array_validates_element_values() { - let constraint = ParamConstraint::number_or_numeric_array( - NumberConstraint::min(0.0), - ArrayConstraint::of_numbers_len(NumberConstraint::min(0.0), 2), - ); - let arr = ParameterValue::Array(vec![ - ArrayElement::Number(0.05), - ArrayElement::Number(-10.0), // Invalid: negative - ]); - let result = validate_parameter("expand", &arr, &constraint); - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!(err.contains("expand[1]")); - assert!(err.contains(">= 0")); - } - - #[test] - fn test_breaks_constraint_accepts_number() { - let constraint = ParamConstraint::number_or_array_or_string( - NumberConstraint::min(1.0), - ArrayConstraint::of_numbers(NumberConstraint::unconstrained()), - ); - assert!(validate_parameter("breaks", &ParameterValue::Number(10.0), &constraint).is_ok()); - } - - #[test] - fn test_breaks_constraint_accepts_array() { - let constraint = ParamConstraint::number_or_array_or_string( - NumberConstraint::min(1.0), - ArrayConstraint::of_numbers(NumberConstraint::unconstrained()), - ); - let arr = ParameterValue::Array(vec![ - ArrayElement::Number(0.0), - ArrayElement::Number(25.0), - ArrayElement::Number(50.0), - ]); - assert!(validate_parameter("breaks", &arr, &constraint).is_ok()); - } - - #[test] - fn test_breaks_constraint_accepts_string() { - let constraint = ParamConstraint::number_or_array_or_string( - NumberConstraint::min(1.0), - ArrayConstraint::of_numbers(NumberConstraint::unconstrained()), - ); - assert!(validate_parameter( - "breaks", - &ParameterValue::String("1 month".to_string()), - &constraint - ) - .is_ok()); - } - - #[test] - fn test_breaks_constraint_rejects_boolean() { - let constraint = ParamConstraint::number_or_array_or_string( - NumberConstraint::min(1.0), - ArrayConstraint::of_numbers(NumberConstraint::unconstrained()), - ); - let result = validate_parameter("breaks", &ParameterValue::Boolean(true), &constraint); - assert!(result.is_err()); - assert!(result - .unwrap_err() - .contains("should be Number, String, or Array")); - } - - #[test] - fn test_breaks_constraint_validates_number_value() { - let constraint = ParamConstraint::number_or_array_or_string( - NumberConstraint::min(1.0), - ArrayConstraint::of_numbers(NumberConstraint::unconstrained()), - ); - let result = validate_parameter("breaks", &ParameterValue::Number(0.0), &constraint); - assert!(result.is_err()); - assert!(result.unwrap_err().contains(">= 1")); - } - - #[test] - fn test_array_element_type_validation() { - let constraint = ParamConstraint::number_or_numeric_array( - NumberConstraint::min(0.0), - ArrayConstraint::of_numbers(NumberConstraint::unconstrained()), - ); - let arr = ParameterValue::Array(vec![ - ArrayElement::Number(1.0), - ArrayElement::String("fifty".to_string()), - ArrayElement::Number(100.0), - ]); - let result = validate_parameter("breaks", &arr, &constraint); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("'breaks[1]' must be a number")); - } - - #[test] - fn test_null_allowed_by_default() { - let constraint = ParamConstraint::number_min(1.0); - assert!(validate_parameter("test", &ParameterValue::Null, &constraint).is_ok()); - } - - #[test] - fn test_null_rejected_when_required() { - let constraint = ParamConstraint::number_min(1.0).required(); - let result = validate_parameter("test", &ParameterValue::Null, &constraint); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("required")); - } - - #[test] - fn test_array_null_elements_rejected_by_default() { - let constraint = ParamConstraint::number_or_numeric_array( - NumberConstraint::min(0.0), - ArrayConstraint::of_numbers(NumberConstraint::unconstrained()), - ); - let arr = ParameterValue::Array(vec![ - ArrayElement::Number(1.0), - ArrayElement::Null, - ArrayElement::Number(3.0), - ]); - let result = validate_parameter("values", &arr, &constraint); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("'values[1]' cannot be null")); - } - - #[test] - fn test_string_or_string_array_accepts_string() { - let constraint = ParamConstraint::string_or_string_array(&["x", "y"]); - assert!(validate_parameter( - "free", - &ParameterValue::String("x".to_string()), - &constraint - ) - .is_ok()); - } - - #[test] - fn test_string_or_string_array_accepts_array() { - let constraint = ParamConstraint::string_or_string_array(&["x", "y"]); - let arr = ParameterValue::Array(vec![ - ArrayElement::String("x".to_string()), - ArrayElement::String("y".to_string()), - ]); - assert!(validate_parameter("free", &arr, &constraint).is_ok()); - } - - #[test] - fn test_string_or_string_array_validates_array_elements() { - let constraint = ParamConstraint::string_or_string_array(&["x", "y"]); - let arr = ParameterValue::Array(vec![ - ArrayElement::String("x".to_string()), - ArrayElement::String("z".to_string()), - ]); - let result = validate_parameter("free", &arr, &constraint); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("not 'z'")); - } - #[test] - fn test_homogenize_mixed_number_string() { - let arr = vec![ - ArrayElement::Number(1.0), - ArrayElement::String("foo".to_string()), - ]; - - let homogenized = ArrayElement::homogenize(&arr); - - // Should fall back to String type since "foo" can't be coerced to Number - assert_eq!(homogenized.len(), 2); - assert!(matches!(homogenized[0], ArrayElement::String(_))); - assert!(matches!(homogenized[1], ArrayElement::String(_))); - - if let ArrayElement::String(s) = &homogenized[0] { - assert_eq!(s, "1"); - } - if let ArrayElement::String(s) = &homogenized[1] { - assert_eq!(s, "foo"); - } - } - - #[test] - fn test_try_as_temporal_date() { - let elem = ArrayElement::String("1973-06-01".to_string()); - let parsed = elem.try_as_temporal(); - assert!(matches!(parsed, ArrayElement::Date(_))); - assert_eq!(parsed.to_key_string(), "1973-06-01"); - } - - #[test] - fn test_try_as_temporal_datetime() { - let elem = ArrayElement::String("2024-03-17T14:30:00".to_string()); - let parsed = elem.try_as_temporal(); - assert!(matches!(parsed, ArrayElement::DateTime(_))); - } - - #[test] - fn test_try_as_temporal_time() { - let elem = ArrayElement::String("14:30:00".to_string()); - let parsed = elem.try_as_temporal(); - assert!(matches!(parsed, ArrayElement::Time(_))); - } - - #[test] - fn test_try_as_temporal_non_temporal_string() { - let elem = ArrayElement::String("not a date".to_string()); - let parsed = elem.try_as_temporal(); - assert!(matches!(parsed, ArrayElement::String(_))); - assert_eq!(parsed.to_key_string(), "not a date"); - } - - #[test] - fn test_try_as_temporal_non_string() { - // Non-string elements should pass through unchanged - let elem = ArrayElement::Number(42.0); - let parsed = elem.try_as_temporal(); - assert!(matches!(parsed, ArrayElement::Number(_))); - - let elem = ArrayElement::Boolean(true); - let parsed = elem.try_as_temporal(); - assert!(matches!(parsed, ArrayElement::Boolean(_))); - } -} diff --git a/src/reader/adbc.rs b/src/reader/adbc.rs index dc918ece6..a1feb7bf4 100644 --- a/src/reader/adbc.rs +++ b/src/reader/adbc.rs @@ -741,7 +741,7 @@ where Ok(()) } - fn execute(&self, query: &str) -> Result { + fn execute(&self, query: &str) -> Result { crate::reader::execute_with_reader(self, query) } diff --git a/src/reader/cache.rs b/src/reader/cache.rs index 6e7c465c4..f38164a57 100644 --- a/src/reader/cache.rs +++ b/src/reader/cache.rs @@ -12,7 +12,7 @@ //! - [`Reader::dialect`] returns the cache dialect. use crate::array_util::{as_i64, as_str}; -use crate::reader::{execute_with_reader, ColumnInfo, Reader, Spec, SqlDialect, TableInfo}; +use crate::reader::{execute_with_reader, ColumnInfo, Reader, ResolvedSpec, SqlDialect, TableInfo}; use crate::{naming, DataFrame, Result}; use arrow::array::Array; use std::cell::{Cell, RefCell}; @@ -474,7 +474,7 @@ impl Reader for CachingReader { Ok(()) } - fn execute(&self, query: &str) -> Result { + fn execute(&self, query: &str) -> Result { execute_with_reader(self, query) } diff --git a/src/reader/cache_equivalence.rs b/src/reader/cache_equivalence.rs index 373bfbcaf..94d95c684 100644 --- a/src/reader/cache_equivalence.rs +++ b/src/reader/cache_equivalence.rs @@ -131,6 +131,12 @@ fn assert_equivalent(plain: &dyn Reader, cached: &dyn Reader, query: &str) { b.as_ref().err(), ); let (Ok(sa), Ok(sb)) = (a, b) else { return }; + let sa = sa + .into_plot() + .expect("cache-equivalence corpus is VISUALISE-only"); + let sb = sb + .into_plot() + .expect("cache-equivalence corpus is VISUALISE-only"); assert_eq!( sa.layer_count(), @@ -273,7 +279,7 @@ mod adbc_mode { use super::*; use crate::reader::sqlite::SqliteDialect; use crate::reader::test_support::assert_dataframes_equal; - use crate::reader::{AdbcReader, Spec, SqlDialect}; + use crate::reader::{AdbcReader, ResolvedSpec, SqlDialect}; use crate::{DataFrame, Result}; use adbc_core::options::{AdbcVersion, OptionDatabase, OptionValue}; use adbc_core::LOAD_FLAG_DEFAULT; @@ -329,7 +335,7 @@ mod adbc_mode { fn unregister(&self, name: &str) -> Result<()> { self.inner.unregister(name) } - fn execute(&self, query: &str) -> Result { + fn execute(&self, query: &str) -> Result { crate::reader::execute_with_reader(self, query) } fn dialect(&self) -> &dyn SqlDialect { diff --git a/src/reader/connection.rs b/src/reader/connection.rs index 319dffb86..5bc3ee15e 100644 --- a/src/reader/connection.rs +++ b/src/reader/connection.rs @@ -732,7 +732,7 @@ mod tests { let spec = reader .execute("SELECT 1.0 AS x, 2.0 AS y VISUALISE x, y DRAW point") .unwrap(); - assert_eq!(spec.metadata().rows, 1); + assert_eq!(spec.as_plot().unwrap().metadata().rows, 1); } #[cfg(feature = "duckdb")] diff --git a/src/reader/duckdb.rs b/src/reader/duckdb.rs index f69ec59ba..2e431120f 100644 --- a/src/reader/duckdb.rs +++ b/src/reader/duckdb.rs @@ -329,7 +329,7 @@ impl Reader for DuckDBReader { Ok(()) } - fn execute(&self, query: &str) -> Result { + fn execute(&self, query: &str) -> Result { super::execute_with_reader(self, query) } @@ -662,8 +662,9 @@ mod tests { .execute("SELECT * FROM bar_data VISUALISE DRAW bar MAPPING category AS x") .unwrap(); - assert_eq!(spec.plot().layers.len(), 1); - assert!(spec.layer_data(0).is_some()); + let plot = spec.as_plot().unwrap(); + assert_eq!(plot.plot().layers.len(), 1); + assert!(plot.layer_data(0).is_some()); let writer = VegaLiteWriter::new(); let json = writer.render(&spec).unwrap(); @@ -690,8 +691,9 @@ mod tests { .execute("SELECT * FROM hist_data VISUALISE DRAW histogram MAPPING value AS x") .unwrap(); - assert_eq!(spec.plot().layers.len(), 1); - let layer_df = spec.layer_data(0).unwrap(); + let plot = spec.as_plot().unwrap(); + assert_eq!(plot.plot().layers.len(), 1); + let layer_df = plot.layer_data(0).unwrap(); assert!( layer_df.height() < 50, "Histogram should bin data: got {} rows", @@ -723,8 +725,9 @@ mod tests { .execute("SELECT * FROM density_data VISUALISE DRAW density MAPPING value AS x") .unwrap(); - assert_eq!(spec.plot().layers.len(), 1); - assert!(spec.layer_data(0).is_some()); + let plot = spec.as_plot().unwrap(); + assert_eq!(plot.plot().layers.len(), 1); + assert!(plot.layer_data(0).is_some()); let writer = VegaLiteWriter::new(); let json = writer.render(&spec).unwrap(); @@ -754,7 +757,7 @@ mod tests { .execute("SELECT * FROM box_data VISUALISE DRAW boxplot MAPPING grp AS x, value AS y") .unwrap(); - assert!(spec.layer_data(0).is_some()); + assert!(spec.as_plot().unwrap().layer_data(0).is_some()); let writer = VegaLiteWriter::new(); let json = writer.render(&spec).unwrap(); diff --git a/src/reader/mod.rs b/src/reader/mod.rs index 463c5103f..31058c135 100644 --- a/src/reader/mod.rs +++ b/src/reader/mod.rs @@ -7,7 +7,7 @@ //! //! All readers implement the `Reader` trait, which provides: //! - SQL query execution → DataFrame conversion -//! - Visualization query execution → Spec +//! - Visualization query execution → ResolvedPlot //! - Optional DataFrame registration for queryable tables //! - Connection management and error handling //! @@ -33,10 +33,12 @@ use std::collections::HashMap; -use crate::execute::prepare_data_with_reader; +use crate::execute::{prepare_data_with_reader, resolve_table_with_reader}; +use crate::parser::{self, SourceTree}; use crate::plot::{CastTargetType, Plot}; +use crate::table::TableLayout; use crate::validate::{validate, ValidationWarning}; -use crate::{naming, DataFrame, GgsqlError, Result}; +use crate::{naming, DataFrame, GgsqlError, Result, Spec}; // ============================================================================= // SQL Dialect @@ -1467,11 +1469,11 @@ mod helper_tests { } // ============================================================================ -// Spec - Result of reader.execute() +// ResolvedPlot - Result of reader.execute() // ============================================================================ /// Result of executing a ggsql query, ready for rendering. -pub struct Spec { +pub struct ResolvedPlot { /// Single resolved plot specification pub(crate) plot: Plot, /// Internal data map (global + layer-specific DataFrames) @@ -1494,6 +1496,37 @@ pub struct Metadata { pub layer_count: usize, } +// ============================================================================ +// ResolvedTable - Result of reader.execute() for a TABULATE statement +// ============================================================================ + +/// Result of executing a ggsql TABULATE query, ready for rendering. +pub struct ResolvedTable { + /// The resolved layout (cells, columns, rows), built from + /// `table.source` (or the main SQL if there was no TABULATE FROM) and + /// validated before handoff. See `TableLayout` for the shape invariants + /// it maintains. + pub(crate) layout: TableLayout, + /// The SQL query that was executed to produce the layout's cells + pub(crate) sql: String, + /// Validation warnings from preparation + pub(crate) warnings: Vec, +} + +// ============================================================================ +// ResolvedSpec - Result of reader.execute() +// ============================================================================ + +/// Result of executing a ggsql query: either a resolved plot or a resolved +/// table, mirroring the parse-time `Spec` (`Plot` or `Table`). +pub enum ResolvedSpec { + // Both boxed, mirroring `Spec`'s own two variants: whichever of + // `ResolvedPlot`/`ResolvedTable` is larger, an unboxed size gap between + // them trips clippy's `large_enum_variant` lint. + Plot(Box), + Table(Box), +} + // ============================================================================ // Reader Trait // ============================================================================ @@ -1587,24 +1620,32 @@ pub trait Reader { ))) } - /// Execute a ggsql query and return the visualization specification. + /// Execute a ggsql query and return the resolved specification. /// - /// This is the main entry point for creating visualizations. It parses the query, - /// executes the SQL portion, and returns a `Spec` ready for rendering. + /// This is the main entry point for creating visualizations or tables. + /// It parses the query, executes the SQL portion, and returns a + /// `ResolvedSpec` ready for rendering — either a `ResolvedPlot` (from a + /// `VISUALISE`) or a `ResolvedTable` (from a `TABULATE`). + /// + /// No default body: implementations delegate to `execute_with_reader` + /// (each with a concrete, `Sized` `self`) so the trait stays object-safe + /// — a default method here would need to unsize `&Self` into + /// `&dyn Reader` to call that same free function, which requires + /// `Self: Sized` and would remove `execute` from `dyn Reader`'s vtable. /// /// # Arguments /// - /// * `query` - The ggsql query (SQL + VISUALISE clause) + /// * `query` - The ggsql query (SQL + VISUALISE/TABULATE clause) /// /// # Returns /// - /// A `Spec` containing the resolved visualization specification and data. + /// A `ResolvedSpec` containing the resolved plot or table. /// /// # Errors /// /// Returns an error if: /// - The query syntax is invalid - /// - The query has no VISUALISE clause + /// - The query has no VISUALISE/TABULATE clause /// - The SQL execution fails /// /// # Example @@ -1619,7 +1660,7 @@ pub trait Reader { /// let writer = VegaLiteWriter::new(); /// let json = writer.render(&spec)?; /// ``` - fn execute(&self, query: &str) -> Result; + fn execute(&self, query: &str) -> Result; /// Get the SQL dialect for this reader. /// @@ -1731,12 +1772,11 @@ pub struct ColumnInfo { pub data_type: String, } -/// Execute a ggsql query using any reader +/// Resolve a VISUALISE query into a `ResolvedPlot`. /// -/// This is the shared implementation behind `Reader::execute()`. Concrete -/// readers delegate to this so the trait stays object-safe (no `Self: Sized` -/// bound on `execute`). -pub fn execute_with_reader(reader: &dyn Reader, query: &str) -> Result { +/// This is the Plot-side counterpart to `execute::resolve_table_with_reader` +/// — see that function's doc comment for how the two relate. +pub fn resolve_plot_with_reader(reader: &dyn Reader, query: &str) -> Result { let validated = validate(query)?; let warnings: Vec = validated.warnings().to_vec(); @@ -1747,7 +1787,7 @@ pub fn execute_with_reader(reader: &dyn Reader, query: &str) -> Result { GgsqlError::ValidationError("No visualization spec found".to_string()) })?; - Ok(Spec::new( + Ok(ResolvedPlot::new( plot, prepared_data.data, prepared_data.sql, @@ -1755,3 +1795,1095 @@ pub fn execute_with_reader(reader: &dyn Reader, query: &str) -> Result { warnings, )) } + +/// Execute a ggsql query using any reader. +/// +/// This is the shared implementation behind `Reader::execute()`. Concrete +/// readers delegate to this so the trait stays object-safe (no `Self: Sized` +/// bound on `execute`). Dispatches to the Plot or Table pipeline depending on +/// which kind of `Spec` the query's first statement is. +pub fn execute_with_reader(reader: &dyn Reader, query: &str) -> Result { + let source_tree = SourceTree::new(query)?; + source_tree.validate()?; + let specs = parser::build_ast(&source_tree)?; + + match specs.into_iter().next() { + Some(Spec::Table(_)) => { + let resolved = resolve_table_with_reader(query, reader)?; + Ok(ResolvedSpec::Table(Box::new(resolved))) + } + _ => { + let resolved = resolve_plot_with_reader(reader, query)?; + Ok(ResolvedSpec::Plot(Box::new(resolved))) + } + } +} + +#[cfg(test)] +#[cfg(all(feature = "duckdb", feature = "vegalite"))] +mod tests { + use super::*; + use crate::df; + use crate::writer::{VegaLiteWriter, Writer}; + + fn data_layer(json: &serde_json::Value, index: usize) -> &serde_json::Value { + json["layer"] + .as_array() + .unwrap() + .iter() + .filter(|l| { + !matches!( + l.get("description").and_then(|d| d.as_str()), + Some("background" | "foreground") + ) + }) + .nth(index) + .expect("data layer not found at index") + } + + #[test] + fn test_execute_and_render() { + let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); + let spec = reader + .execute("SELECT 1 as x, 2 as y VISUALISE x, y DRAW point") + .unwrap(); + + let plot = spec.as_plot().unwrap(); + assert_eq!(plot.plot().layers.len(), 1); + assert_eq!(plot.metadata().layer_count, 1); + assert!(plot.layer_data(0).is_some()); + + let writer = VegaLiteWriter::new(); + let result = writer.render(&spec).unwrap(); + assert!(result.contains("point")); + } + + /// Confirms the actual dispatch in `execute_with_reader` — not just its + /// two pieces (`resolve_plot_with_reader`, `resolve_table_with_reader`) + /// tested elsewhere — routes each Spec kind correctly through + /// `Reader::execute()`, and that `Writer::render()` rejects a Table + /// cleanly rather than panicking. + #[test] + fn test_execute_dispatches_plot_and_table() { + let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); + reader + .execute_sql("CREATE TABLE sales AS SELECT 1 AS id") + .unwrap(); + + let plot_spec = reader + .execute("SELECT 1 as x, 2 as y VISUALISE x, y DRAW point") + .unwrap(); + assert!(plot_spec.as_plot().is_some()); + assert!(plot_spec.as_table().is_none()); + + let table_spec = reader.execute("TABULATE * FROM sales").unwrap(); + assert!(table_spec.as_table().is_some()); + assert!(table_spec.as_plot().is_none()); + + let writer = VegaLiteWriter::new(); + assert!(writer.render(&table_spec).is_err()); + } + + #[test] + fn test_execute_metadata() { + let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); + let spec = reader + .execute( + "SELECT * FROM (VALUES (1, 10), (2, 20), (3, 30)) AS t(x, y) VISUALISE x, y DRAW point", + ) + .unwrap(); + + let metadata = spec.as_plot().unwrap().metadata(); + assert_eq!(metadata.rows, 3); + // Columns now includes both user mappings (pos1, pos2) and resolved defaults (size, stroke, fill, opacity, shape, linewidth) + // Aesthetics are transformed to internal names (x -> pos1, y -> pos2) + assert!(metadata.columns.contains(&"pos1".to_string())); + assert!(metadata.columns.contains(&"pos2".to_string())); + assert_eq!(metadata.layer_count, 1); + } + + #[test] + fn test_execute_with_cte() { + let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); + let query = r#" + WITH data AS ( + SELECT * FROM (VALUES (1, 10), (2, 20)) AS t(x, y) + ) + SELECT * FROM data + VISUALISE x, y DRAW point + "#; + + let spec = reader.execute(query).unwrap(); + let plot = spec.as_plot().unwrap(); + + assert_eq!(plot.plot().layers.len(), 1); + assert!(plot.layer_data(0).is_some()); + assert_eq!(plot.layer_data(0).unwrap().height(), 2); + } + + #[test] + fn test_render_multi_layer() { + let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); + let query = r#" + SELECT * FROM (VALUES (1, 10), (2, 20), (3, 30)) AS t(x, y) + VISUALISE + DRAW point MAPPING x AS x, y AS y + DRAW line MAPPING x AS x, y AS y + "#; + + let spec = reader.execute(query).unwrap(); + let writer = VegaLiteWriter::new(); + let result = writer.render(&spec).unwrap(); + + assert!(result.contains("layer")); + } + + #[test] + fn test_polar_project_with_start() { + let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); + let query = r#" + SELECT * FROM (VALUES ('A', 10), ('B', 20), ('C', 30)) AS t(category, value) + VISUALISE value AS y, category AS fill + DRAW bar + PROJECT y, x TO polar SETTING start => 90 + "#; + + let spec = reader.execute(query).unwrap(); + let writer = VegaLiteWriter::new(); + let result = writer.render(&spec).unwrap(); + + // Parse the JSON to verify the theta scale range is set correctly + let json: serde_json::Value = serde_json::from_str(&result).unwrap(); + + // The encoding should have a theta channel with a scale range offset by 90 degrees + // 90 degrees = π/2 radians + let layer = data_layer(&json, 0); + let theta = &layer["encoding"]["theta"]; + assert!(theta.is_object(), "theta encoding should exist"); + + // Check that the scale has a range with the start offset + let scale = &theta["scale"]; + let range = scale["range"].as_array().unwrap(); + assert_eq!(range.len(), 2); + + // π/2 ≈ 1.5707963 + let start = range[0].as_f64().unwrap(); + assert!( + (start - std::f64::consts::FRAC_PI_2).abs() < 0.001, + "start should be π/2 (90 degrees), got {}", + start + ); + + // π/2 + 2π ≈ 7.8539816 + let end = range[1].as_f64().unwrap(); + let expected_end = std::f64::consts::FRAC_PI_2 + 2.0 * std::f64::consts::PI; + assert!( + (end - expected_end).abs() < 0.001, + "end should be π/2 + 2π, got {}", + end + ); + } + + #[test] + fn test_polar_project_default_start() { + let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); + let query = r#" + SELECT * FROM (VALUES ('A', 10), ('B', 20), ('C', 30)) AS t(category, value) + VISUALISE value AS y, category AS fill + DRAW bar + PROJECT y, x TO polar + "#; + + let spec = reader.execute(query).unwrap(); + let writer = VegaLiteWriter::new(); + let result = writer.render(&spec).unwrap(); + + // Parse the JSON + let json: serde_json::Value = serde_json::from_str(&result).unwrap(); + + // The theta encoding should NOT have a scale with range when start is 0 (default) + let layer = data_layer(&json, 0); + let theta = &layer["encoding"]["theta"]; + assert!(theta.is_object(), "theta encoding should exist"); + + // Either no scale, or no range in scale (since default is 0) + if let Some(scale) = theta.get("scale") { + assert!( + scale.get("range").is_none(), + "theta scale should not have range when start is 0" + ); + } + } + + #[test] + fn test_polar_project_with_end() { + let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); + let query = r#" + SELECT * FROM (VALUES ('A', 10), ('B', 20)) AS t(category, value) + VISUALISE value AS y, category AS fill + DRAW bar + PROJECT y, x TO polar SETTING start => -90, end => 90 + "#; + + let spec = reader.execute(query).unwrap(); + let writer = VegaLiteWriter::new(); + let result = writer.render(&spec).unwrap(); + + let json: serde_json::Value = serde_json::from_str(&result).unwrap(); + let layer = data_layer(&json, 0); + let theta = &layer["encoding"]["theta"]; + let range = theta["scale"]["range"].as_array().unwrap(); + + // -90° = -π/2 ≈ -1.5708, 90° = π/2 ≈ 1.5708 + let start = range[0].as_f64().unwrap(); + let end = range[1].as_f64().unwrap(); + assert!( + (start - (-std::f64::consts::FRAC_PI_2)).abs() < 0.001, + "start should be -π/2 (-90 degrees), got {}", + start + ); + assert!( + (end - std::f64::consts::FRAC_PI_2).abs() < 0.001, + "end should be π/2 (90 degrees), got {}", + end + ); + } + + #[test] + fn test_polar_project_with_end_only() { + // Test using end without explicit start (start defaults to 0) + let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); + let query = r#" + SELECT * FROM (VALUES ('A', 10), ('B', 20)) AS t(category, value) + VISUALISE value AS y, category AS fill + DRAW bar + PROJECT y, x TO polar SETTING end => 180 + "#; + + let spec = reader.execute(query).unwrap(); + let writer = VegaLiteWriter::new(); + let result = writer.render(&spec).unwrap(); + + let json: serde_json::Value = serde_json::from_str(&result).unwrap(); + let layer = data_layer(&json, 0); + let theta = &layer["encoding"]["theta"]; + let range = theta["scale"]["range"].as_array().unwrap(); + + // start=0 (default), end=180° = π + let start = range[0].as_f64().unwrap(); + let end = range[1].as_f64().unwrap(); + assert!( + start.abs() < 0.001, + "start should be 0 (default), got {}", + start + ); + assert!( + (end - std::f64::consts::PI).abs() < 0.001, + "end should be π (180 degrees), got {}", + end + ); + } + + #[test] + fn test_polar_encoding_keys_independent_of_user_names() { + // This test verifies that polar projections always produce theta/radius encoding keys + // in Vega-Lite output, regardless of what position names the user specified in PROJECT. + // This is critical because Vega-Lite expects specific channel names for polar marks. + let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); + + // Helper to check encoding keys + fn check_encoding_keys(json: &serde_json::Value, test_name: &str) { + let layer = data_layer(json, 0); + assert!( + layer["encoding"].get("theta").is_some(), + "{} should produce theta encoding, got keys: {:?}", + test_name, + layer["encoding"] + .as_object() + .map(|o| o.keys().collect::>()) + ); + // Also verify no x or y keys exist (they should be mapped to theta/radius) + assert!( + layer["encoding"].get("x").is_none(), + "{} should NOT have x encoding in polar mode", + test_name + ); + assert!( + layer["encoding"].get("y").is_none(), + "{} should NOT have y encoding in polar mode", + test_name + ); + } + + // Test case 1: PROJECT y, x TO polar (y as pos1→radius, x as pos2→theta) + let query1 = r#" + SELECT * FROM (VALUES ('A', 10), ('B', 20)) AS t(category, value) + VISUALISE value AS y, category AS fill + DRAW bar + PROJECT y, x TO polar + "#; + let spec1 = reader.execute(query1).unwrap(); + let writer = VegaLiteWriter::new(); + let result1 = writer.render(&spec1).unwrap(); + let json1: serde_json::Value = serde_json::from_str(&result1).unwrap(); + check_encoding_keys(&json1, "PROJECT y, x TO polar"); + + // Test case 2: PROJECT x, y TO polar (x as pos1→radius, y as pos2→theta) + let query2 = r#" + SELECT * FROM (VALUES ('A', 10), ('B', 20)) AS t(category, value) + VISUALISE value AS x, category AS fill + DRAW bar + PROJECT x, y TO polar + "#; + let spec2 = reader.execute(query2).unwrap(); + let result2 = writer.render(&spec2).unwrap(); + let json2: serde_json::Value = serde_json::from_str(&result2).unwrap(); + check_encoding_keys(&json2, "PROJECT x, y TO polar"); + + // Test case 3: PROJECT TO polar (default radius/angle names) + let query3 = r#" + SELECT * FROM (VALUES ('A', 10), ('B', 20)) AS t(category, value) + VISUALISE value AS angle, category AS fill + DRAW bar + PROJECT TO polar + "#; + let spec3 = reader.execute(query3).unwrap(); + let result3 = writer.render(&spec3).unwrap(); + let json3: serde_json::Value = serde_json::from_str(&result3).unwrap(); + check_encoding_keys(&json3, "PROJECT TO polar"); + + // Test case 4: PROJECT a, b TO polar (custom aesthetic names) + let query4 = r#" + SELECT * FROM (VALUES ('A', 10), ('B', 20)) AS t(category, value) + VISUALISE value AS a, category AS fill + DRAW bar + PROJECT a, b TO polar + "#; + let spec4 = reader.execute(query4).unwrap(); + let result4 = writer.render(&spec4).unwrap(); + let json4: serde_json::Value = serde_json::from_str(&result4).unwrap(); + check_encoding_keys(&json4, "PROJECT a, b TO polar (custom names)"); + } + + #[test] + fn test_cartesian_encoding_keys_with_custom_names() { + // This test verifies that cartesian projections produce x/y encoding keys + // even when custom position names are used in PROJECT. + let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); + + fn check_cartesian_keys(json: &serde_json::Value, test_name: &str) { + let layer = data_layer(json, 0); + assert!( + layer["encoding"].get("x").is_some(), + "{} should produce x encoding, got keys: {:?}", + test_name, + layer["encoding"] + .as_object() + .map(|o| o.keys().collect::>()) + ); + // Verify no theta/radius keys exist + assert!( + layer["encoding"].get("theta").is_none(), + "{} should NOT have theta encoding in cartesian mode", + test_name + ); + } + + // Test case: PROJECT a, b TO cartesian (custom aesthetic names) + let query = r#" + SELECT * FROM (VALUES ('A', 10), ('B', 20)) AS t(category, value) + VISUALISE category AS a, value AS b + DRAW bar + PROJECT a, b TO cartesian + "#; + let spec = reader.execute(query).unwrap(); + let writer = VegaLiteWriter::new(); + let result = writer.render(&spec).unwrap(); + let json: serde_json::Value = serde_json::from_str(&result).unwrap(); + check_cartesian_keys(&json, "PROJECT a, b TO cartesian (custom names)"); + } + + #[test] + fn test_register_and_query() { + let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); + + let df = df! { + "x" => vec![1i32, 2, 3], + "y" => vec![10i32, 20, 30], + } + .unwrap(); + + reader.register("my_data", df, false).unwrap(); + + let query = "SELECT * FROM my_data VISUALISE x, y DRAW point"; + let spec = reader.execute(query).unwrap(); + + let plot = spec.as_plot().unwrap(); + assert_eq!(plot.metadata().rows, 3); + // Aesthetics are transformed to internal names (x -> pos1) + assert!(plot.metadata().columns.contains(&"pos1".to_string())); + + let writer = VegaLiteWriter::new(); + let result = writer.render(&spec).unwrap(); + assert!(result.contains("point")); + } + + #[test] + fn test_register_and_join() { + let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); + + let sales = df! { + "id" => vec![1i32, 2, 3], + "amount" => vec![100i32, 200, 300], + "product_id" => vec![1i32, 1, 2], + } + .unwrap(); + + let products = df! { + "id" => vec![1i32, 2], + "name" => vec!["Widget", "Gadget"], + } + .unwrap(); + + reader.register("sales", sales, false).unwrap(); + reader.register("products", products, false).unwrap(); + + let query = r#" + SELECT s.id, s.amount, p.name + FROM sales s + JOIN products p ON s.product_id = p.id + VISUALISE id AS x, amount AS y + DRAW bar + "#; + + let spec = reader.execute(query).unwrap(); + assert_eq!(spec.as_plot().unwrap().metadata().rows, 3); + } + + #[test] + fn test_execute_no_viz_fails() { + let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); + let query = "SELECT 1 as x, 2 as y"; + + let result = reader.execute(query); + assert!(result.is_err()); + } + + #[test] + fn test_binned_fill_legend_renders_threshold_scale() { + // End-to-end test for binned fill scale rendering to Vega-Lite + // Verifies that binned material aesthetics use threshold scale type + let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); + + // Create data with values that span the binned range + // Binned scales use FROM [min, max] for range and SETTING breaks => [...] for explicit breaks + let query = r#" + SELECT * FROM (VALUES + (1, 10, 15.0), + (2, 20, 35.0), + (3, 30, 55.0), + (4, 40, 85.0) + ) AS t(x, y, value) + VISUALISE + DRAW point MAPPING x AS x, y AS y, value AS fill + SCALE BINNED fill FROM [0, 100] TO viridis SETTING breaks => [0, 25, 50, 75, 100] + "#; + + let spec = reader.execute(query).unwrap(); + + // Verify spec structure + let plot = spec.as_plot().unwrap(); + assert_eq!(plot.plot().layers.len(), 1); + // Note: scales may include auto-generated x/y scales plus the explicit fill scale + assert!( + plot.plot().find_scale("fill").is_some(), + "Should have a fill scale" + ); + + // Render to Vega-Lite + let writer = VegaLiteWriter::new(); + let result = writer.render(&spec).unwrap(); + let vl: serde_json::Value = serde_json::from_str(&result).unwrap(); + + // Verify threshold scale type for fill + let fill_scale = &vl["layer"][0]["encoding"]["fill"]["scale"]; + assert_eq!( + fill_scale["type"], + "threshold", + "Binned fill should use threshold scale type. Got: {}", + serde_json::to_string_pretty(&vl["layer"][0]["encoding"]["fill"]).unwrap() + ); + + // Verify internal breaks as domain (excludes first and last terminals) + // breaks = [0, 25, 50, 75, 100] → domain = [25, 50, 75] + let domain = fill_scale["domain"].as_array().unwrap(); + assert_eq!( + domain.len(), + 3, + "Threshold domain should have internal breaks only. Got: {:?}", + domain + ); + assert_eq!(domain[0], 25.0); + assert_eq!(domain[1], 50.0); + assert_eq!(domain[2], 75.0); + + // Verify color output - viridis palette gets expanded to an explicit range array + // for threshold scales (Vega-Lite needs explicit colors for threshold domain) + assert!( + fill_scale["range"].is_array() || fill_scale["scheme"] == "viridis", + "Should have color range or scheme. Got scale: {}", + serde_json::to_string_pretty(fill_scale).unwrap() + ); + + // Verify legend values + // For `fill` alone (single binned legend scale), uses gradient legend with all 5 break values + // For symbol legends (multiple binned scales or non-gradient aesthetics), would have N-1 values + let legend_values = &vl["layer"][0]["encoding"]["fill"]["legend"]["values"]; + assert!( + legend_values.is_array(), + "Legend should have values array. Got: {}", + serde_json::to_string_pretty(&vl["layer"][0]["encoding"]["fill"]["legend"]).unwrap() + ); + let values = legend_values.as_array().unwrap(); + assert_eq!( + values.len(), + 5, + "Gradient legend should have all 5 break values. Got: {:?}", + values + ); + } + + #[test] + fn test_binned_color_legend_with_label_mapping() { + // Test binned color scale with custom labels renders correctly + let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); + + let query = r#" + SELECT * FROM (VALUES + (1, 10, 20.0), + (2, 20, 60.0), + (3, 30, 90.0) + ) AS t(x, y, score) + VISUALISE + DRAW point MAPPING x AS x, y AS y, score AS color + SCALE BINNED color FROM [0, 100] TO ['blue', 'yellow', 'red'] SETTING breaks => [0, 50, 100] + RENAMING 0 => 'Low', 50 => 'High' + "#; + + let spec = reader.execute(query).unwrap(); + + let writer = VegaLiteWriter::new(); + let result = writer.render(&spec).unwrap(); + let vl: serde_json::Value = serde_json::from_str(&result).unwrap(); + + // Verify threshold scale + // Note: "color" aesthetic is mapped to "stroke" for point geom (not fill) + let encoding = if vl["layer"].is_array() { + &vl["layer"][0]["encoding"] + } else { + &vl["encoding"] + }; + // Find the stroke or fill encoding (color maps to one of these) + let color_encoding = if encoding["stroke"].is_object() { + &encoding["stroke"] + } else { + &encoding["fill"] + }; + assert_eq!( + color_encoding["scale"]["type"], + "threshold", + "Binned color should use threshold scale. Got encoding: {}", + serde_json::to_string_pretty(color_encoding).unwrap() + ); + + // Verify labelExpr exists for custom labels + let legend = &color_encoding["legend"]; + assert!( + legend["labelExpr"].is_string(), + "Legend should have labelExpr for custom labels. Got legend: {}", + serde_json::to_string_pretty(legend).unwrap() + ); + + let label_expr = legend["labelExpr"].as_str().unwrap_or(""); + // For symbol legends, VL generates range-style labels like "0 – 50" + // Our labelExpr should map these to custom range formats + assert!( + label_expr.contains("Low") || label_expr.contains("High"), + "labelExpr should contain custom labels, got: {}", + label_expr + ); + } + + #[test] + fn test_polar_project_with_inner() { + let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); + let query = r#" + SELECT * FROM (VALUES ('A', 10), ('B', 20)) AS t(category, value) + VISUALISE value AS y, category AS fill + DRAW bar + PROJECT y, x TO polar SETTING inner => 0.5 + "#; + + let spec = reader.execute(query).unwrap(); + let writer = VegaLiteWriter::new(); + let result = writer.render(&spec).unwrap(); + + let json: serde_json::Value = serde_json::from_str(&result).unwrap(); + let layer = data_layer(&json, 0); + + // Check radius scale has range with expressions + let radius = &layer["encoding"]["radius"]; + assert!(radius["scale"]["range"].is_array()); + let range = radius["scale"]["range"].as_array().unwrap(); + + // First element should be inner proportion expression + assert!( + range[0]["expr"].as_str().unwrap().contains("0.5"), + "Inner radius expression should contain 0.5, got: {:?}", + range[0] + ); + + // Second element should be the outer radius expression + assert!( + range[1]["expr"] + .as_str() + .unwrap() + .contains("min(width, height) / 2"), + "Outer radius expression should contain min(width, height) / 2, got: {:?}", + range[1] + ); + } + + #[test] + fn test_stacked_bar_chart() { + // Test stacked bar chart via position => 'stack' + let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); + let query = r#" + SELECT * FROM (VALUES + ('A', 'X', 10), + ('A', 'Y', 20), + ('B', 'X', 15), + ('B', 'Y', 25) + ) AS t(cat, grp, val) + VISUALISE + DRAW bar MAPPING cat AS x, val AS y, grp AS fill + SETTING position => 'stack' + "#; + + let spec = reader.execute(query).unwrap(); + let writer = VegaLiteWriter::new(); + let result = writer.render(&spec).unwrap(); + + let json: serde_json::Value = serde_json::from_str(&result).unwrap(); + let layer = data_layer(&json, 0); + + // Verify y and y2 encodings exist (stacked bars use y/y2 for range) + let encoding = &layer["encoding"]; + assert!(encoding["y"].is_object(), "Should have y encoding"); + assert!( + encoding["y2"].is_object(), + "Should have y2 encoding for stacked bars" + ); + + // Verify Vega-Lite stacking is disabled (we handle it ourselves) + assert!( + encoding["y"]["stack"].is_null(), + "y encoding should have stack: null to disable VL stacking. Got: {}", + serde_json::to_string_pretty(&encoding["y"]).unwrap() + ); + } + + #[cfg(feature = "builtin-data")] + #[test] + fn test_stacked_bar_chart_dummy_x() { + // Test stacked bar chart with no x mapping (dummy x column) + // This is the case where only fill is mapped: all bars at same x position should stack + let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); + let query = r#" + VISUALISE FROM ggsql:penguins + DRAW bar MAPPING species AS fill + "#; + + let spec = reader.execute(query).unwrap(); + let writer = VegaLiteWriter::new(); + let result = writer.render(&spec).unwrap(); + + let json: serde_json::Value = serde_json::from_str(&result).unwrap(); + let layer = data_layer(&json, 0); + + // Verify y and y2 encodings exist (stacked bars use y/y2 for range) + let encoding = &layer["encoding"]; + assert!(encoding["y"].is_object(), "Should have y encoding"); + assert!( + encoding["y2"].is_object(), + "Should have y2 encoding for stacked bars with dummy x. Encoding: {}", + serde_json::to_string_pretty(encoding).unwrap() + ); + + // Verify Vega-Lite stacking is disabled (we handle it ourselves) + assert!( + encoding["y"]["stack"].is_null(), + "y encoding should have stack: null to disable VL stacking. Got: {}", + serde_json::to_string_pretty(&encoding["y"]).unwrap() + ); + } + + #[cfg(feature = "builtin-data")] + #[test] + fn test_boxplot_dummy_x() { + // Boxplot with only y mapped: should render a single boxplot of the + // whole distribution and suppress the categorical x axis. + let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); + let query = r#" + VISUALISE FROM ggsql:penguins + DRAW boxplot MAPPING bill_len AS y + "#; + + let spec = reader.execute(query).unwrap(); + let writer = VegaLiteWriter::new(); + let result = writer.render(&spec).unwrap(); + + let json: serde_json::Value = serde_json::from_str(&result).unwrap(); + // Boxplot is a composite renderer (multiple sub-layers). Check that + // the first layer's x encoding suppresses its axis. + let layer = data_layer(&json, 0); + let encoding = &layer["encoding"]; + assert!( + encoding["x"]["axis"].is_null(), + "Boxplot dummy x should have axis: null. Encoding: {}", + serde_json::to_string_pretty(encoding).unwrap() + ); + } + + #[cfg(feature = "builtin-data")] + #[test] + fn test_violin_dummy_x() { + // Violin with only y mapped: single violin spanning the whole dataset. + let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); + let query = r#" + VISUALISE FROM ggsql:penguins + DRAW violin MAPPING bill_len AS y + "#; + + let spec = reader.execute(query).unwrap(); + let writer = VegaLiteWriter::new(); + let result = writer.render(&spec).unwrap(); + + let json: serde_json::Value = serde_json::from_str(&result).unwrap(); + let layer = data_layer(&json, 0); + let encoding = &layer["encoding"]; + assert!( + encoding["x"]["axis"].is_null(), + "Violin dummy x should have axis: null. Encoding: {}", + serde_json::to_string_pretty(encoding).unwrap() + ); + } + + #[cfg(feature = "builtin-data")] + #[test] + fn test_point_dummy_x() { + // Point with only y mapped: strip plot at a single dummy x position. + let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); + let query = r#" + VISUALISE FROM ggsql:penguins + DRAW point MAPPING bill_len AS y + "#; + + let spec = reader.execute(query).unwrap(); + let writer = VegaLiteWriter::new(); + let result = writer.render(&spec).unwrap(); + + let json: serde_json::Value = serde_json::from_str(&result).unwrap(); + let layer = data_layer(&json, 0); + let encoding = &layer["encoding"]; + assert!( + encoding["x"]["axis"].is_null(), + "Point dummy x should have axis: null. Encoding: {}", + serde_json::to_string_pretty(encoding).unwrap() + ); + } + + #[test] + fn test_range_dummy_x() { + // Range with only ymin/ymax mapped: a single vertical interval. + let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); + let query = r#" + SELECT 10.0 AS lo, 20.0 AS hi + VISUALISE + DRAW range MAPPING lo AS ymin, hi AS ymax + "#; + + let spec = reader.execute(query).unwrap(); + let writer = VegaLiteWriter::new(); + let result = writer.render(&spec).unwrap(); + + let json: serde_json::Value = serde_json::from_str(&result).unwrap(); + let layer = data_layer(&json, 0); + let encoding = &layer["encoding"]; + assert!( + encoding["x"]["axis"].is_null(), + "Range dummy x should have axis: null. Encoding: {}", + serde_json::to_string_pretty(encoding).unwrap() + ); + } + + #[cfg(feature = "builtin-data")] + #[test] + fn test_point_dummy_y() { + // Symmetric to test_point_dummy_x: only x mapped means dummy y. + let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); + let query = r#" + VISUALISE FROM ggsql:penguins + DRAW point MAPPING bill_len AS x + "#; + + let spec = reader.execute(query).unwrap(); + let writer = VegaLiteWriter::new(); + let result = writer.render(&spec).unwrap(); + + let json: serde_json::Value = serde_json::from_str(&result).unwrap(); + let layer = data_layer(&json, 0); + let encoding = &layer["encoding"]; + assert!( + encoding["y"]["axis"].is_null(), + "Point dummy y should have axis: null. Encoding: {}", + serde_json::to_string_pretty(encoding).unwrap() + ); + } + + #[cfg(feature = "builtin-data")] + #[test] + fn test_point_dummy_both_with_aggregate() { + // Both axes omitted, but aggregate gives the single point meaning: + // a count of all rows at the dummy x/y intersection. + let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); + let query = r#" + VISUALISE FROM ggsql:penguins + DRAW point MAPPING bill_len AS size + SETTING aggregate => 'size:count' + "#; + + let spec = reader.execute(query).unwrap(); + let writer = VegaLiteWriter::new(); + let result = writer.render(&spec).unwrap(); + + let json: serde_json::Value = serde_json::from_str(&result).unwrap(); + let layer = data_layer(&json, 0); + let encoding = &layer["encoding"]; + assert!( + encoding["x"]["axis"].is_null(), + "Both-dummy point should hide x axis. Encoding: {}", + serde_json::to_string_pretty(encoding).unwrap() + ); + assert!( + encoding["y"]["axis"].is_null(), + "Both-dummy point should hide y axis. Encoding: {}", + serde_json::to_string_pretty(encoding).unwrap() + ); + } + + #[cfg(feature = "builtin-data")] + #[test] + fn test_point_dummy_x_with_aggregate() { + // Point with aggregate SETTING and no x mapping: should aggregate the + // whole dataset to a single point and suppress the dummy x axis. + let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); + let query = r#" + VISUALISE FROM ggsql:penguins + DRAW point MAPPING bill_len AS y + SETTING aggregate => 'mean' + "#; + + let spec = reader.execute(query).unwrap(); + let writer = VegaLiteWriter::new(); + let result = writer.render(&spec).unwrap(); + + let json: serde_json::Value = serde_json::from_str(&result).unwrap(); + let layer = data_layer(&json, 0); + let encoding = &layer["encoding"]; + assert!( + encoding["x"]["axis"].is_null(), + "Aggregated point with dummy x should have axis: null. Encoding: {}", + serde_json::to_string_pretty(encoding).unwrap() + ); + } + + #[cfg(feature = "builtin-data")] + #[test] + fn test_bar_chart_with_expand_setting() { + // Test bar chart with SCALE y SETTING expand - should work even when y is stat-derived + // This tests that: + // 1. Scale type inference works for stat-generated count columns + // 2. Stacking still works (y2 encoding exists) when SCALE y is specified + let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); + let query = r#" + VISUALISE FROM ggsql:penguins + DRAW bar MAPPING species AS fill + SCALE y SETTING expand => [0.05, 0.05] + "#; + + let spec = reader.execute(query).unwrap(); + let writer = VegaLiteWriter::new(); + let result = writer.render(&spec).unwrap(); + + // Should succeed without "discrete scale does not support SETTING 'expand'" error + let json: serde_json::Value = serde_json::from_str(&result).unwrap(); + let layer = data_layer(&json, 0); + + // Verify stacking works (y2 encoding exists for stacked bars) + let encoding = &layer["encoding"]; + assert!( + encoding["y2"].is_object(), + "Should have y2 encoding for stacked bars. Encoding: {}", + serde_json::to_string_pretty(encoding).unwrap() + ); + } + + #[test] + fn test_dodged_bar_chart() { + // Test dodged bar chart via position => 'dodge' + let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); + let query = r#" + SELECT * FROM (VALUES + ('A', 'X', 10), + ('A', 'Y', 20), + ('B', 'X', 15), + ('B', 'Y', 25) + ) AS t(cat, grp, val) + VISUALISE + DRAW bar MAPPING cat AS x, val AS y, grp AS fill + SETTING position => 'dodge' + "#; + + let spec = reader.execute(query).unwrap(); + let writer = VegaLiteWriter::new(); + let result = writer.render(&spec).unwrap(); + + let json: serde_json::Value = serde_json::from_str(&result).unwrap(); + let layer = data_layer(&json, 0); + + // Verify xOffset encoding exists (dodged bars use xOffset for displacement) + let encoding = &layer["encoding"]; + assert!( + encoding["xOffset"].is_object(), + "Should have xOffset encoding for dodged bars. Encoding: {}", + serde_json::to_string_pretty(encoding).unwrap() + ); + + // Verify bar width uses bandwidth expression with adjusted_width for dodged bars + // For 2 groups with default width 0.9: adjusted_width = 0.9 / 2 = 0.45 + let mark = &layer["mark"]; + let width_expr = mark["width"]["expr"].as_str(); + assert!( + width_expr.is_some(), + "Dodged bars should have expression-based width. Mark: {}", + serde_json::to_string_pretty(mark).unwrap() + ); + let expr = width_expr.unwrap(); + assert!( + expr.contains("bandwidth('x')") && expr.contains("0.45"), + "Width expression should use bandwidth('x') * adjusted_width, got: {}", + expr + ); + } + + #[test] + fn test_position_identity_default() { + // Test that identity position (default) doesn't modify data + let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); + let query = r#" + SELECT * FROM (VALUES + ('A', 10), + ('B', 20) + ) AS t(cat, val) + VISUALISE + DRAW bar MAPPING cat AS x, val AS y + "#; + + let spec = reader.execute(query).unwrap(); + let writer = VegaLiteWriter::new(); + let result = writer.render(&spec).unwrap(); + + let json: serde_json::Value = serde_json::from_str(&result).unwrap(); + let layer = data_layer(&json, 0); + + // Verify no xOffset encoding (identity position) + let encoding = &layer["encoding"]; + assert!( + encoding.get("xOffset").is_none(), + "Identity position should not have xOffset encoding" + ); + } + + #[test] + fn test_label_with_flipped_project() { + // End-to-end test: LABEL x/y with PROJECT y, x TO cartesian + // Labels should be correctly applied to the flipped axes + let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); + let query = r#" + SELECT * FROM (VALUES (1, 10), (2, 20)) AS t(x, y) + VISUALISE + DRAW bar MAPPING x AS y, y AS x + PROJECT y, x TO cartesian + LABEL x => 'Value', y => 'Category' + "#; + + let spec = reader.execute(query).unwrap(); + let writer = VegaLiteWriter::new(); + let result = writer.render(&spec).unwrap(); + + let json: serde_json::Value = serde_json::from_str(&result).unwrap(); + let layer = data_layer(&json, 0); + let encoding = &layer["encoding"]; + + // With PROJECT y, x TO cartesian: + // - y is pos1 (first position), renders to VL x-axis in cartesian + // - x is pos2 (second position), renders to VL y-axis in cartesian + // So LABEL y => 'Category' should appear on VL x-axis, LABEL x => 'Value' on VL y-axis + let x_title = encoding["x"]["title"].as_str(); + let y_title = encoding["y"]["title"].as_str(); + + assert_eq!( + x_title, + Some("Category"), + "x-axis should have 'Category' title (from LABEL y). Got encoding: {}", + serde_json::to_string_pretty(encoding).unwrap() + ); + assert_eq!( + y_title, + Some("Value"), + "y-axis should have 'Value' title (from LABEL x). Got encoding: {}", + serde_json::to_string_pretty(encoding).unwrap() + ); + } + + #[test] + fn test_label_with_polar_project() { + // End-to-end test: LABEL angle/radius with PROJECT TO polar + let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); + let query = r#" + SELECT * FROM (VALUES ('A', 10), ('B', 20)) AS t(category, value) + VISUALISE value AS angle, category AS fill + DRAW bar + PROJECT TO polar + LABEL angle => 'Angle', radius => 'Distance' + "#; + + let spec = reader.execute(query).unwrap(); + let writer = VegaLiteWriter::new(); + let result = writer.render(&spec).unwrap(); + + let json: serde_json::Value = serde_json::from_str(&result).unwrap(); + let layer = data_layer(&json, 0); + let encoding = &layer["encoding"]; + + // Verify theta encoding has the label + let theta_title = encoding["theta"]["title"].as_str(); + assert_eq!( + theta_title, + Some("Angle"), + "theta encoding should have 'Angle' title. Got encoding: {}", + serde_json::to_string_pretty(encoding).unwrap() + ); + } +} diff --git a/src/reader/odbc/mod.rs b/src/reader/odbc/mod.rs index 8cb3cbdfe..11df50ece 100644 --- a/src/reader/odbc/mod.rs +++ b/src/reader/odbc/mod.rs @@ -268,7 +268,7 @@ impl Reader for OdbcReader { Ok(()) } - fn execute(&self, query: &str) -> Result { + fn execute(&self, query: &str) -> Result { super::execute_with_reader(self, query) } @@ -1530,8 +1530,9 @@ mod tests { ) .unwrap(); - assert_eq!(spec.plot.layers.len(), 1); - assert!(spec.layer_data(0).unwrap().height() > 0); + let plot = spec.as_plot().unwrap(); + assert_eq!(plot.plot.layers.len(), 1); + assert!(plot.layer_data(0).unwrap().height() > 0); reader.execute_sql("DROP TABLE __ggsql_countries").unwrap(); } @@ -1575,7 +1576,7 @@ mod tests { .unwrap(); // Only the visible polygon should survive clipping - assert_eq!(spec.layer_data(0).unwrap().height(), 1); + assert_eq!(spec.as_plot().unwrap().layer_data(0).unwrap().height(), 1); reader.execute_sql("DROP TABLE __ggsql_clip_test").unwrap(); } @@ -1618,17 +1619,18 @@ mod tests { ) .unwrap(); - assert_eq!(spec.plot.layers.len(), 1); - let df = spec.layer_data(0).unwrap(); + let plot = spec.as_plot().unwrap(); + assert_eq!(plot.plot.layers.len(), 1); + let df = plot.layer_data(0).unwrap(); assert_eq!(df.height(), 3); let writer = crate::writer::vegalite::VegaLiteWriter::new(); - let json_str = writer.write(&spec.plot, &spec.data).unwrap(); + let json_str = writer.write_plot(&plot.plot, &plot.data).unwrap(); let vl_spec: serde_json::Value = serde_json::from_str(&json_str).unwrap(); // Coordinates should be projected (not raw lon/lat) let data = vl_spec["data"]["values"].as_array().unwrap(); - let layer_key = spec.plot.layers[0].data_key.as_ref().unwrap(); + let layer_key = plot.plot.layers[0].data_key.as_ref().unwrap(); let rows: Vec<_> = data .iter() .filter(|r| r[crate::naming::SOURCE_COLUMN] == layer_key.as_str()) diff --git a/src/reader/spec.rs b/src/reader/spec.rs index 787ceb09a..0e00bddbe 100644 --- a/src/reader/spec.rs +++ b/src/reader/spec.rs @@ -1,16 +1,17 @@ -//! Implementation of Spec methods. +//! Implementation of ResolvedPlot, ResolvedTable, and ResolvedSpec methods. use std::collections::HashMap; use crate::naming; use crate::plot::Plot; +use crate::table::TableLayout; use crate::validate::ValidationWarning; -use crate::DataFrame; +use crate::{DataFrame, TableCell, TableColumn, TableRow}; -use super::{Metadata, Spec}; +use super::{Metadata, ResolvedPlot, ResolvedSpec, ResolvedTable}; -impl Spec { - /// Create a new Spec from PreparedData +impl ResolvedPlot { + /// Create a new ResolvedPlot from PreparedData pub(crate) fn new( plot: Plot, data: HashMap, @@ -96,3 +97,98 @@ impl Spec { &self.warnings } } + +impl ResolvedTable { + /// Create a new ResolvedTable. + pub(crate) fn new(layout: TableLayout, sql: String, warnings: Vec) -> Self { + Self { + layout, + sql, + warnings, + } + } + + /// The resolved layout: cells, columns, and rows, kept in sync by + /// `TableLayout`'s invariants. + pub fn layout(&self) -> &TableLayout { + &self.layout + } + + /// Get the resolved layout's cells: one per column label and per data + /// value. + pub fn cells(&self) -> &[TableCell] { + &self.layout.cells + } + + /// Resolved per-column properties — a writer wanting a whole-column + /// value (e.g. `width`) reads it here instead of the same value + /// repeated across the column's cells. + pub fn columns(&self) -> &[TableColumn] { + &self.layout.columns + } + + /// Resolved per-row properties, symmetric with `columns`. `properties` + /// has no row-wide `TABULATE` clause to populate it yet, but `classes` + /// does — a `Title`/`Subtitle` cell's row carries `TableClass::Heading` + /// here, for a writer to put on the enclosing ``. + pub fn rows(&self) -> &[TableRow] { + &self.layout.rows + } + + /// Total number of rows in the table's rendered grid — heading, spanner, + /// and column-label rows included, not just data rows. + pub fn nrow(&self) -> usize { + self.layout.nrow() + } + + /// Number of columns. + pub fn ncol(&self) -> usize { + self.layout.ncol() + } + + /// The SQL query that was executed to produce `cells`. + pub fn sql(&self) -> &str { + &self.sql + } + + /// Validation warnings from preparation. + pub fn warnings(&self) -> &[ValidationWarning] { + &self.warnings + } +} + +impl ResolvedSpec { + /// Borrow the inner `ResolvedPlot`, or `None` if this is a `ResolvedTable`. + pub fn as_plot(&self) -> Option<&ResolvedPlot> { + match self { + ResolvedSpec::Plot(plot) => Some(plot), + ResolvedSpec::Table(_) => None, + } + } + + /// Borrow the inner `ResolvedTable`, or `None` if this is a `ResolvedPlot`. + pub fn as_table(&self) -> Option<&ResolvedTable> { + match self { + ResolvedSpec::Plot(_) => None, + ResolvedSpec::Table(table) => Some(table), + } + } + + /// Consume this `ResolvedSpec`, returning the inner `ResolvedPlot`, or + /// `None` if it was a `ResolvedTable`. + pub fn into_plot(self) -> Option { + match self { + ResolvedSpec::Plot(plot) => Some(*plot), + ResolvedSpec::Table(_) => None, + } + } + + /// Consume this `ResolvedSpec`, returning the inner `ResolvedTable`, or + /// `None` if it was a `ResolvedPlot`. + pub fn into_table(self) -> Option { + match self { + ResolvedSpec::Plot(_) => None, + ResolvedSpec::Table(table) => Some(*table), + } + } +} diff --git a/src/reader/sqlite.rs b/src/reader/sqlite.rs index 07008d1cc..74d5ebdd7 100644 --- a/src/reader/sqlite.rs +++ b/src/reader/sqlite.rs @@ -442,7 +442,7 @@ impl Reader for SqliteReader { Ok(()) } - fn execute(&self, query: &str) -> Result { + fn execute(&self, query: &str) -> Result { super::execute_with_reader(self, query) } @@ -1136,8 +1136,9 @@ mod tests { .execute("SELECT * FROM bar_data VISUALISE DRAW bar MAPPING category AS x") .unwrap(); - assert_eq!(spec.plot().layers.len(), 1); - assert!(spec.layer_data(0).is_some()); + let plot = spec.as_plot().unwrap(); + assert_eq!(plot.plot().layers.len(), 1); + assert!(plot.layer_data(0).is_some()); let writer = VegaLiteWriter::new(); let json = writer.render(&spec).unwrap(); @@ -1169,8 +1170,9 @@ mod tests { .execute("SELECT * FROM hist_data VISUALISE DRAW histogram MAPPING value AS x") .unwrap(); - assert_eq!(spec.plot().layers.len(), 1); - let layer_df = spec.layer_data(0).unwrap(); + let plot = spec.as_plot().unwrap(); + assert_eq!(plot.plot().layers.len(), 1); + let layer_df = plot.layer_data(0).unwrap(); assert!( layer_df.height() < 50, "Histogram should bin data: got {} rows", @@ -1207,8 +1209,9 @@ mod tests { .execute("SELECT * FROM density_data VISUALISE DRAW density MAPPING value AS x") .unwrap(); - assert_eq!(spec.plot().layers.len(), 1); - assert!(spec.layer_data(0).is_some()); + let plot = spec.as_plot().unwrap(); + assert_eq!(plot.plot().layers.len(), 1); + assert!(plot.layer_data(0).is_some()); let writer = VegaLiteWriter::new(); let json = writer.render(&spec).unwrap(); @@ -1246,7 +1249,7 @@ mod tests { .execute("SELECT * FROM box_data VISUALISE DRAW boxplot MAPPING grp AS x, value AS y") .unwrap(); - assert!(spec.layer_data(0).is_some()); + assert!(spec.as_plot().unwrap().layer_data(0).is_some()); let writer = VegaLiteWriter::new(); let json = writer.render(&spec).unwrap(); @@ -1465,8 +1468,9 @@ mod spatialite_tests { ) .unwrap(); - assert_eq!(spec.plot.layers.len(), 1); - assert!(spec.layer_data(0).unwrap().height() > 0); + let plot = spec.as_plot().unwrap(); + assert_eq!(plot.plot.layers.len(), 1); + assert!(plot.layer_data(0).unwrap().height() > 0); } #[cfg(feature = "vegalite")] @@ -1498,7 +1502,7 @@ mod spatialite_tests { .unwrap(); // Only the visible polygon should survive clipping - assert_eq!(spec.layer_data(0).unwrap().height(), 1); + assert_eq!(spec.as_plot().unwrap().layer_data(0).unwrap().height(), 1); } #[cfg(feature = "vegalite")] @@ -1529,16 +1533,17 @@ mod spatialite_tests { ) .unwrap(); - assert_eq!(spec.plot.layers.len(), 1); - let df = spec.layer_data(0).unwrap(); + let plot = spec.as_plot().unwrap(); + assert_eq!(plot.plot.layers.len(), 1); + let df = plot.layer_data(0).unwrap(); assert_eq!(df.height(), 3); let writer = crate::writer::vegalite::VegaLiteWriter::new(); - let json_str = writer.write(&spec.plot, &spec.data).unwrap(); + let json_str = writer.write_plot(&plot.plot, &plot.data).unwrap(); let vl_spec: serde_json::Value = serde_json::from_str(&json_str).unwrap(); let data = vl_spec["data"]["values"].as_array().unwrap(); - let layer_key = spec.plot.layers[0].data_key.as_ref().unwrap(); + let layer_key = plot.plot.layers[0].data_key.as_ref().unwrap(); let rows: Vec<_> = data .iter() .filter(|r| r[crate::naming::SOURCE_COLUMN] == layer_key.as_str()) diff --git a/src/reader/test_support.rs b/src/reader/test_support.rs index 408f27f70..3e0eadc4b 100644 --- a/src/reader/test_support.rs +++ b/src/reader/test_support.rs @@ -3,7 +3,9 @@ //! Extracted from `reader/mod.rs` in the Phase 5 cleanup; module path is //! unchanged (`crate::reader::test_support`). -use super::{execute_with_reader, returns_rows, ColumnInfo, Reader, Spec, SqlDialect, TableInfo}; +use super::{ + execute_with_reader, returns_rows, ColumnInfo, Reader, ResolvedSpec, SqlDialect, TableInfo, +}; use crate::{DataFrame, GgsqlError, Result}; /// A reader that can serve as an in-memory, writable caching backend. @@ -68,7 +70,7 @@ impl Reader for SpyReader { fn unregister(&self, name: &str) -> Result<()> { self.inner.unregister(name) } - fn execute(&self, query: &str) -> Result { + fn execute(&self, query: &str) -> Result { execute_with_reader(self, query) } fn dialect(&self) -> &dyn SqlDialect { @@ -118,7 +120,7 @@ impl Reader for ReadOnlyReader { fn unregister(&self, _name: &str) -> Result<()> { Err(Self::refuse("unregister")) } - fn execute(&self, query: &str) -> Result { + fn execute(&self, query: &str) -> Result { execute_with_reader(self, query) } fn dialect(&self) -> &dyn SqlDialect { @@ -415,7 +417,7 @@ impl Reader for StubReader { self.tables.lock().unwrap().remove(name); Ok(()) } - fn execute(&self, query: &str) -> Result { + fn execute(&self, query: &str) -> Result { execute_with_reader(self, query) } fn dialect(&self) -> &dyn SqlDialect { diff --git a/src/spec.rs b/src/spec.rs new file mode 100644 index 000000000..f37edea76 --- /dev/null +++ b/src/spec.rs @@ -0,0 +1,79 @@ +//! The parse-time result of a single VISUALISE/TABULATE statement. + +use serde::{Deserialize, Serialize}; + +use crate::{Plot, Table}; + +/// One parsed statement from a ggsql query: either a visualization or a table. +/// +/// A query may contain several `VISUALISE`/`TABULATE` statements +/// (`parser::parse_query` returns one `Spec` per statement, in source order). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum Spec { + // Boxed: `Plot` is far larger than `Table`, and clippy flags the + // resulting size gap (`large_enum_variant`) otherwise. + Plot(Box), + Table(Table), +} + +impl Spec { + /// Borrow the inner `Plot`, or `None` if this is a `Table`. + pub fn as_plot(&self) -> Option<&Plot> { + match self { + Spec::Plot(plot) => Some(plot), + Spec::Table(_) => None, + } + } + + /// Borrow the inner `Table`, or `None` if this is a `Plot`. + pub fn as_table(&self) -> Option<&Table> { + match self { + Spec::Plot(_) => None, + Spec::Table(table) => Some(table), + } + } + + /// Consume this `Spec`, returning the inner `Plot`, or `None` if it was a `Table`. + pub fn into_plot(self) -> Option { + match self { + Spec::Plot(plot) => Some(*plot), + Spec::Table(_) => None, + } + } + + /// Consume this `Spec`, returning the inner `Table`, or `None` if it was a `Plot`. + pub fn into_table(self) -> Option
{ + match self { + Spec::Plot(_) => None, + Spec::Table(table) => Some(table), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn as_plot_and_as_table_are_mutually_exclusive() { + let plot = Spec::Plot(Box::default()); + assert!(plot.as_plot().is_some()); + assert!(plot.as_table().is_none()); + + let table = Spec::Table(Table::default()); + assert!(table.as_plot().is_none()); + assert!(table.as_table().is_some()); + } + + #[test] + fn into_plot_and_into_table_are_mutually_exclusive() { + let plot = Spec::Plot(Box::default()); + assert!(plot.into_plot().is_some()); + + let table = Spec::Table(Table::default()); + assert!(table.into_table().is_some()); + + assert!(Spec::Plot(Box::default()).into_table().is_none()); + assert!(Spec::Table(Table::default()).into_plot().is_none()); + } +} diff --git a/src/table/layout.rs b/src/table/layout.rs new file mode 100644 index 000000000..65fb833d9 --- /dev/null +++ b/src/table/layout.rs @@ -0,0 +1,1411 @@ +//! Resolved table layout vocabulary: the positioned `TableCell`, its +//! `TableCellKind`, and the writer-neutral `TableClass` style classes — +//! plus the `TableLayout` assembly that composes cells, column labels, +//! headings and captions into a grid (`rowbind`, `compose_header`, ...). + +use std::collections::BTreeMap; +use std::collections::HashSet; + +use super::ColumnSection; +use crate::labels::Labels; +use crate::params::Parameters; +use crate::plot::ParameterValue; +use crate::{GgsqlError, Result}; + +/// What role a `TableCell` plays in the table's layout. +/// +/// Naming follows R's gt package (`column_labels`, `body`, `stub`, ...). +/// Lets a writer tell cells apart (e.g. `
` vs ``) without relying on +/// position — a column label is a `ColumnLabel` cell, not "whatever's in row +/// 0". +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TableCellKind { + /// A column label (gt's `column_labels`). + ColumnLabel, + /// The stub's own header cell (gt's `tab_stubhead()`) — sits on the + /// column-label row, over a `TABULATE FORMAT STUB` column. + StubHead, + /// A data value (gt's `body`). + Body, + /// A row-label value in the stub (`TABULATE FORMAT STUB`, gt's `stub`). + StubRowLabel, + /// A spanner cell, grouping several columns under one label (`TABULATE + /// SPAN`). + Spanner, + /// The table's title (`LABEL title => ...`, gt's `gt_title`). + Title, + /// The table's subtitle (`LABEL subtitle => ...`, gt's `gt_subtitle`). + Subtitle, + /// The table's caption (`LABEL caption => ...`, gt's `tab_caption()`). + /// Full-width like `Title`/`Subtitle`, but rendered outside the normal + /// row grid entirely — see `HtmlWriter`'s module doc. + Caption, + /// An empty cell synthesized in `execute` to fill a grid position no + /// real cell reaches. Distinct from whatever kind it visually stands in + /// for, so kind-based filtering can't mistake it for real structure. + Filler, +} + +impl TableCellKind { + /// Whether a cell of this kind is header-shaped rather than a plain data + /// cell — decided by kind alone. `Caption` is excluded; a writer pulls it + /// out of the grid instead of rendering it as either. + pub fn is_header(self) -> bool { + matches!( + self, + TableCellKind::ColumnLabel + | TableCellKind::StubHead + | TableCellKind::Spanner + | TableCellKind::Title + | TableCellKind::Subtitle + | TableCellKind::Filler // every current case is header-shaped + | TableCellKind::StubRowLabel + ) + } +} + +impl std::fmt::Display for TableCellKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let text = match self { + TableCellKind::ColumnLabel => "column label", + TableCellKind::StubHead => "stub head", + TableCellKind::Body => "body", + TableCellKind::StubRowLabel => "stub row label", + TableCellKind::Spanner => "spanner", + TableCellKind::Title => "title", + TableCellKind::Subtitle => "subtitle", + TableCellKind::Caption => "caption", + TableCellKind::Filler => "filler", + }; + write!(f, "{text}") + } +} + +/// A writer-neutral style class, one shared vocabulary for whichever part of +/// a table layout carries a `classes` field. +/// +/// Styling roles are recorded here rather than derived from `TableCellKind`: +/// `kind` informs layout (header vs body, spans), classes inform style. A +/// writer maps each variant to its own class vocabulary — the HTML writer +/// prefixes its `Display` with `ggsql_`. +/// +/// Variants are grouped by scope below (table/row/cell). Nothing in the +/// type enforces the grouping — it's a per-variant convention. +/// +/// Naming follows gt's classes (`gt_row`, `gt_col_heading`, +/// `gt_column_spanner_outer`, ...). The structural variants are recorded by +/// `build_cells()`; the alignment variants are a cell's resolved `hjust` +/// expressed as a class, appended by `TableCell::discretise_hjust`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TableClass { + // ------------------------- Table-scoped ------------------------- + // Applied by a writer to its own top-level elements (``, + // ``) — there's no resolved layout type to record them on. + /// The whole table (gt's `gt_table`). + Table, + /// The `` wrapping every body row (gt's `gt_table_body`). Not to + /// be confused with the per-cell `Row`. + TableBody, + + // -------------------------- Row-scoped -------------------------- + // Recorded on `TableRow::classes`, rendered on the ``. + /// The `` wrapping a `Title`/`Subtitle` cell (gt's `gt_heading`). + Heading, + /// The `` wrapping the row of column-label cells (gt's + /// `gt_col_headings` — note the plural, distinguishing it from the + /// singular per-cell `gt_col_heading`). + ColHeadingRow, + /// The `` wrapping a spanner row (gt's `gt_spanner_row`). + SpannerRow, + + // -------------------------- Cell-scoped ------------------------- + // Recorded on `TableCell::classes`, rendered on the `` rather than on any cell. +#[derive(Debug, Clone, Default)] +pub struct TableRow { + /// Resolved properties for this row's cells. + pub properties: Parameters, + /// Style classes recorded at build time (see `TableClass`) — row-scoped + /// ones, e.g. `Heading`. Ordered, like `TableCell::classes`. + pub classes: Vec, + /// Whether this row belongs in a table's header rather than its body. + /// `false` by default; set `true` for a spanner, column-label, title or + /// subtitle row. + pub is_header: bool, + /// Whether this row is the caption's — always the layout's last row and + /// never also a header (`TableLayout::validate` enforces both). + pub is_caption: bool, +} + +impl TableRow { + /// A header row — everything else at its default. + pub fn header() -> Self { + Self { + is_header: true, + ..Self::default() + } + } + + /// The caption's row — everything else at its default. + pub fn caption() -> Self { + Self { + is_caption: true, + ..Self::default() + } + } + + /// A body row — the default; named for symmetry with `header()`/`caption()`. + pub fn body() -> Self { + Self::default() + } +} + +/// The row extent a set of cells implies — the highest `bottom` plus one, +/// or `0` for no cells. +pub(crate) fn count_cell_rows(cells: &[TableCell]) -> usize { + cells + .iter() + .map(|cell| cell.bottom) + .max() + .map_or(0, |r| r + 1) +} + +/// The column extent a set of cells implies — the highest `right` plus one, +/// or `0` for no cells. +pub(crate) fn count_cell_cols(cells: &[TableCell]) -> usize { + cells + .iter() + .map(|cell| cell.right) + .max() + .map_or(0, |r| r + 1) +} + +/// Standardise an `hjust` value to a number: `"left"`, +/// `"center"`/`"centre"`, `"right"` become `0.0`, `0.5`, `1.0` (any other +/// string is `0.5`); a number passes through; anything else is `None`. +pub(crate) fn standardise_hjust(value: &ParameterValue) -> Option { + match value { + ParameterValue::String(s) if s == "left" => Some(0.0), + ParameterValue::String(s) if s == "right" => Some(1.0), + ParameterValue::String(_) => Some(0.5), + ParameterValue::Number(n) => Some(*n), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::plot::ParameterValue; + + #[test] + fn table_cell_kind_display_names_every_variant() { + assert_eq!(TableCellKind::ColumnLabel.to_string(), "column label"); + assert_eq!(TableCellKind::StubHead.to_string(), "stub head"); + assert_eq!(TableCellKind::Body.to_string(), "body"); + assert_eq!(TableCellKind::StubRowLabel.to_string(), "stub row label"); + assert_eq!(TableCellKind::Spanner.to_string(), "spanner"); + assert_eq!(TableCellKind::Title.to_string(), "title"); + assert_eq!(TableCellKind::Subtitle.to_string(), "subtitle"); + assert_eq!(TableCellKind::Caption.to_string(), "caption"); + assert_eq!(TableCellKind::Filler.to_string(), "filler"); + } + + #[test] + fn discretise_hjust_folds_hjust_into_a_class() { + let discretised = |n: f64| { + let mut properties = Parameters::new(); + properties.insert("hjust".to_string(), ParameterValue::Number(n)); + TableCell::new(TableCellKind::Body, 0, 0, 0, 0, String::new()) + .with_properties(properties) + .discretise_hjust() + }; + assert_eq!(discretised(0.0).classes, [TableClass::AlignLeft]); + assert_eq!(discretised(0.1).classes, [TableClass::AlignLeft]); + assert_eq!(discretised(0.5).classes, [TableClass::AlignCenter]); + assert_eq!(discretised(0.9).classes, [TableClass::AlignRight]); + assert_eq!(discretised(1.0).classes, [TableClass::AlignRight]); + // hjust is consumed, not copied. + assert!(discretised(1.0).properties.is_empty()); + + // The keyword spellings classify identically to their numbers. + let discretised_str = |s: &str| { + let mut properties = Parameters::new(); + properties.insert("hjust".to_string(), ParameterValue::String(s.to_string())); + TableCell::new(TableCellKind::Body, 0, 0, 0, 0, String::new()) + .with_properties(properties) + .discretise_hjust() + }; + assert_eq!(discretised_str("left").classes, [TableClass::AlignLeft]); + assert_eq!(discretised_str("right").classes, [TableClass::AlignRight]); + assert_eq!(discretised_str("center").classes, [TableClass::AlignCenter]); + assert_eq!(discretised_str("centre").classes, [TableClass::AlignCenter]); + // No hjust → no class. + assert!( + TableCell::new(TableCellKind::Body, 0, 0, 0, 0, String::new()) + .discretise_hjust() + .classes + .is_empty() + ); + } +} + +/// What belongs at one column of a rendered row, decided once for the whole +/// table by `TableLayout::slots_by_row` rather than worked out inline while +/// a writer walks columns. (The HTML spec's table model uses the same term: +/// it assigns cells to slots.) +pub(crate) enum Slot<'a> { + /// A real cell starting at this column, `Filler` included — same render + /// path as any other kind. + Cell(&'a TableCell), + /// A column a rowspan from an earlier row already claims — nothing + /// rendered here at all. + Skip, +} + +/// A table's layout: its cells alongside one `TableRow` **per row index they +/// occupy** — not one per cell, since several cells can share a row (a +/// column-label row has one cell per column, but is still a single row) — +/// and one `TableColumn` per column index. Both the build-time composition +/// unit and the resolved handoff to a writer: every `create_*` helper below +/// builds all three together, so `rows` is never derived from `cells` and a +/// row's classification (`is_header`, `Heading`) is decided where its cells +/// are built. +/// +/// The type is public because `Writer::write_table` takes one; construction +/// and composition stay crate-internal. +pub struct TableLayout { + pub(crate) columns: Vec, + pub(crate) rows: Vec, + pub(crate) cells: Vec, +} + +impl TableLayout { + /// Build a `TableLayout`, `debug_assert`ing that `rows.len()` matches + /// the row extent `cells` implies and that `cells` stays within + /// `columns`' width — a part-way layout (e.g. spanners covering only + /// some columns) may be narrower; `validate` requires the full width + /// once composed. + pub(crate) fn new( + columns: Vec, + rows: Vec, + cells: Vec, + ) -> TableLayout { + debug_assert_eq!( + rows.len(), + count_cell_rows(&cells), + "TableLayout's row count doesn't match its own cells' row extent" + ); + debug_assert!( + columns.len() >= count_cell_cols(&cells), + "TableLayout's cells exceed its columns' width" + ); + TableLayout { + columns, + rows, + cells, + } + } + + /// Number of rows in the layout — heading, spanner, and column-label + /// rows included, not just data rows. + pub fn nrow(&self) -> usize { + self.rows.len() + } + + /// Number of columns in the layout. + pub fn ncol(&self) -> usize { + self.columns.len() + } + + /// The layout's cells: one per column label and per data value. + pub fn cells(&self) -> &[TableCell] { + &self.cells + } + + /// Resolved per-column properties — a writer wanting a whole-column + /// value (e.g. `width`) reads it here instead of the same value repeated + /// across the column's cells. + pub fn columns(&self) -> &[TableColumn] { + &self.columns + } + + /// Resolved per-row properties, symmetric with `columns`. + pub fn rows(&self) -> &[TableRow] { + &self.rows + } + + /// Row indices partitioned into header and body (each ascending), per + /// `TableRow::is_header`. A caption's row (`TableRow::is_caption`) + /// belongs to neither, so the two vecs' combined length is the + /// caption-free row count. + pub fn header_and_body_rows(&self) -> (Vec, Vec) { + let mut header_rows = Vec::new(); + let mut body_rows = Vec::new(); + for (idx, row) in self.rows.iter().enumerate() { + if row.is_caption { + continue; + } + if row.is_header { + header_rows.push(idx); + } else { + body_rows.push(idx); + } + } + (header_rows, body_rows) + } + + /// What belongs at every column of every header/body row, decided once + /// for the whole table in one pass over the cells — a cell's + /// rowspan/colspan footprint is marked as the cell itself is placed. + /// Keyed by row index, each value one `Slot` per column. Caption cells + /// stay out: the caption's row is the layout's last (`validate` + /// enforces), outside this grid, and a writer renders the caption + /// outside the row flow anyway. + /// + /// Every grid position is expected to already be a real `Cell` or a + /// `Skip` by the time this runs; see the `expect` below. + pub(crate) fn slots_by_row(&self) -> BTreeMap>> { + let (header_rows, body_rows) = self.header_and_body_rows(); + let nrow = header_rows.len() + body_rows.len(); + let ncol = self.ncol(); + let mut grid: Vec>>> = (0..nrow) + .map(|_| (0..ncol).map(|_| None).collect()) + .collect(); + for cell in self + .cells + .iter() + .filter(|cell| cell.kind != TableCellKind::Caption) + { + for row in &mut grid[cell.top..=cell.bottom] { + for col in &mut row[cell.left..=cell.right] { + *col = Some(Slot::Skip); + } + } + grid[cell.top][cell.left] = Some(Slot::Cell(cell)); + } + + grid.into_iter() + .enumerate() + .map(|(row, row_slots)| { + let slots = row_slots + .into_iter() + .map(|slot| { + slot.expect( + "a resolved table's cells cover every grid position; a gap here is an \ + execute bug", + ) + }) + .collect(); + (row, slots) + }) + .collect() + } + + /// Stack `bottom` below `self`, offsetting `bottom`'s cells down by + /// `self`'s row extent and concatenating both sides' `rows` — no + /// offsetting needed there, since a `TableRow`'s position *is* its + /// index, unlike a `TableCell`'s rectangle. + pub(crate) fn rowbind(self, mut bottom: TableLayout) -> TableLayout { + debug_assert_eq!( + self.columns, bottom.columns, + "rowbind both sides to have identical columns" + ); + if self.cells.is_empty() { + return bottom; + } + if bottom.cells.is_empty() { + return self; + } + + let row_offset = self.nrow(); + for cell in &mut bottom.cells { + cell.offset_rows(row_offset); + } + + let mut rows = self.rows; + rows.extend(bottom.rows); + let mut cells = self.cells; + cells.extend(bottom.cells); + + TableLayout::new(self.columns, rows, cells) + } + + /// Check a fully composed layout: shape extents, caption placement, + /// header-before-body ordering, and cell overlaps — one `validate_*` + /// helper per invariant. + pub fn validate(&self) -> Result<()> { + self.validate_shape()?; + self.validate_caption()?; + self.validate_row_order()?; + self.validate_overlaps() + } + + /// `rows` and `columns` must match the extents `cells` implies. + fn validate_shape(&self) -> Result<()> { + let row_extent = count_cell_rows(&self.cells); + if self.rows.len() != row_extent { + return Err(GgsqlError::ValidationError(format!( + "Table layout has {} rows but its cells span {row_extent}", + self.rows.len() + ))); + } + if !self.cells.is_empty() { + let col_extent = count_cell_cols(&self.cells); + if self.columns.len() != col_extent { + return Err(GgsqlError::ValidationError(format!( + "Table layout has {} columns but its cells span {col_extent}", + self.columns.len() + ))); + } + } + Ok(()) + } + + /// The caption row is the one row a `Caption` cell may occupy: at most + /// one, always the layout's last, never a header. + fn validate_caption(&self) -> Result<()> { + let caption_rows: Vec = self + .rows + .iter() + .enumerate() + .filter_map(|(idx, row)| row.is_caption.then_some(idx)) + .collect(); + if caption_rows.len() > 1 { + return Err(GgsqlError::ValidationError(format!( + "Table layout has {} caption rows; at most one is allowed", + caption_rows.len() + ))); + } + let caption_cells: Vec<&TableCell> = self + .cells + .iter() + .filter(|cell| cell.kind == TableCellKind::Caption) + .collect(); + if let Some(&caption_row) = caption_rows.first() { + if self.rows[caption_row].is_header { + return Err(GgsqlError::ValidationError( + "Table layout's caption row is also marked as a header row".to_string(), + )); + } + if caption_row != self.nrow() - 1 { + return Err(GgsqlError::ValidationError(format!( + "Table layout's caption row is row {caption_row}, not its last row" + ))); + } + if caption_cells.len() != 1 || caption_cells[0].top != caption_row { + return Err(GgsqlError::ValidationError(format!( + "Table layout's caption row (row {caption_row}) must be occupied by exactly one Caption cell" + ))); + } + } else if !caption_cells.is_empty() { + return Err(GgsqlError::ValidationError( + "Table layout has a Caption cell but no caption row".to_string(), + )); + } + Ok(()) + } + + /// Every header row must sit above every body row — writers render the + /// two as separate blocks (HTML's ``/``). + fn validate_row_order(&self) -> Result<()> { + let (header_rows, body_rows) = self.header_and_body_rows(); + if let (Some(&max_header_row), Some(&min_body_row)) = + (header_rows.last(), body_rows.first()) + { + if max_header_row >= min_body_row { + return Err(GgsqlError::ValidationError(format!( + "Table layout renders headers and body as separate blocks, so every header \ + row must sit above every body row; found a header row at {max_header_row} \ + at or below a body row at {min_body_row}" + ))); + } + } + Ok(()) + } + + /// No two cells may claim the same grid position. + /// + /// Walks every cell's full footprint into a map of occupied positions to + /// the `TableCellKind` that claimed each one, erroring — naming both + /// kinds — on the first double claim. `O(total cell area)`. + fn validate_overlaps(&self) -> Result<()> { + let mut occupied: std::collections::HashMap<(usize, usize), TableCellKind> = + std::collections::HashMap::new(); + + for cell in &self.cells { + for row in cell.top..=cell.bottom { + for col in cell.left..=cell.right { + if let Some(existing_kind) = occupied.insert((row, col), cell.kind) { + return Err(GgsqlError::ValidationError(format!( + "Table layout has a {existing_kind} cell and a {} cell clashing at row {row}, column {col}", + cell.kind + ))); + } + } + } + } + + Ok(()) + } + + /// Consume `self` into just its cells. + #[cfg(test)] + pub(crate) fn into_cells(self) -> Vec { + self.cells + } +} +/// Build the title/subtitle heading rows (gt's `title_row`/`subtitle_row`), +/// each spanning the table's full width and numbered locally from +/// `top == 0`. Each row carries `TableClass::Heading` (gt's `gt_heading`). +pub(crate) fn create_heading( + title: Option<&str>, + subtitle: Option<&str>, + columns: &[TableColumn], +) -> TableLayout { + let ncol = columns.len().max(1); + let mut cells = Vec::new(); + if let Some(title) = title { + cells.push(TableCell::new_title(ncol, title.to_string())); + } + if let Some(subtitle) = subtitle { + let row = cells.len(); + cells.push(TableCell::new_subtitle(row, ncol, subtitle.to_string())); + } + let rows = vec![ + TableRow { + classes: vec![TableClass::Heading], + ..TableRow::header() + }; + cells.len() + ]; + TableLayout::new(columns.to_vec(), rows, cells) +} +/// Build the caption cell (gt's `tab_caption()`), spanning the table's full +/// width at local row 0; `rowbind` places it at the very end of the layout. +/// `HtmlWriter` pulls this cell out of the grid before rendering, since HTML +/// requires ``/``. Its row carries no +/// class — a caption never lands in a rendered ``. +pub(crate) fn create_caption(caption: Option<&str>, columns: &[TableColumn]) -> TableLayout { + match caption { + Some(caption) => TableLayout::new( + columns.to_vec(), + vec![TableRow::caption()], + vec![TableCell::new_caption( + columns.len().max(1), + caption.to_string(), + )], + ), + None => TableLayout::new(columns.to_vec(), Vec::new(), Vec::new()), + } +} +/// Build one `ColumnLabel` cell per non-stub column, numbered locally from +/// `top == 0`. Skips every stub column; `create_stubhead` builds those. +pub(crate) fn create_column_labels(columns: &[TableColumn]) -> Vec { + columns + .iter() + .enumerate() + .filter(|(_, column)| !column.is_stub()) + .map(|(index, column)| { + TableCell::new_column_label(index, column.label.clone()) + .with_properties(column.properties.clone()) + }) + .collect() +} + +/// Pop the reserved `title`/`subtitle`/`caption` keys out of a `TABULATE` +/// `LABEL` clause's resolved map, leaving only genuine column-name +/// overrides behind. `.flatten()` treats "key absent" and "key present but +/// NULL" the same: neither produces a title/subtitle/caption. +pub(crate) fn extract_heading_labels( + labels: &mut Labels, +) -> (Option, Option, Option) { + let title = labels.labels.remove("title").flatten(); + let subtitle = labels.labels.remove("subtitle").flatten(); + let caption = labels.labels.remove("caption").flatten(); + (title, subtitle, caption) +} + +/// Move every STUB-targeted column to the front, preserving each group's +/// relative order. Runs after `reorder_table_columns`, as the final word on +/// column position — a SPAN can never cover a stub column +/// (`Table::validate_span_stub_boundary`). +pub(crate) fn move_stub_columns(columns: Vec) -> Vec { + let (mut stub, body): (Vec<_>, Vec<_>) = columns.into_iter().partition(TableColumn::is_stub); + stub.extend(body); + stub +} +/// Stack spanner rows above column labels, letting a column with no spanner +/// covering it stretch its own label upward over the gap, then fill the +/// remaining gaps. +pub(crate) fn compose_header(spanners: TableLayout, column_labels: TableLayout) -> TableLayout { + let num_spanner_rows = spanners.nrow(); + let header = spanners.rowbind(column_labels); + let header = stretch_unspanned_column_labels(header, num_spanner_rows); + fill_spanner_gaps(header, num_spanner_rows) +} + +/// Grow a column's label cell upward into every consecutive spanner row +/// above it (starting from the row closest to the labels) that has no +/// spanner covering that column, stopping at the first one that does — +/// turning those rows into a `rowspan` on the label instead of separate +/// blank filler cells. gt only stretches into the single row immediately +/// above the labels, even when rows further up are also empty. Only touches +/// cells — row count/order is unaffected. +fn stretch_unspanned_column_labels( + mut header: TableLayout, + num_spanner_rows: usize, +) -> TableLayout { + if num_spanner_rows == 0 { + return header; + } + + let ncol = header.ncol(); + let mut stretch_depth = vec![0usize; ncol]; + for (column, depth) in stretch_depth.iter_mut().enumerate() { + for row in (0..num_spanner_rows).rev() { + let covered = header.cells.iter().any(|c| { + c.kind == TableCellKind::Spanner + && c.top == row + && c.left <= column + && column <= c.right + }); + if covered { + break; + } + *depth += 1; + } + } + + for label in header + .cells + .iter_mut() + // A stub column can never be spanned (`Table::validate_span_stub_boundary`), + // so its stretch always reaches the full spanner height. + .filter(|c| matches!(c.kind, TableCellKind::ColumnLabel | TableCellKind::StubHead)) + { + // Assumes every label cell is exactly one column wide (true of + // everything create_column_labels/create_stubhead produce) — a wider + // one would need its own stretch depth reconciled across its whole + // span, not just `left`. + label.top -= stretch_depth[label.left]; + } + + header +} + +/// Fill every spanner-row position the stretch above couldn't reach with a +/// real, empty `Filler` cell. +fn fill_spanner_gaps(mut header: TableLayout, num_spanner_rows: usize) -> TableLayout { + let ncol = header.ncol(); + + // Coverage set, O(area) like `TableLayout::validate`. + let mut occupied: HashSet<(usize, usize)> = HashSet::new(); + for cell in &header.cells { + for row in cell.top..=cell.bottom { + for col in cell.left..=cell.right { + occupied.insert((row, col)); + } + } + } + + for row in 0..num_spanner_rows { + for col in 0..ncol { + if occupied.contains(&(row, col)) { + continue; + } + header.cells.push( + TableCell::new(TableCellKind::Filler, row, row, col, col, String::new()) + .with_classes(vec![TableClass::Spanner]), + ); + } + } + + header +} + +#[cfg(test)] +mod assembly_tests { + use super::*; + use crate::params::Parameters; + use crate::{ColumnSection, GgsqlError}; + + fn column(name: &str, label: &str) -> TableColumn { + TableColumn { + name: name.to_string(), + label: label.to_string(), + properties: Parameters::new(), + target: ColumnSection::Body, + } + } + + fn stub_column(name: &str) -> TableColumn { + TableColumn { + target: ColumnSection::Stub, + ..column(name, name) + } + } + #[test] + fn create_column_labels_builds_one_cell_per_column_at_row_zero() { + let columns = vec![column("id", "id"), column("name", "name")]; + + let labels = create_column_labels(&columns); + + assert_eq!(labels.len(), 2); + assert_eq!(labels[0].kind, TableCellKind::ColumnLabel); + assert_eq!(labels[0].top, 0); + assert_eq!(labels[0].bottom, 0); + assert_eq!(labels[0].left, 0); + assert_eq!(labels[0].right, 0); + assert_eq!(labels[0].content, "id"); + assert_eq!(labels[1].left, 1); + assert_eq!(labels[1].right, 1); + assert_eq!(labels[1].content, "name"); + } + + #[test] + fn create_column_labels_skips_a_stub_column() { + let columns = vec![stub_column("region"), column("sales", "sales")]; + + let labels = create_column_labels(&columns); + + assert_eq!(labels.len(), 1); + assert_eq!(labels[0].kind, TableCellKind::ColumnLabel); + assert_eq!(labels[0].left, 1); + assert_eq!(labels[0].content, "sales"); + } + + #[test] + fn move_stub_columns_moves_stub_columns_to_the_front_preserving_relative_order() { + let columns = vec![ + column("a", "a"), + stub_column("b"), + column("c", "c"), + stub_column("d"), + ]; + + let result = move_stub_columns(columns); + + assert_eq!( + result.iter().map(|c| c.name.as_str()).collect::>(), + vec!["b", "d", "a", "c"] + ); + } + + #[test] + fn move_stub_columns_is_a_no_op_with_no_stub_columns() { + let columns = vec![column("a", "a"), column("b", "b")]; + + let result = move_stub_columns(columns); + + assert_eq!( + result.iter().map(|c| c.name.as_str()).collect::>(), + vec!["a", "b"] + ); + } + fn cell(kind: TableCellKind, top: usize, bottom: usize, content: &str) -> TableCell { + TableCell::new(kind, top, bottom, 0, 0, content.to_string()) + } + + /// Wrap a hand-built `Vec` into a `TableLayout` for a test, + /// with exactly as many default rows and columns as `cells` implies — + /// satisfies `TableLayout::new`'s invariants without every test having + /// to spell out `rows`/`columns` vecs of its own. + fn layout(cells: Vec) -> TableLayout { + let ncol = count_cell_cols(&cells); + layout_wide(cells, ncol) + } + + /// `layout`, with an explicit column count — for a part that doesn't + /// itself touch every column of the table it belongs to (a spanner row + /// covering only some columns, or an empty part). + fn layout_wide(cells: Vec, ncol: usize) -> TableLayout { + let columns = (0..ncol).map(|i| column(&i.to_string(), "")).collect(); + let rows = vec![TableRow::default(); count_cell_rows(&cells)]; + TableLayout::new(columns, rows, cells) + } + + #[test] + fn rowbind_shifts_the_bottom_below_a_single_top_row() { + let column_labels = vec![cell(TableCellKind::ColumnLabel, 0, 0, "id")]; + let body = vec![ + cell(TableCellKind::Body, 0, 0, "1"), + cell(TableCellKind::Body, 1, 1, "2"), + ]; + + let cells = layout(column_labels).rowbind(layout(body)).cells; + + assert_eq!(cells.len(), 3); + assert_eq!(cells[0].kind, TableCellKind::ColumnLabel); + assert_eq!(cells[0].top, 0); + assert_eq!(cells[1].kind, TableCellKind::Body); + assert_eq!(cells[1].top, 1); + assert_eq!(cells[1].bottom, 1); + assert_eq!(cells[2].top, 2); + assert_eq!(cells[2].bottom, 2); + } + + #[test] + fn rowbind_offsets_by_the_top_rows_actual_extent_not_a_hardcoded_one() { + // `rowbind` offsets by `top.rows.len()` rather than assuming exactly + // one row — pin that down directly, independent of whichever caller + // happens to produce a multi-row `top`. + let column_labels = vec![cell(TableCellKind::ColumnLabel, 0, 1, "id")]; + let body = vec![cell(TableCellKind::Body, 0, 0, "1")]; + + let cells = layout(column_labels).rowbind(layout(body)).cells; + + assert_eq!(cells[1].top, 2); + assert_eq!(cells[1].bottom, 2); + } + + #[test] + fn rowbind_returns_bottom_unchanged_when_top_is_empty() { + let body = vec![cell(TableCellKind::Body, 0, 0, "1")]; + + let cells = layout_wide(Vec::new(), 1).rowbind(layout(body)).cells; + + assert_eq!(cells.len(), 1); + assert_eq!(cells[0].top, 0); + } + + #[test] + fn rowbind_returns_top_unchanged_when_bottom_is_empty() { + let column_labels = vec![cell(TableCellKind::ColumnLabel, 0, 0, "id")]; + + let cells = layout(column_labels) + .rowbind(layout_wide(Vec::new(), 1)) + .cells; + + assert_eq!(cells.len(), 1); + assert_eq!(cells[0].top, 0); + } + + #[test] + fn compose_header_stretches_an_unspanned_columns_label_over_the_gap() { + // "G" covers a, b (columns 0, 1) at the one spanner row; c has no + // spanner at all. c is a StubHead: a stub column is never spanned, + // so it stretches the same way an unspanned ColumnLabel does. + let spanners = vec![cell_at(TableCellKind::Spanner, 0, 0, 0, 1)]; + let column_labels = vec![ + cell_at(TableCellKind::ColumnLabel, 0, 0, 0, 0), + cell_at(TableCellKind::ColumnLabel, 0, 0, 1, 1), + cell_at(TableCellKind::StubHead, 0, 0, 2, 2), + ]; + + let header = compose_header(layout_wide(spanners, 3), layout(column_labels)).cells; + + let a = header + .iter() + .find(|c| c.kind == TableCellKind::ColumnLabel && c.left == 0) + .unwrap(); + let c = header + .iter() + .find(|c| c.kind == TableCellKind::StubHead && c.left == 2) + .unwrap(); + assert_eq!((a.top, a.bottom), (1, 1)); + assert_eq!((c.top, c.bottom), (0, 1)); + } + + #[test] + fn compose_header_stops_stretching_at_the_first_row_that_covers_the_column() { + // "Outer" (row 0, the far level) covers both a and b; "Inner" (row + // 1, next to the labels) covers only a. b's row-1 gap is contiguous + // with the labels, so it stretches by one row — but row 0 already + // covers b, so the stretch must stop there, not skip past it. + let spanners = vec![ + cell_at(TableCellKind::Spanner, 0, 0, 0, 1), + cell_at(TableCellKind::Spanner, 1, 1, 0, 0), + ]; + let column_labels = vec![ + cell_at(TableCellKind::ColumnLabel, 0, 0, 0, 0), + cell_at(TableCellKind::ColumnLabel, 0, 0, 1, 1), + ]; + + let header = compose_header(layout(spanners), layout(column_labels)).cells; + + let a = header + .iter() + .find(|c| c.kind == TableCellKind::ColumnLabel && c.left == 0) + .unwrap(); + let b = header + .iter() + .find(|c| c.kind == TableCellKind::ColumnLabel && c.left == 1) + .unwrap(); + assert_eq!((a.top, a.bottom), (2, 2)); + assert_eq!((b.top, b.bottom), (1, 2)); + } + + #[test] + fn compose_header_stretches_across_every_row_when_none_of_them_cover_the_column() { + // "G" (row 1) covers only a; "H" (row 0) covers only b; c has no + // spanner at either level, so its label absorbs both rows. + let spanners = vec![ + cell_at(TableCellKind::Spanner, 0, 0, 1, 1), + cell_at(TableCellKind::Spanner, 1, 1, 0, 0), + ]; + let column_labels = vec![ + cell_at(TableCellKind::ColumnLabel, 0, 0, 0, 0), + cell_at(TableCellKind::ColumnLabel, 0, 0, 1, 1), + cell_at(TableCellKind::ColumnLabel, 0, 0, 2, 2), + ]; + + let header = compose_header(layout_wide(spanners, 3), layout(column_labels)).cells; + + let c = header + .iter() + .find(|cell| cell.kind == TableCellKind::ColumnLabel && cell.left == 2) + .unwrap(); + assert_eq!((c.top, c.bottom), (0, 2)); + } + + #[test] + fn compose_header_fills_a_spanner_gap_a_stretch_cant_reach() { + // Stub column a, plus X(b,c) level 1, Y(c,d) level 2, Z(b,c) level 3: + // d at Z's row (topmost) and b at Y's row (middle) are gaps neither + // the stub head nor d's own label stretch reaches. + let spanners = vec![ + cell_at(TableCellKind::Spanner, 0, 0, 1, 2), // Z, topmost + cell_at(TableCellKind::Spanner, 1, 1, 2, 3), // Y, middle + cell_at(TableCellKind::Spanner, 2, 2, 1, 2), // X, bottom-most + ]; + let column_labels = vec![ + cell_at(TableCellKind::StubHead, 0, 0, 0, 0), + cell_at(TableCellKind::ColumnLabel, 0, 0, 1, 1), + cell_at(TableCellKind::ColumnLabel, 0, 0, 2, 2), + cell_at(TableCellKind::ColumnLabel, 0, 0, 3, 3), + ]; + + let header = compose_header(layout(spanners), layout(column_labels)).cells; + + let fillers: Vec<_> = header + .iter() + .filter(|c| c.kind == TableCellKind::Filler) + .collect(); + assert_eq!(fillers.len(), 2); + let d3 = fillers.iter().find(|c| c.top == 0 && c.left == 3).unwrap(); + assert_eq!(d3.classes, vec![TableClass::Spanner]); + let b2 = fillers.iter().find(|c| c.top == 1 && c.left == 1).unwrap(); + assert_eq!(b2.classes, vec![TableClass::Spanner]); + + // d's own label absorbs the third gap (row 2, X's row) via the + // stretch — no filler there. + let d_label = header + .iter() + .find(|c| c.kind == TableCellKind::ColumnLabel && c.left == 3) + .unwrap(); + assert_eq!((d_label.top, d_label.bottom), (2, 3)); + } + + #[test] + fn compose_header_does_nothing_when_there_are_no_spanners() { + let column_labels = vec![cell_at(TableCellKind::ColumnLabel, 0, 0, 0, 0)]; + + let header = compose_header(layout_wide(Vec::new(), 1), layout(column_labels)).cells; + + assert_eq!((header[0].top, header[0].bottom), (0, 0)); + } + + fn cell_at( + kind: TableCellKind, + top: usize, + bottom: usize, + left: usize, + right: usize, + ) -> TableCell { + TableCell::new(kind, top, bottom, left, right, String::new()) + } + + #[test] + fn header_and_body_rows_partitions_and_excludes_the_caption_row() { + let cells = vec![ + cell_at(TableCellKind::ColumnLabel, 0, 0, 0, 0), + cell_at(TableCellKind::Body, 1, 1, 0, 0), + cell_at(TableCellKind::Caption, 2, 2, 0, 0), + ]; + let rows = vec![ + TableRow::header(), + TableRow::body(), + // The caption's own row: flagged, so excluded from both vecs. + TableRow::caption(), + ]; + let layout = TableLayout::new(vec![column("a", "a")], rows, cells); + + let (header_rows, body_rows) = layout.header_and_body_rows(); + + assert_eq!(header_rows, [0]); + assert_eq!(body_rows, [1]); + } + + #[test] + fn validate_rejects_a_header_row_below_a_body_row() { + let cells = vec![ + cell_at(TableCellKind::Body, 0, 0, 0, 0), + cell_at(TableCellKind::ColumnLabel, 1, 1, 0, 0), + ]; + let rows = vec![TableRow::body(), TableRow::header()]; + let layout = TableLayout::new(vec![column("a", "a")], rows, cells); + + assert!(layout.validate().is_err()); + } + + #[test] + fn slots_by_row_returns_empty_for_no_cells() { + assert!(layout(vec![]).slots_by_row().is_empty()); + } + + #[test] + fn slots_by_row_marks_a_rowspans_continuation_row_as_skip() { + // Column 1 spans rows 0-1 — row 1's column 1 is a continuation, not + // a gap. Columns 0 and 2 are filled at every row too, since every + // grid position needs a real cell. + let cells = vec![ + cell_at(TableCellKind::ColumnLabel, 0, 1, 1, 1), + cell_at(TableCellKind::ColumnLabel, 0, 0, 0, 0), + cell_at(TableCellKind::ColumnLabel, 0, 0, 2, 2), + cell_at(TableCellKind::ColumnLabel, 1, 1, 0, 0), + cell_at(TableCellKind::ColumnLabel, 1, 1, 2, 2), + ]; + + let layout = layout(cells); + let slots = layout.slots_by_row(); + + let row1 = &slots[&1]; + assert_eq!(row1.len(), 3); + assert!(matches!(&row1[0], Slot::Cell(cell) if cell.left == 0)); + assert!(matches!(&row1[1], Slot::Skip)); + assert!(matches!(&row1[2], Slot::Cell(cell) if cell.left == 2)); + } + + #[test] + fn slots_by_row_excludes_the_caption() { + let cells = vec![ + cell_at(TableCellKind::Body, 0, 0, 0, 0), + cell_at(TableCellKind::Caption, 1, 1, 0, 0), + ]; + let layout = TableLayout::new( + vec![column("a", "a")], + vec![TableRow::body(), TableRow::caption()], + cells, + ); + + let slots = layout.slots_by_row(); + + assert_eq!(slots.len(), 1); + assert!(slots.contains_key(&0)); + } + + #[test] + #[should_panic(expected = "a resolved table's cells cover every grid position")] + fn slots_by_row_panics_on_a_genuine_gap() { + // execute is expected to have already filled this with a real + // Filler cell — reaching a writer at all means a bug upstream. + let cells = vec![cell_at(TableCellKind::Spanner, 0, 0, 0, 0)]; + + layout_wide(cells, 2).slots_by_row(); + } + + #[test] + fn validate_overlaps_accepts_a_disjoint_layout() { + let cells = vec![ + cell_at(TableCellKind::ColumnLabel, 0, 0, 0, 0), + cell_at(TableCellKind::ColumnLabel, 0, 0, 1, 1), + cell_at(TableCellKind::Body, 1, 1, 0, 0), + cell_at(TableCellKind::Body, 1, 1, 1, 1), + ]; + + assert!(layout(cells).validate().is_ok()); + } + + #[test] + fn validate_overlaps_rejects_two_cells_at_the_same_position() { + let cells = vec![ + cell_at(TableCellKind::Body, 0, 0, 0, 0), + cell_at(TableCellKind::Body, 0, 0, 0, 0), + ]; + + let error = layout(cells).validate().unwrap_err(); + assert!(matches!(error, GgsqlError::ValidationError(msg) + if msg.contains("row 0, column 0") && msg.contains("body cell and a body cell"))); + } + + #[test] + fn validate_overlaps_rejects_a_spanning_cell_overlapping_a_later_one() { + // A cell spanning columns 0..=1 on row 0 overlapping a second cell + // that only touches column 1 on the same row — the shape a spanner + // bug or a bad spanner declaration would produce, not something + // disjoint labels/body can create on their own. + let cells = vec![ + cell_at(TableCellKind::ColumnLabel, 0, 0, 0, 1), + cell_at(TableCellKind::Spanner, 0, 0, 1, 1), + ]; + + let error = layout(cells).validate().unwrap_err(); + assert!(matches!(error, GgsqlError::ValidationError(msg) + if msg.contains("column label cell and a spanner cell"))); + } +} diff --git a/src/table/mod.rs b/src/table/mod.rs new file mode 100644 index 000000000..7bdb9c7be --- /dev/null +++ b/src/table/mod.rs @@ -0,0 +1,402 @@ +//! Table types for ggsql specification +//! +//! Defines the typed `Table` structure that represents parsed `TABULATE` +//! statements, parallel to how `plot` defines `Plot` for `VISUALISE` +//! statements: `source` (from `TABULATE FROM`), `selection` (from +//! `TABULATE`'s own column list), `labels` (from `TABULATE LABEL`), `spans` +//! (from `TABULATE SPAN`), and `formats` (from `TABULATE FORMAT`) are +//! populated so far. + +mod layout; +mod spanner; + +pub(crate) use layout::{ + compose_header, create_caption, create_column_labels, create_heading, extract_heading_labels, + move_stub_columns, standardise_hjust, Slot, +}; +pub use layout::{TableCell, TableCellKind, TableClass, TableColumn, TableLayout, TableRow}; +pub use spanner::Spanner; +pub(crate) use spanner::{create_spanners, reorder_table_columns}; + +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; + +use crate::labels::Labels; +use crate::params::{ + validate_parameter, DefaultParamValue, NumberConstraint, ParamConstraint, ParamDefinition, + Parameters, +}; +use crate::DataSource; + +/// Complete ggsql table specification. +/// +/// Parallel to [`crate::Plot`], but for `TABULATE` statements. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Table { + /// `FROM` source (CTE, table, or file path) from `TABULATE FROM`. Unlike + /// `Plot`, there are no layers to hold a per-layer source override, so + /// this is the only place a `TABULATE`'s data source can come from. + pub source: Option, + /// Column selection from `TABULATE`'s own column list, right after the + /// keyword (e.g. `TABULATE bill_len, bill_dep AS Depth`) — mandatory in + /// the grammar. Defaults to `*` for a `Table` built without the parser. + pub selection: Vec, + /// Column display labels (from `TABULATE LABEL`). Reuses `labels::Labels` + /// as-is — the same "name → text, None = suppress" shape applies + /// unchanged, just keyed by column name instead of aesthetic name. An + /// empty `Labels` means no overrides at all. + pub labels: Labels, + /// Column spanners (from `TABULATE SPAN`), one per `SPAN` clause written + /// — a query with several `SPAN` clauses produces one `Spanner` each, + /// not one `Spanner` with several groups (`SPAN` itself never bundles + /// more than one group per clause). + pub spans: Vec, + /// Cell formatting (from `TABULATE FORMAT`), one per `FORMAT` clause + /// written — same one-clause-per-group model as `spans`. + pub formats: Vec, +} + +/// One item in a TABULATE column selection. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum SelectionItem { + /// `*`. + Wildcard, + /// A column or a constant. `sql` is the verbatim source text (e.g. + /// `bill_dep AS Depth`); `name` is the output column name (`Depth`). + Column { + sql: String, + name: String, + source: Option, + }, +} + +impl Table { + /// Create a new empty Table. + pub fn new() -> Self { + Self { + source: None, + selection: vec![SelectionItem::Wildcard], + labels: Labels::default(), + spans: Vec::new(), + formats: Vec::new(), + } + } +} + +impl Default for Table { + fn default() -> Self { + Self::new() + } +} + +impl Table { + /// Expand a SPAN's `OVER` entry that names an earlier spanner's `id` + /// into that spanner's own columns — `SPAN y OVER x_id, c` (after + /// `SPAN x_id OVER a, b`) resolves to columns a, b, c for `y`. Only + /// ids from earlier spans are recognised; an id declared later, or a + /// genuine typo, is left as a literal string and caught downstream as + /// an unknown column, the same as any other bad reference. + /// + /// Lives here rather than alongside its sibling spanner-resolution + /// steps in `execute::table::spanner` because it operates on `Spanner` + /// alone, with no `DataFrame` involved — `validate()` calls it directly + /// to catch a duplicate id without depending on `execute`. + pub fn resolve_spanner_ids(&self) -> Result, String> { + let mut resolved: Vec = Vec::with_capacity(self.spans.len()); + let mut ids: std::collections::HashMap = std::collections::HashMap::new(); + + for span in &self.spans { + let columns = span + .columns + .iter() + .flat_map(|entry| match ids.get(entry) { + Some(&idx) => resolved[idx].columns.clone(), + None => vec![entry.clone()], + }) + .collect(); + + let mut resolved_span = span.clone(); + resolved_span.columns = columns; + + if ids + .insert(resolved_span.id.clone(), resolved.len()) + .is_some() + { + return Err(format!("Duplicate SPAN id '{}'", resolved_span.id)); + } + + resolved.push(resolved_span); + } + + Ok(resolved) + } + + /// Reject any SPAN naming a column that any FORMAT clause ever targeted + /// at STUB — spanners over the stub aren't supported yet. No + /// last-clause-wins resolution needed: a column that was ever declared + /// STUB is off-limits to SPAN, regardless of whether a later FORMAT + /// clause retargeted it to BODY. Takes already-`resolve_spanner_ids`- + /// resolved spans, so a SPAN's `OVER` id reference is checked against + /// the real columns it expands to, not the literal id text — and a + /// STUB format's own column list gets the same treatment, expanding any + /// entry that names a SPAN id into the columns it covers, so `FORMAT + /// STUB ` is checked against real columns too rather than the + /// literal id, which would never appear in a span's own `columns`. + /// + /// Needs no DataFrame: FORMAT/SPAN are both plain AST, so this runs from + /// both `validate()` and real execution (`resolve_spanners`), mirroring + /// `resolve_spanner_ids`. + pub fn validate_span_stub_boundary(&self, resolved_spans: &[Spanner]) -> Result<(), String> { + let stub_columns: HashSet<&str> = self + .formats + .iter() + .filter(|format| format.target.is_stub()) + .flat_map(|format| &format.columns) + .flat_map( + |name| match resolved_spans.iter().find(|span| &span.id == name) { + Some(span) => span.columns.iter().map(String::as_str).collect::>(), + None => vec![name.as_str()], + }, + ) + .collect(); + + for (idx, span) in resolved_spans.iter().enumerate() { + span.validate_stub_boundary(&stub_columns) + .map_err(|e| format!("SPAN {}: {}", idx + 1, e))?; + } + + Ok(()) + } + + /// Fully resolve this table's spanners for execution: settings + /// validated, `OVER` ids folded, STUB boundary checked, columns + /// checked against the real schema, and each `label` overlaid with any + /// `LABEL => ...` entry. + pub fn resolve_spanners( + &self, + labels: &Labels, + column_names: Option<&[String]>, + ) -> Result, String> { + for (idx, spanner) in self.spans.iter().enumerate() { + spanner + .validate_settings() + .map_err(|e| format!("SPAN {}: {}", idx + 1, e))?; + } + let spans = self.resolve_spanner_ids()?; + self.validate_span_stub_boundary(&spans)?; + // None (validate()'s case) skips this: no real schema to check. + if let Some(names) = column_names { + for span in &spans { + span.validate_columns(names)?; + } + } + + Ok(spans + .into_iter() + .map(|mut span| { + span.apply_labels(labels); + span + }) + .collect()) + } +} + +/// Which section of the table a `FORMAT` clause's columns belong to. +/// +/// `FORMAT STUB` is the only thing that assigns a column to the table's +/// stub; `FORMAT BODY`, or no target identifier at all, leaves columns in +/// the regular body — `Body` is this enum's default for that reason. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ColumnSection { + #[default] + Body, + Stub, +} + +impl ColumnSection { + /// Whether this is the `STUB` section. + pub fn is_stub(self) -> bool { + self == ColumnSection::Stub + } +} + +/// One `FORMAT` clause: cell formatting for a group of columns. +/// +/// `settings` is validated against `FORMAT_PARAMS` and resolved into each +/// covered column's `TableCell` properties. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Format { + /// The columns this FORMAT applies to, in the order written. + pub columns: Vec, + /// Which section of the table these columns belong to (`BODY`, the + /// default, or `STUB`). + #[serde(default)] + pub target: ColumnSection, + /// `SETTING` parameters for this FORMAT (e.g. `hjust => 'right'`). + pub settings: Parameters, + /// Value mappings for custom cell display (`RENAMING` clause). Maps a raw + /// cell value to its display text; `None` suppresses the cell's text. + /// Same shape as `Scale::label_mapping` — named `value_mapping` rather + /// than `label_mapping` here because `Table::labels` already uses + /// "label" for column headers, a different concept from a cell's value. + #[serde(default)] + pub value_mapping: Option>>, + /// Template for generating display text from cell values (e.g. + /// `"{:num %.2f}"`), applied to values with no specific `value_mapping` + /// entry. Default `"{}"` passes the value through unchanged. Same shape + /// as `Scale::label_template`. + #[serde(default = "crate::format::default_template")] + pub value_template: String, +} + +/// `SETTING` parameters `FORMAT` accepts. +const FORMAT_PARAMS: &[ParamDefinition] = &[ + ParamDefinition { + name: "hjust", + default: DefaultParamValue::Number(0.5), + // Both spellings accepted here; resolve_column_properties standardises + // "centre" to "center" when it builds a column's TableCell properties. + constraint: ParamConstraint::string_option_or_number( + &["left", "right", "centre", "center"], + NumberConstraint::range(0.0, 1.0), + ), + }, + ParamDefinition { + name: "width", + // No default: an unset width leaves column sizing to the writer, + // not to a value resolved here. + default: DefaultParamValue::Null, + constraint: ParamConstraint::string_numeric_with_unit(&["px", "%"]), + }, +]; + +impl Format { + /// Validate `settings` against `FORMAT_PARAMS`. + pub fn validate_settings(&self) -> Result<(), String> { + let valid: Vec<&str> = FORMAT_PARAMS.iter().map(|p| p.name).collect(); + + for (name, value) in &self.settings { + let Some(param) = FORMAT_PARAMS.iter().find(|p| p.name == name.as_str()) else { + return Err(format!( + "FORMAT setting should be {}, not '{}'", + crate::or_list_quoted(&valid, '\''), + name + )); + }; + validate_parameter(name, value, ¶m.constraint)?; + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::plot::ParameterValue; + + fn format_with_settings(settings: Parameters) -> Format { + Format { + columns: vec!["a".to_string()], + target: ColumnSection::Body, + settings, + value_mapping: None, + value_template: "{}".to_string(), + } + } + + #[test] + fn format_validate_settings_accepts_a_string_hjust() { + let mut settings = Parameters::new(); + settings.insert( + "hjust".to_string(), + ParameterValue::String("right".to_string()), + ); + + assert!(format_with_settings(settings).validate_settings().is_ok()); + } + + #[test] + fn format_validate_settings_accepts_a_numeric_hjust() { + let mut settings = Parameters::new(); + settings.insert("hjust".to_string(), ParameterValue::Number(0.25)); + + assert!(format_with_settings(settings).validate_settings().is_ok()); + } + + #[test] + fn format_validate_settings_rejects_an_unrecognized_string() { + let mut settings = Parameters::new(); + settings.insert( + "hjust".to_string(), + ParameterValue::String("up".to_string()), + ); + + assert!(format_with_settings(settings).validate_settings().is_err()); + } + + #[test] + fn format_validate_settings_accepts_a_percent_or_pixel_width() { + let mut settings = Parameters::new(); + settings.insert( + "width".to_string(), + ParameterValue::String("20%".to_string()), + ); + assert!(format_with_settings(settings).validate_settings().is_ok()); + + let mut settings = Parameters::new(); + settings.insert( + "width".to_string(), + ParameterValue::String("240px".to_string()), + ); + assert!(format_with_settings(settings).validate_settings().is_ok()); + } + + #[test] + fn validate_span_stub_boundary_rejects_a_span_over_a_stub_column() { + let table = Table { + formats: vec![Format { + columns: vec!["a".to_string()], + target: ColumnSection::Stub, + ..format_with_settings(Parameters::new()) + }], + spans: vec![Spanner { + id: "S".to_string(), + label: Some("S".to_string()), + columns: vec!["a".to_string(), "sales".to_string()], + settings: Parameters::new(), + }], + ..Table::new() + }; + + let resolved = table.resolve_spanner_ids().unwrap(); + let error = table.validate_span_stub_boundary(&resolved).unwrap_err(); + assert!(error.contains("cannot include stub column 'a'")); + } + + #[test] + fn validate_span_stub_boundary_rejects_a_format_stub_naming_a_span_id() { + // FORMAT STUB naming a SPAN id rather than its columns directly must + // still be caught: `stub_columns` has to expand "G" into "a"/"b" + // before comparing, or this collision goes undetected. + let table = Table { + formats: vec![Format { + columns: vec!["G".to_string()], + target: ColumnSection::Stub, + ..format_with_settings(Parameters::new()) + }], + spans: vec![Spanner { + id: "G".to_string(), + label: Some("G".to_string()), + columns: vec!["a".to_string(), "b".to_string()], + settings: Parameters::new(), + }], + ..Table::new() + }; + + let resolved = table.resolve_spanner_ids().unwrap(); + let error = table.validate_span_stub_boundary(&resolved).unwrap_err(); + assert!(error.contains("cannot include stub column")); + } +} diff --git a/src/table/spanner.rs b/src/table/spanner.rs new file mode 100644 index 000000000..da63b1938 --- /dev/null +++ b/src/table/spanner.rs @@ -0,0 +1,718 @@ +//! `TABULATE SPAN`: the `Spanner` spec type (one `SPAN` clause) and its +//! resolution — column reordering (`gather`) and header-row (level) +//! assignment. + +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; + +use super::layout::{TableCell, TableClass, TableColumn, TableLayout, TableRow}; +use crate::labels::Labels; +use crate::params::{ + validate_parameter, DefaultParamValue, ParamConstraint, ParamDefinition, Parameters, +}; + +/// One `SPAN` clause: a named group of columns rendered as one spanner cell +/// above them. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Spanner { + /// This spanner's identifier, unparsed (quotes included, if quoted). + /// Referenced by a later `SPAN`'s `OVER` list or a `LABEL => + /// ...` override. `SPAN NULL` still gets a real, internally generated + /// id here — nothing else can reasonably reference it. + pub id: String, + /// Dequoted default display text. `None` for `SPAN NULL` — blank cell, + /// columns still group. `LABEL => ...` overrides either way. + pub label: Option, + /// The columns this spanner covers, in the order written. + pub columns: Vec, + /// `SETTING` parameters for this spanner (e.g. `width => '40%'`). + pub settings: Parameters, +} + +/// `SETTING` parameters `SPAN` accepts — one static name/default/constraint +/// list, mirroring how a `GeomTrait` declares `default_params()`, so +/// `Spanner::validate_settings` doesn't hand-roll its own type checks per key. +const SPAN_PARAMS: &[ParamDefinition] = &[ + ParamDefinition { + name: "gather", + default: DefaultParamValue::Boolean(true), + constraint: ParamConstraint::boolean(), + }, + ParamDefinition { + name: "level", + // No default: absence means "assign automatically" (see + // assign_spanner_levels), not "assume some fixed level". + default: DefaultParamValue::Null, + constraint: ParamConstraint::count(1.0), + }, +]; + +impl Spanner { + /// Validate `settings` against `SPAN_PARAMS`, mirroring + /// `Layer::validate_settings`. + pub fn validate_settings(&self) -> Result<(), String> { + let valid: Vec<&str> = SPAN_PARAMS.iter().map(|p| p.name).collect(); + + for (name, value) in &self.settings { + // Key isn't in SPAN_PARAMS at all. + let Some(param) = SPAN_PARAMS.iter().find(|p| p.name == name.as_str()) else { + return Err(format!( + "SPAN setting should be {}, not '{}'", + crate::or_list_quoted(&valid, '\''), + name + )); + }; + // Known key, but wrong shape (e.g. `gather` not a boolean). + validate_parameter(name, value, ¶m.constraint)?; + } + + Ok(()) + } + + /// Check this spanner's columns against the table's STUB columns. + pub fn validate_stub_boundary(&self, stub_columns: &HashSet<&str>) -> Result<(), String> { + if let Some(name) = self + .columns + .iter() + .find(|name| stub_columns.contains(name.as_str())) + { + return Err(format!("cannot include stub column '{name}'.")); + } + + Ok(()) + } + + /// Check this spanner's columns and `id` against the real query + /// result's column names. + pub fn validate_columns(&self, column_names: &[String]) -> Result<(), String> { + for name in &self.columns { + if !column_names.contains(name) { + return Err(format!("SPAN references unknown column '{name}'")); + } + } + + if column_names.contains(&self.id) { + return Err(format!( + "SPAN id '{}' collides with an existing column name", + self.id + )); + } + + Ok(()) + } + + /// Overlay a `LABEL => ...` entry onto `label`, if the query has + /// one for this spanner's id; otherwise leaves `label` as-is. + pub fn apply_labels(&mut self, labels: &Labels) { + if let Some(override_label) = labels.lookup(&self.id) { + self.label = override_label.map(|s| s.to_string()); + } + } +} + +/// Reorder `columns` so every `gather`-enabled spanner's members become +/// contiguous, folding spanners in declaration order. `SETTING gather => +/// false` leaves a spanner's columns in place. Touches column order only; +/// header-row assignment is `assign_spanner_levels`' concern. +pub(crate) fn reorder_table_columns( + mut columns: Vec, + spans: &[Spanner], +) -> Vec { + for span in spans { + let gather = span + .settings + .get("gather") + .and_then(|value| value.as_bool()) + .unwrap_or(true); + + if gather { + columns = gather_columns(columns, &span.columns); + } + } + + columns +} + +/// Move `members` (in the order given) to sit contiguously where the first +/// of them currently is, pulling the rest in around it — the minimal move +/// that unifies them, leaving every other column's relative order untouched. +fn gather_columns(columns: Vec, members: &[String]) -> Vec { + let Some(anchor) = members.first() else { + return columns; + }; + + let anchor_index = columns + .iter() + .position(|c| &c.name == anchor) + .expect("Table::resolve_spanners already validated every OVER column exists"); + let insertion_index = columns[..anchor_index] + .iter() + .filter(|c| !members.contains(&c.name)) + .count(); + + let mut remainder = Vec::with_capacity(columns.len()); + let mut by_name: std::collections::HashMap = + std::collections::HashMap::new(); + for column in columns { + if members.contains(&column.name) { + by_name.insert(column.name.clone(), column); + } else { + remainder.push(column); + } + } + + let mut result: Vec = remainder.drain(..insertion_index).collect(); + for name in members { + let column = by_name + .remove(name) + .expect("Table::resolve_spanners already validated every OVER column exists"); + result.push(column); + } + result.extend(remainder); + + result +} + +/// Assign each spanner a 1-indexed level (header row). An explicit `SETTING +/// level => N` pins a spanner there with no conflict check — +/// `TableLayout::validate` catches a genuine clash once real cells exist. A +/// spanner with no explicit level is assigned greedily (see the `None` +/// arm). Levels are then compacted to remove gaps explicit levels can leave +/// (e.g. 1, 3, 4 → 1, 2, 3). +/// +/// Infallible: `level`'s type is checked by `Spanner::validate_settings`. +fn assign_spanner_levels(spans: &[Spanner]) -> Vec { + let mut levels: Vec = Vec::with_capacity(spans.len()); + + for span in spans { + let level = match span.settings.get("level") { + Some(value) => value + .as_number() + .expect("Spanner::validate_settings already checked 'level' is a number") + as usize, + None => { + // One more than the highest level of any already-assigned + // spanner whose columns intersect this one's. Never revisits + // a lower level, so a chain of pairwise-but-not-all + // conflicting spanners can use more levels than strictly + // necessary. + spans + .iter() + .zip(&levels) + .filter(|(other, _)| other.columns.iter().any(|c| span.columns.contains(c))) + .map(|(_, &level)| level) + .max() + .unwrap_or(0) + + 1 + } + }; + levels.push(level); + } + + // Compact gaps left by explicit levels (e.g. 1, 3, 4) into a dense + // range (1, 2, 3). + let mut distinct = levels.clone(); + distinct.sort_unstable(); + distinct.dedup(); + + levels + .into_iter() + .map(|level| distinct.binary_search(&level).unwrap() + 1) + .collect() +} + +/// Build one `TableCell` per contiguous run of a spanner's columns, calling +/// `assign_spanner_levels` itself. Rows are numbered locally from +/// `top == 0`. The layout's `rows` gets exactly `max_level` entries, each +/// carrying `TableClass::ColHeadingRow`, like the column-label row does. +pub(crate) fn create_spanners(columns: &[TableColumn], spans: &[Spanner]) -> TableLayout { + if spans.is_empty() { + return TableLayout::new(columns.to_vec(), Vec::new(), Vec::new()); + } + + // Spanners with no display text (`SPAN NULL`, or `LABEL => NULL`) + // contribute no cells, so their level shouldn't affect other levels. + let spans: Vec = spans + .iter() + .filter(|s| s.label.is_some()) + .cloned() + .collect(); + + let levels = assign_spanner_levels(&spans); + let max_level = levels.iter().copied().max().unwrap_or(0); + + let mut cells = Vec::new(); + + for (span, &level) in spans.iter().zip(&levels) { + let label = span + .label + .as_ref() + .expect("spans is filtered to only Some(label) spanners"); + // Row 0 is topmost. Level 1 is bottom-most. + let row = max_level - level; + + // Run-length encode the span's columns; disjoint columns give + // multiple runs. + let mut run_start = None; + for (index, column) in columns.iter().enumerate() { + // Does column belong to span? + let in_span = span.columns.contains(&column.name); + match (in_span, run_start) { + // Found column in span, initiate new run + (true, None) => run_start = Some(index), + // Column not in span, end run and push cell + (false, Some(start)) => { + cells.push(TableCell::new_spanner(row, start, index - 1, label.clone())); + run_start = None; + } + _ => {} + } + } + // Close a run still open at the last column. + if let Some(start) = run_start { + cells.push(TableCell::new_spanner( + row, + start, + columns.len() - 1, + label.clone(), + )); + } + } + + TableLayout::new( + columns.to_vec(), + vec![ + TableRow { + classes: vec![TableClass::ColHeadingRow, TableClass::SpannerRow], + ..TableRow::header() + }; + max_level + ], + cells, + ) +} + +#[cfg(test)] +mod tests { + use super::super::layout::TableCellKind; + use super::*; + use crate::plot::{ParameterValue, Parameters}; + use crate::ColumnSection; + + fn column(name: &str, label: &str) -> TableColumn { + TableColumn { + name: name.to_string(), + label: label.to_string(), + properties: Parameters::new(), + target: ColumnSection::Body, + } + } + + fn names(columns: &[TableColumn]) -> Vec<&str> { + columns.iter().map(|c| c.name.as_str()).collect() + } + + fn spanner_with(columns: &[&str], label: Option<&str>, settings: Parameters) -> Spanner { + Spanner { + id: label.unwrap_or("").to_string(), + label: label.map(str::to_string), + columns: columns.iter().map(|s| s.to_string()).collect(), + settings, + } + } + + fn spanner(columns: &[&str]) -> Spanner { + spanner_with(columns, Some(""), Parameters::new()) + } + + fn spanner_with_setting(columns: &[&str], key: &str, value: ParameterValue) -> Spanner { + let mut settings = Parameters::new(); + settings.insert(key.to_string(), value); + spanner_with(columns, Some(""), settings) + } + + fn labeled_spanner(columns: &[&str], label: &str) -> Spanner { + spanner_with(columns, Some(label), Parameters::new()) + } + + fn null_spanner(columns: &[&str]) -> Spanner { + spanner_with(columns, None, Parameters::new()) + } + + #[test] + fn gather_columns_moves_members_next_to_the_anchor_pulling_from_the_right() { + let columns = vec![ + column("x", "x"), + column("a", "a"), + column("y", "y"), + column("b", "b"), + column("z", "z"), + column("c", "c"), + column("w", "w"), + ]; + + let result = gather_columns( + columns, + &["a".to_string(), "b".to_string(), "c".to_string()], + ); + + assert_eq!(names(&result), vec!["x", "a", "b", "c", "y", "z", "w"]); + } + + #[test] + fn gather_columns_uses_declared_order_not_original_order() { + // Members are scattered as C, A, B originally, but declared as + // [A, B, C] — gathering must produce A, B, C (declared order), not + // C, A, B (original relative order). + let columns = vec![ + column("c", "c"), + column("x", "x"), + column("a", "a"), + column("y", "y"), + column("b", "b"), + ]; + + let result = gather_columns( + columns, + &["a".to_string(), "b".to_string(), "c".to_string()], + ); + + assert_eq!(names(&result), vec!["x", "a", "b", "c", "y"]); + } + + #[test] + fn gather_columns_is_a_no_op_when_already_contiguous() { + let columns = vec![column("a", "a"), column("b", "b"), column("c", "c")]; + + let result = gather_columns(columns, &["a".to_string(), "b".to_string()]); + + assert_eq!(names(&result), vec!["a", "b", "c"]); + } + + #[test] + #[should_panic(expected = "Table::resolve_spanners already validated")] + fn gather_columns_panics_on_an_unvalidated_unknown_column() { + let columns = vec![column("a", "a"), column("b", "b")]; + + gather_columns(columns, &["a".to_string(), "nope".to_string()]); + } + + #[test] + fn reorder_table_columns_defaults_gather_to_true() { + let columns = vec![ + column("a", "a"), + column("x", "x"), + column("b", "b"), + column("y", "y"), + ]; + let spans = vec![spanner(&["a", "b"])]; + + let result = reorder_table_columns(columns, &spans); + + assert_eq!(names(&result), vec!["a", "b", "x", "y"]); + } + + #[test] + fn reorder_table_columns_skips_gather_false() { + let columns = vec![ + column("a", "a"), + column("x", "x"), + column("b", "b"), + column("y", "y"), + ]; + let spans = vec![spanner_with_setting( + &["a", "b"], + "gather", + ParameterValue::Boolean(false), + )]; + + let result = reorder_table_columns(columns, &spans); + + assert_eq!(names(&result), vec!["a", "x", "b", "y"]); + } + + #[test] + fn reorder_table_columns_folds_spanners_in_declaration_order() { + let columns = vec![ + column("a", "a"), + column("x", "x"), + column("b", "b"), + column("y", "y"), + ]; + // Second span's gather runs against the *result* of the first, not + // the original order. + let spans = vec![spanner(&["a", "b"]), spanner(&["b", "y"])]; + + let result = reorder_table_columns(columns, &spans); + + assert_eq!(names(&result), vec!["a", "b", "y", "x"]); + } + + #[test] + fn assign_spanner_levels_needs_three_levels_for_mutually_crossing_spanners() { + let spans = vec![ + spanner(&["a", "b"]), + spanner(&["b", "c"]), + spanner(&["a", "c"]), + ]; + + let levels = assign_spanner_levels(&spans); + + assert_eq!(levels, vec![1, 2, 3]); + } + + #[test] + fn assign_spanner_levels_pushes_a_chained_conflict_past_a_free_level() { + // X(a,b) and Z(c,d) share no column and could both sit at level 1, + // but Z conflicts with Y(b,c), which conflicts with X — matching + // gt, Z gets pushed to level 3 rather than reusing X's level 1. + let spans = vec![ + spanner(&["a", "b"]), + spanner(&["b", "c"]), + spanner(&["c", "d"]), + ]; + + let levels = assign_spanner_levels(&spans); + + assert_eq!(levels, vec![1, 2, 3]); + } + + #[test] + fn assign_spanner_levels_pins_explicit_level_without_a_conflict_check() { + // (a,b) auto-assigns to level 1; (b,c) is explicitly pinned to level + // 1 too, despite conflicting with (a,b) — this function doesn't + // error on that, TableLayout::validate does once real cells exist. + let spans = vec![ + spanner(&["a", "b"]), + spanner_with_setting(&["b", "c"], "level", ParameterValue::Number(1.0)), + ]; + + let levels = assign_spanner_levels(&spans); + + assert_eq!(levels, vec![1, 1]); + } + + #[test] + fn assign_spanner_levels_compacts_gaps_left_by_explicit_levels() { + let spans = vec![ + spanner_with_setting(&["a"], "level", ParameterValue::Number(1.0)), + spanner_with_setting(&["b"], "level", ParameterValue::Number(3.0)), + spanner_with_setting(&["c"], "level", ParameterValue::Number(4.0)), + ]; + + let levels = assign_spanner_levels(&spans); + + assert_eq!(levels, vec![1, 2, 3]); + } + + #[test] + fn create_spanners_builds_one_cell_per_disjoint_spanner_on_the_same_row() { + let columns = vec![ + column("a", "a"), + column("b", "b"), + column("c", "c"), + column("d", "d"), + ]; + let spans = vec![ + labeled_spanner(&["a", "b"], "G1"), + labeled_spanner(&["c", "d"], "G2"), + ]; + + let cells = create_spanners(&columns, &spans).into_cells(); + + assert_eq!(cells.len(), 2); + assert_eq!(cells[0].top, 0); + assert_eq!(cells[0].bottom, 0); + assert_eq!(cells[0].left, 0); + assert_eq!(cells[0].right, 1); + assert_eq!(cells[0].content, "G1"); + assert_eq!(cells[1].left, 2); + assert_eq!(cells[1].right, 3); + assert_eq!(cells[1].content, "G2"); + assert!(cells.iter().all(|c| c.kind == TableCellKind::Spanner)); + } + + #[test] + fn create_spanners_puts_crossing_spanners_on_separate_rows() { + let columns = vec![column("a", "a"), column("b", "b"), column("c", "c")]; + let spans = vec![ + labeled_spanner(&["a", "b"], "G1"), + labeled_spanner(&["b", "c"], "G2"), + ]; + + let cells = create_spanners(&columns, &spans).into_cells(); + + // Level 1 (G1, closest to the columns) is the bottom spanner row — + // the higher local row number, since row 0 is the topmost row. + let g1 = cells.iter().find(|c| c.content == "G1").unwrap(); + let g2 = cells.iter().find(|c| c.content == "G2").unwrap(); + assert_eq!(g1.top, 1); + assert_eq!(g1.left, 0); + assert_eq!(g1.right, 1); + assert_eq!(g2.top, 0); + assert_eq!(g2.left, 1); + assert_eq!(g2.right, 2); + } + + #[test] + fn create_spanners_fragments_a_non_contiguous_spanner_into_multiple_cells() { + let columns = vec![column("a", "a"), column("x", "x"), column("b", "b")]; + let spans = vec![labeled_spanner(&["a", "b"], "G")]; + + let cells = create_spanners(&columns, &spans).into_cells(); + + assert_eq!(cells.len(), 2); + assert!(cells.iter().all(|c| c.content == "G")); + assert_eq!(cells[0].left, 0); + assert_eq!(cells[0].right, 0); + assert_eq!(cells[1].left, 2); + assert_eq!(cells[1].right, 2); + } + + #[test] + fn create_spanners_skips_null_labeled_spanners_and_their_levels() { + // If the NULL spanner participated in level assignment, it would + // cross (a,b) and (b,c) and force "G" to level 2 — filtering it out + // first means "G" is the only spanner left, so it stays at level 1. + let columns = vec![column("a", "a"), column("b", "b"), column("c", "c")]; + let spans = vec![null_spanner(&["a", "b"]), labeled_spanner(&["b", "c"], "G")]; + + let cells = create_spanners(&columns, &spans).into_cells(); + + assert_eq!(cells.len(), 1); + assert_eq!(cells[0].top, 0); + assert_eq!(cells[0].content, "G"); + assert_eq!(cells[0].left, 1); + assert_eq!(cells[0].right, 2); + } + + #[test] + fn create_spanners_returns_empty_for_no_spanners() { + let columns = vec![column("a", "a"), column("b", "b")]; + + let cells = create_spanners(&columns, &[]).into_cells(); + + assert!(cells.is_empty()); + } + + fn spanner_with_id(columns: &[&str], id: &str) -> Spanner { + Spanner { + id: id.to_string(), + ..spanner_with(columns, Some(""), Parameters::new()) + } + } + + fn table_with_spans(spans: Vec) -> crate::Table { + crate::Table { + spans, + ..crate::Table::new() + } + } + + #[test] + fn resolve_spanner_ids_expands_a_reference_to_an_earlier_spanners_columns() { + let table = table_with_spans(vec![ + spanner_with_id(&["a", "b"], "x"), + spanner(&["x", "c"]), + ]); + + let resolved = table.resolve_spanner_ids().unwrap(); + + assert_eq!(resolved[1].columns, vec!["a", "b", "c"]); + } + + #[test] + fn resolve_spanner_ids_leaves_a_forward_reference_unresolved() { + // "x" is declared after it's referenced here — left as a literal + // string, to be caught downstream as an unknown column. + let table = table_with_spans(vec![ + spanner(&["x", "c"]), + spanner_with_id(&["a", "b"], "x"), + ]); + + let resolved = table.resolve_spanner_ids().unwrap(); + + assert_eq!(resolved[0].columns, vec!["x", "c"]); + } + + #[test] + fn resolve_spanner_ids_rejects_a_duplicate_id() { + let table = table_with_spans(vec![ + spanner_with_id(&["a"], "dup"), + spanner_with_id(&["b"], "dup"), + ]); + + assert!(table.resolve_spanner_ids().is_err()); + } + + #[test] + fn resolve_spanner_ids_resolves_transitively() { + // z -> y -> x: z references y, which already expanded its own + // reference to x by the time z is processed. + let table = table_with_spans(vec![ + spanner_with_id(&["a", "b"], "x"), + spanner_with_id(&["x", "c"], "y"), + spanner(&["y", "d"]), + ]); + + let resolved = table.resolve_spanner_ids().unwrap(); + + assert_eq!(resolved[2].columns, vec!["a", "b", "c", "d"]); + } + fn spanner_with_settings(settings: Parameters) -> Spanner { + Spanner { + id: "S".to_string(), + label: Some("S".to_string()), + columns: vec!["a".to_string()], + settings, + } + } + + #[test] + fn validate_settings_accepts_valid_gather_and_level() { + let mut settings = Parameters::new(); + settings.insert("gather".to_string(), ParameterValue::Boolean(false)); + settings.insert("level".to_string(), ParameterValue::Number(2.0)); + + assert!(spanner_with_settings(settings).validate_settings().is_ok()); + } + + #[test] + fn validate_settings_rejects_an_unknown_key() { + let mut settings = Parameters::new(); + settings.insert( + "width".to_string(), + ParameterValue::String("40%".to_string()), + ); + + assert!(spanner_with_settings(settings).validate_settings().is_err()); + } + + #[test] + fn validate_columns_rejects_an_unknown_column() { + let span = Spanner { + id: "S".to_string(), + label: Some("S".to_string()), + columns: vec!["nope".to_string()], + settings: Parameters::new(), + }; + + let error = span.validate_columns(&["a".to_string()]).unwrap_err(); + assert!(error.contains("SPAN references unknown column 'nope'")); + } + + #[test] + fn validate_columns_rejects_an_id_matching_a_real_column() { + let span = Spanner { + id: "a".to_string(), + label: Some("a".to_string()), + columns: vec!["b".to_string()], + settings: Parameters::new(), + }; + + let error = span + .validate_columns(&["a".to_string(), "b".to_string()]) + .unwrap_err(); + assert!(error.contains("SPAN id 'a' collides with an existing column name")); + } +} diff --git a/src/tests/dialect_live.rs b/src/tests/dialect_live.rs index e87b04205..736ae5905 100644 --- a/src/tests/dialect_live.rs +++ b/src/tests/dialect_live.rs @@ -72,7 +72,7 @@ mod battery; use ggsql::reader::connection::reader_from_uri; -use ggsql::reader::{ColumnInfo, Reader, Spec, SqlDialect, TableInfo}; +use ggsql::reader::{ColumnInfo, Reader, ResolvedSpec, SqlDialect, TableInfo}; use ggsql::{DataFrame, Result}; const TABLE: &str = battery::TABLE; @@ -132,7 +132,7 @@ impl Reader for SqlSpy<'_> { fn unregister(&self, name: &str) -> Result<()> { self.inner.unregister(name) } - fn execute(&self, query: &str) -> Result { + fn execute(&self, query: &str) -> Result { ggsql::reader::execute_with_reader(self, query) } fn dialect(&self) -> &dyn SqlDialect { @@ -313,9 +313,11 @@ fn run_battery(reader: &dyn Reader, ctx: &str, table: &str) { } /// Apply a case's live assertion to its spec. -fn check_expect(spec: &Spec, case: &battery::Case, ctx: &str, spy: &SqlSpy) { +fn check_expect(spec: &ResolvedSpec, case: &battery::Case, ctx: &str, spy: &SqlSpy) { let layer = || { - spec.layer_data(0) + spec.as_plot() + .unwrap() + .layer_data(0) .unwrap_or_else(|| panic!("{ctx}: case '{}' produced no layer data", case.name)) }; match case.expect { @@ -504,7 +506,7 @@ fn live_backend(scheme: &str) { .execute("VISUALISE DRAW point MAPPING id AS x, val AS y FROM ggsql_live_register") .unwrap_or_else(|e| panic!("{scheme}: plot from registered table failed: {e}")); assert_eq!( - spec.layer_data(0).map(|l| l.height()), + spec.as_plot().unwrap().layer_data(0).map(|l| l.height()), Some(4), "{scheme}: registered table row count" ); @@ -532,7 +534,12 @@ fn live_postgres_ggsql_params() { let spec = reader .execute("SELECT 1 AS x, 2 AS y VISUALISE DRAW point") .unwrap_or_else(|e| panic!("postgres+params: query failed: {e}")); - assert!(spec.layer_data(0).map(|l| l.height() > 0).unwrap_or(false)); + assert!(spec + .as_plot() + .unwrap() + .layer_data(0) + .map(|l| l.height() > 0) + .unwrap_or(false)); } #[test] @@ -672,8 +679,15 @@ fn live_datafusion_as_cache() { let query = "VISUALISE DRAW point MAPPING id AS x, val AS y FROM t"; let first = reader.execute(query).expect("first run (cache fill)"); let second = reader.execute(query).expect("second run (cache hit)"); - assert!(first.layer_data(0).map(|l| l.height() > 0).unwrap_or(false)); + assert!(first + .as_plot() + .unwrap() + .layer_data(0) + .map(|l| l.height() > 0) + .unwrap_or(false)); assert!(second + .as_plot() + .unwrap() .layer_data(0) .map(|l| l.height() > 0) .unwrap_or(false)); @@ -687,6 +701,12 @@ fn live_datafusion_as_cache() { let second = reader .execute(empty_query) .expect("empty second run (cache hit)"); - assert_eq!(first.layer_data(0).map(|l| l.height()), Some(0)); - assert_eq!(second.layer_data(0).map(|l| l.height()), Some(0)); + assert_eq!( + first.as_plot().unwrap().layer_data(0).map(|l| l.height()), + Some(0) + ); + assert_eq!( + second.as_plot().unwrap().layer_data(0).map(|l| l.height()), + Some(0) + ); } diff --git a/src/tests/e2e.rs b/src/tests/e2e.rs index d18539fe5..d054eb6d6 100644 --- a/src/tests/e2e.rs +++ b/src/tests/e2e.rs @@ -30,9 +30,9 @@ fn test_execute_and_render() { .execute("SELECT 1 as x, 2 as y VISUALISE x, y DRAW point") .unwrap(); - assert_eq!(spec.plot().layers.len(), 1); - assert_eq!(spec.metadata().layer_count, 1); - assert!(spec.layer_data(0).is_some()); + assert_eq!(spec.as_plot().unwrap().plot().layers.len(), 1); + assert_eq!(spec.as_plot().unwrap().metadata().layer_count, 1); + assert!(spec.as_plot().unwrap().layer_data(0).is_some()); let writer = VegaLiteWriter::new(); let result = writer.render(&spec).unwrap(); @@ -48,7 +48,7 @@ fn test_execute_metadata() { ) .unwrap(); - let metadata = spec.metadata(); + let metadata = spec.as_plot().unwrap().metadata(); assert_eq!(metadata.rows, 3); assert!(metadata.columns.contains(&"pos1".to_string())); assert!(metadata.columns.contains(&"pos2".to_string())); @@ -68,9 +68,9 @@ fn test_execute_with_cte() { let spec = reader.execute(query).unwrap(); - assert_eq!(spec.plot().layers.len(), 1); - assert!(spec.layer_data(0).is_some()); - let df = spec.layer_data(0).unwrap(); + assert_eq!(spec.as_plot().unwrap().plot().layers.len(), 1); + assert!(spec.as_plot().unwrap().layer_data(0).is_some()); + let df = spec.as_plot().unwrap().layer_data(0).unwrap(); assert_eq!(df.height(), 2); } @@ -349,8 +349,13 @@ fn test_register_and_query() { let query = "SELECT * FROM my_data VISUALISE x, y DRAW point"; let spec = reader.execute(query).unwrap(); - assert_eq!(spec.metadata().rows, 3); - assert!(spec.metadata().columns.contains(&"pos1".to_string())); + assert_eq!(spec.as_plot().unwrap().metadata().rows, 3); + assert!(spec + .as_plot() + .unwrap() + .metadata() + .columns + .contains(&"pos1".to_string())); let writer = VegaLiteWriter::new(); let result = writer.render(&spec).unwrap(); @@ -386,7 +391,7 @@ fn test_register_and_join() { "#; let spec = reader.execute(query).unwrap(); - assert_eq!(spec.metadata().rows, 3); + assert_eq!(spec.as_plot().unwrap().metadata().rows, 3); } #[test] @@ -416,9 +421,9 @@ fn test_binned_fill_legend_renders_threshold_scale() { let spec = reader.execute(query).unwrap(); - assert_eq!(spec.plot().layers.len(), 1); + assert_eq!(spec.as_plot().unwrap().plot().layers.len(), 1); assert!( - spec.plot().find_scale("fill").is_some(), + spec.as_plot().unwrap().plot().find_scale("fill").is_some(), "Should have a fill scale" ); diff --git a/src/tests/temporal_units.rs b/src/tests/temporal_units.rs index 5f50d6684..e5f320278 100644 --- a/src/tests/temporal_units.rs +++ b/src/tests/temporal_units.rs @@ -48,7 +48,7 @@ fn line_temporal_timestamp() { let spec = reader .execute("SELECT * FROM ts_test VISUALISE day AS x, val AS y DRAW line") .unwrap(); - assert_eq!(spec.layer_data(0).unwrap().height(), 8); + assert_eq!(spec.as_plot().unwrap().layer_data(0).unwrap().height(), 8); } #[test] @@ -59,7 +59,7 @@ fn binned_temporal_date_timestamp() { "VISUALISE DRAW point MAPPING day AS x, val AS y FROM ts_test SCALE BINNED x VIA date", ) .unwrap(); - assert!(spec.layer_data(0).unwrap().height() > 0); + assert!(spec.as_plot().unwrap().layer_data(0).unwrap().height() > 0); } #[test] @@ -70,7 +70,7 @@ fn binned_temporal_identity_timestamp() { "VISUALISE DRAW point MAPPING day AS x, val AS y FROM ts_test SCALE BINNED x VIA identity", ) .unwrap(); - assert!(spec.layer_data(0).unwrap().height() > 0); + assert!(spec.as_plot().unwrap().layer_data(0).unwrap().height() > 0); } /// A numeric epoch-milliseconds column under `VIA date` must error, not diff --git a/src/util.rs b/src/util.rs index 9c7b5a073..f257ea228 100644 --- a/src/util.rs +++ b/src/util.rs @@ -87,6 +87,16 @@ pub fn set_union(mut old: Vec, new: &[String]) -> Vec { old } +/// Escape HTML special characters. `&` must be replaced first, or the +/// entities inserted for the others would themselves get escaped. +pub fn escape_html(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + #[cfg(test)] mod tests { use super::*; @@ -237,4 +247,15 @@ mod tests { assert_eq!(and_list(&[1, 2, 3]), "1, 2, and 3"); assert_eq!(or_list(&[42, 99]), "42 or 99"); } + + #[test] + fn test_escape_html() { + assert_eq!( + escape_html(""), + "<script>alert('xss')</script>" + ); + assert_eq!(escape_html("a & b"), "a & b"); + assert_eq!(escape_html("say \"hi\""), "say "hi""); + assert_eq!(escape_html("plain text"), "plain text"); + } } diff --git a/src/validate.rs b/src/validate.rs index 2199bb956..e9c91fa2f 100644 --- a/src/validate.rs +++ b/src/validate.rs @@ -4,7 +4,7 @@ //! any SQL. Use this for IDE integration, syntax checking, and query inspection. use crate::parser; -use crate::Result; +use crate::{Plot, Result, Spec, Table}; // ============================================================================ // Core Types @@ -14,7 +14,7 @@ use crate::Result; pub struct Validated { sql: String, visual: String, - has_visual: bool, + has_spec: bool, tree: Option, valid: bool, errors: Vec, @@ -22,9 +22,9 @@ pub struct Validated { } impl Validated { - /// Whether the query contains a VISUALISE clause. - pub fn has_visual(&self) -> bool { - self.has_visual + /// Whether the query contains a Spec (a VISUALISE or TABULATE clause). + pub fn has_spec(&self) -> bool { + self.has_spec } /// The SQL portion (before VISUALISE). @@ -97,7 +97,7 @@ fn has_error_ancestor(node: &tree_sitter::Node) -> bool { /// Validate query syntax and semantics without executing SQL. pub fn validate(query: &str) -> Result { let mut errors = Vec::new(); - let warnings = Vec::new(); + let mut warnings = Vec::new(); // Parse once and create SourceTree let source_tree = match parser::SourceTree::new(query) { @@ -111,7 +111,7 @@ pub fn validate(query: &str) -> Result { return Ok(Validated { sql: String::new(), visual: String::new(), - has_visual: false, + has_spec: false, tree: None, valid: false, errors, @@ -122,11 +122,12 @@ pub fn validate(query: &str) -> Result { // Extract SQL and viz portions using existing tree let sql_part = source_tree.extract_sql().unwrap_or_default(); - let viz_part = source_tree.extract_visualise().unwrap_or_default(); + let viz_part = source_tree.extract_spec().unwrap_or_default(); let root = source_tree.root(); let visualise_stmt = source_tree.find_node(&root, "(visualise_statement) @viz"); - let has_visual = visualise_stmt.is_some(); + let tabulate_stmt = source_tree.find_node(&root, "(tabulate_statement) @tab"); + let has_spec = visualise_stmt.is_some() || tabulate_stmt.is_some(); if let Err(e) = source_tree.validate() { // The lexer always tokenises VISUALISE / VISUALIZE as @@ -165,7 +166,7 @@ pub fn validate(query: &str) -> Result { return Ok(Validated { sql: sql_part, visual: viz_part, - has_visual, + has_spec, tree: Some(source_tree.tree), valid: false, errors, @@ -173,12 +174,12 @@ pub fn validate(query: &str) -> Result { }); } - // Genuine SQL-only query (no parse errors, no VISUALISE clause). - if !has_visual { + // Genuine SQL-only query (no parse errors, no VISUALISE/TABULATE clause). + if !has_spec { return Ok(Validated { sql: sql_part, visual: viz_part, - has_visual: false, + has_spec: false, tree: None, valid: true, errors, @@ -186,9 +187,9 @@ pub fn validate(query: &str) -> Result { }); } - // Build AST from existing tree for validation - let plots = match parser::build_ast(&source_tree) { - Ok(p) => p, + // Build AST from existing tree for validation. + let specs: Vec = match parser::build_ast(&source_tree) { + Ok(specs) => specs, Err(e) => { errors.push(ValidationError { message: e.to_string(), @@ -197,7 +198,7 @@ pub fn validate(query: &str) -> Result { return Ok(Validated { sql: sql_part, visual: viz_part, - has_visual, + has_spec, tree: Some(source_tree.tree), valid: false, errors, @@ -205,6 +206,22 @@ pub fn validate(query: &str) -> Result { }); } }; + let plots: Vec<&Plot> = specs.iter().filter_map(Spec::as_plot).collect(); + let tables: Vec<&Table> = specs.iter().filter_map(Spec::as_table).collect(); + + // Reader::execute() resolves only the first VISUALISE/TABULATE statement + // and silently drops the rest — a query with more than one gets no + // diagnostic otherwise. This warning is the only signal a caller has that + // part of their query was ignored. + if specs.len() > 1 { + warnings.push(ValidationWarning { + message: format!( + "Query has {} VISUALISE/TABULATE statements; only the first is resolved, the rest are ignored.", + specs.len() + ), + location: None, + }); + } // Validate the single plot (we only support one VISUALISE statement) if let Some(plot) = plots.first() { @@ -265,10 +282,63 @@ pub fn validate(query: &str) -> Result { } } + // Validate the single table (we only support one TABULATE statement). + // `sql_part` already reflects `Table.source`: `extract_sql` synthesizes + // "SELECT * FROM " for a `TABULATE FROM`, so `sql_part` is only + // empty when there is neither a `FROM` nor preceding SQL — the same + // condition `resolve_table_with_reader` rejects at execution time, + // caught here before any SQL runs. + if !tables.is_empty() && sql_part.trim().is_empty() { + errors.push(ValidationError { + message: "TABULATE has no data source: add a FROM, or a SQL query before it" + .to_string(), + location: None, + }); + } + + // Validate each SPAN's SETTING parameters (unknown key, or wrong shape + // for a recognized one) — mirrors the per-layer settings validation + // above. + if let Some(table) = tables.first() { + for (idx, spanner) in table.spans.iter().enumerate() { + if let Err(e) = spanner.validate_settings() { + errors.push(ValidationError { + message: format!("SPAN {}: {}", idx + 1, e), + location: None, + }); + } + } + + match table.resolve_spanner_ids() { + Ok(resolved_spans) => { + if let Err(e) = table.validate_span_stub_boundary(&resolved_spans) { + errors.push(ValidationError { + message: e, + location: None, + }); + } + } + Err(e) => errors.push(ValidationError { + message: e, + location: None, + }), + } + + // Validate each FORMAT's SETTING parameters the same way. + for (idx, format) in table.formats.iter().enumerate() { + if let Err(e) = format.validate_settings() { + errors.push(ValidationError { + message: format!("FORMAT {}: {}", idx + 1, e), + location: None, + }); + } + } + } + Ok(Validated { sql: sql_part, visual: viz_part, - has_visual, + has_spec, tree: Some(source_tree.tree), valid: errors.is_empty(), errors, @@ -284,7 +354,7 @@ mod tests { fn test_validate_with_visual() { let validated = validate("SELECT 1 as x, 2 as y VISUALISE DRAW point MAPPING x AS x, y AS y").unwrap(); - assert!(validated.has_visual()); + assert!(validated.has_spec()); assert_eq!(validated.sql(), "SELECT 1 as x, 2 as y"); assert!(validated.visual().starts_with("VISUALISE")); assert!(validated.tree().is_some()); @@ -294,13 +364,51 @@ mod tests { #[test] fn test_validate_without_visual() { let validated = validate("SELECT 1 as x, 2 as y").unwrap(); - assert!(!validated.has_visual()); + assert!(!validated.has_spec()); assert_eq!(validated.sql(), "SELECT 1 as x, 2 as y"); assert!(validated.visual().is_empty()); assert!(validated.tree().is_none()); assert!(validated.valid()); } + #[test] + fn test_validate_warns_on_multiple_spec_statements() { + // Reader::execute() only ever resolves the first VISUALISE/TABULATE + // statement; a query with more than one should warn rather than + // silently drop the rest with no diagnostic at all. + let validated = validate("SELECT 1 AS x VISUALISE x DRAW point TABULATE *").unwrap(); + assert!(validated.valid()); + assert!(!validated.warnings().is_empty()); + assert!(validated.warnings()[0] + .message + .contains("only the first is resolved")); + } + + #[test] + fn test_validate_tabulate_from_is_valid() { + // A TABULATE FROM has a data source (extract_sql injects + // "SELECT * FROM " the same way it does for VISUALISE FROM), + // so it's recognized as having a Spec and reported valid. + let validated = validate("TABULATE * FROM sales").unwrap(); + assert!(validated.has_spec()); + assert_eq!(validated.sql(), "SELECT * FROM sales"); + assert!(validated.visual().starts_with("TABULATE")); + assert!(validated.valid()); + assert!(validated.errors().is_empty()); + } + + #[test] + fn test_validate_bare_tabulate_has_no_data_source() { + // A bare TABULATE, with neither a FROM nor a preceding SQL query, has + // no data source — caught here before any SQL runs, mirroring + // `resolve_table_with_reader`'s execution-time rejection of the same + // query. + let validated = validate("TABULATE *").unwrap(); + assert!(validated.has_spec()); + assert!(!validated.valid()); + assert!(validated.errors()[0].message.contains("no data source")); + } + #[test] fn test_validate_valid_query() { let validated = @@ -335,7 +443,7 @@ mod tests { let query = "SELECT 1 as x, 2 as y VISUALISE DRAW point MAPPING x AS x, y AS y DRAW line MAPPING x AS x, y AS y"; let validated = validate(query).unwrap(); - assert!(validated.has_visual()); + assert!(validated.has_spec()); assert_eq!(validated.sql(), "SELECT 1 as x, 2 as y"); assert!(validated.visual().contains("DRAW point")); assert!(validated.visual().contains("DRAW line")); @@ -408,7 +516,7 @@ mod tests { } // Issue #256: SQL expressions in VISUALISE mappings used to be silently - // consumed as SQL, with validate() reporting valid=true and has_visual=false. + // consumed as SQL, with validate() reporting valid=true and has_spec=false. // The fix detects a stray visualise_keyword node (one that didn't make it // into a visualise_statement) and emits an actionable error. @@ -479,7 +587,7 @@ mod tests { "string literal containing VISUALISE should be valid: {:?}", validated.errors() ); - assert!(!validated.has_visual()); + assert!(!validated.has_spec()); } #[test] @@ -491,6 +599,6 @@ mod tests { "comment containing VISUALISE should be valid: {:?}", validated.errors() ); - assert!(!validated.has_visual()); + assert!(!validated.has_spec()); } } diff --git a/src/writer/hephaestus/CLAUDE.md b/src/writer/hephaestus/CLAUDE.md index 3e7e716e7..da39a2ecf 100644 --- a/src/writer/hephaestus/CLAUDE.md +++ b/src/writer/hephaestus/CLAUDE.md @@ -1,6 +1,6 @@ # `writer/hephaestus/` — renderer-backed writer internals -The writers here render a resolved ggsql `Spec` through +The writers here render a resolved ggsql `ResolvedPlot` through [hephaestus](https://github.com/posit-dev/hephaestus), a 2D scene renderer with a grammar-of-graphics plot API. Seven of them exist, each behind its own cargo feature — the three GPU-free ones on by default, the four raster ones not: @@ -66,7 +66,7 @@ sibling writer's internals, [`../vegalite/CLAUDE.md`](../vegalite/CLAUDE.md). ## The governing principle **ggsql owns every scale domain; the writer never computes its own extents.** -The `Spec` arrives with each `Scale` fully resolved — type, domain (already +The `ResolvedPlot` arrives with each `Scale` fully resolved — type, domain (already expanded, transform-aware, trained globally over all layers and the whole position family), transform, breaks, formatted labels, and a concrete output range for material aesthetics. The writer's job is to *pass those through* to @@ -634,7 +634,7 @@ small omission that only shows up across the whole feature surface — use the visual-test harness instead of one-off queries. It renders every executable ```` ```{ggsql} ```` cell in [`/doc/`](../../../doc/) (≈190 in `doc/syntax/` alone) and writes one HTML report pairing each query with its render, optionally -beside the Vega-Lite render of the same `Spec`: +beside the Vega-Lite render of the same `ResolvedPlot`: ```sh cargo run -p ggsql-cli --features png --example visual_test -- --compare diff --git a/src/writer/hephaestus/hep.rs b/src/writer/hephaestus/hep.rs index 72cdfcfd3..90b7d6da4 100644 --- a/src/writer/hephaestus/hep.rs +++ b/src/writer/hephaestus/hep.rs @@ -130,7 +130,10 @@ impl HepWriter { /// # Errors /// /// As [`Self::write_reporting`]. - pub fn render_reporting(&self, spec: &crate::reader::Spec) -> Result<(Vec, Vec)> { + pub fn render_reporting( + &self, + spec: &crate::reader::ResolvedPlot, + ) -> Result<(Vec, Vec)> { self.write_reporting(spec.plot(), spec.data()) } @@ -173,11 +176,11 @@ impl Writer for HepWriter { }) } - fn validate(&self, spec: &Plot) -> Result<()> { + fn validate_plot(&self, spec: &Plot) -> Result<()> { compose::validate_plot(spec) } - fn write(&self, spec: &Plot, data: &HashMap) -> Result { + fn write_plot(&self, spec: &Plot, data: &HashMap) -> Result { self.write_reporting(spec, data).map(|(bytes, _)| bytes) } } diff --git a/src/writer/hephaestus/jpeg.rs b/src/writer/hephaestus/jpeg.rs index 42d6d5d19..b832d2aa1 100644 --- a/src/writer/hephaestus/jpeg.rs +++ b/src/writer/hephaestus/jpeg.rs @@ -107,7 +107,7 @@ impl JpegWriter { /// As [`Self::write_with`]. pub fn render_with( &self, - spec: &crate::reader::Spec, + spec: &crate::reader::ResolvedPlot, renderer: &mut RasterRenderer, ) -> Result> { self.write_with(spec.plot(), spec.data(), renderer) @@ -147,11 +147,11 @@ impl Writer for JpegWriter { Ok(Self { canvas, quality }) } - fn validate(&self, spec: &Plot) -> Result<()> { + fn validate_plot(&self, spec: &Plot) -> Result<()> { compose::validate_plot(spec) } - fn write(&self, spec: &Plot, data: &HashMap) -> Result { + fn write_plot(&self, spec: &Plot, data: &HashMap) -> Result { let mut renderer = RasterRenderer::new()?; self.write_with(spec, data, &mut renderer) } diff --git a/src/writer/hephaestus/mod.rs b/src/writer/hephaestus/mod.rs index 32720e656..fe7ab1f68 100644 --- a/src/writer/hephaestus/mod.rs +++ b/src/writer/hephaestus/mod.rs @@ -1,8 +1,7 @@ -//! Renderer-backed writers. -//! -//! Every writer here renders a resolved ggsql `Spec` through the [`hephaestus`] -//! 2D scene renderer. Only the writers themselves are public; the renderer -//! behind them is an implementation detail, and this module is private. +//! Every writer here renders a resolved ggsql `ResolvedPlot` through the +//! [`hephaestus`] 2D scene renderer. Only the writers themselves are public; +//! the renderer behind them is an implementation detail, and this module is +//! private. //! //! The work splits three ways, which is what keeps one writer per format small: //! @@ -115,9 +114,9 @@ mod tests { /// look at the picture — only that the whole pipeline ran. const CORPUS_SIZE: (u32, u32, f64) = (640, 480, 96.0); - fn spec_for(query: &str) -> crate::reader::Spec { + fn spec_for(query: &str) -> crate::reader::ResolvedPlot { let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); - reader.execute(query).unwrap() + reader.execute(query).unwrap().into_plot().unwrap() } /// Render `query` through every compiled writer, asserting each output @@ -163,7 +162,7 @@ mod tests { // Last, and the only one that tolerates a headless box. #[cfg(feature = "png")] - assert_png_or_skip(PngWriter::new(w, h, dpi).render(&spec)); + assert_png_or_skip(PngWriter::new(w, h, dpi).write_plot(spec.plot(), spec.data())); } /// The panels' `(top, right)` strip labels, in panel order. Exercises the @@ -171,7 +170,8 @@ mod tests { fn strips(query: &str) -> Vec<(Option, Option)> { let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); let spec = reader.execute(query).unwrap(); - let (_, panels) = facet::build_panels(spec.plot(), spec.data()).unwrap(); + let plot = spec.as_plot().unwrap(); + let (_, panels) = facet::build_panels(plot.plot(), plot.data()).unwrap(); panels .iter() .map(|p| (p.strip_top.clone(), p.strip_right.clone())) @@ -182,7 +182,7 @@ mod tests { fn axis_titles(query: &str) -> Vec<(AxisSide, String)> { let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); let spec = reader.execute(query).unwrap(); - projection::composition_axis_titles(spec.plot()) + projection::composition_axis_titles(spec.as_plot().unwrap().plot()) } /// Just the top strip labels, in panel order. @@ -1316,7 +1316,7 @@ mod svg_text { fn svg(query: &str) -> String { let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); - let spec = reader.execute(query).unwrap(); + let spec = reader.execute(query).unwrap().into_plot().unwrap(); let (svg, warnings) = SvgWriter::new(640, 480, 96.0) .render_reporting(&spec) .unwrap_or_else(|e| panic!("svg render failed: {e}")); @@ -1501,12 +1501,14 @@ mod svg_text { let query = "SELECT x, y FROM (VALUES (1,2),(2,3)) t(x,y) \ VISUALISE x AS x, y AS y DRAW point LABEL title => 'Outlined'"; let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); - let spec = reader.execute(query).unwrap(); + let spec = reader.execute(query).unwrap().into_plot().unwrap(); - let as_text = SvgWriter::new(640, 480, 96.0).render(&spec).unwrap(); + let as_text = SvgWriter::new(640, 480, 96.0) + .write_plot(spec.plot(), spec.data()) + .unwrap(); let as_paths = SvgWriter::new(640, 480, 96.0) .outline_text(true) - .render(&spec) + .write_plot(spec.plot(), spec.data()) .unwrap(); assert!(as_text.contains(""); @@ -1525,13 +1527,13 @@ mod svg_text { let query = "SELECT x, y FROM (VALUES (1,2),(2,3)) t(x,y) \ VISUALISE x AS x, y AS y DRAW point"; let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); - let spec = reader.execute(query).unwrap(); + let spec = reader.execute(query).unwrap().into_plot().unwrap(); let build = |pairs: &[&str]| { let options = WriterOptions::parse(pairs).unwrap(); SvgWriter::from_options(&options) .unwrap() - .render(&spec) + .write_plot(spec.plot(), spec.data()) .unwrap() }; @@ -1560,12 +1562,14 @@ mod svg_text { let query = "SELECT x, y, c FROM (VALUES (1,2,10),(2,3,50),(3,1,90)) t(x,y,c) \ VISUALISE x AS x, y AS y, c AS color DRAW point"; let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); - let spec = reader.execute(query).unwrap(); + let spec = reader.execute(query).unwrap().into_plot().unwrap(); - let bare = SvgWriter::new(640, 480, 96.0).render(&spec).unwrap(); + let bare = SvgWriter::new(640, 480, 96.0) + .write_plot(spec.plot(), spec.data()) + .unwrap(); let prefixed = SvgWriter::new(640, 480, 96.0) .id_prefix("fig1-") - .render(&spec) + .write_plot(spec.plot(), spec.data()) .unwrap(); assert!(bare.contains("id=\"c0\""), "expected an unprefixed id"); @@ -1586,7 +1590,7 @@ mod pdf_structure { use crate::reader::{DuckDBReader, Reader}; use crate::writer::Writer; - fn spec() -> crate::reader::Spec { + fn spec() -> crate::reader::ResolvedPlot { let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); reader .execute( @@ -1594,6 +1598,8 @@ mod pdf_structure { VISUALISE x AS x, y AS y DRAW point LABEL title => 'A page'", ) .unwrap() + .into_plot() + .unwrap() } #[test] @@ -1601,7 +1607,7 @@ mod pdf_structure { // 640 px at 96 dpi is 6⅔ in, which is 480 pt; 480 px is 360 pt. let pdf = PdfWriter::new(640, 480, 96.0) .compress(false) - .render(&spec()) + .write_plot(spec().plot(), spec().data()) .unwrap(); let text = String::from_utf8_lossy(&pdf); assert!( @@ -1614,9 +1620,11 @@ mod pdf_structure { fn an_uncompressed_page_is_readable_and_a_compressed_one_is_smaller() { let readable = PdfWriter::new(640, 480, 96.0) .compress(false) - .render(&spec()) + .write_plot(spec().plot(), spec().data()) + .unwrap(); + let compressed = PdfWriter::new(640, 480, 96.0) + .write_plot(spec().plot(), spec().data()) .unwrap(); - let compressed = PdfWriter::new(640, 480, 96.0).render(&spec()).unwrap(); assert!(readable.starts_with(b"%PDF-")); assert!(compressed.starts_with(b"%PDF-")); @@ -1632,7 +1640,7 @@ mod pdf_structure { // in whatever the reader substitutes. let pdf = PdfWriter::new(640, 480, 96.0) .compress(false) - .render(&spec()) + .write_plot(spec().plot(), spec().data()) .unwrap(); let text = String::from_utf8_lossy(&pdf); assert!(text.contains("/FontFile2"), "no embedded font programme"); @@ -1686,7 +1694,7 @@ mod hep_roundtrip { fn compose_for(query: &str) -> hephaestus::plot::PlotComposition { let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); - let spec = reader.execute(query).unwrap(); + let spec = reader.execute(query).unwrap().into_plot().unwrap(); compose::validate_plot(spec.plot()).unwrap(); compose::build_composition(spec.plot(), spec.data()).unwrap() } @@ -1748,7 +1756,7 @@ mod hep_roundtrip { fn the_writers_hints_travel_with_the_document() { let spec = { let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); - reader.execute(QUERIES[0].1).unwrap() + reader.execute(QUERIES[0].1).unwrap().into_plot().unwrap() }; let (bytes, warnings) = HepWriter::new(1600, 900, 150.0) .background(rgba(0.0, 0.0, 0.0, 1.0)) diff --git a/src/writer/hephaestus/pdf.rs b/src/writer/hephaestus/pdf.rs index aeff21406..02e495c34 100644 --- a/src/writer/hephaestus/pdf.rs +++ b/src/writer/hephaestus/pdf.rs @@ -98,7 +98,10 @@ impl PdfWriter { /// # Errors /// /// As [`Self::write_reporting`]. - pub fn render_reporting(&self, spec: &crate::reader::Spec) -> Result<(Vec, Vec)> { + pub fn render_reporting( + &self, + spec: &crate::reader::ResolvedPlot, + ) -> Result<(Vec, Vec)> { self.write_reporting(spec.plot(), spec.data()) } @@ -132,11 +135,11 @@ impl Writer for PdfWriter { }) } - fn validate(&self, spec: &Plot) -> Result<()> { + fn validate_plot(&self, spec: &Plot) -> Result<()> { compose::validate_plot(spec) } - fn write(&self, spec: &Plot, data: &HashMap) -> Result { + fn write_plot(&self, spec: &Plot, data: &HashMap) -> Result { self.write_reporting(spec, data).map(|(pdf, _)| pdf) } } diff --git a/src/writer/hephaestus/png.rs b/src/writer/hephaestus/png.rs index 4cab1b14f..9570949b8 100644 --- a/src/writer/hephaestus/png.rs +++ b/src/writer/hephaestus/png.rs @@ -100,7 +100,7 @@ impl PngWriter { /// As [`Self::write_with`]. pub fn render_with( &self, - spec: &crate::reader::Spec, + spec: &crate::reader::ResolvedPlot, renderer: &mut RasterRenderer, ) -> Result> { self.write_with(spec.plot(), spec.data(), renderer) @@ -133,11 +133,11 @@ impl Writer for PngWriter { }) } - fn validate(&self, spec: &Plot) -> Result<()> { + fn validate_plot(&self, spec: &Plot) -> Result<()> { compose::validate_plot(spec) } - fn write(&self, spec: &Plot, data: &HashMap) -> Result { + fn write_plot(&self, spec: &Plot, data: &HashMap) -> Result { let mut renderer = RasterRenderer::new()?; self.write_with(spec, data, &mut renderer) } diff --git a/src/writer/hephaestus/svg.rs b/src/writer/hephaestus/svg.rs index 4c22682d0..92d5abe06 100644 --- a/src/writer/hephaestus/svg.rs +++ b/src/writer/hephaestus/svg.rs @@ -119,7 +119,10 @@ impl SvgWriter { /// # Errors /// /// As [`Self::write_reporting`]. - pub fn render_reporting(&self, spec: &crate::reader::Spec) -> Result<(String, Vec)> { + pub fn render_reporting( + &self, + spec: &crate::reader::ResolvedPlot, + ) -> Result<(String, Vec)> { self.write_reporting(spec.plot(), spec.data()) } @@ -162,11 +165,11 @@ impl Writer for SvgWriter { }) } - fn validate(&self, spec: &Plot) -> Result<()> { + fn validate_plot(&self, spec: &Plot) -> Result<()> { compose::validate_plot(spec) } - fn write(&self, spec: &Plot, data: &HashMap) -> Result { + fn write_plot(&self, spec: &Plot, data: &HashMap) -> Result { self.write_reporting(spec, data).map(|(svg, _)| svg) } } diff --git a/src/writer/hephaestus/tiff.rs b/src/writer/hephaestus/tiff.rs index 7fd55dcd1..7a1bcde12 100644 --- a/src/writer/hephaestus/tiff.rs +++ b/src/writer/hephaestus/tiff.rs @@ -97,7 +97,7 @@ impl TiffWriter { /// As [`Self::write_with`]. pub fn render_with( &self, - spec: &crate::reader::Spec, + spec: &crate::reader::ResolvedPlot, renderer: &mut RasterRenderer, ) -> Result> { self.write_with(spec.plot(), spec.data(), renderer) @@ -121,11 +121,11 @@ impl Writer for TiffWriter { }) } - fn validate(&self, spec: &Plot) -> Result<()> { + fn validate_plot(&self, spec: &Plot) -> Result<()> { compose::validate_plot(spec) } - fn write(&self, spec: &Plot, data: &HashMap) -> Result { + fn write_plot(&self, spec: &Plot, data: &HashMap) -> Result { let mut renderer = RasterRenderer::new()?; self.write_with(spec, data, &mut renderer) } diff --git a/src/writer/hephaestus/webp.rs b/src/writer/hephaestus/webp.rs index 427b72b08..4d705ce12 100644 --- a/src/writer/hephaestus/webp.rs +++ b/src/writer/hephaestus/webp.rs @@ -82,7 +82,7 @@ impl WebpWriter { /// As [`Self::write_with`]. pub fn render_with( &self, - spec: &crate::reader::Spec, + spec: &crate::reader::ResolvedPlot, renderer: &mut RasterRenderer, ) -> Result> { self.write_with(spec.plot(), spec.data(), renderer) @@ -98,11 +98,11 @@ impl Writer for WebpWriter { }) } - fn validate(&self, spec: &Plot) -> Result<()> { + fn validate_plot(&self, spec: &Plot) -> Result<()> { compose::validate_plot(spec) } - fn write(&self, spec: &Plot, data: &HashMap) -> Result { + fn write_plot(&self, spec: &Plot, data: &HashMap) -> Result { let mut renderer = RasterRenderer::new()?; self.write_with(spec, data, &mut renderer) } diff --git a/src/writer/hephaestus/window.rs b/src/writer/hephaestus/window.rs index d2a56d62b..6f49766cd 100644 --- a/src/writer/hephaestus/window.rs +++ b/src/writer/hephaestus/window.rs @@ -8,7 +8,7 @@ use hephaestus::plot::PlotComposition; use hephaestus::window::{self, Event, EventCtx, Frame, WindowApp, WindowConfig}; use super::canvas::{parse_background, whole_pixels}; -use crate::reader::Spec; +use crate::reader::ResolvedPlot; use crate::writer::WriterOptions; use crate::{GgsqlError, Result}; @@ -119,7 +119,7 @@ impl PlotViewer { /// /// Returns `GgsqlError::WriterError` if the plot cannot be composed, if no /// GPU adapter can drive a window, or if the event loop fails. - pub fn show(&self, spec: &Spec) -> Result<()> { + pub fn show(&self, spec: &ResolvedPlot) -> Result<()> { let view = super::compose::prepare(spec.plot(), spec.data())?; let config = WindowConfig::new(self.title.clone()) diff --git a/src/writer/html.rs b/src/writer/html.rs new file mode 100644 index 000000000..6a748af53 --- /dev/null +++ b/src/writer/html.rs @@ -0,0 +1,1073 @@ +//! A minimal HTML table writer. +//! +//! Maps a `TableLayout`'s three parts onto pieces of the `
`/``. + /// A body cell (gt's `gt_row`). + Row, + /// A row-label cell in the stub (`TableCellKind::StubRowLabel`). + Stub, + /// The stub's own header cell (`TableCellKind::StubHead`). No gt + /// equivalent. + StubHead, + /// A column-label cell (gt's `gt_col_heading`). + ColHeading, + /// Every spanner cell, at every level (gt's `gt_column_spanner_outer`). + Spanner, + /// A spanner cell's label text (gt's `gt_column_spanner`). Special + /// meaning for the HTML writer: it goes on an element inside the cell + /// rather than on the cell itself. + SpannerLabel, + /// The title cell (gt's `gt_title`). + Title, + /// The subtitle cell (gt's `gt_subtitle`). + Subtitle, + /// The caption cell (gt's `gt_caption`). + Caption, + /// Left/center/right-aligned cell content (gt's + /// `gt_left`/`gt_center`/`gt_right`). + AlignLeft, + AlignCenter, + AlignRight, +} + +impl std::fmt::Display for TableClass { + /// The class-name suffix a writer hangs its own prefix on, e.g. + /// `ggsql_row` for `Row` in the HTML writer. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let text = match self { + TableClass::Table => "table", + TableClass::TableBody => "table_body", + TableClass::Row => "row", + TableClass::Stub => "stub", + TableClass::ColHeading => "col_heading", + TableClass::ColHeadingRow => "col_heading_row", + TableClass::SpannerRow => "spanner_row", + TableClass::StubHead => "stub_head", + TableClass::Spanner => "spanner", + TableClass::SpannerLabel => "spanner_label", + TableClass::AlignLeft => "left", + TableClass::AlignCenter => "center", + TableClass::AlignRight => "right", + TableClass::Title => "title", + TableClass::Subtitle => "subtitle", + TableClass::Caption => "caption", + TableClass::Heading => "heading", + }; + write!(f, "{text}") + } +} + +/// A single positioned cell within a resolved table layout. +/// +/// Position is an inclusive grid rectangle: `top`/`bottom` are row indices, +/// `left`/`right` are column indices, 0-based, inclusive on both ends. A +/// non-spanning cell has `top == bottom` and `left == right`. +#[derive(Debug, Clone)] +pub struct TableCell { + /// What role this cell plays (column label, body, ...). + pub kind: TableCellKind, + /// Top row index (inclusive). + pub top: usize, + /// Bottom row index (inclusive). + pub bottom: usize, + /// Left column index (inclusive). + pub left: usize, + /// Right column index (inclusive). + pub right: usize, + /// The cell's text content. + pub content: String, + /// Display properties for this cell (e.g. `hjust`), resolved from its + /// column's `FORMAT` `SETTING`. `ColumnLabel` and `Body` cells inherit + /// their column's properties; a `Spanner` cell covers several columns + /// at once, so it has none of its own. + pub properties: Parameters, + /// Style classes recorded at build time (see `TableClass`). Ordered; + /// a writer renders them in order. + pub classes: Vec, +} + +impl TableCell { + /// Build a cell with no display properties or classes. A + /// `ColumnLabel`/`Body` cell should follow with `with_properties` and + /// `with_classes`. + pub(crate) fn new( + kind: TableCellKind, + top: usize, + bottom: usize, + left: usize, + right: usize, + content: String, + ) -> Self { + Self { + kind, + top, + bottom, + left, + right, + content, + properties: Parameters::new(), + classes: Vec::new(), + } + } + + /// A body cell (`TableClass::Row`). + pub(crate) fn new_body(row: usize, col: usize, content: String) -> Self { + Self::new(TableCellKind::Body, row, row, col, col, content) + .with_classes(vec![TableClass::Row]) + } + + /// A column-label cell (`TableClass::ColHeading`). + pub(crate) fn new_column_label(index: usize, content: String) -> Self { + Self::new(TableCellKind::ColumnLabel, 0, 0, index, index, content) + .with_classes(vec![TableClass::ColHeading]) + } + + /// The stub's header cell (`TableClass::StubHead`). + pub(crate) fn new_stub_head(index: usize, content: String) -> Self { + Self::new(TableCellKind::StubHead, 0, 0, index, index, content) + .with_classes(vec![TableClass::StubHead]) + } + + /// A stub row-label cell (`TableClass::Stub`). + pub(crate) fn new_stub_row_label(row: usize, index: usize, content: String) -> Self { + Self::new(TableCellKind::StubRowLabel, row, row, index, index, content) + .with_classes(vec![TableClass::Stub]) + } + + /// A spanner cell (`TableClass::Spanner`, `SpannerLabel`, centered). + pub(crate) fn new_spanner(row: usize, left: usize, right: usize, content: String) -> Self { + Self::new(TableCellKind::Spanner, row, row, left, right, content).with_classes(vec![ + TableClass::Spanner, + TableClass::SpannerLabel, + TableClass::AlignCenter, + ]) + } + + /// The title cell (`TableClass::Title`), spanning all `ncol` columns. + pub(crate) fn new_title(ncol: usize, content: String) -> Self { + Self::new(TableCellKind::Title, 0, 0, 0, ncol - 1, content) + .with_classes(vec![TableClass::Title]) + } + + /// The subtitle cell (`TableClass::Subtitle`), spanning all `ncol` + /// columns. + pub(crate) fn new_subtitle(row: usize, ncol: usize, content: String) -> Self { + Self::new(TableCellKind::Subtitle, row, row, 0, ncol - 1, content) + .with_classes(vec![TableClass::Subtitle]) + } + + /// The caption cell (`TableClass::Caption`), spanning all `ncol` columns. + pub(crate) fn new_caption(ncol: usize, content: String) -> Self { + Self::new(TableCellKind::Caption, 0, 0, 0, ncol - 1, content) + .with_classes(vec![TableClass::Caption]) + } + + /// Set this cell's display properties, e.g. to a column's resolved + /// `FORMAT` `SETTING`. + pub(crate) fn with_properties(mut self, properties: Parameters) -> Self { + self.properties = properties; + self + } + + /// Record this cell's style classes, e.g. `TableClass::Row` for a + /// body cell. + pub(crate) fn with_classes(mut self, classes: Vec) -> Self { + self.classes = classes; + self + } + + /// The alignment class this cell's `hjust` property implies, or `None` + /// if it's absent or unrecognized. Numbers and keyword spellings + /// standardise via `standardise_hjust`; the number is bucketed with the + /// same `0.25`/`0.75` thresholds `VegaLiteWriter`'s `convert_hjust` + /// uses. + pub(crate) fn hjust_class(&self) -> Option { + let n = standardise_hjust(self.properties.get("hjust")?)?; + Some(if n <= 0.25 { + TableClass::AlignLeft + } else if n >= 0.75 { + TableClass::AlignRight + } else { + TableClass::AlignCenter + }) + } + + /// Fold this cell's resolved `hjust` into its alignment class appended + /// to `classes`, removing `hjust` from `properties` so no writer renders + /// it twice. An unrecognized value stays in `properties`. + pub fn discretise_hjust(mut self) -> Self { + let Some(class) = self.hjust_class() else { + return self; + }; + self.properties.remove("hjust"); + self.classes.push(class); + self + } + + /// Shift this cell down by `rows`, moving `top` and `bottom` together so + /// a spanning cell keeps its height. + pub fn offset_rows(&mut self, rows: usize) { + self.top += rows; + self.bottom += rows; + } + + /// Shift this cell right by `cols`, moving `left` and `right` together + /// so a spanning cell keeps its width. + pub fn offset_cols(&mut self, cols: usize) { + self.left += cols; + self.right += cols; + } + + /// Whether this cell belongs in a table's header rather than its body — + /// delegates to `TableCellKind::is_header`. + pub fn is_header(&self) -> bool { + self.kind.is_header() + } + + /// This cell's width in columns — `right - left + 1`, since both bounds + /// are inclusive. + pub fn width(&self) -> usize { + self.right - self.left + 1 + } + + /// This cell's height in rows — `bottom - top + 1`, since both bounds + /// are inclusive. + pub fn height(&self) -> usize { + self.bottom - self.top + 1 + } +} + +/// One column's identity within a table layout: its source name and +/// resolved display label/properties, as resolved by +/// `create_table_columns`. +#[derive(Debug, Clone, PartialEq)] +pub struct TableColumn { + /// The column's name in the resolved `DataFrame` — used to look its + /// values up in `create_body`, independent of display order. + pub name: String, + /// The resolved `ColumnLabel` cell content for this column. + pub label: String, + /// Resolved `SETTING` properties for this column's cells (e.g. `hjust`), + /// carried onto every `ColumnLabel`/`Body` cell in this column. A + /// writer wanting a whole-column property (e.g. `width`) reads it here + /// instead of the same value repeated across the column's cells. + pub properties: Parameters, + /// Which section of the table this column belongs to (`FORMAT`'s + /// `BODY`/`STUB` target, `Body` if no `FORMAT` clause named it). + pub target: ColumnSection, +} + +impl TableColumn { + /// Whether this column is a `TABULATE FORMAT STUB` column. + pub fn is_stub(&self) -> bool { + self.target.is_stub() + } +} + +/// One row's resolved properties within a table layout. `classes` carries +/// row-scoped classes (e.g. `Heading`, `ColHeadingRow`) that belong on the +/// enclosing `
` outside `
`: +//! - `cells` → ``/`` rows. A `Caption`-kind cell is pulled out +//! of the grid before row rendering and emitted as a `/, and its + // content is escaped like any other cell's. The
` (HTML +//! requires it as ``'s first child) — see `write_table`. Styling is +//! expressed per the `css_mode` option: `class` (the default) puts +//! `ggsql_*` classes on the elements, backed by a `\n"); + block +} + +/// Fold classes' declarations into a `style` attribute value, each CSS +/// property appearing at most once — a later class's declaration overrides +/// an earlier one's (CSS's last-wins rule, made explicit so the output +/// doesn't rely on browsers' duplicate-declaration handling). +fn inline_style(classes: &[TableClass]) -> Option { + let mut folded: Vec<(&str, &str)> = Vec::new(); + for class in classes { + for &(property, value) in class_declarations(*class) { + match folded.iter_mut().find(|(p, _)| *p == property) { + Some(entry) => entry.1 = value, + None => folded.push((property, value)), + } + } + } + (!folded.is_empty()).then(|| { + folded + .iter() + .map(|(property, value)| format!("{property}: {value}")) + .collect::>() + .join("; ") + }) +} + +/// Render a `class` (class mode) or `style` (inline mode) attribute from a +/// set of recorded classes — shared by `render_cell` (a ``), since the same class list means the same declarations wherever +/// it ends up. Empty when `classes` is empty (class mode) or resolves no +/// declarations (inline mode). +fn styling_attr(classes: &[TableClass], mode: CssMode) -> String { + match mode { + CssMode::Class => { + if classes.is_empty() { + String::new() + } else { + let names = classes + .iter() + .map(|&class| format!("ggsql_{class}")) + .collect::>() + .join(" "); + format!(" class=\"{names}\"") + } + } + CssMode::Inline => inline_style(classes) + .map(|style| format!(" style=\"{style}\"")) + .unwrap_or_default(), + } +} + +/// Render one `TableCell` as an HTML `", escape_html(&cell.content)) +} + +/// Render a `` block, one `` per column, or `None` if +/// `columns` is empty or none of them resolve a `style`. Skipping the block +/// entirely in that case avoids emitting a run of bare, attribute-less +/// `` tags that would render identically to omitting them. +fn render_colgroup(columns: &[TableColumn]) -> Option { + let styles: Vec> = columns + .iter() + .map(|c| column_style(&c.properties)) + .collect(); + if styles.iter().all(Option::is_none) { + return None; + } + + let mut html = String::from("\n"); + for style in styles { + match style { + Some(style) => html.push_str(&format!("\n")), + None => html.push_str("\n"), + } + } + html.push_str("\n"); + Some(html) +} + +/// Translate a column's resolved `FORMAT` properties into a ``'s +/// `style` attribute value, or `None` if it has no `width`. `width`'s value +/// is already validated (by `ParamConstraint::string_numeric_with_unit`) to +/// be a number immediately followed by `px` or `%`, which is exactly CSS's +/// own `width` syntax, so it's used as-is. +fn column_style(properties: &Parameters) -> Option { + match properties.get("width") { + Some(ParameterValue::String(width)) => Some(format!("width: {width}")), + _ => None, + } +} + +/// Render one row's already-decided `Slot`s as `...`. A `Skip` +/// slot renders nothing at all; a `Cell` slot renders through `render_cell`. +/// Styling on the `` itself comes from `row`'s recorded classes, same as +/// any other element, via `styling_attr`. +fn render_row(slots: &[Slot], row: &TableRow, mode: CssMode) -> String { + // Nothing to render if every slot is a Skip continuation (vacuously + // true for an empty `slots` too). + if slots.iter().all(|slot| matches!(slot, Slot::Skip)) { + return String::new(); + } + + let mut html = format!("", styling_attr(&row.classes, mode)); + + for slot in slots { + match slot { + Slot::Cell(cell) => html.push_str(&render_cell(cell, mode)), + Slot::Skip => {} + } + } + + html.push_str("\n"); + html +} + +#[cfg(test)] +mod render_tests { + use super::*; + use crate::{ColumnSection, TableCellKind, TableClass}; + + fn cell( + kind: TableCellKind, + top: usize, + bottom: usize, + left: usize, + right: usize, + ) -> TableCell { + TableCell::new(kind, top, bottom, left, right, String::new()) + } + + /// Wrap hand-built `cells`/`rows` into a `TableLayout` for `write_table` + /// tests, with as many default columns as `cells` implies. + fn layout(cells: Vec, rows: Vec) -> TableLayout { + let ncol = cells + .iter() + .map(|cell| cell.right) + .max() + .map_or(0, |r| r + 1); + let columns = (0..ncol) + .map(|i| TableColumn { + name: i.to_string(), + label: String::new(), + properties: Parameters::new(), + target: ColumnSection::Body, + }) + .collect(); + TableLayout::new(columns, rows, cells) + } + + #[test] + fn from_options_defaults_to_class_mode() { + assert_eq!(HtmlWriter::new().css_mode, CssMode::Class); + let writer = HtmlWriter::from_options(&WriterOptions::new()).unwrap(); + assert_eq!(writer.css_mode, CssMode::Class); + } + + #[test] + fn from_options_accepts_inline_and_class_case_insensitively() { + let options = WriterOptions::parse(["css-mode=INLINE"]).unwrap(); + assert_eq!( + HtmlWriter::from_options(&options).unwrap().css_mode, + CssMode::Inline + ); + let options = WriterOptions::parse(["css_mode=Class"]).unwrap(); + assert_eq!( + HtmlWriter::from_options(&options).unwrap().css_mode, + CssMode::Class + ); + } + + #[test] + fn from_options_rejects_bad_values_and_unknown_keys() { + let err = HtmlWriter::from_options(&WriterOptions::parse(["css_mode=banana"]).unwrap()) + .unwrap_err() + .to_string(); + assert!(err.contains("expects 'inline' or 'class'"), "{err}"); + assert!(HtmlWriter::from_options(&WriterOptions::parse(["widdth=3"]).unwrap()).is_err()); + } + + #[test] + fn render_row_returns_empty_string_for_no_slots() { + assert_eq!(render_row(&[], &TableRow::body(), CssMode::Inline), ""); + } + + #[test] + fn render_row_returns_empty_string_when_every_slot_is_a_skip() { + // A row entirely swallowed by rowspans from earlier rows gets no + // `` of its own either. + let slots = vec![Slot::Skip, Slot::Skip]; + assert_eq!(render_row(&slots, &TableRow::body(), CssMode::Inline), ""); + } + + #[test] + fn render_cell_emits_rowspan_when_height_is_greater_than_one() { + let c = cell(TableCellKind::ColumnLabel, 0, 1, 0, 0); + assert_eq!( + render_cell(&c, CssMode::Inline), + "" + ); + } + + #[test] + fn render_cell_emits_a_style_attribute_from_properties_in_inline_mode() { + // Declaration content is inline_style's tests' job — this just checks + // render_cell routes `hjust` through it as a `style="..."` attribute. + let mut c = cell(TableCellKind::Body, 0, 0, 0, 0); + c.properties + .insert("hjust".to_string(), ParameterValue::Number(1.0)); + let html = render_cell(&c, CssMode::Inline); + assert!(html.starts_with("")); + } + + #[test] + fn render_cell_emits_classes_in_class_mode() { + let mut body = cell(TableCellKind::Body, 0, 0, 0, 0).with_classes(vec![TableClass::Row]); + body.properties + .insert("hjust".to_string(), ParameterValue::Number(1.0)); + assert_eq!( + render_cell(&body, CssMode::Class), + "" + ); + + let label = + cell(TableCellKind::ColumnLabel, 0, 0, 0, 0).with_classes(vec![TableClass::ColHeading]); + assert_eq!( + render_cell(&label, CssMode::Class), + "" + ); + + let spanner = + cell(TableCellKind::Spanner, 0, 0, 0, 1).with_classes(vec![TableClass::SpannerLabel]); + assert_eq!( + render_cell(&spanner, CssMode::Class), + "" + ); + + let outer = + cell(TableCellKind::Spanner, 0, 0, 0, 1).with_classes(vec![TableClass::Spanner]); + assert_eq!( + render_cell(&outer, CssMode::Class), + "" + ); + } + + #[test] + fn inline_style_folds_declarations_per_class() { + assert_eq!(inline_style(&[]), None); + assert_eq!( + inline_style(&[TableClass::AlignLeft]), + Some("text-align: left".to_string()) + ); + assert_eq!( + inline_style(&[TableClass::AlignRight]), + Some("text-align: right; font-variant-numeric: tabular-nums".to_string()) + ); + // `StubHead` is the one structural class with no gt equivalent and + // so no declarations. + assert_eq!(inline_style(&[TableClass::StubHead]), None); + } + + #[test] + fn inline_style_lets_a_later_class_override_an_earlier_ones_property() { + // AlignLeft and AlignRight both declare text-align — last wins. + assert_eq!( + inline_style(&[TableClass::AlignLeft, TableClass::AlignRight]), + Some("text-align: right; font-variant-numeric: tabular-nums".to_string()) + ); + } + + #[test] + fn column_style_reads_a_string_width_as_a_css_width() { + let mut properties = Parameters::new(); + properties.insert( + "width".to_string(), + ParameterValue::String("20%".to_string()), + ); + assert_eq!(column_style(&properties), Some("width: 20%".to_string())); + + assert_eq!(column_style(&Parameters::new()), None); + } + + fn column(properties: Parameters) -> TableColumn { + TableColumn { + name: String::new(), + label: String::new(), + properties, + target: ColumnSection::Body, + } + } + + #[test] + fn render_colgroup_returns_none_without_columns() { + assert_eq!(render_colgroup(&[]), None); + } + + #[test] + fn render_colgroup_returns_none_when_no_column_has_a_width() { + let columns = vec![column(Parameters::new()), column(Parameters::new())]; + assert_eq!(render_colgroup(&columns), None); + } + + #[test] + fn render_colgroup_emits_a_bare_col_for_a_column_with_no_width() { + let mut widened = Parameters::new(); + widened.insert( + "width".to_string(), + ParameterValue::String("20%".to_string()), + ); + let columns = vec![column(widened), column(Parameters::new())]; + + assert_eq!( + render_colgroup(&columns).unwrap(), + "\n\n\n\n" + ); + } + + #[test] + fn render_row_renders_nothing_for_a_skip_slot() { + let a = cell(TableCellKind::ColumnLabel, 1, 1, 0, 0); + let c = cell(TableCellKind::ColumnLabel, 1, 1, 2, 2); + let slots = vec![Slot::Cell(&a), Slot::Skip, Slot::Cell(&c)]; + + let row = render_row(&slots, &TableRow::header(), CssMode::Inline); + + assert_eq!( + row, + "\n" + ); + } + + #[test] + fn write_table_errors_when_a_header_row_is_not_above_every_body_row() { + let cells = vec![ + cell(TableCellKind::Body, 0, 0, 0, 0), + cell(TableCellKind::ColumnLabel, 1, 1, 0, 0), + ]; + let rows = vec![TableRow::body(), TableRow::header()]; + + assert!(HtmlWriter::new().write_table(&layout(cells, rows)).is_err()); + } + + #[test] + fn write_table_renders_a_rowspan_cell_once_and_skips_it_in_the_next_row() { + // Column 1 has no spanner, so its label rowspans up through row 0 + // instead of row 0 getting a blank filler there. + let cells = vec![ + cell(TableCellKind::Spanner, 0, 0, 0, 0), + cell(TableCellKind::ColumnLabel, 0, 1, 1, 1), + cell(TableCellKind::ColumnLabel, 1, 1, 0, 0), + cell(TableCellKind::Body, 2, 2, 0, 0), + cell(TableCellKind::Body, 2, 2, 1, 1), + ]; + let rows = vec![TableRow::header(), TableRow::header(), TableRow::body()]; + + let html = HtmlWriter { + css_mode: CssMode::Inline, + } + .write_table(&layout(cells, rows)) + .unwrap(); + + // The
`/``), +/// `render_caption` (the extracted `
`) and `render_row` (the +/// `
`/``, picked from +/// `cell.is_header()`, with a `colspan`/`rowspan` attribute only when the +/// cell actually spans more than one column/row, and a `scope` attribute per +/// `header_scope`. Styling per `mode` via `styling_attr` — classes in +/// `INNER_CLASSES` go on a `
` around the content rather than on the +/// `
`/`` itself. Not used for a `Caption`-kind cell — see +/// `render_caption`. +fn render_cell(cell: &TableCell, mode: CssMode) -> String { + let tag = if cell.is_header() { "th" } else { "td" }; + let colspan = cell.width(); + let rowspan = cell.height(); + let mut attrs = String::new(); + if colspan > 1 { + attrs.push_str(&format!(" colspan=\"{colspan}\"")); + } + if rowspan > 1 { + attrs.push_str(&format!(" rowspan=\"{rowspan}\"")); + } + if let Some(scope) = header_scope(cell.kind, colspan) { + attrs.push_str(&format!(" scope=\"{scope}\"")); + } + // `slots_by_row`'s slots borrow the layout's cells, so `hjust` can't be + // folded into `classes` up front — its class joins the fold here. + let (inner, outer): (Vec, Vec) = cell + .classes + .iter() + .copied() + .chain(cell.hjust_class()) + .partition(|class| INNER_CLASSES.contains(class)); + attrs.push_str(&styling_attr(&outer, mode)); + if inner.is_empty() { + format!("<{tag}{attrs}>{}", escape_html(&cell.content)) + } else { + let inner_attrs = styling_attr(&inner, mode); + format!( + "<{tag}{attrs}>{}", + escape_html(&cell.content) + ) + } +} + +/// `scope` value for a header cell of `kind` spanning `colspan` columns, or +/// `None` if `scope` doesn't apply. `ColumnLabel`/`StubHead` are always +/// single-column (`"col"`); `Spanner` can span several (`"colgroup"` if +/// `colspan > 1`); `StubRowLabel` heads its row (`"row"`); everything else +/// gets none. +fn header_scope(kind: TableCellKind, colspan: usize) -> Option<&'static str> { + match kind { + TableCellKind::ColumnLabel | TableCellKind::StubHead => Some("col"), + TableCellKind::Spanner => Some(if colspan > 1 { "colgroup" } else { "col" }), + TableCellKind::StubRowLabel => Some("row"), + TableCellKind::Body + | TableCellKind::Title + | TableCellKind::Subtitle + | TableCellKind::Caption + | TableCellKind::Filler => None, + } +} + +/// Render a `Caption`-kind `TableCell` as a `
` element — no +/// `colspan`/`rowspan`, since HTML doesn't take either there, and +/// `write_table` already pulled it out of the row grid before this is ever +/// called. Styling per `mode` via `styling_attr`, same as `render_cell`. +fn render_caption(cell: &TableCell, mode: CssMode) -> String { + let attrs = styling_attr(&cell.classes, mode); + format!("{}
/ tags' own attributes are `TableClass::Table`/ + // `TableClass::TableBody`'s concern, not this test's — split both off + // and check only that each is the tag it claims to be, then + // assert_eq! the rest verbatim. + let (table_tag, rest) = html.split_once('\n').unwrap(); + assert!(table_tag.starts_with("')); + let (before_tbody, rest) = rest.split_once("')); + assert_eq!( + before_tbody, + "\n\ + \n\ + \n\ + \n" + ); + assert_eq!( + rest, + "\n\ + \n\ +
" + ); + } + + #[test] + fn write_table_renders_a_caption_as_tables_first_child() { + let cells = vec![ + cell(TableCellKind::ColumnLabel, 0, 0, 0, 0), + cell(TableCellKind::Body, 1, 1, 0, 0), + TableCell::new( + TableCellKind::Caption, + 2, + 2, + 0, + 0, + "Source".to_string(), + ), + ]; + let rows = vec![TableRow::header(), TableRow::body(), TableRow::caption()]; + + let html = HtmlWriter { + css_mode: CssMode::Inline, + } + .write_table(&layout(cells, rows)) + .unwrap(); + + //
is table's first child, before
tag's own + // attributes are `TableClass::Table`'s concern, not this test's. + let (table_tag, rest) = html.split_once('\n').unwrap(); + assert!(table_tag.starts_with("')); + assert!(rest.starts_with("\n")); + assert!(html.find("").unwrap()); + // Not part of the grid: no is + // bare. + assert!(html.contains("")); + } + + #[test] + fn write_table_renders_an_inline_mode_heading_row_style_from_row_classes() { + // `Heading` carries real declarations, so inline mode's gets a + // style attribute from the recorded row class. + let cells = vec![ + cell(TableCellKind::Title, 0, 0, 0, 0).with_classes(vec![TableClass::Title]), + cell(TableCellKind::Body, 1, 1, 0, 0), + ]; + let rows = vec![ + TableRow { + classes: vec![TableClass::Heading], + ..TableRow::header() + }, + TableRow::body(), + ]; + + let html = HtmlWriter { + css_mode: CssMode::Inline, + } + .write_table(&layout(cells, rows)) + .unwrap(); + + assert!(html.contains(" stays bare. + assert!(html.contains("")); + } +} + +#[cfg(test)] +#[cfg(feature = "duckdb")] +mod tests { + use super::*; + use crate::reader::{DuckDBReader, Reader}; + + #[test] + fn test_write_table_renders_rows_and_escapes_html() { + let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); + reader + .execute_sql( + "CREATE TABLE sales AS SELECT * FROM (VALUES (1, 'a'), (2, 'b')) AS t(id, name)", + ) + .unwrap(); + let spec = reader.execute("TABULATE * FROM sales").unwrap(); + + let writer = HtmlWriter::new(); + let html = writer.render(&spec).unwrap(); + + // Precise per-dtype alignment is covered directly by + // resolve_column_properties's/discretise_hjust's own tests — this + // just checks the columns render and escaping works end to end. + assert!(html.starts_with("
<b>Source</b>").unwrap() < html.find("
/ for it, and it doesn't trip the + // "header above body" check despite sitting below the body row. + assert!(!html.contains("<b>Source</b>")); + assert!(!html.contains("<b>Source</b>")); + } + + #[test] + fn write_table_renders_a_heading_rows_tr_class_from_the_rows_param() { + let cells = vec![ + cell(TableCellKind::Title, 0, 0, 0, 0).with_classes(vec![TableClass::Title]), + cell(TableCellKind::ColumnLabel, 1, 1, 0, 0), + cell(TableCellKind::Body, 2, 2, 0, 0), + ]; + let rows = vec![ + TableRow { + classes: vec![TableClass::Heading], + ..TableRow::header() + }, + TableRow::header(), + TableRow::body(), + ]; + + let html = HtmlWriter::new().write_table(&layout(cells, rows)).unwrap(); + + assert!(html.contains("
")); + // The column-label row has no recorded row classes, so its