use arrow_schema::SchemaRef;
use std::collections::HashMap;
#[derive(Debug, Clone)]
pub struct DeleteContext {
pub custom_delete_marker: Option<(String, String)>,
pub has_built_in_delete_field: bool,
pub hoodie_operation_pos: Option<usize>,
pub reader_schema: SchemaRef,
}
const DELETE_KEY_PROP: &str = "hoodie.payload.delete.field";
const DELETE_MARKER_PROP: &str = "hoodie.payload.delete.marker";
const RECORD_MERGE_PROPERTY_PREFIX: &str = "hoodie.record.merge.property.";
const TABLE_VERSION_KEY: &str = "hoodie.table.version";
const MERGE_PROPS_SYNTHESIS_MAX_TABLE_VERSION: i32 = 9;
const PAYLOAD_CLASS_KEYS: [&str; 3] = [
"hoodie.compaction.payload.class",
"hoodie.datasource.write.payload.class",
"hoodie.table.legacy.payload.class",
];
impl DeleteContext {
pub fn new(props: &HashMap<String, String>, table_schema: &SchemaRef) -> Self {
let custom_delete_marker = Self::get_custom_delete_marker(props);
let has_built_in_delete_field = table_schema
.column_with_name("_hoodie_is_deleted")
.is_some();
Self {
custom_delete_marker,
has_built_in_delete_field,
hoodie_operation_pos: None,
reader_schema: table_schema.clone(),
}
}
pub fn from_props(props: &HashMap<String, String>) -> Self {
let custom_delete_marker = Self::get_custom_delete_marker(props);
Self {
custom_delete_marker,
has_built_in_delete_field: true,
hoodie_operation_pos: None,
reader_schema: arrow_schema::Schema::empty().into(),
}
}
pub fn with_reader_schema(mut self, schema: SchemaRef) -> Self {
self.has_built_in_delete_field = schema.column_with_name("_hoodie_is_deleted").is_some();
self.hoodie_operation_pos = schema
.column_with_name("_hoodie_operation")
.map(|(idx, _)| idx);
self.reader_schema = schema;
self
}
#[allow(dead_code)]
pub fn from_reader_schema(schema: SchemaRef) -> Self {
let has_built_in_delete_field = schema.column_with_name("_hoodie_is_deleted").is_some();
let hoodie_operation_pos = schema
.column_with_name("_hoodie_operation")
.map(|(idx, _)| idx);
Self {
custom_delete_marker: None,
has_built_in_delete_field,
hoodie_operation_pos,
reader_schema: schema,
}
}
fn get_custom_delete_marker(props: &HashMap<String, String>) -> Option<(String, String)> {
let lookup = |suffix: &str| {
props
.get(&format!("{RECORD_MERGE_PROPERTY_PREFIX}{suffix}"))
.or_else(|| props.get(suffix))
.filter(|v| !v.is_empty())
.cloned()
};
if let (Some(key), Some(marker)) = (lookup(DELETE_KEY_PROP), lookup(DELETE_MARKER_PROP)) {
return Some((key, marker));
}
Self::synthesize_legacy_delete_marker(props)
}
fn synthesize_legacy_delete_marker(
props: &HashMap<String, String>,
) -> Option<(String, String)> {
let version = props.get(TABLE_VERSION_KEY)?.trim().parse::<i32>().ok()?;
if version >= MERGE_PROPS_SYNTHESIS_MAX_TABLE_VERSION {
return None;
}
let payload_class = PAYLOAD_CLASS_KEYS
.iter()
.find_map(|k| props.get(*k))
.map(String::as_str)?;
if payload_class.ends_with("AWSDmsAvroPayload") {
Some(("Op".to_string(), "D".to_string()))
} else if payload_class.ends_with("MySqlDebeziumAvroPayload")
|| payload_class.ends_with("PostgresDebeziumAvroPayload")
{
Some(("_change_operation_type".to_string(), "d".to_string()))
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use arrow_schema::{DataType, Field, Schema};
use std::sync::Arc;
fn schema() -> SchemaRef {
Arc::new(Schema::new(vec![
Field::new("_hoodie_record_key", DataType::Utf8, false),
Field::new("Op", DataType::Utf8, true),
Field::new("val", DataType::Int64, true),
]))
}
#[test]
fn test_custom_delete_marker_read_under_java_keys() {
let props = HashMap::from([
(
"hoodie.record.merge.property.hoodie.payload.delete.field".to_string(),
"Op".to_string(),
),
(
"hoodie.record.merge.property.hoodie.payload.delete.marker".to_string(),
"D".to_string(),
),
]);
let ctx = DeleteContext::new(&props, &schema());
assert_eq!(
ctx.custom_delete_marker,
Some(("Op".to_string(), "D".to_string())),
"custom delete marker must be read under the Java keys"
);
let bare = HashMap::from([
("hoodie.payload.delete.field".to_string(), "Op".to_string()),
("hoodie.payload.delete.marker".to_string(), "D".to_string()),
]);
assert_eq!(
DeleteContext::new(&bare, &schema()).custom_delete_marker,
Some(("Op".to_string(), "D".to_string()))
);
let old_keys = HashMap::from([
(
"hoodie.datasource.write.payload.delete.field".to_string(),
"Op".to_string(),
),
(
"hoodie.datasource.write.payload.delete.marker".to_string(),
"D".to_string(),
),
]);
assert_eq!(
DeleteContext::new(&old_keys, &schema()).custom_delete_marker,
None,
"the non-Java (old hudi-rs) keys must NOT configure a delete marker"
);
}
#[test]
fn test_legacy_payload_delete_marker_synthesis() {
let aws_v6 = HashMap::from([
("hoodie.table.version".to_string(), "6".to_string()),
(
"hoodie.compaction.payload.class".to_string(),
"org.apache.hudi.common.model.AWSDmsAvroPayload".to_string(),
),
]);
assert_eq!(
DeleteContext::new(&aws_v6, &schema()).custom_delete_marker,
Some(("Op".to_string(), "D".to_string())),
"AWS DMS payload (v6) synthesizes Op/D"
);
let pg_v6 = HashMap::from([
("hoodie.table.version".to_string(), "6".to_string()),
(
"hoodie.datasource.write.payload.class".to_string(),
"org.apache.hudi.common.model.debezium.PostgresDebeziumAvroPayload".to_string(),
),
]);
assert_eq!(
DeleteContext::new(&pg_v6, &schema()).custom_delete_marker,
Some(("_change_operation_type".to_string(), "d".to_string())),
"Postgres Debezium payload (v6) synthesizes _change_operation_type/d"
);
let aws_v9 = HashMap::from([
("hoodie.table.version".to_string(), "9".to_string()),
(
"hoodie.compaction.payload.class".to_string(),
"org.apache.hudi.common.model.AWSDmsAvroPayload".to_string(),
),
]);
assert_eq!(
DeleteContext::new(&aws_v9, &schema()).custom_delete_marker,
None,
"v9+ does not synthesize; the marker is persisted as a merge property"
);
}
}