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