Skip to main content

zer_blocking/keys/
document.rs

1use zer_core::{record::Record, schema::Schema};
2
3use super::BlockingKey;
4use crate::normalize::normalize_digits_only;
5
6// ── DocumentSuffixKey ─────────────────────────────────────────────────────────
7
8/// Blocking key that strips non-alphanumeric characters from a document number
9/// and emits the last `suffix_len` characters as a key.
10///
11/// Useful for matching passport or ID numbers that may be entered with
12/// different prefix conventions or formatting (e.g. "P-NL-AB123456" vs
13/// "AB123456"), while the suffix (serial part) stays stable.
14///
15/// Key format: `"SUFFIX"` (uppercase, alphanumeric only)
16pub struct DocumentSuffixKey {
17    field: String,
18    suffix_len: usize,
19}
20
21impl DocumentSuffixKey {
22    /// `suffix_len = 6` is a reasonable default for European ID numbers.
23    pub fn new(field: &str, suffix_len: usize) -> Self {
24        Self {
25            field: field.into(),
26            suffix_len,
27        }
28    }
29}
30
31impl BlockingKey for DocumentSuffixKey {
32    fn name(&self) -> &str {
33        "document_suffix"
34    }
35
36    fn extract(&self, record: &Record, _schema: &Schema) -> Vec<String> {
37        let cow = record.field_as_str(&self.field);
38        let raw = match cow.as_deref() {
39            Some(s) => s,
40            None => return vec![],
41        };
42        let clean: String = raw
43            .chars()
44            .filter(|c| c.is_ascii_alphanumeric())
45            .collect::<String>()
46            .to_ascii_uppercase();
47        if clean.len() < self.suffix_len {
48            return vec![];
49        }
50        let suffix = &clean[clean.len() - self.suffix_len..];
51        vec![suffix.to_string()]
52    }
53}
54
55// ── DocumentDigitSuffixKey ────────────────────────────────────────────────────
56
57/// Variant that strips ALL non-digit characters before taking the suffix.
58///
59/// Intended for purely numeric document identifiers (BSN, fiscal numbers)
60/// where alphabetic characters are noise or country-code prefixes.
61pub struct DocumentDigitSuffixKey {
62    field: String,
63    suffix_len: usize,
64}
65
66impl DocumentDigitSuffixKey {
67    pub fn new(field: &str, suffix_len: usize) -> Self {
68        Self {
69            field: field.into(),
70            suffix_len,
71        }
72    }
73}
74
75impl BlockingKey for DocumentDigitSuffixKey {
76    fn name(&self) -> &str {
77        "document_digit_suffix"
78    }
79
80    fn extract(&self, record: &Record, _schema: &Schema) -> Vec<String> {
81        let cow = record.field_as_str(&self.field);
82        let raw = match cow.as_deref() {
83            Some(s) => s,
84            None => return vec![],
85        };
86        let digits = normalize_digits_only(raw);
87        if digits.len() < self.suffix_len {
88            return vec![];
89        }
90        let suffix = &digits[digits.len() - self.suffix_len..];
91        vec![suffix.to_string()]
92    }
93}
94
95// ── Tests ─────────────────────────────────────────────────────────────────────
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100    use zer_core::{
101        record::FieldValue,
102        schema::{FieldKind, SchemaBuilder},
103    };
104
105    fn schema() -> Schema {
106        SchemaBuilder::new()
107            .field("document_nummer", FieldKind::Id)
108            .build()
109            .unwrap()
110    }
111
112    fn rec(id: u64, doc: &str) -> Record {
113        Record::new(id).insert("document_nummer", FieldValue::Text(doc.into()))
114    }
115
116    // ── DocumentSuffixKey
117
118    #[test]
119    fn suffix_key_strips_non_alphanum_and_uppercases() {
120        let schema = schema();
121        let key = DocumentSuffixKey::new("document_nummer", 6);
122        let r = rec(1, "P-NL-AB123456");
123        let keys = key.extract(&r, &schema);
124        assert_eq!(keys, vec!["123456"]);
125    }
126
127    #[test]
128    fn suffix_key_same_serial_different_prefix_collide() {
129        let schema = schema();
130        let key = DocumentSuffixKey::new("document_nummer", 6);
131
132        let r1 = rec(1, "P-NL-AB123456");
133        let r2 = rec(2, "AB123456");
134        assert_eq!(key.extract(&r1, &schema), key.extract(&r2, &schema));
135    }
136
137    #[test]
138    fn suffix_key_too_short_returns_empty() {
139        let schema = schema();
140        let key = DocumentSuffixKey::new("document_nummer", 6);
141        let r = rec(1, "AB12"); // only 4 chars after stripping
142        assert!(key.extract(&r, &schema).is_empty());
143    }
144
145    #[test]
146    fn suffix_key_missing_field_returns_empty() {
147        let schema = schema();
148        let key = DocumentSuffixKey::new("document_nummer", 6);
149        assert!(key.extract(&Record::new(1), &schema).is_empty());
150    }
151
152    // ── DocumentDigitSuffixKey
153
154    #[test]
155    fn digit_suffix_strips_all_letters() {
156        let schema = schema();
157        let key = DocumentDigitSuffixKey::new("document_nummer", 4);
158        let r = rec(1, "BSN-12345678");
159        let keys = key.extract(&r, &schema);
160        assert_eq!(keys, vec!["5678"]);
161    }
162
163    #[test]
164    fn digit_suffix_same_number_different_format_collide() {
165        let schema = schema();
166        let key = DocumentDigitSuffixKey::new("document_nummer", 6);
167
168        let r1 = rec(1, "123-45-6789");
169        let r2 = rec(2, "123456789");
170        assert_eq!(key.extract(&r1, &schema), key.extract(&r2, &schema));
171    }
172}