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(&registry, 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(&registry, 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(&registry, 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(&registry, 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(&registry, 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(&registry, 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(&registry, 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(&registry, 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(&registry, 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(&registry, 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(&registry, 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(&registry, 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(&registry, 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(&registry2, 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}