Skip to content

Commit 6b1970a

Browse files
hidekojiclaude
andcommitted
Force NLS_LANG to AL32UTF8 for Oracle (OCI) connections (tam#35892)
Multibyte data came back as literal question marks in the app. Measured against a real database with a column of Japanese text: NLS_LANG unset, or *.US7ASCII -> every non-ASCII character arrives as 0x3f *.JA16SJIS -> Shift-JIS bytes, despite unicode_as_utf8=TRUE *.AL32UTF8 -> correct UTF-8 The server converts before the data reaches the client, so the characters are gone rather than merely mislabeled, and the design's assumption that the existing NLS_LANG setting would simply carry over was wrong in both directions: an unset value loses data, and a value a user set for the ODBC based Oracle data source (where odbc handles encoding separately) actively breaks OCI. The character set is therefore forced, not defaulted, keeping the user's language and territory. NLS_LANG is set only around the connect and restored immediately after: OCI fixes the client character set when it creates its environment on that first connect, so later queries and connections in the session stay UTF-8 while the ODBC path keeps the user's own setting. Verified with a pre-existing JA16SJIS value present. Fetched character columns are now also marked as UTF-8. ROracle leaves them unmarked, which R reads in the session's native encoding - wrong on a machine running an en_US locale, as this one does. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 0b13d29 commit 6b1970a

3 files changed

Lines changed: 68 additions & 1 deletion

File tree

DESCRIPTION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
Package: exploratory
22
Type: Package
33
Title: R package for Exploratory
4-
Version: 16.0.19
4+
Version: 16.0.20
55
Date: 2026-07-28
66
Authors@R: c(person("Hideaki", "Hayashi", email = "hideaki@exploratory.io", role = c("aut", "cre")), person("Hide", "Kojima", email = "hide@exploratory.io", role = c("aut")), person("Kan", "Nishida", email = "kan@exploratory.io", role = c("aut")), person("Kei", "Saito", email = "kei@exploratory.io", role = c("aut")), person("Yosuke", "Yasuda", email = "double.y.919.quick@gmail.com", role = c("aut")))
77
URL: https://github.com/exploratory-io/exploratory_func

R/system.R

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1034,6 +1034,31 @@ applyOracleClientEnv <- function(explicitDir = Sys.getenv("EXPLORATORY_ORACLE_CL
10341034
invisible(TRUE)
10351035
}
10361036

1037+
# Returns an NLS_LANG value whose character set is AL32UTF8, preserving the language and
1038+
# territory the user configured (if any).
1039+
#
1040+
# The character set half of NLS_LANG decides how the Oracle server converts character data
1041+
# before it reaches the client, and getting it wrong loses data rather than merely mislabeling
1042+
# it. Measured against a real database, with a column holding Japanese text:
1043+
# NLS_LANG unset / *.US7ASCII -> every non-ASCII character arrives as "?" (0x3f). Gone.
1044+
# *.JA16SJIS -> Shift-JIS bytes, even though the driver is asked for UTF-8
1045+
# via unicode_as_utf8, so the strings are then mislabeled.
1046+
# *.AL32UTF8 -> correct UTF-8.
1047+
# So the character set has to be forced, not merely defaulted: a user who set NLS_LANG for the
1048+
# ODBC based Oracle data source would otherwise silently corrupt Oracle (OCI) results.
1049+
oracleOCINlsLang <- function(currentNlsLang = Sys.getenv("NLS_LANG")) {
1050+
if (is.null(currentNlsLang) || length(currentNlsLang) == 0 || is.na(currentNlsLang[[1]]) ||
1051+
currentNlsLang[[1]] == "") {
1052+
return("American_America.AL32UTF8")
1053+
}
1054+
value <- as.character(currentNlsLang[[1]])
1055+
if (!grepl(".", value, fixed = TRUE)) {
1056+
# Language and territory only, no character set part.
1057+
return(paste0(value, ".AL32UTF8"))
1058+
}
1059+
paste0(sub("\\.[^.]*$", "", value), ".AL32UTF8")
1060+
}
1061+
10371062
# Single source of truth for the Oracle (OCI) connection pool key so that getDBConnection
10381063
# and clearDBConnection can never drift apart. Every component is normalized here because
10391064
# paste() silently drops NULL, which would produce a key that no clear call can match.
@@ -1990,6 +2015,16 @@ getDBConnection <- function(type, host = NULL, port = "", databaseName = "", use
19902015
if (is.na(bulkReadRows) || bulkReadRows <= 0) {
19912016
bulkReadRows <- 10000L
19922017
}
2018+
# Force the NLS_LANG character set to AL32UTF8 for the duration of the connect, then put
2019+
# the user's own value back. OCI fixes the client character set when it creates its
2020+
# environment, which happens on this first connect, so restoring afterwards leaves this
2021+
# connection (and every later one in the session) reading UTF-8 while the ODBC based
2022+
# Oracle data source keeps whatever NLS_LANG the user configured for it.
2023+
previousNlsLang <- Sys.getenv("NLS_LANG")
2024+
Sys.setenv(NLS_LANG = oracleOCINlsLang(previousNlsLang))
2025+
on.exit({
2026+
if (previousNlsLang == "") Sys.unsetenv("NLS_LANG") else Sys.setenv(NLS_LANG = previousNlsLang)
2027+
}, add = TRUE)
19932028
conn <- ROracle::dbConnect(
19942029
ROracle::Oracle(unicode_as_utf8 = TRUE),
19952030
username = username,
@@ -2311,6 +2346,17 @@ queryOracleOCI <- function(host = "", port = 1521, databaseName = "", username =
23112346
})
23122347
}
23132348
colnames(df) <- iconv(colnames(df), from = "utf8", to = "utf8") # Work around to read multibyte column names without getting garbled.
2349+
# The connection forces the NLS_LANG character set to AL32UTF8, so character data is UTF-8
2350+
# here. ROracle leaves it unmarked ("unknown"), which R then reads in the session's native
2351+
# encoding - wrong whenever that is not UTF-8, as on a machine running an en_US locale.
2352+
# Marking it keeps multibyte values intact through the rest of the pipeline.
2353+
characterColumns <- vapply(df, is.character, logical(1))
2354+
if (any(characterColumns)) {
2355+
df[characterColumns] <- lapply(df[characterColumns], function(column) {
2356+
Encoding(column) <- "UTF-8"
2357+
column
2358+
})
2359+
}
23142360
df
23152361
}
23162362

tests/testthat/test_system.R

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1300,3 +1300,24 @@ test_that("applyOracleClientEnv reports FALSE when no Oracle client can be found
13001300
dir.create(emptyDir)
13011301
expect_false(exploratory:::applyOracleClientEnv(emptyDir))
13021302
})
1303+
1304+
test_that("oracleOCINlsLang always forces the AL32UTF8 character set", {
1305+
# Measured against a real database: an unset or US7ASCII NLS_LANG makes the server replace
1306+
# every non-ASCII character with "?" before it reaches the client, and JA16SJIS returns
1307+
# Shift-JIS bytes even though the driver is asked for UTF-8. Only AL32UTF8 is safe, so the
1308+
# character set is forced rather than merely defaulted.
1309+
expect_equal(exploratory:::oracleOCINlsLang(""), "American_America.AL32UTF8")
1310+
expect_equal(exploratory:::oracleOCINlsLang(NULL), "American_America.AL32UTF8")
1311+
expect_equal(exploratory:::oracleOCINlsLang(NA), "American_America.AL32UTF8")
1312+
1313+
# A user's language and territory are preserved; only the character set is replaced.
1314+
expect_equal(exploratory:::oracleOCINlsLang("Japanese_Japan.JA16SJIS"), "Japanese_Japan.AL32UTF8")
1315+
expect_equal(exploratory:::oracleOCINlsLang("American_America.US7ASCII"), "American_America.AL32UTF8")
1316+
expect_equal(exploratory:::oracleOCINlsLang("Japanese_Japan.AL32UTF8"), "Japanese_Japan.AL32UTF8")
1317+
1318+
# Language and territory with no character set part at all.
1319+
expect_equal(exploratory:::oracleOCINlsLang("Japanese_Japan"), "Japanese_Japan.AL32UTF8")
1320+
1321+
# A territory containing a dot must not confuse the character set split.
1322+
expect_equal(exploratory:::oracleOCINlsLang("A_B.C.JA16SJIS"), "A_B.C.AL32UTF8")
1323+
})

0 commit comments

Comments
 (0)