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