Skip to content

Commit

Permalink
Merge pull request #662 from atheriel/aj-snowflake-helper
Browse files Browse the repository at this point in the history
Add a helper function for connecting to Snowflake.
  • Loading branch information
atheriel authored May 15, 2024
2 parents 3899d4d + 81abe10 commit 889242b
Show file tree
Hide file tree
Showing 12 changed files with 544 additions and 9 deletions.
1 change: 1 addition & 0 deletions .github/workflows/db.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ jobs:
- name: Install Snowflake Driver
run: |
echo "ODBC_CS_SNOWFLAKE=dsn=Snowflake;pwd=${{ secrets.SNOWFLAKE_PWD }}" >> $GITHUB_ENV
echo "ODBC_PWD_SNOWFLAKE=${{ secrets.SNOWFLAKE_PWD }}" >> $GITHUB_ENV
curl https://sfc-repo.snowflakecomputing.com/odbc/linux/3.2.0/snowflake_linux_x8664_odbc-3.2.0.tgz --output snowflake_linux_x8664_odbc-3.2.0.tgz
gunzip snowflake_linux_x8664_odbc-3.2.0.tgz
tar -xvf snowflake_linux_x8664_odbc-3.2.0.tar
Expand Down
1 change: 1 addition & 0 deletions NAMESPACE
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export(odbcListObjects)
export(odbcPreviewObject)
export(odbcSetTransactionIsolationLevel)
export(quote_value)
export(snowflake)
exportClasses("DB2/AIX64")
exportClasses("Microsoft SQL Server")
exportClasses("Spark SQL")
Expand Down
4 changes: 4 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# odbc (development version)

* New `odbc::snowflake()` makes it easier to connect to Snowflake,
automatically handling authentication correctly on platforms that provide
Snowflake-native OAuth credentials (@atheriel, #662).

* Transitioned to the cli package for formatting most error messages
(@simonpcouch, #781, #784, #785, #788).

Expand Down
2 changes: 1 addition & 1 deletion R/driver-databricks.R
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ databricks_args <- function(httpPath,

arg_names <- tolower(names(all))
if (!"authmech" %in% arg_names && !all(c("uid", "pwd") %in% arg_names)) {
warn(
abort(
c(
"x" = "Failed to detect ambient Databricks credentials.",
"i" = "Supply `uid` and `pwd` to authenticate manually."
Expand Down
246 changes: 244 additions & 2 deletions R/driver-snowflake.R
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ getCatalogSchema <- function(conn, catalog_name = NULL, schema_name = NULL) {
#' to aid with performance, as the SQLColumns method is more performant
#' when restricted to a particular DB/schema.
#' @inheritParams DBI::dbListFields
#' @rdname Snowflake
#' @param catalog_name,schema_name Catalog and schema names.
#' @rdname driver-Snowflake
#' @usage NULL
setMethod("odbcConnectionColumns_", c("Snowflake", "character"),
function(conn,
Expand All @@ -52,7 +53,7 @@ setMethod("odbcConnectionColumns_", c("Snowflake", "character"),
}
)

#' @rdname Snowflake
#' @rdname driver-Snowflake
setMethod("dbExistsTableForWrite", c("Snowflake", "character"),
function(conn, name, ...,
catalog_name = NULL, schema_name = NULL) {
Expand Down Expand Up @@ -87,3 +88,244 @@ setMethod("odbcDataType", "Snowflake",
)
}
)

#' Helper for connecting to Snowflake via ODBC
#'
#' @description
#'
#' Connect to a Snowflake account via the [Snowflake ODBC
#' driver](https://docs.snowflake.com/en/developer-guide/odbc/odbc).
#'
#' In particular, the custom `dbConnect()` method for the Snowflake ODBC driver
#' detects ambient OAuth credentials on platforms like Snowpark Container
#' Services or Posit Workbench.
#'
#' @inheritParams DBI::dbConnect
#' @param account A Snowflake [account
#' identifier](https://docs.snowflake.com/en/user-guide/admin-account-identifier),
#' e.g. `"testorg-test_account"`.
#' @param driver The name of the Snowflake ODBC driver, or `NULL` to use the
#' default name.
#' @param warehouse The name of a Snowflake compute warehouse, or `NULL` to use
#' the default.
#' @param database The name of a Snowflake database, or `NULL` to use the
#' default.
#' @param schema The name of a Snowflake database schema, or `NULL` to use the
#' default.
#' @param uid,pwd Manually specify a username and password for authentication.
#' Specifying these options will disable ambient credential discovery.
#' @param ... Further arguments passed on to [`dbConnect()`].
#'
#' @returns An `OdbcConnection` object with an active connection to a Snowflake
#' account.
#'
#' @examples
#' \dontrun{
#' # Use ambient credentials.
#' DBI::dbConnect(odbc::snowflake())
#'
#' # Use browser-based SSO (if configured). Only works on desktop.
#' DBI::dbConnect(
#' odbc::snowflake(),
#' account = "testorg-test_account",
#' authenticator = "externalbrowser"
#' )
#'
#' # Use a traditional username & password.
#' DBI::dbConnect(
#' odbc::snowflake(),
#' account = "testorg-test_account",
#' uid = "me",
#' pwd = rstudioapi::askForPassword()
#' )
#' }
#' @export
snowflake <- function() {
new("Snowflake")
}

#' @rdname snowflake
#' @export
setMethod(
"dbConnect", "Snowflake",
function(drv,
account = Sys.getenv("SNOWFLAKE_ACCOUNT"),
driver = NULL,
warehouse = NULL,
database = NULL,
schema = NULL,
uid = NULL,
pwd = NULL,
...) {
call <- caller_env()
check_string(account, call = call)
check_string(driver, allow_null = TRUE, call = call)
check_string(warehouse, allow_null = TRUE, call = call)
check_string(database, allow_null = TRUE, call = call)
check_string(uid, allow_null = TRUE, call = call)
check_string(pwd, allow_null = TRUE, call = call)
args <- snowflake_args(
account = account,
driver = driver,
warehouse = warehouse,
database = database,
schema = schema,
uid = uid,
pwd = pwd,
...
)
inject(dbConnect(odbc(), !!!args))
}
)

snowflake_args <- function(account = Sys.getenv("SNOWFLAKE_ACCOUNT"),
driver = NULL,
...) {
args <- list(
driver = driver %||% snowflake_default_driver(),
account = account,
server = snowflake_server(account),
# Connections to Snowflake are always over HTTPS.
port = 443
)
auth <- snowflake_auth_args(account, ...)
all <- utils::modifyList(c(args, auth), list(...))

# Respect the Snowflake Partner environment variable, if present.
if (is.null(all$application) && nchar(Sys.getenv("SF_PARTNER")) != 0) {
all$application <- Sys.getenv("SF_PARTNER")
}

arg_names <- tolower(names(all))
if (!"authenticator" %in% arg_names && !all(c("uid", "pwd") %in% arg_names)) {
abort(
c(
"x" = "Failed to detect ambient Snowflake credentials.",
"i" = "Supply `uid` and `pwd` to authenticate manually."
),
call = quote(DBI::dbConnect())
)
}

all
}

# Returns a sensible driver name even if odbc.ini and odbcinst.ini do not
# contain an entry for the Snowflake ODBC driver. For Linux and macOS we
# default to known shared library paths used by the official installers.
# On Windows we use the official driver name.
snowflake_default_driver <- function() {
default_paths <- snowflake_default_driver_paths()
if (length(default_paths) > 0) {
return(default_paths[1])
}

fallbacks <- c("Snowflake", "SnowflakeDSIIDriver")
fallbacks <- intersect(fallbacks, odbcListDrivers()$name)
if (length(fallbacks) > 0) {
return(fallbacks[1])
}

abort(
c(
"Failed to automatically find Snowflake ODBC driver.",
i = "Set `driver` to known driver name or path."
),
call = quote(DBI::dbConnect())
)
}

snowflake_default_driver_paths <- function() {
if (Sys.info()["sysname"] == "Linux") {
paths <- c(
"/opt/rstudio-drivers/snowflake/bin/lib/libsnowflakeodbc_sb64.so",
"/usr/lib/snowflake/odbc/lib/libSnowflake.so"
)
} else if (Sys.info()["sysname"] == "Darwin") {
paths <- "/opt/snowflake/snowflakeodbc/lib/universal/libSnowflake.dylib"
} else {
paths <- character()
}
paths[file.exists(paths)]
}

snowflake_server <- function(account) {
if (nchar(account) == 0) {
abort(
c(
"No Snowflake account ID provided.",
i = "Either supply `account` argument or set env var `SNOWFLAKE_ACCOUNT`."
),
call = quote(DBI::dbConnect())
)
}
paste0(account, ".snowflakecomputing.com")
}

snowflake_auth_args <- function(account,
uid = NULL,
pwd = NULL,
authenticator = NULL,
...) {
if (!is.null(uid) && !is.null(pwd)) {
return(list(uid = uid, pwd = pwd))
} else if (xor(is.null(uid), is.null(pwd))) {
abort(
c(
"Both `uid` and `pwd` must be specified to authenticate.",
i = "Or leave both unset to use ambient Snowflake credentials."
),
call = quote(DBI::dbConnect())
)
}

# Manual override of the authentication method.
if (!is.null(authenticator)) {
return(list())
}

# Check for Workbench-provided credentials.
sf_home <- Sys.getenv("SNOWFLAKE_HOME")
if (grepl("posit-workbench", sf_home, fixed = TRUE)) {
token <- workbench_snowflake_token(account, sf_home)
if (!is.null(token)) {
return(list(authenticator = "oauth", token = token))
}
}

# Check for the default token mounted when running in Snowpark Container
# Services.
if (file.exists("/snowflake/session/token")) {
return(list(
authenticator = "oauth",
token = readLines("/snowflake/session/token", warn = FALSE)
))
}

list()
}

# Reads Posit Workbench-managed Snowflake credentials from a
# $SNOWFLAKE_HOME/connections.toml file, as used by the Snowflake Connector for
# Python implementation. The file will look as follows:
#
# [workbench]
# account = "account-id"
# token = "token"
# authenticator = "oauth"
workbench_snowflake_token <- function(account, sf_home) {
cfg <- readLines(file.path(sf_home, "connections.toml"))
# We don't attempt a full parse of the TOML syntax, instead relying on the
# fact that this file will always contain only one section.
if (!any(grepl(account, cfg, fixed = TRUE))) {
# The configuration doesn't actually apply to this account.
return(NULL)
}
line <- grepl("token = ", cfg, fixed = TRUE)
token <- gsub("token = ", "", cfg[line])
if (nchar(token) == 0) {
return(NULL)
}
# Drop enclosing quotes.
gsub("\"", "", token)
}
1 change: 1 addition & 0 deletions _pkgdown.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ reference:
- databricks
- 'Microsoft SQL Server-class'
- 'Oracle-class'
- snowflake

- title: ODBC configuration
contents:
Expand Down
4 changes: 3 additions & 1 deletion man/Snowflake.Rd → man/driver-Snowflake.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading

0 comments on commit 889242b

Please sign in to comment.