use std::collections::HashMap;
use std::sync::{PoisonError, RwLock, RwLockReadGuard, RwLockWriteGuard};
use async_trait::async_trait;
use business::domain::{{ snake }}::{
model::{{ "{" }}{{ pascal }}, {{ pascal }}Id},
repository::{{ pascal }}RepositoryTrait,
};
use puerto_core::{Page, PageRequest, RepositoryError};
/// Keeps the {{ plural }} it is given in process memory: they are lost when the process stops.
/// Like the Postgres adapter, it hides deleted rows and lists newest first{% set uniques = fields | filter(attribute="unique", value=true) %}{% if uniques %};
/// a unique value another live row holds is `Duplicate`{% endif %}.
/// It stands in until there is a database (`puerto new --db` generates Postgres repositories).
#[derive(Default)]
pub struct InMemory{{ pascal }}Repository {
rows: RwLock<HashMap<{{ pascal }}Id, {{ pascal }}>>,
}
impl InMemory{{ pascal }}Repository {
pub fn new() -> Self {
Self::default()
}
// A poisoned lock means a writer panicked; every write is a single insert, so the map is
// still whole and is used as it is.
fn read(&self) -> RwLockReadGuard<'_, HashMap<{{ pascal }}Id, {{ pascal }}>> {
self.rows.read().unwrap_or_else(PoisonError::into_inner)
}
fn write(&self) -> RwLockWriteGuard<'_, HashMap<{{ pascal }}Id, {{ pascal }}>> {
self.rows.write().unwrap_or_else(PoisonError::into_inner)
}
}
#[async_trait]
impl {{ pascal }}RepositoryTrait for InMemory{{ pascal }}Repository {
async fn find_all(&self, page: PageRequest) -> Result<Page<{{ pascal }}>, RepositoryError> {
tracing::debug!(limit = page.limit(), offset = page.offset(), "find_all");
let rows = self.read();
let mut live: Vec<&{{ pascal }}> = rows.values().filter(|row| !row.deleted).collect();
live.sort_by(|a, b| {
b.created_at
.cmp(&a.created_at)
.then_with(|| a.id.value().cmp(&b.id.value()))
});
let total = live.len() as u64;
let items = live
.into_iter()
.skip(page.offset() as usize)
.take(page.limit() as usize)
.cloned()
.collect();
Ok(Page::new(items, total, page))
}
async fn find_by_id(&self, id: {{ pascal }}Id) -> Result<Option<{{ pascal }}>, RepositoryError> {
tracing::debug!(%id, "find_by_id");
Ok(self.read().get(&id).filter(|row| !row.deleted).cloned())
}
async fn save(&self, entity: &{{ pascal }}) -> Result<(), RepositoryError> {
tracing::debug!(id = %entity.id, "save");
let mut rows = self.write();
{% if uniques %} // Like the partial unique indexes: only live rows hold their values.
let taken = !entity.deleted
&& rows.values().any(|row| {
row.id != entity.id
&& !row.deleted
&& {% if uniques | length > 1 %}({% endif %}{% for u in uniques %}{% if not loop.first %} || {% endif %}row.{{ u.name }} == entity.{{ u.name }}{% endfor %}{% if uniques | length > 1 %}){% endif %}
});
if taken {
tracing::warn!(id = %entity.id, "save rejected: a unique value is taken");
return Err(RepositoryError::Duplicate);
}
{% endif %} rows.insert(entity.id, entity.clone());
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use business::domain::{{ snake }}::model::{{ pascal }}Props;
{% for import in test_imports %} use {{ import }};
{% endfor %}
fn new_{{ snake }}() -> {{ pascal }} {
{{ pascal }}::new({{ pascal }}Props {
{% for f in fields %} {{ f.name }}: {{ f.seed_literal }},
{% endfor %} })
.unwrap()
}
#[tokio::test]
async fn should_find_a_saved_{{ snake }}_by_id() {
// Arrange
let repo = InMemory{{ pascal }}Repository::new();
let entity = new_{{ snake }}();
repo.save(&entity).await.unwrap();
// Act
let found = repo.find_by_id(entity.id).await.unwrap();
// Assert
assert_eq!(found.map(|row| row.id), Some(entity.id));
}
#[tokio::test]
async fn should_update_the_row_when_saved_again() {
// Arrange
let repo = InMemory{{ pascal }}Repository::new();
let mut entity = new_{{ snake }}();
repo.save(&entity).await.unwrap();
entity.updated_at = chrono::Utc::now();
// Act
repo.save(&entity).await.unwrap();
// Assert
let page = repo.find_all(PageRequest::default()).await.unwrap();
assert_eq!(page.total, 1);
assert_eq!(page.items[0].updated_at, entity.updated_at);
}
#[tokio::test]
async fn should_not_find_a_deleted_{{ snake }}() {
// Arrange
let repo = InMemory{{ pascal }}Repository::new();
let mut entity = new_{{ snake }}();
entity.deleted = true;
entity.deleted_at = Some(entity.updated_at);
repo.save(&entity).await.unwrap();
// Act
let found = repo.find_by_id(entity.id).await.unwrap();
let page = repo.find_all(PageRequest::default()).await.unwrap();
// Assert
assert!(found.is_none());
assert_eq!(page.total, 0);
}
#[tokio::test]
async fn should_return_the_requested_page_of_a_larger_list() {
// Arrange
let repo = InMemory{{ pascal }}Repository::new();
for _ in 0..3 {
repo.save(&new_{{ snake }}()).await.unwrap();
}
// Act
let page = repo.find_all(PageRequest::new(Some(2), Some(2))).await.unwrap();
// Assert
assert_eq!(page.items.len(), 1);
assert_eq!(page.total, 3);
assert_eq!((page.limit, page.offset), (2, 2));
}
{% for u in uniques %}
#[tokio::test]
async fn should_reject_a_duplicate_{{ u.name }}() {
// Arrange
let repo = InMemory{{ pascal }}Repository::new();
let first = new_{{ snake }}();
repo.save(&first).await.unwrap();
let mut second = new_{{ snake }}();
second.{{ u.name }} = first.{{ u.name }}{% if u.needs_clone %}.clone(){% endif %};
// Act
let result = repo.save(&second).await;
// Assert
assert_eq!(result.unwrap_err(), RepositoryError::Duplicate);
}
{% endfor %}}