use kael::{Context, SharedString};
use std::collections::HashMap;
use std::future::Future;
use std::time::Duration;
use web_time::Instant;
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub enum Loadable<T> {
#[default]
Idle,
Loading,
Loaded(T),
Error(SharedString),
}
impl<T> Loadable<T> {
pub fn is_loading(&self) -> bool {
matches!(self, Loadable::Loading)
}
pub fn is_idle(&self) -> bool {
matches!(self, Loadable::Idle)
}
pub fn is_error(&self) -> bool {
matches!(self, Loadable::Error(_))
}
pub fn as_loaded(&self) -> Option<&T> {
match self {
Loadable::Loaded(value) => Some(value),
_ => None,
}
}
pub fn as_error(&self) -> Option<&SharedString> {
match self {
Loadable::Error(message) => Some(message),
_ => None,
}
}
pub fn map<U>(self, f: impl FnOnce(T) -> U) -> Loadable<U> {
match self {
Loadable::Idle => Loadable::Idle,
Loadable::Loading => Loadable::Loading,
Loadable::Loaded(value) => Loadable::Loaded(f(value)),
Loadable::Error(message) => Loadable::Error(message),
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
pub struct Generation(u64);
impl Generation {
fn next(self) -> Generation {
Generation(self.0.wrapping_add(1))
}
}
type LastQuery<T> = Box<dyn Fn() -> BoxFetch<T> + 'static>;
type BoxFetch<T> = std::pin::Pin<Box<dyn Future<Output = Result<T, SharedString>> + 'static>>;
pub struct QueryState<T: 'static> {
state: Loadable<T>,
generation: Generation,
debounce: Option<Duration>,
last_query: Option<LastQuery<T>>,
}
impl<T: 'static> Default for QueryState<T> {
fn default() -> Self {
Self::new()
}
}
impl<T: 'static> QueryState<T> {
pub fn new() -> Self {
Self {
state: Loadable::Idle,
generation: Generation::default(),
debounce: None,
last_query: None,
}
}
pub fn debounce(mut self, delay: Duration) -> Self {
self.debounce = Some(delay);
self
}
pub fn state(&self) -> &Loadable<T> {
&self.state
}
pub fn is_loading(&self) -> bool {
self.state.is_loading()
}
pub fn as_loaded(&self) -> Option<&T> {
self.state.as_loaded()
}
pub fn generation(&self) -> Generation {
self.generation
}
pub fn accepts(&self, generation: Generation) -> bool {
self.generation == generation
}
pub fn run<Fut>(&mut self, cx: &mut Context<Self>, fetch: impl Fn() -> Fut + 'static)
where
Fut: Future<Output = Result<T, SharedString>> + 'static,
{
self.last_query = Some(Box::new(move || Box::pin(fetch())));
self.spawn_current(cx);
}
pub fn refetch(&mut self, cx: &mut Context<Self>) {
if self.last_query.is_some() {
self.spawn_current(cx);
}
}
fn spawn_current(&mut self, cx: &mut Context<Self>) {
let Some(query) = self.last_query.as_ref() else {
return;
};
self.generation = self.generation.next();
let generation = self.generation;
self.state = Loadable::Loading;
cx.notify();
let future = query();
let debounce = self.debounce;
cx.spawn(async move |this, cx| {
if let Some(delay) = debounce {
cx.background_executor().timer(delay).await;
}
let outcome = future.await;
_ = this.update(cx, |this, cx| {
if !this.accepts(generation) {
return;
}
this.state = match outcome {
Ok(value) => Loadable::Loaded(value),
Err(message) => Loadable::Error(message),
};
cx.notify();
});
})
.detach();
}
}
struct CacheEntry<T> {
value: T,
inserted_at: Instant,
}
pub struct QueryCache<T> {
ttl: Duration,
entries: HashMap<SharedString, CacheEntry<T>>,
}
impl<T: Clone> QueryCache<T> {
pub fn new(ttl: Duration) -> Self {
Self {
ttl,
entries: HashMap::new(),
}
}
pub fn get(&self, key: &str) -> Option<T> {
self.get_at(key, Instant::now())
}
fn get_at(&self, key: &str, now: Instant) -> Option<T> {
self.entries.get(key).and_then(|entry| {
if now.duration_since(entry.inserted_at) < self.ttl {
Some(entry.value.clone())
} else {
None
}
})
}
pub fn insert(&mut self, key: impl Into<SharedString>, value: T) {
self.insert_at(key, value, Instant::now());
}
fn insert_at(&mut self, key: impl Into<SharedString>, value: T, now: Instant) {
self.entries.insert(
key.into(),
CacheEntry {
value,
inserted_at: now,
},
);
}
pub fn invalidate(&mut self, key: &str) {
self.entries.remove(key);
}
pub fn clear(&mut self) {
self.entries.clear();
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[::core::prelude::v1::test]
fn loadable_helpers_cover_every_state() {
let idle: Loadable<i32> = Loadable::Idle;
assert!(idle.is_idle());
assert!(!idle.is_loading());
assert_eq!(idle.as_loaded(), None);
let loading: Loadable<i32> = Loadable::Loading;
assert!(loading.is_loading());
let loaded = Loadable::Loaded(7);
assert_eq!(loaded.as_loaded(), Some(&7));
assert!(!loaded.is_loading());
let error: Loadable<i32> = Loadable::Error("boom".into());
assert!(error.is_error());
assert_eq!(error.as_error().map(|m| m.to_string()), Some("boom".into()));
}
#[::core::prelude::v1::test]
fn loadable_map_only_transforms_loaded() {
assert_eq!(Loadable::Loaded(2).map(|v| v * 10), Loadable::Loaded(20));
assert_eq!(Loadable::<i32>::Loading.map(|v| v * 10), Loadable::Loading);
assert_eq!(Loadable::<i32>::Idle.map(|v| v * 10), Loadable::Idle);
assert_eq!(
Loadable::<i32>::Error("e".into()).map(|v| v * 10),
Loadable::Error("e".into())
);
}
#[::core::prelude::v1::test]
fn generation_gating_drops_stale_responses() {
let mut state: QueryState<i32> = QueryState::new();
let first = state.generation();
state.generation = state.generation.next();
let second = state.generation();
assert!(!state.accepts(first));
assert!(state.accepts(second));
state.generation = state.generation.next();
assert!(!state.accepts(second));
assert!(state.accepts(state.generation()));
}
#[::core::prelude::v1::test]
fn cache_returns_value_within_ttl_and_expires_after() {
let mut cache: QueryCache<i32> = QueryCache::new(Duration::from_secs(10));
let start = Instant::now();
cache.insert_at("users", 42, start);
assert_eq!(cache.get_at("users", start), Some(42));
assert_eq!(
cache.get_at("users", start + Duration::from_secs(9)),
Some(42)
);
assert_eq!(cache.get_at("users", start + Duration::from_secs(11)), None);
assert_eq!(cache.get_at("missing", start), None);
}
#[::core::prelude::v1::test]
fn cache_invalidate_and_clear() {
let mut cache: QueryCache<i32> = QueryCache::new(Duration::from_secs(60));
cache.insert("a", 1);
cache.insert("b", 2);
assert_eq!(cache.len(), 2);
cache.invalidate("a");
assert_eq!(cache.get("a"), None);
assert_eq!(cache.get("b"), Some(2));
cache.clear();
assert!(cache.is_empty());
}
}