From 0789e4e49644320dcbf048a066dbd905cf3c6c79 Mon Sep 17 00:00:00 2001 From: Niels Savvides Date: Wed, 5 Aug 2026 15:52:21 +0200 Subject: [PATCH 1/2] Add tests for PostgreSQL folding logic of identifiers PostgreSQL converts a Orders -> orders, and "Orders" -> Orders. This rule should be upheld in the configurations aswell --- pgdog/src/backend/databases.rs | 40 ++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/pgdog/src/backend/databases.rs b/pgdog/src/backend/databases.rs index a6e170341..c585a7a46 100644 --- a/pgdog/src/backend/databases.rs +++ b/pgdog/src/backend/databases.rs @@ -1987,4 +1987,44 @@ password = "testpass" assert_eq!(new_users.users[0].name, "testuser"); assert_eq!(new_users.users[0].database, "destination_db"); } + + /// PostgreSQL folds unquoted identifiers to lower case, so the parser + /// hands the router `orders` for `FROM Orders`. Identifiers configured + /// in `pgdog.toml` must be folded the same way, otherwise they never + /// match and the table silently isn't sharded. + #[test] + fn test_unquoted_config_identifiers_are_folded() { + let config = ShardedTableConfig { + database: "pgdog".into(), + name: Some("Orders".into()), + schema: Some("Public".into()), + column: "Tenant_Id".into(), + ..Default::default() + }; + + let resolved = resolve_sharded_table(&config, &IndexMap::new(), 2); + + assert_eq!(resolved.name.as_deref(), Some("orders")); + assert_eq!(resolved.schema.as_deref(), Some("public")); + assert_eq!(resolved.column, "tenant_id"); + } + + /// Quoted identifiers keep their case, and the surrounding quotes are + /// not part of the identifier itself. + #[test] + fn test_quoted_config_identifiers_preserve_case() { + let config = ShardedTableConfig { + database: "pgdog".into(), + name: Some(r#""Orders""#.into()), + schema: Some(r#""Public""#.into()), + column: r#""Tenant_Id""#.into(), + ..Default::default() + }; + + let resolved = resolve_sharded_table(&config, &IndexMap::new(), 2); + + assert_eq!(resolved.name.as_deref(), Some("Orders")); + assert_eq!(resolved.schema.as_deref(), Some("Public")); + assert_eq!(resolved.column, "Tenant_Id"); + } } From 70768172bf3305e278b4944ea324afd57e43f597 Mon Sep 17 00:00:00 2001 From: Niels Savvides Date: Wed, 5 Aug 2026 16:38:11 +0200 Subject: [PATCH 2/2] Add logic for normalization of config identifiers Following the SQL protocol --- pgdog-config/src/util.rs | 64 ++++++++++++++++++++++++++++++++++ pgdog/src/backend/databases.rs | 7 ++-- 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/pgdog-config/src/util.rs b/pgdog-config/src/util.rs index d8848b862..62816cdf2 100644 --- a/pgdog-config/src/util.rs +++ b/pgdog-config/src/util.rs @@ -53,6 +53,26 @@ pub fn random_string(n: usize) -> String { .collect() } +/// Fold a configured identifier the way PostgreSQL's parser folds +/// identifiers in SQL, so values from `pgdog.toml` compare equal to +/// the ones the query parser produces. +/// +/// - Unquoted identifiers are lower-cased: `Orders` -> `orders` +/// - Quoted identifiers keep their case, lose the surrounding quotes, +/// and un-escape doubled quotes: `"Or""ders"` -> `Or"ders` +/// +/// Folding is ASCII-only, matching PostgreSQL's behaviour for +/// multi-byte encodings such as UTF-8. +pub fn normalize_identifier(identifier: &str) -> String { + match identifier + .strip_prefix('"') + .and_then(|inner| inner.strip_suffix('"')) + { + Some(inner) => inner.replace("\"\"", "\""), + None => identifier.to_ascii_lowercase(), + } +} + /// Swap field values using tmp pattern: source -> tmp, dest -> source, tmp -> dest. #[macro_export] macro_rules! swap_field { @@ -74,3 +94,47 @@ macro_rules! swap_field { }); }; } + +#[cfg(test)] +mod test_normalize_identifier { + use super::normalize_identifier; + + #[test] + fn test_unquoted_is_lowercased() { + assert_eq!(normalize_identifier("Orders"), "orders"); + assert_eq!(normalize_identifier("ORDERS"), "orders"); + assert_eq!(normalize_identifier("orders"), "orders"); + assert_eq!(normalize_identifier("Tenant_Id"), "tenant_id"); + } + + #[test] + fn test_quoted_preserves_case_and_drops_quotes() { + assert_eq!(normalize_identifier(r#""Orders""#), "Orders"); + assert_eq!(normalize_identifier(r#""ORDERS""#), "ORDERS"); + assert_eq!(normalize_identifier(r#""orders""#), "orders"); + } + + #[test] + fn test_quoted_unescapes_doubled_quotes() { + assert_eq!(normalize_identifier(r#""Or""ders""#), r#"Or"ders"#); + assert_eq!( + normalize_identifier(r#""He said ""hi""""#), + r#"He said "hi""# + ); + } + + #[test] + fn test_folding_is_ascii_only() { + // PostgreSQL folds only ASCII in multi-byte encodings, so the + // accented character is left alone while ECOLE is lowered. + assert_eq!(normalize_identifier("ÉCOLE"), "École"); + } + + #[test] + fn test_edge_cases() { + assert_eq!(normalize_identifier(""), ""); + assert_eq!(normalize_identifier(r#""""#), ""); + // A lone quote is not a valid quoted identifier; treat it as unquoted. + assert_eq!(normalize_identifier(r#""Orders"#), r#""orders"#); + } +} diff --git a/pgdog/src/backend/databases.rs b/pgdog/src/backend/databases.rs index c585a7a46..e4bbbbe75 100644 --- a/pgdog/src/backend/databases.rs +++ b/pgdog/src/backend/databases.rs @@ -11,6 +11,7 @@ use once_cell::sync::Lazy; use parking_lot::lock_api::MutexGuard; use parking_lot::{Mutex, RawMutex}; use pgdog_config::users::PasswordKind; +use pgdog_config::util::normalize_identifier; use pgdog_config::{ QueryParser, ShardedMappingConfig, ShardedMappingKey, ShardedMappingKeyRef, ShardedMappingKindDeprecated, ShardedMappingList, ShardedMappingRange, ShardedTableConfig, @@ -507,9 +508,9 @@ fn resolve_sharded_table( ShardedTable { database: config.database.clone(), - name: config.name.clone(), - schema: config.schema.clone(), - column: config.column.clone(), + name: config.name.as_deref().map(normalize_identifier), + schema: config.schema.as_deref().map(normalize_identifier), + column: normalize_identifier(&config.column), primary: config.primary, centroids: config.centroids.clone(), data_type: config.data_type,