diff --git a/pkg-r/DESCRIPTION b/pkg-r/DESCRIPTION
index f98170564..84ac7f3ac 100644
--- a/pkg-r/DESCRIPTION
+++ b/pkg-r/DESCRIPTION
@@ -61,6 +61,8 @@ Suggests:
withr
VignetteBuilder:
knitr
+Remotes:
+ ggsql=posit-dev/ggsql-r#18
Config/roxygen2/version: 8.1.1
Config/testthat/edition: 3
Config/testthat/parallel: true
diff --git a/pkg-r/NEWS.md b/pkg-r/NEWS.md
index 375e6d362..f5a660d78 100644
--- a/pkg-r/NEWS.md
+++ b/pkg-r/NEWS.md
@@ -1,5 +1,9 @@
# querychat (development version)
+## New features
+
+* The `"visualize"` tool now executes ggsql queries end to end through a caching reader (`ggsql::caching_reader()`): identical base reads across chart refinements are memoized (a session of refinements over one shared base read costs 1 source read instead of N), and queries with layer-level sources (`DRAW ... FROM
`) are now supported instead of rejected. Requires ggsql >= 0.5.2.9000; see the caching section of `vignette("visualize")` for tuning knobs and the staleness caveat.
+
## Bug fixes
* JSON pins now fall back to `pins::pin_read()` when DuckDB's JSON extension cannot be installed or loaded, including on builds that disable extension loading. Native DuckDB JSON reading is retained when the extension is available.
diff --git a/pkg-r/R/QueryChat.R b/pkg-r/R/QueryChat.R
index ad3cb876e..5551032b5 100644
--- a/pkg-r/R/QueryChat.R
+++ b/pkg-r/R/QueryChat.R
@@ -1654,13 +1654,13 @@ check_viz_deps <- function(tools) {
if (
is.null(tools) ||
!"visualize" %in% tools ||
- rlang::is_installed("ggsql", version = "0.5.2")
+ rlang::is_installed("ggsql", version = "0.5.2.9000")
) {
return(tools)
}
rlang::warn(
c(
- 'The "visualize" tool requires {.pkg ggsql} 0.5.2 or later.',
+ 'The "visualize" tool requires {.pkg ggsql} 0.5.2.9000 or later.',
"i" = 'Install it with `install.packages("ggsql")`; continuing without the "visualize" tool.'
),
.frequency = "once",
diff --git a/pkg-r/R/querychat_module.R b/pkg-r/R/querychat_module.R
index 6020c2275..6470a36bf 100644
--- a/pkg-r/R/querychat_module.R
+++ b/pkg-r/R/querychat_module.R
@@ -354,20 +354,22 @@ restore_record_list <- function(x) {
}
restore_viz_widgets <- function(executor, saved_widgets, session) {
- if (!rlang::is_installed("ggsql")) {
+ if (!rlang::is_installed("ggsql", version = "0.5.2.9000")) {
warning(
- "ggsql is not installed; skipping restoration of visualization widgets.",
+ "ggsql >= 0.5.2.9000 is not installed; skipping restoration of visualization widgets.",
call. = FALSE
)
return(list())
}
restored <- list()
+ # One caching reader shared across restored widgets: identical base reads
+ # across widgets are served from the reader's memoized cache.
+ reader <- viz_caching_reader(executor)
for (entry in saved_widgets) {
tryCatch(
{
- validated <- ggsql::ggsql_validate(entry$ggsql)
- spec <- execute_ggsql(executor, validated)
+ spec <- ggsql::ggsql_execute(reader, entry$ggsql)
session$output[[entry$widget_id]] <- ggsql::renderGgsql(spec)
restored <- c(restored, list(entry))
},
diff --git a/pkg-r/R/querychat_viz.R b/pkg-r/R/querychat_viz.R
index 27bbb10dc..b7bafe311 100644
--- a/pkg-r/R/querychat_viz.R
+++ b/pkg-r/R/querychat_viz.R
@@ -38,11 +38,45 @@ tool_visualize_impl <- function(executor, session, update_fn) {
force(session)
force(update_fn)
+ # One caching reader per chat client (this closure is created once per
+ # client): memoized base reads are shared across viz calls in the session,
+ # and the reader's cache is freed when the client is dropped. Created
+ # lazily so clients that never visualize don't pay for an idle cache.
+ reader <- NULL
+
function(ggsql, title) {
- visualize_result(executor, session, update_fn, ggsql, title)
+ if (is.null(reader)) {
+ reader <<- viz_caching_reader(executor)
+ }
+ visualize_result(reader, session, update_fn, ggsql, title)
}
}
+# A ggsql caching reader serving base reads through a querychat executor.
+# Writes are never forwarded to the executor: ggsql's CachingReader routes
+# them to its own in-memory cache, so only `execute_sql` is bridged.
+viz_caching_reader <- function(executor) {
+ rlang::check_installed(
+ "ggsql",
+ version = "0.5.2.9000",
+ reason = "for visualization support (ggsql::caching_reader)."
+ )
+ # Indirect lookup: caching_reader() doesn't exist in ggsql <= 0.5.2, so a
+ # static ggsql::caching_reader reference trips R CMD check's "missing or
+ # unexported object" warning when an older ggsql is installed.
+ caching_reader <- getExportedValue("ggsql", "caching_reader")
+ caching_reader(
+ primary = function(sql) {
+ df <- executor$execute_query(sql)
+ if (inherits(df, "tbl_sql")) {
+ # Materialize the query for ggsql, {dplyr} guaranteed by TblSqlSource
+ df <- dplyr::collect(df)
+ }
+ df
+ }
+ )
+}
+
random_hex <- function(n_bytes = 8) {
paste0(
format(as.hexmode(sample(256, n_bytes, replace = TRUE) - 1L), width = 2),
@@ -51,13 +85,17 @@ random_hex <- function(n_bytes = 8) {
}
visualize_result <- function(
- executor,
+ reader,
session,
update_fn,
ggsql_str,
title
) {
- rlang::check_installed("ggsql", reason = "for visualization support.")
+ rlang::check_installed(
+ "ggsql",
+ version = "0.5.2.9000",
+ reason = "for visualization support (ggsql::caching_reader)."
+ )
validated <- ggsql::ggsql_validate(ggsql_str)
has_visual <- ggsql::ggsql_has_visual(validated)
@@ -72,7 +110,7 @@ visualize_result <- function(
rlang::abort(collapse_validation_errors(validated))
}
- spec <- execute_ggsql(executor, validated)
+ spec <- ggsql::ggsql_execute(reader, ggsql_str)
widget_id <- paste0("querychat_viz_", random_hex())
@@ -311,117 +349,3 @@ render_viz_tool_description <- function(db_type, has_tool_query = FALSE) {
)
)
}
-
-#' Execute a pre-validated ggsql query against an executor
-#'
-#' Executes the SQL portion through an executor (preserving database pushdown),
-#' then feeds the result into a ggsql DuckDB reader to produce a Spec.
-#'
-#' @param executor A querychat QueryExecutor R6 object.
-#' @param validated A pre-validated ggsql query (from `ggsql::ggsql_validate()`).
-#' Must be a list with `$sql` and `$visual` fields.
-#'
-#' @return A `ggsql::Spec` R6 object (the writer-independent plot specification).
-#'
-#' @keywords internal
-execute_ggsql <- function(executor, validated) {
- rlang::check_installed("ggsql", reason = "for visualization support.")
-
- visual <- validated$visual
-
- if (has_layer_level_source(visual)) {
- cli::cli_abort(
- "Layer-specific sources are not currently supported in querychat visual queries. Rewrite the query so that all layers come from the final SQL result."
- )
- }
-
- df <- executor$execute_query(validated$sql)
-
- if (inherits(df, "tbl_sql")) {
- # Materialize the query for ggsql, {dplyr} guaranteed by TblSqlSource
- df <- dplyr::collect(df)
- }
-
- reader <- ggsql::duckdb_reader()
- table <- extract_visualise_table(visual)
-
- if (!is.null(table)) {
- # VISUALISE [mappings] FROM
— register data under the
- # referenced table name and execute the visual part directly.
- name <- if (startsWith(table, '"') && endsWith(table, '"')) {
- substr(table, 2, nchar(table) - 1)
- } else {
- table
- }
- ggsql::ggsql_register(reader, df, name)
- ggsql::ggsql_execute(reader, visual)
- } else {
- # SELECT ... VISUALISE — no FROM in VISUALISE clause, so register
- # under a synthetic name and prepend a SELECT.
- ggsql::ggsql_register(reader, df, "_data")
- ggsql::ggsql_execute(reader, paste("SELECT * FROM _data", visual))
- }
-}
-
-#' Extract the table name from a VISUALISE clause's FROM, if present
-#'
-#' Looks only in the portion of the visual string before the first DRAW keyword,
-#' so FROM clauses inside DRAW (e.g., MAPPING x FROM other) are ignored.
-#'
-#' @param visual A ggsql VISUALISE string.
-#' @return The table name string (possibly quoted), or `NULL` if not present.
-#'
-#' @keywords internal
-extract_visualise_table <- function(visual) {
- draw_pos <- regexpr("\\bDRAW\\b", visual, ignore.case = TRUE, perl = TRUE)
- vis_clause <- if (draw_pos > 0) substr(visual, 1, draw_pos - 1L) else visual
- m <- regmatches(
- vis_clause,
- regexpr(
- '\\bFROM\\s+("[^"]+?"|\\S+)',
- vis_clause,
- ignore.case = TRUE,
- perl = TRUE
- )
- )
- if (length(m) == 0 || !nzchar(m)) {
- return(NULL)
- }
- sub("^(?i)FROM\\s+", "", m, perl = TRUE)
-}
-
-#' Detect whether a VISUALISE string has a layer-level FROM source
-#'
-#' Returns `TRUE` when a DRAW clause defines its own `FROM ` via a
-#' MAPPING sub-clause. Querychat replays VISUALISE against a single local
-#' relation, so layer-specific sources cannot be preserved reliably.
-#'
-#' @param visual A ggsql VISUALISE string.
-#' @return `TRUE` if any DRAW clause contains a MAPPING ... FROM source.
-#'
-#' @keywords internal
-has_layer_level_source <- function(visual) {
- # Split at clause boundaries (DRAW, SCALE, etc.) using a lookbehind for
- # whitespace rather than \b, which can split mid-word in R's PCRE engine.
- clauses <- strsplit(
- visual,
- "(?i)(?<=\\s)(?=DRAW|SCALE|PROJECT|FACET|PLACE|LABEL|THEME)",
- perl = TRUE
- )[[1]]
- for (clause in clauses) {
- if (!grepl("^\\s*DRAW\\b", clause, ignore.case = TRUE, perl = TRUE)) {
- next
- }
- if (
- grepl(
- "\\bMAPPING\\b[\\s\\S]*?\\bFROM\\s+(\"[^\"]+?\"|\\S+)",
- clause,
- ignore.case = TRUE,
- perl = TRUE
- )
- ) {
- return(TRUE)
- }
- }
- FALSE
-}
diff --git a/pkg-r/inst/prompts/ggsql-syntax.md b/pkg-r/inst/prompts/ggsql-syntax.md
index f9ad904ab..79abd7bb8 100644
--- a/pkg-r/inst/prompts/ggsql-syntax.md
+++ b/pkg-r/inst/prompts/ggsql-syntax.md
@@ -513,34 +513,15 @@ PROJECT TO polar SETTING inner => 0.5
VISUALISE FROM titanic
DRAW bar MAPPING class AS x
```
-3. **In querychat, all layers must come from the final SQL result**: Do not use layer-specific `FROM source` inside `DRAW ... MAPPING ...` clauses. If you need raw data and a summary in one chart, put both into one final relation and distinguish layers with a column such as `layer_type`:
+3. **Layers can draw from other relations in the query**: Attach `FROM ` to a layer's `MAPPING` to combine different granularities in one chart — e.g. raw points from the final SELECT with a summary line from a CTE. The `FROM` belongs to `MAPPING` (a bare `DRAW line FROM t` is a parse error). Explicit layer mappings resolve against the layer's source; aesthetics the layer doesn't map are inherited from VISUALISE:
```sql
- WITH raw AS (
- SELECT
- date,
- amount,
- region,
- 'raw' AS layer_type
- FROM sales
- ),
- summary AS (
- SELECT
- date,
- AVG(amount) AS amount,
- region,
- 'summary' AS layer_type
- FROM sales
- GROUP BY date, region
- ),
- combined AS (
- SELECT * FROM raw
- UNION ALL
- SELECT * FROM summary
+ WITH summary AS (
+ SELECT date, AVG(amount) AS amount FROM sales GROUP BY date
)
- SELECT * FROM combined
+ SELECT date, amount FROM sales
VISUALISE date AS x, amount AS y
- DRAW point MAPPING region AS color FILTER layer_type = 'raw'
- DRAW line MAPPING region AS color FILTER layer_type = 'summary'
+ DRAW point SETTING opacity => 0.3
+ DRAW line MAPPING date AS x, amount AS y FROM summary
```
4. **String values use single quotes**: In SETTING, LABEL, and RENAMING clauses, always use single quotes for string values. Double quotes cause parse errors.
5. **Statistical layers**: When using `histogram`, `bar` (without y), `density`, `smooth`, `violin`, or `boxplot`, the layer computes statistics. Use REMAPPING to access `density`, `intensity`, `proportion`, etc.
diff --git a/pkg-r/man/execute_ggsql.Rd b/pkg-r/man/execute_ggsql.Rd
deleted file mode 100644
index 532af74ae..000000000
--- a/pkg-r/man/execute_ggsql.Rd
+++ /dev/null
@@ -1,22 +0,0 @@
-% Generated by roxygen2: do not edit by hand
-% Please edit documentation in R/querychat_viz.R
-\name{execute_ggsql}
-\alias{execute_ggsql}
-\title{Execute a pre-validated ggsql query against an executor}
-\usage{
-execute_ggsql(executor, validated)
-}
-\arguments{
-\item{executor}{A querychat QueryExecutor R6 object.}
-
-\item{validated}{A pre-validated ggsql query (from \code{ggsql::ggsql_validate()}).
-Must be a list with \verb{$sql} and \verb{$visual} fields.}
-}
-\value{
-A \code{ggsql::Spec} R6 object (the writer-independent plot specification).
-}
-\description{
-Executes the SQL portion through an executor (preserving database pushdown),
-then feeds the result into a ggsql DuckDB reader to produce a Spec.
-}
-\keyword{internal}
diff --git a/pkg-r/man/extract_visualise_table.Rd b/pkg-r/man/extract_visualise_table.Rd
deleted file mode 100644
index dbc1039c7..000000000
--- a/pkg-r/man/extract_visualise_table.Rd
+++ /dev/null
@@ -1,19 +0,0 @@
-% Generated by roxygen2: do not edit by hand
-% Please edit documentation in R/querychat_viz.R
-\name{extract_visualise_table}
-\alias{extract_visualise_table}
-\title{Extract the table name from a VISUALISE clause's FROM, if present}
-\usage{
-extract_visualise_table(visual)
-}
-\arguments{
-\item{visual}{A ggsql VISUALISE string.}
-}
-\value{
-The table name string (possibly quoted), or \code{NULL} if not present.
-}
-\description{
-Looks only in the portion of the visual string before the first DRAW keyword,
-so FROM clauses inside DRAW (e.g., MAPPING x FROM other) are ignored.
-}
-\keyword{internal}
diff --git a/pkg-r/man/has_layer_level_source.Rd b/pkg-r/man/has_layer_level_source.Rd
deleted file mode 100644
index bacaa5c33..000000000
--- a/pkg-r/man/has_layer_level_source.Rd
+++ /dev/null
@@ -1,20 +0,0 @@
-% Generated by roxygen2: do not edit by hand
-% Please edit documentation in R/querychat_viz.R
-\name{has_layer_level_source}
-\alias{has_layer_level_source}
-\title{Detect whether a VISUALISE string has a layer-level FROM source}
-\usage{
-has_layer_level_source(visual)
-}
-\arguments{
-\item{visual}{A ggsql VISUALISE string.}
-}
-\value{
-\code{TRUE} if any DRAW clause contains a MAPPING ... FROM source.
-}
-\description{
-Returns \code{TRUE} when a DRAW clause defines its own \verb{FROM } via a
-MAPPING sub-clause. Querychat replays VISUALISE against a single local
-relation, so layer-specific sources cannot be preserved reliably.
-}
-\keyword{internal}
diff --git a/pkg-r/tests/testthat/test-QueryChat.R b/pkg-r/tests/testthat/test-QueryChat.R
index c2b1ddb22..05f118519 100644
--- a/pkg-r/tests/testthat/test-QueryChat.R
+++ b/pkg-r/tests/testthat/test-QueryChat.R
@@ -441,7 +441,7 @@ describe("QueryChat$client()", {
})
it("registers visualize tool when tools include 'visualize'", {
- skip_if_not_installed("ggsql")
+ skip_if_not_installed("ggsql", minimum_version = "0.5.2.9000")
session <- structure(
list(
output = list(),
@@ -467,7 +467,7 @@ describe("QueryChat$client()", {
})
it("registers visualize tool without a session", {
- skip_if_not_installed("ggsql")
+ skip_if_not_installed("ggsql", minimum_version = "0.5.2.9000")
qc <- QueryChat$new(
new_test_df(),
@@ -482,7 +482,7 @@ describe("QueryChat$client()", {
})
it("registers only visualize tool when tools = 'visualize'", {
- skip_if_not_installed("ggsql")
+ skip_if_not_installed("ggsql", minimum_version = "0.5.2.9000")
session <- structure(
list(
output = list(),
diff --git a/pkg-r/tests/testthat/test-querychat-viz.R b/pkg-r/tests/testthat/test-querychat-viz.R
index 865fe30d0..282bcce5f 100644
--- a/pkg-r/tests/testthat/test-querychat-viz.R
+++ b/pkg-r/tests/testthat/test-querychat-viz.R
@@ -1,66 +1,14 @@
-describe("extract_visualise_table()", {
- it("extracts bare identifier", {
- expect_equal(
- extract_visualise_table(
- "VISUALISE x AS x, y AS y FROM my_table DRAW point"
- ),
- "my_table"
- )
- })
-
- it("extracts quoted identifier", {
- expect_equal(
- extract_visualise_table('VISUALISE x AS x FROM "My Table" DRAW point'),
- '"My Table"'
- )
- })
-
- it("returns NULL when no FROM in VISUALISE", {
- expect_null(extract_visualise_table("VISUALISE x AS x, y AS y DRAW point"))
- })
-
- it("ignores FROM inside DRAW clause", {
- visual <- "VISUALISE x AS x FROM data DRAW point MAPPING x FROM other"
- expect_equal(extract_visualise_table(visual), "data")
- })
-})
-
-describe("has_layer_level_source()", {
- it("detects DRAW-level FROM source", {
- expect_true(
- has_layer_level_source(
- "VISUALISE x AS x DRAW point MAPPING x FROM my_table"
- )
- )
- })
-
- it("ignores VISUALISE-level FROM", {
- expect_false(
- has_layer_level_source(
- "VISUALISE x AS x FROM my_table DRAW point"
- )
- )
- })
-
- it("ignores SCALE FROM", {
- expect_false(
- has_layer_level_source(
- "VISUALISE x AS x DRAW bar SCALE fill FROM 'red'"
- )
- )
- })
-})
-
-describe("execute_ggsql()", {
+describe("ggsql execution through a caching reader", {
skip_if_no_dataframe_engine()
- skip_if_not_installed("ggsql")
+ skip_if_not_installed("ggsql", minimum_version = "0.5.2.9000")
it("executes a basic ggsql query", {
ds <- local_data_frame_source(new_test_df())
- validated <- ggsql::ggsql_validate(
+ reader <- viz_caching_reader(ds)
+ spec <- ggsql::ggsql_execute(
+ reader,
"SELECT * FROM test_table VISUALISE value AS x DRAW histogram"
)
- spec <- execute_ggsql(ds, validated)
expect_s3_class(spec, "Spec")
})
@@ -70,72 +18,105 @@ describe("execute_ggsql()", {
listings = c(10, 20)
)
ds <- local_data_frame_source(df, table_name = "upper_table")
- validated <- ggsql::ggsql_validate(
+ reader <- viz_caching_reader(ds)
+ spec <- ggsql::ggsql_execute(
+ reader,
paste(
"SELECT ROOM_TYPE, listings FROM upper_table",
"VISUALISE ROOM_TYPE AS x, listings AS y DRAW bar"
)
)
- spec <- execute_ggsql(ds, validated)
expect_s3_class(spec, "Spec")
})
- it("passes original column names through to ggsql", {
- df <- data.frame(
- ROOM_TYPE = c("Entire home", "Private room"),
- listings = c(10, 20)
- )
- ds <- local_data_frame_source(df, table_name = "upper_table")
- validated <- list(
- sql = "SELECT ROOM_TYPE, listings FROM upper_table",
- visual = "VISUALISE ROOM_TYPE AS x, listings AS y DRAW bar"
+ it("supports FROM before VISUALISE", {
+ ds <- local_data_frame_source(new_test_df())
+ reader <- viz_caching_reader(ds)
+ spec <- ggsql::ggsql_execute(
+ reader,
+ "FROM test_table VISUALISE value AS x DRAW histogram"
)
- captured_names <- NULL
+ expect_s3_class(spec, "Spec")
+ })
- local_mocked_bindings(
- duckdb_reader = function(...) "reader",
- ggsql_register = function(reader, df, name) {
- captured_names <<- names(df)
- invisible(NULL)
- },
- ggsql_execute = function(reader, query) {
- structure(list(), class = "Spec")
- },
- .package = "ggsql"
+ it("supports layer-level FROM sources", {
+ # Previously rejected with "Layer-specific sources are not currently
+ # supported"; the caching reader stages each layer source through its
+ # private cache, so the query now runs end to end.
+ ds <- local_data_frame_source(new_test_df())
+ reader <- viz_caching_reader(ds)
+ spec <- ggsql::ggsql_execute(
+ reader,
+ "VISUALISE value AS x DRAW histogram MAPPING value AS x FROM test_table"
)
+ expect_s3_class(spec, "Spec")
+ })
- spec <- execute_ggsql(ds, validated)
+ it("supports single-relation raw + summary overlay", {
+ df <- data.frame(
+ x = c(1, 1, 2, 2),
+ y = c(10, 20, 30, 40),
+ category = c("a", "b", "a", "b")
+ )
+ ds <- local_data_frame_source(df, table_name = "sales")
+ reader <- viz_caching_reader(ds)
+ query <- "
+ WITH raw AS (
+ SELECT x, y, category, 'raw' AS layer_type FROM sales
+ ),
+ summary AS (
+ SELECT x, AVG(y) AS y, category, 'summary' AS layer_type
+ FROM sales GROUP BY x, category
+ ),
+ combined AS (
+ SELECT * FROM raw UNION ALL SELECT * FROM summary
+ )
+ SELECT * FROM combined
+ VISUALISE x AS x, y AS y
+ DRAW point MAPPING category AS color FILTER layer_type = 'raw'
+ DRAW line MAPPING category AS color FILTER layer_type = 'summary'
+ "
+ spec <- ggsql::ggsql_execute(reader, query)
expect_s3_class(spec, "Spec")
- expect_equal(captured_names, c("ROOM_TYPE", "listings"))
+ expect_equal(ggsql::ggsql_metadata(spec)$rows, 4)
})
+})
- it("supports FROM before VISUALISE", {
+describe("viz caching behavior", {
+ skip_if_no_dataframe_engine()
+ skip_if_not_installed("ggsql", minimum_version = "0.5.2.9000")
+
+ # Wraps a DataFrameSource, counting base reads that reach it.
+ counting_executor <- function(ds) {
+ env <- new.env(parent = emptyenv())
+ env$calls <- 0L
+ env$execute_query <- function(sql) {
+ env$calls <- env$calls + 1L
+ ds$execute_query(sql)
+ }
+ env
+ }
+
+ it("identical queries share memoized source reads through one reader", {
ds <- local_data_frame_source(new_test_df())
- validated <- ggsql::ggsql_validate(
- "FROM test_table VISUALISE value AS x DRAW histogram"
- )
- expect_true(validated$valid)
- expect_true(validated$has_visual)
- expect_equal(validated$sql, "SELECT * FROM test_table")
- expect_equal(validated$visual, "VISUALISE value AS x DRAW histogram")
+ executor <- counting_executor(ds)
+ reader <- viz_caching_reader(executor)
- spec <- execute_ggsql(ds, validated)
- expect_s3_class(spec, "Spec")
+ query <- "SELECT * FROM test_table VISUALISE value AS x DRAW point"
+ ggsql::ggsql_execute(reader, query)
+ ggsql::ggsql_execute(reader, query)
+ expect_equal(executor$calls, 1L)
})
- it("rejects layer-level FROM sources", {
+ it("independent readers do not share memoized reads", {
+ # Guards against reintroducing an implicit shared registry: readers own
+ # their caches, so two readers over one source each re-read it.
ds <- local_data_frame_source(new_test_df())
- # This is a synthetic test — construct a validated object whose
- # visual has a layer-level source
- expect_error(
- {
- validated <- list(
- sql = "SELECT * FROM test_table",
- visual = "VISUALISE value AS x DRAW point MAPPING x FROM other_table"
- )
- execute_ggsql(ds, validated)
- },
- "Layer-specific sources"
- )
+ executor <- counting_executor(ds)
+
+ query <- "SELECT * FROM test_table VISUALISE value AS x DRAW point"
+ ggsql::ggsql_execute(viz_caching_reader(executor), query)
+ ggsql::ggsql_execute(viz_caching_reader(executor), query)
+ expect_equal(executor$calls, 2L)
})
})
diff --git a/pkg-r/tests/testthat/test-viz-tool.R b/pkg-r/tests/testthat/test-viz-tool.R
index d20ec28a3..a03ba55b7 100644
--- a/pkg-r/tests/testthat/test-viz-tool.R
+++ b/pkg-r/tests/testthat/test-viz-tool.R
@@ -1,6 +1,6 @@
describe("tool_visualize_dashboard()", {
skip_if_no_dataframe_engine()
- skip_if_not_installed("ggsql")
+ skip_if_not_installed("ggsql", minimum_version = "0.5.2.9000")
it("creates a tool with correct name", {
ds <- local_data_frame_source(new_test_df())
@@ -156,7 +156,6 @@ describe("tool_visualize_dashboard()", {
)
local_mocked_bindings(
- execute_ggsql = function(...) structure(list(), class = "ggsql_spec"),
build_viz_footer = function(...) htmltools::tagList(),
.package = "querychat"
)
@@ -165,6 +164,7 @@ describe("tool_visualize_dashboard()", {
structure(list(valid = TRUE), class = "ggsql_validated")
},
ggsql_has_visual = function(...) TRUE,
+ ggsql_execute = function(...) structure(list(), class = "ggsql_spec"),
renderGgsql = function(...) shiny::renderText("ok"),
ggsqlOutput = function(id) htmltools::div(id = id),
ggsql_save = function(...) rlang::abort("V8 is not installed"),
@@ -264,14 +264,11 @@ describe("tool_visualize_dashboard()", {
local_mocked_bindings(
ggsql_validate = function(...) validated,
ggsql_has_visual = function(...) TRUE,
- .package = "ggsql"
- )
- local_mocked_bindings(
- execute_ggsql = function(...) {
+ ggsql_execute = function(...) {
executed <<- TRUE
- rlang::abort("execute_ggsql should not be called")
+ rlang::abort("ggsql_execute should not be called")
},
- .package = "querychat"
+ .package = "ggsql"
)
tool <- tool_visualize_dashboard(
@@ -464,7 +461,7 @@ describe("build_viz_footer()", {
describe("visualization tool display", {
skip_if_no_dataframe_engine()
- skip_if_not_installed("ggsql")
+ skip_if_not_installed("ggsql", minimum_version = "0.5.2.9000")
it("requests Shiny Chat's framed open style", {
ds <- local_data_frame_source(new_test_df())
@@ -477,7 +474,6 @@ describe("visualization tool display", {
)
local_mocked_bindings(
- execute_ggsql = function(...) structure(list(), class = "ggsql_spec"),
build_viz_footer = function(...) htmltools::tagList(),
.package = "querychat"
)
@@ -486,6 +482,7 @@ describe("visualization tool display", {
structure(list(valid = TRUE), class = "ggsql_validated")
},
ggsql_has_visual = function(...) TRUE,
+ ggsql_execute = function(...) structure(list(), class = "ggsql_spec"),
renderGgsql = function(...) shiny::renderText("ok"),
ggsqlOutput = function(id) htmltools::div(id = id),
ggsql_save = function(...) rlang::abort("V8 is not installed"),
diff --git a/pkg-r/vignettes/visualize.Rmd b/pkg-r/vignettes/visualize.Rmd
index 77c27b2fc..0f954174c 100644
--- a/pkg-r/vignettes/visualize.Rmd
+++ b/pkg-r/vignettes/visualize.Rmd
@@ -137,13 +137,29 @@ Each chart has controls in its footer:
## How it works
1. **The LLM generates a ggsql query** — a SQL-like grammar that describes both data transformation and visual encoding in a single statement.
-2. **The SQL is executed** — querychat runs the data portion of the query against your data source locally.
-3. **The VISUALISE clause is rendered** — the result is passed to a ggsql reader, which produces an interactive chart.
+2. **The query executes through a caching reader** — base reads run against your data source (preserving database pushdown) and are memoized, while ggsql-generated derived tables live in a private in-memory cache. This also means layers can draw from other relations in the query (`DRAW ... FROM
`), such as a summary line from a CTE over raw points from the final SELECT.
+3. **The VISUALISE clause is rendered** — the reader produces an interactive chart specification.
4. **The chart appears inline** — the chart is rendered in the conversation as an interactive widget.
Note that visualization queries are independent of any active dashboard filter set by the `update` tool.
They always run against the full dataset.
+### Caching
+
+To keep iterative chart refinement fast, querychat memoizes the data portion of visualization queries: when the LLM issues identical SQL within 5 minutes (for example, when only the visual encoding changes), the cached result is reused instead of re-reading your data source.
+Each chat session has its own cache, bounded at 512 MiB, which is freed when the session ends.
+
+This assumes the data doesn't change mid-session.
+If it can — for example, update queries are enabled (`QUERYCHAT_ENABLE_UPDATE_QUERIES=true`), or another process writes to the database — charts may lag those changes by up to the TTL below.
+
+Tune or disable the cache with environment variables (set before the app starts):
+
+| Variable | Default | Purpose |
+|---|---|---|
+| `GGSQL_CACHE_TTL` | `300` | Seconds a cached result is treated as fresh |
+| `GGSQL_CACHE_MAX_BYTES` | `512mb` | Per-session memory budget before least-recently-used eviction |
+| `GGSQL_CACHE_DISABLED` | unset | Set to any non-empty value except `0` to always re-read the source |
+
Learn more about the ggsql grammar at [ggsql.org](https://ggsql.org/).
## See also