Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions ggsql-cli/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<feature>`. A writer feature gates only its own row's render function in `writers.rs`; the row itself is always present.
Expand Down Expand Up @@ -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 type="application/json">` and mounted lazily, so the report works opened straight off disk (`fetch` would be blocked on `file://`) without paying for 200 charts up front.
Expand Down
5 changes: 3 additions & 2 deletions ggsql-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ regex.workspace = true
ureq = "3"

[features]
default = ["duckdb", "sqlite", "vegalite", "parquet", "builtin-data", "odbc", "svg", "pdf", "hep"]
default = ["duckdb", "sqlite", "vegalite", "html", "parquet", "builtin-data", "odbc", "svg", "pdf", "hep"]
duckdb = ["ggsql/duckdb"]
parquet = ["ggsql/parquet"]
sqlite = ["ggsql/sqlite"]
Expand All @@ -62,6 +62,7 @@ odbc = ["ggsql/odbc"]
any-writer = []

vegalite = ["ggsql/vegalite", "any-writer"]
html = ["ggsql/html", "any-writer"]
png = ["ggsql/png", "any-writer"]
jpeg = ["ggsql/jpeg", "any-writer"]
tiff = ["ggsql/tiff", "any-writer"]
Expand All @@ -75,7 +76,7 @@ hep = ["ggsql/hep", "any-writer"]
window = ["ggsql/window"]
builtin-data = ["ggsql/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"]

# cargo-packager configuration for cross-platform installers
[package.metadata.packager]
Expand Down
49 changes: 25 additions & 24 deletions ggsql-cli/examples/visual_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ when checking visual correctness.

use clap::{Parser, ValueEnum};
use ggsql::reader::{DuckDBReader, Reader};
use ggsql::util::escape_html;
use ggsql::validate::validate;
use ggsql::writer::{PngWriter, SvgWriter, VegaLiteWriter, Writer};
use std::fmt::Write as _;
Expand Down Expand Up @@ -119,12 +120,12 @@ impl Renderer {
/// beside the execution warnings rather than into the render error.
fn render(
self,
spec: &ggsql::reader::Spec,
spec: &ggsql::reader::ResolvedPlot,
args: &Args,
) -> ggsql::Result<(Vec<u8>, Vec<String>)> {
match self {
Renderer::Png => PngWriter::new(args.width, args.height, args.dpi)
.render(spec)
.write_plot(spec.plot(), spec.data())
.map(|bytes| (bytes, Vec::new())),
Renderer::Svg => SvgWriter::new(args.width, args.height, args.dpi)
.render_reporting(spec)
Expand Down Expand Up @@ -581,18 +582,24 @@ fn run_cells(source: Source, args: &Args, assets: &Path) -> SourceResult {
let start = Instant::now();
let mut warnings = Vec::new();

let has_visual = validate(&cell.query)
.map(|v| v.has_visual())
.unwrap_or(true);
let has_spec = validate(&cell.query).map(|v| v.has_spec()).unwrap_or(true);

let outcome = if has_visual {
let outcome = if has_spec {
match capture(|| reader.execute(&cell.query)) {
Err(e) => Outcome::Failed(e),
// Known gap: this harness only renders VISUALISE cells (the
// `Outcome` enum has no table variant), so a TABULATE cell
// is reported as failed rather than actually rendered — fine
// while no doc page uses TABULATE, but revisit once one does.
Ok(spec) if spec.as_plot().is_none() => {
Outcome::Failed("TABULATE cells aren't rendered by this harness yet".into())
}
Ok(spec) => {
warnings.extend(spec.warnings().iter().map(|w| w.message.clone()));
let plot = spec.as_plot().unwrap();
warnings.extend(plot.warnings().iter().map(|w| w.message.clone()));

let mut delta = None;
let (image, image_error) = match capture(|| args.writer.render(&spec, args)) {
let (image, image_error) = match capture(|| args.writer.render(plot, args)) {
Ok((bytes, degraded)) => {
// Beside the execution warnings, not folded into the
// render error: the render succeeded.
Expand Down Expand Up @@ -714,12 +721,6 @@ fn slug(text: &str) -> String {
.to_string()
}

fn escape(text: &str) -> String {
text.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}

/// Prepare a Vega-Lite spec for inlining in a `<script>` block.
///
/// The writer pretty-prints, which triples the size of a spec carrying a few
Expand Down Expand Up @@ -778,7 +779,7 @@ fn write_report(results: &[SourceResult], args: &Args, out: &Path) -> std::io::R
html,
"<a href=\"#{}\">{}{}</a>",
slug(&result.label),
escape(&result.label),
escape_html(&result.label),
marker
);
}
Expand All @@ -789,8 +790,8 @@ fn write_report(results: &[SourceResult], args: &Args, out: &Path) -> std::io::R
html,
"<h2 id=\"{}\" class=\"source\">{}<small>{}</small></h2>",
slug(&result.label),
escape(&result.title),
escape(&result.label)
escape_html(&result.title),
escape_html(&result.label)
);
for cell in &result.cells {
html.push_str(&render_cell(cell, &result.label, &aspect));
Expand Down Expand Up @@ -839,22 +840,22 @@ fn render_cell(cell: &CellResult, label: &str, aspect: &str) -> String {
<span class=\"time\">{} ms</span></header>\n",
slug(label),
cell.cell.index,
escape(label),
escape_html(label),
cell.cell.line,
cell.cell.index,
escape(&cell.cell.heading),
escape_html(&cell.cell.heading),
cell.millis
);

let _ = write!(
html,
"<div class=\"body\"><pre class=\"query\"><code>{}</code></pre>\n<div class=\"renders\">",
escape(&cell.cell.query)
escape_html(&cell.cell.query)
);

match &cell.outcome {
Outcome::Failed(message) => {
let _ = write!(html, "<pre class=\"error\">{}</pre>", escape(message));
let _ = write!(html, "<pre class=\"error\">{}</pre>", escape_html(message));
}
Outcome::Setup { rows, columns } => {
let _ = write!(
Expand Down Expand Up @@ -891,7 +892,7 @@ fn render_cell(cell: &CellResult, label: &str, aspect: &str) -> String {
);
}
(None, Some(message)) => {
let _ = write!(html, "<pre class=\"error\">{}</pre>", escape(message));
let _ = write!(html, "<pre class=\"error\">{}</pre>", escape_html(message));
}
(None, None) => html.push_str("<p class=\"note\">no output</p>"),
}
Expand All @@ -914,7 +915,7 @@ fn render_cell(cell: &CellResult, label: &str, aspect: &str) -> String {
html,
"<figure><figcaption>vega-lite</figcaption>\
<pre class=\"error\">{}</pre></figure>",
escape(message)
escape_html(message)
);
}
}
Expand All @@ -925,7 +926,7 @@ fn render_cell(cell: &CellResult, label: &str, aspect: &str) -> String {
if !cell.warnings.is_empty() {
html.push_str("<ul class=\"warnings\">");
for warning in &cell.warnings {
let _ = write!(html, "<li>{}</li>", escape(warning));
let _ = write!(html, "<li>{}</li>", escape_html(warning));
}
html.push_str("</ul>");
}
Expand Down
75 changes: 54 additions & 21 deletions ggsql-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ Provides commands for executing ggsql queries with various data sources and outp
*/

use clap::{Args, Parser, Subcommand, ValueEnum};
use ggsql::reader::{connection, Reader, Spec};
use ggsql::reader::{connection, Reader, ResolvedSpec};
use ggsql::validate::validate;
use ggsql::writer::WriterOptions;
use ggsql::{parser, VERSION};
Expand Down Expand Up @@ -391,7 +391,7 @@ fn exec_with_reader(query: &str, reader: &dyn Reader, args: &RenderArgs, writer:
}
};

if !validated.has_visual() {
if !validated.has_spec() {
if args.verbose {
eprintln!("Visualisation is empty. Printing table instead.");
}
Expand All @@ -411,18 +411,40 @@ fn exec_with_reader(query: &str, reader: &dyn Reader, args: &RenderArgs, writer:
render_spec(spec, args, writer);
}

fn render_spec(spec: Spec, args: &RenderArgs, writer: &WriterSpec) {
if args.verbose {
let metadata = spec.metadata();
eprintln!("\nQuery executed:");
eprintln!(" Rows: {}", metadata.rows);
eprintln!(" Columns: {}", metadata.columns.join(", "));
eprintln!(" Layers: {}", metadata.layer_count);
fn render_spec(spec: ResolvedSpec, args: &RenderArgs, writer: &WriterSpec) {
match &spec {
ResolvedSpec::Plot(plot) => {
if args.verbose {
let metadata = plot.metadata();
eprintln!("\nQuery executed:");
eprintln!(" Rows: {}", metadata.rows);
eprintln!(" Columns: {}", metadata.columns.join(", "));
eprintln!(" Layers: {}", metadata.layer_count);
}

if plot.plot().layers.is_empty() {
eprintln!("No visualization specifications found");
std::process::exit(1);
}
}
ResolvedSpec::Table(table) => {
if args.verbose {
eprintln!("\nQuery executed:");
eprintln!(" Rows: {}", table.body().height());
eprintln!(" Columns: {}", table.body().width());
}
}
}

if spec.plot().layers.is_empty() {
eprintln!("No visualization specifications found");
std::process::exit(1);
// Not behind -v: a query that silently dropped a statement (e.g. a mix of
// VISUALISE and TABULATE, only the first of which is resolved) is a
// correctness-relevant fact, not a verbose-only detail.
let query_warnings = match &spec {
ResolvedSpec::Plot(plot) => plot.warnings(),
ResolvedSpec::Table(table) => table.warnings(),
};
for warning in query_warnings {
eprintln!("warning: {}", warning.message);
}

let info = writer.info;
Expand Down Expand Up @@ -514,7 +536,7 @@ fn cmd_view(query: String, args: &ViewArgs) {
eprintln!("Failed to validate query: {}", e);
std::process::exit(1);
});
if !validated.has_visual() {
if !validated.has_spec() {
eprintln!("This query has no VISUALISE clause, so there is no plot to show.");
std::process::exit(1);
}
Expand All @@ -523,16 +545,20 @@ fn cmd_view(query: String, args: &ViewArgs) {
eprintln!("Failed to execute query: {}", e);
std::process::exit(1);
});
let plot = spec.as_plot().unwrap_or_else(|| {
eprintln!("This is a TABULATE query; there is no plot to show.");
std::process::exit(1);
});

if args.verbose {
let metadata = spec.metadata();
let metadata = plot.metadata();
eprintln!(" Rows: {}", metadata.rows);
eprintln!(" Layers: {}", metadata.layer_count);
eprintln!("Close the window to exit.");
}

// Blocks on the main thread until the window closes.
if let Err(e) = viewer.show(&spec) {
if let Err(e) = viewer.show(plot) {
eprintln!("{}", e);
std::process::exit(1);
}
Expand Down Expand Up @@ -567,12 +593,19 @@ fn cmd_parse(query: String, format: String) {
"pretty" => {
println!("ggsql Specifications: {} total", specs.len());
for (i, spec) in specs.iter().enumerate() {
println!("\nVisualization #{}:", i + 1);
println!(" Global Mappings: {:?}", spec.global_mappings);
println!(" Layers: {}", spec.layers.len());
println!(" Scales: {}", spec.scales.len());
if spec.facet.is_some() {
println!(" Faceting: Yes");
match spec {
ggsql::Spec::Plot(plot) => {
println!("\nVisualization #{}:", i + 1);
println!(" Global Mappings: {:?}", plot.global_mappings);
println!(" Layers: {}", plot.layers.len());
println!(" Scales: {}", plot.scales.len());
if plot.facet.is_some() {
println!(" Faceting: Yes");
}
}
ggsql::Spec::Table(_) => {
println!("\nTable #{}:", i + 1);
}
}
}
}
Expand Down
Loading