Skip to main content

sofie_db/
codes.rs

1use sofie_core::{Cast, Error, Result, SqlType, SqlValue};
2use sofie_values::{LATIN_COMBINING_ACCENTS, is_slug_character};
3use unicode_normalization::UnicodeNormalization as _;
4
5use crate::behavior::stored;
6use crate::{
7    Aggregate, Behavior, Column, Dialect, Field, Plan, Query, Read, Source, Step, Uses, Write,
8};
9
10const SLUG_LENGTH: usize = 100;
11/// Crockford's base 32: no I, L, O or U, so a code read aloud is not misread.
12const CODE_ALPHABET: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
13const CODE_LENGTH: usize = 10;
14
15pub struct Slugged {
16    pub from: &'static str,
17}
18
19impl Behavior for Slugged {
20    fn columns(&self) -> &'static [Field] {
21        const { &[Field::new("slug", String::COLUMNS, false)] }
22    }
23
24    /// An unchanged `from` keeps its slug, so a later transliteration does not move the record.
25    fn written(&self, write: &Write<'_>, values: &mut [SqlValue]) {
26        let Some(SqlValue::Text(text)) = write.value(self.from) else {
27            return;
28        };
29        let kept = matches!(values[0], SqlValue::Text(_))
30            && write.loaded(self.from) == write.value(self.from);
31        if !kept {
32            values[0] = SqlValue::Text(slug(text));
33        }
34    }
35
36    /// A slug taken now is no longer another record's previous one; a deleted record keeps none.
37    fn planned(&self, write: &Write<'_>, plan: &mut Plan<'_>) -> Result<()> {
38        let index = format!("{}_slug", write.table.name);
39        plan.unique_fields.push(crate::outcome::UniqueField::column(
40            write.table.schema,
41            index,
42            self.from,
43        ));
44        let table = record_table(write);
45        if write.deleted() == (false, true) {
46            let params = vec![table, SqlValue::Uuid(write.id)];
47            plan.push(previous_slugs(plan.dialect(), FORGET_RECORD, params));
48            return Ok(());
49        }
50        let Some((previous, current)) = renamed(write) else {
51            return Ok(());
52        };
53        if current.is_empty() {
54            return Err(Error::invalid(
55                self.from,
56                "needs a letter or digit to name it by",
57            ));
58        }
59        let params = vec![table.clone(), SqlValue::Text(current.to_owned())];
60        plan.push(previous_slugs(plan.dialect(), FORGET_SLUG, params));
61        if let Some(previous) = previous {
62            let params = vec![
63                table,
64                SqlValue::Text(previous.to_owned()),
65                SqlValue::Uuid(write.id),
66                SqlValue::Timestamp(write.stamp.now.timestamp()),
67            ];
68            plan.push(previous_slugs(plan.dialect(), KEEP, params));
69        }
70        Ok(())
71    }
72}
73
74const FORGET_RECORD: &str = "DELETE FROM {table} WHERE record_table = {1} AND record = {2}";
75const FORGET_SLUG: &str = "DELETE FROM {table} WHERE record_table = {1} AND slug = {2}";
76const KEEP: &str = "INSERT INTO {table} (record_table, slug, record, changed_at) \
77    VALUES ({1}, {2}, {3}, {4}) ON CONFLICT (record_table, slug) \
78    DO UPDATE SET record = excluded.record, changed_at = excluded.changed_at";
79
80/// The slug the record leaves, if any, and the one it takes.
81fn renamed<'w>(write: &'w Write<'_>) -> Option<(Option<&'w str>, &'w str)> {
82    let Some(SqlValue::Text(current)) = write.value("slug") else {
83        return None;
84    };
85    match write.loaded("slug") {
86        Some(SqlValue::Text(previous)) if previous == current => None,
87        Some(SqlValue::Text(previous)) if !previous.is_empty() => Some((Some(previous), current)),
88        _ => Some((None, current)),
89    }
90}
91
92fn record_table(write: &Write<'_>) -> SqlValue {
93    SqlValue::Text(format!("{}.{}", write.table.schema, write.table.name))
94}
95
96fn previous_slugs(dialect: &dyn Dialect, template: &str, params: Vec<SqlValue>) -> Step {
97    let mut sql = template.replace("{table}", &dialect.qualified("sofie", "previous_slug"));
98    for index in 1..=params.len() {
99        sql = sql.replace(&format!("{{{index}}}"), &dialect.placeholder(index));
100    }
101    Step::statement(sql, params, None)
102}
103
104fn slug(text: &str) -> String {
105    let mut slug = String::new();
106    for word in words(text).split('-').filter(|word| !word.is_empty()) {
107        if slug.len() + word.len() + 1 > SLUG_LENGTH {
108            if slug.is_empty() {
109                slug.push_str(cut(word, SLUG_LENGTH));
110            }
111            break;
112        }
113        if !slug.is_empty() {
114            slug.push('-');
115        }
116        slug.push_str(word);
117    }
118    slug
119}
120
121fn words(text: &str) -> String {
122    let mut words = String::with_capacity(text.len());
123    for character in text.nfc() {
124        if let Some(latin) = latin(character) {
125            words.push_str(latin);
126            continue;
127        }
128        for lowered in character.to_lowercase() {
129            match latin(lowered) {
130                Some(latin) => words.push_str(latin),
131                // A decomposed accent belongs to the Latin letter before it.
132                None if LATIN_COMBINING_ACCENTS.contains(&lowered) => {}
133                None if is_slug_character(lowered) => words.push(lowered),
134                None => words.push('-'),
135            }
136        }
137    }
138    words
139}
140
141fn cut(word: &str, length: usize) -> &str {
142    let mut end = length;
143    while !word.is_char_boundary(end) {
144        end -= 1;
145    }
146    &word[..end]
147}
148
149/// `İ` is matched before lowercasing, which would leave a dot above its `i`.
150fn latin(character: char) -> Option<&'static str> {
151    Some(match character {
152        'à' | 'á' | 'â' | 'ã' | 'ä' | 'å' | 'ā' | 'ă' | 'ą' => "a",
153        'æ' => "ae",
154        'ç' | 'ć' | 'č' => "c",
155        'ď' | 'đ' | 'ð' => "d",
156        'è' | 'é' | 'ê' | 'ë' | 'ē' | 'ę' | 'ě' => "e",
157        'ğ' => "g",
158        'ì' | 'í' | 'î' | 'ï' | 'ī' | 'ı' | 'İ' => "i",
159        'ł' => "l",
160        'ñ' | 'ń' | 'ň' => "n",
161        'ò' | 'ó' | 'ô' | 'õ' | 'ö' | 'ø' | 'ō' | 'ő' => "o",
162        'œ' => "oe",
163        'ř' => "r",
164        'ß' => "ss",
165        'ś' | 'ş' | 'š' | 'ș' => "s",
166        'ț' | 'ţ' | 'ť' => "t",
167        'þ' => "th",
168        'ù' | 'ú' | 'û' | 'ü' | 'ū' | 'ů' | 'ű' => "u",
169        'ý' | 'ÿ' => "y",
170        'ź' | 'ż' | 'ž' => "z",
171        _ => return cyrillic(character),
172    })
173}
174
175fn cyrillic(character: char) -> Option<&'static str> {
176    Some(match character {
177        'а' => "a",
178        'б' => "b",
179        'в' => "v",
180        'г' | 'ґ' => "g",
181        'д' => "d",
182        'е' | 'ё' | 'э' => "e",
183        'є' => "ye",
184        'ж' => "zh",
185        'з' => "z",
186        'и' | 'і' => "i",
187        'ї' => "yi",
188        'й' | 'ы' => "y",
189        'к' => "k",
190        'л' => "l",
191        'м' => "m",
192        'н' => "n",
193        'о' => "o",
194        'п' => "p",
195        'р' => "r",
196        'с' => "s",
197        'т' => "t",
198        'у' => "u",
199        'ф' => "f",
200        'х' => "kh",
201        'ц' => "ts",
202        'ч' => "ch",
203        'ш' => "sh",
204        'щ' => "shch",
205        'ъ' | 'ь' => "",
206        'ю' => "yu",
207        'я' => "ya",
208        _ => return None,
209    })
210}
211
212pub trait Slugs: Aggregate {
213    const SLUG: Column<Self, String> = Column::new("slug");
214
215    fn slug(&self) -> &str {
216        stored_text(self, "slug")
217    }
218}
219
220impl<A: Uses<Slugged>> Slugs for A {}
221
222impl<T: Source> Query<T>
223where
224    T::Base: Slugs,
225{
226    /// A slug that a record this query finds had before is `Error::Moved` to its current one.
227    pub async fn by_slug(self, read: &Read, slug: &str) -> Result<T> {
228        let named = self
229            .clone()
230            .where_(T::Base::SLUG.eq(slug.to_owned()))
231            .first(read)
232            .await?;
233        if let Some(record) = named {
234            return Ok(record);
235        }
236        let Some(current) = current_slug::<T::Base>(read, slug).await? else {
237            return Err(Error::NotFound);
238        };
239        if self
240            .where_(T::Base::SLUG.eq(current.clone()))
241            .exists(read)
242            .await?
243        {
244            return Err(Error::Moved {
245                from: slug.to_owned(),
246                to: current,
247            });
248        }
249        Err(Error::NotFound)
250    }
251}
252
253async fn current_slug<A: Aggregate>(read: &Read, previous: &str) -> Result<Option<String>> {
254    let dialect = read.dialect();
255    let sql = format!(
256        "SELECT named.slug FROM {} AS moved JOIN {} AS named ON named.id = moved.record \
257         WHERE moved.record_table = {} AND moved.slug = {}",
258        dialect.qualified("sofie", "previous_slug"),
259        dialect.qualified(A::MODULE, A::NAME),
260        dialect.placeholder(1),
261        dialect.placeholder(2)
262    );
263    let params = [
264        SqlValue::Text(format!("{}.{}", A::MODULE, A::NAME)),
265        SqlValue::Text(previous.to_owned()),
266    ];
267    let slugs = read
268        .decoded(&sql, &params, 1, vec![Some(SqlType::Text)], String::decode)
269        .await?;
270    Ok(slugs.into_iter().next())
271}
272
273/// From the id's random bits; the migration adds the unique index.
274pub struct UniqueCode;
275
276impl Behavior for UniqueCode {
277    fn columns(&self) -> &'static [Field] {
278        const { &[Field::new("code", String::COLUMNS, false)] }
279    }
280
281    fn written(&self, write: &Write<'_>, values: &mut [SqlValue]) {
282        if values[0] == SqlValue::Null {
283            values[0] = SqlValue::Text(code(write.id.as_bytes()));
284        }
285    }
286}
287
288/// The id's last 50 bits, all random in a `UUIDv7`.
289fn code(id: &[u8; 16]) -> String {
290    let random = u64::from_be_bytes(id[8..].try_into().expect("eight bytes"));
291    (0..CODE_LENGTH)
292        .map(|index| {
293            let digit = (random >> (5 * index)) & 0b1_1111;
294            char::from(CODE_ALPHABET[usize::try_from(digit).expect("a five-bit digit")])
295        })
296        .collect()
297}
298
299pub trait Coded: Aggregate {
300    const CODE: Column<Self, String> = Column::new("code");
301
302    fn code(&self) -> &str {
303        stored_text(self, "code")
304    }
305}
306
307impl<A: Uses<UniqueCode>> Coded for A {}
308
309fn stored_text<'a, A: Aggregate>(aggregate: &'a A, column: &str) -> &'a str {
310    match stored(aggregate, column) {
311        Some(SqlValue::Text(text)) => text,
312        _ => "",
313    }
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319
320    #[test]
321    fn a_slug_joins_lowercase_words_by_hyphens() {
322        assert_eq!(slug("  Blue Wool -- Scarf (2026)!"), "blue-wool-scarf-2026");
323        assert_eq!(slug("a/b_c.d,e:f"), "a-b-c-d-e-f");
324        assert_eq!(slug("!?"), "");
325    }
326
327    #[test]
328    fn a_slug_spells_turkish_german_and_accented_latin_letters_in_ascii() {
329        assert_eq!(slug("Öğle Yemeği"), "ogle-yemegi");
330        assert_eq!(
331            slug("İstanbul'da Çay Şöleni ılık"),
332            "istanbul-da-cay-soleni-ilik"
333        );
334        assert_eq!(slug("Große Straße für Äpfel"), "grosse-strasse-fur-apfel");
335        assert_eq!(
336            slug("Crème brûlée à la française"),
337            "creme-brulee-a-la-francaise"
338        );
339        assert_eq!(slug("Cre\u{300}me"), "creme");
340        assert_eq!(
341            slug("Smørrebrød Æble Œuvre Łódź Đak Ñandú Åre"),
342            "smorrebrod-aeble-oeuvre-lodz-dak-nandu-are"
343        );
344    }
345
346    #[test]
347    fn a_slug_spells_cyrillic_in_latin_and_keeps_other_scripts() {
348        assert_eq!(slug("Привет, мир!"), "privet-mir");
349        assert_eq!(slug("Щедрий вечір, Україно"), "shchedriy-vechir-ukrayino");
350        assert_eq!(slug("关于我们 2026"), "关于我们-2026");
351        assert_eq!(slug("Ελλάδα"), "ελλάδα");
352    }
353
354    #[test]
355    fn a_decomposed_title_slugs_as_its_composed_form() {
356        assert_eq!(slug("Мой край"), slug("Мои\u{306} край"));
357        assert_eq!(slug("Ελλάδα"), slug("Ελλα\u{301}δα"));
358    }
359
360    #[test]
361    fn a_slug_keeps_the_marks_of_other_scripts_in_their_word() {
362        assert_eq!(slug("नमस्ते दुनिया"), "नमस्ते-दुनिया");
363        assert_eq!(slug("ไม่ ใช่"), "ไม่-ใช่");
364        assert_eq!(slug("关于 — 我们 🎉 «x»"), "关于-我们-x");
365    }
366
367    #[test]
368    fn a_slug_stops_before_the_word_that_passes_its_length() {
369        assert_eq!(slug(&"word ".repeat(30)).len(), 99);
370        let long = slug(&"ğüş ".repeat(40));
371        assert!(long.len() <= SLUG_LENGTH && long.ends_with("gus"), "{long}");
372        let chinese = slug(&"关".repeat(40));
373        assert_eq!(chinese, "关".repeat(33));
374    }
375
376    #[test]
377    fn a_code_spells_the_random_bits_of_the_id() {
378        let first = code(&[0; 16]);
379        let other = code(&[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1]);
380
381        assert_eq!(first, "0000000000");
382        assert_eq!(other, "1000000000");
383    }
384}