use std::collections::HashMap;
use std::path::{Path, PathBuf};
use commonmeta::{Organization, Work};
use rusqlite::params;
use crate::error::AppError;
use crate::pool;
fn bare_pid(id: &str) -> &str {
id.trim_start_matches("https://doi.org/")
.trim_start_matches("http://doi.org/")
.trim_start_matches("https://dx.doi.org/")
.trim_start_matches("http://dx.doi.org/")
.trim_start_matches("https://orcid.org/")
.trim_start_matches("http://orcid.org/")
.trim_start_matches("https://ror.org/")
.trim_start_matches("http://ror.org/")
}
fn doi_url(bare: &str) -> String {
format!("https://doi.org/{bare}")
}
pub fn open(path: &std::path::Path) -> Result<Option<PathBuf>, AppError> {
if !path.exists() {
tracing::warn!(path = %path.display(), "sqlite file not found, running without local database");
return Ok(None);
}
let path = path.to_path_buf();
let conn = pool::get(&path)?;
let has_pid_records: bool = conn
.query_row(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='pid_records'",
[],
|_| Ok(true),
)
.unwrap_or(false);
if !has_pid_records {
tracing::info!(path = %path.display(), "sqlite database has no pid_records table, running without local database");
return Ok(None);
}
Ok(Some(path))
}
pub fn random_id(path: &PathBuf) -> Result<Option<String>, AppError> {
let conn = pool::get(path)?;
let mut tables: Vec<(&str, &str)> = Vec::new();
for (tbl, col) in &[("pid_records", "pid"), ("organizations", "id")] {
let has_rows: bool = conn
.query_row(&format!("SELECT 1 FROM {tbl} LIMIT 1"), [], |_| Ok(true))
.unwrap_or(false);
if has_rows {
tables.push((tbl, col));
}
}
if tables.is_empty() {
return Ok(None);
}
let n = tables.len() as i64;
let idx = conn
.query_row("SELECT abs(random()) % ?1", params![n], |r| {
r.get::<_, i64>(0)
})
.unwrap_or(0) as usize;
let (table, col) = tables[idx];
let query = format!(
"SELECT {col} FROM {table} \
WHERE rowid >= abs(random()) % (SELECT max(rowid) FROM {table}) + 1 \
LIMIT 1"
);
match conn.query_row(&query, [], |row| row.get(0)) {
Ok(id) => Ok(Some(id)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(AppError::Internal(format!("sqlite random: {e}"))),
}
}
pub fn lookup_container(path: &PathBuf, identifier: &str) -> Result<Option<Vec<u8>>, AppError> {
let conn = pool::get(path)?;
let Some(container) = commonmeta::read_container_sqlite_conn(identifier, &conn)
.map_err(|e| AppError::Internal(format!("read container: {e}")))?
else {
return Ok(None);
};
let bytes = serde_json::to_vec_pretty(&[container])
.map_err(|e| AppError::Internal(format!("container serialize: {e}")))?;
Ok(Some(bytes))
}
pub fn container_record(
path: &PathBuf,
identifier: &str,
) -> Option<commonmeta::metadata::ContainerRecord> {
commonmeta::read_container_sqlite_conn(identifier, &*pool::get(path).ok()?)
.ok()
.flatten()
}
pub fn container_works_local_page(
path: &PathBuf,
identifier: &str,
page: commonmeta::WorksPage<'_>,
) -> (Vec<Work>, usize) {
let Ok(conn) = pool::get(path) else {
return Default::default();
};
commonmeta::read_sqlite_by_container_page_conn(identifier, &conn, page).unwrap_or_default()
}
pub fn container_work_facets(
path: &PathBuf,
identifier: &str,
max_works: usize,
) -> Option<commonmeta::WorkFacets> {
commonmeta::read_sqlite_container_facets_conn(
identifier,
&*pool::get(path).ok()?,
max_works,
true,
)
.ok()
.flatten()
}
pub fn orcid_works_page(
path: &PathBuf,
orcid_url: &str,
page: commonmeta::WorksPage<'_>,
) -> (Vec<Work>, usize) {
let Ok(conn) = pool::get(path) else {
return Default::default();
};
commonmeta::read_sqlite_by_orcid_page_conn(orcid_url, &conn, page).unwrap_or_default()
}
pub fn orcid_work_facets(
path: &PathBuf,
orcid_url: &str,
max_works: usize,
) -> Option<commonmeta::WorkFacets> {
commonmeta::read_sqlite_orcid_facets_conn(orcid_url, &*pool::get(path).ok()?, max_works, true)
.ok()
.flatten()
}
pub fn ror_works_page(
path: &PathBuf,
ror_url: &str,
page: commonmeta::WorksPage<'_>,
) -> (Vec<Work>, usize) {
let Ok(conn) = pool::get(path) else {
return Default::default();
};
commonmeta::read_sqlite_by_ror_page_conn(ror_url, &conn, page).unwrap_or_default()
}
pub fn ror_work_facets(
path: &PathBuf,
ror_url: &str,
max_works: usize,
) -> Option<commonmeta::WorkFacets> {
commonmeta::read_sqlite_ror_facets_conn(ror_url, &*pool::get(path).ok()?, max_works, true)
.ok()
.flatten()
}
pub fn resolve_container_remote(identifier: &str) -> Option<commonmeta::metadata::ContainerRecord> {
commonmeta::read("openalex_source", identifier)
.ok()?
.into_container()
}
pub fn cache_upsert_container(
cache: &PathBuf,
container: &commonmeta::metadata::ContainerRecord,
) -> Result<(), AppError> {
commonmeta::write_container_sqlite(std::slice::from_ref(container), cache)
.map(|_| ())
.map_err(|e| AppError::Internal(format!("cache container: {e}")))
}
pub fn lookup(path: &PathBuf, doi: &str) -> Result<Option<Work>, AppError> {
let id = commonmeta::doi_utils::normalize_doi(doi);
if id.is_empty() {
return Ok(None);
}
commonmeta::read_sqlite_by_id_conn(&id, &*pool::get(path)?)
.map_err(|e| AppError::Internal(format!("sqlite lookup: {e}")))
}
pub fn lookup_url(path: &PathBuf, doi: &str) -> Option<String> {
let id = commonmeta::doi_utils::normalize_doi(doi);
if id.is_empty() {
return None;
}
commonmeta::read_sqlite_url_by_id_conn(&id, &*pool::get(path).ok()?)
.ok()
.flatten()
}
#[derive(serde::Serialize)]
pub struct SearchResult {
pub id: String,
pub title: String,
pub entity_type: String,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub also_known_as: Vec<String>,
#[serde(skip_serializing_if = "String::is_empty", default)]
pub location: String,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub types: Vec<String>,
#[serde(skip_serializing_if = "String::is_empty", default)]
pub given_name: String,
#[serde(skip_serializing_if = "String::is_empty", default)]
pub family_name: String,
#[serde(skip_serializing_if = "String::is_empty", default)]
pub country: String,
#[serde(skip_serializing_if = "String::is_empty", default)]
pub description: String,
#[serde(skip_serializing_if = "String::is_empty", default)]
pub publisher: String,
#[serde(skip_serializing_if = "String::is_empty", default)]
pub publisher_id: String,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub identifiers: Vec<SearchIdentifier>,
#[serde(skip_serializing_if = "String::is_empty", default)]
pub container_type: String,
#[serde(skip_serializing_if = "is_zero", default)]
pub works: usize,
}
fn is_zero(n: &usize) -> bool {
*n == 0
}
#[derive(serde::Serialize)]
pub struct SearchIdentifier {
pub identifier: String,
pub identifier_type: String,
}
impl SearchResult {
fn named(id: String, title: String, entity_type: &str) -> Self {
SearchResult {
id,
title,
entity_type: entity_type.to_string(),
also_known_as: Vec::new(),
location: String::new(),
types: Vec::new(),
given_name: String::new(),
family_name: String::new(),
country: String::new(),
description: String::new(),
publisher: String::new(),
publisher_id: String::new(),
identifiers: Vec::new(),
container_type: String::new(),
works: 0,
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub enum SearchKind {
#[default]
Works,
People,
Organizations,
Containers,
}
impl SearchKind {
pub fn parse(value: Option<&str>) -> Self {
match value.unwrap_or("").trim().to_ascii_lowercase().as_str() {
"people" | "person" | "persons" => Self::People,
"organizations" | "organization" | "orgs" | "org" => Self::Organizations,
"containers" | "container" => Self::Containers,
_ => Self::Works,
}
}
fn index_kind(self) -> &'static str {
match self {
Self::Works => "work",
Self::People => "person",
Self::Organizations => "organization",
Self::Containers => "container",
}
}
fn entity_type(self) -> &'static str {
match self {
Self::Works => "Work",
Self::People => "Person",
Self::Organizations => "Organization",
Self::Containers => "Container",
}
}
}
pub const SEARCH_LIMIT: usize = 10;
pub const SEARCH_LIMIT_MAX: usize = 100;
#[derive(serde::Serialize, Default, Clone, Copy)]
pub struct StoreCounts {
pub works: usize,
pub people: usize,
pub organizations: usize,
pub containers: usize,
}
pub fn store_counts(path: &PathBuf) -> StoreCounts {
let of = |kind: SearchKind| {
search_page(path, SEARCH_ALL, kind, &SearchFilters::default(), 1, 0)
.map(|(_, total)| total)
.unwrap_or(0)
};
StoreCounts {
works: of(SearchKind::Works),
people: of(SearchKind::People),
organizations: of(SearchKind::Organizations),
containers: of(SearchKind::Containers),
}
}
pub const SEARCH_ALL: &str = "*";
pub const SEARCH_FACETS_MAX_MATCHES: usize = 1_000_000;
pub const SEARCH_FACET_VALUES: usize = 10;
fn search_facets_years(
counts: Vec<commonmeta::search::FacetCount>,
keep: usize,
this_year: i64,
) -> Vec<SearchFacetCount> {
let mut years: Vec<SearchFacetCount> = counts
.into_iter()
.map(|c| SearchFacetCount {
value: c.value,
count: c.count,
})
.collect();
years.sort_by_key(|c| {
let year = c.value.parse::<i64>().unwrap_or(i64::MIN);
(year > this_year, std::cmp::Reverse(year))
});
years.truncate(keep);
years
}
fn this_year() -> i64 {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
1970 + (secs / 86_400) * 400 / 146_097
}
const SEARCH_VERSION_OVERFETCH: usize = 2;
#[derive(Clone, Debug, Default)]
pub struct SearchFilters {
pub year: Vec<String>,
pub language: Vec<String>,
pub work_type: Vec<String>,
pub subject: Vec<String>,
pub country: Vec<String>,
}
impl SearchFilters {
fn borrowed(&self) -> BorrowedFilters<'_> {
fn of(values: &[String]) -> Vec<&str> {
values.iter().map(String::as_str).collect()
}
BorrowedFilters {
year: of(&self.year),
language: of(&self.language),
work_type: of(&self.work_type),
subject: of(&self.subject),
country: of(&self.country),
}
}
}
struct BorrowedFilters<'a> {
year: Vec<&'a str>,
language: Vec<&'a str>,
work_type: Vec<&'a str>,
subject: Vec<&'a str>,
country: Vec<&'a str>,
}
impl BorrowedFilters<'_> {
fn as_commonmeta(&self) -> commonmeta::search::SearchFilters<'_> {
commonmeta::search::SearchFilters {
year: &self.year,
language: &self.language,
work_type: &self.work_type,
subject: &self.subject,
country: &self.country,
}
}
}
#[derive(serde::Serialize)]
pub struct SearchFacetCount {
pub value: String,
pub count: u64,
}
#[derive(serde::Serialize, Default)]
pub struct SearchFacets {
pub years: Vec<SearchFacetCount>,
pub languages: Vec<SearchFacetCount>,
pub types: Vec<SearchFacetCount>,
pub subjects: Vec<SearchFacetCount>,
pub countries: Vec<SearchFacetCount>,
pub counted: bool,
}
fn warn_index_unavailable(path: &Path, kind: &str, error: &dyn std::fmt::Display) {
tracing::warn!(
kind,
index = %commonmeta::search::index_dir(path).display(),
error = %error,
"search index unavailable, returning no results"
);
}
pub fn search_page(
path: &PathBuf,
q: &str,
kind: SearchKind,
filters: &SearchFilters,
limit: usize,
offset: usize,
) -> Result<(Vec<SearchResult>, usize), AppError> {
let q = q.trim();
if q.is_empty() || !commonmeta::search::index_dir(path).exists() {
return Ok((vec![], 0));
}
let limit = limit.clamp(1, SEARCH_LIMIT_MAX);
let want = if kind == SearchKind::Works {
limit * SEARCH_VERSION_OVERFETCH
} else {
limit
};
let borrowed = filters.borrowed();
let (hits, total) = match commonmeta::search::search_page(
path,
q,
None,
Some(kind.index_kind()),
borrowed.as_commonmeta(),
want,
offset,
) {
Ok(found) => found,
Err(e) => {
warn_index_unavailable(path, kind.index_kind(), &e);
return Ok((vec![], 0));
}
};
if kind == SearchKind::Containers {
return Ok((container_results(path, &hits), total));
}
if kind == SearchKind::Organizations {
return Ok((organization_results(path, &hits), total));
}
if kind == SearchKind::People {
return Ok((person_results(path, &hits), total));
}
let mut results: Vec<SearchResult> = hits
.into_iter()
.map(|h| SearchResult::named(h.pid, h.name, kind.entity_type()))
.collect();
if kind == SearchKind::Works {
drop_superseded_versions(path, &mut results);
}
results.truncate(limit);
Ok((results, total))
}
fn organization_results(
path: &PathBuf,
hits: &[commonmeta::search::SearchHit],
) -> Vec<SearchResult> {
let pids: Vec<String> = hits.iter().map(|h| h.pid.clone()).collect();
let records: HashMap<String, Organization> =
match commonmeta::pidbox::read_sqlite_orgs_by_pid(path, &pids) {
Ok(found) => found.into_iter().collect(),
Err(e) => {
tracing::warn!(error = %e, "reading organizations for search results failed");
HashMap::new()
}
};
let works = works_counts(path, &pids, "hasOrganization");
hits.iter()
.map(|hit| {
let counted = works.get(&hit.pid).copied().unwrap_or(0);
let Some(org) = records.get(&hit.pid) else {
return SearchResult {
works: counted,
..SearchResult::named(hit.pid.clone(), hit.name.clone(), "Organization")
};
};
let mut also_known_as: Vec<String> = Vec::new();
for name in std::iter::once(&org.acronym).chain(org.additional_names.iter()) {
let name = name.trim();
if !name.is_empty()
&& !name.eq_ignore_ascii_case(org.name.trim())
&& !also_known_as
.iter()
.any(|seen| seen.eq_ignore_ascii_case(name))
{
also_known_as.push(name.to_string());
}
}
let location = org
.geo_locations
.iter()
.map(|geo| geo.place.trim())
.find(|place| !place.is_empty())
.unwrap_or(org.country.trim())
.to_string();
SearchResult {
id: hit.pid.clone(),
title: if org.name.trim().is_empty() {
hit.name.clone()
} else {
org.name.clone()
},
entity_type: "Organization".to_string(),
also_known_as,
location,
types: org.types.clone(),
works: counted,
..SearchResult::named(String::new(), String::new(), "Organization")
}
})
.collect()
}
fn works_counts(path: &PathBuf, pids: &[String], relation: &str) -> HashMap<String, usize> {
let mut counts = HashMap::new();
if pids.is_empty() {
return counts;
}
let Ok(conn) = pool::get(path) else {
return counts;
};
let placeholders = std::iter::repeat_n("?", pids.len())
.collect::<Vec<_>>()
.join(",");
let sql = format!(
"SELECT r.target_id, COUNT(*) FROM pid_relations r \
WHERE r.type = ?1 AND r.target_id IN ({placeholders}) \
AND NOT EXISTS (SELECT 1 FROM pid_relations v \
WHERE v.source_id = r.source_id AND v.type = 'IsVersionOf') \
GROUP BY r.target_id"
);
let Ok(mut stmt) = conn.prepare(&sql) else {
return counts;
};
let params = std::iter::once(relation).chain(pids.iter().map(String::as_str));
let rows = stmt.query_map(rusqlite::params_from_iter(params), |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as usize))
});
if let Ok(rows) = rows {
counts.extend(rows.flatten());
}
counts
}
fn container_results(path: &PathBuf, hits: &[commonmeta::search::SearchHit]) -> Vec<SearchResult> {
let pids: Vec<String> = hits.iter().map(|h| h.pid.clone()).collect();
let navigable = container_navigable_ids(path, &pids);
let records: HashMap<String, commonmeta::metadata::ContainerRecord> = match pool::get(path) {
Ok(conn) => navigable
.values()
.filter(|id| !id.is_empty())
.filter_map(|id| {
commonmeta::read_container_sqlite_conn(id, &conn)
.ok()
.flatten()
.map(|container| (id.clone(), container))
})
.collect(),
Err(e) => {
tracing::warn!(error = %e, "reading containers for search results failed");
HashMap::new()
}
};
let works = works_counts(path, &pids, "hasContainer");
hits.iter()
.filter_map(|hit| {
let id = navigable.get(&hit.pid).filter(|s| !s.is_empty())?;
let counted = works.get(&hit.pid).copied().unwrap_or(0);
let Some(container) = records.get(id) else {
return Some(SearchResult {
works: counted,
..SearchResult::named(id.clone(), hit.name.clone(), "Container")
});
};
let title = if container.title.trim().is_empty() {
hit.name.clone()
} else {
container.title.trim().to_string()
};
let also_known_as: Vec<String> = container
.additional_titles
.iter()
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty() && !t.eq_ignore_ascii_case(&title))
.collect();
let identifiers: Vec<SearchIdentifier> = container
.identifiers
.iter()
.filter(|i| !i.identifier.trim().is_empty())
.filter(|i| bare_pid(&i.identifier) != bare_pid(id))
.map(|i| SearchIdentifier {
identifier: i.identifier.trim().to_string(),
identifier_type: i.identifier_type.clone(),
})
.collect();
let publisher = container.publisher.as_ref();
Some(SearchResult {
id: id.clone(),
title,
also_known_as,
country: container.country.trim().to_string(),
publisher: publisher
.map(|p| p.name.trim().to_string())
.unwrap_or_default(),
publisher_id: publisher
.map(|p| p.id.trim().to_string())
.unwrap_or_default(),
identifiers,
container_type: container.type_.trim().to_string(),
works: counted,
..SearchResult::named(String::new(), String::new(), "Container")
})
})
.collect()
}
fn person_results(path: &PathBuf, hits: &[commonmeta::search::SearchHit]) -> Vec<SearchResult> {
let pids: Vec<String> = hits.iter().map(|h| h.pid.clone()).collect();
let records: HashMap<String, commonmeta::Person> =
match commonmeta::pidbox::read_sqlite_persons_by_pid(path, &pids) {
Ok(found) => found.into_iter().collect(),
Err(e) => {
tracing::warn!(error = %e, "reading people for search results failed");
HashMap::new()
}
};
hits.iter()
.map(|hit| {
let Some(person) = records.get(&hit.pid) else {
return SearchResult::named(hit.pid.clone(), hit.name.clone(), "Person");
};
let joined = [person.given_name.trim(), person.family_name.trim()]
.into_iter()
.filter(|part| !part.is_empty())
.collect::<Vec<_>>()
.join(" ");
let title = [person.name.trim(), joined.as_str(), hit.name.as_str()]
.into_iter()
.find(|name| !name.is_empty())
.unwrap_or_default()
.to_string();
let also_known_as: Vec<String> = person
.additional_names
.iter()
.map(|name| name.trim().to_string())
.filter(|name| !name.is_empty() && !name.eq_ignore_ascii_case(&title))
.collect();
SearchResult {
id: hit.pid.clone(),
title,
also_known_as,
given_name: person.given_name.trim().to_string(),
family_name: person.family_name.trim().to_string(),
country: person.country.trim().to_string(),
description: person.description.trim().to_string(),
..SearchResult::named(String::new(), String::new(), "Person")
}
})
.collect()
}
pub fn search_facets(path: &PathBuf, q: &str, kind: SearchKind) -> SearchFacets {
let q = q.trim();
if q.is_empty()
|| kind == SearchKind::Containers
|| !commonmeta::search::index_dir(path).exists()
{
return SearchFacets::default();
}
let counts = match commonmeta::search::search_facets(
path,
q,
None,
Some(kind.index_kind()),
0,
SEARCH_FACETS_MAX_MATCHES,
) {
Ok(counts) => counts,
Err(e) => {
warn_index_unavailable(path, kind.index_kind(), &e);
return SearchFacets::default();
}
};
fn commonest(
counts: Vec<commonmeta::search::FacetCount>,
keep: usize,
) -> Vec<SearchFacetCount> {
counts
.into_iter()
.take(keep)
.map(|c| SearchFacetCount {
value: c.value,
count: c.count,
})
.collect()
}
SearchFacets {
years: search_facets_years(counts.years, SEARCH_FACET_VALUES, this_year()),
languages: commonest(counts.languages, SEARCH_FACET_VALUES),
types: commonest(counts.types, SEARCH_FACET_VALUES),
subjects: commonest(counts.subjects, SEARCH_FACET_VALUES),
countries: commonest(counts.countries, SEARCH_FACET_VALUES),
counted: counts.counted,
}
}
fn drop_superseded_versions(path: &PathBuf, results: &mut Vec<SearchResult>) {
if results.len() < 2 {
return;
}
let pids: Vec<String> = results
.iter()
.map(|r| bare_pid(&r.id).to_string())
.collect();
let Ok(conn) = pool::get(path) else {
return;
};
let placeholders = std::iter::repeat_n("?", pids.len())
.collect::<Vec<_>>()
.join(",");
let sql = format!(
"SELECT source_id, target_id FROM pid_relations \
WHERE type = 'IsVersionOf' \
AND source_id IN ({placeholders}) AND target_id IN ({placeholders})"
);
let Ok(mut stmt) = conn.prepare(&sql) else {
return;
};
let params = pids.iter().chain(pids.iter());
let Ok(rows) = stmt.query_map(rusqlite::params_from_iter(params), |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
}) else {
return;
};
let mut linked: std::collections::HashMap<String, Vec<String>> =
std::collections::HashMap::new();
for (source, target) in rows.flatten() {
linked
.entry(source.clone())
.or_default()
.push(target.clone());
linked.entry(target).or_default().push(source);
}
if linked.is_empty() {
return;
}
let mut kept: std::collections::HashSet<String> = std::collections::HashSet::new();
results.retain(|r| {
let pid = bare_pid(&r.id).to_string();
let duplicates_kept = linked
.get(&pid)
.is_some_and(|others| others.iter().any(|o| kept.contains(o)));
if !duplicates_kept {
kept.insert(pid);
}
!duplicates_kept
});
}
fn container_navigable_ids(
path: &PathBuf,
pids: &[String],
) -> std::collections::HashMap<String, String> {
let mut out = std::collections::HashMap::new();
if pids.is_empty() {
return out;
}
let Ok(conn) = pool::get(path) else {
return out;
};
let placeholders = std::iter::repeat_n("?", pids.len())
.collect::<Vec<_>>()
.join(",");
let sql = format!(
"SELECT pid, issn, doi, openalex, datacite FROM pid_annotations WHERE pid IN ({placeholders})"
);
let Ok(mut stmt) = conn.prepare(&sql) else {
return out;
};
let rows = stmt.query_map(rusqlite::params_from_iter(pids.iter()), |row| {
let col = |i: usize| -> String {
row.get::<_, Option<String>>(i)
.ok()
.flatten()
.unwrap_or_default()
};
let pid: String = row.get(0)?;
let (issn, doi, openalex, datacite) = (col(1), col(2), col(3), col(4));
let id = if !issn.is_empty() {
issn
} else if !doi.is_empty() {
doi
} else if !openalex.is_empty() {
openalex
.trim_start_matches("https://openalex.org/")
.to_string()
} else {
datacite
};
Ok((pid, id))
});
if let Ok(rows) = rows {
for (pid, id) in rows.flatten() {
out.insert(pid, id);
}
}
out
}
#[derive(serde::Serialize)]
pub struct EntityCount {
#[serde(skip_serializing_if = "Option::is_none")]
pub citations: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub works: Option<i64>,
}
pub fn count_for_id(path: &PathBuf, id: &str) -> Result<EntityCount, AppError> {
if id.starts_with("https://doi.org/") {
let n = commonmeta::count_sqlite_citations_conn(id, &*pool::get(path)?)
.map_err(|e| AppError::Internal(format!("count citations: {e}")))?
as i64;
Ok(EntityCount {
citations: Some(n),
works: None,
})
} else if let Some(orcid) = id.strip_prefix("https://orcid.org/") {
let conn = pool::get(path)?;
let n: i64 = conn.query_row(
r#"SELECT COUNT(*) FROM pid_relations WHERE "target_id" = ?1 AND "type" = 'hasPerson'"#,
params![orcid], |row| row.get(0),
).unwrap_or(0);
Ok(EntityCount {
citations: None,
works: Some(n),
})
} else if let Some(ror) = id.strip_prefix("https://ror.org/") {
let conn = pool::get(path)?;
let n: i64 = conn.query_row(
r#"SELECT COUNT(*) FROM pid_relations WHERE "target_id" = ?1 AND "type" = 'hasOrganization'"#,
params![ror], |row| row.get(0),
).unwrap_or(0);
Ok(EntityCount {
citations: None,
works: Some(n),
})
} else {
Ok(EntityCount {
citations: None,
works: None,
})
}
}
pub fn lookup_source_relations(
path: &PathBuf,
doi: &str,
) -> Result<Vec<(String, String)>, AppError> {
let id = bare_pid(&commonmeta::doi_utils::normalize_doi(doi)).to_string();
if id.is_empty() {
return Ok(vec![]);
}
let conn = pool::get(path)?;
let mut stmt = match conn.prepare(
r#"SELECT "target_id", "type" FROM pid_relations
WHERE "source_id" = ?1
AND "type" NOT IN ('hasPerson', 'hasOrganization', 'hasContainer')
LIMIT 200"#,
) {
Ok(s) => s,
Err(_) => return Ok(vec![]), };
let rels = stmt
.query_map(params![id], |row| {
Ok((doi_url(&row.get::<_, String>(0)?), row.get::<_, String>(1)?))
})
.map_err(|e| AppError::Internal(format!("query source_relations: {e}")))?
.filter_map(|r| r.ok())
.collect();
Ok(rels)
}
pub fn lookup_target_relations(
path: &PathBuf,
doi: &str,
) -> Result<Vec<(String, String, i64)>, AppError> {
let id = bare_pid(&commonmeta::doi_utils::normalize_doi(doi)).to_string();
if id.is_empty() {
return Ok(vec![]);
}
let conn = pool::get(path)?;
let mut stmt = match conn.prepare(
r#"SELECT "source_id", "type", COALESCE("source_year", 0) FROM pid_relations
WHERE "target_id" = ?1
AND "type" NOT IN ('hasPerson', 'hasOrganization', 'hasContainer')
ORDER BY (COALESCE("source_year", 0) > CAST(strftime('%Y', 'now') AS INTEGER)) ASC,
"source_year" DESC LIMIT 200"#,
) {
Ok(s) => s,
Err(_) => return Ok(vec![]), };
let rels = stmt
.query_map(params![id], |row| {
Ok((
doi_url(&row.get::<_, String>(0)?),
row.get::<_, String>(1)?,
row.get::<_, i64>(2).unwrap_or(0),
))
})
.map_err(|e| AppError::Internal(format!("query target_relations: {e}")))?
.filter_map(|r| r.ok())
.collect();
Ok(rels)
}
pub fn filter_versioned_ids(path: &PathBuf, ids: &[String]) -> std::collections::HashSet<String> {
if ids.is_empty() {
return std::collections::HashSet::new();
}
let conn = match pool::get(path) {
Ok(c) => c,
Err(_) => return std::collections::HashSet::new(),
};
let bare: Vec<String> = ids.iter().map(|id| bare_pid(id).to_string()).collect();
let placeholders = bare
.iter()
.enumerate()
.map(|(i, _)| format!("?{}", i + 1))
.collect::<Vec<_>>()
.join(", ");
let sql = format!(
r#"SELECT DISTINCT "source_id" FROM pid_relations WHERE "type" = 'IsVersionOf' AND "source_id" IN ({placeholders})"#
);
let mut stmt = match conn.prepare(&sql) {
Ok(s) => s,
Err(_) => return std::collections::HashSet::new(),
};
let params: Vec<&dyn rusqlite::ToSql> =
bare.iter().map(|s| s as &dyn rusqlite::ToSql).collect();
match stmt.query_map(params.as_slice(), |row| row.get::<_, String>(0)) {
Ok(rows) => rows.filter_map(|r| r.ok()).map(|id| doi_url(&id)).collect(),
Err(_) => std::collections::HashSet::new(),
}
}
pub fn lookup_ror_types(path: &PathBuf, ror_url: &str) -> Result<Vec<String>, AppError> {
if ror_url.is_empty() {
return Ok(Vec::new());
}
let conn = pool::get(path)?;
let has_orgs: bool = conn
.query_row(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='organizations'",
[],
|_| Ok(true),
)
.unwrap_or(false);
if !has_orgs {
return Ok(Vec::new());
}
match conn.query_row(
"SELECT types FROM organizations WHERE id = ?1",
params![ror_url],
|row| row.get::<_, String>(0),
) {
Ok(json) => Ok(serde_json::from_str::<Vec<String>>(&json).unwrap_or_default()),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(Vec::new()),
Err(e) => Err(AppError::Internal(format!("sqlite ror types: {e}"))),
}
}
pub fn lookup_by_ror(path: &PathBuf, ror_url: &str) -> Result<Option<Organization>, AppError> {
if ror_url.is_empty() {
return Ok(None);
}
let conn = pool::get(path)?;
let has_orgs: bool = conn
.query_row(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='organizations'",
[],
|_| Ok(true),
)
.unwrap_or(false);
if !has_orgs {
return Ok(None);
}
drop(conn);
let mut ror = match commonmeta::fetch_ror_raw_sqlite(ror_url, path) {
Ok(r) => r,
Err(_) => return Ok(None),
};
commonmeta::enrich_ror_locations(&mut ror, path);
let json = commonmeta::write_ror_v2_json(&ror)
.map_err(|e| AppError::Internal(format!("ror serialize: {e}")))?;
let json_str =
String::from_utf8(json).map_err(|e| AppError::Internal(format!("ror utf8: {e}")))?;
let entity = commonmeta::read("ror", &json_str)
.map_err(|e| AppError::Internal(format!("ror parse: {e}")))?;
entity
.into_organization()
.map(Some)
.ok_or_else(|| AppError::Internal(format!("'{ror_url}' did not parse as an organization")))
}
pub fn cache_open(path: &Path) -> Result<PathBuf, AppError> {
let path = path.to_path_buf();
commonmeta::open_pid_records_writer(&path)
.map_err(|e| AppError::Internal(format!("cache init: {e}")))?;
Ok(path)
}
pub fn cache_lookup(path: &PathBuf, doi: &str) -> Result<Option<Work>, AppError> {
let id = commonmeta::doi_utils::normalize_doi(doi);
if id.is_empty() {
return Ok(None);
}
let Ok(conn) = pool::get(path) else {
return Ok(None);
};
Ok(commonmeta::read_sqlite_by_id_conn(&id, &conn).unwrap_or(None))
}
pub fn person_page(path: &Path, orcid: &str) -> Option<Vec<u8>> {
commonmeta::read_person_commonmeta_sqlite_conn(orcid, &*pool::get(path).ok()?).ok()
}
pub fn org_record_page(path: &Path, ror: &str) -> Option<Vec<u8>> {
commonmeta::read_org_record_commonmeta_sqlite_conn(ror, &*pool::get(path).ok()?).ok()
}
pub fn orcid_works(path: &Path, orcid_url: &str) -> Vec<Work> {
let Ok(conn) = pool::get(path) else {
return Vec::new();
};
commonmeta::read_sqlite_by_orcid_conn(orcid_url, &conn).unwrap_or_default()
}
pub fn ror_works(path: &Path, ror_url: &str) -> Vec<Work> {
let Ok(conn) = pool::get(path) else {
return Vec::new();
};
commonmeta::read_sqlite_by_ror_conn(ror_url, &conn).unwrap_or_default()
}
pub fn citations_page(
path: &PathBuf,
doi: &str,
page: commonmeta::WorksPage<'_>,
) -> (Vec<Work>, usize) {
let Ok(conn) = pool::get(path) else {
return Default::default();
};
commonmeta::read_sqlite_by_citation_page_conn(doi, &conn, page).unwrap_or_default()
}
pub fn citation_facets(
path: &PathBuf,
doi: &str,
max_works: usize,
) -> Option<commonmeta::WorkFacets> {
commonmeta::read_sqlite_citation_facets_conn(doi, &*pool::get(path).ok()?, max_works, true)
.ok()
.flatten()
}
pub fn cache_lookup_person_page(path: &PathBuf, orcid_url: &str) -> Option<Vec<u8>> {
person_page(path, orcid_url)
}
pub fn cache_lookup_org_page(path: &PathBuf, ror_url: &str) -> Option<Vec<u8>> {
commonmeta::read_org_commonmeta_sqlite_conn(ror_url, &*pool::get(path).ok()?).ok()
}
pub fn cache_upsert_raw(path: &PathBuf, entries: &[(String, i64, String)]) -> Result<(), AppError> {
commonmeta::upsert_pid_records(entries, path)
.map_err(|e| AppError::Internal(format!("cache upsert raw: {e}")))
}
pub fn cache_upsert_person(path: &PathBuf, orcid_url: &str, xml: &str) -> Result<(), AppError> {
commonmeta::upsert_orcid_record(orcid_url, xml, path)
.map_err(|e| AppError::Internal(format!("cache upsert person: {e}")))
}
pub fn cache_upsert_org(path: &PathBuf, ror: &commonmeta::Ror) -> Result<(), AppError> {
let value = serde_json::to_value(ror)
.map_err(|e| AppError::Internal(format!("cache upsert org: serialize: {e}")))?;
commonmeta::upsert_ror_record(&ror.id, &value, path)
.map_err(|e| AppError::Internal(format!("cache upsert org: {e}")))
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
use rusqlite::Connection;
use std::path::Path;
pub(crate) fn make_test_cache_db(path: &Path) -> PathBuf {
let db_path = cache_open(path).expect("cache_open");
let raw = serde_json::json!({
"DOI": "10.5678/cached",
"type": "journal-article",
"title": ["Cached Article"],
"resource": {"primary": {"URL": "https://example.com/cached-article"}},
})
.to_string();
cache_upsert_raw(&db_path, &[("10.5678/cached".to_string(), 1, raw)])
.expect("cache_upsert_raw");
db_path
}
pub(crate) fn make_test_db(path: &Path) -> PathBuf {
let article = serde_json::json!({
"DOI": "10.1234/test",
"type": "journal-article",
"title": ["Test Article on Content Negotiation"],
"resource": {"primary": {"URL": "https://example.com/test-article"}},
})
.to_string();
let article2 = serde_json::json!({
"DOI": "10.1234/second",
"type": "journal-article",
"title": ["Second Test Article"],
"resource": {"primary": {"URL": "https://example.com/second-article"}},
})
.to_string();
commonmeta::upsert_pid_records(
&[
("10.1234/test".to_string(), 1, article),
("10.1234/second".to_string(), 1, article2),
],
path,
)
.expect("upsert works into pid_records");
path.to_path_buf()
}
fn make_test_orgs_db(path: &Path) -> PathBuf {
let humboldt = serde_json::json!({
"id": "https://ror.org/01hcx6992",
"names": [
{"value": "Humboldt-Universität zu Berlin", "types": ["ror_display", "label"]},
{"value": "HU", "types": ["acronym"]},
{"value": "HU Berlin", "types": ["alias"]},
{"value": "Humboldt University Berlin", "types": ["alias"]},
{"value": "Humboldt-Universität zu Berlin", "types": ["alias"]},
],
"locations": [{
"geonames_id": 2950159,
"geonames_details": {
"name": "Berlin", "country_name": "Germany", "country_code": "DE",
"country_subdivision_name": "State of Berlin",
"lat": 52.52437, "lng": 13.41053,
},
}],
"types": ["education"],
})
.to_string();
let placeless = serde_json::json!({
"id": "https://ror.org/02nr0ka47",
"names": [{"value": "Universität Münster", "types": ["ror_display"]}],
"locations": [{"geonames_details": {"country_code": "DE", "country_name": "Germany"}}],
"types": ["education"],
})
.to_string();
commonmeta::upsert_pid_records(
&[
("01hcx6992".to_string(), 3, humboldt),
("02nr0ka47".to_string(), 3, placeless),
],
path,
)
.expect("upsert organizations into pid_records");
let conn = Connection::open(path).expect("open store for writing");
conn.execute_batch(
r#"CREATE TABLE IF NOT EXISTS pid_relations (
source_id TEXT NOT NULL,
target_id TEXT NOT NULL,
"type" TEXT NOT NULL,
source_year INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (source_id, target_id, "type")
)"#,
)
.expect("create pid_relations");
for (source, target, rel) in [
("10.1234/aff-1", "01hcx6992", "hasOrganization"),
("10.1234/aff-2", "01hcx6992", "hasOrganization"),
("10.1234/aff-2", "10.1234/aff-1", "IsVersionOf"),
] {
conn.execute(
r#"INSERT OR IGNORE INTO pid_relations (source_id, target_id, "type") VALUES (?1, ?2, ?3)"#,
params![source, target, rel],
)
.expect("insert relation");
}
path.to_path_buf()
}
#[test]
fn an_organization_row_names_it_properly_and_once() {
let dir = tempfile::tempdir().unwrap();
let path = make_test_orgs_db(&dir.path().join("orgs.sqlite3"));
let hit = |pid: &str| commonmeta::search::SearchHit {
pid: pid.to_string(),
kind: "organization".to_string(),
name: "Humboldt-Universität zu Berlin HU HU Berlin".to_string(),
score: 1.0,
};
let rows = organization_results(&path, &[hit("01hcx6992"), hit("02nr0ka47")]);
assert_eq!(rows.len(), 2);
let humboldt = &rows[0];
assert_eq!(humboldt.title, "Humboldt-Universität zu Berlin");
assert_eq!(humboldt.entity_type, "Organization");
assert_eq!(
humboldt.also_known_as,
vec!["HU", "HU Berlin", "Humboldt University Berlin"]
);
assert!(
humboldt.location.contains("Berlin"),
"{}",
humboldt.location
);
assert_eq!(humboldt.types, vec!["education"]);
assert_eq!(humboldt.works, 1);
let münster = &rows[1];
assert_eq!(münster.title, "Universität Münster");
assert!(münster.also_known_as.is_empty());
assert_eq!(münster.location, "DE");
assert_eq!(münster.works, 0);
let rows = organization_results(&path, &[hit("09zzzzzz9")]);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].id, "09zzzzzz9");
assert_eq!(rows[0].title, "Humboldt-Universität zu Berlin HU HU Berlin");
assert!(rows[0].also_known_as.is_empty());
}
fn make_test_people_db(path: &Path) -> PathBuf {
let record = |orcid: &str, names: &str, extras: &str| {
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<record:record xmlns:record="http://www.orcid.org/ns/record"
xmlns:common="http://www.orcid.org/ns/common"
xmlns:person="http://www.orcid.org/ns/person"
xmlns:other-name="http://www.orcid.org/ns/other-name"
xmlns:address="http://www.orcid.org/ns/address"
xmlns:personal-details="http://www.orcid.org/ns/personal-details">
<common:orcid-identifier>
<common:uri>https://orcid.org/{orcid}</common:uri>
<common:path>{orcid}</common:path>
</common:orcid-identifier>
<person:person>
<person:name>{names}</person:name>
{extras}
</person:person>
</record:record>"#
)
};
let carberry = record(
"0000-0002-1825-0097",
"<personal-details:given-names>Josiah</personal-details:given-names>\
<personal-details:family-name>Carberry</personal-details:family-name>\
<personal-details:credit-name>Josiah S. Carberry</personal-details:credit-name>",
r#"<other-name:other-names>
<other-name:other-name><other-name:content>J. S. Carberry</other-name:content></other-name:other-name>
<other-name:other-name><other-name:content>Josiah S. Carberry</other-name:content></other-name:other-name>
</other-name:other-names>
<address:addresses>
<address:address><address:country>US</address:country></address:address>
</address:addresses>
<person:biography visibility="public">
<personal-details:content> Professor of Psychoceramics. </personal-details:content>
</person:biography>"#,
);
let sparse = record(
"0000-0003-0920-415X",
"<personal-details:credit-name>A Sparse Record</personal-details:credit-name>",
"",
);
commonmeta::upsert_pid_records(
&[
("0000-0002-1825-0097".to_string(), 4, carberry),
("0000-0003-0920-415X".to_string(), 4, sparse),
],
path,
)
.expect("upsert people into pid_records");
path.to_path_buf()
}
#[test]
fn a_container_row_carries_its_publisher_country_and_other_identifiers() {
let dir = tempfile::tempdir().unwrap();
let path = make_test_containers_db(&dir.path().join("c.sqlite3"));
let surrogate = {
let conn = pool::get(&path).unwrap();
conn.query_row(
"SELECT pid FROM pid_records WHERE source_id = 5 LIMIT 1",
[],
|row| row.get::<_, String>(0),
)
.expect("the fixture registered a container")
};
let rows = container_results(
&path,
&[commonmeta::search::SearchHit {
pid: surrogate,
kind: "container".to_string(),
name: "Front Matter Front Matter Blog".to_string(),
score: 1.0,
}],
);
assert_eq!(rows.len(), 1);
let row = &rows[0];
assert_eq!(row.id, "2749-9952");
assert_eq!(row.title, "Front Matter");
assert_eq!(row.entity_type, "Container");
assert_eq!(row.publisher, "Front Matter");
assert_eq!(row.country, "DE");
let listed: Vec<(&str, &str)> = row
.identifiers
.iter()
.map(|i| (i.identifier_type.as_str(), i.identifier.as_str()))
.collect();
assert!(
listed.contains(&("OpenAlex", "https://openalex.org/S7407056926")),
"{listed:?}"
);
assert!(
listed.contains(&("DOI", "https://doi.org/10.53731/front_matter")),
"{listed:?}"
);
assert!(
!listed.iter().any(|(_, id)| *id == "2749-9952"),
"the ISSN the row links by is not also listed: {listed:?}"
);
assert!(row.given_name.is_empty() && row.types.is_empty());
assert_eq!(row.works, 1);
assert_eq!(row.container_type, "Blog");
}
#[test]
fn a_person_row_carries_the_name_its_parts_and_the_country() {
let dir = tempfile::tempdir().unwrap();
let path = make_test_people_db(&dir.path().join("people.sqlite3"));
let hit = |pid: &str| commonmeta::search::SearchHit {
pid: pid.to_string(),
kind: "person".to_string(),
name: "Josiah S. Carberry Josiah Carberry J. S. Carberry".to_string(),
score: 1.0,
};
let rows = person_results(
&path,
&[hit("0000-0002-1825-0097"), hit("0000-0003-0920-415X")],
);
assert_eq!(rows.len(), 2);
let carberry = &rows[0];
assert_eq!(carberry.title, "Josiah S. Carberry");
assert_eq!(carberry.entity_type, "Person");
assert_eq!(carberry.given_name, "Josiah");
assert_eq!(carberry.family_name, "Carberry");
assert_eq!(carberry.country, "US");
assert_eq!(carberry.description, "Professor of Psychoceramics.");
assert_eq!(carberry.also_known_as, vec!["J. S. Carberry"]);
assert!(carberry.types.is_empty() && carberry.location.is_empty());
let sparse = &rows[1];
assert_eq!(sparse.title, "A Sparse Record");
assert!(sparse.given_name.is_empty() && sparse.family_name.is_empty());
assert!(sparse.country.is_empty() && sparse.also_known_as.is_empty());
assert!(sparse.description.is_empty());
let rows = person_results(&path, &[hit("0000-0000-0000-0000")]);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].id, "0000-0000-0000-0000");
assert!(rows[0].given_name.is_empty());
}
#[test]
fn open_returns_none_for_missing_file() {
let result = open(Path::new("/nonexistent/path/db.sqlite3"));
assert!(
matches!(result, Ok(None)),
"expected Ok(None), got {result:?}"
);
}
#[test]
fn open_returns_none_when_no_works_table() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("no_works.sqlite3");
let conn = Connection::open(&path).unwrap();
conn.execute_batch("CREATE TABLE organizations (id TEXT PRIMARY KEY, name TEXT);")
.unwrap();
drop(conn);
let result = open(&path);
assert!(
matches!(result, Ok(None)),
"expected Ok(None), got {result:?}"
);
}
#[test]
fn lookup_returns_none_for_unknown_doi() {
let dir = tempfile::tempdir().unwrap();
let path = make_test_db(&dir.path().join("test.sqlite3"));
let result = lookup(&path, "10.9999/does-not-exist").unwrap();
assert!(result.is_none());
}
#[test]
fn search_kind_parses_and_defaults_to_works() {
assert_eq!(SearchKind::parse(Some("works")), SearchKind::Works);
assert_eq!(SearchKind::parse(Some("people")), SearchKind::People);
assert_eq!(
SearchKind::parse(Some("organizations")),
SearchKind::Organizations
);
assert_eq!(
SearchKind::parse(Some("containers")),
SearchKind::Containers
);
assert_eq!(SearchKind::parse(Some(" People ")), SearchKind::People);
assert_eq!(SearchKind::parse(Some("person")), SearchKind::People);
assert_eq!(SearchKind::parse(Some("container")), SearchKind::Containers);
assert_eq!(SearchKind::parse(None), SearchKind::Works);
assert_eq!(SearchKind::parse(Some("")), SearchKind::Works);
assert_eq!(SearchKind::parse(Some("nonsense")), SearchKind::Works);
assert_eq!(SearchKind::default(), SearchKind::Works);
}
#[test]
fn search_empty_query_returns_no_results() {
let dir = tempfile::tempdir().unwrap();
let path = make_test_db(&dir.path().join("test.sqlite3"));
let (hits, total) = search_page(
&path,
" ",
SearchKind::Works,
&SearchFilters::default(),
10,
0,
)
.unwrap();
assert!(hits.is_empty());
assert_eq!(total, 0);
assert!(!search_facets(&path, " ", SearchKind::Works).counted);
}
#[test]
fn search_without_index_returns_empty() {
let dir = tempfile::tempdir().unwrap();
let path = make_test_db(&dir.path().join("test.sqlite3"));
assert!(!commonmeta::search::index_dir(&path).exists());
let (hits, total) = search_page(
&path,
"carberry",
SearchKind::Works,
&SearchFilters::default(),
10,
0,
)
.unwrap();
assert!(hits.is_empty());
assert_eq!(total, 0);
assert!(!search_facets(&path, "carberry", SearchKind::Works).counted);
}
#[test]
fn this_year_is_the_current_year() {
let year = this_year();
assert!(
(2026..=2100).contains(&year),
"{year} is not a plausible current year"
);
}
#[test]
fn the_year_facet_reads_newest_first_with_future_years_last() {
let facet = |value: &str, count: u64| commonmeta::search::FacetCount {
value: value.to_string(),
count,
};
let counts = vec![
facet("2023", 303),
facet("2026", 165),
facet("2024", 71),
facet("2025", 70),
facet("2021", 45),
facet("2022", 43),
facet("2018", 32),
facet("2019", 26),
facet("2020", 36),
facet("2017", 12),
facet("2016", 8),
facet("2029", 1),
facet("2031", 2),
];
let years = search_facets_years(counts, 10, 2026);
let shown: Vec<&str> = years.iter().map(|c| c.value.as_str()).collect();
assert_eq!(
shown,
vec![
"2026", "2025", "2024", "2023", "2022", "2021", "2020", "2019", "2018", "2017"
]
);
let years = search_facets_years(
vec![facet("2031", 2), facet("2026", 1), facet("2029", 1)],
10,
2026,
);
let shown: Vec<&str> = years.iter().map(|c| c.value.as_str()).collect();
assert_eq!(shown, vec!["2026", "2031", "2029"]);
}
#[test]
fn only_works_carry_facets() {
let dir = tempfile::tempdir().unwrap();
let path = make_test_db(&dir.path().join("test.sqlite3"));
for kind in [
SearchKind::People,
SearchKind::Organizations,
SearchKind::Containers,
] {
assert!(
!search_facets(&path, "carberry", kind).counted,
"{kind:?} should carry no facets"
);
}
}
#[test]
fn a_search_page_is_clamped_to_a_sane_size() {
let dir = tempfile::tempdir().unwrap();
let path = make_test_db(&dir.path().join("test.sqlite3"));
for limit in [0, 1, SEARCH_LIMIT, SEARCH_LIMIT_MAX, usize::MAX] {
let page = search_page(
&path,
"carberry",
SearchKind::Works,
&SearchFilters::default(),
limit,
0,
);
assert!(page.is_ok(), "limit {limit} should not fail");
}
}
#[test]
fn container_navigable_ids_prefers_issn_then_doi_then_openalex_then_datacite() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("c.sqlite3");
let conn = rusqlite::Connection::open(&path).unwrap();
conn.execute_batch(
"CREATE TABLE pid_annotations (pid TEXT, issn TEXT DEFAULT '', doi TEXT DEFAULT '', \
openalex TEXT DEFAULT '', datacite TEXT DEFAULT '');
INSERT INTO pid_annotations VALUES
('u-issn','2749-9952','https://doi.org/10.1/x','https://openalex.org/S1','a.b'),
('u-doi','','https://doi.org/10.2/y','https://openalex.org/S2','c.d'),
('u-oa','','','https://openalex.org/S3','e.f'),
('u-dc','','','','g.h'),
('u-none','','','','');",
)
.unwrap();
let pids: Vec<String> = ["u-issn", "u-doi", "u-oa", "u-dc", "u-none"]
.iter()
.map(|s| s.to_string())
.collect();
let ids = container_navigable_ids(&path, &pids);
assert_eq!(ids.get("u-issn").map(String::as_str), Some("2749-9952"));
assert_eq!(
ids.get("u-doi").map(String::as_str),
Some("https://doi.org/10.2/y")
);
assert_eq!(ids.get("u-oa").map(String::as_str), Some("S3"));
assert_eq!(ids.get("u-dc").map(String::as_str), Some("g.h"));
assert_eq!(ids.get("u-none").map(String::as_str), Some(""));
}
#[test]
fn lookup_finds_existing_doi() {
let dir = tempfile::tempdir().unwrap();
let path = make_test_db(&dir.path().join("test.sqlite3"));
let data = lookup(&path, "10.1234/test")
.unwrap()
.expect("should find DOI");
assert_eq!(data.id, "https://doi.org/10.1234/test");
assert_eq!(data.title, "Test Article on Content Negotiation");
assert_eq!(data.url, "https://example.com/test-article");
assert_eq!(data.type_, "JournalArticle");
}
#[test]
fn lookup_normalises_doi_prefix_form() {
let dir = tempfile::tempdir().unwrap();
let path = make_test_db(&dir.path().join("test.sqlite3"));
for doi in &[
"10.1234/test",
"https://doi.org/10.1234/test",
"http://dx.doi.org/10.1234/test",
] {
assert!(
lookup(&path, doi).unwrap().is_some(),
"should find DOI in form '{doi}'"
);
}
}
#[test]
fn lookup_empty_string_returns_none() {
let dir = tempfile::tempdir().unwrap();
let path = make_test_db(&dir.path().join("test.sqlite3"));
assert!(lookup(&path, "").unwrap().is_none());
}
#[test]
fn cache_open_creates_pid_records_schema() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("cache.sqlite3");
cache_open(&path).unwrap();
let conn = Connection::open(&path).unwrap();
let cols: Vec<String> = conn
.prepare("PRAGMA table_info(pid_records)")
.unwrap()
.query_map([], |row| row.get::<_, String>(1))
.unwrap()
.filter_map(|r| r.ok())
.collect();
for col in &["pid", "source_id", "raw_metadata", "raw_metadata_type"] {
assert!(
cols.iter().any(|c| c == col),
"missing pid_records column: {col}"
);
}
}
#[test]
fn cache_lookup_returns_none_for_unknown_doi() {
let dir = tempfile::tempdir().unwrap();
let path = make_test_cache_db(&dir.path().join("cache.sqlite3"));
assert!(cache_lookup(&path, "10.9999/nope").unwrap().is_none());
}
#[test]
fn cache_lookup_finds_inserted_record() {
let dir = tempfile::tempdir().unwrap();
let path = make_test_cache_db(&dir.path().join("cache.sqlite3"));
let data = cache_lookup(&path, "10.5678/cached")
.unwrap()
.expect("should find cached DOI");
assert_eq!(data.id, "https://doi.org/10.5678/cached");
assert_eq!(data.title, "Cached Article");
assert_eq!(data.url, "https://example.com/cached-article");
assert_eq!(data.type_, "JournalArticle");
}
#[test]
fn cache_lookup_empty_string_returns_none() {
let dir = tempfile::tempdir().unwrap();
let path = make_test_cache_db(&dir.path().join("cache.sqlite3"));
assert!(cache_lookup(&path, "").unwrap().is_none());
}
#[test]
fn cache_lookup_on_empty_cache_is_a_miss_not_an_error() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("cache.sqlite3");
cache_open(&path).unwrap();
assert!(cache_lookup(&path, "10.9999/nope").unwrap().is_none());
}
#[test]
fn cache_upsert_replaces_existing_record() {
let dir = tempfile::tempdir().unwrap();
let path = make_test_cache_db(&dir.path().join("cache.sqlite3"));
let raw = serde_json::json!({
"DOI": "10.5678/cached",
"type": "journal-article",
"title": ["Updated Title"],
})
.to_string();
cache_upsert_raw(&path, &[("10.5678/cached".to_string(), 1, raw)]).unwrap();
let data = cache_lookup(&path, "10.5678/cached")
.unwrap()
.expect("should still exist");
assert_eq!(data.title, "Updated Title");
}
pub(crate) fn make_test_relations_db(path: &std::path::Path) -> PathBuf {
let conn = Connection::open(path).expect("open relations db");
conn.execute_batch(
r#"CREATE TABLE pid_relations (
source_id TEXT NOT NULL,
target_id TEXT NOT NULL,
"type" TEXT NOT NULL,
source_year INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (source_id, target_id, "type")
)"#,
)
.expect("create pid_relations");
for (src, tgt, rel_type) in &[
(
"10.5281/zenodo.1000000",
"10.5281/zenodo.1000001",
"HasVersion",
),
(
"10.5281/zenodo.1000000",
"10.5281/zenodo.1000002",
"HasVersion",
),
(
"10.5281/zenodo.9999999",
"10.5281/zenodo.1000000",
"IsVersionOf",
),
] {
conn.execute(
r#"INSERT INTO pid_relations (source_id, target_id, "type") VALUES (?1, ?2, ?3)"#,
params![src, tgt, rel_type],
)
.expect("insert relation");
}
path.to_path_buf()
}
fn versions_db(path: &std::path::Path, links: &[(&str, &str, &str)]) -> PathBuf {
let conn = Connection::open(path).expect("open versions db");
conn.execute_batch(
r#"CREATE TABLE pid_relations (
source_id TEXT NOT NULL,
target_id TEXT NOT NULL,
"type" TEXT NOT NULL,
source_year INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (source_id, target_id, "type")
)"#,
)
.expect("create pid_relations");
for (src, tgt, rel) in links {
conn.execute(
r#"INSERT INTO pid_relations (source_id, target_id, "type") VALUES (?1, ?2, ?3)"#,
params![src, tgt, rel],
)
.expect("insert relation");
}
path.to_path_buf()
}
fn work_hits(ids: &[&str]) -> Vec<SearchResult> {
ids.iter()
.map(|id| SearchResult::named((*id).to_string(), "same title".to_string(), "Work"))
.collect()
}
fn ids_of(results: &[SearchResult]) -> Vec<&str> {
results.iter().map(|r| r.id.as_str()).collect()
}
#[test]
fn drop_superseded_versions_keeps_the_higher_ranked_record() {
let dir = tempfile::tempdir().unwrap();
let path = versions_db(
&dir.path().join("v.sqlite3"),
&[
(
"10.59350/27wky-wpg41",
"10.59350/4w0sm-qww29",
"IsVersionOf",
),
("10.59350/4w0sm-qww29", "10.59350/27wky-wpg41", "HasVersion"),
],
);
let mut results = work_hits(&["10.59350/4w0sm-qww29", "10.59350/27wky-wpg41"]);
drop_superseded_versions(&path, &mut results);
assert_eq!(ids_of(&results), vec!["10.59350/4w0sm-qww29"]);
let mut results = work_hits(&["10.59350/27wky-wpg41", "10.59350/4w0sm-qww29"]);
drop_superseded_versions(&path, &mut results);
assert_eq!(ids_of(&results), vec!["10.59350/27wky-wpg41"]);
}
#[test]
fn drop_superseded_versions_survives_a_mutual_pair() {
let dir = tempfile::tempdir().unwrap();
let path = versions_db(
&dir.path().join("v.sqlite3"),
&[
("10.1/a", "10.1/b", "IsVersionOf"),
("10.1/b", "10.1/a", "IsVersionOf"),
],
);
let mut results = work_hits(&["10.1/a", "10.1/b"]);
drop_superseded_versions(&path, &mut results);
assert_eq!(ids_of(&results), vec!["10.1/a"], "exactly one must survive");
}
#[test]
fn drop_superseded_versions_leaves_unrelated_results_alone() {
let dir = tempfile::tempdir().unwrap();
let path = versions_db(
&dir.path().join("v.sqlite3"),
&[("10.1/a", "10.1/elsewhere", "IsVersionOf")],
);
let mut results = work_hits(&["10.1/a", "10.1/b", "10.1/c"]);
drop_superseded_versions(&path, &mut results);
assert_eq!(ids_of(&results), vec!["10.1/a", "10.1/b", "10.1/c"]);
}
pub(crate) fn make_test_pid_relations_db(path: &std::path::Path) -> PathBuf {
let conn = Connection::open(path).expect("open pid_relations db");
conn.execute_batch(
r#"CREATE TABLE pid_relations (
source_id TEXT NOT NULL,
target_id TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'References',
source_year INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (source_id, target_id, type)
)"#,
)
.expect("create pid_relations");
let rows: &[(&str, &str, &str, i64)] = &[
("10.1234/citing-a", "10.5678/cited-z", "References", 2023),
("10.1234/citing-b", "10.5678/cited-z", "References", 2024),
(
"10.1234/supplement-s",
"10.5678/cited-z",
"IsSupplementedBy",
2025,
),
("10.1234/citing-c", "10.5678/other", "References", 2024),
("10.1234/citing-a", "0000-0002-1825-0097", "hasPerson", 0),
("10.1234/citing-b", "0000-0002-1825-0097", "hasPerson", 0),
("10.1234/citing-a", "04aj4c181", "hasOrganization", 0),
];
for (source_id, target_id, type_, source_year) in rows {
conn.execute(
r#"INSERT INTO pid_relations (source_id, target_id, type, source_year) VALUES (?1, ?2, ?3, ?4)"#,
params![source_id, target_id, type_, source_year],
)
.expect("insert pid_relations row");
}
path.to_path_buf()
}
#[test]
fn lookup_target_relations_empty_doi_returns_empty() {
let dir = tempfile::tempdir().unwrap();
let path = make_test_pid_relations_db(&dir.path().join("refs.sqlite3"));
let result = lookup_target_relations(&path, "").unwrap();
assert!(result.is_empty());
}
#[test]
fn lookup_target_relations_table_absent_returns_empty() {
let dir = tempfile::tempdir().unwrap();
let path = make_test_db(&dir.path().join("test.sqlite3"));
let result = lookup_target_relations(&path, "https://doi.org/10.5678/cited-z").unwrap();
assert!(result.is_empty());
}
#[test]
fn lookup_target_relations_finds_citing_works() {
let dir = tempfile::tempdir().unwrap();
let path = make_test_pid_relations_db(&dir.path().join("refs.sqlite3"));
let mut ids: Vec<_> = lookup_target_relations(&path, "https://doi.org/10.5678/cited-z")
.unwrap()
.into_iter()
.map(|(id, _type_, _year)| id)
.collect();
ids.sort();
assert_eq!(
ids,
vec![
"https://doi.org/10.1234/citing-a",
"https://doi.org/10.1234/citing-b",
"https://doi.org/10.1234/supplement-s",
]
);
}
#[test]
fn lookup_target_relations_returns_empty_for_uncited_doi() {
let dir = tempfile::tempdir().unwrap();
let path = make_test_pid_relations_db(&dir.path().join("refs.sqlite3"));
let result = lookup_target_relations(&path, "https://doi.org/10.9999/no-such-doi").unwrap();
assert!(result.is_empty());
}
#[test]
fn count_for_id_returns_citation_count_for_doi() {
let dir = tempfile::tempdir().unwrap();
let path = make_test_pid_relations_db(&dir.path().join("refs.sqlite3"));
let count = count_for_id(&path, "https://doi.org/10.5678/cited-z").unwrap();
assert_eq!(count.citations, Some(3));
assert_eq!(count.works, None);
}
#[test]
fn count_for_id_returns_zero_for_uncited_doi() {
let dir = tempfile::tempdir().unwrap();
let path = make_test_pid_relations_db(&dir.path().join("refs.sqlite3"));
let count = count_for_id(&path, "https://doi.org/10.9999/uncited").unwrap();
assert_eq!(count.citations, Some(0));
assert_eq!(count.works, None);
}
#[test]
fn count_for_id_returns_works_count_for_orcid() {
let dir = tempfile::tempdir().unwrap();
let path = make_test_pid_relations_db(&dir.path().join("refs.sqlite3"));
let count = count_for_id(&path, "https://orcid.org/0000-0002-1825-0097").unwrap();
assert_eq!(count.citations, None);
assert_eq!(count.works, Some(2));
}
#[test]
fn count_for_id_returns_works_count_for_ror() {
let dir = tempfile::tempdir().unwrap();
let path = make_test_pid_relations_db(&dir.path().join("refs.sqlite3"));
let count = count_for_id(&path, "https://ror.org/04aj4c181").unwrap();
assert_eq!(count.citations, None);
assert_eq!(count.works, Some(1));
}
#[test]
fn count_for_id_returns_none_for_unrecognised_id() {
let dir = tempfile::tempdir().unwrap();
let path = make_test_pid_relations_db(&dir.path().join("refs.sqlite3"));
let count = count_for_id(&path, "https://example.com/not-a-pid").unwrap();
assert_eq!(count.citations, None);
assert_eq!(count.works, None);
}
#[test]
fn lookup_source_relations_empty_doi_returns_empty() {
let dir = tempfile::tempdir().unwrap();
let path = make_test_relations_db(&dir.path().join("r.sqlite3"));
let result = lookup_source_relations(&path, "").unwrap();
assert!(result.is_empty());
}
#[test]
fn lookup_source_relations_table_absent_returns_empty() {
let dir = tempfile::tempdir().unwrap();
let path = make_test_db(&dir.path().join("test.sqlite3"));
let result =
lookup_source_relations(&path, "https://doi.org/10.5281/zenodo.1000000").unwrap();
assert!(result.is_empty());
}
#[test]
fn lookup_source_relations_finds_has_version_rows() {
let dir = tempfile::tempdir().unwrap();
let path = make_test_relations_db(&dir.path().join("r.sqlite3"));
let mut rels =
lookup_source_relations(&path, "https://doi.org/10.5281/zenodo.1000000").unwrap();
rels.sort();
assert_eq!(
rels,
vec![
(
"https://doi.org/10.5281/zenodo.1000001".to_string(),
"HasVersion".to_string()
),
(
"https://doi.org/10.5281/zenodo.1000002".to_string(),
"HasVersion".to_string()
),
]
);
}
#[test]
fn lookup_source_relations_accepts_doi_suffix_form() {
let dir = tempfile::tempdir().unwrap();
let path = make_test_relations_db(&dir.path().join("r.sqlite3"));
let mut rels = lookup_source_relations(&path, "10.5281/zenodo.1000000").unwrap();
rels.sort();
assert_eq!(rels.len(), 2);
assert!(
rels.iter()
.any(|(id, t)| id == "https://doi.org/10.5281/zenodo.1000001" && t == "HasVersion")
);
}
#[test]
fn relation_readers_exclude_structural_entity_edges() {
let dir = tempfile::tempdir().unwrap();
let path = make_test_pid_relations_db(&dir.path().join("refs.sqlite3"));
let types: Vec<String> = lookup_source_relations(&path, "https://doi.org/10.1234/citing-a")
.unwrap()
.into_iter()
.map(|(_id, type_)| type_)
.collect();
assert!(
!types
.iter()
.any(|t| t == "hasPerson" || t == "hasOrganization"),
"structural edges leaked into source relations: {types:?}"
);
let inbound =
lookup_target_relations(&path, "https://orcid.org/0000-0002-1825-0097").unwrap();
assert!(
inbound.is_empty(),
"structural edges leaked into target relations: {inbound:?}"
);
}
#[test]
fn lookup_target_relations_returns_resolver_urls_not_bare_pids() {
let dir = tempfile::tempdir().unwrap();
let path = make_test_pid_relations_db(&dir.path().join("refs.sqlite3"));
let rels = lookup_target_relations(&path, "https://doi.org/10.5678/cited-z").unwrap();
assert!(!rels.is_empty(), "fixture should yield inbound citations");
assert!(
rels.iter()
.all(|(id, _, _)| id.starts_with("https://doi.org/")),
"ids must be re-expanded to resolver URLs: {rels:?}"
);
}
#[test]
fn lookup_source_relations_returns_empty_for_unknown_doi() {
let dir = tempfile::tempdir().unwrap();
let path = make_test_relations_db(&dir.path().join("r.sqlite3"));
let result = lookup_source_relations(&path, "https://doi.org/10.9999/no-such-doi").unwrap();
assert!(result.is_empty());
}
pub(crate) fn add_container_edges(path: &std::path::Path, issn: &str, n: usize) -> String {
let conn = rusqlite::Connection::open(path).expect("open");
let cpid: String = conn
.query_row(
r#"SELECT pid FROM pid_annotations WHERE "issn" = ?1"#,
rusqlite::params![issn],
|r| r.get(0),
)
.expect("container pid");
let tx = conn.unchecked_transaction().expect("tx");
for i in 0..n {
tx.execute(
r#"INSERT OR IGNORE INTO pid_relations
("source_id","target_id","type","source_year","source_type")
VALUES (?1, ?2, 'hasContainer', ?3, 'JournalArticle')"#,
rusqlite::params![format!("10.5555/w{i}"), &cpid, 2000 + (i % 25) as i64],
)
.expect("edge");
}
tx.commit().expect("commit");
cpid
}
pub(crate) fn add_person_edges(
path: &std::path::Path,
orcid: &str,
rows: &[(&str, i64, &str)],
) {
let conn = rusqlite::Connection::open(path).expect("open");
conn.execute_batch(
r#"CREATE TABLE IF NOT EXISTS pid_relations (
source_id TEXT NOT NULL,
target_id TEXT NOT NULL,
"type" TEXT NOT NULL,
source_year INTEGER NOT NULL DEFAULT 0,
source_type TEXT NOT NULL DEFAULT '',
PRIMARY KEY (source_id, target_id, "type")
);"#,
)
.expect("pid_relations");
for (doi, year, type_) in rows {
conn.execute(
r#"INSERT INTO pid_relations
("source_id","target_id","type","source_year","source_type")
VALUES (?1, ?2, 'hasPerson', ?3, ?4)"#,
rusqlite::params![doi, orcid, year, type_],
)
.expect("edge");
}
}
pub(crate) fn add_work_identifiers(
path: &std::path::Path,
pid: &str,
pmid: &str,
openalex: &str,
source_id: i64,
) {
let conn = rusqlite::Connection::open(path).expect("open");
conn.execute_batch(
r#"CREATE TABLE IF NOT EXISTS pid_annotations (
"pid" TEXT PRIMARY KEY NOT NULL, "valid" INTEGER NOT NULL DEFAULT 1,
"pmid" TEXT NOT NULL DEFAULT '', "openalex" TEXT NOT NULL DEFAULT '',
"source_id" INTEGER NOT NULL DEFAULT 0);"#,
)
.expect("pid_annotations");
conn.execute(
r#"INSERT INTO pid_annotations ("pid","pmid","openalex","source_id") VALUES (?1,?2,?3,?4)"#,
rusqlite::params![pid, pmid, openalex, source_id],
)
.expect("annotation");
}
pub(crate) fn make_test_containers_db(path: &std::path::Path) -> PathBuf {
use commonmeta::metadata::{ContainerRecord, Identifier, Publisher};
let container = ContainerRecord {
identifiers: vec![
Identifier {
identifier: "https://openalex.org/S7407056926".to_string(),
identifier_type: "OpenAlex".to_string(),
..Default::default()
},
Identifier {
identifier: "2749-9952".to_string(),
identifier_type: "ISSN".to_string(),
..Default::default()
},
Identifier {
identifier: "https://doi.org/10.53731/front_matter".to_string(),
identifier_type: "DOI".to_string(),
..Default::default()
},
],
type_: "Journal".to_string(),
title: "Front Matter".to_string(),
publisher: Some(Publisher {
name: "Front Matter".to_string(),
..Default::default()
}),
country: "DE".to_string(),
url: "https://blog.front-matter.io".to_string(),
..Default::default()
};
commonmeta::write_container_sqlite(std::slice::from_ref(&container), path)
.expect("register container");
let raw = serde_json::json!({
"DOI": "10.53731/article-1",
"type": "journal-article",
"title": ["A blog post"],
"container-title": ["Front Matter"],
"ISSN": ["2749-9952"],
})
.to_string();
commonmeta::upsert_pid_records(&[("10.53731/article-1".to_string(), 1, raw)], path)
.expect("upsert container work");
commonmeta::backfill_pid_relations(path, &[1]).expect("backfill hasContainer");
path.to_path_buf()
}
pub(crate) fn make_test_duplicate_containers_db(path: &std::path::Path) -> PathBuf {
let path = make_test_containers_db(path);
let raw = serde_json::json!({
"DOI": "10.53731/article-2",
"type": "journal-article",
"title": ["A second blog post"],
"container-title": ["Front Matter"],
})
.to_string();
commonmeta::upsert_pid_records(&[("10.53731/article-2".to_string(), 1, raw)], &path)
.expect("upsert second work");
let conn = rusqlite::Connection::open(&path).expect("open fixture");
conn.execute(
r#"INSERT INTO pid_annotations ("pid","openalex","source_id")
VALUES ('duplicate-container-pid', 'https://openalex.org/S7407056926', 5)"#,
[],
)
.expect("insert duplicate container row");
conn.execute(
r#"INSERT INTO pid_relations ("source_id","target_id","type","source_year")
VALUES ('10.53731/article-2', 'duplicate-container-pid', 'hasContainer', 2024)"#,
[],
)
.expect("link the second work to the duplicate");
path
}
fn container_from_lookup(bytes: &[u8]) -> serde_json::Value {
let arr: serde_json::Value = serde_json::from_slice(bytes).unwrap();
arr.as_array().unwrap()[0].clone()
}
#[test]
fn lookup_container_by_issn_openalex_and_doi() {
let dir = tempfile::tempdir().unwrap();
let path = make_test_containers_db(&dir.path().join("c.sqlite3"));
for id in &[
"2749-9952",
"S7407056926",
"https://openalex.org/S7407056926",
"10.53731/front_matter",
"https://doi.org/10.53731/front_matter",
] {
let bytes = lookup_container(&path, id)
.unwrap()
.unwrap_or_else(|| panic!("container should resolve for '{id}'"));
let c = container_from_lookup(&bytes);
assert_eq!(c["title"], "Front Matter", "id '{id}'");
}
}
#[test]
fn lookup_container_applies_front_matter_blog_override() {
let dir = tempfile::tempdir().unwrap();
let path = make_test_containers_db(&dir.path().join("c.sqlite3"));
let bytes = lookup_container(&path, "2749-9952").unwrap().unwrap();
let c = container_from_lookup(&bytes);
assert_eq!(c["type"], "Blog");
}
#[test]
fn lookup_container_carries_no_works() {
let dir = tempfile::tempdir().unwrap();
let path = make_test_containers_db(&dir.path().join("c.sqlite3"));
let bytes = lookup_container(&path, "2749-9952").unwrap().unwrap();
let arr: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(arr.as_array().map(Vec::len), Some(1), "the container alone");
}
#[test]
fn lookup_container_returns_none_for_unknown() {
let dir = tempfile::tempdir().unwrap();
let path = make_test_containers_db(&dir.path().join("c.sqlite3"));
assert!(lookup_container(&path, "0000-0000").unwrap().is_none());
assert!(lookup_container(&path, "S9999999999").unwrap().is_none());
}
#[test]
fn lookup_container_table_absent_returns_none() {
let dir = tempfile::tempdir().unwrap();
let path = make_test_db(&dir.path().join("test.sqlite3"));
assert!(lookup_container(&path, "2749-9952").unwrap().is_none());
}
#[test]
fn container_record_reads_and_applies_override() {
let dir = tempfile::tempdir().unwrap();
let path = make_test_containers_db(&dir.path().join("c.sqlite3"));
let rec = container_record(&path, "2749-9952").expect("record present");
assert_eq!(rec.title, "Front Matter");
assert_eq!(rec.type_, "Blog"); assert!(
rec.identifiers
.iter()
.any(|i| i.identifier_type == "ISSN" && i.identifier == "2749-9952")
);
}
#[test]
fn container_works_local_reads_linked_works() {
let dir = tempfile::tempdir().unwrap();
let path = make_test_containers_db(&dir.path().join("c.sqlite3"));
let page = commonmeta::WorksPage::new(Some(50), 0);
let (works, total) = container_works_local_page(&path, "10.53731/front_matter", page);
assert_eq!(total, 1);
assert_eq!(works.len(), 1);
assert_eq!(works[0].id, "https://doi.org/10.53731/article-1");
}
#[test]
fn container_works_local_empty_for_unknown() {
let dir = tempfile::tempdir().unwrap();
let path = make_test_containers_db(&dir.path().join("c.sqlite3"));
let page = commonmeta::WorksPage::new(Some(50), 0);
let (works, total) = container_works_local_page(&path, "0000-0000", page);
assert!(works.is_empty());
assert_eq!(total, 0, "an unknown container has no works to page over");
}
#[test]
fn cache_upsert_raw_round_trips() {
let dir = tempfile::tempdir().unwrap();
let cache = cache_open(&dir.path().join("cache.sqlite3")).expect("cache_open");
let raw = serde_json::json!({
"DOI": "10.53731/fetched-1",
"type": "journal-article",
"title": ["Fetched work"],
})
.to_string();
cache_upsert_raw(&cache, &[("10.53731/fetched-1".to_string(), 1, raw)])
.expect("cache upsert raw");
let got = cache_lookup(&cache, "10.53731/fetched-1")
.unwrap()
.expect("cached work");
assert_eq!(got.id, "https://doi.org/10.53731/fetched-1");
assert_eq!(got.title, "Fetched work");
}
#[test]
fn lookup_source_relations_returns_all_types() {
let dir = tempfile::tempdir().unwrap();
let path = make_test_relations_db(&dir.path().join("r.sqlite3"));
let result =
lookup_source_relations(&path, "https://doi.org/10.5281/zenodo.9999999").unwrap();
assert_eq!(result.len(), 1);
assert_eq!(
result[0],
(
"https://doi.org/10.5281/zenodo.1000000".to_string(),
"IsVersionOf".to_string()
)
);
}
}