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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .github/scripts/live-warehouse-configured.sh
Original file line number Diff line number Diff line change
@@ -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
119 changes: 119 additions & 0 deletions .github/workflows/R-live-warehouse.yaml
Original file line number Diff line number Diff line change
@@ -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)
26 changes: 2 additions & 24 deletions .github/workflows/py-live-warehouse.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
23 changes: 23 additions & 0 deletions pkg-py/tests/test_live_warehouse_fixture.py
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down
101 changes: 19 additions & 82 deletions pkg-r/tests/testthat/README.md
Original file line number Diff line number Diff line change
@@ -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.
24 changes: 21 additions & 3 deletions pkg-r/tests/testthat/fixtures/shared/live-warehouse-env.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
}
},
Expand Down Expand Up @@ -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."
}
}
}
Expand Down
Loading
Loading