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