articles_rs/databases/
article_repository.rs

1//! Article repository module for database operations
2//! 
3//! This module provides the database model and repository implementation for articles.
4//! It handles CRUD operations and queries for articles stored in a PostgreSQL database.
5
6use async_trait::async_trait;
7use chrono::{DateTime, Utc};
8use sqlx::{
9    postgres::{PgPool, PgRow},
10    Error as SqlxError, FromRow, Row,
11};
12use std::collections::HashMap;
13use uuid::Uuid;
14
15use crate::postgres_repository::PostgresRepository;
16
17/// Database model representing an article
18///
19/// Contains all fields that map to the articles table in the database.
20pub struct DbArticle {
21    pub id: Option<Uuid>,
22    pub title: String,
23    pub hero_image: String,
24    pub slug: String,
25    pub description: String,
26    pub author: String,
27    pub status: String,
28    pub created: DateTime<Utc>,
29    pub content: String,
30    pub source: String,
31    pub published: Option<DateTime<Utc>>,
32    pub kind: String,
33}
34
35
36impl DbArticle {
37    /// Creates a new `DbArticle` with basic required fields
38    ///
39    /// # Arguments
40    /// * `title` - Article title
41    /// * `slug` - URL-friendly slug
42    /// * `description` - Brief description
43    /// * `author` - Article author
44    pub fn new(title: &str, slug: &str, description: &str, author: &str) -> Self {
45        let now = Utc::now();
46        DbArticle {
47            id: None,
48            title: title.to_string(),
49            hero_image: "".to_string(),
50            slug: slug.to_string(),
51            description: description.to_string(),
52            author: author.to_string(),
53            status: "NEW".to_string(),
54            created: now,
55            content: String::new(),
56            source: String::new(),
57            published: None,
58            kind: "POST".to_string(),
59        }
60    }
61}
62
63/// Implementation for converting database rows to DbArticle
64impl<'r> FromRow<'r, PgRow> for DbArticle {
65    fn from_row(row: &'r PgRow) -> Result<Self, sqlx::Error> {
66        let row_id = row.try_get("id").ok();
67        let hero: String = row.try_get("hero_image").unwrap_or_else(|_| String::new());
68        let published = row.try_get::<chrono::NaiveDateTime, _>("published")
69            .ok()
70            .map(|naive_date| DateTime::<Utc>::from_naive_utc_and_offset(naive_date, Utc));
71        Ok(DbArticle {
72            id: row_id,
73            title: row.try_get("title")?,
74            hero_image: hero,
75            slug: row.try_get("slug")?,
76            description: row.try_get("description")?,
77            author: row.try_get("author")?,
78            status: row.try_get("status")?,
79            created: row
80                .try_get::<DateTime<Utc>, _>("created")
81                .unwrap_or_else(|_| {
82                    // Handle conversion of naive datetime to UTC
83                    let naive_date: chrono::NaiveDateTime = row.try_get("created").unwrap();
84                    DateTime::<Utc>::from_naive_utc_and_offset(naive_date, Utc)
85                }),
86            content: row.try_get("content")?,
87            source: row.try_get("source").unwrap_or_default(),
88            published: published,
89            kind: row.try_get("kind").unwrap_or_else(|_| "POST".to_string()),
90        })
91    }
92}
93
94/// Repository for handling article database operations
95pub struct ArticleRepository {
96    /// Database connection pool
97    pool: PgPool,
98}
99
100impl ArticleRepository {
101    /// Creates a new `ArticleRepository` instance
102    ///
103    /// # Arguments
104    /// * `pool` - PostgreSQL connection pool
105    pub fn new(pool: PgPool) -> Self {
106        Self { pool }
107    }
108}
109
110/// Implementation of PostgresRepository trait for ArticleRepository
111#[async_trait]
112impl PostgresRepository for ArticleRepository {
113    type Error = SqlxError;
114    type Item = DbArticle;
115
116    /// Creates a new article in the database
117    ///
118    /// # Arguments
119    /// * `article` - Article to create
120    ///
121    /// # Returns
122    /// The UUID of the created article
123    async fn create(&self, article: &Self::Item) -> Result<Uuid, Self::Error> {
124        let row = sqlx::query(
125            r#"
126                INSERT INTO articles (title, slug, source, description, author, status, created, content, published, kind)
127                VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
128                RETURNING id
129                "#,
130        )
131        .bind(&article.title)
132        .bind(&article.slug)
133        .bind(&article.source)
134        .bind(&article.description)
135        .bind(&article.author)
136        .bind(&article.status)
137        .bind(article.created)
138        .bind(&article.content)
139        .bind(article.published)
140        .bind(&article.kind)
141        .fetch_one(&self.pool)
142        .await?;
143
144        let id: Uuid = row.try_get("id")?;
145        Ok(id)
146    }
147
148    /// Updates an existing article in the database
149    ///
150    /// # Arguments
151    /// * `article` - Article with updated fields
152    ///
153    /// # Returns
154    /// Error if article not found
155    async fn update(&self, article: &Self::Item) -> Result<(), Self::Error> {
156        let affected_rows = sqlx::query(
157            r#"
158                UPDATE articles
159                SET title = $1, slug = $2, author = $3, status = $4, created = $5, content = $6, description = $7, source = $8, published = $9, kind = $10
160                WHERE id = $11
161                "#,
162        )
163        .bind(&article.title)
164        .bind(&article.slug)
165        .bind(&article.author)
166        .bind(&article.status)
167        .bind(article.created)
168        .bind(&article.content)
169        .bind(&article.description)
170        .bind(&article.source)
171        .bind(article.published)
172        .bind(&article.kind)
173        .bind(article.id)
174        .execute(&self.pool)
175        .await?
176        .rows_affected();
177
178        if affected_rows == 0 {
179            return Err(SqlxError::RowNotFound);
180        }
181
182        Ok(())
183    }
184
185    /// Deletes an article from the database
186    ///
187    /// # Arguments
188    /// * `id` - UUID of article to delete
189    ///
190    /// # Returns
191    /// Error if article not found
192    async fn delete(&self, id: Uuid) -> Result<(), Self::Error> {
193        let affected_rows = sqlx::query(
194            r#"
195                DELETE FROM articles
196                WHERE id = $1
197                "#,
198        )
199        .bind(id)
200        .execute(&self.pool)
201        .await?
202        .rows_affected();
203
204        if affected_rows == 0 {
205            return Err(SqlxError::RowNotFound);
206        }
207
208        Ok(())
209    }
210
211    /// Deletes all articles matching the provided filters
212    /// 
213    /// Currently not implemented
214    async fn delete_all(
215        &self,
216        _filters: Option<HashMap<String, String>>,
217    ) -> Result<(), Self::Error> {
218        tracing::warn!("delete_all not implemented");
219        return Err(SqlxError::RowNotFound);
220    }
221
222    /// Finds an article by its UUID
223    ///
224    /// # Arguments
225    /// * `id` - UUID to search for
226    ///
227    /// # Returns
228    /// The article if found, None otherwise
229    async fn find_by_id(&self, id: Uuid) -> Result<Option<Self::Item>, Self::Error> {
230        let row_result = sqlx::query(
231            "SELECT articles.id, title, slug, description, author, status, created, content,
232            (SELECT image_path FROM article_images WHERE article_images.article_id = articles.id LIMIT 1) as hero_image,
233            source,
234            published,
235            kind
236            FROM articles WHERE articles.id = $1",
237        )
238        .bind(id)
239        .fetch_optional(&self.pool)
240        .await?;
241
242        match row_result {
243            Some(row) => {
244                let article = DbArticle::from_row(&row)?;
245                Ok(Some(article))
246            }
247            None => Ok(None),
248        }
249    }
250
251    /// Finds an article by its slug
252    ///
253    /// # Arguments
254    /// * `slug` - Slug to search for
255    ///
256    /// # Returns
257    /// The article if found, None otherwise
258    async fn find_by_name(&self, slug: String) -> Result<Option<Self::Item>, Self::Error> {
259        let sql = "SELECT articles.id, title, slug, description, author, status, created, content,
260                            (SELECT image_path FROM article_images WHERE article_images.article_id = articles.id LIMIT 1) as hero_image,
261                            source,
262                            published,
263                            kind
264                            FROM articles WHERE articles.slug = $1";
265        let row_result = sqlx::query(sql)
266            .bind(slug.clone())
267            .fetch_optional(&self.pool)
268            .await?;
269
270        match row_result {
271            Some(row) => {
272                let article = DbArticle::from_row(&row)?;
273                Ok(Some(article))
274            }
275            None => Ok(None),
276        }
277    }
278
279    /// Lists articles with optional filters
280    ///
281    /// # Arguments
282    /// * `filters` - Optional HashMap of column names to values to filter by
283    ///
284    /// # Returns
285    /// Vector of matching articles
286    async fn list(
287        &self,
288        filters: Option<HashMap<String, String>>,
289    ) -> Result<Vec<Self::Item>, Self::Error> {
290        // Build base query
291        let mut query = String::from(
292            "SELECT articles.id, title, slug, description, author, status, created, content, source, published, kind,
293                                        (SELECT image_path FROM article_images WHERE article_images.article_id = articles.id LIMIT 1) as hero_image
294                                        FROM articles",
295        );
296        let mut params: Vec<String> = Vec::new();
297        let mut param_count = 1;
298
299        // Add WHERE clause if filters are provided
300        if let Some(filter_map) = filters {
301            if !filter_map.is_empty() {
302                query.push_str(" WHERE ");
303                for (column, value) in filter_map {
304                    if param_count > 1 {
305                        query.push_str(" AND ");
306                    }
307                    query.push_str(&format!("{} = ${}", column, param_count));
308                    params.push(value);
309                    param_count += 1;
310                }
311            }
312        }
313
314        // Add ORDER BY clause
315        query.push_str(" ORDER BY created DESC");
316        let mut query_builder = sqlx::query(&query);
317        
318        // Bind parameters, handling UUIDs specially
319        for param in params {
320            // Try parsing as UUID first, if it fails, bind as a string
321            if let Ok(uuid) = sqlx::types::Uuid::parse_str(&param) {
322                query_builder = query_builder.bind(uuid);
323            } else {
324                query_builder = query_builder.bind(param);
325            }
326        }
327
328        // Execute query and convert results to DbArticles
329        let articles = query_builder
330            .fetch_all(&self.pool)
331            .await?
332            .into_iter()
333            .map(|row| DbArticle::from_row(&row))
334            .collect::<Result<Vec<_>, _>>()?;
335
336        Ok(articles)
337    }
338}