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