This is an automated email from the ASF dual-hosted git repository.
jerry-024 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-rust.git
The following commit(s) were added to refs/heads/main by this push:
new 6824972f fix(api): honor REST config overrides
6824972f is described below
commit 6824972f4e2cfc62e1321e2f4dfdb004a82900a3
Author: shyjsarah <[email protected]>
AuthorDate: Mon Jul 20 23:39:52 2026 -0700
fix(api): honor REST config overrides
Match the Java REST client merge semantics: treat null config maps as
empty, apply defaults then client options then overrides, and remove entries
whose final override value is null.
---
crates/paimon/src/api/api_response.rs | 171 ++++++++++++++++++++++++++++++++--
1 file changed, 164 insertions(+), 7 deletions(-)
diff --git a/crates/paimon/src/api/api_response.rs
b/crates/paimon/src/api/api_response.rs
index 0e311372..9d68cf3d 100644
--- a/crates/paimon/src/api/api_response.rs
+++ b/crates/paimon/src/api/api_response.rs
@@ -19,7 +19,7 @@
//!
//! This module contains all response structures used in REST API calls.
-use serde::{Deserialize, Serialize};
+use serde::{Deserialize, Deserializer, Serialize};
use std::collections::HashMap;
use crate::catalog::{Function, FunctionDefinition, ViewSchema};
@@ -266,31 +266,78 @@ impl GetDatabaseResponse {
}
}
-/// Response containing configuration defaults.
+/// Response containing server-provided configuration defaults and overrides.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConfigResponse {
/// Default configuration values.
+ #[serde(default, deserialize_with = "deserialize_config_defaults")]
pub defaults: HashMap<String, String>,
+ /// Configuration values that override client-provided options.
+ ///
+ /// A null value removes the corresponding option after all maps are
merged.
+ #[serde(default, deserialize_with = "deserialize_null_to_default")]
+ pub overrides: HashMap<String, Option<String>>,
+}
+
+fn deserialize_null_to_default<'de, D, T>(deserializer: D) -> Result<T,
D::Error>
+where
+ D: Deserializer<'de>,
+ T: Deserialize<'de> + Default,
+{
+ Ok(Option::<T>::deserialize(deserializer)?.unwrap_or_default())
+}
+
+fn deserialize_config_defaults<'de, D>(deserializer: D) ->
Result<HashMap<String, String>, D::Error>
+where
+ D: Deserializer<'de>,
+{
+ let defaults =
+ Option::<HashMap<String,
Option<String>>>::deserialize(deserializer)?.unwrap_or_default();
+ // Java filters null values after merging. Defaults have the lowest
precedence, so dropping
+ // their null entries here produces the same result while preserving the
existing field type.
+ Ok(defaults
+ .into_iter()
+ .filter_map(|(key, value)| value.map(|value| (key, value)))
+ .collect())
}
impl ConfigResponse {
/// Create a new ConfigResponse.
pub fn new(defaults: HashMap<String, String>) -> Self {
- Self { defaults }
+ Self {
+ defaults,
+ overrides: HashMap::new(),
+ }
}
- /// Merge these defaults with the provided Options.
- /// User options take precedence over defaults.
+ /// Merge server defaults and overrides with the provided Options.
+ /// Overrides take precedence over user options, which take precedence
over defaults.
pub fn merge_options(&self, options: &crate::common::Options) ->
crate::common::Options {
let mut merged = self.defaults.clone();
merged.extend(options.to_map().clone());
+ self.apply_overrides(&mut merged);
crate::common::Options::from_map(merged)
}
- /// Convert to Options struct.
+ /// Convert server-provided defaults and overrides to Options.
pub fn to_options(&self) -> crate::common::Options {
- crate::common::Options::from_map(self.defaults.clone())
+ let mut options = self.defaults.clone();
+ self.apply_overrides(&mut options);
+ crate::common::Options::from_map(options)
+ }
+
+ fn apply_overrides(&self, options: &mut HashMap<String, String>) {
+ for (key, value) in &self.overrides {
+ match value {
+ Some(value) => {
+ options.insert(key.clone(), value.clone());
+ }
+ None => {
+ options.remove(key);
+ }
+ }
+ }
}
}
@@ -450,6 +497,116 @@ impl AuthTableQueryResponse {
mod tests {
use super::*;
+ #[test]
+ fn test_config_response_overrides_client_options() {
+ let response: ConfigResponse = serde_json::from_str(
+ r#"{
+ "defaults": {
+ "default-only": "default",
+ "client-wins": "default",
+ "override-wins": "default"
+ },
+ "overrides": {
+ "override-only": "override",
+ "override-wins": "override"
+ }
+ }"#,
+ )
+ .unwrap();
+ let client_options = crate::common::Options::from_map(HashMap::from([
+ ("client-only".to_string(), "client".to_string()),
+ ("client-wins".to_string(), "client".to_string()),
+ ("override-wins".to_string(), "client".to_string()),
+ ]));
+
+ let merged = response.merge_options(&client_options);
+
+ assert_eq!(merged.get("default-only"), Some(&"default".to_string()));
+ assert_eq!(merged.get("client-only"), Some(&"client".to_string()));
+ assert_eq!(merged.get("client-wins"), Some(&"client".to_string()));
+ assert_eq!(merged.get("override-only"), Some(&"override".to_string()));
+ assert_eq!(merged.get("override-wins"), Some(&"override".to_string()));
+ }
+
+ #[test]
+ fn test_config_response_without_overrides_is_backward_compatible() {
+ let response: ConfigResponse =
+ serde_json::from_str(r#"{"defaults": {"warehouse":
"s3://warehouse"}}"#).unwrap();
+
+ assert!(response.overrides.is_empty());
+ assert_eq!(
+ response.to_options().get("warehouse"),
+ Some(&"s3://warehouse".to_string())
+ );
+ }
+
+ #[test]
+ fn test_config_response_accepts_null_maps() {
+ let response: ConfigResponse =
+ serde_json::from_str(r#"{"defaults": null, "overrides":
null}"#).unwrap();
+ let client_options = crate::common::Options::from_map(HashMap::from([(
+ "client-only".to_string(),
+ "client".to_string(),
+ )]));
+
+ let merged = response.merge_options(&client_options);
+
+ assert_eq!(merged.get("client-only"), Some(&"client".to_string()));
+ }
+
+ #[test]
+ fn test_config_response_filters_null_values_after_merge() {
+ let response: ConfigResponse = serde_json::from_str(
+ r#"{
+ "defaults": {
+ "default-null": null,
+ "client-replaces-null": null,
+ "override-removes": "default",
+ "override-wins": "default"
+ },
+ "overrides": {
+ "override-null-only": null,
+ "override-removes": null,
+ "override-wins": "override"
+ }
+ }"#,
+ )
+ .unwrap();
+ let client_options = crate::common::Options::from_map(HashMap::from([
+ ("client-only".to_string(), "client".to_string()),
+ ("client-replaces-null".to_string(), "client".to_string()),
+ ("override-removes".to_string(), "client".to_string()),
+ ("override-wins".to_string(), "client".to_string()),
+ ]));
+
+ let merged = response.merge_options(&client_options);
+
+ assert!(!merged.contains("default-null"));
+ assert_eq!(
+ merged.get("client-replaces-null"),
+ Some(&"client".to_string())
+ );
+ assert_eq!(merged.get("client-only"), Some(&"client".to_string()));
+ assert!(!merged.contains("override-null-only"));
+ assert!(!merged.contains("override-removes"));
+ assert_eq!(merged.get("override-wins"), Some(&"override".to_string()));
+ }
+
+ #[test]
+ fn test_config_response_to_options_applies_overrides() {
+ let response: ConfigResponse = serde_json::from_str(
+ r#"{
+ "defaults": {"data-token.enabled": "false"},
+ "overrides": {"data-token.enabled": "true"}
+ }"#,
+ )
+ .unwrap();
+
+ let options = response.to_options();
+
+ assert_eq!(options.get("data-token.enabled"),
Some(&"true".to_string()));
+ }
+
#[test]
fn test_auth_table_query_response_deserialization() {
// A restricted grant: pin the exact wire field names. A drift in
either