diff --git a/.github/scripts/live-warehouse-configured.sh b/.github/scripts/live-warehouse-configured.sh new file mode 100755 index 00000000..31949eab --- /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 00000000..c58684f6 --- /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 25acd41f..3a266aeb 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-py/tests/test_live_warehouse_fixture.py b/pkg-py/tests/test_live_warehouse_fixture.py index e7c4c586..79300ff2 100644 --- a/pkg-py/tests/test_live_warehouse_fixture.py +++ b/pkg-py/tests/test_live_warehouse_fixture.py @@ -146,6 +146,29 @@ 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_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]) diff --git a/pkg-r/tests/testthat/README.md b/pkg-r/tests/testthat/README.md index a605a1a4..4edb704a 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 a51f37b3..9666590e 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 b4879711..a88b3e10 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/pkg-r/tests/testthat/test-live-warehouses.R b/pkg-r/tests/testthat/test-live-warehouses.R index 37153616..e8649bc0 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" ) }) diff --git a/scripts/provision-live-warehouse.py b/scripts/provision-live-warehouse.py index 68ee1055..11c08f3b 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,72 @@ 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 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": """\ +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 {namespace}."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. 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} + SPECIAL_DOUBLES = { "databricks": {"NaN": "double('NaN')", "Infinity": "double('Infinity')", "-Infinity": "double('-Infinity')"}, "snowflake": {"NaN": "'NaN'::FLOAT", "Infinity": "'Inf'::FLOAT", "-Infinity": "'-Inf'::FLOAT"}, @@ -157,6 +226,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 +264,48 @@ 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. 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 = (quote_identifier(part, "snowflake") for part in parts) + namespace = f"{database}.{schema}" + lines = [] + for view, spec in SEMANTIC_VIEWS.items(): + # 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)}, $$", + body.rstrip("\n"), + "$$);", + "", + ] + return lines def main(argv: list[str]) -> int: diff --git a/tests/shared/live-warehouse-env.json b/tests/shared/live-warehouse-env.json index a51f37b3..9666590e 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." } } }