Skip to content

Table labels - #551

Draft
teunbrand wants to merge 5 commits into
table_plumbingfrom
table_labels
Draft

teunbrand wants to merge 5 commits into
table_plumbingfrom
table_labels

Conversation

@teunbrand

@teunbrand teunbrand commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator

This is a PR stacking on top of #550.

The user-facing change is that we can now use the LABEL clause in tabulate.

TABULATE FROM ggsql:penguins
  LABEL bill_dep => 'Depth'

However, the real meat of this PR is the intermediate representation of table cells, which is groundwork for future features.
The idea is that writers only receive a vector of TableCells, which contains all the relevant information for rendering the table.
Currently, these track the cells position in the table, the kind of cell (only body cell / column label now), the content (label) and in the future they'll also have formatting/styling information to be processed by the writer.
The HtmlWriter is made to have a surface-level understanding of TableCells, currently only considering top-left positions for placing them. Row- and column spans are planned once we develop features that generate them.

teunbrand and others added 5 commits September 14, 2026 11:28
TableCell (execute/table.rs) positions each column label and data value
on an inclusive row/column grid, tagged ColumnLabel/Body (TableCellKind,
naming borrowed from R's gt) so a writer can tell them apart without
relying on position. ResolvedTable now holds Vec<TableCell> instead of
a DataFrame; nrow()/ncol() are computed from cells rather than stored,
so there's one source of truth for the table's shape.

Writer::write_table drops its Table parameter entirely: Table's only
field (source) is already consumed before cells exist, so there's
nothing left for a writer to use it for. HtmlWriter, ggsql-cli and
ggsql-jupyter are updated to match.
create_column_labels and create_body each build their half of the
layout independently, both numbering rows from 0 — neither knows where
it sits relative to the other. compose_cells is a pure function of the
two (no DataFrame/SQL knowledge), computing the row offset from the
column labels' actual extent and shifting the body via
TableCell::offset_rows/offset_cols rather than each caller touching
top/bottom or left/right separately.

This is what lets more intermediate composers (header, footer, ...)
join compose_cells later as Table grows, and keeps cells_from_dataframe
useful as a place to test offset arithmetic in isolation from any real
query — added layout_tests covering create_column_labels, create_body,
and compose_cells (including a label section spanning more than one
row, to pin down that the offset comes from the labels' real extent
rather than an assumed single row) using the df! macro, no duckdb
feature or Reader needed.
tabulate_statement now accepts an optional label_clause (reused
unchanged from VISUALISE), so TABULATE FROM sales LABEL id => 'ID'
parses. Table.labels: Labels (not Option<Labels> — an empty Labels
already means "no overrides", so there's no third state an Option
would distinguish, and every consumer's logic is the same either way).

build_tabulate_statement now mirrors build_visualise_statement's
structure: a process_tab_clause function parallels process_viz_clause,
ready to grow an arm per future TABULATE clause (FACET/SCALE).

execute::table::create_column_labels resolves each column's content
against Table.labels with three outcomes: a column absent from labels
keeps its name, an explicit LABEL col => NULL empties the cell, and
LABEL col => 'text' overrides it. Verified end-to-end via a real
ggsql exec --writer html invocation, plus a unit test covering all
three outcomes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Groundwork ahead of column spanners, the feature expected to make cell
overlap actually reachable: validate_overlaps walks each cell's grid
footprint into a position set and errors on the first collision.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
create_table_columns is now the one place a TABULATE LABEL clause gets
resolved; create_column_labels and create_body both build cells off its
order instead of the DataFrame's raw column order, so a future spanner-
driven column rearrangement reaches cell positions without either
function changing. create_body also now looks columns up by name and
fetches each column's array once (outside the row loop) rather than
once per cell.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@teunbrand
teunbrand added this pull request to stack #552 September 17, 2026 09:29
@teunbrand teunbrand mentioned this pull request Sep 17, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant