From d6e686c2bea7f7d9d7d83abc47a63374bb3c9dec Mon Sep 17 00:00:00 2001 From: Josh Taillon Date: Tue, 20 Oct 2026 21:31:49 -0600 Subject: [PATCH 1/7] test(r): configure the live warehouse tests from the shared env contract, and run them in CI The R live tests read R options naming each object. They now read the variables in tests/shared/live-warehouse-env.json, the same ones the Python suite reads, and use the orders table that provision-live-warehouse.py creates in the configured schema. The contract gains a parameterized model and its JSON arguments for each backend, which the R semantic-model tests need; the Databricks metric_view entry becomes parameterized_model. Snowflake connects with the key pair from SNOWFLAKE_PRIVATE_KEY_FILE. The helper passes the account and host explicitly, though odbc reads both from the same variables by default, so every contract variable it relies on is visible at the call. Databricks connects through odbc::databricks(), which finds a token, a client secret, or a federated OIDC token in the environment, so the DSN named Databricks is no longer needed. R-live-warehouse.yaml installs each vendor's ODBC driver where odbc looks for it and runs the tests from the same GitHub environments as the Python job. For GitHub OIDC it fetches the token just before the tests, with the audience the Databricks SDK uses, so one federation policy serves both languages. The check for a configured backend moves to a script both workflows call. Checked in a linux/amd64 container: both drivers install and load, Snowflake rejects the not-yet-created service user's JWT, and Databricks rejects a dummy token with a 403. --- .github/scripts/live-warehouse-configured.sh | 31 ++++ .github/workflows/R-live-warehouse.yaml | 119 +++++++++++++ .github/workflows/py-live-warehouse.yaml | 26 +-- pkg-r/tests/testthat/README.md | 101 +++-------- .../fixtures/shared/live-warehouse-env.json | 24 ++- pkg-r/tests/testthat/helper-live-warehouses.R | 161 +++++++++--------- tests/shared/live-warehouse-env.json | 24 ++- 7 files changed, 293 insertions(+), 193 deletions(-) create mode 100755 .github/scripts/live-warehouse-configured.sh create mode 100644 .github/workflows/R-live-warehouse.yaml diff --git a/.github/scripts/live-warehouse-configured.sh b/.github/scripts/live-warehouse-configured.sh new file mode 100755 index 000000000..31949eab7 --- /dev/null +++ b/.github/scripts/live-warehouse-configured.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Decide whether a live warehouse job has anything to test. +# +# The live tests skip when a required variable is unset, so a job missing one +# would pass having tested nothing. Writes run=false to $GITHUB_OUTPUT when +# none of the backend's required variables are set, run=true when all are, and +# fails when only some are. +# +# .github/scripts/live-warehouse-configured.sh snowflake +set -euo pipefail + +backend="$1" +contract="$(dirname "$0")/../../tests/shared/live-warehouse-env.json" +names=$(jq -r --arg b "$backend" \ + '.backends[$b] | .connection.required[], .fixtures.schema.variable' "$contract") + +missing=() +for name in $names; do + [ -n "${!name:-}" ] || missing+=("$name") +done + +total=$(echo "$names" | wc -w) +if [ "${#missing[@]}" -eq "$total" ]; then + echo "::notice::$backend is not configured for this repository; skipping." + echo "run=false" >> "${GITHUB_OUTPUT:-/dev/stdout}" +elif [ "${#missing[@]}" -gt 0 ]; then + echo "::error::$backend is partly configured. Missing: ${missing[*]}" + exit 1 +else + echo "run=true" >> "${GITHUB_OUTPUT:-/dev/stdout}" +fi diff --git a/.github/workflows/R-live-warehouse.yaml b/.github/workflows/R-live-warehouse.yaml new file mode 100644 index 000000000..c58684f60 --- /dev/null +++ b/.github/workflows/R-live-warehouse.yaml @@ -0,0 +1,119 @@ +name: "R: Live warehouse tests" + +on: + push: + branches: [main] + paths: [pkg-r/**, tests/shared/**, .github/scripts/live-warehouse-configured.sh, .github/workflows/R-live-warehouse.yaml] + workflow_dispatch: + +permissions: + contents: read + +jobs: + live: + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + backend: [databricks, snowflake] + # The same environments the Python live tests use. + environment: ${{ matrix.backend }}-ci + permissions: + contents: read + # GitHub OIDC for the Databricks service principal. + id-token: write + defaults: + run: + working-directory: pkg-r + env: + GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} + DATABRICKS_HOST: ${{ vars.DATABRICKS_HOST }} + DATABRICKS_HTTP_PATH: ${{ vars.DATABRICKS_HTTP_PATH }} + DATABRICKS_CLIENT_ID: ${{ vars.DATABRICKS_CLIENT_ID }} + DATABRICKS_TOKEN_AUDIENCE: ${{ vars.DATABRICKS_TOKEN_AUDIENCE }} + # Unset when the service principal signs in with GitHub OIDC instead. + DATABRICKS_CLIENT_SECRET: ${{ secrets.DATABRICKS_CLIENT_SECRET }} + COMMONS_TEST_DATABRICKS_SCHEMA: ${{ vars.COMMONS_TEST_DATABRICKS_SCHEMA }} + COMMONS_TEST_DATABRICKS_DENIED_TABLE: ${{ vars.COMMONS_TEST_DATABRICKS_DENIED_TABLE }} + COMMONS_TEST_DATABRICKS_PARAMETERIZED_MODEL: ${{ vars.COMMONS_TEST_DATABRICKS_PARAMETERIZED_MODEL }} + COMMONS_TEST_DATABRICKS_PARAMETERIZED_MODEL_ARGUMENTS: ${{ vars.COMMONS_TEST_DATABRICKS_PARAMETERIZED_MODEL_ARGUMENTS }} + SNOWFLAKE_ACCOUNT: ${{ vars.SNOWFLAKE_ACCOUNT }} + SNOWFLAKE_USER: ${{ vars.SNOWFLAKE_USER }} + SNOWFLAKE_WAREHOUSE: ${{ vars.SNOWFLAKE_WAREHOUSE }} + COMMONS_TEST_SNOWFLAKE_SCHEMA: ${{ vars.COMMONS_TEST_SNOWFLAKE_SCHEMA }} + COMMONS_TEST_SNOWFLAKE_DENIED_TABLE: ${{ vars.COMMONS_TEST_SNOWFLAKE_DENIED_TABLE }} + COMMONS_TEST_SNOWFLAKE_ALTERNATE_ROLE: ${{ vars.COMMONS_TEST_SNOWFLAKE_ALTERNATE_ROLE }} + COMMONS_TEST_SNOWFLAKE_SEMANTIC_VIEW: ${{ vars.COMMONS_TEST_SNOWFLAKE_SEMANTIC_VIEW }} + COMMONS_TEST_SNOWFLAKE_VERIFIED_QUERY_VIEW: ${{ vars.COMMONS_TEST_SNOWFLAKE_VERIFIED_QUERY_VIEW }} + COMMONS_TEST_SNOWFLAKE_PARAMETERIZED_MODEL: ${{ vars.COMMONS_TEST_SNOWFLAKE_PARAMETERIZED_MODEL }} + COMMONS_TEST_SNOWFLAKE_PARAMETERIZED_MODEL_ARGUMENTS: ${{ vars.COMMONS_TEST_SNOWFLAKE_PARAMETERIZED_MODEL_ARGUMENTS }} + steps: + - uses: actions/checkout@v6 + - name: Write the Snowflake private key + if: matrix.backend == 'snowflake' + env: + SNOWFLAKE_PRIVATE_KEY: ${{ secrets.SNOWFLAKE_PRIVATE_KEY }} + run: | + if [ -n "$SNOWFLAKE_PRIVATE_KEY" ]; then + key="$RUNNER_TEMP/snowflake_key.p8" + (umask 077 && printf '%s\n' "$SNOWFLAKE_PRIVATE_KEY" > "$key") + echo "SNOWFLAKE_PRIVATE_KEY_FILE=$key" >> "$GITHUB_ENV" + fi + - name: Check the configuration + id: configured + run: ../.github/scripts/live-warehouse-configured.sh '${{ matrix.backend }}' + + # Each driver installs where odbc::snowflake() and odbc::databricks() + # look for it by default. + - name: Install the Snowflake ODBC driver + if: steps.configured.outputs.run == 'true' && matrix.backend == 'snowflake' + run: | + sudo apt-get update -q + sudo apt-get install -y -q unixodbc + curl -fsSL -o "$RUNNER_TEMP/snowflake-odbc.deb" \ + https://sfc-repo.snowflakecomputing.com/odbc/linux/3.21.0/snowflake-odbc-3.21.0.x86_64.deb + sudo apt-get install -y -q "$RUNNER_TEMP/snowflake-odbc.deb" + # The driver's own config names libodbcinst.so.1, and Ubuntu ships .so.2. + sudo sed -i 's|^ODBCInstLib=.*|ODBCInstLib=libodbcinst.so.2|' \ + /usr/lib/snowflake/odbc/lib/simba.snowflake.ini + - name: Install the Databricks ODBC driver + if: steps.configured.outputs.run == 'true' && matrix.backend == 'databricks' + run: | + sudo apt-get update -q + sudo apt-get install -y -q unixodbc + curl -fsSL -o "$RUNNER_TEMP/simbaspark.zip" \ + https://databricks-bi-artifacts.s3.us-east-2.amazonaws.com/simbaspark-drivers/odbc/2.9.2/SimbaSparkODBC-2.9.2.1008-Debian-64bit.zip + unzip -j -o "$RUNNER_TEMP/simbaspark.zip" '*.deb' -x '__MACOSX/*' -d "$RUNNER_TEMP/simbaspark" + sudo apt-get install -y -q "$RUNNER_TEMP"/simbaspark/*.deb + + - uses: r-lib/actions/setup-r@v2 + if: steps.configured.outputs.run == 'true' + with: + use-public-rspm: true + - uses: r-lib/actions/setup-r-dependencies@v2 + if: steps.configured.outputs.run == 'true' + with: + extra-packages: any::testthat, local::. + working-directory: pkg-r + + - name: Request a GitHub OIDC token for Databricks + # odbc::databricks() exchanges this token for a Databricks one on each + # connection. The audience matches what the Databricks SDK requests in + # the Python job, so one federation policy serves both. Fetched last, + # just before the tests, because the token is short-lived. + if: >- + steps.configured.outputs.run == 'true' && matrix.backend == 'databricks' + && env.DATABRICKS_CLIENT_ID != '' && env.DATABRICKS_CLIENT_SECRET == '' + run: | + host="${DATABRICKS_HOST#https://}" + audience="${DATABRICKS_TOKEN_AUDIENCE:-https://${host%/}/oidc/v1/token}" + token=$(curl -fsS -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ + "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=$audience" | jq -r .value) + echo "::add-mask::$token" + echo "DATABRICKS_OIDC_TOKEN=$token" >> "$GITHUB_ENV" + echo "DATABRICKS_AUTH_TYPE=env-oidc" >> "$GITHUB_ENV" + - name: Test + if: steps.configured.outputs.run == 'true' + shell: Rscript {0} + run: testthat::test_local(filter = "live-warehouses", stop_on_failure = TRUE) diff --git a/.github/workflows/py-live-warehouse.yaml b/.github/workflows/py-live-warehouse.yaml index 25acd41f2..3a266aebb 100644 --- a/.github/workflows/py-live-warehouse.yaml +++ b/.github/workflows/py-live-warehouse.yaml @@ -3,7 +3,7 @@ name: "Py: Live warehouse tests" on: push: branches: [main] - paths: [pkg-py/**, tests/shared/**, .github/workflows/py-live-warehouse.yaml] + paths: [pkg-py/**, tests/shared/**, .github/scripts/live-warehouse-configured.sh, .github/workflows/py-live-warehouse.yaml] workflow_dispatch: permissions: @@ -59,30 +59,8 @@ jobs: echo "SNOWFLAKE_PRIVATE_KEY_FILE=$key" >> "$GITHUB_ENV" fi - name: Check the configuration - # The tests skip when a required variable is unset, so a job with a - # missing variable would pass having tested nothing. Stop here instead: - # skip the job when nothing is configured, and fail it when only some - # of it is. id: configured - run: | - backend='${{ matrix.backend }}' - names=$(jq -r --arg b "$backend" \ - '.backends[$b] | .connection.required[], .fixtures.schema.variable' \ - ../tests/shared/live-warehouse-env.json) - missing=() - for name in $names; do - [ -n "${!name:-}" ] || missing+=("$name") - done - total=$(echo "$names" | wc -w) - if [ "${#missing[@]}" -eq "$total" ]; then - echo "::notice::$backend is not configured for this repository; skipping." - echo "run=false" >> "$GITHUB_OUTPUT" - elif [ "${#missing[@]}" -gt 0 ]; then - echo "::error::$backend is partly configured. Missing: ${missing[*]}" - exit 1 - else - echo "run=true" >> "$GITHUB_OUTPUT" - fi + run: ../.github/scripts/live-warehouse-configured.sh '${{ matrix.backend }}' - uses: astral-sh/setup-uv@v6 if: steps.configured.outputs.run == 'true' with: diff --git a/pkg-r/tests/testthat/README.md b/pkg-r/tests/testthat/README.md index a605a1a4e..4edb704ab 100644 --- a/pkg-r/tests/testthat/README.md +++ b/pkg-r/tests/testthat/README.md @@ -1,95 +1,32 @@ # Live warehouse tests -The Snowflake and Databricks smoke tests are opt in. The ordinary test suite -skips them before connecting, so contributors do not need warehouse credentials -or ODBC drivers. +The Snowflake and Databricks tests in `test-live-warehouses.R` are opt in. They skip before connecting unless a warehouse is configured, so contributors do not need warehouse credentials or ODBC drivers. -To run the Snowflake test, configure `odbc::snowflake()` with a default -warehouse and set: +Configure a warehouse with environment variables. The names are the ones in `tests/shared/live-warehouse-env.json`, which the Python suite reads too, so one export block configures both: -```r -options(commons.test.snowflake = DBI::Id( - catalog = "...", - schema = "...", - table = "..." -)) -``` - -To also run the Snowflake semantic-view test, set: - -```r -options(commons.test.snowflake.semantic_view = DBI::Id( - catalog = "...", - schema = "...", - table = "..." -)) -``` - -Parameterized native-model and imported verified-query tests additionally use: +```sh +export SNOWFLAKE_ACCOUNT=myorg-myaccount +export SNOWFLAKE_USER=MY_USER +export SNOWFLAKE_WAREHOUSE=MY_WH +export SNOWFLAKE_PRIVATE_KEY_FILE=~/.snowflake/keys/rsa_key.p8 +export COMMONS_TEST_SNOWFLAKE_SCHEMA=COMMONS_CI.PUBLIC -```r -options( - commons.test.snowflake.parameterized_model = DBI::Id( - catalog = "...", schema = "...", table = "..." - ), - commons.test.snowflake.parameterized_model_arguments = list( - threshold = 100 - ), - commons.test.snowflake.verified_query_view = DBI::Id( - catalog = "...", schema = "...", table = "..." - ) -) +export DATABRICKS_HOST=https://example.cloud.databricks.com +export DATABRICKS_HTTP_PATH=/sql/1.0/warehouses/abc123 +export COMMONS_TEST_DATABRICKS_SCHEMA=main.commons_live ``` -To run the Databricks test, configure an ODBC DSN named `Databricks` and set: +Snowflake signs in with a key pair registered to `SNOWFLAKE_USER`. `odbc::databricks()` finds Databricks credentials in the environment: `DATABRICKS_TOKEN`, a service principal's `DATABRICKS_CLIENT_ID` and `DATABRICKS_CLIENT_SECRET`, or a `databricks auth login` session. Both need the vendor's ODBC driver. -```r -options(commons.test.databricks = DBI::Id( - catalog = "...", - schema = "...", - table = "..." -)) -``` +Create the tables with `scripts/provision-live-warehouse.py`; `pkg-py/tests/README.md` describes it. The R tests use its `orders` table. -For a parameterized Databricks metric view, also set: +The optional variables in the contract each enable more tests: a denied table for the permission tests, a second Snowflake role for the active-role test, and semantic views or metric views for the semantic-model tests. A parameterized model also needs its arguments as a JSON object: -```r -options( - commons.test.databricks.parameterized_model = DBI::Id( - catalog = "...", schema = "...", table = "..." - ), - commons.test.databricks.parameterized_model_arguments = list( - minimum_amount = 10 - ) -) +```sh +export COMMONS_TEST_SNOWFLAKE_PARAMETERIZED_MODEL=ANALYTICS.PUBLIC.SALES_MODEL +export COMMONS_TEST_SNOWFLAKE_PARAMETERIZED_MODEL_ARGUMENTS='{"threshold": 100}' ``` -Permission tests additionally run when an inaccessible object is configured: - -```r -options( - commons.test.snowflake.denied_table = DBI::Id( - catalog = "DATABASE", - schema = "RESTRICTED", - table = "TABLE" - ), - commons.test.databricks.denied_table = DBI::Id( - catalog = "catalog", - schema = "restricted", - table = "table" - ) -) -``` - -The Snowflake active-role test runs when a second usable role is configured: - -```r -options(commons.test.snowflake.alternate_role = "REPORTER") -``` +Once a backend is configured, connection and query failures fail its tests. -Both warehouse tests exercise exact table selection, schema and catalog -expansion, current-schema discovery, and native column metadata. -The Snowflake semantic-view test imports the selected model and executes one of -its public metrics. The Databricks test also uses a temporary view to exercise -quoted relation and column names. If a backend's option is absent, its test -skips. Once enabled, connection and query failures fail the test. +`.github/workflows/R-live-warehouse.yaml` runs these tests in CI, with credentials from the repository's `databricks-ci` and `snowflake-ci` environments. diff --git a/pkg-r/tests/testthat/fixtures/shared/live-warehouse-env.json b/pkg-r/tests/testthat/fixtures/shared/live-warehouse-env.json index a51f37b3d..9666590e8 100644 --- a/pkg-r/tests/testthat/fixtures/shared/live-warehouse-env.json +++ b/pkg-r/tests/testthat/fixtures/shared/live-warehouse-env.json @@ -18,11 +18,17 @@ "arity": 3, "description": "A relation the test principal genuinely cannot read. Provisioning with --grant-to creates one, owned by the principal that runs the SQL (on Snowflake, its active role)." }, - "metric_view": { - "variable": "COMMONS_TEST_DATABRICKS_METRIC_VIEW", + "parameterized_model": { + "variable": "COMMONS_TEST_DATABRICKS_PARAMETERIZED_MODEL", "required": false, "arity": 3, - "description": "A parameterized metric view. Read by the R suite only: pkg-py has no semantic-model surface (commons kata gcgj)." + "description": "A metric view with at least one parameter and one metric. Read by the R suite only: pkg-py has no semantic-model surface (commons kata gcgj)." + }, + "parameterized_model_arguments": { + "variable": "COMMONS_TEST_DATABRICKS_PARAMETERIZED_MODEL_ARGUMENTS", + "required": false, + "format": "json", + "description": "The arguments to call the parameterized model with, as a JSON object. Needed alongside it." } } }, @@ -65,6 +71,18 @@ "required": false, "arity": 3, "description": "A semantic view carrying a verified query. Read by the R suite only." + }, + "parameterized_model": { + "variable": "COMMONS_TEST_SNOWFLAKE_PARAMETERIZED_MODEL", + "required": false, + "arity": 3, + "description": "A semantic view with at least one variable and one metric. Read by the R suite only." + }, + "parameterized_model_arguments": { + "variable": "COMMONS_TEST_SNOWFLAKE_PARAMETERIZED_MODEL_ARGUMENTS", + "required": false, + "format": "json", + "description": "The arguments to call the parameterized model with, as a JSON object. Needed alongside it." } } } diff --git a/pkg-r/tests/testthat/helper-live-warehouses.R b/pkg-r/tests/testthat/helper-live-warehouses.R index b4879711c..a88b3e10c 100644 --- a/pkg-r/tests/testthat/helper-live-warehouses.R +++ b/pkg-r/tests/testthat/helper-live-warehouses.R @@ -1,9 +1,56 @@ +# The variable names come from tests/shared/live-warehouse-env.json, which the +# Python suite reads as well. The test table is one of the tables +# scripts/provision-live-warehouse.py creates in the configured schema. +live_warehouse_table <- "orders" + +live_warehouse_spec <- function(backend) { + backend <- match.arg(backend, c("snowflake", "databricks")) + shared_fixture("live-warehouse-env")$backends[[backend]] +} + +# Skips unless the backend's connection variables and schema are all set. +require_live_warehouse <- function(backend) { + spec <- live_warehouse_spec(backend) + names <- c( + unlist(spec$connection$required), + spec$fixtures$schema$variable + ) + missing <- names[!nzchar(Sys.getenv(names))] + skip_if( + length(missing) > 0L, + paste0("live ", backend, " needs ", paste(sort(missing), collapse = ", ")) + ) + invisible(spec) +} + +live_warehouse_value <- function(backend, fixture) { + variable <- live_warehouse_spec(backend)$fixtures[[fixture]]$variable + value <- Sys.getenv(variable) + skip_if(!nzchar(value), paste("Set", variable, "to run this test")) + value +} + +live_warehouse_id <- function(backend, fixture, call = rlang::caller_env()) { + spec <- live_warehouse_spec(backend)$fixtures[[fixture]] + value <- live_warehouse_value(backend, fixture) + parts <- strsplit(value, ".", fixed = TRUE)[[1]] + if (length(parts) != spec$arity) { + cli::cli_abort( + "{.envvar {spec$variable}} must name {spec$arity} dotted identifier parts, not {.val {value}}.", + call = call + ) + } + names(parts) <- c("catalog", "schema", "table")[seq_along(parts)] + do.call(DBI::Id, as.list(parts)) +} + local_warehouse_connection <- function( backend, require_table = TRUE, env = parent.frame() ) { backend <- match.arg(backend, c("snowflake", "databricks")) + require_live_warehouse(backend) if (require_table) { warehouse_test_table(backend) } @@ -11,115 +58,67 @@ local_warehouse_connection <- function( con <- switch( backend, - snowflake = DBI::dbConnect(odbc::snowflake()), + snowflake = DBI::dbConnect( + odbc::snowflake(), + account = Sys.getenv("SNOWFLAKE_ACCOUNT"), + uid = Sys.getenv("SNOWFLAKE_USER"), + authenticator = "SNOWFLAKE_JWT", + priv_key_file = Sys.getenv("SNOWFLAKE_PRIVATE_KEY_FILE"), + warehouse = Sys.getenv("SNOWFLAKE_WAREHOUSE") + ), + # odbc::databricks() resolves credentials from the environment: a token, + # a client secret, or a federated OIDC token. databricks = DBI::dbConnect( - odbc::odbc(), - "Databricks" + odbc::databricks(), + workspace = Sys.getenv("DATABRICKS_HOST"), + httpPath = Sys.getenv("DATABRICKS_HTTP_PATH") ) ) withr::defer(DBI::dbDisconnect(con), envir = env) con } -warehouse_test_table <- function(backend, call = rlang::caller_env()) { - backend <- match.arg(backend, c("snowflake", "databricks")) - option <- paste0("commons.test.", backend) - table <- getOption(option) - skip_if( - is.null(table), - paste0( - "Set options(", option, " = DBI::Id(...)) to run live warehouse tests" - ) - ) - if (!inherits(table, "Id")) { - cli::cli_abort( - "The {.option {option}} option must be a {.cls DBI::Id} object.", - call = call - ) - } - table +warehouse_test_table <- function(backend) { + require_live_warehouse(backend) + schema <- live_warehouse_id(backend, "schema") + do.call(DBI::Id, c(as.list(schema@name), table = live_warehouse_table)) } -warehouse_test_denied_table <- function(backend, call = rlang::caller_env()) { - backend <- match.arg(backend, c("snowflake", "databricks")) - option <- paste0("commons.test.", backend, ".denied_table") - table <- getOption(option) - skip_if(is.null(table), paste0("Set options(", option, " = DBI::Id(...))")) - if (!inherits(table, "Id")) { - cli::cli_abort( - "The {.option {option}} option must be a {.cls DBI::Id} object.", - call = call - ) - } - table +warehouse_test_denied_table <- function(backend) { + live_warehouse_id(backend, "denied_table") } -warehouse_test_alternate_role <- function(call = rlang::caller_env()) { - option <- "commons.test.snowflake.alternate_role" - role <- getOption(option) - skip_if(is.null(role), paste0("Set options(", option, " = \"ROLE\")")) - rlang::check_string(role, call = call) - role +warehouse_test_alternate_role <- function() { + live_warehouse_value("snowflake", "alternate_role") } -warehouse_test_semantic_view <- function(call = rlang::caller_env()) { - option <- "commons.test.snowflake.semantic_view" - view <- getOption(option) - skip_if( - is.null(view), - paste0("Set options(", option, " = DBI::Id(...)) to run this test") - ) - if (!inherits(view, "Id")) { - cli::cli_abort( - "The {.option {option}} option must be a {.cls DBI::Id} object.", - call = call - ) - } - view +warehouse_test_semantic_view <- function() { + live_warehouse_id("snowflake", "semantic_view") +} + +warehouse_test_verified_query_view <- function() { + live_warehouse_id("snowflake", "verified_query_view") } warehouse_test_parameterized_model <- function( backend, call = rlang::caller_env() ) { - backend <- match.arg(backend, c("snowflake", "databricks")) - option <- paste0("commons.test.", backend, ".parameterized_model") - model <- getOption(option) - skip_if(is.null(model), paste0("Set options(", option, " = DBI::Id(...))")) - if (!inherits(model, "Id")) { - cli::cli_abort( - "The {.option {option}} option must be a {.cls DBI::Id} object.", - call = call - ) - } - arguments_option <- paste0(option, "_arguments") - arguments <- getOption(arguments_option) - skip_if( - is.null(arguments), - paste0("Set options(", arguments_option, " = list(...))") + model <- live_warehouse_id(backend, "parameterized_model") + variable <- live_warehouse_spec(backend)$fixtures$parameterized_model_arguments$variable + arguments <- jsonlite::fromJSON( + live_warehouse_value(backend, "parameterized_model_arguments"), + simplifyVector = FALSE ) if (!is.list(arguments) || is.null(names(arguments))) { cli::cli_abort( - "The {.option {arguments_option}} option must be a named list.", + "{.envvar {variable}} must be a JSON object.", call = call ) } list(id = model, arguments = arguments) } -warehouse_test_verified_query_view <- function(call = rlang::caller_env()) { - option <- "commons.test.snowflake.verified_query_view" - view <- getOption(option) - skip_if(is.null(view), paste0("Set options(", option, " = DBI::Id(...))")) - if (!inherits(view, "Id")) { - cli::cli_abort( - "The {.option {option}} option must be a {.cls DBI::Id} object.", - call = call - ) - } - view -} - warehouse_read_one <- function(con, id) { sql <- paste( "SELECT * FROM", diff --git a/tests/shared/live-warehouse-env.json b/tests/shared/live-warehouse-env.json index a51f37b3d..9666590e8 100644 --- a/tests/shared/live-warehouse-env.json +++ b/tests/shared/live-warehouse-env.json @@ -18,11 +18,17 @@ "arity": 3, "description": "A relation the test principal genuinely cannot read. Provisioning with --grant-to creates one, owned by the principal that runs the SQL (on Snowflake, its active role)." }, - "metric_view": { - "variable": "COMMONS_TEST_DATABRICKS_METRIC_VIEW", + "parameterized_model": { + "variable": "COMMONS_TEST_DATABRICKS_PARAMETERIZED_MODEL", "required": false, "arity": 3, - "description": "A parameterized metric view. Read by the R suite only: pkg-py has no semantic-model surface (commons kata gcgj)." + "description": "A metric view with at least one parameter and one metric. Read by the R suite only: pkg-py has no semantic-model surface (commons kata gcgj)." + }, + "parameterized_model_arguments": { + "variable": "COMMONS_TEST_DATABRICKS_PARAMETERIZED_MODEL_ARGUMENTS", + "required": false, + "format": "json", + "description": "The arguments to call the parameterized model with, as a JSON object. Needed alongside it." } } }, @@ -65,6 +71,18 @@ "required": false, "arity": 3, "description": "A semantic view carrying a verified query. Read by the R suite only." + }, + "parameterized_model": { + "variable": "COMMONS_TEST_SNOWFLAKE_PARAMETERIZED_MODEL", + "required": false, + "arity": 3, + "description": "A semantic view with at least one variable and one metric. Read by the R suite only." + }, + "parameterized_model_arguments": { + "variable": "COMMONS_TEST_SNOWFLAKE_PARAMETERIZED_MODEL_ARGUMENTS", + "required": false, + "format": "json", + "description": "The arguments to call the parameterized model with, as a JSON object. Needed alongside it." } } } From ed8e8404cd2964acda38474f01960316f6a1eeed Mon Sep 17 00:00:00 2001 From: Josh Taillon Date: Wed, 21 Oct 2026 15:55:26 -0600 Subject: [PATCH 2/7] test(r): expect a Snowflake table the role cannot see to be reported missing Snowflake leaves a relation the role holds no privilege on out of the listing, and the shared access-precedence contract reports a name the listing never returned as missing ahead of any refusal. The probe still classifies the query failure as an authorization refusal. --- pkg-r/tests/testthat/test-live-warehouses.R | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pkg-r/tests/testthat/test-live-warehouses.R b/pkg-r/tests/testthat/test-live-warehouses.R index 371536164..e8649bc00 100644 --- a/pkg-r/tests/testthat/test-live-warehouses.R +++ b/pkg-r/tests/testthat/test-live-warehouses.R @@ -146,9 +146,12 @@ test_that("live Snowflake classifies denied query access", { con <- local_warehouse_connection("snowflake", require_table = FALSE) expect_equal(catalog_probe_relation(con, denied)$state, "authorization") + # Snowflake leaves a relation the role holds no privilege on out of the + # listing, and a name the listing never reported is missing rather than + # refused (tests/shared/catalog-access-precedence.json). expect_error( data_source(con, tables = denied), - class = "commons_catalog_authorization_error" + "not on the connection" ) }) From ed5bd1109eb915248dca97f480f86348bd639839 Mon Sep 17 00:00:00 2001 From: Josh Taillon Date: Wed, 21 Oct 2026 20:19:55 -0600 Subject: [PATCH 3/7] test: provision the Snowflake semantic views the R live tests read The provisioning script now creates two semantic views over the orders table: ORDERS_MODEL, with a metric and a verified query, and ORDERS_PARAM_MODEL, with a variable. It grants them to the test role and prints the exports for the semantic view, verified query view, and parameterized model fixtures, with the arguments to call the model with. Variable names come from the env contract. --- pkg-py/tests/test_live_warehouse_fixture.py | 16 +++ scripts/provision-live-warehouse.py | 106 +++++++++++++++++++- 2 files changed, 120 insertions(+), 2 deletions(-) diff --git a/pkg-py/tests/test_live_warehouse_fixture.py b/pkg-py/tests/test_live_warehouse_fixture.py index e7c4c5867..0d7b4ce3e 100644 --- a/pkg-py/tests/test_live_warehouse_fixture.py +++ b/pkg-py/tests/test_live_warehouse_fixture.py @@ -146,6 +146,22 @@ def test_the_grants_cover_every_fixture_table_but_the_denied_one(dialect): ) +def test_every_semantic_fixture_is_provisioned_on_snowflake(): + generator = _generator() + fixtures = load_shared_fixture("live-warehouse-env")["backends"]["snowflake"][ + "fixtures" + ] + sql = generator.render("snowflake", "catalog.schema", grantee="tester") + + for view in generator.SEMANTIC_VIEWS: + assert f"name: {view}\n" in sql + assert f'SEMANTIC VIEW "catalog"."schema"."{view}" TO ROLE' in sql + for fixture, view in generator.SEMANTIC_FIXTURES.items(): + assert f"export {fixtures[fixture]['variable']}=catalog.schema.{view}" in sql + assert fixtures["parameterized_model_arguments"]["variable"] in sql + assert "SEMANTIC VIEW" not in generator.render("databricks", "catalog.schema") + + def test_a_mismatch_is_reported_as_one(): """The comparator, since every live assertion rests on it.""" assert matches([1.0, None], [None, 1.0]) diff --git a/scripts/provision-live-warehouse.py b/scripts/provision-live-warehouse.py index 68ee10552..22040931b 100755 --- a/scripts/provision-live-warehouse.py +++ b/scripts/provision-live-warehouse.py @@ -17,6 +17,9 @@ principal, or the test principal owns the copy and can read it. Without `--grant-to` the tests run as the owner, who can read everything, so the denied table is left to do by hand. + +On Snowflake it also creates the semantic views the R suite's semantic model +tests read, over the orders table. """ from __future__ import annotations @@ -31,6 +34,70 @@ ENV = SHARED / "live-warehouse-env.json" DENIED_TABLE = "denied_orders" +# Snowflake semantic views over the orders table, for the R suite's semantic +# model tests. Each is YAML for SYSTEM$CREATE_SEMANTIC_VIEW_FROM_YAML, with +# {database} and {schema} filled in. ORDERS_MODEL serves both the semantic view +# and the verified query fixtures; ORDERS_PARAM_MODEL declares a variable. +SEMANTIC_VIEWS = { + "ORDERS_MODEL": """\ +name: ORDERS_MODEL +description: Orders from the commons live-test fixture. +tables: + - name: orders + base_table: + database: {database} + schema: {schema} + table: '"orders"' + dimensions: + - name: tile_size + expr: '"tile_size"' + data_type: VARCHAR + facts: + - name: order_total + expr: '"order_total"' + data_type: FLOAT + metrics: + - name: total_order_value + description: Sum of order totals. + expr: SUM(orders.order_total) +verified_queries: + - name: total_order_value_by_tile + question: What is the total order value for each tile size? + sql: SELECT "tile_size", SUM("order_total") AS total_order_value FROM {database}.{schema}."orders" GROUP BY "tile_size" +""", + "ORDERS_PARAM_MODEL": """\ +name: ORDERS_PARAM_MODEL +description: Orders from the commons live-test fixture, with a threshold variable. +variables: + - name: min_total + data_type: NUMBER(10,2) + default_value: "0" + description: Only orders whose total is at least this amount count. +tables: + - name: orders + base_table: + database: {database} + schema: {schema} + table: '"orders"' + facts: + - name: order_total + expr: '"order_total"' + data_type: FLOAT + metrics: + - name: total_above_min + description: Sum of order totals at or above min_total. + expr: SUM(CASE WHEN orders.order_total >= min_total THEN orders.order_total ELSE 0 END) +""", +} +# The env fixture each semantic view fills, and the arguments the +# parameterized model is called with. +SEMANTIC_FIXTURES = { + "semantic_view": "ORDERS_MODEL", + "verified_query_view": "ORDERS_MODEL", + "parameterized_model": "ORDERS_PARAM_MODEL", +} +PARAMETERIZED_ARGUMENTS = {"min_total": 50} + SPECIAL_DOUBLES = { "databricks": {"NaN": "double('NaN')", "Infinity": "double('Infinity')", "-Infinity": "double('-Infinity')"}, "snowflake": {"NaN": "'NaN'::FLOAT", "Infinity": "'Inf'::FLOAT", "-Infinity": "'-Inf'::FLOAT"}, @@ -157,6 +224,8 @@ def render(dialect: str, namespace: str, grantee: str | None = None) -> str: values = ",\n".join(f" ({row})" for row in rows) lines += [f"INSERT INTO {target} ({column_list}) VALUES", values + ";", ""] + if dialect == "snowflake": + lines += semantic_views(parts) if grantee is None: lines += [ "-- Still to do by hand, because an owner can always read their own relation:", @@ -193,8 +262,41 @@ def grants(dialect: str, parts: list[str], qualified: str, tables: list[str], gr f"GRANT USAGE ON SCHEMA {qualified} TO ROLE {principal};", ] lines += [f"GRANT SELECT ON TABLE {qualified}.{quote_identifier(t, dialect)} TO ROLE {principal};" for t in tables] - variable = json.loads(ENV.read_text(encoding="utf-8"))["backends"][dialect]["fixtures"]["denied_table"]["variable"] - return lines + ["", f"-- Then: export {variable}={'.'.join(parts)}.{DENIED_TABLE}"] + lines += [ + f"GRANT REFERENCES, SELECT ON SEMANTIC VIEW {qualified}.{quote_identifier(view, dialect)} TO ROLE {principal};" + for view in SEMANTIC_VIEWS + ] + fixtures = json.loads(ENV.read_text(encoding="utf-8"))["backends"][dialect]["fixtures"] + namespace = ".".join(parts) + exports = [f"{fixtures['denied_table']['variable']}={namespace}.{DENIED_TABLE}"] + if dialect == "snowflake": + exports += [ + f"{fixtures[fixture]['variable']}={namespace}.{view}" for fixture, view in SEMANTIC_FIXTURES.items() + ] + arguments = json.dumps(PARAMETERIZED_ARGUMENTS) + exports.append(f"{fixtures['parameterized_model_arguments']['variable']}='{arguments}'") + return lines + ["", "-- Then:"] + [f"-- export {export}" for export in exports] + + +def semantic_views(parts: list[str]) -> list[str]: + """The Snowflake semantic views, created from YAML. + + Dollar quoting keeps the YAML's own quotes intact. A semantic view takes + a qualified base table, so the catalog and schema are written in. + """ + database, schema = parts + namespace = f"{quote_identifier(database, 'snowflake')}.{quote_identifier(schema, 'snowflake')}" + lines = [] + for view, spec in SEMANTIC_VIEWS.items(): + body = spec.format(database=database, schema=schema) + lines += [ + f"DROP SEMANTIC VIEW IF EXISTS {namespace}.{quote_identifier(view, 'snowflake')};", + f"CALL SYSTEM$CREATE_SEMANTIC_VIEW_FROM_YAML('{database}.{schema}', $$", + body.rstrip("\n"), + "$$);", + "", + ] + return lines def main(argv: list[str]) -> int: From e0ef87ebcf26289f3008106bb7e7aa2c58fb761f Mon Sep 17 00:00:00 2001 From: Josh Taillon Date: Wed, 21 Oct 2026 20:24:17 -0600 Subject: [PATCH 4/7] test: name the semantic view variable in the case Snowflake stores Snowflake folds a semantic view's member names to upper case, and commons reports the parameter as MIN_TOTAL, so the arguments the live test passes have to use that spelling. --- scripts/provision-live-warehouse.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/scripts/provision-live-warehouse.py b/scripts/provision-live-warehouse.py index 22040931b..cddc5ede7 100755 --- a/scripts/provision-live-warehouse.py +++ b/scripts/provision-live-warehouse.py @@ -69,7 +69,7 @@ name: ORDERS_PARAM_MODEL description: Orders from the commons live-test fixture, with a threshold variable. variables: - - name: min_total + - name: MIN_TOTAL data_type: NUMBER(10,2) default_value: "0" description: Only orders whose total is at least this amount count. @@ -85,18 +85,19 @@ data_type: FLOAT metrics: - name: total_above_min - description: Sum of order totals at or above min_total. - expr: SUM(CASE WHEN orders.order_total >= min_total THEN orders.order_total ELSE 0 END) + description: Sum of order totals at or above MIN_TOTAL. + expr: SUM(CASE WHEN orders.order_total >= MIN_TOTAL THEN orders.order_total ELSE 0 END) """, } # The env fixture each semantic view fills, and the arguments the -# parameterized model is called with. +# parameterized model is called with. Snowflake stores a semantic view's +# member names in upper case, so the YAML spells them that way too. SEMANTIC_FIXTURES = { "semantic_view": "ORDERS_MODEL", "verified_query_view": "ORDERS_MODEL", "parameterized_model": "ORDERS_PARAM_MODEL", } -PARAMETERIZED_ARGUMENTS = {"min_total": 50} +PARAMETERIZED_ARGUMENTS = {"MIN_TOTAL": 50} SPECIAL_DOUBLES = { "databricks": {"NaN": "double('NaN')", "Infinity": "double('Infinity')", "-Infinity": "double('-Infinity')"}, From 090404aea0b528cf01c35901ef22b378e362f30d Mon Sep 17 00:00:00 2001 From: Josh Taillon Date: Wed, 21 Oct 2026 20:24:48 -0600 Subject: [PATCH 5/7] test: quote the namespace in the semantic view YAML The script quotes every other identifier it writes, but put the database and schema into the semantic view YAML, its verified query, and the procedure's schema argument bare, so a lowercase namespace would resolve to an upper-case one. --- scripts/provision-live-warehouse.py | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/scripts/provision-live-warehouse.py b/scripts/provision-live-warehouse.py index cddc5ede7..f8dbdca58 100755 --- a/scripts/provision-live-warehouse.py +++ b/scripts/provision-live-warehouse.py @@ -36,7 +36,7 @@ # Snowflake semantic views over the orders table, for the R suite's semantic # model tests. Each is YAML for SYSTEM$CREATE_SEMANTIC_VIEW_FROM_YAML, with -# {database} and {schema} filled in. ORDERS_MODEL serves both the semantic view +# {database} and {schema} filled in as quoted identifiers. ORDERS_MODEL serves both the semantic view # and the verified query fixtures; ORDERS_PARAM_MODEL declares a variable. SEMANTIC_VIEWS = { "ORDERS_MODEL": """\ @@ -45,8 +45,8 @@ tables: - name: orders base_table: - database: {database} - schema: {schema} + database: '{database}' + schema: '{schema}' table: '"orders"' dimensions: - name: tile_size @@ -76,8 +76,8 @@ tables: - name: orders base_table: - database: {database} - schema: {schema} + database: '{database}' + schema: '{schema}' table: '"orders"' facts: - name: order_total @@ -282,17 +282,19 @@ def grants(dialect: str, parts: list[str], qualified: str, tables: list[str], gr def semantic_views(parts: list[str]) -> list[str]: """The Snowflake semantic views, created from YAML. - Dollar quoting keeps the YAML's own quotes intact. A semantic view takes - a qualified base table, so the catalog and schema are written in. + Dollar quoting keeps the YAML's own quotes intact. The procedure takes + the schema, and the view's name comes from the YAML. A semantic view + takes a qualified base table, so the catalog and schema are written in. """ - database, schema = parts - namespace = f"{quote_identifier(database, 'snowflake')}.{quote_identifier(schema, 'snowflake')}" + database, schema = (quote_identifier(part, "snowflake") for part in parts) + namespace = f"{database}.{schema}" lines = [] for view, spec in SEMANTIC_VIEWS.items(): - body = spec.format(database=database, schema=schema) + # YAML single quotes double a quote inside them, as SQL does. + body = spec.format(database=database.replace("'", "''"), schema=schema.replace("'", "''")) lines += [ f"DROP SEMANTIC VIEW IF EXISTS {namespace}.{quote_identifier(view, 'snowflake')};", - f"CALL SYSTEM$CREATE_SEMANTIC_VIEW_FROM_YAML('{database}.{schema}', $$", + f"CALL SYSTEM$CREATE_SEMANTIC_VIEW_FROM_YAML({quote_string(namespace)}, $$", body.rstrip("\n"), "$$);", "", From 2d44b9ccaeea2655ae062d75117a3dedd0efc723 Mon Sep 17 00:00:00 2001 From: Josh Taillon Date: Wed, 21 Oct 2026 20:25:53 -0600 Subject: [PATCH 6/7] test: keep the verified query's identifiers unescaped for YAML Doubling apostrophes is right for the YAML scalars but changed the identifiers inside the verified query's SQL, which named a different database for a namespace with an apostrophe. --- scripts/provision-live-warehouse.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/scripts/provision-live-warehouse.py b/scripts/provision-live-warehouse.py index f8dbdca58..11c08f3b1 100755 --- a/scripts/provision-live-warehouse.py +++ b/scripts/provision-live-warehouse.py @@ -36,7 +36,8 @@ # Snowflake semantic views over the orders table, for the R suite's semantic # model tests. Each is YAML for SYSTEM$CREATE_SEMANTIC_VIEW_FROM_YAML, with -# {database} and {schema} filled in as quoted identifiers. ORDERS_MODEL serves both the semantic view +# {database} and {schema} filled in as YAML scalars and {namespace} as the +# quoted identifiers the verified query's SQL names. ORDERS_MODEL serves both the semantic view # and the verified query fixtures; ORDERS_PARAM_MODEL declares a variable. SEMANTIC_VIEWS = { "ORDERS_MODEL": """\ @@ -63,7 +64,7 @@ verified_queries: - name: total_order_value_by_tile question: What is the total order value for each tile size? - sql: SELECT "tile_size", SUM("order_total") AS total_order_value FROM {database}.{schema}."orders" GROUP BY "tile_size" + sql: SELECT "tile_size", SUM("order_total") AS total_order_value FROM {namespace}."orders" GROUP BY "tile_size" """, "ORDERS_PARAM_MODEL": """\ name: ORDERS_PARAM_MODEL @@ -290,8 +291,13 @@ def semantic_views(parts: list[str]) -> list[str]: namespace = f"{database}.{schema}" lines = [] for view, spec in SEMANTIC_VIEWS.items(): - # YAML single quotes double a quote inside them, as SQL does. - body = spec.format(database=database.replace("'", "''"), schema=schema.replace("'", "''")) + # A YAML single-quoted scalar doubles a quote inside it; the SQL in + # the verified query takes the identifiers as they are. + body = spec.format( + database=database.replace("'", "''"), + schema=schema.replace("'", "''"), + namespace=namespace, + ) lines += [ f"DROP SEMANTIC VIEW IF EXISTS {namespace}.{quote_identifier(view, 'snowflake')};", f"CALL SYSTEM$CREATE_SEMANTIC_VIEW_FROM_YAML({quote_string(namespace)}, $$", From 8b095ae7db728cd2f0bb1239339deff5e2cedb0c Mon Sep 17 00:00:00 2001 From: Josh Taillon Date: Wed, 21 Oct 2026 20:26:42 -0600 Subject: [PATCH 7/7] test: cover the semantic view namespace escaping --- pkg-py/tests/test_live_warehouse_fixture.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/pkg-py/tests/test_live_warehouse_fixture.py b/pkg-py/tests/test_live_warehouse_fixture.py index 0d7b4ce3e..79300ff29 100644 --- a/pkg-py/tests/test_live_warehouse_fixture.py +++ b/pkg-py/tests/test_live_warehouse_fixture.py @@ -162,6 +162,13 @@ def test_every_semantic_fixture_is_provisioned_on_snowflake(): assert "SEMANTIC VIEW" not in generator.render("databricks", "catalog.schema") +def test_a_semantic_view_escapes_the_namespace_for_yaml_alone(): + sql = _generator().render("snowflake", "O'Brien.schema") + + assert "database: '\"O''Brien\"'" in sql + assert 'FROM "O\'Brien"."schema"."orders"' in sql + + def test_a_mismatch_is_reported_as_one(): """The comparator, since every live assertion rests on it.""" assert matches([1.0, None], [None, 1.0])