From e4d8cdd4568de5a07f87d593e01149359f663a40 Mon Sep 17 00:00:00 2001
From: Magnus Persson <1431685+magnusp@users.noreply.github.com>
Date: Sat, 31 Oct 2026 15:58:30 +0100
Subject: [PATCH 1/2] refactor(jdbc): use Locale.ROOT for every case-folding
call site
---
.../magnusp/libsql/jdbc/ColumnMetadata.java | 2 +-
.../magnusp/libsql/jdbc/DeclaredType.java | 2 +-
.../libsql/jdbc/ForeignKeyMetadata.java | 2 +-
.../libsql/jdbc/LocaleFoldingTest.java | 212 ++++++++++++++++++
4 files changed, 215 insertions(+), 3 deletions(-)
create mode 100644 libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LocaleFoldingTest.java
diff --git a/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/ColumnMetadata.java b/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/ColumnMetadata.java
index 84494f6..05f44f9 100644
--- a/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/ColumnMetadata.java
+++ b/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/ColumnMetadata.java
@@ -120,7 +120,7 @@ private Row toRow() {
private record ColumnShape(int jdbcType, String typeName, int columnSize, int decimalDigits) {}
private static ColumnShape columnShape(String declaredType) {
- String typeName = declaredType == null ? "TEXT" : declaredType.toUpperCase(Locale.ENGLISH);
+ String typeName = declaredType == null ? "TEXT" : declaredType.toUpperCase(Locale.ROOT);
int jdbcType;
int decimalDigits = DEFAULT_DECIMAL_DIGITS;
if (INTEGER_TYPE.matcher(typeName).find()) {
diff --git a/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/DeclaredType.java b/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/DeclaredType.java
index 933f50d..7d649be 100644
--- a/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/DeclaredType.java
+++ b/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/DeclaredType.java
@@ -41,7 +41,7 @@ String declaredTypeName() {
}
Matcher matcher = COLUMN_TYPENAME.matcher(declType);
matcher.find();
- return matcher.group(1).toUpperCase(Locale.ENGLISH);
+ return matcher.group(1).toUpperCase(Locale.ROOT);
}
String typeName() {
diff --git a/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/ForeignKeyMetadata.java b/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/ForeignKeyMetadata.java
index 8f03891..0ddc1d5 100644
--- a/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/ForeignKeyMetadata.java
+++ b/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/ForeignKeyMetadata.java
@@ -128,7 +128,7 @@ private static int keyRule(String rule) {
if (rule == null) {
return DatabaseMetaData.importedKeyNoAction;
}
- return switch (rule.toUpperCase(Locale.ENGLISH)) {
+ return switch (rule.toUpperCase(Locale.ROOT)) {
case "CASCADE" -> DatabaseMetaData.importedKeyCascade;
case "RESTRICT" -> DatabaseMetaData.importedKeyRestrict;
case "SET NULL" -> DatabaseMetaData.importedKeySetNull;
diff --git a/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LocaleFoldingTest.java b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LocaleFoldingTest.java
new file mode 100644
index 0000000..0b78126
--- /dev/null
+++ b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LocaleFoldingTest.java
@@ -0,0 +1,212 @@
+package io.github.magnusp.libsql.jdbc;
+
+import io.github.magnusp.libsql.hrana.codec.Col;
+import io.github.magnusp.libsql.hrana.codec.Row;
+import io.github.magnusp.libsql.hrana.codec.StmtResult;
+import io.github.magnusp.libsql.hrana.codec.Value;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import java.sql.DatabaseMetaData;
+import java.util.List;
+import java.util.Locale;
+import java.util.stream.Stream;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.params.provider.Arguments.arguments;
+
+/**
+ * Pins that every case-folding call site uses {@link Locale#ROOT}, so no default locale can change
+ * a metadata result. The corpus covers the four kinds of text that reach the call sites.
+ */
+class LocaleFoldingTest {
+
+ private static final Locale TURKISH = Locale.forLanguageTag("tr");
+
+ private static final List
COLUMN_COLUMNS = List.of(
+ new Col("TABLE_CAT", "TEXT"),
+ new Col("TABLE_SCHEM", "TEXT"),
+ new Col("TABLE_NAME", "TEXT"),
+ new Col("COLUMN_NAME", "TEXT"),
+ new Col("DATA_TYPE", "INTEGER"),
+ new Col("TYPE_NAME", "TEXT"),
+ new Col("COLUMN_SIZE", "INTEGER"),
+ new Col("BUFFER_LENGTH", "INTEGER"),
+ new Col("DECIMAL_DIGITS", "INTEGER"),
+ new Col("NUM_PREC_RADIX", "INTEGER"),
+ new Col("NULLABLE", "INTEGER"),
+ new Col("REMARKS", "TEXT"),
+ new Col("COLUMN_DEF", "TEXT"),
+ new Col("SQL_DATA_TYPE", "INTEGER"),
+ new Col("SQL_DATETIME_SUB", "INTEGER"),
+ new Col("CHAR_OCTET_LENGTH", "INTEGER"),
+ new Col("ORDINAL_POSITION", "INTEGER"),
+ new Col("IS_NULLABLE", "TEXT"),
+ new Col("SCOPE_CATALOG", "TEXT"),
+ new Col("SCOPE_SCHEMA", "TEXT"),
+ new Col("SCOPE_TABLE", "TEXT"),
+ new Col("SOURCE_DATA_TYPE", "INTEGER"),
+ new Col("IS_AUTOINCREMENT", "TEXT"),
+ new Col("IS_GENERATEDCOLUMN", "TEXT"));
+
+ private static final List FOREIGN_KEY_COLUMNS = List.of(
+ new Col("PKTABLE_CAT", "TEXT"),
+ new Col("PKTABLE_SCHEM", "TEXT"),
+ new Col("PKTABLE_NAME", "TEXT"),
+ new Col("PKCOLUMN_NAME", "TEXT"),
+ new Col("FKTABLE_CAT", "TEXT"),
+ new Col("FKTABLE_SCHEM", "TEXT"),
+ new Col("FKTABLE_NAME", "TEXT"),
+ new Col("FKCOLUMN_NAME", "TEXT"),
+ new Col("KEY_SEQ", "INTEGER"),
+ new Col("UPDATE_RULE", "INTEGER"),
+ new Col("DELETE_RULE", "INTEGER"),
+ new Col("FK_NAME", "TEXT"),
+ new Col("PK_NAME", "TEXT"),
+ new Col("DEFERRABILITY", "INTEGER"));
+
+ @AfterEach
+ void restoreDefaultLocale() {
+ Locale.setDefault(Locale.ROOT);
+ }
+
+ static Stream corpus() {
+ return Stream.of(
+ // Declared type names.
+ "INTEGER",
+ "VARCHAR(10)",
+ "numeric",
+ "TEXT",
+ "REAL",
+ "DECIMAL(10,2)",
+ "BOOL",
+ "CLOB",
+ "BLOB",
+ "DOUBLE",
+ // Foreign key rules.
+ "CASCADE",
+ "RESTRICT",
+ "SET NULL",
+ "SET DEFAULT",
+ "NO ACTION",
+ "cascade",
+ "set null",
+ // SQLite error codes and messages.
+ "SQLITE_CONSTRAINT",
+ "SQLITE_ERROR",
+ "constraint failed",
+ "UNIQUE constraint failed: t.id",
+ "database is locked",
+ "no such table: main.t",
+ // Identifiers, including the Turkish dotless and dotted i and the sharp s.
+ "People",
+ "id",
+ "İstanbul",
+ "ıi",
+ "straße",
+ "i",
+ "I",
+ "İ",
+ "ı",
+ "ß")
+ .map(value -> arguments(value));
+ }
+
+ @ParameterizedTest
+ @MethodSource("corpus")
+ void englishAndRootFoldIdentically(String value) {
+ assertThat(value.toUpperCase(Locale.ENGLISH))
+ .as("upper case of %s must not depend on ENGLISH versus ROOT", value)
+ .isEqualTo(value.toUpperCase(Locale.ROOT));
+ assertThat(value.toLowerCase(Locale.ENGLISH))
+ .as("lower case of %s must not depend on ENGLISH versus ROOT", value)
+ .isEqualTo(value.toLowerCase(Locale.ROOT));
+ }
+
+ @Test
+ void columnMetadataFoldsUnderATurkishDefaultLocale() {
+ StmtResult raw = new StmtResult(
+ List.of(),
+ List.of(new Row(List.of(
+ Value.of("t"),
+ Value.of("CREATE TABLE t (c numeric)"),
+ Value.of(0L),
+ Value.of("c"),
+ Value.of("numeric"),
+ Value.of(0L),
+ Value.ofNull(),
+ Value.of(0L),
+ Value.of(0L)))),
+ 0,
+ null);
+
+ Locale.setDefault(TURKISH);
+
+ assertThat(typeName(ColumnMetadata.columns(raw, COLUMN_COLUMNS)))
+ .as("columnShape must fold the declared type with Locale.ROOT, not the default locale")
+ .isEqualTo("NUMERIC");
+ }
+
+ @Test
+ void declaredTypeFoldsUnderATurkishDefaultLocale() {
+ Locale.setDefault(TURKISH);
+
+ assertThat(DeclaredType.of(new Col("c", "numeric")).typeName())
+ .as("DeclaredType must fold with Locale.ROOT, not the default locale")
+ .isEqualTo("NUMERIC");
+ }
+
+ @Test
+ void foreignKeyRulesFoldUnderATurkishDefaultLocale() {
+ StmtResult raw = new StmtResult(
+ List.of(),
+ List.of(new Row(List.of(
+ Value.of(0L), // fk id
+ Value.of(0L), // seq
+ Value.of("parent"),
+ Value.of("parent_id"),
+ Value.of("id"),
+ Value.of("cascade"), // update rule
+ Value.of("set null"), // delete rule
+ Value.of("CREATE TABLE child (parent_id INTEGER REFERENCES parent(id))"),
+ Value.of("CREATE TABLE parent (id INTEGER PRIMARY KEY)"),
+ Value.of("id")))),
+ 0,
+ null);
+
+ Locale.setDefault(TURKISH);
+ Row imported = ForeignKeyMetadata.fromImported(raw, null, null, "child")
+ .rows()
+ .get(0);
+
+ assertThat(integer(imported, FOREIGN_KEY_COLUMNS, "UPDATE_RULE"))
+ .as("ForeignKeyMetadata must fold the rule with Locale.ROOT, not the default locale")
+ .isEqualTo(DatabaseMetaData.importedKeyCascade);
+ assertThat(integer(imported, FOREIGN_KEY_COLUMNS, "DELETE_RULE"))
+ .as("ForeignKeyMetadata must fold the rule with Locale.ROOT, not the default locale")
+ .isEqualTo(DatabaseMetaData.importedKeySetNull);
+ }
+
+ private static String typeName(StmtResult result) {
+ Row row = result.rows().get(0);
+ Value value = row.values().get(indexOf(COLUMN_COLUMNS, "TYPE_NAME"));
+ return ((Value.Text) value).value();
+ }
+
+ private static long integer(Row row, List columns, String columnName) {
+ Value value = row.values().get(indexOf(columns, columnName));
+ return ((Value.Integer) value).value();
+ }
+
+ private static int indexOf(List columns, String columnName) {
+ for (int i = 0; i < columns.size(); i++) {
+ if (columns.get(i).name().equals(columnName)) {
+ return i;
+ }
+ }
+ throw new IllegalArgumentException("no such column: " + columnName);
+ }
+}
From f5693573e1b02aa4645b7b45ea7a0ffd9e5da3b7 Mon Sep 17 00:00:00 2001
From: Magnus Persson <1431685+magnusp@users.noreply.github.com>
Date: Sat, 31 Oct 2026 16:04:38 +0100
Subject: [PATCH 2/2] test(jdbc): restore the captured default locale in
LocaleFoldingTest
---
.../github/magnusp/libsql/jdbc/LocaleFoldingTest.java | 10 +++++++++-
1 file changed, 9 insertions(+), 1 deletion(-)
diff --git a/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LocaleFoldingTest.java b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LocaleFoldingTest.java
index 0b78126..df89ee1 100644
--- a/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LocaleFoldingTest.java
+++ b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LocaleFoldingTest.java
@@ -5,6 +5,7 @@
import io.github.magnusp.libsql.hrana.codec.StmtResult;
import io.github.magnusp.libsql.hrana.codec.Value;
import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
@@ -68,9 +69,16 @@ class LocaleFoldingTest {
new Col("PK_NAME", "TEXT"),
new Col("DEFERRABILITY", "INTEGER"));
+ private Locale previousDefaultLocale;
+
+ @BeforeEach
+ void captureDefaultLocale() {
+ previousDefaultLocale = Locale.getDefault();
+ }
+
@AfterEach
void restoreDefaultLocale() {
- Locale.setDefault(Locale.ROOT);
+ Locale.setDefault(previousDefaultLocale);
}
static Stream corpus() {