Skip to main content

musefs_db/
tags.rs

1use crate::error::{check_field_len, check_tag_count};
2use crate::limits::{MAX_TAG_KEY_LEN, MAX_TAG_VALUE_LEN};
3use crate::models::{BinaryTag, BinaryTagRow, Tag};
4use crate::{Db, ReadWrite, Result};
5use rusqlite::params;
6
7/// Reject an over-cap text-tag row from its `length(key)`/`length(value)`
8/// columns *before* the strings are materialized. Routes through the shared
9/// `check_field_len`, so the allocation-free guarantee is the same one its
10/// unit test pins (spec N13).
11fn check_tag_lengths(key_len: i64, value_len: i64) -> Result<()> {
12    check_field_len("tags", "key", key_len, MAX_TAG_KEY_LEN)?;
13    check_field_len("tags", "value", value_len, MAX_TAG_VALUE_LEN)?;
14    Ok(())
15}
16
17impl<M> Db<M> {
18    pub fn get_tags(&self, track_id: i64) -> Result<Vec<Tag>> {
19        let mut stmt = self.conn.prepare(
20            "SELECT length(key), length(value), key, value, ordinal FROM tags \
21             WHERE track_id = ?1 AND value_blob IS NULL ORDER BY key, ordinal",
22        )?;
23        let mut rows = stmt.query(params![track_id])?;
24        let mut out = Vec::new();
25        while let Some(r) = rows.next()? {
26            check_tag_lengths(r.get(0)?, r.get(1)?)?;
27            out.push(Tag {
28                key: r.get(2)?,
29                value: r.get(3)?,
30                ordinal: r.get(4)?,
31            });
32            check_tag_count(track_id, out.len())?;
33        }
34        Ok(out)
35    }
36
37    pub fn tags_for_tracks(
38        &self,
39        track_ids: &[i64],
40    ) -> Result<std::collections::HashMap<i64, Vec<Tag>>> {
41        const CHUNK: usize = 900;
42        let mut out: std::collections::HashMap<i64, Vec<Tag>> = std::collections::HashMap::new();
43        for chunk in track_ids.chunks(CHUNK) {
44            let placeholders = vec!["?"; chunk.len()].join(",");
45            let sql = format!(
46                "SELECT track_id, length(key), length(value), key, value, ordinal FROM tags \
47                 WHERE track_id IN ({placeholders}) AND value_blob IS NULL \
48                 ORDER BY track_id, key, ordinal"
49            );
50            let mut stmt = self.conn.prepare(&sql)?;
51            let params = rusqlite::params_from_iter(chunk.iter());
52            let mut rows = stmt.query(params)?;
53            while let Some(r) = rows.next()? {
54                let track_id: i64 = r.get(0)?;
55                check_tag_lengths(r.get(1)?, r.get(2)?)?;
56                let entry = out.entry(track_id).or_default();
57                entry.push(Tag {
58                    key: r.get(3)?,
59                    value: r.get(4)?,
60                    ordinal: r.get(5)?,
61                });
62                check_tag_count(track_id, entry.len())?;
63            }
64        }
65        Ok(out)
66    }
67
68    pub fn tags_grouped(&self) -> Result<std::collections::HashMap<i64, Vec<Tag>>> {
69        let mut stmt = self.conn.prepare(
70            "SELECT track_id, length(key), length(value), key, value, ordinal FROM tags \
71             WHERE value_blob IS NULL ORDER BY track_id, key, ordinal",
72        )?;
73        let mut rows = stmt.query([])?;
74        let mut out: std::collections::HashMap<i64, Vec<Tag>> = std::collections::HashMap::new();
75        while let Some(r) = rows.next()? {
76            let track_id: i64 = r.get(0)?;
77            check_tag_lengths(r.get(1)?, r.get(2)?)?;
78            let entry = out.entry(track_id).or_default();
79            entry.push(Tag {
80                key: r.get(3)?,
81                value: r.get(4)?,
82                ordinal: r.get(5)?,
83            });
84            check_tag_count(track_id, entry.len())?;
85        }
86        Ok(out)
87    }
88
89    pub fn tags_grouped_for_keys(
90        &self,
91        keys: &[&str],
92    ) -> Result<std::collections::HashMap<i64, Vec<Tag>>> {
93        const CHUNK: usize = 900;
94        let mut out: std::collections::HashMap<i64, Vec<Tag>> = std::collections::HashMap::new();
95        for chunk in keys.chunks(CHUNK) {
96            let lowered: Vec<String> = chunk.iter().map(|k| k.to_ascii_lowercase()).collect();
97            let placeholders = vec!["?"; lowered.len()].join(",");
98            let sql = format!(
99                "SELECT track_id, length(key), length(value), key, value, ordinal FROM tags \
100                 WHERE value_blob IS NULL AND lower(key) IN ({placeholders}) \
101                 ORDER BY track_id, key, ordinal"
102            );
103            let mut stmt = self.conn.prepare(&sql)?;
104            let params = rusqlite::params_from_iter(lowered.iter());
105            let mut rows = stmt.query(params)?;
106            while let Some(r) = rows.next()? {
107                let track_id: i64 = r.get(0)?;
108                check_tag_lengths(r.get(1)?, r.get(2)?)?;
109                let entry = out.entry(track_id).or_default();
110                entry.push(Tag {
111                    key: r.get(3)?,
112                    value: r.get(4)?,
113                    ordinal: r.get(5)?,
114                });
115                check_tag_count(track_id, entry.len())?;
116            }
117        }
118        Ok(out)
119    }
120
121    /// Binary tag rows for a track: streaming handle (rowid), key, and payload
122    /// length. Ordered by (key, ordinal) to match the layout builder's emission
123    /// order. The blob bytes stream at read time; only `key` (materialized here)
124    /// is length-guarded, plus the per-track row count.
125    pub fn get_binary_tags(&self, track_id: i64) -> Result<Vec<BinaryTagRow>> {
126        let mut stmt = self.conn.prepare(
127            "SELECT length(key), rowid, key, length(value_blob) FROM tags \
128             WHERE track_id = ?1 AND value_blob IS NOT NULL ORDER BY key, ordinal",
129        )?;
130        let mut rows = stmt.query(params![track_id])?;
131        let mut out = Vec::new();
132        while let Some(r) = rows.next()? {
133            check_field_len("tags", "key", r.get(0)?, MAX_TAG_KEY_LEN)?;
134            out.push(BinaryTagRow {
135                rowid: r.get(1)?,
136                key: r.get(2)?,
137                byte_len: r.get(3)?,
138            });
139            check_tag_count(track_id, out.len())?;
140        }
141        Ok(out)
142    }
143
144    /// Stream binary-tag bytes at `offset` directly into `buf` via incremental blob
145    /// I/O — no intermediate allocation (#70). A short read means the row changed
146    /// underneath the resolved layout; `read_at_exact` surfaces it as an error rather
147    /// than zero-filling. (`payload_id` is the `tags` rowid; see the spec's
148    /// "payload_id validity invariant".)
149    pub fn read_binary_tag_chunk_into(
150        &self,
151        payload_id: i64,
152        offset: u64,
153        buf: &mut [u8],
154    ) -> Result<()> {
155        let blob = self
156            .conn
157            .blob_open("main", "tags", "value_blob", payload_id, true)?;
158        blob.read_at_exact(buf, crate::convert::usize_from(offset))?;
159        Ok(())
160    }
161
162    /// Allocating convenience form of `read_binary_tag_chunk_into` (non-hot-path
163    /// callers).
164    pub fn read_binary_tag_chunk(
165        &self,
166        payload_id: i64,
167        offset: u64,
168        len: usize,
169    ) -> Result<Vec<u8>> {
170        let mut buf = vec![0u8; len];
171        self.read_binary_tag_chunk_into(payload_id, offset, &mut buf)?;
172        Ok(buf)
173    }
174}
175
176impl Db<ReadWrite> {
177    pub fn replace_tags(&self, track_id: i64, tags: &[Tag]) -> Result<()> {
178        let tx = self.conn.unchecked_transaction()?;
179        tx.execute(
180            "DELETE FROM tags WHERE track_id = ?1 AND value_blob IS NULL",
181            params![track_id],
182        )?;
183        {
184            let mut stmt = tx.prepare(
185                "INSERT INTO tags (track_id, key, value, ordinal) VALUES (?1, ?2, ?3, ?4)",
186            )?;
187            for t in tags {
188                stmt.execute(params![track_id, t.key, t.value, t.ordinal])?;
189            }
190        }
191        tx.commit()?;
192        Ok(())
193    }
194
195    /// Replace the track's binary tag rows (value_blob IS NOT NULL); text rows
196    /// (managed by `replace_tags`) are untouched. Binary rows store '' in `value`.
197    pub fn set_binary_tags(&self, track_id: i64, tags: &[BinaryTag]) -> Result<()> {
198        let tx = self.conn.unchecked_transaction()?;
199        tx.execute(
200            "DELETE FROM tags WHERE track_id = ?1 AND value_blob IS NOT NULL",
201            params![track_id],
202        )?;
203        {
204            let mut stmt = tx.prepare(
205                "INSERT INTO tags (track_id, key, value, value_blob, ordinal) \
206                 VALUES (?1, ?2, '', ?3, ?4)",
207            )?;
208            for t in tags {
209                stmt.execute(params![track_id, t.key, t.payload, t.ordinal])?;
210            }
211        }
212        tx.commit()?;
213        Ok(())
214    }
215}
216
217#[cfg(test)]
218mod tags_for_tracks_tests {
219    use super::*;
220    use crate::{Format, NewTrack, Tag};
221
222    fn open_mem() -> Db {
223        Db::open_in_memory().unwrap()
224    }
225    fn new_track(path: &str) -> NewTrack {
226        NewTrack {
227            backing_path: path.into(),
228            format: Format::Flac,
229            audio_offset: 0,
230            audio_length: 1,
231            backing_size: 1,
232            backing_mtime_ns: 0,
233            backing_ctime_ns: 0,
234        }
235    }
236
237    #[test]
238    fn tags_for_tracks_returns_only_requested_ordered_by_key_ordinal() {
239        let db = open_mem();
240        let a = db.upsert_track(&new_track("/a.flac")).unwrap();
241        let b = db.upsert_track(&new_track("/b.flac")).unwrap();
242        let c = db.upsert_track(&new_track("/c.flac")).unwrap();
243        db.replace_tags(
244            a,
245            &[
246                Tag::new("ARTIST", "second", 1),
247                Tag::new("ARTIST", "first", 0),
248            ],
249        )
250        .unwrap();
251        db.replace_tags(b, &[Tag::new("ARTIST", "bee", 0)]).unwrap();
252        db.replace_tags(c, &[Tag::new("ARTIST", "cee", 0)]).unwrap();
253
254        let got = db.tags_for_tracks(&[a, b]).unwrap();
255        assert_eq!(got.len(), 2, "c was not requested");
256        assert!(!got.contains_key(&c));
257        let a_tags = &got[&a];
258        assert_eq!(a_tags[0].value, "first");
259        assert_eq!(a_tags[1].value, "second");
260    }
261
262    #[test]
263    fn tags_for_tracks_chunks_beyond_sqlite_variable_limit() {
264        let db = open_mem();
265        let mut ids = Vec::new();
266        for i in 0..1500 {
267            let id = db.upsert_track(&new_track(&format!("/t{i}.flac"))).unwrap();
268            db.replace_tags(id, &[Tag::new("TITLE", &format!("t{i}"), 0)])
269                .unwrap();
270            ids.push(id);
271        }
272        let got = db.tags_for_tracks(&ids).unwrap();
273        assert_eq!(got.len(), 1500, "all chunks fetched");
274    }
275
276    #[test]
277    fn tags_for_tracks_empty_input_is_empty_map() {
278        let db = open_mem();
279        assert!(db.tags_for_tracks(&[]).unwrap().is_empty());
280    }
281
282    #[test]
283    fn text_queries_exclude_binary_rows() {
284        let db = open_mem();
285        let a = db.upsert_track(&new_track("/a.flac")).unwrap();
286        db.replace_tags(a, &[Tag::new("artist", "Alice", 0)])
287            .unwrap();
288        db.conn
289            .execute(
290                "INSERT INTO tags (track_id, key, value, value_blob, ordinal) \
291                 VALUES (?1, 'PRIV', '', X'DEADBEEF', 0)",
292                rusqlite::params![a],
293            )
294            .unwrap();
295
296        let got = db.get_tags(a).unwrap();
297        assert_eq!(got, vec![Tag::new("artist", "Alice", 0)]);
298        let grouped = db.tags_grouped().unwrap();
299        assert_eq!(grouped[&a], vec![Tag::new("artist", "Alice", 0)]);
300        let for_tracks = db.tags_for_tracks(&[a]).unwrap();
301        assert_eq!(for_tracks[&a], vec![Tag::new("artist", "Alice", 0)]);
302    }
303
304    #[test]
305    fn binary_tags_round_trip_and_are_independent_of_text() {
306        let db = open_mem();
307        let a = db.upsert_track(&new_track("/a.flac")).unwrap();
308        db.replace_tags(a, &[Tag::new("artist", "Alice", 0)])
309            .unwrap();
310        db.set_binary_tags(
311            a,
312            &[
313                crate::BinaryTag {
314                    key: "PRIV".into(),
315                    payload: vec![1, 2, 3],
316                    ordinal: 0,
317                },
318                crate::BinaryTag {
319                    key: "PRIV".into(),
320                    payload: vec![9, 9],
321                    ordinal: 1,
322                },
323                crate::BinaryTag {
324                    key: "GEOB".into(),
325                    payload: vec![7],
326                    ordinal: 0,
327                },
328            ],
329        )
330        .unwrap();
331
332        assert_eq!(
333            db.get_tags(a).unwrap(),
334            vec![Tag::new("artist", "Alice", 0)]
335        );
336
337        let rows = db.get_binary_tags(a).unwrap();
338        assert_eq!(rows.len(), 3);
339        assert_eq!(rows[0].key, "GEOB");
340        assert_eq!(rows[0].byte_len, 1);
341        assert_eq!(rows[1].key, "PRIV");
342        assert_eq!(rows[1].byte_len, 3);
343        assert_eq!(rows[2].byte_len, 2);
344
345        let full = db.read_binary_tag_chunk(rows[1].rowid, 0, 3).unwrap();
346        assert_eq!(full, vec![1, 2, 3]);
347        let mid = db.read_binary_tag_chunk(rows[1].rowid, 1, 2).unwrap();
348        assert_eq!(mid, vec![2, 3]);
349
350        db.set_binary_tags(a, &[]).unwrap();
351        assert!(db.get_binary_tags(a).unwrap().is_empty());
352        assert_eq!(
353            db.get_tags(a).unwrap(),
354            vec![Tag::new("artist", "Alice", 0)]
355        );
356    }
357
358    #[test]
359    fn tags_grouped_for_keys_filters_case_insensitively() {
360        let db = open_mem();
361        let a = db.upsert_track(&new_track("/a.flac")).unwrap();
362        db.replace_tags(
363            a,
364            &[
365                Tag::new("ARTIST", "Pix", 0),
366                Tag::new("Title", "Song", 0),
367                Tag::new("LYRICS", "la la", 0),
368            ],
369        )
370        .unwrap();
371        let got = db.tags_grouped_for_keys(&["artist", "title"]).unwrap();
372        let tags = &got[&a];
373        assert!(tags.iter().any(|t| t.value == "Pix"), "ARTIST matched");
374        assert!(tags.iter().any(|t| t.value == "Song"), "Title matched");
375        assert!(!tags.iter().any(|t| t.value == "la la"), "LYRICS excluded");
376    }
377
378    #[test]
379    fn get_tags_rejects_oversize_value() {
380        let db = open_mem();
381        let a = db.upsert_track(&new_track("/a.flac")).unwrap();
382        db.conn
383            .execute_batch("PRAGMA ignore_check_constraints=ON")
384            .unwrap();
385        let big = "v".repeat(262_145);
386        db.conn
387            .execute(
388                "INSERT INTO tags (track_id, key, value, ordinal) VALUES (?1, 'k', ?2, 0)",
389                rusqlite::params![a, big],
390            )
391            .unwrap();
392        let err = db.get_tags(a).unwrap_err();
393        assert!(
394            matches!(err, crate::DbError::FieldTooLarge { field: "value", .. }),
395            "{err:?}"
396        );
397    }
398
399    #[test]
400    fn get_tags_accepts_value_at_cap() {
401        let db = open_mem();
402        let a = db.upsert_track(&new_track("/a.flac")).unwrap();
403        let at = "v".repeat(262_144);
404        db.conn
405            .execute(
406                "INSERT INTO tags (track_id, key, value, ordinal) VALUES (?1, 'k', ?2, 0)",
407                rusqlite::params![a, at],
408            )
409            .unwrap();
410        assert_eq!(db.get_tags(a).unwrap()[0].value.len(), 262_144);
411    }
412
413    #[test]
414    fn get_binary_tags_rejects_oversize_key() {
415        let db = open_mem();
416        let a = db.upsert_track(&new_track("/a.flac")).unwrap();
417        db.conn
418            .execute_batch("PRAGMA ignore_check_constraints=ON")
419            .unwrap();
420        let key = "k".repeat(257);
421        db.conn
422            .execute(
423                "INSERT INTO tags (track_id, key, value, value_blob, ordinal) VALUES (?1, ?2, '', X'00', 0)",
424                rusqlite::params![a, key],
425            )
426            .unwrap();
427        let err = db.get_binary_tags(a).unwrap_err();
428        assert!(
429            matches!(
430                err,
431                crate::DbError::FieldTooLarge {
432                    table: "tags",
433                    field: "key",
434                    ..
435                }
436            ),
437            "{err:?}"
438        );
439    }
440
441    #[test]
442    fn per_track_count_cap_text_and_binary() {
443        let db = open_mem();
444        let a = db.upsert_track(&new_track("/a.flac")).unwrap();
445        // 4097 text rows -> TooManyValues on get_tags.
446        {
447            let tx = db.conn.unchecked_transaction().unwrap();
448            let mut stmt = tx
449                .prepare(
450                    "INSERT INTO tags (track_id, key, value, ordinal) VALUES (?1, 'k', 'v', ?2)",
451                )
452                .unwrap();
453            for i in 0..4097 {
454                stmt.execute(rusqlite::params![a, i]).unwrap();
455            }
456            drop(stmt);
457            tx.commit().unwrap();
458        }
459        let err = db.get_tags(a).unwrap_err();
460        assert!(
461            matches!(err, crate::DbError::TooManyValues { .. }),
462            "{err:?}"
463        );
464    }
465
466    #[test]
467    fn bulk_reader_rejects_one_oversized_track_in_batch() {
468        let db = open_mem();
469        let a = db.upsert_track(&new_track("/a.flac")).unwrap();
470        let b = db.upsert_track(&new_track("/b.flac")).unwrap();
471        db.replace_tags(b, &[Tag::new("ok", "fine", 0)]).unwrap();
472        db.conn
473            .execute_batch("PRAGMA ignore_check_constraints=ON")
474            .unwrap();
475        let big = "v".repeat(262_145);
476        db.conn
477            .execute(
478                "INSERT INTO tags (track_id, key, value, ordinal) VALUES (?1, 'k', ?2, 0)",
479                rusqlite::params![a, big],
480            )
481            .unwrap();
482        let err = db.tags_for_tracks(&[a, b]).unwrap_err();
483        assert!(
484            matches!(err, crate::DbError::FieldTooLarge { field: "value", .. }),
485            "{err:?}"
486        );
487    }
488
489    #[test]
490    fn tags_grouped_for_keys_empty_keys_is_empty_map() {
491        let db = open_mem();
492        let a = db.upsert_track(&new_track("/a.flac")).unwrap();
493        db.replace_tags(a, &[Tag::new("ARTIST", "Pix", 0)]).unwrap();
494        let got = db.tags_grouped_for_keys(&[]).unwrap();
495        assert!(got.is_empty());
496    }
497
498    #[test]
499    fn replace_tags_rejects_floor_violating_keys() {
500        let db = open_mem();
501        let t = db.upsert_track(&new_track("/a.flac")).unwrap();
502        // A row violating the floor aborts the whole row-by-row transactional insert.
503        assert!(db.replace_tags(t, &[Tag::new("", "v", 0)]).is_err());
504        assert!(db.replace_tags(t, &[Tag::new("\u{7}", "v", 0)]).is_err());
505        // '=' passes the DB floor (only the Vorbis path bars it).
506        db.replace_tags(t, &[Tag::new("a=b", "c", 0)]).unwrap();
507        let got = db.get_tags(t).unwrap();
508        assert_eq!(got.len(), 1);
509        assert_eq!(got[0].key, "a=b");
510    }
511
512    #[test]
513    fn replace_tags_rolls_back_a_mixed_valid_invalid_batch() {
514        let db = open_mem();
515        let t = db.upsert_track(&new_track("/a.flac")).unwrap();
516        db.replace_tags(t, &[Tag::new("artist", "Alice", 0)])
517            .unwrap();
518        // replace_tags DELETEs the existing text rows before re-inserting; a CHECK
519        // violation later in the batch must roll the whole transaction back —
520        // including the DELETE — so the original rows survive rather than the batch
521        // half-applying.
522        assert!(
523            db.replace_tags(t, &[Tag::new("title", "ok", 0), Tag::new("", "bad", 0)])
524                .is_err()
525        );
526        let got = db.get_tags(t).unwrap();
527        assert_eq!(got.len(), 1);
528        assert_eq!(got[0].key, "artist");
529        assert_eq!(got[0].value, "Alice");
530    }
531}