shared/models/
egg_repository.rs

1use crate::{
2    models::{InsertQueryBuilder, UpdateQueryBuilder},
3    prelude::*,
4};
5use compact_str::ToCompactString;
6use futures_util::StreamExt;
7use garde::Validate;
8use git2::FetchOptions;
9use serde::{Deserialize, Serialize};
10use sqlx::{Row, postgres::PgRow};
11use std::{
12    collections::BTreeMap,
13    path::PathBuf,
14    sync::{Arc, LazyLock},
15};
16use utoipa::ToSchema;
17
18#[derive(Serialize, Deserialize, Clone)]
19pub struct EggRepository {
20    pub uuid: uuid::Uuid,
21
22    pub name: compact_str::CompactString,
23    pub description: Option<compact_str::CompactString>,
24    pub git_repository: compact_str::CompactString,
25
26    pub last_synced: Option<chrono::NaiveDateTime>,
27    pub created: chrono::NaiveDateTime,
28}
29
30impl BaseModel for EggRepository {
31    const NAME: &'static str = "egg_repository";
32
33    #[inline]
34    fn columns(prefix: Option<&str>) -> BTreeMap<&'static str, compact_str::CompactString> {
35        let prefix = prefix.unwrap_or_default();
36
37        BTreeMap::from([
38            (
39                "egg_repositories.uuid",
40                compact_str::format_compact!("{prefix}uuid"),
41            ),
42            (
43                "egg_repositories.name",
44                compact_str::format_compact!("{prefix}name"),
45            ),
46            (
47                "egg_repositories.description",
48                compact_str::format_compact!("{prefix}description"),
49            ),
50            (
51                "egg_repositories.git_repository",
52                compact_str::format_compact!("{prefix}git_repository"),
53            ),
54            (
55                "egg_repositories.last_synced",
56                compact_str::format_compact!("{prefix}last_synced"),
57            ),
58            (
59                "egg_repositories.created",
60                compact_str::format_compact!("{prefix}created"),
61            ),
62        ])
63    }
64
65    #[inline]
66    fn map(prefix: Option<&str>, row: &PgRow) -> Result<Self, crate::database::DatabaseError> {
67        let prefix = prefix.unwrap_or_default();
68
69        Ok(Self {
70            uuid: row.try_get(compact_str::format_compact!("{prefix}uuid").as_str())?,
71            name: row.try_get(compact_str::format_compact!("{prefix}name").as_str())?,
72            description: row
73                .try_get(compact_str::format_compact!("{prefix}description").as_str())?,
74            git_repository: row
75                .try_get(compact_str::format_compact!("{prefix}git_repository").as_str())?,
76            last_synced: row
77                .try_get(compact_str::format_compact!("{prefix}last_synced").as_str())?,
78            created: row.try_get(compact_str::format_compact!("{prefix}created").as_str())?,
79        })
80    }
81}
82
83impl EggRepository {
84    pub async fn all_with_pagination(
85        database: &crate::database::Database,
86        page: i64,
87        per_page: i64,
88        search: Option<&str>,
89    ) -> Result<super::Pagination<Self>, crate::database::DatabaseError> {
90        let offset = (page - 1) * per_page;
91
92        let rows = sqlx::query(&format!(
93            r#"
94            SELECT {}, COUNT(*) OVER() AS total_count
95            FROM egg_repositories
96            WHERE ($1 IS NULL OR egg_repositories.name ILIKE '%' || $1 || '%')
97            ORDER BY egg_repositories.created
98            LIMIT $2 OFFSET $3
99            "#,
100            Self::columns_sql(None)
101        ))
102        .bind(search)
103        .bind(per_page)
104        .bind(offset)
105        .fetch_all(database.read())
106        .await?;
107
108        Ok(super::Pagination {
109            total: rows
110                .first()
111                .map_or(Ok(0), |row| row.try_get("total_count"))?,
112            per_page,
113            page,
114            data: rows
115                .into_iter()
116                .map(|row| Self::map(None, &row))
117                .try_collect_vec()?,
118        })
119    }
120
121    pub async fn sync(&self, database: &crate::database::Database) -> Result<usize, anyhow::Error> {
122        let git_repository = self.git_repository.clone();
123
124        let exported_eggs = tokio::task::spawn_blocking(
125            move || -> Result<Vec<(PathBuf, super::nest_egg::ExportedNestEgg)>, anyhow::Error> {
126                let mut exported_eggs = Vec::new();
127                let temp_dir = tempfile::tempdir()?;
128                let filesystem = crate::cap::CapFilesystem::new(temp_dir.path().to_path_buf())?;
129
130                let mut fetch_options = FetchOptions::new();
131                fetch_options.depth(1);
132                git2::build::RepoBuilder::new()
133                    .fetch_options(fetch_options)
134                    .clone(&git_repository, temp_dir.path())?;
135
136                let mut walker = filesystem.walk_dir(".")?;
137                while let Some(Ok((is_dir, entry))) = walker.next_entry() {
138                    if is_dir
139                        || !matches!(
140                            entry.extension().and_then(|s| s.to_str()),
141                            Some("json") | Some("yml") | Some("yaml")
142                        )
143                    {
144                        continue;
145                    }
146
147                    let metadata = match filesystem.metadata(&entry) {
148                        Ok(metadata) => metadata,
149                        Err(_) => continue,
150                    };
151
152                    // if any egg is larger than 1 MB, something went horribly wrong in development
153                    if !metadata.is_file() || metadata.len() > 1024 * 1024 {
154                        continue;
155                    }
156
157                    let file_content = match filesystem.read_to_string(&entry) {
158                        Ok(content) => content,
159                        Err(_) => continue,
160                    };
161                    let exported_egg: super::nest_egg::ExportedNestEgg =
162                        if entry.extension().and_then(|s| s.to_str()) == Some("json") {
163                            match serde_json::from_str(&file_content) {
164                                Ok(egg) => egg,
165                                Err(_) => continue,
166                            }
167                        } else {
168                            match serde_norway::from_str(&file_content) {
169                                Ok(egg) => egg,
170                                Err(_) => continue,
171                            }
172                        };
173
174                    exported_eggs.push((entry, exported_egg));
175                }
176
177                Ok(exported_eggs)
178            },
179        )
180        .await??;
181
182        super::egg_repository_egg::EggRepositoryEgg::delete_unused(
183            database,
184            self.uuid,
185            &exported_eggs
186                .iter()
187                .map(|(path, _)| path.to_string_lossy().to_compact_string())
188                .collect::<Vec<_>>(),
189        )
190        .await?;
191
192        let mut futures = Vec::new();
193        futures.reserve_exact(exported_eggs.len());
194
195        for (path, exported_egg) in exported_eggs.iter() {
196            futures.push(super::egg_repository_egg::EggRepositoryEgg::create(
197                database,
198                self.uuid,
199                path.to_string_lossy(),
200                &exported_egg.name,
201                exported_egg.description.as_deref(),
202                &exported_egg.author,
203                exported_egg,
204            ));
205        }
206
207        let mut results_stream = futures_util::stream::iter(futures).buffer_unordered(25);
208        while let Some(result) = results_stream.next().await {
209            result?;
210        }
211
212        sqlx::query(
213            r#"
214            UPDATE egg_repositories
215            SET last_synced = NOW()
216            WHERE egg_repositories.uuid = $1
217            "#,
218        )
219        .bind(self.uuid)
220        .execute(database.write())
221        .await?;
222
223        Ok(exported_eggs.len())
224    }
225
226    #[inline]
227    pub fn into_admin_api_object(self) -> AdminApiEggRepository {
228        AdminApiEggRepository {
229            uuid: self.uuid,
230            name: self.name,
231            description: self.description,
232            git_repository: self.git_repository,
233            last_synced: self.last_synced.map(|dt| dt.and_utc()),
234            created: self.created.and_utc(),
235        }
236    }
237}
238
239#[derive(ToSchema, Deserialize, Validate)]
240pub struct CreateEggRepositoryOptions {
241    #[garde(length(chars, min = 3, max = 255))]
242    #[schema(min_length = 3, max_length = 255)]
243    pub name: compact_str::CompactString,
244    #[garde(length(max = 1024))]
245    #[schema(max_length = 1024)]
246    pub description: Option<compact_str::CompactString>,
247    #[garde(url)]
248    #[schema(example = "https://github.com/example/repo.git", format = "uri")]
249    pub git_repository: compact_str::CompactString,
250}
251
252#[async_trait::async_trait]
253impl CreatableModel for EggRepository {
254    type CreateOptions<'a> = CreateEggRepositoryOptions;
255    type CreateResult = Self;
256
257    fn get_create_handlers() -> &'static LazyLock<CreateListenerList<Self>> {
258        static CREATE_LISTENERS: LazyLock<CreateListenerList<EggRepository>> =
259            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
260
261        &CREATE_LISTENERS
262    }
263
264    async fn create(
265        state: &crate::State,
266        mut options: Self::CreateOptions<'_>,
267    ) -> Result<Self::CreateResult, crate::database::DatabaseError> {
268        options.validate()?;
269
270        let mut transaction = state.database.write().begin().await?;
271
272        let mut query_builder = InsertQueryBuilder::new("egg_repositories");
273
274        Self::run_create_handlers(&mut options, &mut query_builder, state, &mut transaction)
275            .await?;
276
277        query_builder
278            .set("name", &options.name)
279            .set("description", &options.description)
280            .set("git_repository", &options.git_repository);
281
282        let row = query_builder
283            .returning(&Self::columns_sql(None))
284            .fetch_one(&mut *transaction)
285            .await?;
286        let egg_repository = Self::map(None, &row)?;
287
288        transaction.commit().await?;
289
290        Ok(egg_repository)
291    }
292}
293
294#[derive(ToSchema, Serialize, Deserialize, Validate, Clone, Default)]
295pub struct UpdateEggRepositoryOptions {
296    #[garde(length(chars, min = 3, max = 255))]
297    #[schema(min_length = 3, max_length = 255)]
298    pub name: Option<compact_str::CompactString>,
299    #[garde(length(max = 1024))]
300    #[schema(max_length = 1024)]
301    #[serde(
302        default,
303        skip_serializing_if = "Option::is_none",
304        with = "::serde_with::rust::double_option"
305    )]
306    pub description: Option<Option<compact_str::CompactString>>,
307    #[garde(url)]
308    #[schema(example = "https://github.com/example/repo.git", format = "uri")]
309    pub git_repository: Option<compact_str::CompactString>,
310}
311
312#[async_trait::async_trait]
313impl UpdatableModel for EggRepository {
314    type UpdateOptions = UpdateEggRepositoryOptions;
315
316    fn get_update_handlers() -> &'static LazyLock<UpdateListenerList<Self>> {
317        static UPDATE_LISTENERS: LazyLock<UpdateListenerList<EggRepository>> =
318            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
319
320        &UPDATE_LISTENERS
321    }
322
323    async fn update(
324        &mut self,
325        state: &crate::State,
326        mut options: Self::UpdateOptions,
327    ) -> Result<(), crate::database::DatabaseError> {
328        options.validate()?;
329
330        let mut transaction = state.database.write().begin().await?;
331
332        let mut query_builder = UpdateQueryBuilder::new("egg_repositories");
333
334        Self::run_update_handlers(
335            self,
336            &mut options,
337            &mut query_builder,
338            state,
339            &mut transaction,
340        )
341        .await?;
342
343        query_builder
344            .set("name", options.name.as_ref())
345            .set(
346                "description",
347                options.description.as_ref().map(|d| d.as_ref()),
348            )
349            .set("git_repository", options.git_repository.as_ref())
350            .where_eq("uuid", self.uuid);
351
352        query_builder.execute(&mut *transaction).await?;
353
354        if let Some(name) = options.name {
355            self.name = name;
356        }
357        if let Some(description) = options.description {
358            self.description = description;
359        }
360        if let Some(git_repository) = options.git_repository {
361            self.git_repository = git_repository;
362        }
363
364        transaction.commit().await?;
365
366        Ok(())
367    }
368}
369
370#[async_trait::async_trait]
371impl ByUuid for EggRepository {
372    async fn by_uuid(
373        database: &crate::database::Database,
374        uuid: uuid::Uuid,
375    ) -> Result<Self, crate::database::DatabaseError> {
376        let row = sqlx::query(&format!(
377            r#"
378            SELECT {}
379            FROM egg_repositories
380            WHERE egg_repositories.uuid = $1
381            "#,
382            Self::columns_sql(None)
383        ))
384        .bind(uuid)
385        .fetch_one(database.read())
386        .await?;
387
388        Self::map(None, &row)
389    }
390}
391
392#[async_trait::async_trait]
393impl DeletableModel for EggRepository {
394    type DeleteOptions = ();
395
396    fn get_delete_handlers() -> &'static LazyLock<DeleteListenerList<Self>> {
397        static DELETE_LISTENERS: LazyLock<DeleteListenerList<EggRepository>> =
398            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
399
400        &DELETE_LISTENERS
401    }
402
403    async fn delete(
404        &self,
405        state: &crate::State,
406        options: Self::DeleteOptions,
407    ) -> Result<(), anyhow::Error> {
408        let mut transaction = state.database.write().begin().await?;
409
410        self.run_delete_handlers(&options, state, &mut transaction)
411            .await?;
412
413        sqlx::query(
414            r#"
415            DELETE FROM egg_repositories
416            WHERE egg_repositories.uuid = $1
417            "#,
418        )
419        .bind(self.uuid)
420        .execute(&mut *transaction)
421        .await?;
422
423        transaction.commit().await?;
424
425        Ok(())
426    }
427}
428
429#[derive(ToSchema, Serialize)]
430#[schema(title = "EggRepository")]
431pub struct AdminApiEggRepository {
432    pub uuid: uuid::Uuid,
433
434    pub name: compact_str::CompactString,
435    pub description: Option<compact_str::CompactString>,
436    pub git_repository: compact_str::CompactString,
437
438    pub last_synced: Option<chrono::DateTime<chrono::Utc>>,
439    pub created: chrono::DateTime<chrono::Utc>,
440}