prefix_register/lib.rs
1// Copyright TELICENT LTD
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! # Prefix Register
16//!
17//! **Status: Beta** - API may change before 1.0 release.
18//!
19//! A PostgreSQL-backed namespace prefix registry for CURIE expansion
20//! and prefix management.
21//!
22//! This library provides bidirectional mapping between namespace prefixes
23//! (like "foaf", "rdf", "schema") and their full URI bases, optimised for
24//! use in RDF/semantic web applications.
25//!
26//! **API:** Async-only, built on tokio and deadpool-postgres for high concurrency.
27//!
28//! ## Features
29//!
30//! - **Async-only** - Built on tokio for high concurrency
31//! - **In-memory caching** - Prefixes loaded on startup for fast CURIE expansion
32//! - **First-prefix-wins** - Each URI can only have one registered prefix
33//! - **Batch operations** - Efficiently store multiple prefixes at once
34//! - **PostgreSQL backend** - Durable, scalable storage with connection pooling
35//!
36//! ## Use Cases
37//!
38//! - CURIE expansion in RDF processing
39//! - Namespace prefix management for semantic web applications
40//! - Prefix discovery from Turtle, JSON-LD, XML documents
41//!
42//! ## Example
43//!
44//! ```rust,no_run
45//! use prefix_register::PrefixRegistry;
46//!
47//! #[tokio::main]
48//! async fn main() -> prefix_register::Result<()> {
49//! // Connect to PostgreSQL (schema must have namespaces table)
50//! let registry = PrefixRegistry::new(
51//! "postgres://localhost/mydb",
52//! 10, // max connections
53//! ).await?;
54//!
55//! // Store a prefix (only if URI doesn't already have one)
56//! let stored = registry.store_prefix_if_new("foaf", "http://xmlns.com/foaf/0.1/").await?;
57//! println!("Prefix stored: {}", stored);
58//!
59//! // Expand a CURIE
60//! if let Some(uri) = registry.expand_curie("foaf", "Person").await? {
61//! println!("foaf:Person = {}", uri);
62//! }
63//!
64//! Ok(())
65//! }
66//! ```
67
68mod error;
69
70pub use error::{ConfigurationError, Error, Result};
71
72use deadpool_postgres::{Config as PoolConfig, Pool, Runtime};
73use std::collections::HashMap;
74use tokio::sync::RwLock;
75use tokio_postgres::NoTls;
76
77/// Result of a batch store operation.
78///
79/// Provides detailed information about what happened during
80/// a batch prefix store, allowing callers to log appropriately.
81#[derive(Debug, Clone, Default, PartialEq, Eq)]
82pub struct BatchStoreResult {
83 /// Number of new prefixes successfully stored.
84 pub stored: usize,
85 /// Number of prefixes skipped (URI already had a prefix).
86 pub skipped: usize,
87}
88
89impl BatchStoreResult {
90 /// Total number of prefixes processed.
91 pub fn total(&self) -> usize {
92 self.stored + self.skipped
93 }
94
95 /// Returns true if all prefixes were stored (none skipped).
96 pub fn all_stored(&self) -> bool {
97 self.skipped == 0
98 }
99
100 /// Returns true if no prefixes were stored (all skipped or empty input).
101 pub fn none_stored(&self) -> bool {
102 self.stored == 0
103 }
104}
105
106/// Registry for namespace prefixes.
107///
108/// Provides async access to the namespaces table in PostgreSQL.
109/// Prefixes are stored with their corresponding URIs, following
110/// the rule that each URI can only have one prefix (first one wins).
111///
112/// The registry maintains an in-memory cache of all prefixes, which
113/// is populated on startup and updated as new prefixes are stored.
114/// This ensures fast CURIE expansion without database round-trips.
115pub struct PrefixRegistry {
116 /// Connection pool for the prefix database.
117 pool: Pool,
118 /// In-memory cache of prefix -> URI mappings for fast lookups.
119 /// This cache is populated on startup and updated as new prefixes are stored.
120 prefix_cache: RwLock<HashMap<String, String>>,
121}
122
123impl PrefixRegistry {
124 /// Create a new prefix registry connected to the given PostgreSQL database.
125 ///
126 /// The registry will connect to the database and pre-populate its
127 /// in-memory cache with existing prefixes for fast CURIE expansion.
128 ///
129 /// # Arguments
130 ///
131 /// * `database_url` - PostgreSQL connection URL (e.g., "postgres://user:pass@host:port/db")
132 /// * `max_connections` - Maximum number of connections in the pool
133 ///
134 /// # Errors
135 ///
136 /// Returns an error if the database connection cannot be established
137 /// or if the namespaces table does not exist.
138 ///
139 /// # Example
140 ///
141 /// ```rust,no_run
142 /// use prefix_register::PrefixRegistry;
143 ///
144 /// # async fn example() -> prefix_register::Result<()> {
145 /// let registry = PrefixRegistry::new(
146 /// "postgres://localhost/mydb",
147 /// 10,
148 /// ).await?;
149 /// # Ok(())
150 /// # }
151 /// ```
152 pub async fn new(database_url: &str, max_connections: usize) -> Result<Self> {
153 if max_connections == 0 {
154 return Err(ConfigurationError::InvalidMaxConnections(max_connections).into());
155 }
156 if database_url.is_empty() {
157 return Err(ConfigurationError::InvalidDatabaseUrl("empty URL".to_string()).into());
158 }
159
160 // Parse the database URL and create pool configuration
161 let mut cfg = PoolConfig::new();
162 cfg.url = Some(database_url.to_string());
163 cfg.pool = Some(deadpool_postgres::PoolConfig::new(max_connections));
164
165 // Create the connection pool
166 let pool = cfg.create_pool(Some(Runtime::Tokio1), NoTls)?;
167
168 // Verify connection by getting a client
169 let client = pool.get().await?;
170
171 // Pre-populate the cache with existing prefixes
172 let rows = client
173 .query("SELECT prefix, uri FROM namespaces", &[])
174 .await?;
175
176 let mut cache = HashMap::new();
177 for row in rows {
178 let prefix: String = row.get(0);
179 let uri: String = row.get(1);
180 cache.insert(prefix, uri);
181 }
182
183 Ok(Self {
184 pool,
185 prefix_cache: RwLock::new(cache),
186 })
187 }
188
189 /// Get the URI for a given prefix.
190 ///
191 /// First checks the in-memory cache, then falls back to the database.
192 /// This is the primary method used for CURIE expansion.
193 ///
194 /// # Arguments
195 ///
196 /// * `prefix` - The namespace prefix (e.g., "foaf", "rdf")
197 ///
198 /// # Returns
199 ///
200 /// The URI if the prefix is known, None otherwise.
201 ///
202 /// # Example
203 ///
204 /// ```rust,no_run
205 /// # use prefix_register::PrefixRegistry;
206 /// # async fn example(registry: &PrefixRegistry) -> prefix_register::Result<()> {
207 /// if let Some(uri) = registry.get_uri_for_prefix("foaf").await? {
208 /// println!("foaf = {}", uri);
209 /// }
210 /// # Ok(())
211 /// # }
212 /// ```
213 pub async fn get_uri_for_prefix(&self, prefix: &str) -> Result<Option<String>> {
214 // Check cache first (fast path)
215 {
216 let cache = self.prefix_cache.read().await;
217 if let Some(uri) = cache.get(prefix) {
218 return Ok(Some(uri.clone()));
219 }
220 }
221
222 // Cache miss - check database (handles concurrent updates)
223 let client = self.pool.get().await?;
224 let row = client
225 .query_opt("SELECT uri FROM namespaces WHERE prefix = $1", &[&prefix])
226 .await?;
227
228 if let Some(row) = row {
229 let uri: String = row.get(0);
230 // Update cache for future lookups
231 {
232 let mut cache = self.prefix_cache.write().await;
233 cache.insert(prefix.to_string(), uri.clone());
234 }
235 Ok(Some(uri))
236 } else {
237 Ok(None)
238 }
239 }
240
241 /// Get the prefix for a given URI.
242 ///
243 /// Used to check if a URI already has a registered prefix.
244 ///
245 /// # Arguments
246 ///
247 /// * `uri` - The full namespace URI
248 ///
249 /// # Returns
250 ///
251 /// The prefix if the URI is registered, None otherwise.
252 ///
253 /// # Example
254 ///
255 /// ```rust,no_run
256 /// # use prefix_register::PrefixRegistry;
257 /// # async fn example(registry: &PrefixRegistry) -> prefix_register::Result<()> {
258 /// if let Some(prefix) = registry.get_prefix_for_uri("http://xmlns.com/foaf/0.1/").await? {
259 /// println!("URI has prefix: {}", prefix);
260 /// }
261 /// # Ok(())
262 /// # }
263 /// ```
264 pub async fn get_prefix_for_uri(&self, uri: &str) -> Result<Option<String>> {
265 let client = self.pool.get().await?;
266 let row = client
267 .query_opt("SELECT prefix FROM namespaces WHERE uri = $1", &[&uri])
268 .await?;
269
270 Ok(row.map(|r| r.get(0)))
271 }
272
273 /// Store a new prefix if the URI doesn't already have one.
274 ///
275 /// This follows the "first prefix wins" rule - if a URI already
276 /// has a prefix registered, the new prefix is ignored.
277 ///
278 /// # Arguments
279 ///
280 /// * `prefix` - The namespace prefix to store
281 /// * `uri` - The full namespace URI
282 ///
283 /// # Returns
284 ///
285 /// `true` if the prefix was stored, `false` if the URI already had a prefix.
286 ///
287 /// # Example
288 ///
289 /// ```rust,no_run
290 /// # use prefix_register::PrefixRegistry;
291 /// # async fn example(registry: &PrefixRegistry) -> prefix_register::Result<()> {
292 /// let stored = registry.store_prefix_if_new("schema", "https://schema.org/").await?;
293 /// if stored {
294 /// println!("New prefix stored");
295 /// } else {
296 /// println!("URI already has a prefix");
297 /// }
298 /// # Ok(())
299 /// # }
300 /// ```
301 pub async fn store_prefix_if_new(&self, prefix: &str, uri: &str) -> Result<bool> {
302 let client = self.pool.get().await?;
303
304 // Use INSERT ... ON CONFLICT to atomically check and insert
305 // This handles race conditions between multiple consumers
306 let result = client
307 .execute(
308 "INSERT INTO namespaces (uri, prefix) VALUES ($1, $2)
309 ON CONFLICT (uri) DO NOTHING",
310 &[&uri, &prefix],
311 )
312 .await?;
313
314 if result > 0 {
315 // Successfully inserted - update our cache
316 let mut cache = self.prefix_cache.write().await;
317 cache.insert(prefix.to_string(), uri.to_string());
318 Ok(true)
319 } else {
320 // URI already has a prefix
321 Ok(false)
322 }
323 }
324
325 /// Store multiple prefixes, skipping any where the URI already has a prefix.
326 ///
327 /// More efficient than calling store_prefix_if_new repeatedly.
328 ///
329 /// # Arguments
330 ///
331 /// * `prefixes` - Iterator of (prefix, uri) pairs to store
332 ///
333 /// # Returns
334 ///
335 /// A [`BatchStoreResult`] with counts of stored and skipped prefixes.
336 ///
337 /// # Example
338 ///
339 /// ```rust,no_run
340 /// # use prefix_register::PrefixRegistry;
341 /// # async fn example(registry: &PrefixRegistry) -> prefix_register::Result<()> {
342 /// let prefixes = vec![
343 /// ("foaf", "http://xmlns.com/foaf/0.1/"),
344 /// ("rdf", "http://www.w3.org/1999/02/22-rdf-syntax-ns#"),
345 /// ("schema", "https://schema.org/"),
346 /// ];
347 /// let result = registry.store_prefixes_if_new(prefixes).await?;
348 /// println!("Stored {}, skipped {}", result.stored, result.skipped);
349 /// # Ok(())
350 /// # }
351 /// ```
352 pub async fn store_prefixes_if_new<'a, I>(&self, prefixes: I) -> Result<BatchStoreResult>
353 where
354 I: IntoIterator<Item = (&'a str, &'a str)>,
355 {
356 let prefixes: Vec<_> = prefixes.into_iter().collect();
357 if prefixes.is_empty() {
358 return Ok(BatchStoreResult::default());
359 }
360
361 let client = self.pool.get().await?;
362 let mut result = BatchStoreResult {
363 stored: 0,
364 skipped: 0,
365 };
366 let mut cache_updates = Vec::new();
367
368 // Process each prefix
369 for (prefix, uri) in prefixes {
370 let rows_affected = client
371 .execute(
372 "INSERT INTO namespaces (uri, prefix) VALUES ($1, $2)
373 ON CONFLICT (uri) DO NOTHING",
374 &[&uri, &prefix],
375 )
376 .await?;
377
378 if rows_affected > 0 {
379 result.stored += 1;
380 cache_updates.push((prefix.to_string(), uri.to_string()));
381 } else {
382 result.skipped += 1;
383 }
384 }
385
386 // Batch update the cache
387 if !cache_updates.is_empty() {
388 let mut cache = self.prefix_cache.write().await;
389 for (prefix, uri) in cache_updates {
390 cache.insert(prefix, uri);
391 }
392 }
393
394 Ok(result)
395 }
396
397 /// Expand a CURIE (Compact URI) to a full URI.
398 ///
399 /// Given a prefix and local name, returns the expanded URI
400 /// if the prefix is known.
401 ///
402 /// # Arguments
403 ///
404 /// * `prefix` - The namespace prefix (e.g., "foaf")
405 /// * `local_name` - The local part (e.g., "Person")
406 ///
407 /// # Returns
408 ///
409 /// The full URI (e.g., "http://xmlns.com/foaf/0.1/Person")
410 /// or None if the prefix is unknown.
411 ///
412 /// # Example
413 ///
414 /// ```rust,no_run
415 /// # use prefix_register::PrefixRegistry;
416 /// # async fn example(registry: &PrefixRegistry) -> prefix_register::Result<()> {
417 /// if let Some(uri) = registry.expand_curie("foaf", "Person").await? {
418 /// println!("foaf:Person = {}", uri);
419 /// // Output: foaf:Person = http://xmlns.com/foaf/0.1/Person
420 /// }
421 /// # Ok(())
422 /// # }
423 /// ```
424 pub async fn expand_curie(&self, prefix: &str, local_name: &str) -> Result<Option<String>> {
425 if let Some(base_uri) = self.get_uri_for_prefix(prefix).await? {
426 Ok(Some(format!("{}{}", base_uri, local_name)))
427 } else {
428 // Unknown prefix - caller can decide how to handle
429 Ok(None)
430 }
431 }
432
433 /// Get all registered prefixes.
434 ///
435 /// Returns a copy of the in-memory cache containing all prefix -> URI mappings.
436 ///
437 /// # Example
438 ///
439 /// ```rust,no_run
440 /// # use prefix_register::PrefixRegistry;
441 /// # async fn example(registry: &PrefixRegistry) -> prefix_register::Result<()> {
442 /// let prefixes = registry.get_all_prefixes().await;
443 /// for (prefix, uri) in prefixes {
444 /// println!("{}: {}", prefix, uri);
445 /// }
446 /// # Ok(())
447 /// # }
448 /// ```
449 pub async fn get_all_prefixes(&self) -> HashMap<String, String> {
450 self.prefix_cache.read().await.clone()
451 }
452
453 /// Get the number of registered prefixes.
454 ///
455 /// # Example
456 ///
457 /// ```rust,no_run
458 /// # use prefix_register::PrefixRegistry;
459 /// # async fn example(registry: &PrefixRegistry) -> prefix_register::Result<()> {
460 /// let count = registry.prefix_count().await;
461 /// println!("Registered prefixes: {}", count);
462 /// # Ok(())
463 /// # }
464 /// ```
465 pub async fn prefix_count(&self) -> usize {
466 self.prefix_cache.read().await.len()
467 }
468}
469
470#[cfg(test)]
471mod tests {
472 use super::*;
473
474 // ==================== Unit Tests ====================
475 // These run without a database
476
477 #[test]
478 fn test_configuration_error_max_connections() {
479 let err = ConfigurationError::InvalidMaxConnections(0);
480 assert!(err.to_string().contains("max_connections"));
481 }
482
483 #[test]
484 fn test_configuration_error_database_url() {
485 let err = ConfigurationError::InvalidDatabaseUrl("empty".to_string());
486 assert!(err.to_string().contains("database_url"));
487 }
488
489 #[test]
490 fn test_batch_store_result_default() {
491 let result = BatchStoreResult::default();
492 assert_eq!(result.stored, 0);
493 assert_eq!(result.skipped, 0);
494 assert_eq!(result.total(), 0);
495 assert!(result.all_stored());
496 assert!(result.none_stored());
497 }
498
499 #[test]
500 fn test_batch_store_result_all_stored() {
501 let result = BatchStoreResult {
502 stored: 5,
503 skipped: 0,
504 };
505 assert_eq!(result.total(), 5);
506 assert!(result.all_stored());
507 assert!(!result.none_stored());
508 }
509
510 #[test]
511 fn test_batch_store_result_mixed() {
512 let result = BatchStoreResult {
513 stored: 3,
514 skipped: 2,
515 };
516 assert_eq!(result.total(), 5);
517 assert!(!result.all_stored());
518 assert!(!result.none_stored());
519 }
520
521 #[test]
522 fn test_batch_store_result_all_skipped() {
523 let result = BatchStoreResult {
524 stored: 0,
525 skipped: 5,
526 };
527 assert_eq!(result.total(), 5);
528 assert!(!result.all_stored());
529 assert!(result.none_stored());
530 }
531
532 // ==================== Integration Tests ====================
533 // These require DATABASE_URL to be set (provided by CI)
534
535 /// Helper to get database URL from environment.
536 /// Returns None if not set (skips integration tests locally).
537 fn get_test_database_url() -> Option<String> {
538 std::env::var("DATABASE_URL").ok()
539 }
540
541 /// Helper to clean up test data. Uses a unique prefix to avoid conflicts.
542 async fn cleanup_test_data(registry: &PrefixRegistry, test_prefix: &str) {
543 let client = registry.pool.get().await.unwrap();
544 client
545 .execute(
546 "DELETE FROM namespaces WHERE prefix LIKE $1",
547 &[&format!("{}%", test_prefix)],
548 )
549 .await
550 .unwrap();
551 }
552
553 #[tokio::test]
554 async fn test_new_with_invalid_max_connections() {
555 let result = PrefixRegistry::new("postgres://localhost/test", 0).await;
556 assert!(matches!(
557 result,
558 Err(Error::Configuration(
559 ConfigurationError::InvalidMaxConnections(0)
560 ))
561 ));
562 }
563
564 #[tokio::test]
565 async fn test_new_with_empty_url() {
566 let result = PrefixRegistry::new("", 5).await;
567 assert!(matches!(
568 result,
569 Err(Error::Configuration(
570 ConfigurationError::InvalidDatabaseUrl(_)
571 ))
572 ));
573 }
574
575 #[tokio::test]
576 async fn test_store_and_retrieve_prefix() {
577 let Some(db_url) = get_test_database_url() else {
578 return; // Skip if no database
579 };
580
581 let registry = PrefixRegistry::new(&db_url, 5).await.unwrap();
582 let test_prefix = "test_sr_";
583 cleanup_test_data(®istry, test_prefix).await;
584
585 // Store a new prefix
586 let prefix = format!("{test_prefix}foaf");
587 let uri = "http://xmlns.com/foaf/0.1/";
588 let stored = registry.store_prefix_if_new(&prefix, uri).await.unwrap();
589 assert!(stored, "First store should succeed");
590
591 // Retrieve by prefix
592 let retrieved = registry.get_uri_for_prefix(&prefix).await.unwrap();
593 assert_eq!(retrieved, Some(uri.to_string()));
594
595 // Retrieve by URI
596 let retrieved_prefix = registry.get_prefix_for_uri(uri).await.unwrap();
597 assert_eq!(retrieved_prefix, Some(prefix.clone()));
598
599 cleanup_test_data(®istry, test_prefix).await;
600 }
601
602 #[tokio::test]
603 async fn test_first_prefix_wins() {
604 let Some(db_url) = get_test_database_url() else {
605 return;
606 };
607
608 let registry = PrefixRegistry::new(&db_url, 5).await.unwrap();
609 let test_prefix = "test_fpw_";
610 cleanup_test_data(®istry, test_prefix).await;
611
612 let uri = "http://example.org/test/first-wins/";
613 let first_prefix = format!("{test_prefix}first");
614 let second_prefix = format!("{test_prefix}second");
615
616 // Store first prefix
617 let stored1 = registry
618 .store_prefix_if_new(&first_prefix, uri)
619 .await
620 .unwrap();
621 assert!(stored1, "First prefix should be stored");
622
623 // Try to store second prefix for same URI
624 let stored2 = registry
625 .store_prefix_if_new(&second_prefix, uri)
626 .await
627 .unwrap();
628 assert!(!stored2, "Second prefix should be rejected");
629
630 // Verify the first prefix is still there
631 let retrieved = registry.get_prefix_for_uri(uri).await.unwrap();
632 assert_eq!(retrieved, Some(first_prefix));
633
634 cleanup_test_data(®istry, test_prefix).await;
635 }
636
637 #[tokio::test]
638 async fn test_expand_curie() {
639 let Some(db_url) = get_test_database_url() else {
640 return;
641 };
642
643 let registry = PrefixRegistry::new(&db_url, 5).await.unwrap();
644 let test_prefix = "test_ec_";
645 cleanup_test_data(®istry, test_prefix).await;
646
647 let prefix = format!("{test_prefix}schema");
648 let uri = "https://schema.org/";
649 registry.store_prefix_if_new(&prefix, uri).await.unwrap();
650
651 // Expand known prefix
652 let expanded = registry.expand_curie(&prefix, "Person").await.unwrap();
653 assert_eq!(expanded, Some("https://schema.org/Person".to_string()));
654
655 // Unknown prefix returns None
656 let unknown = registry
657 .expand_curie(&format!("{test_prefix}unknown"), "Thing")
658 .await
659 .unwrap();
660 assert_eq!(unknown, None);
661
662 cleanup_test_data(®istry, test_prefix).await;
663 }
664
665 #[tokio::test]
666 async fn test_batch_store_prefixes() {
667 let Some(db_url) = get_test_database_url() else {
668 return;
669 };
670
671 let registry = PrefixRegistry::new(&db_url, 5).await.unwrap();
672 let test_prefix = "test_bs_";
673 cleanup_test_data(®istry, test_prefix).await;
674
675 let prefixes = [
676 (
677 format!("{test_prefix}rdf"),
678 "http://www.w3.org/1999/02/22-rdf-syntax-ns#".to_string(),
679 ),
680 (
681 format!("{test_prefix}rdfs"),
682 "http://www.w3.org/2000/01/rdf-schema#".to_string(),
683 ),
684 (
685 format!("{test_prefix}owl"),
686 "http://www.w3.org/2002/07/owl#".to_string(),
687 ),
688 ];
689
690 let prefix_refs: Vec<(&str, &str)> = prefixes
691 .iter()
692 .map(|(p, u)| (p.as_str(), u.as_str()))
693 .collect();
694
695 let result = registry.store_prefixes_if_new(prefix_refs).await.unwrap();
696 assert_eq!(result.stored, 3);
697 assert_eq!(result.skipped, 0);
698 assert!(result.all_stored());
699
700 // Verify all were stored
701 assert_eq!(registry.prefix_count().await, 3);
702
703 cleanup_test_data(®istry, test_prefix).await;
704 }
705
706 #[tokio::test]
707 async fn test_batch_store_with_duplicates() {
708 let Some(db_url) = get_test_database_url() else {
709 return;
710 };
711
712 let registry = PrefixRegistry::new(&db_url, 5).await.unwrap();
713 let test_prefix = "test_bsd_";
714 cleanup_test_data(®istry, test_prefix).await;
715
716 // Pre-store one prefix
717 let existing_uri = "http://example.org/existing/";
718 registry
719 .store_prefix_if_new(&format!("{test_prefix}existing"), existing_uri)
720 .await
721 .unwrap();
722
723 // Batch store including the existing URI with a different prefix
724 let prefixes = [
725 (
726 format!("{test_prefix}new1"),
727 "http://example.org/new1/".to_string(),
728 ),
729 (format!("{test_prefix}duplicate"), existing_uri.to_string()), // Should be skipped
730 (
731 format!("{test_prefix}new2"),
732 "http://example.org/new2/".to_string(),
733 ),
734 ];
735
736 let prefix_refs: Vec<(&str, &str)> = prefixes
737 .iter()
738 .map(|(p, u)| (p.as_str(), u.as_str()))
739 .collect();
740
741 let result = registry.store_prefixes_if_new(prefix_refs).await.unwrap();
742 assert_eq!(result.stored, 2);
743 assert_eq!(result.skipped, 1);
744 assert!(!result.all_stored());
745 assert!(!result.none_stored());
746
747 cleanup_test_data(®istry, test_prefix).await;
748 }
749
750 #[tokio::test]
751 async fn test_batch_store_empty() {
752 let Some(db_url) = get_test_database_url() else {
753 return;
754 };
755
756 let registry = PrefixRegistry::new(&db_url, 5).await.unwrap();
757 let empty: Vec<(&str, &str)> = vec![];
758
759 let result = registry.store_prefixes_if_new(empty).await.unwrap();
760 assert_eq!(result.stored, 0);
761 assert_eq!(result.skipped, 0);
762 assert!(result.all_stored()); // Vacuously true
763 assert!(result.none_stored());
764 }
765
766 #[tokio::test]
767 async fn test_get_all_prefixes() {
768 let Some(db_url) = get_test_database_url() else {
769 return;
770 };
771
772 let registry = PrefixRegistry::new(&db_url, 5).await.unwrap();
773 let test_prefix = "test_gap_";
774 cleanup_test_data(®istry, test_prefix).await;
775
776 // Store some prefixes
777 let prefix1 = format!("{test_prefix}a");
778 let prefix2 = format!("{test_prefix}b");
779 registry
780 .store_prefix_if_new(&prefix1, "http://example.org/a/")
781 .await
782 .unwrap();
783 registry
784 .store_prefix_if_new(&prefix2, "http://example.org/b/")
785 .await
786 .unwrap();
787
788 let all = registry.get_all_prefixes().await;
789 assert!(all.contains_key(&prefix1));
790 assert!(all.contains_key(&prefix2));
791 assert_eq!(
792 all.get(&prefix1),
793 Some(&"http://example.org/a/".to_string())
794 );
795
796 cleanup_test_data(®istry, test_prefix).await;
797 }
798
799 #[tokio::test]
800 async fn test_cache_populated_on_startup() {
801 let Some(db_url) = get_test_database_url() else {
802 return;
803 };
804
805 let test_prefix = "test_cache_";
806
807 // Create first registry and store a prefix
808 {
809 let registry = PrefixRegistry::new(&db_url, 5).await.unwrap();
810 cleanup_test_data(®istry, test_prefix).await;
811 registry
812 .store_prefix_if_new(
813 &format!("{test_prefix}cached"),
814 "http://example.org/cached/",
815 )
816 .await
817 .unwrap();
818 }
819
820 // Create new registry - should have prefix in cache from startup
821 let registry2 = PrefixRegistry::new(&db_url, 5).await.unwrap();
822 let cached = registry2.get_all_prefixes().await;
823 assert!(cached.contains_key(&format!("{test_prefix}cached")));
824
825 cleanup_test_data(®istry2, test_prefix).await;
826 }
827
828 #[tokio::test]
829 async fn test_unknown_prefix_returns_none() {
830 let Some(db_url) = get_test_database_url() else {
831 return;
832 };
833
834 let registry = PrefixRegistry::new(&db_url, 5).await.unwrap();
835
836 let result = registry
837 .get_uri_for_prefix("definitely_not_a_real_prefix_xyz123")
838 .await
839 .unwrap();
840 assert_eq!(result, None);
841 }
842
843 #[tokio::test]
844 async fn test_unknown_uri_returns_none() {
845 let Some(db_url) = get_test_database_url() else {
846 return;
847 };
848
849 let registry = PrefixRegistry::new(&db_url, 5).await.unwrap();
850
851 let result = registry
852 .get_prefix_for_uri("http://definitely-not-registered.example.org/")
853 .await
854 .unwrap();
855 assert_eq!(result, None);
856 }
857}