Skip to main content

zer_blocking/keys/
token.rs

1use zer_core::{record::Record, schema::Schema};
2
3use super::BlockingKey;
4use crate::normalize::normalize_text;
5
6/// Blocks on: (first token of address field) + ":" + (first char of first-name field).
7/// Handles surname transpositions, two records at the same address with the same initial
8/// should end up in the same bucket even if the surname differs.
9pub struct AddressInitialKey {
10    address_field: String,
11    first_name_field: String,
12}
13
14impl AddressInitialKey {
15    pub fn new(address_field: &str, first_name_field: &str) -> Self {
16        Self {
17            address_field: address_field.into(),
18            first_name_field: first_name_field.into(),
19        }
20    }
21}
22
23impl BlockingKey for AddressInitialKey {
24    fn name(&self) -> &str {
25        "addr_initial"
26    }
27
28    fn extract(&self, record: &Record, _schema: &Schema) -> Vec<String> {
29        let addr_cow = record.field_as_str(&self.address_field);
30        let addr_raw = match addr_cow.as_deref() {
31            Some(s) => s,
32            None => return vec![],
33        };
34        let name_cow = record.field_as_str(&self.first_name_field);
35        let name_raw = match name_cow.as_deref() {
36            Some(s) => s,
37            None => return vec![],
38        };
39
40        let addr_norm = normalize_text(addr_raw);
41        let name_norm = normalize_text(name_raw);
42
43        let addr_token = addr_norm
44            .split_whitespace()
45            .next()
46            .unwrap_or("")
47            .to_string();
48        let initial = name_norm.chars().next().unwrap_or(' ');
49
50        if addr_token.is_empty() || !initial.is_ascii_alphabetic() {
51            return vec![];
52        }
53
54        vec![format!("{}:{}", addr_token, initial)]
55    }
56}
57
58#[cfg(test)]
59mod tests {
60    use super::*;
61    use zer_core::{
62        record::FieldValue,
63        schema::{FieldKind, SchemaBuilder},
64    };
65
66    fn schema() -> Schema {
67        SchemaBuilder::new()
68            .field("address", FieldKind::Address)
69            .field("first_name", FieldKind::Name)
70            .build()
71            .unwrap()
72    }
73
74    #[test]
75    fn extracts_first_token_and_initial() {
76        let k = AddressInitialKey::new("address", "first_name");
77        let r = Record::new(1)
78            .insert("address", FieldValue::Text("123 Main Street".into()))
79            .insert("first_name", FieldValue::Text("John".into()));
80        assert_eq!(k.extract(&r, &schema()), vec!["123:J"]);
81    }
82
83    #[test]
84    fn same_address_different_first_name_no_collision() {
85        let k = AddressInitialKey::new("address", "first_name");
86        let s = schema();
87        let r1 = Record::new(1)
88            .insert("address", FieldValue::Text("Singel 191".into()))
89            .insert("first_name", FieldValue::Text("Alice".into()));
90        let r2 = Record::new(2)
91            .insert("address", FieldValue::Text("Singel 191".into()))
92            .insert("first_name", FieldValue::Text("Bob".into()));
93        assert_ne!(k.extract(&r1, &s), k.extract(&r2, &s));
94    }
95
96    #[test]
97    fn missing_field_returns_empty() {
98        let k = AddressInitialKey::new("address", "first_name");
99        let r = Record::new(1).insert("address", FieldValue::Text("Singel 191".into()));
100        assert!(k.extract(&r, &schema()).is_empty());
101    }
102}