use std::collections::BTreeMap;
use serde::ser::SerializeMap;
use serde::{Deserialize, Serialize};
use crate::domain::other_ids::OtherIds;
#[derive(Debug, Serialize, strum_macros::AsRefStr)]
pub enum RsIdsError {
InvalidId(),
NotAMediaId(String),
NoMediaIdRequired(Box<RsIds>),
}
impl core::fmt::Display for RsIdsError {
fn fmt(&self, fmt: &mut core::fmt::Formatter) -> core::result::Result<(), core::fmt::Error> {
write!(fmt, "{self:?}")
}
}
impl std::error::Error for RsIdsError {}
pub trait ApplyRsIds {
fn apply_rs_ids(&mut self, ids: &RsIds);
}
const NUMERIC_KEYS: &[&str] = &["trakt", "tmdb", "tvdb", "tvrage", "anilist", "mal"];
const EXTERNAL_PRIORITY: &[&str] = &[
"trakt", "imdb", "tmdb", "tvdb", "isbn13", "oleid", "olwid", "gbvid", "anilist", "mangadex",
"mal", "asin",
];
const KEY_ALIASES: &[(&str, &str)] = &[
("openlibrary_edition_id", "oleid"),
("openlibrary_work_id", "olwid"),
("google_books_volume_id", "gbvid"),
("anilist_manga_id", "anilist"),
("mangadex_manga_uuid", "mangadex"),
("myanimelist_manga_id", "mal"),
("openlibraryeditionid", "oleid"),
("openlibraryworkid", "olwid"),
("googlebooksvolumeid", "gbvid"),
("anilistmangaid", "anilist"),
("mangadexmangauuid", "mangadex"),
("myanimelistmangaid", "mal"),
];
#[derive(Debug, Clone, Default, Eq, PartialEq, Ord, PartialOrd)]
pub struct RsIds(pub BTreeMap<String, String>);
macro_rules! str_accessor {
($name:ident, $key:expr) => {
pub fn $name(&self) -> Option<&str> {
self.get($key)
}
};
}
macro_rules! u64_accessor {
($name:ident, $key:expr) => {
pub fn $name(&self) -> Option<u64> {
self.get_u64($key)
}
};
}
macro_rules! from_str_factory {
($name:ident, $key:expr) => {
pub fn $name(value: String) -> Self {
let mut ids = Self::default();
ids.set($key, value);
ids
}
};
}
macro_rules! from_u64_factory {
($name:ident, $key:expr) => {
pub fn $name(value: u64) -> Self {
let mut ids = Self::default();
ids.set($key, value);
ids
}
};
}
impl RsIds {
fn canonicalize_key(key: &str) -> String {
let lower = key.to_ascii_lowercase();
for &(alias, canonical) in KEY_ALIASES {
if lower == alias {
return canonical.to_string();
}
}
lower
}
pub fn get(&self, key: &str) -> Option<&str> {
let canonical = Self::canonicalize_key(key);
self.0.get(&canonical).map(|s| s.as_str())
}
pub fn get_u64(&self, key: &str) -> Option<u64> {
self.get(key).and_then(|v| {
let (base, _) = Self::split_details(v);
base.parse().ok()
})
}
pub fn get_f64(&self, key: &str) -> Option<f64> {
self.get(key).and_then(|v| {
let (base, _) = Self::split_details(v);
base.parse().ok()
})
}
pub fn set(&mut self, key: &str, value: impl ToString) {
let canonical = Self::canonicalize_key(key);
if canonical.is_empty() {
return;
}
self.0.insert(canonical, value.to_string());
}
pub fn has(&self, key: &str) -> bool {
let canonical = Self::canonicalize_key(key);
self.0.contains_key(&canonical)
}
pub fn remove(&mut self, key: &str) -> Option<String> {
let canonical = Self::canonicalize_key(key);
self.0.remove(&canonical)
}
pub fn iter(&self) -> impl Iterator<Item = (&String, &String)> {
self.0.iter()
}
pub fn len(&self) -> usize {
self.0.len()
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
str_accessor!(imdb, "imdb");
str_accessor!(slug, "slug");
str_accessor!(redseat, "redseat");
str_accessor!(isbn13, "isbn13");
str_accessor!(asin, "asin");
str_accessor!(openlibrary_edition_id, "oleid");
str_accessor!(openlibrary_work_id, "olwid");
str_accessor!(google_books_volume_id, "gbvid");
str_accessor!(mangadex_manga_uuid, "mangadex");
u64_accessor!(trakt, "trakt");
u64_accessor!(tmdb, "tmdb");
u64_accessor!(tvdb, "tvdb");
u64_accessor!(tvrage, "tvrage");
u64_accessor!(anilist_manga_id, "anilist");
u64_accessor!(myanimelist_manga_id, "mal");
from_str_factory!(from_imdb, "imdb");
from_str_factory!(from_redseat, "redseat");
from_u64_factory!(from_trakt, "trakt");
from_u64_factory!(from_tvdb, "tvdb");
from_u64_factory!(from_tmdb, "tmdb");
pub fn try_add(&mut self, value: String) -> Result<(), RsIdsError> {
if !Self::is_id(&value) {
return Err(RsIdsError::NotAMediaId(value));
}
let base = value.split('|').next().ok_or(RsIdsError::InvalidId())?;
let (source_raw, id_value) = base.split_once(':').ok_or(RsIdsError::InvalidId())?;
let canonical_key = Self::canonicalize_key(source_raw);
let value_to_store = if let Some(pipe_start) = value.find('|') {
format!("{}{}", id_value, &value[pipe_start..])
} else {
id_value.to_string()
};
self.0.insert(canonical_key, value_to_store);
Ok(())
}
pub fn is_id(id: &str) -> bool {
let base = id.split('|').next().unwrap_or(id);
base.contains(':') && base.split(':').count() == 2
}
pub fn split_details(value: &str) -> (&str, Vec<(&str, &str)>) {
let mut parts = value.split('|');
let base = parts.next().unwrap_or(value);
let details = parts.filter_map(|part| part.split_once(':')).collect();
(base, details)
}
pub fn find_detail<'a>(&'a self, detail_key: &str) -> Option<&'a str> {
if let Some(v) = self.get(detail_key) {
return Some(v);
}
let lower_key = detail_key.to_ascii_lowercase();
for value in self.0.values() {
let (_, details) = Self::split_details(value);
for (k, _v) in &details {
if k.to_ascii_lowercase() == lower_key {
let (_, details2) = Self::split_details(value);
for (k2, v2) in details2 {
if k2.to_ascii_lowercase() == lower_key {
return Some(v2);
}
}
}
}
}
None
}
pub fn find_detail_f64(&self, detail_key: &str) -> Option<f64> {
self.find_detail(detail_key).and_then(|v| v.parse().ok())
}
pub fn as_string(&self, key: &str) -> Option<String> {
let canonical = Self::canonicalize_key(key);
self.0
.get(&canonical)
.map(|v| format!("{}:{}", canonical, v))
}
pub fn as_best_external(&self) -> Option<String> {
for key in EXTERNAL_PRIORITY {
if let Some(formatted) = self.as_string(key) {
return Some(formatted);
}
}
self.0
.iter()
.find(|(k, _)| {
k.as_str() != "redseat" && !EXTERNAL_PRIORITY.contains(&k.as_str())
})
.map(|(k, v)| format!("{}:{}", k, v))
}
pub fn into_best_external(self) -> Option<String> {
self.as_best_external()
}
pub fn into_best(self) -> Option<String> {
self.as_string("redseat").or_else(|| self.as_best_external())
}
pub fn into_best_external_or_local(self) -> Option<String> {
self.as_best_external()
.or_else(|| self.as_string("redseat"))
}
pub fn as_all_external_ids(&self) -> Vec<String> {
self.0
.iter()
.filter(|(k, _)| k.as_str() != "redseat")
.map(|(k, v)| format!("{}:{}", k, v))
.collect()
}
pub fn as_all_ids(&self) -> Vec<String> {
self.0
.iter()
.map(|(k, v)| format!("{}:{}", k, v))
.collect()
}
pub fn as_all_other_ids(&self) -> OtherIds {
OtherIds(self.as_all_ids())
}
pub fn as_id(&self) -> Result<String, RsIdsError> {
for key in &["imdb", "trakt", "tmdb", "tvdb"] {
if let Some(formatted) = self.as_string(key) {
return Ok(formatted);
}
}
Err(RsIdsError::NoMediaIdRequired(Box::new(self.clone())))
}
pub fn try_tvdb(&self) -> Result<u64, RsIdsError> {
self.tvdb()
.ok_or_else(|| RsIdsError::NoMediaIdRequired(Box::new(self.clone())))
}
pub fn try_tmdb(&self) -> Result<u64, RsIdsError> {
self.tmdb()
.ok_or_else(|| RsIdsError::NoMediaIdRequired(Box::new(self.clone())))
}
pub fn apply_to<T: ApplyRsIds>(&self, target: &mut T) {
target.apply_rs_ids(self);
}
}
impl Serialize for RsIds {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let mut map = serializer.serialize_map(Some(self.0.len()))?;
for (key, value) in &self.0 {
if NUMERIC_KEYS.contains(&key.as_str()) {
if let Ok(num) = value.parse::<u64>() {
map.serialize_entry(key, &num)?;
continue;
}
}
map.serialize_entry(key, value)?;
}
map.end()
}
}
impl<'de> Deserialize<'de> for RsIds {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
deserializer.deserialize_map(RsIdsDeVisitor)
}
}
struct RsIdsDeVisitor;
impl<'de> serde::de::Visitor<'de> for RsIdsDeVisitor {
type Value = RsIds;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("a map of ID key-value pairs")
}
fn visit_map<M>(self, mut map: M) -> Result<RsIds, M::Error>
where
M: serde::de::MapAccess<'de>,
{
let mut ids = RsIds::default();
while let Some(key) = map.next_key::<String>()? {
let canonical = RsIds::canonicalize_key(&key);
if canonical == "other_ids" || canonical == "otherids" {
if let Ok(entries) = map.next_value::<Vec<String>>() {
for entry in entries {
let _ = ids.try_add(entry);
}
}
continue;
}
let value: serde_json::Value = map.next_value()?;
let string_value = match value {
serde_json::Value::String(s) => s,
serde_json::Value::Number(n) => n.to_string(),
serde_json::Value::Bool(b) => b.to_string(),
serde_json::Value::Null => continue,
_ => continue,
};
ids.0.insert(canonical, string_value);
}
Ok(ids)
}
}
impl TryFrom<Vec<String>> for RsIds {
type Error = RsIdsError;
fn try_from(values: Vec<String>) -> Result<Self, RsIdsError> {
let mut ids = Self::default();
for value in values {
ids.try_add(value)?;
}
Ok(ids)
}
}
impl TryFrom<OtherIds> for RsIds {
type Error = RsIdsError;
fn try_from(value: OtherIds) -> Result<Self, RsIdsError> {
Self::try_from(value.into_vec())
}
}
impl TryFrom<String> for RsIds {
type Error = RsIdsError;
fn try_from(value: String) -> Result<Self, RsIdsError> {
let mut ids = RsIds::default();
ids.try_add(value)?;
Ok(ids)
}
}
impl From<RsIds> for Vec<String> {
fn from(value: RsIds) -> Self {
value.as_all_ids()
}
}
#[cfg(feature = "rusqlite")]
pub mod external_images_rusqlite {
use rusqlite::{
types::{FromSql, FromSqlError, FromSqlResult, ToSqlOutput, ValueRef},
ToSql,
};
use super::RsIds;
impl FromSql for RsIds {
fn column_result(value: ValueRef) -> FromSqlResult<Self> {
String::column_result(value).and_then(|as_string| {
serde_json::from_str(&as_string).map_err(|_| FromSqlError::InvalidType)
})
}
}
impl ToSql for RsIds {
fn to_sql(&self) -> rusqlite::Result<rusqlite::types::ToSqlOutput<'_>> {
let r = serde_json::to_string(self).map_err(|_| FromSqlError::InvalidType)?;
Ok(ToSqlOutput::from(r))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_existing_movie_show_ids_regression() -> Result<(), RsIdsError> {
let parsed: RsIds = "trakt:905982".to_string().try_into()?;
assert_eq!(parsed.trakt(), Some(905982));
let parsed: RsIds = "imdb:tt1234567".to_string().try_into()?;
assert_eq!(parsed.imdb(), Some("tt1234567"));
let parsed: RsIds = "tmdb:42".to_string().try_into()?;
assert_eq!(parsed.tmdb(), Some(42));
let parsed: RsIds = "tvdb:99".to_string().try_into()?;
assert_eq!(parsed.tvdb(), Some(99));
assert_eq!(parsed.as_best_external(), Some("tvdb:99".to_string()));
assert_eq!(parsed.as_id()?, "tvdb:99");
Ok(())
}
#[test]
fn test_parse_short_prefixes() -> Result<(), RsIdsError> {
let mut ids = RsIds::default();
ids.try_add("isbn13:9780143127741".to_string())?;
ids.try_add("oleid:OL12345M".to_string())?;
ids.try_add("olwid:OL6789W".to_string())?;
ids.try_add("gbvid:abcDEF_123".to_string())?;
ids.try_add("anilist:123".to_string())?;
ids.try_add("mangadex:7f2f8cdd-b241-4f27-a6fe-13f7f7fb9164".to_string())?;
ids.try_add("mal:456".to_string())?;
ids.try_add("asin:B08XYZ1234".to_string())?;
assert_eq!(ids.isbn13(), Some("9780143127741"));
assert_eq!(ids.openlibrary_edition_id(), Some("OL12345M"));
assert_eq!(ids.openlibrary_work_id(), Some("OL6789W"));
assert_eq!(ids.google_books_volume_id(), Some("abcDEF_123"));
assert_eq!(ids.anilist_manga_id(), Some(123));
assert_eq!(
ids.mangadex_manga_uuid(),
Some("7f2f8cdd-b241-4f27-a6fe-13f7f7fb9164")
);
assert_eq!(ids.myanimelist_manga_id(), Some(456));
assert_eq!(ids.asin(), Some("B08XYZ1234"));
Ok(())
}
#[test]
fn test_parse_pipe_details_generic() -> Result<(), RsIdsError> {
let mut ids = RsIds::default();
ids.try_add("anilist:123|volume:1|chapter:2.5".to_string())?;
assert_eq!(ids.anilist_manga_id(), Some(123));
assert_eq!(ids.find_detail_f64("volume"), Some(1.0));
assert_eq!(ids.find_detail_f64("chapter"), Some(2.5));
ids.try_add("custom:abc|extra:42".to_string())?;
assert_eq!(ids.get("custom"), Some("abc|extra:42"));
let (base, details) = RsIds::split_details(ids.get("custom").unwrap());
assert_eq!(base, "abc");
assert_eq!(details, vec![("extra", "42")]);
Ok(())
}
#[test]
fn test_parse_long_aliases() -> Result<(), RsIdsError> {
let mut ids = RsIds::default();
ids.try_add("openlibrary_edition_id:OL1M".to_string())?;
ids.try_add("openlibrary_work_id:OL2W".to_string())?;
ids.try_add("google_books_volume_id:vol123".to_string())?;
ids.try_add("anilist_manga_id:111".to_string())?;
ids.try_add("mangadex_manga_uuid:uuid-1".to_string())?;
ids.try_add("myanimelist_manga_id:222".to_string())?;
assert_eq!(ids.openlibrary_edition_id(), Some("OL1M"));
assert_eq!(ids.openlibrary_work_id(), Some("OL2W"));
assert_eq!(ids.google_books_volume_id(), Some("vol123"));
assert_eq!(ids.anilist_manga_id(), Some(111));
assert_eq!(ids.mangadex_manga_uuid(), Some("uuid-1"));
assert_eq!(ids.myanimelist_manga_id(), Some(222));
Ok(())
}
#[test]
fn test_case_insensitive_parsing() -> Result<(), RsIdsError> {
let mut ids = RsIds::default();
ids.try_add("AnIlIsT:55".to_string())?;
ids.try_add("MAL:77".to_string())?;
ids.try_add("OLEID:OLX".to_string())?;
ids.try_add("GBVID:gbx".to_string())?;
assert_eq!(ids.anilist_manga_id(), Some(55));
assert_eq!(ids.myanimelist_manga_id(), Some(77));
assert_eq!(ids.openlibrary_edition_id(), Some("OLX"));
assert_eq!(ids.google_books_volume_id(), Some("gbx"));
Ok(())
}
#[test]
fn test_unknown_source_stored_in_map() -> Result<(), RsIdsError> {
let mut ids = RsIds::default();
ids.try_add("AniDb:1234".to_string())?;
assert!(ids.has("anidb"));
assert_eq!(ids.get("ANIDB"), Some("1234"));
Ok(())
}
#[test]
fn test_set_replaces_existing_key_value() {
let mut ids = RsIds::default();
ids.set("custom", "first");
ids.set("CUSTOM", "second");
assert_eq!(ids.get("custom"), Some("second"));
}
#[test]
fn test_roundtrip_vec_rsids_vec_uses_canonical_prefixes() -> Result<(), RsIdsError> {
let input = vec![
"openlibrary_edition_id:OL3M".to_string(),
"openlibrary_work_id:OL4W".to_string(),
"google_books_volume_id:vol-3".to_string(),
"anilist_manga_id:999".to_string(),
"mangadex_manga_uuid:uuid-3".to_string(),
"myanimelist_manga_id:1111".to_string(),
"isbn13:9780316769488".to_string(),
"asin:B012345678".to_string(),
];
let ids = RsIds::try_from(input)?;
let output: Vec<String> = ids.into();
assert!(output.contains(&"oleid:OL3M".to_string()));
assert!(output.contains(&"olwid:OL4W".to_string()));
assert!(output.contains(&"gbvid:vol-3".to_string()));
assert!(output.contains(&"anilist:999".to_string()));
assert!(output.contains(&"mangadex:uuid-3".to_string()));
assert!(output.contains(&"mal:1111".to_string()));
assert!(output.contains(&"isbn13:9780316769488".to_string()));
assert!(output.contains(&"asin:B012345678".to_string()));
Ok(())
}
#[test]
fn test_roundtrip_vec_rsids_vec_preserves_pipe_details() -> Result<(), RsIdsError> {
let input = vec!["anilist:999|chapter:2|volume:1".to_string()];
let ids = RsIds::try_from(input)?;
let output: Vec<String> = ids.into();
assert!(output.contains(&"anilist:999|chapter:2|volume:1".to_string()));
Ok(())
}
#[test]
fn test_roundtrip_vec_rsids_vec_preserves_unknown_ids() -> Result<(), RsIdsError> {
let input = vec![
"foo:1".to_string(),
"bar:value-2".to_string(),
"imdb:tt1234567".to_string(),
];
let ids = RsIds::try_from(input)?;
let output: Vec<String> = ids.into();
assert!(output.contains(&"foo:1".to_string()));
assert!(output.contains(&"bar:value-2".to_string()));
assert!(output.contains(&"imdb:tt1234567".to_string()));
Ok(())
}
#[test]
fn test_as_all_ids_returns_all_set_ids() {
let mut ids = RsIds::default();
ids.set("redseat", "rs-1");
ids.set("imdb", "tt1234567");
ids.set("custom", "abc");
ids.set("foo", "bar");
let all = ids.as_all_ids();
assert!(all.contains(&"redseat:rs-1".to_string()));
assert!(all.contains(&"imdb:tt1234567".to_string()));
assert!(all.contains(&"custom:abc".to_string()));
assert!(all.contains(&"foo:bar".to_string()));
assert_eq!(all.len(), 4);
}
#[test]
fn test_best_external_selection_for_book_ids_only() {
let mut ids = RsIds::default();
ids.set("isbn13", "9780131103627");
ids.set("oleid", "OL5M");
ids.set("olwid", "OL6W");
ids.set("gbvid", "vol-5");
ids.set("anilist", "12");
ids.set("mangadex", "uuid-5");
ids.set("mal", "34");
ids.set("asin", "B00TEST000");
assert_eq!(
ids.as_best_external(),
Some("isbn13:9780131103627".to_string())
);
let mut ids = RsIds::default();
ids.set("oleid", "OL5M");
ids.set("olwid", "OL6W");
assert_eq!(ids.as_best_external(), Some("oleid:OL5M".to_string()));
let mut ids = RsIds::default();
ids.set("anilist", "12");
ids.set("mangadex", "uuid-5");
ids.set("mal", "34");
ids.set("asin", "B00TEST000");
assert_eq!(ids.as_best_external(), Some("anilist:12".to_string()));
}
#[test]
fn test_try_from_other_ids_to_rsids() -> Result<(), RsIdsError> {
let input = OtherIds(vec![
"imdb:tt1234567".to_string(),
"tmdb:42".to_string(),
"foo:bar".to_string(),
]);
let ids = RsIds::try_from(input)?;
assert_eq!(ids.imdb(), Some("tt1234567"));
assert_eq!(ids.tmdb(), Some(42));
assert_eq!(ids.get("foo"), Some("bar"));
Ok(())
}
#[test]
fn test_serde_json_roundtrip() {
let mut ids = RsIds::default();
ids.set("trakt", "905982");
ids.set("imdb", "tt1234567");
ids.set("custom", "abc");
let json = serde_json::to_string(&ids).unwrap();
let parsed: RsIds = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.trakt(), Some(905982));
assert_eq!(parsed.imdb(), Some("tt1234567"));
assert_eq!(parsed.get("custom"), Some("abc"));
}
#[test]
fn test_serde_deserialize_numeric_values() {
let json = r#"{"trakt": 905982, "imdb": "tt123", "tmdb": 42}"#;
let ids: RsIds = serde_json::from_str(json).unwrap();
assert_eq!(ids.trakt(), Some(905982));
assert_eq!(ids.imdb(), Some("tt123"));
assert_eq!(ids.tmdb(), Some(42));
}
#[test]
fn test_serde_serialize_numeric_keys_as_numbers() {
let mut ids = RsIds::default();
ids.set("trakt", "905982");
ids.set("imdb", "tt123");
let json = serde_json::to_string(&ids).unwrap();
assert!(json.contains("\"trakt\":905982") || json.contains("\"trakt\": 905982"));
assert!(json.contains("\"imdb\":\"tt123\"") || json.contains("\"imdb\": \"tt123\""));
}
#[test]
fn test_serde_deserialize_old_other_ids_format() {
let json = r#"{"imdb": "tt123", "otherIds": ["foo:1", "bar:2"]}"#;
let ids: RsIds = serde_json::from_str(json).unwrap();
assert_eq!(ids.imdb(), Some("tt123"));
assert_eq!(ids.get("foo"), Some("1"));
assert_eq!(ids.get("bar"), Some("2"));
}
#[test]
fn test_serde_deserialize_old_camelcase_keys() {
let json = r#"{"openlibraryEditionId": "OL5M", "anilistMangaId": 123}"#;
let ids: RsIds = serde_json::from_str(json).unwrap();
assert_eq!(ids.openlibrary_edition_id(), Some("OL5M"));
assert_eq!(ids.anilist_manga_id(), Some(123));
}
#[test]
fn test_split_details_no_details() {
let (base, details) = RsIds::split_details("123");
assert_eq!(base, "123");
assert!(details.is_empty());
}
#[test]
fn test_split_details_with_details() {
let (base, details) = RsIds::split_details("123|volume:1|chapter:2.5");
assert_eq!(base, "123");
assert_eq!(details, vec![("volume", "1"), ("chapter", "2.5")]);
}
#[test]
fn test_find_detail_in_pipe_values() {
let mut ids = RsIds::default();
ids.set("anilist", "123|volume:1|chapter:2.5");
assert_eq!(ids.find_detail_f64("volume"), Some(1.0));
assert_eq!(ids.find_detail_f64("chapter"), Some(2.5));
assert_eq!(ids.find_detail("volume"), Some("1"));
}
#[test]
fn test_find_detail_top_level_fallback() {
let mut ids = RsIds::default();
ids.set("volume", "3.0");
assert_eq!(ids.find_detail_f64("volume"), Some(3.0));
}
#[cfg(feature = "rusqlite")]
#[test]
fn test_rusqlite_roundtrip_rsids() -> rusqlite::Result<()> {
use rusqlite::Connection;
let conn = Connection::open_in_memory()?;
conn.execute("CREATE TABLE test_rsids (ids TEXT NOT NULL)", [])?;
let mut ids = RsIds::default();
ids.set("foo", "42");
ids.set("bar", "abc");
conn.execute("INSERT INTO test_rsids (ids) VALUES (?1)", [&ids])?;
let loaded: RsIds =
conn.query_row("SELECT ids FROM test_rsids LIMIT 1", [], |row| row.get(0))?;
assert_eq!(loaded.get("foo"), Some("42"));
assert_eq!(loaded.get("bar"), Some("abc"));
Ok(())
}
}