Skip to content
Merged
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
64 changes: 64 additions & 0 deletions pgdog-config/src/util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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"#);
}
}
47 changes: 44 additions & 3 deletions pgdog/src/backend/databases.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1987,4 +1988,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");
}
}
Loading