use std::{collections::HashMap, sync::Arc, time::SystemTime};
use wikibase::mediawiki::api::Api;
use crate::wikidata_interaction::WikidataInteraction;
const MAX_CACHE_SIZE_PER_PROPERTY: usize = 10000;
#[derive(Debug, Clone)]
struct WikidataStringValue {
timestamp: SystemTime,
key: Option<String>, }
impl WikidataStringValue {
pub fn new(key: Option<String>) -> Self {
Self { key, timestamp: SystemTime::now() }
}
pub fn key(&mut self) -> Option<String> {
self.update_timestamp();
self.key.to_owned()
}
pub fn timestamp(&self) -> SystemTime {
self.timestamp
}
fn update_timestamp(&mut self) {
self.timestamp = SystemTime::now();
}
}
type WikidataStringHash = HashMap<String, WikidataStringValue>;
#[derive(Debug, Clone)]
pub struct WikidataStringCache {
cache: Arc<tokio::sync::RwLock<HashMap<String, WikidataStringHash>>>,
mw_api: Arc<tokio::sync::RwLock<Api>>,
max_cache_size_per_property: usize,
}
impl WikidataInteraction for WikidataStringCache {}
impl WikidataStringCache {
pub fn new(mw_api: Arc<tokio::sync::RwLock<Api>>) -> Self {
Self {
cache: Arc::new(tokio::sync::RwLock::new(HashMap::new())),
mw_api,
max_cache_size_per_property: MAX_CACHE_SIZE_PER_PROPERTY,
}
}
pub async fn get(&self, property: &str, key: &str) -> Option<String> {
let key = self.fix_key(key);
self.ensure_property(property).await;
let (ret, do_search) = match self.cache.write().await.get_mut(property)?.get_mut(&key) {
Some(ret) => {
ret.update_timestamp();
(ret.key(), false)
},
None => (None, true),
};
if do_search {
self.search(property, &key).await
} else {
ret
}
}
pub async fn set(&self, property: &str, key: &str, q: Option<String>) {
let key = self.fix_key(key);
self.ensure_property(property).await;
self.cache
.write()
.await
.get_mut(property)
.expect("wikidata_string_cache::set: property not found")
.insert(key, WikidataStringValue::new(q));
self.prune_property(property).await;
}
pub async fn issn2q(&self, issn: &str) -> Option<String> {
self.get("P236", issn).await
}
fn fix_key(&self, key: &str) -> String {
key.trim().to_lowercase()
}
async fn property_needs_pruning(&self, property: &str) -> bool {
match self.cache.read().await.get(&property.to_string()) {
Some(hash) => hash.len() >= self.max_cache_size_per_property,
None => false,
}
}
async fn prune_property(&self, property: &str) {
if !self.property_needs_pruning(property).await {
return;
}
let mut cache = self.cache.write().await;
let data = match cache.get_mut(&property.to_string()) {
Some(data) => data,
None => return,
};
tracing::info!(property, "pruning cache property");
let mut times: Vec<SystemTime> = data.values().map(|v| v.timestamp()).collect();
if times.is_empty() {
return;
}
times.sort();
let half_time = times[times.len() / 2];
data.retain(|_k, v| v.timestamp() >= half_time);
tracing::info!(property, remaining = data.len(), "pruned cache property");
}
async fn has_property(&self, property: &str) -> bool {
self.cache.read().await.get(&property.to_string()).is_some()
}
async fn ensure_property(&self, property: &str) {
if !self.has_property(property).await {
self.cache.write().await.insert(property.to_string(), HashMap::new());
}
}
async fn search(&self, property: &str, key: &str) -> Option<String> {
let ret = match self
.search_wikibase(&format!("haswbstatement:{}={}", property, key), self.mw_api.clone())
.await
{
Ok(items) => items.first().map(|s| s.to_string()), Err(_) => None,
};
self.set(property, key, ret.to_owned()).await;
ret
}
}
#[cfg(test)]
mod tests {
use std::{thread, time::Duration};
use wikibase::mediawiki::api::Api;
use wiremock::{
matchers::{method, query_param},
Mock, MockServer, ResponseTemplate,
};
use super::*;
use crate::test_helpers::start_mediawiki_mock_server as start_mock_server;
const SEARCH_Q46664291: &str = include_str!("../test_data/search_found_q46664291.json");
const SEARCH_Q15757256: &str = include_str!("../test_data/search_found_q15757256.json");
const SEARCH_EMPTY: &str = include_str!("../test_data/search_empty.json");
async fn add_search_mock(mock_server: &MockServer, srsearch: &str, body: &'static str) {
Mock::given(method("GET"))
.and(query_param("srsearch", srsearch))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "application/json; charset=utf-8")
.set_body_string(body),
)
.mount(mock_server)
.await;
}
async fn mock_api(mock_server: &MockServer) -> Arc<tokio::sync::RwLock<Api>> {
Arc::new(tokio::sync::RwLock::new(Api::new(&mock_server.uri()).await.unwrap()))
}
#[test]
fn wsv_new() {
assert_eq!(
WikidataStringValue::new(Some("foobar".to_string())).key(),
Some("foobar".to_string())
);
}
#[test]
fn wsv_timestamp() {
let mut wsv = WikidataStringValue::new(Some("foobar".to_string()));
let ts1 = wsv.timestamp();
thread::sleep(Duration::from_millis(100));
let _tmp = wsv.key();
let ts2 = wsv.timestamp();
assert_ne!(ts1, ts2);
}
#[tokio::test]
async fn fix_key() {
let mock_server = start_mock_server().await;
let wsc = WikidataStringCache::new(mock_api(&mock_server).await);
assert_eq!(wsc.fix_key(" fOoBAr "), "foobar".to_string());
}
#[tokio::test]
async fn ensure_property() {
let mock_server = start_mock_server().await;
let wsc = WikidataStringCache::new(mock_api(&mock_server).await);
assert!(!wsc.cache.read().await.contains_key("P123"));
wsc.ensure_property("P123").await;
assert!(wsc.cache.read().await.contains_key("P123"));
}
#[tokio::test]
async fn search() {
let mock_server = start_mock_server().await;
add_search_mock(&mock_server, "haswbstatement:P698=16116339", SEARCH_Q46664291).await;
add_search_mock(&mock_server, "haswbstatement:P698=not_a_valid_id", SEARCH_EMPTY).await;
let wsc = WikidataStringCache::new(mock_api(&mock_server).await);
assert_eq!(wsc.search("P698", "16116339").await, Some("Q46664291".to_string()));
assert_eq!(wsc.search("P698", "not_a_valid_id").await, None);
}
#[tokio::test]
async fn get_set() {
let mock_server = start_mock_server().await;
add_search_mock(&mock_server, "haswbstatement:P698=16116339", SEARCH_Q46664291).await;
add_search_mock(&mock_server, "haswbstatement:P698=not_a_valid_id", SEARCH_EMPTY).await;
let wsc = WikidataStringCache::new(mock_api(&mock_server).await);
assert_eq!(wsc.get("P698", "16116339").await, Some("Q46664291".to_string()));
assert_eq!(wsc.get("P698", "not_a_valid_id").await, None);
wsc.set("P698", "16116339", Some("foobar".to_string())).await;
assert_eq!(wsc.get("P698", "16116339").await, Some("foobar".to_string()));
wsc.set("P698", "16116339", None).await;
assert_eq!(wsc.get("P698", "16116339").await, None);
}
#[tokio::test]
async fn issn2q() {
let mock_server = start_mock_server().await;
add_search_mock(&mock_server, "haswbstatement:P236=1351-5101", SEARCH_Q15757256).await;
add_search_mock(&mock_server, "haswbstatement:P236=nope-di-dope", SEARCH_EMPTY).await;
let wsc = WikidataStringCache::new(mock_api(&mock_server).await);
assert_eq!(wsc.issn2q("1351-5101").await, Some("Q15757256".to_string()));
assert_eq!(wsc.issn2q("nope-di-dope").await, None);
}
#[tokio::test]
async fn prune() {
let mock_server = start_mock_server().await;
let mut wsc = WikidataStringCache::new(mock_api(&mock_server).await);
for num in 1..10 {
wsc.set("P123", &format!("Key #{}", num), Some(format!("Value #{}", num))).await;
tokio::time::sleep(Duration::from_millis(10)).await;
}
wsc.max_cache_size_per_property = 5;
wsc.prune_property("P123").await;
assert_eq!(wsc.cache.read().await.get("P123").unwrap().len(), 5);
}
#[tokio::test]
async fn property_needs_pruning() {
let mock_server = start_mock_server().await;
let mut wsc = WikidataStringCache::new(mock_api(&mock_server).await);
assert!(!wsc.property_needs_pruning("P123").await);
for num in 1..10 {
wsc.set("P123", &format!("Key #{}", num), Some(format!("Value #{}", num))).await;
}
wsc.max_cache_size_per_property = 5;
assert!(wsc.property_needs_pruning("P123").await);
}
#[tokio::test]
async fn prune_empty_property() {
let mock_server = start_mock_server().await;
let mut wsc = WikidataStringCache::new(mock_api(&mock_server).await);
wsc.max_cache_size_per_property = 0; wsc.ensure_property("P999").await;
wsc.prune_property("P999").await;
assert_eq!(wsc.cache.read().await.get("P999").unwrap().len(), 0);
}
#[tokio::test]
async fn has_property() {
let mock_server = start_mock_server().await;
let wsc = WikidataStringCache::new(mock_api(&mock_server).await);
assert!(!wsc.has_property("P123").await);
wsc.ensure_property("P123").await;
assert!(wsc.has_property("P123").await);
}
#[tokio::test]
async fn search_wikibase() {
let mock_server = start_mock_server().await;
add_search_mock(&mock_server, "haswbstatement:P698=16116339", SEARCH_Q46664291).await;
add_search_mock(&mock_server, "haswbstatement:P698=not_a_valid_id", SEARCH_EMPTY).await;
let api = mock_api(&mock_server).await;
let wsc = WikidataStringCache::new(api.clone());
assert_eq!(
wsc.search_wikibase("haswbstatement:P698=16116339", api.clone()).await.unwrap(),
vec!["Q46664291".to_string()]
);
assert_eq!(
wsc.search_wikibase("haswbstatement:P698=not_a_valid_id", api.clone())
.await
.unwrap()
.len(),
0
);
}
}