From 0a2cab01d81a1f3a5081d5c407ac499285753519 Mon Sep 17 00:00:00 2001 From: Suracheth Chawla Date: Fri, 13 Dec 2024 21:28:48 +0700 Subject: [PATCH 01/11] add missing UserReport struct --- docs-site/content/docs/getting-started/guide.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs-site/content/docs/getting-started/guide.md b/docs-site/content/docs/getting-started/guide.md index da7dc4443..79cf889ba 100644 --- a/docs-site/content/docs/getting-started/guide.md +++ b/docs-site/content/docs/getting-started/guide.md @@ -798,6 +798,8 @@ use loco_rs::task::Vars; use crate::models::users; +pub struct UserReport; + #[async_trait] impl Task for UserReport { fn task(&self) -> TaskInfo { From f5a90320b935cd4d10260256d816aa5836a8e4f7 Mon Sep 17 00:00:00 2001 From: Elad Kaplan Date: Tue, 17 Dec 2024 13:45:51 +0200 Subject: [PATCH 02/11] magic link (#1085) magic link flow --- loco-new/base_template/Cargo.toml.t | 19 +- loco-new/base_template/config/test.yaml.t | 3 +- .../migration/src/{lib.rs => lib.rs.t} | 4 + .../migration/src/m20220101_000001_users.rs | 4 + loco-new/base_template/src/app.rs.t | 22 +- .../base_template/src/controllers/auth.rs | 87 +++++++- loco-new/base_template/src/mailers/auth.rs | 27 +++ .../src/mailers/auth/magic_link/html.t | 8 + .../src/mailers/auth/magic_link/subject.t | 1 + .../src/mailers/auth/magic_link/text.t | 2 + .../src/models/_entities/{mod.rs => mod.rs.t} | 3 +- .../_entities/{prelude.rs => prelude.rs.t} | 2 + .../src/models/_entities/users.rs | 2 + loco-new/base_template/src/models/mod.rs | 2 - loco-new/base_template/src/models/mod.rs.t | 4 + loco-new/base_template/src/models/users.rs | 78 ++++++- loco-new/base_template/tests/models/mod.rs | 1 - loco-new/base_template/tests/models/mod.rs.t | 3 + .../can_create_with_password@users.snap | 2 + .../snapshots/can_find_by_email@users.snap | 2 + .../snapshots/can_find_by_pid@users.snap | 2 + .../base_template/tests/models/users.rs.t | 195 ++++++++++++------ .../base_template/tests/requests/auth.rs.t | 161 ++++++++++++--- ...can_auth_with_magic_link@auth_request.snap | 5 + ...can_login_without_verify@auth_request.snap | 7 +- .../can_register@auth_request-2.snap | 8 - .../snapshots/can_register@auth_request.snap | 2 + .../can_reset_password@auth_request-2.snap | 8 - .../can_reset_password@auth_request.snap | 5 +- loco-new/setup.rhai | 38 +++- loco-new/src/settings.rs | 4 +- loco-new/src/wizard.rs | 37 ++-- loco-new/tests/templates/auth.rs | 93 +++++++-- loco-new/tests/templates/background.rs | 47 ++++- loco-new/tests/templates/mailer.rs | 8 +- loco-new/tests/templates/module_name.rs | 13 +- ..._asset__cargo_dependencies_Clientside.snap | 4 +- ...lates__asset__cargo_dependencies_None.snap | 4 +- ..._asset__cargo_dependencies_Serverside.snap | 4 +- ...es__auth__src_app_rs_auth_false_None.snap} | 2 +- ...s__auth__src_app_rs_auth_false_Sqlite.snap | 69 +++++++ ...tes__auth__src_app_rs_auth_true_None.snap} | 4 +- ...es__auth__src_app_rs_auth_true_Sqlite.snap | 73 +++++++ ...mplates__background__src_app_rs_Async.snap | 4 +- ...ates__background__src_app_rs_Blocking.snap | 4 +- ...emplates__background__src_app_rs_None.snap | 2 +- ...mplates__background__src_app_rs_Queue.snap | 4 +- ...emplates__db__cargo_dependencies_None.snap | 4 +- ...ates__db__cargo_dependencies_Postgres.snap | 4 +- ...plates__db__cargo_dependencies_Sqlite.snap | 4 +- ...r#mod__templates__db__src_app_rs_None.snap | 2 +- ...d__templates__db__src_app_rs_Postgres.snap | 14 +- ...mod__templates__db__src_app_rs_Sqlite.snap | 14 +- ...alizers__src_app_rs_with_initializers.snap | 2 +- ...zers__src_app_rs_without_initializers.snap | 2 +- ...iler__cargo_dependencies_mailer_false.snap | 4 +- ...ailer__cargo_dependencies_mailer_true.snap | 4 +- loco-new/tests/wizard/new.rs | 7 + src/bgworker/mod.rs | 16 +- src/cli.rs | 12 +- src/env_vars.rs | 2 + src/hash.rs | 34 ++- src/prelude.rs | 2 +- 63 files changed, 967 insertions(+), 243 deletions(-) rename loco-new/base_template/migration/src/{lib.rs => lib.rs.t} (81%) create mode 100644 loco-new/base_template/src/mailers/auth/magic_link/html.t create mode 100644 loco-new/base_template/src/mailers/auth/magic_link/subject.t create mode 100644 loco-new/base_template/src/mailers/auth/magic_link/text.t rename loco-new/base_template/src/models/_entities/{mod.rs => mod.rs.t} (71%) rename loco-new/base_template/src/models/_entities/{prelude.rs => prelude.rs.t} (72%) delete mode 100644 loco-new/base_template/src/models/mod.rs create mode 100644 loco-new/base_template/src/models/mod.rs.t delete mode 100644 loco-new/base_template/tests/models/mod.rs create mode 100644 loco-new/base_template/tests/models/mod.rs.t create mode 100644 loco-new/base_template/tests/requests/snapshots/can_auth_with_magic_link@auth_request.snap delete mode 100644 loco-new/base_template/tests/requests/snapshots/can_register@auth_request-2.snap delete mode 100644 loco-new/base_template/tests/requests/snapshots/can_reset_password@auth_request-2.snap rename loco-new/tests/templates/snapshots/{r#mod__templates__auth__src_app_rs_auth_false.snap => r#mod__templates__auth__src_app_rs_auth_false_None.snap} (98%) create mode 100644 loco-new/tests/templates/snapshots/r#mod__templates__auth__src_app_rs_auth_false_Sqlite.snap rename loco-new/tests/templates/snapshots/{r#mod__templates__auth__src_app_rs_auth_true.snap => r#mod__templates__auth__src_app_rs_auth_true_None.snap} (94%) create mode 100644 loco-new/tests/templates/snapshots/r#mod__templates__auth__src_app_rs_auth_true_Sqlite.snap diff --git a/loco-new/base_template/Cargo.toml.t b/loco-new/base_template/Cargo.toml.t index b54aae8d7..a7fbe94f5 100644 --- a/loco-new/base_template/Cargo.toml.t +++ b/loco-new/base_template/Cargo.toml.t @@ -21,14 +21,15 @@ loco-rs = { {{settings.loco_version_text}} {%- if not settings.features.default_ [dependencies] loco-rs = { workspace = true {% if feature_list | length > 0 %}, features = {{feature_list}}{% endif %} } serde = { version = "1", features = ["derive"] } -serde_json = "1" +serde_json = { version = "1" } tokio = { version = "1.33.0", default-features = false, features = [ "rt-multi-thread", ] } -async-trait = "0.1.74" -axum = "0.7.5" -tracing = "0.1.40" +async-trait = { version = "0.1.74" } +axum = { version = "0.7.5" } +tracing = { version = "0.1.40" } tracing-subscriber = { version = "0.3.17", features = ["env-filter", "json"] } +regex = { version = "1.11.1" } {%- if settings.db %} migration = { path = "migration" } sea-orm = { version = "1.1.0", features = [ @@ -37,19 +38,19 @@ sea-orm = { version = "1.1.0", features = [ "runtime-tokio-rustls", "macros", ] } -chrono = "0.4" +chrono = { version = "0.4" } validator = { version = "0.19" } uuid = { version = "1.6.0", features = ["v4"] } {%- endif %} {%- if settings.mailer %} -include_dir = "0.7" +include_dir = { version = "0.7" } {%- endif %} {%- if settings.asset %} # view engine i18n fluent-templates = { version = "0.8.0", features = ["tera"] } -unic-langid = "0.9.4" +unic-langid = { version = "0.9.4" } # /view engine {%- endif %} @@ -67,6 +68,6 @@ required-features = [] [dev-dependencies] loco-rs = { workspace = true, features = ["testing"] } -serial_test = "3.1.1" -rstest = "0.21.0" +serial_test = { version = "3.1.1" } +rstest = { version = "0.21.0" } insta = { version = "1.34.0", features = ["redactions", "yaml", "filters"] } diff --git a/loco-new/base_template/config/test.yaml.t b/loco-new/base_template/config/test.yaml.t index b1d1ed535..267c60562 100644 --- a/loco-new/base_template/config/test.yaml.t +++ b/loco-new/base_template/config/test.yaml.t @@ -53,7 +53,7 @@ workers: # - BackgroundQueue - Workers operate asynchronously in the background, processing queued. # - ForegroundBlocking - Workers operate in the foreground and block until tasks are completed. # - BackgroundAsync - Workers operate asynchronously in the background, processing tasks with async capabilities. - mode: {{settings.background.kind}} + mode: ForegroundBlocking {% if settings.background.kind == "BackgroundQueue"%} # Queue Configuration @@ -70,6 +70,7 @@ queue: # Mailer Configuration. mailer: + stub: true # SMTP mailer configuration. smtp: # Enable/Disable smtp mailer. diff --git a/loco-new/base_template/migration/src/lib.rs b/loco-new/base_template/migration/src/lib.rs.t similarity index 81% rename from loco-new/base_template/migration/src/lib.rs rename to loco-new/base_template/migration/src/lib.rs.t index d37c3d1b0..388470d31 100644 --- a/loco-new/base_template/migration/src/lib.rs +++ b/loco-new/base_template/migration/src/lib.rs.t @@ -2,7 +2,9 @@ #![allow(clippy::wildcard_imports)] pub use sea_orm_migration::prelude::*; +{%- if settings.auth %} mod m20220101_000001_users; +{%- endif %} pub struct Migrator; @@ -10,7 +12,9 @@ pub struct Migrator; impl MigratorTrait for Migrator { fn migrations() -> Vec> { vec![ + {%- if settings.auth %} Box::new(m20220101_000001_users::Migration), + {%- endif %} // inject-above (do not remove this comment) ] } diff --git a/loco-new/base_template/migration/src/m20220101_000001_users.rs b/loco-new/base_template/migration/src/m20220101_000001_users.rs index 936ad3d0c..0b689d73e 100644 --- a/loco-new/base_template/migration/src/m20220101_000001_users.rs +++ b/loco-new/base_template/migration/src/m20220101_000001_users.rs @@ -21,6 +21,8 @@ impl MigrationTrait for Migration { Users::EmailVerificationSentAt, )) .col(timestamp_with_time_zone_null(Users::EmailVerifiedAt)) + .col(string_null(Users::MagicLinkToken)) + .col(timestamp_with_time_zone_null(Users::MagicLinkExpiration)) .to_owned(); manager.create_table(table).await?; Ok(()) @@ -47,4 +49,6 @@ pub enum Users { EmailVerificationToken, EmailVerificationSentAt, EmailVerifiedAt, + MagicLinkToken, + MagicLinkExpiration, } diff --git a/loco-new/base_template/src/app.rs.t b/loco-new/base_template/src/app.rs.t index 655adf32b..5414e23c2 100644 --- a/loco-new/base_template/src/app.rs.t +++ b/loco-new/base_template/src/app.rs.t @@ -11,7 +11,7 @@ use loco_rs::{ Queue}, boot::{create_app, BootResult, StartMode}, controller::AppRoutes, - {%- if settings.db %} + {%- if settings.auth %} db::{self, truncate_table}, {%- endif %} environment::Environment, @@ -25,17 +25,16 @@ use sea_orm::DatabaseConnection; #[allow(unused_imports)] use crate::{ - controllers + controllers ,tasks {%- if settings.initializers -%} , initializers {%- endif %} - {%- if settings.db %} - ,tasks + {%- if settings.auth %} , models::_entities::users {%- endif %} {%- if settings.background %} , workers::downloader::DownloadWorker - {%- endif %}, + {%- endif %} }; pub struct App; @@ -97,13 +96,26 @@ impl Hooks for App { } {%- if settings.db %} + + {%- if settings.auth %} async fn truncate(db: &DatabaseConnection) -> Result<()> { + {%- else %} + async fn truncate(_db: &DatabaseConnection) -> Result<()> { + {%- endif %} + {%- if settings.auth %} truncate_table(db, users::Entity).await?; + {%- endif %} Ok(()) } + {%- if settings.auth %} async fn seed(db: &DatabaseConnection, base: &Path) -> Result<()> { + {%- else %} + async fn seed(_db: &DatabaseConnection, _base: &Path) -> Result<()> { + {%- endif %} + {%- if settings.auth %} db::seed::(db, &base.join("users.yaml").display().to_string()).await?; + {%- endif %} Ok(()) } {%- endif %} diff --git a/loco-new/base_template/src/controllers/auth.rs b/loco-new/base_template/src/controllers/auth.rs index 27e3de71e..2b6fb5218 100644 --- a/loco-new/base_template/src/controllers/auth.rs +++ b/loco-new/base_template/src/controllers/auth.rs @@ -1,7 +1,3 @@ -use axum::debug_handler; -use loco_rs::prelude::*; -use serde::{Deserialize, Serialize}; - use crate::{ mailers::auth::AuthMailer, models::{ @@ -10,6 +6,20 @@ use crate::{ }, views::auth::{CurrentResponse, LoginResponse}, }; +use axum::debug_handler; +use loco_rs::prelude::*; +use regex::Regex; +use serde::{Deserialize, Serialize}; +use std::sync::OnceLock; + +pub static EMAIL_DOMAIN_RE: OnceLock = OnceLock::new(); + +fn get_allow_email_domain_re() -> &'static Regex { + EMAIL_DOMAIN_RE.get_or_init(|| { + Regex::new(r"@example\.com$|@gmail\.com$").expect("Failed to compile regex") + }) +} + #[derive(Debug, Deserialize, Serialize)] pub struct VerifyParams { pub token: String, @@ -26,6 +36,11 @@ pub struct ResetParams { pub password: String, } +#[derive(Debug, Deserialize, Serialize)] +pub struct MagicLinkParams { + pub email: String, +} + /// Register function creates a new user with the given parameters and sends a /// welcome email to the user #[debug_handler] @@ -145,6 +160,68 @@ async fn current(auth: auth::JWT, State(ctx): State) -> Result, + Json(params): Json, +) -> Result { + let email_regex = get_allow_email_domain_re(); + if !email_regex.is_match(¶ms.email) { + tracing::debug!( + email = params.email, + "The provided email is invalid or does not match the allowed domains" + ); + return bad_request("invalid request"); + } + + let Ok(user) = users::Model::find_by_email(&ctx.db, ¶ms.email).await else { + // we don't want to expose our users email. if the email is invalid we still + // returning success to the caller + tracing::debug!(email = params.email, "user not found by email"); + return format::empty_json(); + }; + + let user = user.into_active_model().create_magic_link(&ctx.db).await?; + AuthMailer::send_magic_link(&ctx, &user).await?; + + format::empty_json() +} + +/// Verifies a magic link token and authenticates the user. +async fn magic_link_verify( + Path(token): Path, + State(ctx): State, +) -> Result { + let Ok(user) = users::Model::find_by_magic_token(&ctx.db, &token).await else { + // we don't want to expose our users email. if the email is invalid we still + // returning success to the caller + return unauthorized("unauthorized!"); + }; + + let user = user.into_active_model().clear_magic_link(&ctx.db).await?; + + let jwt_secret = ctx.config.get_jwt_config()?; + + let token = user + .generate_jwt(&jwt_secret.secret, &jwt_secret.expiration) + .or_else(|_| unauthorized("unauthorized!"))?; + + format::json(LoginResponse::new(&user, &token)) +} + pub fn routes() -> Routes { Routes::new() .prefix("/api/auth") @@ -154,4 +231,6 @@ pub fn routes() -> Routes { .add("/forgot", post(forgot)) .add("/reset", post(reset)) .add("/current", get(current)) + .add("/magic-link", post(magic_link)) + .add("/magic-link/:token", get(magic_link_verify)) } diff --git a/loco-new/base_template/src/mailers/auth.rs b/loco-new/base_template/src/mailers/auth.rs index 30bb1bf2f..f2d635232 100644 --- a/loco-new/base_template/src/mailers/auth.rs +++ b/loco-new/base_template/src/mailers/auth.rs @@ -8,6 +8,7 @@ use crate::models::users; static welcome: Dir<'_> = include_dir!("src/mailers/auth/welcome"); static forgot: Dir<'_> = include_dir!("src/mailers/auth/forgot"); +static magic_link: Dir<'_> = include_dir!("src/mailers/auth/magic_link"); // #[derive(Mailer)] // -- disabled for faster build speed. it works. but lets // move on for now. @@ -62,4 +63,30 @@ impl AuthMailer { Ok(()) } + + /// Sends a magic link authentication email to the user. + /// + /// # Errors + /// + /// When email sending is failed + pub async fn send_magic_link(ctx: &AppContext, user: &users::Model) -> Result<()> { + Self::mail_template( + ctx, + &magic_link, + mailer::Args { + to: user.email.to_string(), + locals: json!({ + "name": user.name, + "token": user.magic_link_token.clone().ok_or_else(|| Error::string( + "the user model not contains magic link token", + ))?, + "host": ctx.config.server.full_url() + }), + ..Default::default() + }, + ) + .await?; + + Ok(()) + } } diff --git a/loco-new/base_template/src/mailers/auth/magic_link/html.t b/loco-new/base_template/src/mailers/auth/magic_link/html.t new file mode 100644 index 000000000..56eb2527c --- /dev/null +++ b/loco-new/base_template/src/mailers/auth/magic_link/html.t @@ -0,0 +1,8 @@ +; + +

Magic link example:

+ +Verify Your Account + + + diff --git a/loco-new/base_template/src/mailers/auth/magic_link/subject.t b/loco-new/base_template/src/mailers/auth/magic_link/subject.t new file mode 100644 index 000000000..93eaba77c --- /dev/null +++ b/loco-new/base_template/src/mailers/auth/magic_link/subject.t @@ -0,0 +1 @@ +Magic link example diff --git a/loco-new/base_template/src/mailers/auth/magic_link/text.t b/loco-new/base_template/src/mailers/auth/magic_link/text.t new file mode 100644 index 000000000..b33d33106 --- /dev/null +++ b/loco-new/base_template/src/mailers/auth/magic_link/text.t @@ -0,0 +1,2 @@ +Magic link with this link: +{{host}}/api/auth/magic-link/{{token}} \ No newline at end of file diff --git a/loco-new/base_template/src/models/_entities/mod.rs b/loco-new/base_template/src/models/_entities/mod.rs.t similarity index 71% rename from loco-new/base_template/src/models/_entities/mod.rs rename to loco-new/base_template/src/models/_entities/mod.rs.t index 095dade0f..dfdb3aff5 100644 --- a/loco-new/base_template/src/models/_entities/mod.rs +++ b/loco-new/base_template/src/models/_entities/mod.rs.t @@ -1,5 +1,6 @@ //! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0 pub mod prelude; - +{%- if settings.auth %} pub mod users; +{%- endif %} \ No newline at end of file diff --git a/loco-new/base_template/src/models/_entities/prelude.rs b/loco-new/base_template/src/models/_entities/prelude.rs.t similarity index 72% rename from loco-new/base_template/src/models/_entities/prelude.rs rename to loco-new/base_template/src/models/_entities/prelude.rs.t index 4036adeec..2896e25a3 100644 --- a/loco-new/base_template/src/models/_entities/prelude.rs +++ b/loco-new/base_template/src/models/_entities/prelude.rs.t @@ -1,3 +1,5 @@ //! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0 +{%- if settings.auth %} pub use super::users::Entity as Users; +{%- endif %} \ No newline at end of file diff --git a/loco-new/base_template/src/models/_entities/users.rs b/loco-new/base_template/src/models/_entities/users.rs index 120b1a1b1..765e99272 100644 --- a/loco-new/base_template/src/models/_entities/users.rs +++ b/loco-new/base_template/src/models/_entities/users.rs @@ -22,6 +22,8 @@ pub struct Model { pub email_verification_token: Option, pub email_verification_sent_at: Option, pub email_verified_at: Option, + pub magic_link_token: Option, + pub magic_link_expiration: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/loco-new/base_template/src/models/mod.rs b/loco-new/base_template/src/models/mod.rs deleted file mode 100644 index 48da463b6..000000000 --- a/loco-new/base_template/src/models/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod _entities; -pub mod users; diff --git a/loco-new/base_template/src/models/mod.rs.t b/loco-new/base_template/src/models/mod.rs.t new file mode 100644 index 000000000..9f2e96c67 --- /dev/null +++ b/loco-new/base_template/src/models/mod.rs.t @@ -0,0 +1,4 @@ +pub mod _entities; +{%- if settings.auth %} +pub mod users; +{%- endif %} \ No newline at end of file diff --git a/loco-new/base_template/src/models/users.rs b/loco-new/base_template/src/models/users.rs index b4f3aaea0..2855e1f9e 100644 --- a/loco-new/base_template/src/models/users.rs +++ b/loco-new/base_template/src/models/users.rs @@ -1,11 +1,14 @@ use async_trait::async_trait; -use chrono::offset::Local; +use chrono::{offset::Local, Duration}; use loco_rs::{auth::jwt, hash, prelude::*}; use serde::{Deserialize, Serialize}; use uuid::Uuid; pub use super::_entities::users::{self, ActiveModel, Entity, Model}; +pub const MAGIC_LINK_LENGTH: i8 = 32; +pub const MAGIC_LINK_EXPIRATION_MIN: i8 = 5; + #[derive(Debug, Deserialize, Serialize)] pub struct LoginParams { pub email: String, @@ -27,7 +30,7 @@ pub struct Validator { pub email: String, } -impl Validatable for super::_entities::users::ActiveModel { +impl Validatable for ActiveModel { fn validator(&self) -> Box { Box::new(Validator { name: self.name.as_ref().to_owned(), @@ -55,7 +58,7 @@ impl ActiveModelBehavior for super::_entities::users::ActiveModel { } #[async_trait] -impl Authenticable for super::_entities::users::Model { +impl Authenticable for Model { async fn find_by_api_key(db: &DatabaseConnection, api_key: &str) -> ModelResult { let user = users::Entity::find() .filter( @@ -73,7 +76,7 @@ impl Authenticable for super::_entities::users::Model { } } -impl super::_entities::users::Model { +impl Model { /// finds a user by the provided email /// /// # Errors @@ -111,6 +114,42 @@ impl super::_entities::users::Model { user.ok_or_else(|| ModelError::EntityNotFound) } + /// finds a user by the magic token and verify and token expiration + /// + /// # Errors + /// + /// When could not find user by the given token or DB query error ot token expired + pub async fn find_by_magic_token(db: &DatabaseConnection, token: &str) -> ModelResult { + let user = users::Entity::find() + .filter( + query::condition() + .eq(users::Column::MagicLinkToken, token) + .build(), + ) + .one(db) + .await?; + + let user = user.ok_or_else(|| ModelError::EntityNotFound)?; + if let Some(expired_at) = user.magic_link_expiration { + if expired_at >= Local::now() { + Ok(user) + } else { + tracing::debug!( + user_pid = user.pid.to_string(), + token_expiration = expired_at.to_string(), + "magic token expired for the user." + ); + Err(ModelError::msg("magic token expired")) + } + } else { + tracing::error!( + user_pid = user.pid.to_string(), + "magic link expiration time not exists" + ); + Err(ModelError::msg("expiration token not exists")) + } + } + /// finds a user by the provided reset token /// /// # Errors @@ -224,7 +263,7 @@ impl super::_entities::users::Model { } } -impl super::_entities::users::ActiveModel { +impl ActiveModel { /// Sets the email verification information for the user and /// updates it in the database. /// @@ -295,4 +334,33 @@ impl super::_entities::users::ActiveModel { self.reset_sent_at = ActiveValue::Set(None); Ok(self.update(db).await?) } + + /// Creates a magic link token for passwordless authentication. + /// + /// Generates a random token with a specified length and sets an expiration time + /// for the magic link. This method is used to initiate the magic link authentication flow. + /// + /// # Errors + /// - Returns an error if database update fails + pub async fn create_magic_link(mut self, db: &DatabaseConnection) -> ModelResult { + let random_str = hash::random_string(MAGIC_LINK_LENGTH as usize); + let expired = Local::now() + Duration::minutes(MAGIC_LINK_EXPIRATION_MIN.into()); + + self.magic_link_token = ActiveValue::set(Some(random_str)); + self.magic_link_expiration = ActiveValue::set(Some(expired.into())); + Ok(self.update(db).await?) + } + + /// Verifies and invalidates the magic link after successful authentication. + /// + /// Clears the magic link token and expiration time after the user has + /// successfully authenticated using the magic link. + /// + /// # Errors + /// - Returns an error if database update fails + pub async fn clear_magic_link(mut self, db: &DatabaseConnection) -> ModelResult { + self.magic_link_token = ActiveValue::set(None); + self.magic_link_expiration = ActiveValue::set(None); + Ok(self.update(db).await?) + } } diff --git a/loco-new/base_template/tests/models/mod.rs b/loco-new/base_template/tests/models/mod.rs deleted file mode 100644 index 59759880d..000000000 --- a/loco-new/base_template/tests/models/mod.rs +++ /dev/null @@ -1 +0,0 @@ -mod users; diff --git a/loco-new/base_template/tests/models/mod.rs.t b/loco-new/base_template/tests/models/mod.rs.t new file mode 100644 index 000000000..1a3a0a456 --- /dev/null +++ b/loco-new/base_template/tests/models/mod.rs.t @@ -0,0 +1,3 @@ +{%- if settings.auth %} +mod users; +{%- endif %} \ No newline at end of file diff --git a/loco-new/base_template/tests/models/snapshots/can_create_with_password@users.snap b/loco-new/base_template/tests/models/snapshots/can_create_with_password@users.snap index 6e66fd35a..98113622d 100644 --- a/loco-new/base_template/tests/models/snapshots/can_create_with_password@users.snap +++ b/loco-new/base_template/tests/models/snapshots/can_create_with_password@users.snap @@ -17,5 +17,7 @@ Ok( email_verification_token: None, email_verification_sent_at: None, email_verified_at: None, + magic_link_token: None, + magic_link_expiration: None, }, ) diff --git a/loco-new/base_template/tests/models/snapshots/can_find_by_email@users.snap b/loco-new/base_template/tests/models/snapshots/can_find_by_email@users.snap index 067d0e752..518753a67 100644 --- a/loco-new/base_template/tests/models/snapshots/can_find_by_email@users.snap +++ b/loco-new/base_template/tests/models/snapshots/can_find_by_email@users.snap @@ -17,5 +17,7 @@ Ok( email_verification_token: None, email_verification_sent_at: None, email_verified_at: None, + magic_link_token: None, + magic_link_expiration: None, }, ) diff --git a/loco-new/base_template/tests/models/snapshots/can_find_by_pid@users.snap b/loco-new/base_template/tests/models/snapshots/can_find_by_pid@users.snap index 067d0e752..518753a67 100644 --- a/loco-new/base_template/tests/models/snapshots/can_find_by_pid@users.snap +++ b/loco-new/base_template/tests/models/snapshots/can_find_by_pid@users.snap @@ -17,5 +17,7 @@ Ok( email_verification_token: None, email_verification_sent_at: None, email_verified_at: None, + magic_link_token: None, + magic_link_expiration: None, }, ) diff --git a/loco-new/base_template/tests/models/users.rs.t b/loco-new/base_template/tests/models/users.rs.t index 7ace87eca..16f845b6b 100644 --- a/loco-new/base_template/tests/models/users.rs.t +++ b/loco-new/base_template/tests/models/users.rs.t @@ -1,5 +1,6 @@ +use chrono::{offset::Local, Duration}; use insta::assert_debug_snapshot; -use loco_rs::{model::ModelError, testing::prelude::*}; +use loco_rs::testing::prelude::*; use {{settings.module_name}}::{ app::App, models::users::{self, Model, RegisterParams}, @@ -21,15 +22,15 @@ macro_rules! configure_insta { async fn test_can_validate_model() { configure_insta!(); - let boot = boot_test::().await.unwrap(); + let boot = boot_test::().await.expect("Failed to boot test application"); - let res = users::ActiveModel { + let invalid_user = users::ActiveModel { name: ActiveValue::set("1".to_string()), email: ActiveValue::set("invalid-email".to_string()), ..Default::default() - } - .insert(&boot.app_context.db) - .await; + }; + + let res = invalid_user.insert(&boot.app_context.db).await; assert_debug_snapshot!(res); } @@ -39,13 +40,14 @@ async fn test_can_validate_model() { async fn can_create_with_password() { configure_insta!(); - let boot = boot_test::().await.unwrap(); + let boot = boot_test::().await.expect("Failed to boot test application"); let params = RegisterParams { email: "test@framework.com".to_string(), password: "1234".to_string(), name: "framework".to_string(), }; + let res = Model::create_with_password(&boot.app_context.db, ¶ms).await; insta::with_settings!({ @@ -54,16 +56,15 @@ async fn can_create_with_password() { assert_debug_snapshot!(res); }); } - #[tokio::test] #[serial] async fn handle_create_with_password_with_duplicate() { configure_insta!(); - let boot = boot_test::().await.unwrap(); - seed::(&boot.app_context.db).await.unwrap(); + let boot = boot_test::().await.expect("Failed to boot test application"); + seed::(&boot.app_context.db).await.expect("Failed to seed database"); - let new_user: Result = Model::create_with_password( + let new_user = Model::create_with_password( &boot.app_context.db, &RegisterParams { email: "user1@example.com".to_string(), @@ -72,6 +73,7 @@ async fn handle_create_with_password_with_duplicate() { }, ) .await; + assert_debug_snapshot!(new_user); } @@ -80,12 +82,11 @@ async fn handle_create_with_password_with_duplicate() { async fn can_find_by_email() { configure_insta!(); - let boot = boot_test::().await.unwrap(); - seed::(&boot.app_context.db).await.unwrap(); + let boot = boot_test::().await.expect("Failed to boot test application"); + seed::(&boot.app_context.db).await.expect("Failed to seed database"); let existing_user = Model::find_by_email(&boot.app_context.db, "user1@example.com").await; - let non_existing_user_results = - Model::find_by_email(&boot.app_context.db, "un@existing-email.com").await; + let non_existing_user_results = Model::find_by_email(&boot.app_context.db, "un@existing-email.com").await; assert_debug_snapshot!(existing_user); assert_debug_snapshot!(non_existing_user_results); @@ -96,13 +97,11 @@ async fn can_find_by_email() { async fn can_find_by_pid() { configure_insta!(); - let boot = boot_test::().await.unwrap(); - seed::(&boot.app_context.db).await.unwrap(); + let boot = boot_test::().await.expect("Failed to boot test application"); + seed::(&boot.app_context.db).await.expect("Failed to seed database"); - let existing_user = - Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111").await; - let non_existing_user_results = - Model::find_by_pid(&boot.app_context.db, "23232323-2323-2323-2323-232323232323").await; + let existing_user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111").await; + let non_existing_user_results = Model::find_by_pid(&boot.app_context.db, "23232323-2323-2323-2323-232323232323").await; assert_debug_snapshot!(existing_user); assert_debug_snapshot!(non_existing_user_results); @@ -113,111 +112,185 @@ async fn can_find_by_pid() { async fn can_verification_token() { configure_insta!(); - let boot = boot_test::().await.unwrap(); - seed::(&boot.app_context.db).await.unwrap(); + let boot = boot_test::().await.expect("Failed to boot test application"); + seed::(&boot.app_context.db).await.expect("Failed to seed database"); let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111") .await - .unwrap(); + .expect("Failed to find user by PID"); - assert!(user.email_verification_sent_at.is_none()); - assert!(user.email_verification_token.is_none()); + assert!(user.email_verification_sent_at.is_none(), "Expected no email verification sent timestamp"); + assert!(user.email_verification_token.is_none(), "Expected no email verification token"); - assert!(user + let result = user .into_active_model() .set_email_verification_sent(&boot.app_context.db) - .await - .is_ok()); + .await; + + assert!(result.is_ok(), "Failed to set email verification sent"); let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111") .await - .unwrap(); + .expect("Failed to find user by PID after setting verification sent"); - assert!(user.email_verification_sent_at.is_some()); - assert!(user.email_verification_token.is_some()); + assert!(user.email_verification_sent_at.is_some(), "Expected email verification sent timestamp to be present"); + assert!(user.email_verification_token.is_some(), "Expected email verification token to be present"); } + #[tokio::test] #[serial] async fn can_set_forgot_password_sent() { configure_insta!(); - let boot = boot_test::().await.unwrap(); - seed::(&boot.app_context.db).await.unwrap(); + let boot = boot_test::().await.expect("Failed to boot test application"); + seed::(&boot.app_context.db).await.expect("Failed to seed database"); let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111") .await - .unwrap(); + .expect("Failed to find user by PID"); - assert!(user.reset_sent_at.is_none()); - assert!(user.reset_token.is_none()); + assert!(user.reset_sent_at.is_none(), "Expected no reset sent timestamp"); + assert!(user.reset_token.is_none(), "Expected no reset token"); - assert!(user + let result = user .into_active_model() .set_forgot_password_sent(&boot.app_context.db) - .await - .is_ok()); + .await; + + assert!(result.is_ok(), "Failed to set forgot password sent"); let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111") .await - .unwrap(); + .expect("Failed to find user by PID after setting forgot password sent"); - assert!(user.reset_sent_at.is_some()); - assert!(user.reset_token.is_some()); + assert!(user.reset_sent_at.is_some(), "Expected reset sent timestamp to be present"); + assert!(user.reset_token.is_some(), "Expected reset token to be present"); } + #[tokio::test] #[serial] async fn can_verified() { configure_insta!(); - let boot = boot_test::().await.unwrap(); - seed::(&boot.app_context.db).await.unwrap(); + let boot = boot_test::().await.expect("Failed to boot test application"); + seed::(&boot.app_context.db).await.expect("Failed to seed database"); let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111") .await - .unwrap(); + .expect("Failed to find user by PID"); - assert!(user.email_verified_at.is_none()); + assert!(user.email_verified_at.is_none(), "Expected email to be unverified"); - assert!(user + let result = user .into_active_model() .verified(&boot.app_context.db) - .await - .is_ok()); + .await; + + assert!(result.is_ok(), "Failed to mark email as verified"); let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111") .await - .unwrap(); + .expect("Failed to find user by PID after verification"); - assert!(user.email_verified_at.is_some()); + assert!(user.email_verified_at.is_some(), "Expected email to be verified"); } + #[tokio::test] #[serial] async fn can_reset_password() { configure_insta!(); - let boot = boot_test::().await.unwrap(); - seed::(&boot.app_context.db).await.unwrap(); + let boot = boot_test::().await.expect("Failed to boot test application"); + seed::(&boot.app_context.db).await.expect("Failed to seed database"); let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111") .await - .unwrap(); + .expect("Failed to find user by PID"); - assert!(user.verify_password("12341234")); + assert!(user.verify_password("12341234"), "Password verification failed for original password"); - assert!(user + let result = user .clone() .into_active_model() .reset_password(&boot.app_context.db, "new-password") + .await; + + assert!(result.is_ok(), "Failed to reset password"); + + let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111") .await - .is_ok()); + .expect("Failed to find user by PID after password reset"); + + assert!(user.verify_password("new-password"), "Password verification failed for new password"); +} + + +#[tokio::test] +#[serial] +async fn magic_link() { + let boot = boot_test::().await.unwrap(); + seed::(&boot.app_context.db).await.unwrap(); + + let user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111") + .await + .unwrap(); + + assert!( + user.magic_link_token.is_none(), + "Magic link token should be initially unset" + ); + assert!( + user.magic_link_expiration.is_none(), + "Magic link expiration should be initially unset" + ); + + let create_result = user + .into_active_model() + .create_magic_link(&boot.app_context.db) + .await; assert!( + create_result.is_ok(), + "Failed to create magic link: {:?}", + create_result.unwrap_err() + ); + + let updated_user = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111") .await - .unwrap() - .verify_password("new-password") + .expect("Failed to refetch user after magic link creation"); + + assert!( + updated_user.magic_link_token.is_some(), + "Magic link token should be set after creation" ); -} + + let magic_link_token = updated_user.magic_link_token.unwrap(); + assert_eq!( + magic_link_token.len(), + users::MAGIC_LINK_LENGTH as usize, + "Magic link token length does not match expected length" + ); + + assert!( + updated_user.magic_link_expiration.is_some(), + "Magic link expiration should be set after creation" + ); + + let now = Local::now(); + let should_expired_at = now + Duration::minutes(users::MAGIC_LINK_EXPIRATION_MIN.into()); + let actual_expiration = updated_user.magic_link_expiration.unwrap(); + + assert!( + actual_expiration >= now, + "Magic link expiration should be in the future or now" + ); + + assert!( + actual_expiration <= should_expired_at, + "Magic link expiration exceeds expected maximum expiration time" + ); +} \ No newline at end of file diff --git a/loco-new/base_template/tests/requests/auth.rs.t b/loco-new/base_template/tests/requests/auth.rs.t index c7e37da7d..a15c971e3 100644 --- a/loco-new/base_template/tests/requests/auth.rs.t +++ b/loco-new/base_template/tests/requests/auth.rs.t @@ -30,7 +30,8 @@ async fn can_register() { "password": "12341234" }); - let _response = request.post("/api/auth/register").json(&payload).await; + let response = request.post("/api/auth/register").json(&payload).await; + assert_eq!(response.status_code(), 200, "Register request should succeed"); let saved_user = users::Model::find_by_email(&ctx.db, email).await; with_settings!({ @@ -39,11 +40,14 @@ async fn can_register() { assert_debug_snapshot!(saved_user); }); - with_settings!({ - filters => cleanup_email() - }, { - assert_debug_snapshot!(ctx.mailer.unwrap().deliveries()); - }); + let deliveries = ctx.mailer.unwrap().deliveries(); + assert_eq!(deliveries.count, 1, "Exactly one email should be sent"); + + // with_settings!({ + // filters => cleanup_email() + // }, { + // assert_debug_snapshot!(ctx.mailer.unwrap().deliveries()); + // }); }) .await; } @@ -65,11 +69,13 @@ async fn can_login_with_verify(#[case] test_name: &str, #[case] password: &str) }); //Creating a new user - _ = request + let register_response = request .post("/api/auth/register") .json(®ister_payload) .await; + assert_eq!(register_response.status_code(), 200, "Register request should succeed"); + let user = users::Model::find_by_email(&ctx.db, email).await.unwrap(); let verify_payload = serde_json::json!({ "token": user.email_verification_token, @@ -86,11 +92,15 @@ async fn can_login_with_verify(#[case] test_name: &str, #[case] password: &str) .await; // Make sure email_verified_at is set - assert!(users::Model::find_by_email(&ctx.db, email) + let user = users::Model::find_by_email(&ctx.db, email) .await - .unwrap() - .email_verified_at - .is_some()); + .expect("Failed to find user by email"); + + assert!( + user.email_verified_at.is_some(), + "Expected the email to be verified, but it was not. User: {:?}", + user + ); with_settings!({ filters => cleanup_user_model() @@ -116,13 +126,15 @@ async fn can_login_without_verify() { }); //Creating a new user - _ = request + let register_response = request .post("/api/auth/register") .json(®ister_payload) .await; + assert_eq!(register_response.status_code(), 200, "Register request should succeed"); + //verify user request - let response = request + let login_response = request .post("/api/auth/login") .json(&serde_json::json!({ "email": email, @@ -130,10 +142,12 @@ async fn can_login_without_verify() { })) .await; + assert_eq!(login_response.status_code(), 200, "Login request should succeed"); + with_settings!({ filters => cleanup_user_model() }, { - assert_debug_snapshot!((response.status_code(), response.text())); + assert_debug_snapshot!(login_response.text()); }); }) .await; @@ -150,13 +164,21 @@ async fn can_reset_password() { let forgot_payload = serde_json::json!({ "email": login_data.user.email, }); - _ = request.post("/api/auth/forgot").json(&forgot_payload).await; + let forget_response = request.post("/api/auth/forgot").json(&forgot_payload).await; + assert_eq!(forget_response.status_code(), 200, "Forget request should succeed"); let user = users::Model::find_by_email(&ctx.db, &login_data.user.email) .await - .unwrap(); - assert!(user.reset_token.is_some()); - assert!(user.reset_sent_at.is_some()); + .expect("Failed to find user by email"); + + assert!( + user.reset_token.is_some(), + "Expected reset_token to be set, but it was None. User: {user:?}" + ); + assert!( + user.reset_sent_at.is_some(), + "Expected reset_sent_at to be set, but it was None. User: {user:?}" + ); let new_password = "new-password"; let reset_payload = serde_json::json!({ @@ -165,6 +187,7 @@ async fn can_reset_password() { }); let reset_response = request.post("/api/auth/reset").json(&reset_payload).await; + assert_eq!(reset_response.status_code(), 200, "Reset password request should succeed"); let user = users::Model::find_by_email(&ctx.db, &user.email) .await @@ -173,9 +196,9 @@ async fn can_reset_password() { assert!(user.reset_token.is_none()); assert!(user.reset_sent_at.is_none()); - assert_debug_snapshot!((reset_response.status_code(), reset_response.text())); + assert_debug_snapshot!(reset_response.text()); - let response = request + let login_response = request .post("/api/auth/login") .json(&serde_json::json!({ "email": user.email, @@ -183,13 +206,15 @@ async fn can_reset_password() { })) .await; - assert_eq!(response.status_code(), 200); + assert_eq!(login_response.status_code(), 200, "Login request should succeed"); - with_settings!({ - filters => cleanup_email() - }, { - assert_debug_snapshot!(ctx.mailer.unwrap().deliveries()); - }); + let deliveries = ctx.mailer.unwrap().deliveries(); + assert_eq!(deliveries.count, 2, "Exactly one email should be sent"); + // with_settings!({ + // filters => cleanup_email() + // }, { + // assert_debug_snapshot!(deliveries.messages); + // }); }) .await; } @@ -208,6 +233,8 @@ async fn can_get_current_user() { .add_header(auth_key, auth_value) .await; + assert_eq!(response.status_code(), 200, "Current request should succeed"); + with_settings!({ filters => cleanup_user_model() }, { @@ -216,3 +243,85 @@ async fn can_get_current_user() { }) .await; } + +#[tokio::test] +#[serial] +async fn can_auth_with_magic_link() { + configure_insta!(); + request::(|request, ctx| async move { + seed::(&ctx.db).await.unwrap(); + + let payload = serde_json::json!({ + "email": "user1@example.com", + }); + let response = request.post("/api/auth/magic-link").json(&payload).await; + assert_eq!(response.status_code(), 200, "Magic link request should succeed"); + + let deliveries = ctx.mailer.unwrap().deliveries(); + assert_eq!(deliveries.count, 1, "Exactly one email should be sent"); + + // let redact_token = format!("[a-zA-Z0-9]{% raw %}{{{}}}{% endraw %}", users::MAGIC_LINK_LENGTH); + // with_settings!({ + // filters => { + // let mut combined_filters = cleanup_email().clone(); + // combined_filters.extend(vec![(r"(\\r\\n|=\\r\\n)", ""), (redact_token.as_str(), "[REDACT_TOKEN]") ]); + // combined_filters + // } + // }, { + // assert_debug_snapshot!(deliveries.messages); + // }); + + let user = users::Model::find_by_email(&ctx.db, "user1@example.com") + .await + .expect("User should be found"); + + let magic_link_token = user.magic_link_token + .expect("Magic link token should be generated"); + let magic_link_response = request.get(&format!("/api/auth/magic-link/{magic_link_token}")).await; + assert_eq!(magic_link_response.status_code(), 200, "Magic link authentication should succeed"); + + with_settings!({ + filters => cleanup_user_model() + }, { + assert_debug_snapshot!(magic_link_response.text()); + }); + + }) + .await; +} + +#[tokio::test] +#[serial] +async fn can_reject_invalid_email() { + configure_insta!(); + request::(|request, _ctx| async move { + let invalid_email = "user1@temp-mail.com"; + let payload = serde_json::json!({ + "email": invalid_email, + }); + let response = request.post("/api/auth/magic-link").json(&payload).await; + assert_eq!( + response.status_code(), + 400, + "Expected request with invalid email '{invalid_email}' to be blocked, but it was allowed." + ); + }) + .await; +} + +#[tokio::test] +#[serial] +async fn can_reject_invalid_magic_link_token() { + configure_insta!(); + request::(|request, ctx| async move { + seed::(&ctx.db).await.unwrap(); + + let magic_link_response = request.get("/api/auth/magic-link/invalid-token").await; + assert_eq!( + magic_link_response.status_code(), + 401, + "Magic link authentication should be rejected" + ); + }) + .await; +} \ No newline at end of file diff --git a/loco-new/base_template/tests/requests/snapshots/can_auth_with_magic_link@auth_request.snap b/loco-new/base_template/tests/requests/snapshots/can_auth_with_magic_link@auth_request.snap new file mode 100644 index 000000000..199985703 --- /dev/null +++ b/loco-new/base_template/tests/requests/snapshots/can_auth_with_magic_link@auth_request.snap @@ -0,0 +1,5 @@ +--- +source: tests/requests/auth.rs +expression: magic_link_response.text() +--- +"{\"token\":\"TOKEN\",\"pid\":\"PID\",\"name\":\"user1\",\"is_verified\":false}" diff --git a/loco-new/base_template/tests/requests/snapshots/can_login_without_verify@auth_request.snap b/loco-new/base_template/tests/requests/snapshots/can_login_without_verify@auth_request.snap index ef54ba671..1fac2fd22 100644 --- a/loco-new/base_template/tests/requests/snapshots/can_login_without_verify@auth_request.snap +++ b/loco-new/base_template/tests/requests/snapshots/can_login_without_verify@auth_request.snap @@ -1,8 +1,5 @@ --- source: tests/requests/auth.rs -expression: "(response.status_code(), response.text())" +expression: login_response.text() --- -( - 200, - "{\"token\":\"TOKEN\",\"pid\":\"PID\",\"name\":\"loco\",\"is_verified\":false}", -) +"{\"token\":\"TOKEN\",\"pid\":\"PID\",\"name\":\"loco\",\"is_verified\":false}" diff --git a/loco-new/base_template/tests/requests/snapshots/can_register@auth_request-2.snap b/loco-new/base_template/tests/requests/snapshots/can_register@auth_request-2.snap deleted file mode 100644 index f380dd9f0..000000000 --- a/loco-new/base_template/tests/requests/snapshots/can_register@auth_request-2.snap +++ /dev/null @@ -1,8 +0,0 @@ ---- -source: tests/requests/auth.rs -expression: ctx.mailer.unwrap().deliveries() ---- -Deliveries { - count: 0, - messages: [], -} diff --git a/loco-new/base_template/tests/requests/snapshots/can_register@auth_request.snap b/loco-new/base_template/tests/requests/snapshots/can_register@auth_request.snap index 0c0e13bb7..687580cd7 100644 --- a/loco-new/base_template/tests/requests/snapshots/can_register@auth_request.snap +++ b/loco-new/base_template/tests/requests/snapshots/can_register@auth_request.snap @@ -21,5 +21,7 @@ Ok( DATE, ), email_verified_at: None, + magic_link_token: None, + magic_link_expiration: None, }, ) diff --git a/loco-new/base_template/tests/requests/snapshots/can_reset_password@auth_request-2.snap b/loco-new/base_template/tests/requests/snapshots/can_reset_password@auth_request-2.snap deleted file mode 100644 index f380dd9f0..000000000 --- a/loco-new/base_template/tests/requests/snapshots/can_reset_password@auth_request-2.snap +++ /dev/null @@ -1,8 +0,0 @@ ---- -source: tests/requests/auth.rs -expression: ctx.mailer.unwrap().deliveries() ---- -Deliveries { - count: 0, - messages: [], -} diff --git a/loco-new/base_template/tests/requests/snapshots/can_reset_password@auth_request.snap b/loco-new/base_template/tests/requests/snapshots/can_reset_password@auth_request.snap index be6838d35..d42607978 100644 --- a/loco-new/base_template/tests/requests/snapshots/can_reset_password@auth_request.snap +++ b/loco-new/base_template/tests/requests/snapshots/can_reset_password@auth_request.snap @@ -2,7 +2,4 @@ source: tests/requests/auth.rs expression: "(reset_response.status_code(), reset_response.text())" --- -( - 200, - "null", -) +"null" \ No newline at end of file diff --git a/loco-new/setup.rhai b/loco-new/setup.rhai index 3e780a2fd..802e835b3 100644 --- a/loco-new/setup.rhai +++ b/loco-new/setup.rhai @@ -51,16 +51,34 @@ gen.copy_file("config/production.yaml"); // Production config // Database-Related Files // ===================== if db { - gen.copy_template_dir("migration"); // Database migrations directory - gen.copy_dir("src/models"); // Models directory, copied if background enabled - gen.copy_dir("src/fixtures"); // Database fixtures directory - gen.copy_template("examples/playground.rs.t"); // Example playground template with DB setup - - // Test modules related to database models - gen.copy_file("tests/models/mod.rs"); // Models tests root - gen.copy_dir("tests/models/snapshots"); // Test snapshots for models - gen.copy_template("tests/models/users.rs.t"); // User model test template - gen.copy_template("tests/requests/prepare_data.rs.t"); // Data preparation template + // Database migrations configuration and setup + gen.copy_template("migration/Cargo.toml.t"); // Database migrations Cargo configuration + gen.copy_template("migration/src/lib.rs.t"); // Database migrations library + + // Entity modules for database models + gen.copy_template("src/models/_entities/mod.rs.t"); // Root module for database entities + gen.copy_template("src/models/_entities/prelude.rs.t"); // Root module for database entities + gen.copy_template("src/models/mod.rs.t"); // Root module for database entities + + // Test modules related to database models + gen.copy_template("tests/models/mod.rs.t"); // Models tests root module + + if (settings.auth) { + // Authentication-related models and migrations + gen.copy_file("migration/src/m20220101_000001_users.rs"); // Users migration file + gen.copy_file("src/models/_entities/users.rs"); // Users entity definition + gen.copy_file("src/models/users.rs"); // Users model logic + + // Fixtures and test setup for authentication + gen.copy_dir("src/fixtures"); // Database fixtures directory + + // Test modules related to user models + gen.copy_dir("tests/models/snapshots"); // Test snapshots for models + gen.copy_template("tests/models/users.rs.t"); // User model test template + gen.copy_template("tests/requests/prepare_data.rs.t"); // Data preparation template for tests + } + + gen.copy_template("examples/playground.rs.t"); // Example playground template with DB setup } // ===================== diff --git a/loco-new/src/settings.rs b/loco-new/src/settings.rs index 28df3f5fc..b142b09be 100644 --- a/loco-new/src/settings.rs +++ b/loco-new/src/settings.rs @@ -75,8 +75,8 @@ impl Settings { Self { package_name: package_name.to_string(), module_name: package_name.to_snake_case(), - auth: prompt_selection.db.enable(), - mailer: prompt_selection.db.enable(), + auth: prompt_selection.db.enable() && prompt_selection.background.enable(), + mailer: prompt_selection.db.enable() && prompt_selection.background.enable(), db: prompt_selection.db.clone().into(), background: prompt_selection.background.clone().into(), asset: prompt_selection.asset.clone().into(), diff --git a/loco-new/src/wizard.rs b/loco-new/src/wizard.rs index 61971e100..16ba8c699 100644 --- a/loco-new/src/wizard.rs +++ b/loco-new/src/wizard.rs @@ -318,24 +318,31 @@ pub fn start(args: &ArgsPlaceholder) -> crate::Result { }), Template::RestApi => Ok(Selections { db: select_db(args)?, - background: select_background(args)?, + background: select_background(args, None)?, asset: AssetsOption::None, }), Template::SaasServerSideRendering => Ok(Selections { db: select_db(args)?, - background: select_background(args)?, + background: select_background(args, None)?, asset: AssetsOption::Serverside, }), Template::SaasClientSideRendering => Ok(Selections { db: select_db(args)?, - background: select_background(args)?, + background: select_background(args, None)?, asset: AssetsOption::Clientside, }), - Template::Advanced => Ok(Selections { - db: select_db(args)?, - background: select_background(args)?, - asset: select_asset(args)?, - }), + Template::Advanced => { + let db = select_db(args)?; + let background_options = match db { + DBOption::Sqlite | DBOption::Postgres => Some(vec![BackgroundOption::None]), + DBOption::None => None, + }; + Ok(Selections { + db, + background: select_background(args, background_options.as_ref())?, + asset: select_asset(args)?, + }) + } } } @@ -355,14 +362,18 @@ fn select_db(args: &ArgsPlaceholder) -> crate::Result { /// Prompts the user to select a background worker option if none is provided in /// the arguments. -fn select_background(args: &ArgsPlaceholder) -> crate::Result { +fn select_background( + args: &ArgsPlaceholder, + filters: Option<&Vec>, +) -> crate::Result { let bgopt = if let Some(bgopt) = args.bg.clone() { bgopt } else { - select_option( - "❯ Select your background worker type", - &BackgroundOption::iter().collect::>(), - )? + let available_options = BackgroundOption::iter() + .filter(|opt| filters.as_ref().map_or(true, |f| !f.contains(opt))) + .collect::>(); + + select_option("❯ Select your background worker type", &available_options)? }; Ok(bgopt) } diff --git a/loco-new/tests/templates/auth.rs b/loco-new/tests/templates/auth.rs index 90cbf3d47..6bf2f16a0 100644 --- a/loco-new/tests/templates/auth.rs +++ b/loco-new/tests/templates/auth.rs @@ -1,14 +1,15 @@ -use loco::settings; +use loco::{settings, wizard::DBOption}; use rstest::rstest; use super::*; use crate::assertion; -pub fn run_generator(enable_auth: bool) -> TestGenerator { +pub fn run_generator(enable_auth: bool, db: DBOption) -> TestGenerator { let settings = settings::Settings { package_name: "loco-app-test".to_string(), module_name: "loco_app_test".to_string(), auth: enable_auth, + db: db.into(), ..Default::default() }; @@ -19,7 +20,7 @@ pub fn run_generator(enable_auth: bool) -> TestGenerator { fn test_config_file_without_auth( #[values("config/development.yaml", "config/test.yaml")] config_file: &str, ) { - let generator = run_generator(false); + let generator = run_generator(false, DBOption::None); let content = assertion::yaml::load(generator.path(config_file)); assertion::yaml::assert_path_is_empty(&content, &["auth"]); } @@ -28,7 +29,7 @@ fn test_config_file_without_auth( fn test_config_file_with_auth( #[values("config/development.yaml", "config/test.yaml")] config_file: &str, ) { - let generator = run_generator(true); + let generator = run_generator(true, DBOption::None); let content = assertion::yaml::load(generator.path(config_file)); assertion::yaml::assert_path_key_count(&content, &["auth"], 1); @@ -37,7 +38,7 @@ fn test_config_file_with_auth( #[test] fn test_config_file_development_rand_secret() { - let generator = run_generator(true); + let generator = run_generator(true, DBOption::None); let content = assertion::yaml::load(generator.path("config/development.yaml")); assertion::yaml::assert_path_value_eq_string( &content, @@ -48,7 +49,7 @@ fn test_config_file_development_rand_secret() { #[test] fn test_config_file_test_rand_secret() { - let generator = run_generator(true); + let generator = run_generator(true, DBOption::None); let content = assertion::yaml::load(generator.path("config/test.yaml")); assertion::yaml::assert_path_value_eq_string( &content, @@ -58,17 +59,20 @@ fn test_config_file_test_rand_secret() { } #[rstest] -fn test_app_rs(#[values(true, false)] auth: bool) { - let generator = run_generator(auth); +fn test_app_rs( + #[values(true, false)] auth: bool, + #[values(DBOption::None, DBOption::Sqlite)] db: DBOption, +) { + let generator = run_generator(auth, db.clone()); insta::assert_snapshot!( - format!("src_app_rs_auth_{:?}", auth), + format!("src_app_rs_auth_{:?}_{:?}", auth, db), std::fs::read_to_string(generator.path("src/app.rs")).expect("could not open file") ); } #[rstest] fn test_src_controllers_mod_rs(#[values(true, false)] auth: bool) { - let generator = run_generator(auth); + let generator = run_generator(auth, DBOption::None); let content = std::fs::read_to_string(generator.path("src/controllers/mod.rs")) .expect("could not open file"); @@ -81,7 +85,7 @@ fn test_src_controllers_mod_rs(#[values(true, false)] auth: bool) { #[rstest] fn test_src_views_mod_rs(#[values(true, false)] auth: bool) { - let generator = run_generator(auth); + let generator = run_generator(auth, DBOption::None); let content = std::fs::read_to_string(generator.path("src/views/mod.rs")).expect("could not open file"); @@ -91,9 +95,10 @@ fn test_src_views_mod_rs(#[values(true, false)] auth: bool) { assertion::string::assert_line_regex(&content, "(?m)^pub mod home;$"); } } + #[rstest] fn test_tests_requests_mod_rs(#[values(true, false)] auth: bool) { - let generator = run_generator(auth); + let generator = run_generator(auth, DBOption::None); let content = std::fs::read_to_string(generator.path("tests/requests/mod.rs")) .expect("could not open file"); @@ -104,3 +109,67 @@ fn test_tests_requests_mod_rs(#[values(true, false)] auth: bool) { assertion::string::assert_line_regex(&content, "(?m)^mod home;$"); } } + +#[rstest] +fn test_migration_src_lib(#[values(true)] auth: bool) { + let generator = run_generator(auth, DBOption::Sqlite); + let content = std::fs::read_to_string(generator.path("migration/src/lib.rs")) + .expect("could not open file"); + + if auth { + assertion::string::assert_line_regex(&content, "(?m)^mod m20220101_000001_users;$"); + assertion::string::assert_line_regex( + &content, + r"(?m)Box::new\(m20220101_000001_users::Migration\),$", + ); + } +} + +#[rstest] +fn test_models_mod_rs(#[values(true)] auth: bool) { + let generator = run_generator(auth, DBOption::Sqlite); + let content = + std::fs::read_to_string(generator.path("src/models/mod.rs")).expect("could not open file"); + + if auth { + assertion::string::assert_line_regex(&content, "(?m)^pub mod users;$"); + } +} + +#[rstest] +fn test_models_entities_mod_rs(#[values(true)] auth: bool) { + let generator = run_generator(auth, DBOption::Sqlite); + let content = std::fs::read_to_string(generator.path("src/models/_entities/mod.rs")) + .expect("could not open file"); + + if auth { + assertion::string::assert_line_regex(&content, "(?m)^pub mod users;$"); + } +} + +#[rstest] +fn test_models_entities_prelude_rs(#[values(true)] auth: bool) { + let generator = run_generator(auth, DBOption::Sqlite); + let content = std::fs::read_to_string(generator.path("src/models/_entities/prelude.rs")) + .expect("could not open file"); + + if auth { + assertion::string::assert_line_regex( + &content, + "(?m)^pub use super::users::Entity as Users;$", + ); + } +} + +#[rstest] +fn test_tests_models_mod_rs(#[values(true, false)] auth: bool) { + let generator = run_generator(auth, DBOption::Sqlite); + let content = std::fs::read_to_string(generator.path("tests/models/mod.rs")) + .expect("could not open file"); + + if auth { + assertion::string::assert_line_regex(&content, "(?m)^mod users;$"); + } else { + assert!(content.is_empty()); + } +} diff --git a/loco-new/tests/templates/background.rs b/loco-new/tests/templates/background.rs index adcab5cba..222b95516 100644 --- a/loco-new/tests/templates/background.rs +++ b/loco-new/tests/templates/background.rs @@ -60,7 +60,7 @@ fn test_config_file_queue( #[rstest] fn test_config_file_workers( - #[values("config/development.yaml", "config/test.yaml")] config_file: &str, + #[values("config/development.yaml")] config_file: &str, #[values( BackgroundOption::None, BackgroundOption::Async, @@ -104,6 +104,51 @@ fn test_config_file_workers( } } +#[rstest] +fn test_config_file_workers_tests( + #[values( + BackgroundOption::None, + BackgroundOption::Async, + BackgroundOption::Queue, + BackgroundOption::Blocking + )] + background: BackgroundOption, +) { + let generator = run_generator(background.clone()); + let content = assertion::yaml::load(generator.path("config/test.yaml")); + + match background { + BackgroundOption::Async => { + assertion::yaml::assert_path_value_eq_string( + &content, + &["workers", "mode"], + "ForegroundBlocking", + ); + } + BackgroundOption::Queue => { + assertion::yaml::assert_path_value_eq_string( + &content, + &["workers", "mode"], + "ForegroundBlocking", + ); + } + BackgroundOption::Blocking => { + assertion::yaml::assert_path_value_eq_string( + &content, + &["workers", "mode"], + "ForegroundBlocking", + ); + } + BackgroundOption::None => { + assertion::yaml::assert_path_is_empty(&content, &["workers"]); + } + }; + + if background.enable() { + assertion::yaml::assert_path_key_count(&content, &["workers"], 1); + } +} + #[rstest] fn test_app_rs( #[values( diff --git a/loco-new/tests/templates/mailer.rs b/loco-new/tests/templates/mailer.rs index 17148184d..4f7185fe6 100644 --- a/loco-new/tests/templates/mailer.rs +++ b/loco-new/tests/templates/mailer.rs @@ -28,7 +28,13 @@ fn test_config_file_with_mailer( ) { let generator = run_generator(true); let content = assertion::yaml::load(generator.path(config_file)); - assertion::yaml::assert_path_key_count(&content, &["mailer"], 1); + if config_file == "config/test.yaml" { + assertion::yaml::assert_path_key_count(&content, &["mailer"], 2); + assertion::yaml::assert_path_value_eq_bool(&content, &["mailer", "stub"], true); + } else { + assertion::yaml::assert_path_key_count(&content, &["mailer"], 1); + } + assertion::yaml::assert_path_key_count(&content, &["mailer", "smtp"], 4); assertion::yaml::assert_path_value_eq_bool(&content, &["mailer", "smtp", "enable"], true); assertion::yaml::assert_path_value_eq_int(&content, &["mailer", "smtp", "port"], 1025); diff --git a/loco-new/tests/templates/module_name.rs b/loco-new/tests/templates/module_name.rs index 1291b0bbd..14efcf765 100644 --- a/loco-new/tests/templates/module_name.rs +++ b/loco-new/tests/templates/module_name.rs @@ -48,7 +48,16 @@ fn test_use_name(#[values("src/bin/main.rs", "tests/requests/home.rs")] file: &s fn test_use_name_with_db( #[values("tests/models/users.rs", "tests/requests/prepare_data.rs")] file: &str, ) { - let generator = super::db::run_generator(DBOption::Sqlite); + let settings = settings::Settings { + package_name: "loco-app-test".to_string(), + module_name: "loco_app_test".to_string(), + db: DBOption::Sqlite.into(), + auth: true, + ..Default::default() + }; + + let generator = TestGenerator::generate(settings); + println!("{:#?}", generator.tree); let content = std::fs::read_to_string(generator.path(file)).expect("could not open file"); @@ -57,7 +66,7 @@ fn test_use_name_with_db( #[rstest] fn test_use_name_with_auth(#[values("tests/requests/auth.rs")] file: &str) { - let generator = super::auth::run_generator(true); + let generator = super::auth::run_generator(true, DBOption::None); let content = std::fs::read_to_string(generator.path(file)).expect("could not open file"); diff --git a/loco-new/tests/templates/snapshots/r#mod__templates__asset__cargo_dependencies_Clientside.snap b/loco-new/tests/templates/snapshots/r#mod__templates__asset__cargo_dependencies_Clientside.snap index fbfcb3e2f..db70c3d1f 100644 --- a/loco-new/tests/templates/snapshots/r#mod__templates__asset__cargo_dependencies_Clientside.snap +++ b/loco-new/tests/templates/snapshots/r#mod__templates__asset__cargo_dependencies_Clientside.snap @@ -1,5 +1,5 @@ --- -source: loco-new/tests/templates/asset.rs +source: tests/templates/asset.rs expression: "content.get(\"dependencies\").unwrap()" --- -{ async-trait = "0.1.74", axum = "0.7.5", serde_json = "1", tracing = "0.1.40", unic-langid = "0.9.4", fluent-templates = { features = ["tera"], version = "0.8.0" }, loco-rs = { workspace = true }, serde = { features = ["derive"], version = "1" }, tokio = { default-features = false, features = ["rt-multi-thread"], version = "1.33.0" }, tracing-subscriber = { features = ["env-filter", "json"], version = "0.3.17" } } +{ async-trait = { version = "0.1.74" }, axum = { version = "0.7.5" }, fluent-templates = { features = ["tera"], version = "0.8.0" }, loco-rs = { workspace = true }, regex = { version = "1.11.1" }, serde = { features = ["derive"], version = "1" }, serde_json = { version = "1" }, tokio = { default-features = false, features = ["rt-multi-thread"], version = "1.33.0" }, tracing = { version = "0.1.40" }, tracing-subscriber = { features = ["env-filter", "json"], version = "0.3.17" }, unic-langid = { version = "0.9.4" } } diff --git a/loco-new/tests/templates/snapshots/r#mod__templates__asset__cargo_dependencies_None.snap b/loco-new/tests/templates/snapshots/r#mod__templates__asset__cargo_dependencies_None.snap index 567b40792..29bf8d6cc 100644 --- a/loco-new/tests/templates/snapshots/r#mod__templates__asset__cargo_dependencies_None.snap +++ b/loco-new/tests/templates/snapshots/r#mod__templates__asset__cargo_dependencies_None.snap @@ -1,5 +1,5 @@ --- -source: loco-new/tests/templates/asset.rs +source: tests/templates/asset.rs expression: "content.get(\"dependencies\").unwrap()" --- -{ async-trait = "0.1.74", axum = "0.7.5", serde_json = "1", tracing = "0.1.40", loco-rs = { workspace = true }, serde = { features = ["derive"], version = "1" }, tokio = { default-features = false, features = ["rt-multi-thread"], version = "1.33.0" }, tracing-subscriber = { features = ["env-filter", "json"], version = "0.3.17" } } +{ async-trait = { version = "0.1.74" }, axum = { version = "0.7.5" }, loco-rs = { workspace = true }, regex = { version = "1.11.1" }, serde = { features = ["derive"], version = "1" }, serde_json = { version = "1" }, tokio = { default-features = false, features = ["rt-multi-thread"], version = "1.33.0" }, tracing = { version = "0.1.40" }, tracing-subscriber = { features = ["env-filter", "json"], version = "0.3.17" } } diff --git a/loco-new/tests/templates/snapshots/r#mod__templates__asset__cargo_dependencies_Serverside.snap b/loco-new/tests/templates/snapshots/r#mod__templates__asset__cargo_dependencies_Serverside.snap index fbfcb3e2f..db70c3d1f 100644 --- a/loco-new/tests/templates/snapshots/r#mod__templates__asset__cargo_dependencies_Serverside.snap +++ b/loco-new/tests/templates/snapshots/r#mod__templates__asset__cargo_dependencies_Serverside.snap @@ -1,5 +1,5 @@ --- -source: loco-new/tests/templates/asset.rs +source: tests/templates/asset.rs expression: "content.get(\"dependencies\").unwrap()" --- -{ async-trait = "0.1.74", axum = "0.7.5", serde_json = "1", tracing = "0.1.40", unic-langid = "0.9.4", fluent-templates = { features = ["tera"], version = "0.8.0" }, loco-rs = { workspace = true }, serde = { features = ["derive"], version = "1" }, tokio = { default-features = false, features = ["rt-multi-thread"], version = "1.33.0" }, tracing-subscriber = { features = ["env-filter", "json"], version = "0.3.17" } } +{ async-trait = { version = "0.1.74" }, axum = { version = "0.7.5" }, fluent-templates = { features = ["tera"], version = "0.8.0" }, loco-rs = { workspace = true }, regex = { version = "1.11.1" }, serde = { features = ["derive"], version = "1" }, serde_json = { version = "1" }, tokio = { default-features = false, features = ["rt-multi-thread"], version = "1.33.0" }, tracing = { version = "0.1.40" }, tracing-subscriber = { features = ["env-filter", "json"], version = "0.3.17" }, unic-langid = { version = "0.9.4" } } diff --git a/loco-new/tests/templates/snapshots/r#mod__templates__auth__src_app_rs_auth_false.snap b/loco-new/tests/templates/snapshots/r#mod__templates__auth__src_app_rs_auth_false_None.snap similarity index 98% rename from loco-new/tests/templates/snapshots/r#mod__templates__auth__src_app_rs_auth_false.snap rename to loco-new/tests/templates/snapshots/r#mod__templates__auth__src_app_rs_auth_false_None.snap index bc2274ca4..692d668ff 100644 --- a/loco-new/tests/templates/snapshots/r#mod__templates__auth__src_app_rs_auth_false.snap +++ b/loco-new/tests/templates/snapshots/r#mod__templates__auth__src_app_rs_auth_false_None.snap @@ -16,7 +16,7 @@ use loco_rs::{ #[allow(unused_imports)] use crate::{ - controllers, + controllers ,tasks }; pub struct App; diff --git a/loco-new/tests/templates/snapshots/r#mod__templates__auth__src_app_rs_auth_false_Sqlite.snap b/loco-new/tests/templates/snapshots/r#mod__templates__auth__src_app_rs_auth_false_Sqlite.snap new file mode 100644 index 000000000..908d93f36 --- /dev/null +++ b/loco-new/tests/templates/snapshots/r#mod__templates__auth__src_app_rs_auth_false_Sqlite.snap @@ -0,0 +1,69 @@ +--- +source: tests/templates/auth.rs +expression: "std::fs::read_to_string(generator.path(\"src/app.rs\")).expect(\"could not open file\")" +--- +use std::path::Path; +use async_trait::async_trait; +use loco_rs::{ + app::{AppContext, Hooks, Initializer}, + bgworker::{ + Queue}, + boot::{create_app, BootResult, StartMode}, + controller::AppRoutes, + environment::Environment, + task::Tasks, + Result, +}; +use migration::Migrator; +use sea_orm::DatabaseConnection; + +#[allow(unused_imports)] +use crate::{ + controllers ,tasks +}; + +pub struct App; +#[async_trait] +impl Hooks for App { + fn app_name() -> &'static str { + env!("CARGO_CRATE_NAME") + } + + fn app_version() -> String { + format!( + "{} ({})", + env!("CARGO_PKG_VERSION"), + option_env!("BUILD_SHA") + .or(option_env!("GITHUB_SHA")) + .unwrap_or("dev") + ) + } + + async fn boot(mode: StartMode, environment: &Environment) -> Result { + create_app::(mode, environment).await + + } + + async fn initializers(_ctx: &AppContext) -> Result>> { + Ok(vec![]) + } + + fn routes(_ctx: &AppContext) -> AppRoutes { + AppRoutes::with_default_routes() // controller routes below + .add_route(controllers::home::routes()) + } + async fn connect_workers(_ctx: &AppContext, _queue: &Queue) -> Result<()> { + Ok(()) + } + + #[allow(unused_variables)] + fn register_tasks(tasks: &mut Tasks) { + // tasks-inject (do not remove) + } + async fn truncate(_db: &DatabaseConnection) -> Result<()> { + Ok(()) + } + async fn seed(_db: &DatabaseConnection, _base: &Path) -> Result<()> { + Ok(()) + } +} diff --git a/loco-new/tests/templates/snapshots/r#mod__templates__auth__src_app_rs_auth_true.snap b/loco-new/tests/templates/snapshots/r#mod__templates__auth__src_app_rs_auth_true_None.snap similarity index 94% rename from loco-new/tests/templates/snapshots/r#mod__templates__auth__src_app_rs_auth_true.snap rename to loco-new/tests/templates/snapshots/r#mod__templates__auth__src_app_rs_auth_true_None.snap index a98e1f422..1059932aa 100644 --- a/loco-new/tests/templates/snapshots/r#mod__templates__auth__src_app_rs_auth_true.snap +++ b/loco-new/tests/templates/snapshots/r#mod__templates__auth__src_app_rs_auth_true_None.snap @@ -9,6 +9,7 @@ use loco_rs::{ Queue}, boot::{create_app, BootResult, StartMode}, controller::AppRoutes, + db::{self, truncate_table}, environment::Environment, task::Tasks, Result, @@ -16,7 +17,8 @@ use loco_rs::{ #[allow(unused_imports)] use crate::{ - controllers, + controllers ,tasks + , models::_entities::users }; pub struct App; diff --git a/loco-new/tests/templates/snapshots/r#mod__templates__auth__src_app_rs_auth_true_Sqlite.snap b/loco-new/tests/templates/snapshots/r#mod__templates__auth__src_app_rs_auth_true_Sqlite.snap new file mode 100644 index 000000000..84e421a78 --- /dev/null +++ b/loco-new/tests/templates/snapshots/r#mod__templates__auth__src_app_rs_auth_true_Sqlite.snap @@ -0,0 +1,73 @@ +--- +source: tests/templates/auth.rs +expression: "std::fs::read_to_string(generator.path(\"src/app.rs\")).expect(\"could not open file\")" +--- +use std::path::Path; +use async_trait::async_trait; +use loco_rs::{ + app::{AppContext, Hooks, Initializer}, + bgworker::{ + Queue}, + boot::{create_app, BootResult, StartMode}, + controller::AppRoutes, + db::{self, truncate_table}, + environment::Environment, + task::Tasks, + Result, +}; +use migration::Migrator; +use sea_orm::DatabaseConnection; + +#[allow(unused_imports)] +use crate::{ + controllers ,tasks + , models::_entities::users +}; + +pub struct App; +#[async_trait] +impl Hooks for App { + fn app_name() -> &'static str { + env!("CARGO_CRATE_NAME") + } + + fn app_version() -> String { + format!( + "{} ({})", + env!("CARGO_PKG_VERSION"), + option_env!("BUILD_SHA") + .or(option_env!("GITHUB_SHA")) + .unwrap_or("dev") + ) + } + + async fn boot(mode: StartMode, environment: &Environment) -> Result { + create_app::(mode, environment).await + + } + + async fn initializers(_ctx: &AppContext) -> Result>> { + Ok(vec![]) + } + + fn routes(_ctx: &AppContext) -> AppRoutes { + AppRoutes::with_default_routes() // controller routes below + .add_route(controllers::auth::routes()) + } + async fn connect_workers(_ctx: &AppContext, _queue: &Queue) -> Result<()> { + Ok(()) + } + + #[allow(unused_variables)] + fn register_tasks(tasks: &mut Tasks) { + // tasks-inject (do not remove) + } + async fn truncate(db: &DatabaseConnection) -> Result<()> { + truncate_table(db, users::Entity).await?; + Ok(()) + } + async fn seed(db: &DatabaseConnection, base: &Path) -> Result<()> { + db::seed::(db, &base.join("users.yaml").display().to_string()).await?; + Ok(()) + } +} diff --git a/loco-new/tests/templates/snapshots/r#mod__templates__background__src_app_rs_Async.snap b/loco-new/tests/templates/snapshots/r#mod__templates__background__src_app_rs_Async.snap index cf70e6fdc..cad57e737 100644 --- a/loco-new/tests/templates/snapshots/r#mod__templates__background__src_app_rs_Async.snap +++ b/loco-new/tests/templates/snapshots/r#mod__templates__background__src_app_rs_Async.snap @@ -17,8 +17,8 @@ use loco_rs::{ #[allow(unused_imports)] use crate::{ - controllers - , workers::downloader::DownloadWorker, + controllers ,tasks + , workers::downloader::DownloadWorker }; pub struct App; diff --git a/loco-new/tests/templates/snapshots/r#mod__templates__background__src_app_rs_Blocking.snap b/loco-new/tests/templates/snapshots/r#mod__templates__background__src_app_rs_Blocking.snap index cf70e6fdc..cad57e737 100644 --- a/loco-new/tests/templates/snapshots/r#mod__templates__background__src_app_rs_Blocking.snap +++ b/loco-new/tests/templates/snapshots/r#mod__templates__background__src_app_rs_Blocking.snap @@ -17,8 +17,8 @@ use loco_rs::{ #[allow(unused_imports)] use crate::{ - controllers - , workers::downloader::DownloadWorker, + controllers ,tasks + , workers::downloader::DownloadWorker }; pub struct App; diff --git a/loco-new/tests/templates/snapshots/r#mod__templates__background__src_app_rs_None.snap b/loco-new/tests/templates/snapshots/r#mod__templates__background__src_app_rs_None.snap index 16e6e5ccc..8d1799bd0 100644 --- a/loco-new/tests/templates/snapshots/r#mod__templates__background__src_app_rs_None.snap +++ b/loco-new/tests/templates/snapshots/r#mod__templates__background__src_app_rs_None.snap @@ -16,7 +16,7 @@ use loco_rs::{ #[allow(unused_imports)] use crate::{ - controllers, + controllers ,tasks }; pub struct App; diff --git a/loco-new/tests/templates/snapshots/r#mod__templates__background__src_app_rs_Queue.snap b/loco-new/tests/templates/snapshots/r#mod__templates__background__src_app_rs_Queue.snap index cf70e6fdc..cad57e737 100644 --- a/loco-new/tests/templates/snapshots/r#mod__templates__background__src_app_rs_Queue.snap +++ b/loco-new/tests/templates/snapshots/r#mod__templates__background__src_app_rs_Queue.snap @@ -17,8 +17,8 @@ use loco_rs::{ #[allow(unused_imports)] use crate::{ - controllers - , workers::downloader::DownloadWorker, + controllers ,tasks + , workers::downloader::DownloadWorker }; pub struct App; diff --git a/loco-new/tests/templates/snapshots/r#mod__templates__db__cargo_dependencies_None.snap b/loco-new/tests/templates/snapshots/r#mod__templates__db__cargo_dependencies_None.snap index 9f6247be3..023a52c70 100644 --- a/loco-new/tests/templates/snapshots/r#mod__templates__db__cargo_dependencies_None.snap +++ b/loco-new/tests/templates/snapshots/r#mod__templates__db__cargo_dependencies_None.snap @@ -1,5 +1,5 @@ --- -source: loco-new/tests/templates/db.rs +source: tests/templates/db.rs expression: "content.get(\"dependencies\").unwrap()" --- -{ async-trait = "0.1.74", axum = "0.7.5", serde_json = "1", tracing = "0.1.40", loco-rs = { workspace = true }, serde = { features = ["derive"], version = "1" }, tokio = { default-features = false, features = ["rt-multi-thread"], version = "1.33.0" }, tracing-subscriber = { features = ["env-filter", "json"], version = "0.3.17" } } +{ async-trait = { version = "0.1.74" }, axum = { version = "0.7.5" }, loco-rs = { workspace = true }, regex = { version = "1.11.1" }, serde = { features = ["derive"], version = "1" }, serde_json = { version = "1" }, tokio = { default-features = false, features = ["rt-multi-thread"], version = "1.33.0" }, tracing = { version = "0.1.40" }, tracing-subscriber = { features = ["env-filter", "json"], version = "0.3.17" } } diff --git a/loco-new/tests/templates/snapshots/r#mod__templates__db__cargo_dependencies_Postgres.snap b/loco-new/tests/templates/snapshots/r#mod__templates__db__cargo_dependencies_Postgres.snap index 859cb0fdf..385489b5e 100644 --- a/loco-new/tests/templates/snapshots/r#mod__templates__db__cargo_dependencies_Postgres.snap +++ b/loco-new/tests/templates/snapshots/r#mod__templates__db__cargo_dependencies_Postgres.snap @@ -1,5 +1,5 @@ --- -source: loco-new/tests/templates/db.rs +source: tests/templates/db.rs expression: "content.get(\"dependencies\").unwrap()" --- -{ async-trait = "0.1.74", axum = "0.7.5", chrono = "0.4", serde_json = "1", tracing = "0.1.40", loco-rs = { workspace = true }, migration = { path = "migration" }, sea-orm = { features = ["sqlx-sqlite", "sqlx-postgres", "runtime-tokio-rustls", "macros"], version = "1.1.0" }, serde = { features = ["derive"], version = "1" }, tokio = { default-features = false, features = ["rt-multi-thread"], version = "1.33.0" }, tracing-subscriber = { features = ["env-filter", "json"], version = "0.3.17" }, uuid = { features = ["v4"], version = "1.6.0" }, validator = { version = "0.19" } } +{ async-trait = { version = "0.1.74" }, axum = { version = "0.7.5" }, chrono = { version = "0.4" }, loco-rs = { workspace = true }, migration = { path = "migration" }, regex = { version = "1.11.1" }, sea-orm = { features = ["sqlx-sqlite", "sqlx-postgres", "runtime-tokio-rustls", "macros"], version = "1.1.0" }, serde = { features = ["derive"], version = "1" }, serde_json = { version = "1" }, tokio = { default-features = false, features = ["rt-multi-thread"], version = "1.33.0" }, tracing = { version = "0.1.40" }, tracing-subscriber = { features = ["env-filter", "json"], version = "0.3.17" }, uuid = { features = ["v4"], version = "1.6.0" }, validator = { version = "0.19" } } diff --git a/loco-new/tests/templates/snapshots/r#mod__templates__db__cargo_dependencies_Sqlite.snap b/loco-new/tests/templates/snapshots/r#mod__templates__db__cargo_dependencies_Sqlite.snap index 859cb0fdf..385489b5e 100644 --- a/loco-new/tests/templates/snapshots/r#mod__templates__db__cargo_dependencies_Sqlite.snap +++ b/loco-new/tests/templates/snapshots/r#mod__templates__db__cargo_dependencies_Sqlite.snap @@ -1,5 +1,5 @@ --- -source: loco-new/tests/templates/db.rs +source: tests/templates/db.rs expression: "content.get(\"dependencies\").unwrap()" --- -{ async-trait = "0.1.74", axum = "0.7.5", chrono = "0.4", serde_json = "1", tracing = "0.1.40", loco-rs = { workspace = true }, migration = { path = "migration" }, sea-orm = { features = ["sqlx-sqlite", "sqlx-postgres", "runtime-tokio-rustls", "macros"], version = "1.1.0" }, serde = { features = ["derive"], version = "1" }, tokio = { default-features = false, features = ["rt-multi-thread"], version = "1.33.0" }, tracing-subscriber = { features = ["env-filter", "json"], version = "0.3.17" }, uuid = { features = ["v4"], version = "1.6.0" }, validator = { version = "0.19" } } +{ async-trait = { version = "0.1.74" }, axum = { version = "0.7.5" }, chrono = { version = "0.4" }, loco-rs = { workspace = true }, migration = { path = "migration" }, regex = { version = "1.11.1" }, sea-orm = { features = ["sqlx-sqlite", "sqlx-postgres", "runtime-tokio-rustls", "macros"], version = "1.1.0" }, serde = { features = ["derive"], version = "1" }, serde_json = { version = "1" }, tokio = { default-features = false, features = ["rt-multi-thread"], version = "1.33.0" }, tracing = { version = "0.1.40" }, tracing-subscriber = { features = ["env-filter", "json"], version = "0.3.17" }, uuid = { features = ["v4"], version = "1.6.0" }, validator = { version = "0.19" } } diff --git a/loco-new/tests/templates/snapshots/r#mod__templates__db__src_app_rs_None.snap b/loco-new/tests/templates/snapshots/r#mod__templates__db__src_app_rs_None.snap index 2f3556aa9..b09ac84eb 100644 --- a/loco-new/tests/templates/snapshots/r#mod__templates__db__src_app_rs_None.snap +++ b/loco-new/tests/templates/snapshots/r#mod__templates__db__src_app_rs_None.snap @@ -16,7 +16,7 @@ use loco_rs::{ #[allow(unused_imports)] use crate::{ - controllers, + controllers ,tasks }; pub struct App; diff --git a/loco-new/tests/templates/snapshots/r#mod__templates__db__src_app_rs_Postgres.snap b/loco-new/tests/templates/snapshots/r#mod__templates__db__src_app_rs_Postgres.snap index 41925c044..c9393b9eb 100644 --- a/loco-new/tests/templates/snapshots/r#mod__templates__db__src_app_rs_Postgres.snap +++ b/loco-new/tests/templates/snapshots/r#mod__templates__db__src_app_rs_Postgres.snap @@ -10,7 +10,6 @@ use loco_rs::{ Queue}, boot::{create_app, BootResult, StartMode}, controller::AppRoutes, - db::{self, truncate_table}, environment::Environment, task::Tasks, Result, @@ -20,9 +19,7 @@ use sea_orm::DatabaseConnection; #[allow(unused_imports)] use crate::{ - controllers - ,tasks - , models::_entities::users, + controllers ,tasks }; pub struct App; @@ -63,13 +60,10 @@ impl Hooks for App { fn register_tasks(tasks: &mut Tasks) { // tasks-inject (do not remove) } - async fn truncate(db: &DatabaseConnection) -> Result<()> { - truncate_table(db, users::Entity).await?; + async fn truncate(_db: &DatabaseConnection) -> Result<()> { Ok(()) - } - - async fn seed(db: &DatabaseConnection, base: &Path) -> Result<()> { - db::seed::(db, &base.join("users.yaml").display().to_string()).await?; + } + async fn seed(_db: &DatabaseConnection, _base: &Path) -> Result<()> { Ok(()) } } diff --git a/loco-new/tests/templates/snapshots/r#mod__templates__db__src_app_rs_Sqlite.snap b/loco-new/tests/templates/snapshots/r#mod__templates__db__src_app_rs_Sqlite.snap index 41925c044..c9393b9eb 100644 --- a/loco-new/tests/templates/snapshots/r#mod__templates__db__src_app_rs_Sqlite.snap +++ b/loco-new/tests/templates/snapshots/r#mod__templates__db__src_app_rs_Sqlite.snap @@ -10,7 +10,6 @@ use loco_rs::{ Queue}, boot::{create_app, BootResult, StartMode}, controller::AppRoutes, - db::{self, truncate_table}, environment::Environment, task::Tasks, Result, @@ -20,9 +19,7 @@ use sea_orm::DatabaseConnection; #[allow(unused_imports)] use crate::{ - controllers - ,tasks - , models::_entities::users, + controllers ,tasks }; pub struct App; @@ -63,13 +60,10 @@ impl Hooks for App { fn register_tasks(tasks: &mut Tasks) { // tasks-inject (do not remove) } - async fn truncate(db: &DatabaseConnection) -> Result<()> { - truncate_table(db, users::Entity).await?; + async fn truncate(_db: &DatabaseConnection) -> Result<()> { Ok(()) - } - - async fn seed(db: &DatabaseConnection, base: &Path) -> Result<()> { - db::seed::(db, &base.join("users.yaml").display().to_string()).await?; + } + async fn seed(_db: &DatabaseConnection, _base: &Path) -> Result<()> { Ok(()) } } diff --git a/loco-new/tests/templates/snapshots/r#mod__templates__initializers__src_app_rs_with_initializers.snap b/loco-new/tests/templates/snapshots/r#mod__templates__initializers__src_app_rs_with_initializers.snap index 6dcd176ac..3199b528d 100644 --- a/loco-new/tests/templates/snapshots/r#mod__templates__initializers__src_app_rs_with_initializers.snap +++ b/loco-new/tests/templates/snapshots/r#mod__templates__initializers__src_app_rs_with_initializers.snap @@ -16,7 +16,7 @@ use loco_rs::{ #[allow(unused_imports)] use crate::{ - controllers, + controllers ,tasks }; pub struct App; diff --git a/loco-new/tests/templates/snapshots/r#mod__templates__initializers__src_app_rs_without_initializers.snap b/loco-new/tests/templates/snapshots/r#mod__templates__initializers__src_app_rs_without_initializers.snap index 7bbc9d0e1..4691632a0 100644 --- a/loco-new/tests/templates/snapshots/r#mod__templates__initializers__src_app_rs_without_initializers.snap +++ b/loco-new/tests/templates/snapshots/r#mod__templates__initializers__src_app_rs_without_initializers.snap @@ -16,7 +16,7 @@ use loco_rs::{ #[allow(unused_imports)] use crate::{ - controllers, initializers, + controllers ,tasks, initializers }; pub struct App; diff --git a/loco-new/tests/templates/snapshots/r#mod__templates__mailer__cargo_dependencies_mailer_false.snap b/loco-new/tests/templates/snapshots/r#mod__templates__mailer__cargo_dependencies_mailer_false.snap index 650bcff34..5a2cce91d 100644 --- a/loco-new/tests/templates/snapshots/r#mod__templates__mailer__cargo_dependencies_mailer_false.snap +++ b/loco-new/tests/templates/snapshots/r#mod__templates__mailer__cargo_dependencies_mailer_false.snap @@ -1,5 +1,5 @@ --- -source: loco-new/tests/templates/mailer.rs +source: tests/templates/mailer.rs expression: "content.get(\"dependencies\").unwrap()" --- -{ async-trait = "0.1.74", axum = "0.7.5", serde_json = "1", tracing = "0.1.40", loco-rs = { workspace = true }, serde = { features = ["derive"], version = "1" }, tokio = { default-features = false, features = ["rt-multi-thread"], version = "1.33.0" }, tracing-subscriber = { features = ["env-filter", "json"], version = "0.3.17" } } +{ async-trait = { version = "0.1.74" }, axum = { version = "0.7.5" }, loco-rs = { workspace = true }, regex = { version = "1.11.1" }, serde = { features = ["derive"], version = "1" }, serde_json = { version = "1" }, tokio = { default-features = false, features = ["rt-multi-thread"], version = "1.33.0" }, tracing = { version = "0.1.40" }, tracing-subscriber = { features = ["env-filter", "json"], version = "0.3.17" } } diff --git a/loco-new/tests/templates/snapshots/r#mod__templates__mailer__cargo_dependencies_mailer_true.snap b/loco-new/tests/templates/snapshots/r#mod__templates__mailer__cargo_dependencies_mailer_true.snap index 5e1a0f8d3..7d0bba4b7 100644 --- a/loco-new/tests/templates/snapshots/r#mod__templates__mailer__cargo_dependencies_mailer_true.snap +++ b/loco-new/tests/templates/snapshots/r#mod__templates__mailer__cargo_dependencies_mailer_true.snap @@ -1,5 +1,5 @@ --- -source: loco-new/tests/templates/mailer.rs +source: tests/templates/mailer.rs expression: "content.get(\"dependencies\").unwrap()" --- -{ async-trait = "0.1.74", axum = "0.7.5", include_dir = "0.7", serde_json = "1", tracing = "0.1.40", loco-rs = { workspace = true }, serde = { features = ["derive"], version = "1" }, tokio = { default-features = false, features = ["rt-multi-thread"], version = "1.33.0" }, tracing-subscriber = { features = ["env-filter", "json"], version = "0.3.17" } } +{ async-trait = { version = "0.1.74" }, axum = { version = "0.7.5" }, include_dir = { version = "0.7" }, loco-rs = { workspace = true }, regex = { version = "1.11.1" }, serde = { features = ["derive"], version = "1" }, serde_json = { version = "1" }, tokio = { default-features = false, features = ["rt-multi-thread"], version = "1.33.0" }, tracing = { version = "0.1.40" }, tracing-subscriber = { features = ["env-filter", "json"], version = "0.3.17" } } diff --git a/loco-new/tests/wizard/new.rs b/loco-new/tests/wizard/new.rs index 26f60a902..f23cd80e7 100644 --- a/loco-new/tests/wizard/new.rs +++ b/loco-new/tests/wizard/new.rs @@ -78,6 +78,13 @@ fn test_starter_combinations() { AssetsOption::Clientside, true, ); + // test only DB + test_combination( + DBOption::Sqlite, + BackgroundOption::None, + AssetsOption::None, + true, + ); } fn test_combination( diff --git a/src/bgworker/mod.rs b/src/bgworker/mod.rs index be6b43b92..1fe9ba0a9 100644 --- a/src/bgworker/mod.rs +++ b/src/bgworker/mod.rs @@ -313,13 +313,13 @@ impl Queue { age_days: Option, ) -> Result { tracing::debug!(status = ?status, age_days = ?age_days, "getting jobs"); - let jobs = match self { + match self { #[cfg(feature = "bg_pg")] Self::Postgres(pool, _, _) => { let jobs = pg::get_jobs(pool, status, age_days) .await .map_err(Box::from)?; - serde_json::to_value(jobs)? + Ok(serde_json::to_value(jobs)?) } #[cfg(feature = "bg_sqlt")] Self::Sqlite(pool, _, _) => { @@ -327,25 +327,23 @@ impl Queue { .await .map_err(Box::from)?; - serde_json::to_value(jobs)? + Ok(serde_json::to_value(jobs)?) } #[cfg(feature = "bg_redis")] Self::Redis(_, _, _) => { tracing::error!("getting jobs for redis provider not implemented"); - return Err(Error::string( + Err(Error::string( "getting jobs not supported for redis provider", - )); + )) } Self::None => { tracing::error!( "no queue provider is configured: compile with at least one queue provider \ feature" ); - return Err(Error::string("provider not configure")); + Err(Error::string("provider not configure")) } - }; - - Ok(jobs) + } } /// Cancels jobs based on the given job name for the configured queue provider. diff --git a/src/cli.rs b/src/cli.rs index a179bc7c1..8f88f8455 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -20,12 +20,21 @@ cfg_if::cfg_if! { use crate::doctor; use crate::boot::{run_db}; use crate::db; - use std::process::exit; } else {} } +#[cfg(any( + feature = "bg_redis", + feature = "bg_pg", + feature = "bg_sqlt", + feature = "with-db" +))] +use std::process::exit; + use std::{collections::BTreeMap, path::PathBuf}; +#[cfg(any(feature = "bg_redis", feature = "bg_pg", feature = "bg_sqlt"))] +use crate::bgworker::JobStatus; use clap::{ArgAction, Parser, Subcommand}; use colored::Colorize; use duct::cmd; @@ -33,7 +42,6 @@ use loco_gen::{Component, ScaffoldKind}; use crate::{ app::{AppContext, Hooks}, - bgworker::JobStatus, boot::{ create_app, create_context, list_endpoints, list_middlewares, run_scheduler, run_task, start, RunDbCommand, ServeParams, StartMode, diff --git a/src/env_vars.rs b/src/env_vars.rs index 83a6e8587..aaf111cee 100644 --- a/src/env_vars.rs +++ b/src/env_vars.rs @@ -3,6 +3,7 @@ //! fetching environment variables, ensuring that keys are easily accessible //! from a single location in the codebase. +#[cfg(feature = "with-db")] /// The key for `PostgreSQL` database options environment variable. pub const POSTGRES_DB_OPTIONS: &str = "LOCO_POSTGRES_DB_OPTIONS"; /// The key for the application's environment (e.g., development, production). @@ -19,6 +20,7 @@ pub fn get(key: &str) -> Result { std::env::var(key) } +#[allow(dead_code)] /// Retrieves the value of the given environment variable, or returns a default value if the variable is not set. pub fn get_or_default(key: &str, default: &str) -> String { get(key).unwrap_or_else(|_| default.to_string()) diff --git a/src/hash.rs b/src/hash.rs index d8a96c2b3..84236112c 100644 --- a/src/hash.rs +++ b/src/hash.rs @@ -1,9 +1,9 @@ +use crate::{Error, Result}; use argon2::{ password_hash::SaltString, Argon2, Params, PasswordHash, PasswordHasher, PasswordVerifier, Version, }; - -use crate::{Error, Result}; +use rand::{distributions::Alphanumeric, thread_rng, Rng}; /// Hashes a plain text password and returns the hashed result. /// @@ -57,6 +57,26 @@ pub fn verify_password(pass: &str, hashed_password: &str) -> bool { arg2.verify_password(pass.as_bytes(), &hash).is_ok() } +/// Generates a random alphanumeric string of the specified length. +/// +/// # Example +/// +/// ```rust +/// use loco_rs::hash; +/// +/// let rand_str = hash::random_string(10); +/// assert_eq!(rand_str.len(), 10); +/// assert_ne!(rand_str, hash::random_string(10)); +/// +/// ``` +pub fn random_string(length: usize) -> String { + thread_rng() + .sample_iter(&Alphanumeric) + .take(length) + .map(char::from) + .collect() +} + #[cfg(test)] mod tests { @@ -70,4 +90,14 @@ mod tests { assert!(verify_password(pass, &hash_pass)); } + + #[test] + fn can_random_string() { + let random_length = 32; + let first = random_string(random_length); + assert_eq!(first.len(), random_length); + let second: String = random_string(random_length); + assert_eq!(second.len(), random_length); + assert_ne!(first, second); + } } diff --git a/src/prelude.rs b/src/prelude.rs index 03ae9cf8c..47353fd17 100644 --- a/src/prelude.rs +++ b/src/prelude.rs @@ -27,7 +27,7 @@ pub use crate::{ app::{AppContext, Initializer}, bgworker::{BackgroundWorker, Queue}, controller::{ - format, + bad_request, format, middleware::{ format::{Format, RespondTo}, remote_ip::RemoteIP, From 481e1d0b2b345309a23ddbf1dcbde6973cef8826 Mon Sep 17 00:00:00 2001 From: "Dotan J. Nahum" Date: Tue, 17 Dec 2024 14:35:15 +0200 Subject: [PATCH 03/11] add: infer migration (#1086) * add: infer migration --- CHANGELOG.md | 1 + docs-site/content/docs/the-app/models.md | 103 +++-- examples/demo/Cargo.lock | 283 ++++++------ loco-gen/Cargo.toml | 1 + loco-gen/src/infer.rs | 101 +++++ loco-gen/src/lib.rs | 27 +- loco-gen/src/migration.rs | 75 ++++ loco-gen/src/model.rs | 88 ++-- loco-gen/src/scaffold.rs | 2 +- .../src/templates/migration/add_columns.t | 59 +++ .../src/templates/migration/add_references.t | 83 ++++ .../migration/{migration.t => empty.t} | 0 loco-gen/src/templates/migration/join_table.t | 81 ++++ .../src/templates/migration/remove_columns.t | 59 +++ src/cli.rs | 24 +- src/schema.rs | 402 +----------------- 16 files changed, 733 insertions(+), 656 deletions(-) create mode 100644 loco-gen/src/infer.rs create mode 100644 loco-gen/src/migration.rs create mode 100644 loco-gen/src/templates/migration/add_columns.t create mode 100644 loco-gen/src/templates/migration/add_references.t rename loco-gen/src/templates/migration/{migration.t => empty.t} (100%) create mode 100644 loco-gen/src/templates/migration/join_table.t create mode 100644 loco-gen/src/templates/migration/remove_columns.t diff --git a/CHANGELOG.md b/CHANGELOG.md index c128ab926..d08a89ef3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +* feat: smart migration generator. you can now generate migration based on naming them for creating a table, adding columns, references, join tables and more. [https://github.com/loco-rs/loco/pull/1086](https://github.com/loco-rs/loco/pull/1086) * feat: `cargo loco routes` will now pretty-print routes * fix: guard jwt error behind feature flag. [https://github.com/loco-rs/loco/pull/1032](https://github.com/loco-rs/loco/pull/1032) * fix: logger file_appender not using the seperated format setting. [https://github.com/loco-rs/loco/pull/1036](https://github.com/loco-rs/loco/pull/1036) diff --git a/docs-site/content/docs/the-app/models.md b/docs-site/content/docs/the-app/models.md index a8f66df10..e0d139154 100644 --- a/docs-site/content/docs/the-app/models.md +++ b/docs-site/content/docs/the-app/models.md @@ -111,9 +111,9 @@ impl super::_entities::users::ActiveModel { # Crafting models -## Migrations +## The model generator -To add a new model _you have to use a migration_. +To add a new model the model generator creates a migration, runs it, and then triggers an entities sync from your database schema which will hydrate and create your model entities. ``` $ cargo loco generate model posts title:string! content:text user:references @@ -181,20 +181,24 @@ You can generate an empty model: $ cargo loco generate model posts ``` -You can generate an empty model **migration only** which means migrations will not run automatically: + +Or a data model, without any references: ``` -$ cargo loco generate model --migration-only posts +$ cargo loco generate model posts title:string! content:text ``` -Or a data model, without any references: +## Migrations + +Other than using the model generator, you drive your schema by *creating migrations*. ``` -$ cargo loco generate model posts title:string! content:text +$ cargo loco generate migration [name:type, name:type ...] ``` This creates a migration in the root of your project in `migration/`. -You can now apply it: + +You can apply it: ``` $ cargo loco db migrate @@ -210,6 +214,63 @@ Loco is a migration-first framework, similar to Rails. Which means that when you This enforces _everything-as-code_, _reproducibility_ and _atomicity_, where no knowledge of the schema goes missing. +**Naming the migration is important**, the type of migration that is being generated is inferred from the migration name. + +### Create a new table + +* Name template: `Create___` +* Example: `CreatePosts` + +``` +$ cargo loco g migration CreatePosts title:string content:string +``` + +### Add columns + +* Name template: `Add___To___` +* Example: `AddNameAndAgeToUsers` (the string `NameAndAge` does not matter, you specify columns individually, however `Users` does matter because this will be the name of the table) + +``` +$ cargo loco g migration AddNameAndAgeToUsers name:string age:int +``` + +### Remove columns + +* Name template: `Remove___From___` +* Example: `RemoveNameAndAgeFromUsers` (same note exists as in _add columns_) + +``` +$ cargo logo g migration RemoveNameAndAgeFromUsers name:string age:int +``` + +### Add references + +* Name template: `Add___RefTo___` +* Example: `AddUserRefToPosts` (`User` does not matter, as you specify one or many references individually, `Posts` does matter as it will be the table name in the migration) + +``` +$ cargo loco g migration AddUserRefToPosts user:references +``` + +### Create a join table + +* Name template: `CreateJoinTable___And___` (supported between 2 tables) +* Example: `CreateJoinTableUsersAndGroups` + +``` +$ cargo loco g migration CreateJoinTableUsersAndGroups count:int +``` + +You can also add some state columns regarding the relationship (such as `count` here). + +### Create an empty migration + +Use any descriptive name for a migration that does not fall into one of the above patterns to create an empty migration. + +``` +$ cargo loco g migration FixUsersTable +``` + ### Down Migrations If you realize that you made a mistake, you can always undo the migration. This will undo the changes made by the migration (assuming that you added the appropriate code for `down` in the migration). @@ -247,27 +308,11 @@ $ cargo loco generate model movies long_title:string added_by:references:users d * reference added_by is in singular, the referenced model is a model and is plural: `added_by:references:users` * column name in snake case: `long_title:string` -### Naming migrations - -There are no rules for how to name migrations, but here's a few guidelines to keep your migration stack readable as a list of files: - -* `` - create a table, plural, `movies` -* `add_
_` - add a column, `add_users_email` -* `index_
_` - add an index, `index_users_email` -* `alter_` - change a schema, `alter_users` -* `delete_
_` - remove a column, `delete_users_email` -* `data_fix_` - fix some data, using entity queries or raw SQL, `data_fix_users_timezone_issue_315` -Example: - -```sh -$ cargo loco generate migration add_users_email -``` +### Migration Definition -### Add or remove a column - -Adding a column: +**Add a column** ```rust manager @@ -280,7 +325,7 @@ Adding a column: .await ``` -Dropping a column: +**Drop a column** ```rust manager @@ -293,8 +338,7 @@ Dropping a column: .await ``` -### Add index - +**Add index** You can copy some of this code for adding an index @@ -310,8 +354,7 @@ You can copy some of this code for adding an index .await; ``` -### Create a data fix - +**Create a data fix** Creating a data fix in a migration is easy - just use SQL statements as you like: diff --git a/examples/demo/Cargo.lock b/examples/demo/Cargo.lock index 1c18600ef..6f4f3515c 100644 --- a/examples/demo/Cargo.lock +++ b/examples/demo/Cargo.lock @@ -559,7 +559,7 @@ dependencies = [ "rand", "serde", "serde_json", - "thiserror", + "thiserror 1.0.69", "tokio", "tower-layer", "tower-service", @@ -591,7 +591,7 @@ dependencies = [ "btparse-stable", "colored", "regex", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -1141,6 +1141,16 @@ dependencies = [ "regex", ] +[[package]] +name = "cruet" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6132609543972496bc97b1e01f1ce6586768870aeb4cabeb3385f4e05b5caead" +dependencies = [ + "once_cell", + "regex", +] + [[package]] name = "crypto-common" version = "0.1.6" @@ -1312,7 +1322,7 @@ dependencies = [ "console", "shell-words", "tempfile", - "thiserror", + "thiserror 1.0.69", "zeroize", ] @@ -1578,7 +1588,7 @@ dependencies = [ "fluent-syntax", "intl-memoizer", "intl_pluralrules", - "rustc-hash", + "rustc-hash 1.1.0", "self_cell 0.10.3", "smallvec", "unic-langid", @@ -1599,7 +1609,7 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a530c4694a6a8d528794ee9bbd8ba0122e779629ac908d15ad5a7ae7763a33d" dependencies = [ - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -1668,21 +1678,6 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - [[package]] name = "form_urlencoded" version = "1.2.1" @@ -2204,22 +2199,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", -] - -[[package]] -name = "hyper-tls" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" -dependencies = [ - "bytes", - "http-body-util", - "hyper", - "hyper-util", - "native-tls", - "tokio", - "tokio-native-tls", - "tower-service", + "webpki-roots", ] [[package]] @@ -2716,13 +2696,14 @@ version = "0.13.2" dependencies = [ "chrono", "clap", + "cruet 0.14.0", "dialoguer", "duct", "regex", "rrgen", "serde", "serde_json", - "thiserror", + "thiserror 1.0.69", "tracing", ] @@ -2772,7 +2753,7 @@ dependencies = [ "serde_yaml", "sqlx", "tera", - "thiserror", + "thiserror 1.0.69", "thousands", "tokio", "tokio-cron-scheduler", @@ -2915,7 +2896,7 @@ dependencies = [ "rustc_version", "smallvec", "tagptr", - "thiserror", + "thiserror 1.0.69", "triomphe", "uuid", ] @@ -2937,23 +2918,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "native-tls" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8614eb2c83d59d1c8cc974dd3f920198647674a0a035e1af1fa58707e317466" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", -] - [[package]] name = "new_debug_unreachable" version = "1.0.6" @@ -3112,50 +3076,6 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" -[[package]] -name = "openssl" -version = "0.10.68" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6174bc48f102d208783c2c84bf931bb75927a617866870de8a4ea85597f871f5" -dependencies = [ - "bitflags", - "cfg-if", - "foreign-types", - "libc", - "once_cell", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.87", -] - -[[package]] -name = "openssl-probe" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" - -[[package]] -name = "openssl-sys" -version = "0.9.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45abf306cbf99debc8195b66b7346498d7b10c210de50418b5ccd7ceba08c741" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - [[package]] name = "ordered-float" version = "3.9.2" @@ -3293,7 +3213,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879952a81a83930934cbf1786752d6dedc3b1f29e8f8fb2ad1d0a36f377cf442" dependencies = [ "memchr", - "thiserror", + "thiserror 1.0.69", "ucd-trie", ] @@ -3637,6 +3557,58 @@ dependencies = [ "winapi", ] +[[package]] +name = "quinn" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62e96808277ec6f97351a2380e6c25114bc9e67037775464979f3037c92d05ef" +dependencies = [ + "bytes", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.0", + "rustls", + "socket2", + "thiserror 2.0.4", + "tokio", + "tracing", +] + +[[package]] +name = "quinn-proto" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2fe5ef3495d7d2e377ff17b1a8ce2ee2ec2a18cde8b6ad6619d65d0701c135d" +dependencies = [ + "bytes", + "getrandom", + "rand", + "ring", + "rustc-hash 2.1.0", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.4", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5a626c6807713b15cac82a6acaccd6043c9a5408c24baae07611fec3f243da" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.59.0", +] + [[package]] name = "quote" version = "1.0.37" @@ -3754,7 +3726,7 @@ checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" dependencies = [ "getrandom", "libredox", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -3833,29 +3805,31 @@ dependencies = [ "http-body-util", "hyper", "hyper-rustls", - "hyper-tls", "hyper-util", "ipnet", "js-sys", "log", "mime", - "native-tls", "once_cell", "percent-encoding", "pin-project-lite", + "quinn", + "rustls", "rustls-pemfile", + "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", "sync_wrapper 1.0.1", "system-configuration", "tokio", - "tokio-native-tls", + "tokio-rustls", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", + "webpki-roots", "windows-registry", ] @@ -3866,7 +3840,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9838134a2bfaa8e1f40738fcc972ac799de6e0e06b5157acb95fc2b05a0ea283" dependencies = [ "lazy_static", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -3919,7 +3893,7 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e40013551787f9f535e7dbc8dafc164591d941aeae48881a385d8b0393dd45f6" dependencies = [ - "cruet", + "cruet 0.13.3", "fs-err", "glob", "heck 0.4.1", @@ -3929,7 +3903,7 @@ dependencies = [ "serde_regex", "serde_yaml", "tera", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -3995,7 +3969,7 @@ dependencies = [ "mime", "mime_guess", "rand", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -4026,6 +4000,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" +[[package]] +name = "rustc-hash" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7fb8039b3032c191086b10f11f319a6e99e1e82889c5cc6046f515c9db1d497" + [[package]] name = "rustc_version" version = "0.4.1" @@ -4077,6 +4057,9 @@ name = "rustls-pki-types" version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16f1201b3c9a7ee8039bcadc17b7e605e2945b27eee7631788c1bd2b0643674b" +dependencies = [ + "web-time", +] [[package]] name = "rustls-webpki" @@ -4116,7 +4099,7 @@ dependencies = [ "serial_test", "sha2", "slog-term", - "thiserror", + "thiserror 1.0.69", "tokio", "tokio-util", "tracing", @@ -4147,15 +4130,6 @@ dependencies = [ "sdd", ] -[[package]] -name = "schannel" -version = "0.1.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" -dependencies = [ - "windows-sys 0.59.0", -] - [[package]] name = "scopeguard" version = "1.2.0" @@ -4218,7 +4192,7 @@ dependencies = [ "serde_json", "sqlx", "strum", - "thiserror", + "thiserror 1.0.69", "time", "tracing", "url", @@ -4317,7 +4291,7 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.87", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -4349,29 +4323,6 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" -[[package]] -name = "security-framework" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" -dependencies = [ - "bitflags", - "core-foundation", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa39c7303dc58b5543c94d22c1766b0d31f2ee58306363ea622b10bbc075eaa2" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "selectors" version = "0.26.0" @@ -4639,7 +4590,7 @@ checksum = "adc4e5204eb1910f40f9cfa375f6f05b68c3abac4b6fd879c8ff5e7ae8a0a085" dependencies = [ "num-bigint", "num-traits", - "thiserror", + "thiserror 1.0.69", "time", ] @@ -4859,7 +4810,7 @@ dependencies = [ "sha2", "smallvec", "sqlformat", - "thiserror", + "thiserror 1.0.69", "time", "tokio", "tokio-stream", @@ -4948,7 +4899,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 1.0.69", "time", "tracing", "uuid", @@ -4992,7 +4943,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 1.0.69", "time", "tracing", "uuid", @@ -5249,7 +5200,16 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f49a1853cf82743e3b7950f77e0f4d622ca36cf4317cba00c767838bac8d490" +dependencies = [ + "thiserror-impl 2.0.4", ] [[package]] @@ -5263,6 +5223,17 @@ dependencies = [ "syn 2.0.87", ] +[[package]] +name = "thiserror-impl" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8381894bb3efe0c4acac3ded651301ceee58a15d47c2e34885ed1908ad667061" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.87", +] + [[package]] name = "thousands" version = "0.2.0" @@ -5379,16 +5350,6 @@ dependencies = [ "syn 2.0.87", ] -[[package]] -name = "tokio-native-tls" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" -dependencies = [ - "native-tls", - "tokio", -] - [[package]] name = "tokio-rustls" version = "0.26.0" @@ -5543,7 +5504,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3566e8ce28cc0a3fe42519fc80e6b4c943cc4c8cef275620eb8dac2d3d4e06cf" dependencies = [ "crossbeam-channel", - "thiserror", + "thiserror 1.0.69", "time", "tracing-subscriber", ] @@ -5645,7 +5606,7 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "deb68604048ff8fa93347f02441e4487594adc20bb8a084f9e564d2b827a0a9f" dependencies = [ - "rustc-hash", + "rustc-hash 1.1.0", ] [[package]] diff --git a/loco-gen/Cargo.toml b/loco-gen/Cargo.toml index d59fe5681..56b7583e7 100644 --- a/loco-gen/Cargo.toml +++ b/loco-gen/Cargo.toml @@ -14,6 +14,7 @@ path = "src/lib.rs" [dependencies] +cruet = "0.14.0" rrgen = "0.5.3" serde = { workspace = true } serde_json = { workspace = true } diff --git a/loco-gen/src/infer.rs b/loco-gen/src/infer.rs new file mode 100644 index 000000000..128e926f3 --- /dev/null +++ b/loco-gen/src/infer.rs @@ -0,0 +1,101 @@ +use cruet::{case::snake::to_snake_case, Inflector}; // For pluralization and singularization + +#[derive(Debug, PartialEq, Eq)] +pub enum MigrationType { + CreateTable { table: String }, + AddColumns { table: String }, + RemoveColumns { table: String }, + AddReference { table: String }, + CreateJoinTable { table_a: String, table_b: String }, + Empty, +} + +pub fn guess_migration_type(migration_name: &str) -> MigrationType { + let normalized_name = to_snake_case(migration_name); + let parts: Vec<&str> = normalized_name.split('_').collect(); + + match parts.as_slice() { + ["create", table_name] => MigrationType::CreateTable { + table: table_name.to_plural(), + }, + ["add", _reference_name, "ref", "to", table_name] => MigrationType::AddReference { + table: table_name.to_plural(), + }, + ["add", _column_names @ .., "to", table_name] => MigrationType::AddColumns { + table: table_name.to_plural(), + }, + ["remove", _column_names @ .., "from", table_name] => MigrationType::RemoveColumns { + table: table_name.to_plural(), + }, + ["create", "join", "table", table_a, "and", table_b] => { + let table_a = table_a.to_singular(); + let table_b = table_b.to_singular(); + MigrationType::CreateJoinTable { table_a, table_b } + } + _ => MigrationType::Empty, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_infer_create_table() { + assert_eq!( + guess_migration_type("CreateUsers"), + MigrationType::CreateTable { + table: "users".to_string(), + } + ); + } + + #[test] + fn test_infer_add_columns() { + assert_eq!( + guess_migration_type("AddNameAndAgeToUsers"), + MigrationType::AddColumns { + table: "users".to_string(), + } + ); + } + + #[test] + fn test_infer_remove_columns() { + assert_eq!( + guess_migration_type("RemoveNameAndAgeFromUsers"), + MigrationType::RemoveColumns { + table: "users".to_string(), + } + ); + } + + #[test] + fn test_infer_add_reference() { + assert_eq!( + guess_migration_type("AddUserRefToPosts"), + MigrationType::AddReference { + table: "posts".to_string(), + } + ); + } + + #[test] + fn test_infer_create_join_table() { + assert_eq!( + guess_migration_type("CreateJoinTableUsersAndGroups"), + MigrationType::CreateJoinTable { + table_a: "user".to_string(), + table_b: "group".to_string() + } + ); + } + + #[test] + fn test_empty_migration() { + assert_eq!( + guess_migration_type("UnknownMigrationType"), + MigrationType::Empty + ); + } +} diff --git a/loco-gen/src/lib.rs b/loco-gen/src/lib.rs index 90ed56a93..0a1f20cf3 100644 --- a/loco-gen/src/lib.rs +++ b/loco-gen/src/lib.rs @@ -8,6 +8,10 @@ use serde_json::json; mod controller; #[cfg(feature = "with-db")] +mod infer; +#[cfg(feature = "with-db")] +mod migration; +#[cfg(feature = "with-db")] mod model; #[cfg(feature = "with-db")] mod scaffold; @@ -20,8 +24,6 @@ const MAILER_SUB_T: &str = include_str!("templates/mailer/subject.t"); const MAILER_TEXT_T: &str = include_str!("templates/mailer/text.t"); const MAILER_HTML_T: &str = include_str!("templates/mailer/html.t"); -const MIGRATION_T: &str = include_str!("templates/migration/migration.t"); - const TASK_T: &str = include_str!("templates/task/task.t"); const TASK_TEST_T: &str = include_str!("templates/task/test.t"); @@ -151,14 +153,14 @@ pub enum Component { /// Model fields, eg. title:string hits:int fields: Vec<(String, String)>, - - /// Generate migration code and stop, don't run the migration - migration_only: bool, }, #[cfg(feature = "with-db")] Migration { /// Name of the migration file name: String, + + /// Params fields, eg. title:string hits:int + fields: Vec<(String, String)>, }, #[cfg(feature = "with-db")] Scaffold { @@ -222,15 +224,10 @@ pub fn generate(component: Component, appinfo: &AppInfo) -> Result<()> { */ match component { #[cfg(feature = "with-db")] - Component::Model { - name, - link, - fields, - migration_only, - } => { + Component::Model { name, link, fields } => { println!( "{}", - model::generate(&rrgen, &name, link, migration_only, &fields, appinfo)? + model::generate(&rrgen, &name, link, &fields, appinfo)? ); } #[cfg(feature = "with-db")] @@ -241,10 +238,8 @@ pub fn generate(component: Component, appinfo: &AppInfo) -> Result<()> { ); } #[cfg(feature = "with-db")] - Component::Migration { name } => { - let vars = - json!({ "name": name, "ts": chrono::Utc::now(), "pkg_name": appinfo.app_name}); - rrgen.generate(MIGRATION_T, &vars)?; + Component::Migration { name, fields } => { + migration::generate(&rrgen, &name, &fields, appinfo)?; } Component::Controller { name, diff --git a/loco-gen/src/migration.rs b/loco-gen/src/migration.rs new file mode 100644 index 000000000..f53918c71 --- /dev/null +++ b/loco-gen/src/migration.rs @@ -0,0 +1,75 @@ +use chrono::Utc; +use rrgen::RRgen; +use serde_json::json; + +use super::Result; +use crate::{ + infer, + model::{get_columns_and_references, MODEL_T}, +}; + +const MIGRATION_T: &str = include_str!("templates/migration/empty.t"); +const ADD_COLS_T: &str = include_str!("templates/migration/add_columns.t"); +const ADD_REFS_T: &str = include_str!("templates/migration/add_references.t"); +const REMOVE_COLS_T: &str = include_str!("templates/migration/remove_columns.t"); +const JOIN_TABLE_T: &str = include_str!("templates/migration/join_table.t"); + +use super::{collect_messages, AppInfo}; + +/// skipping some fields from the generated models. +/// For example, the `created_at` and `updated_at` fields are automatically +/// generated by the Loco app and should be given +pub const IGNORE_FIELDS: &[&str] = &["created_at", "updated_at", "create_at", "update_at"]; + +pub fn generate( + rrgen: &RRgen, + name: &str, + fields: &[(String, String)], + appinfo: &AppInfo, +) -> Result { + let pkg_name: &str = &appinfo.app_name; + let ts = Utc::now(); + + let res = infer::guess_migration_type(name); + let migration_gen = match res { + // NOTE: re-uses the 'new model' migration template! + infer::MigrationType::CreateTable { table } => { + let (columns, references) = get_columns_and_references(fields)?; + let vars = json!({"name": table, "ts": ts, "pkg_name": pkg_name, "is_link": false, "columns": columns, "references": references}); + rrgen.generate(MODEL_T, &vars)? + } + infer::MigrationType::AddColumns { table } => { + let (columns, references) = get_columns_and_references(fields)?; + let vars = json!({"name": name, "table": table, "ts": ts, "pkg_name": pkg_name, "is_link": false, "columns": columns, "references": references}); + rrgen.generate(ADD_COLS_T, &vars)? + } + infer::MigrationType::RemoveColumns { table } => { + let (columns, _references) = get_columns_and_references(fields)?; + let vars = json!({"name": name, "table": table, "ts": ts, "pkg_name": pkg_name, "columns": columns}); + rrgen.generate(REMOVE_COLS_T, &vars)? + } + infer::MigrationType::AddReference { table } => { + let (columns, references) = get_columns_and_references(fields)?; + let vars = json!({"name": name, "table": table, "ts": ts, "pkg_name": pkg_name, "columns": columns, "references": references}); + rrgen.generate(ADD_REFS_T, &vars)? + } + infer::MigrationType::CreateJoinTable { table_a, table_b } => { + let mut tables = [table_a.clone(), table_b.clone()]; + tables.sort(); + let table = tables.join("_"); + let (columns, references) = get_columns_and_references(&[ + (table_a, "references".to_string()), + (table_b, "references".to_string()), + ])?; + let vars = json!({"name": name, "table": table, "ts": ts, "pkg_name": pkg_name, "columns": columns, "references": references}); + rrgen.generate(JOIN_TABLE_T, &vars)? + } + infer::MigrationType::Empty => { + let vars = json!({"name": name, "ts": ts, "pkg_name": pkg_name}); + rrgen.generate(MIGRATION_T, &vars)? + } + }; + + let messages = collect_messages(vec![migration_gen]); + Ok(messages) +} diff --git a/loco-gen/src/model.rs b/loco-gen/src/model.rs index 928f280be..238feef03 100644 --- a/loco-gen/src/model.rs +++ b/loco-gen/src/model.rs @@ -8,7 +8,7 @@ use serde_json::json; use super::{Error, Result}; use crate::get_mappings; -const MODEL_T: &str = include_str!("templates/model/model.t"); +pub const MODEL_T: &str = include_str!("templates/model/model.t"); const MODEL_TEST_T: &str = include_str!("templates/model/test.t"); use super::{collect_messages, AppInfo}; @@ -18,17 +18,14 @@ use super::{collect_messages, AppInfo}; /// generated by the Loco app and should be given pub const IGNORE_FIELDS: &[&str] = &["created_at", "updated_at", "create_at", "update_at"]; -pub fn generate( - rrgen: &RRgen, - name: &str, - is_link: bool, - migration_only: bool, +/// columns are , : ("content", "string") +/// references are : ("user", `user_id`) +/// parsed from e.g.: model article content:string user:references +/// puts a `user_id` in articles, then fk to users +#[allow(clippy::type_complexity)] +pub fn get_columns_and_references( fields: &[(String, String)], - appinfo: &AppInfo, -) -> Result { - let pkg_name: &str = &appinfo.app_name; - let ts = Utc::now(); - +) -> Result<(Vec<(String, String)>, Vec<(String, String)>)> { let mut columns = Vec::new(); let mut references = Vec::new(); for (fname, ftype) in fields { @@ -41,12 +38,12 @@ pub fn generate( } if ftype == "references" { let fkey = format!("{fname}_id"); - columns.push((fkey.clone(), "integer")); + columns.push((fkey.clone(), "integer".to_string())); // user, user_id references.push((fname.to_string(), fkey)); } else if let Some(refname) = ftype.strip_prefix("references:") { let fkey = format!("{fname}_id"); - columns.push((fkey.clone(), "integer")); + columns.push((fkey.clone(), "integer".to_string())); references.push((refname.to_string(), fkey)); } else { let mappings = get_mappings(); @@ -57,39 +54,51 @@ pub fn generate( mappings.schema_fields() )) })?; - columns.push((fname.to_string(), schema_type.as_str())); + columns.push((fname.to_string(), schema_type.to_string())); } } + Ok((columns, references)) +} +pub fn generate( + rrgen: &RRgen, + name: &str, + is_link: bool, + fields: &[(String, String)], + appinfo: &AppInfo, +) -> Result { + let pkg_name: &str = &appinfo.app_name; + let ts = Utc::now(); + + let (columns, references) = get_columns_and_references(fields)?; let vars = json!({"name": name, "ts": ts, "pkg_name": pkg_name, "is_link": is_link, "columns": columns, "references": references}); let res1 = rrgen.generate(MODEL_T, &vars)?; let res2 = rrgen.generate(MODEL_TEST_T, &vars)?; - if !migration_only { - let cwd = current_dir()?; - let env_map: HashMap<_, _> = std::env::vars().collect(); - - let _ = cmd!("cargo", "loco-tool", "db", "migrate",) - .stderr_to_stdout() - .dir(cwd.as_path()) - .full_env(&env_map) - .run() - .map_err(|err| { - Error::Message(format!( - "failed to run loco db migration. error details: `{err}`", - )) - })?; - let _ = cmd!("cargo", "loco-tool", "db", "entities",) - .stderr_to_stdout() - .dir(cwd.as_path()) - .full_env(&env_map) - .run() - .map_err(|err| { - Error::Message(format!( - "failed to run loco db entities. error details: `{err}`", - )) - })?; - } + // generate the model files by migrating and re-running seaorm + let cwd = current_dir()?; + let env_map: HashMap<_, _> = std::env::vars().collect(); + + let _ = cmd!("cargo", "loco-tool", "db", "migrate",) + .stderr_to_stdout() + .dir(cwd.as_path()) + .full_env(&env_map) + .run() + .map_err(|err| { + Error::Message(format!( + "failed to run loco db migration. error details: `{err}`", + )) + })?; + let _ = cmd!("cargo", "loco-tool", "db", "entities",) + .stderr_to_stdout() + .dir(cwd.as_path()) + .full_env(&env_map) + .run() + .map_err(|err| { + Error::Message(format!( + "failed to run loco db entities. error details: `{err}`", + )) + })?; let messages = collect_messages(vec![res1, res2]); Ok(messages) @@ -141,7 +150,6 @@ mod tests { &rrgen, "movies", false, - true, &[("title".to_string(), "string".to_string())], &AppInfo { app_name: "saas".to_string(), diff --git a/loco-gen/src/scaffold.rs b/loco-gen/src/scaffold.rs index b627b695e..b56f230d3 100644 --- a/loco-gen/src/scaffold.rs +++ b/loco-gen/src/scaffold.rs @@ -34,7 +34,7 @@ pub fn generate( // - scaffold is never a link table // - never run with migration_only, because the controllers will refer to the // models. the models only arrive after migration and entities sync. - let model_messages = model::generate(rrgen, name, false, false, fields, appinfo)?; + let model_messages = model::generate(rrgen, name, false, fields, appinfo)?; let mappings = get_mappings(); let mut columns = Vec::new(); diff --git a/loco-gen/src/templates/migration/add_columns.t b/loco-gen/src/templates/migration/add_columns.t new file mode 100644 index 000000000..4e97fdf81 --- /dev/null +++ b/loco-gen/src/templates/migration/add_columns.t @@ -0,0 +1,59 @@ +{% set mig_ts = ts | date(format="%Y%m%d_%H%M%S") -%} +{% set mig_name = name | snake_case -%} +{% set tbl_enum = table | plural | pascal_case -%} +{% set module_name = "m" ~ mig_ts ~ "_" ~ mig_name -%} +to: "migration/src/{{module_name}}.rs" +skip_glob: "migration/src/m????????_??????_{{mig_name}}.rs" +message: "Migration `{{mig_name}}` added! You can now apply it with `$ cargo loco db migrate`." +injections: +- into: "migration/src/lib.rs" + before: "inject-above" + content: " Box::new({{module_name}}::Migration)," +- into: "migration/src/lib.rs" + before: "pub struct Migrator" + content: "mod {{module_name}};" +--- +use loco_rs::schema::*; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + alter({{tbl_enum}}::Table) + {% for column in columns -%} + {% if column.1 == "decimal_len_null" or column.1 == "decimal_len" -%} + .add_column({{column.1}}({{tbl_enum}}::{{column.0 | pascal_case }}, 16, 4)) + {% else -%} + .add_column({{column.1}}({{tbl_enum}}::{{column.0 | pascal_case}})) + {% endif -%} + {% endfor -%} + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + alter({{tbl_enum}}::Table) + {% for column in columns -%} + .drop_column({{tbl_enum}}::{{column.0 | pascal_case}}) + {% endfor -%} + .to_owned() + ) + .await + } +} + +#[derive(DeriveIden)] +enum {{tbl_enum}} { + Table, + {% for column in columns -%} + {{column.0 | pascal_case}}, + {% endfor %} +} diff --git a/loco-gen/src/templates/migration/add_references.t b/loco-gen/src/templates/migration/add_references.t new file mode 100644 index 000000000..9357f4917 --- /dev/null +++ b/loco-gen/src/templates/migration/add_references.t @@ -0,0 +1,83 @@ +{% set mig_ts = ts | date(format="%Y%m%d_%H%M%S") -%} +{% set mig_name = name | snake_case -%} +{% set tbl_enum = table | plural | pascal_case -%} +{% set module_name = "m" ~ mig_ts ~ "_" ~ mig_name -%} +to: "migration/src/{{module_name}}.rs" +skip_glob: "migration/src/m????????_??????_{{mig_name}}.rs" +message: "Migration `{{mig_name}}` added! You can now apply it with `$ cargo loco db migrate`." +injections: +- into: "migration/src/lib.rs" + before: "inject-above" + content: " Box::new({{module_name}}::Migration)," +- into: "migration/src/lib.rs" + before: "pub struct Migrator" + content: "mod {{module_name}};" +--- +use loco_rs::schema::*; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + alter({{tbl_enum}}::Table) + {% for column in columns -%} + {% if column.1 == "decimal_len_null" or column.1 == "decimal_len" -%} + .add_column({{column.1}}({{tbl_enum}}::{{column.0 | pascal_case }}, 16, 4)) + {% else -%} + .add_column({{column.1}}({{tbl_enum}}::{{column.0 | pascal_case}})) + {% endif -%} + {% endfor -%} + {% for ref in references -%} + .add_foreign_key( + TableForeignKey::new() + .name("fk-{{table | plural | snake_case}}-{{ref.0 | plural| snake_case}}") + .from_tbl({{tbl_enum}}::Table) + .from_col({{tbl_enum}}::{{ref.1 | pascal_case}}) + .to_tbl({{ref.0 | plural | pascal_case}}::Table) + .to_col({{ref.0 | plural | pascal_case}}::Id) + .on_delete(ForeignKeyAction::Cascade) + .on_update(ForeignKeyAction::Cascade), + ) + {% endfor -%} + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + alter({{tbl_enum}}::Table) + {% for ref in references -%} + .drop_foreign_key(Alias::new("fk-{{table | plural | snake_case}}-{{ref.0 | plural| snake_case}}")) + {% endfor -%} + {% for column in columns -%} + .drop_column({{tbl_enum}}::{{column.0 | pascal_case}}) + {% endfor -%} + .to_owned() + ) + .await + } +} + +#[derive(DeriveIden)] +enum {{tbl_enum}} { + Table, + {% for column in columns -%} + {{column.0 | pascal_case}}, + {% endfor %} +} + +{% for ref in references | unique(attribute="0") -%} +#[derive(DeriveIden)] +enum {{ref.0 | plural | pascal_case}} { + Table, + Id, +} +{% endfor -%} + diff --git a/loco-gen/src/templates/migration/migration.t b/loco-gen/src/templates/migration/empty.t similarity index 100% rename from loco-gen/src/templates/migration/migration.t rename to loco-gen/src/templates/migration/empty.t diff --git a/loco-gen/src/templates/migration/join_table.t b/loco-gen/src/templates/migration/join_table.t new file mode 100644 index 000000000..5029a687d --- /dev/null +++ b/loco-gen/src/templates/migration/join_table.t @@ -0,0 +1,81 @@ +{% set mig_ts = ts | date(format="%Y%m%d_%H%M%S") -%} +{% set plural_snake = name | plural | snake_case -%} +{% set module_name = "m" ~ mig_ts ~ "_" ~ plural_snake -%} +{% set tbl_enum = table | plural | pascal_case -%} +to: "migration/src/{{module_name}}.rs" +skip_glob: "migration/src/m????????_??????_{{plural_snake}}.rs" +message: "Migration for `{{name}}` added! You can now apply it with `$ cargo loco db migrate`." +injections: +- into: "migration/src/lib.rs" + before: "inject-above" + content: " Box::new({{module_name}}::Migration)," +- into: "migration/src/lib.rs" + before: "pub struct Migrator" + content: "mod {{module_name}};" +--- +use loco_rs::schema::table_auto_tz; +use sea_orm_migration::{prelude::*, schema::*}; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + table_auto_tz({{tbl_enum}}::Table) + .primary_key( + Index::create() + .name("idx-{{plural_snake}}-refs-pk") + .table({{tbl_enum}}::Table) + {% for ref in references -%} + .col({{tbl_enum}}::{{ref.1 | pascal_case}}) + {% endfor -%} + , + ) + {% for column in columns -%} + {% if column.1 == "decimal_len_null" or column.1 == "decimal_len" -%} + .col({{column.1}}({{tbl_enum}}::{{column.0 | pascal_case }}, 16, 4)) + {% else -%} + .col({{column.1}}({{tbl_enum}}::{{column.0 | pascal_case}})) + {% endif -%} + {% endfor -%} + {% for ref in references -%} + .foreign_key( + ForeignKey::create() + .name("fk-{{plural_snake}}-{{ref.1 | plural| snake_case}}") + .from({{tbl_enum}}::Table, {{tbl_enum}}::{{ref.1 | pascal_case}}) + .to({{ref.0 | plural | pascal_case}}::Table, {{ref.0 | plural | pascal_case}}::Id) + .on_delete(ForeignKeyAction::Cascade) + .on_update(ForeignKeyAction::Cascade), + ) + {% endfor -%} + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table({{tbl_enum}}::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum {{tbl_enum}} { + Table, + {% for column in columns -%} + {{column.0 | pascal_case}}, + {% endfor %} +} + +{% for ref in references | unique(attribute="0") -%} +#[derive(DeriveIden)] +enum {{ref.0 | plural | pascal_case}} { + Table, + Id, +} +{% endfor -%} + diff --git a/loco-gen/src/templates/migration/remove_columns.t b/loco-gen/src/templates/migration/remove_columns.t new file mode 100644 index 000000000..1f6f959f4 --- /dev/null +++ b/loco-gen/src/templates/migration/remove_columns.t @@ -0,0 +1,59 @@ +{% set mig_ts = ts | date(format="%Y%m%d_%H%M%S") -%} +{% set mig_name = name | snake_case -%} +{% set tbl_enum = table | plural | pascal_case -%} +{% set module_name = "m" ~ mig_ts ~ "_" ~ mig_name -%} +to: "migration/src/{{module_name}}.rs" +skip_glob: "migration/src/m????????_??????_{{mig_name}}.rs" +message: "Migration `{{mig_name}}` added! You can now apply it with `$ cargo loco db migrate`." +injections: +- into: "migration/src/lib.rs" + before: "inject-above" + content: " Box::new({{module_name}}::Migration)," +- into: "migration/src/lib.rs" + before: "pub struct Migrator" + content: "mod {{module_name}};" +--- +use loco_rs::schema::*; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + alter({{tbl_enum}}::Table) + {% for column in columns -%} + .drop_column({{tbl_enum}}::{{column.0 | pascal_case}}) + {% endfor -%} + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + alter({{tbl_enum}}::Table) + {% for column in columns -%} + {% if column.1 == "decimal_len_null" or column.1 == "decimal_len" -%} + .add_column({{column.1}}({{tbl_enum}}::{{column.0 | pascal_case }}, 16, 4)) + {% else -%} + .add_column({{column.1}}({{tbl_enum}}::{{column.0 | pascal_case}})) + {% endif -%} + {% endfor -%} + .to_owned(), + ) + .await + } +} + +#[derive(DeriveIden)] +enum {{tbl_enum}} { + Table, + {% for column in columns -%} + {{column.0 | pascal_case}}, + {% endfor %} +} diff --git a/src/cli.rs b/src/cli.rs index 8f88f8455..8fc4564d4 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -183,10 +183,6 @@ enum ComponentArg { #[arg(short, long, action)] link: bool, - /// Generate migration code only. Don't run the migration automatically. - #[arg(short, long, action)] - migration_only: bool, - /// Model fields, eg. title:string hits:int #[clap(value_parser = parse_key_val::)] fields: Vec<(String, String)>, @@ -196,6 +192,9 @@ enum ComponentArg { Migration { /// Name of the migration to generate name: String, + /// Table fields, eg. title:string hits:int + #[clap(value_parser = parse_key_val::)] + fields: Vec<(String, String)>, }, #[cfg(feature = "with-db")] /// Generates a CRUD scaffold, model and controller @@ -272,19 +271,9 @@ impl ComponentArg { fn into_gen_component(self, config: &Config) -> crate::Result { match self { #[cfg(feature = "with-db")] - Self::Model { - name, - link, - migration_only, - fields, - } => Ok(Component::Model { - name, - link, - migration_only, - fields, - }), + Self::Model { name, link, fields } => Ok(Component::Model { name, link, fields }), #[cfg(feature = "with-db")] - Self::Migration { name } => Ok(Component::Migration { name }), + Self::Migration { name, fields } => Ok(Component::Migration { name, fields }), #[cfg(feature = "with-db")] Self::Scaffold { name, @@ -448,7 +437,8 @@ enum JobsCommands { Tidy {}, /// Deletes jobs based on their age in days. Purge { - /// Deletes jobs with errors or cancelled, older than the specified maximum age in days. + /// Deletes jobs with errors or cancelled, older than the specified + /// maximum age in days. #[arg(long, default_value_t = 90)] max_age: i64, /// Limits the jobs being saved to those with specific criteria like diff --git a/src/schema.rs b/src/schema.rs index e093235db..bb68c2282 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -1,57 +1,8 @@ -//! # Database Table Schema Helpers -//! -//! This module defines functions and helpers for creating database table -//! schemas using the `sea-orm` and `sea-query` libraries. -//! -//! # Example -//! -//! The following example shows how the user migration file should be and using -//! the schema helpers to create the Db fields. -//! -//! ```rust -//! use sea_orm_migration::{prelude::*, schema::*}; -//! -//! #[derive(DeriveMigrationName)] -//! pub struct Migration; -//! -//! #[async_trait::async_trait] -//! impl MigrationTrait for Migration { -//! async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { -//! let table = table_auto(Users::Table) -//! .col(pk_auto(Users::Id)) -//! .col(uuid(Users::Pid)) -//! .col(string_uniq(Users::Email)) -//! .col(string(Users::Password)) -//! .col(string(Users::Name)) -//! .col(string_null(Users::ResetToken)) -//! .col(timestamp_null(Users::ResetSentAt)) -//! .to_owned(); -//! manager.create_table(table).await?; -//! Ok(()) -//! } -//! -//! async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { -//! manager -//! .drop_table(Table::drop().table(Users::Table).to_owned()) -//! .await -//! } -//! } -//! -//! #[derive(Iden)] -//! pub enum Users { -//! Table, -//! Id, -//! Pid, -//! Email, -//! Name, -//! Password, -//! ResetToken, -//! ResetSentAt, -//! } -//! ``` - -use sea_orm::sea_query::{ColumnDef, Expr, IntoIden, Table, TableCreateStatement}; -use sea_orm_migration::{prelude::Iden, schema::timestamp_with_time_zone, sea_query}; +use sea_orm::sea_query::{ + ColumnDef, Expr, IntoIden, Table, TableAlterStatement, TableCreateStatement, +}; +pub use sea_orm_migration::schema::*; +use sea_orm_migration::{prelude::Iden, sea_query}; #[derive(Iden)] enum GeneralIds { @@ -59,6 +10,11 @@ enum GeneralIds { UpdatedAt, } +/// Alter table +pub fn alter(name: T) -> TableAlterStatement { + Table::alter().table(name).take() +} + /// Wrapping table schema creation. pub fn table_auto_tz(name: T) -> TableCreateStatement where @@ -67,27 +23,8 @@ where timestamps_tz(Table::create().table(name).if_not_exists().take()) } -pub fn table_auto(name: T) -> TableCreateStatement -where - T: IntoIden + 'static, -{ - timestamps(Table::create().table(name).if_not_exists().take()) -} +// these two are just aliases, original types exist in seaorm already. -/// Create a primary key column with auto-increment feature. -pub fn pk_auto(name: T) -> ColumnDef -where - T: IntoIden, -{ - ColumnDef::new(name) - .integer() - .not_null() - .auto_increment() - .primary_key() - .take() -} - -/// Add timestamp columns (`CreatedAt` and `UpdatedAt`) to an existing table. #[must_use] pub fn timestamps_tz(t: TableCreateStatement) -> TableCreateStatement { let mut t = t; @@ -96,46 +33,6 @@ pub fn timestamps_tz(t: TableCreateStatement) -> TableCreateStatement { t.take() } -#[must_use] -pub fn timestamps(t: TableCreateStatement) -> TableCreateStatement { - let mut t = t; - t.col(timestamp(GeneralIds::CreatedAt).default(Expr::current_timestamp())) - .col(timestamp(GeneralIds::UpdatedAt).default(Expr::current_timestamp())); - t.take() -} - -/// Create a UUID column definition with a unique constraint. -pub fn uuid(name: T) -> ColumnDef -where - T: IntoIden, -{ - ColumnDef::new(name).unique_key().uuid().not_null().take() -} - -/// Create a UUID type column definition. -pub fn uuid_col(name: T) -> ColumnDef -where - T: IntoIden, -{ - ColumnDef::new(name).uuid().not_null().take() -} - -/// Create a nullable UUID type column definition. -pub fn uuid_col_null(name: T) -> ColumnDef -where - T: IntoIden, -{ - ColumnDef::new(name).uuid().take() -} - -/// Create a nullable string column definition. -pub fn string_null(name: T) -> ColumnDef -where - T: IntoIden, -{ - ColumnDef::new(name).string().take() -} - /// Create a nullable timestamptz column definition. pub fn timestamptz_null(name: T) -> ColumnDef where @@ -154,280 +51,3 @@ where .not_null() .take() } - -/// Create a non-nullable string column definition. -pub fn string(name: T) -> ColumnDef -where - T: IntoIden, -{ - string_null(name).not_null().take() -} - -/// Create a unique string column definition. -pub fn string_uniq(name: T) -> ColumnDef -where - T: IntoIden, -{ - string(name).unique_key().take() -} - -/// Create a nullable text column definition. -pub fn text_null(name: T) -> ColumnDef -where - T: IntoIden, -{ - ColumnDef::new(name).text().take() -} - -/// Create a nullable text column definition. -pub fn text(name: T) -> ColumnDef -where - T: IntoIden, -{ - ColumnDef::new(name).text().take() -} - -/// Create a nullable tiny integer column definition. -pub fn tiny_integer_null(name: T) -> ColumnDef -where - T: IntoIden, -{ - ColumnDef::new(name).tiny_integer().take() -} - -/// Create a non-nullable tiny integer column definition. -pub fn tiny_integer(name: T) -> ColumnDef -where - T: IntoIden, -{ - ColumnDef::new(name).tiny_integer().not_null().take() -} - -/// Create a unique tiny integer column definition. -pub fn tiny_integer_uniq(name: T) -> ColumnDef -where - T: IntoIden, -{ - ColumnDef::new(name).tiny_integer().unique_key().take() -} - -/// Create a nullable small integer column definition. -pub fn small_integer_null(name: T) -> ColumnDef -where - T: IntoIden, -{ - ColumnDef::new(name).small_integer().take() -} - -/// Create a non-nullable small integer column definition. -pub fn small_integer(name: T) -> ColumnDef -where - T: IntoIden, -{ - ColumnDef::new(name).small_integer().not_null().take() -} - -/// Create a unique small integer column definition. -pub fn small_integer_uniq(name: T) -> ColumnDef -where - T: IntoIden, -{ - ColumnDef::new(name).small_integer().unique_key().take() -} - -/// Create a nullable integer column definition. -pub fn integer_null(name: T) -> ColumnDef -where - T: IntoIden, -{ - ColumnDef::new(name).integer().take() -} - -/// Create a non-nullable integer column definition. -pub fn integer(name: T) -> ColumnDef -where - T: IntoIden, -{ - ColumnDef::new(name).integer().not_null().take() -} - -/// Create a unique integer column definition. -pub fn integer_uniq(name: T) -> ColumnDef -where - T: IntoIden, -{ - ColumnDef::new(name).integer().unique_key().take() -} - -/// Create a nullable big integer column definition. -pub fn big_integer_null(name: T) -> ColumnDef -where - T: IntoIden, -{ - ColumnDef::new(name).big_integer().take() -} - -/// Create a non-nullable big integer column definition. -pub fn big_integer(name: T) -> ColumnDef -where - T: IntoIden, -{ - ColumnDef::new(name).big_integer().not_null().take() -} - -/// Create a unique big integer column definition. -pub fn big_integer_uniq(name: T) -> ColumnDef -where - T: IntoIden, -{ - ColumnDef::new(name).big_integer().unique_key().take() -} - -/// Create a nullable float column definition. -pub fn float_null(name: T) -> ColumnDef -where - T: IntoIden, -{ - ColumnDef::new(name).float().take() -} - -/// Create a non-nullable float column definition. -pub fn float(name: T) -> ColumnDef -where - T: IntoIden, -{ - ColumnDef::new(name).float().not_null().take() -} - -/// Create a nullable double column definition. -pub fn double_null(name: T) -> ColumnDef -where - T: IntoIden, -{ - ColumnDef::new(name).double().take() -} - -/// Create a non-nullable double column definition. -pub fn double(name: T) -> ColumnDef -where - T: IntoIden, -{ - ColumnDef::new(name).double().not_null().take() -} - -/// Create a nullable decimal column definition. -pub fn decimal_null(name: T) -> ColumnDef -where - T: IntoIden, -{ - ColumnDef::new(name).decimal().take() -} - -/// Create a non-nullable decimal column definition. -pub fn decimal(name: T) -> ColumnDef -where - T: IntoIden, -{ - ColumnDef::new(name).decimal().not_null().take() -} - -/// Create a nullable decimal length column definition with custom precision and -/// scale. -pub fn decimal_len_null(name: T, precision: u32, scale: u32) -> ColumnDef -where - T: IntoIden, -{ - ColumnDef::new(name).decimal_len(precision, scale).take() -} - -/// Create a non-nullable decimal length column definition with custom precision -/// and scale. -pub fn decimal_len(name: T, precision: u32, scale: u32) -> ColumnDef -where - T: IntoIden, -{ - ColumnDef::new(name) - .decimal_len(precision, scale) - .not_null() - .take() -} - -/// Create a nullable boolean column definition. -pub fn bool_null(name: T) -> ColumnDef -where - T: IntoIden, -{ - ColumnDef::new(name).boolean().take() -} - -/// Create a non-nullable boolean column definition. -pub fn bool(name: T) -> ColumnDef -where - T: IntoIden, -{ - ColumnDef::new(name).boolean().not_null().take() -} - -/// Create a nullable date column definition. -pub fn date_null(name: T) -> ColumnDef -where - T: IntoIden, -{ - ColumnDef::new(name).date().take() -} - -/// Create a non-nullable date column definition. -pub fn date(name: T) -> ColumnDef -where - T: IntoIden, -{ - ColumnDef::new(name).date().not_null().take() -} - -/// Create a nullable timestamp column definition. -pub fn timestamp_null(name: T) -> ColumnDef -where - T: IntoIden, -{ - ColumnDef::new(name).date_time().take() -} - -/// Create a non-nullable timestamp column definition. -pub fn timestamp(name: T) -> ColumnDef -where - T: IntoIden, -{ - ColumnDef::new(name).date_time().not_null().take() -} - -/// Create a non-nullable json column definition. -pub fn json(name: T) -> ColumnDef -where - T: IntoIden, -{ - ColumnDef::new(name).json().not_null().take() -} - -/// Create a nullable json column definition. -pub fn json_null(name: T) -> ColumnDef -where - T: IntoIden, -{ - ColumnDef::new(name).json().take() -} - -/// Create a non-nullable json binary column definition. -pub fn jsonb(name: T) -> ColumnDef -where - T: IntoIden, -{ - ColumnDef::new(name).json_binary().not_null().take() -} - -/// Create a nullable json binary column definition. -pub fn jsonb_null(name: T) -> ColumnDef -where - T: IntoIden, -{ - ColumnDef::new(name).json_binary().take() -} From 056d383e9593f94cc2665f39548ae18ddcd8cfdf Mon Sep 17 00:00:00 2001 From: Elad Kaplan Date: Sun, 29 Dec 2024 12:24:18 +0200 Subject: [PATCH 04/11] Update loco-gen-ci.yml --- .github/workflows/loco-gen-ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/loco-gen-ci.yml b/.github/workflows/loco-gen-ci.yml index 6c72b4c82..3e8d01387 100644 --- a/.github/workflows/loco-gen-ci.yml +++ b/.github/workflows/loco-gen-ci.yml @@ -57,6 +57,9 @@ jobs: - name: Setup Rust cache uses: Swatinem/rust-cache@v2 + - name: Install seaorm cli + run: cargo install sea-orm-cli + - run: | cargo install --path ../loco-new From 63b1f1c5a58038ed54468f7ca8bf552036a6bb57 Mon Sep 17 00:00:00 2001 From: Jouke Waleson Date: Sun, 29 Dec 2024 11:56:00 +0100 Subject: [PATCH 05/11] docs: update /api/user/current -> /api/auth... (#1112) The starter app generated by loco cli does not have /api/user, only /api/auth so the docs should use this for a streamlined onboarding. Co-authored-by: Elad Kaplan --- docs-site/content/docs/extras/authentication.md | 6 +++--- docs-site/translations/tour-fr.md | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs-site/content/docs/extras/authentication.md b/docs-site/content/docs/extras/authentication.md index 05acd694a..2e16fdbf8 100644 --- a/docs-site/content/docs/extras/authentication.md +++ b/docs-site/content/docs/extras/authentication.md @@ -39,7 +39,7 @@ $ cargo loco routes [POST] /api/auth/register [POST] /api/auth/reset [POST] /api/auth/verify -[GET] /api/user/current +[GET] /api/auth/current . . . @@ -144,7 +144,7 @@ curl --location '127.0.0.1:5150/api/auth/reset' \ This endpoint is protected by auth middleware. ```sh -curl --location --request GET '127.0.0.1:5150/api/user/current' \ +curl --location --request GET '127.0.0.1:5150/api/auth/current' \ --header 'Content-Type: application/json' \ --header 'Authorization: Bearer TOKEN' ``` @@ -196,7 +196,7 @@ $ cargo loco routes [POST] /api/auth/register [POST] /api/auth/reset [POST] /api/auth/verify -[GET] /api/user/current +[GET] /api/auth/current . . . diff --git a/docs-site/translations/tour-fr.md b/docs-site/translations/tour-fr.md index 1475b3921..6893f215b 100644 --- a/docs-site/translations/tour-fr.md +++ b/docs-site/translations/tour-fr.md @@ -205,7 +205,7 @@ Dans votre application côté client, vous enregistrez ce jeton JWT et effectuez Ce point de terminaison est protégé par un middleware d'authentification. Nous utiliserons le jeton que nous avons obtenu précédemment pour effectuer une requête avec la technique _bearer token_ (remplacez `TOKEN` par le jeton JWT que vous avez obtenu précédemment): ```sh -$ curl --location --request GET '127.0.0.1:5150/api/user/current' \ +$ curl --location --request GET '127.0.0.1:5150/api/auth/current' \ --header 'Content-Type: application/json' \ --header 'Authorization: Bearer TOKEN' ``` From 3266c424b28126185e683628b48f547cb9e00b27 Mon Sep 17 00:00:00 2001 From: Joel Parker Henderson Date: Sun, 29 Dec 2024 11:23:59 +0000 Subject: [PATCH 06/11] Fix spelling (#1105) Co-authored-by: Elad Kaplan --- docs-site/content/docs/the-app/models.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs-site/content/docs/the-app/models.md b/docs-site/content/docs/the-app/models.md index e0d139154..7d6c84656 100644 --- a/docs-site/content/docs/the-app/models.md +++ b/docs-site/content/docs/the-app/models.md @@ -173,7 +173,7 @@ For schema data types, you can use the following mapping to understand the schem Using `user:references` uses the special `references` type, which will create a relationship between a `post` and a `user`, adding a `user_id` reference field to the `posts` table. -Using `aproved_by:references:users` uses the special `references:
` type, which will create a relationship between a `post` and a `user`, adding a `aproved_by` reference field to the `posts` table. +Using `approved_by:references:users` uses the special `references:
` type, which will create a relationship between a `post` and a `user`, adding a `approved_by` reference field to the `posts` table. You can generate an empty model: From c1b2ca9bbaa42df833ea7cffd27007eec2a39b19 Mon Sep 17 00:00:00 2001 From: tsailin <148511745+tsailin-bit@users.noreply.github.com> Date: Sun, 29 Dec 2024 19:43:44 +0800 Subject: [PATCH 07/11] fix: Remove unnecessary calls to 'register_tasks' functions in scheduler mode (#1100) Co-authored-by: Elad Kaplan --- src/boot.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/boot.rs b/src/boot.rs index a793b80e2..8fbe15cf1 100644 --- a/src/boot.rs +++ b/src/boot.rs @@ -199,9 +199,6 @@ pub async fn run_scheduler( tag: Option, list: bool, ) -> Result<()> { - let mut tasks = Tasks::default(); - H::register_tasks(&mut tasks); - let task_span = tracing::span!(tracing::Level::DEBUG, "scheduler_jobs"); let _guard = task_span.enter(); From b2fd9a4bc54ecc4b9027a839c962938ea6d44166 Mon Sep 17 00:00:00 2001 From: Elad Kaplan Date: Sun, 29 Dec 2024 14:23:29 +0200 Subject: [PATCH 08/11] Update CHANGELOG.md --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d08a89ef3..3d512b783 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ * fix: logger file_appender not using the seperated format setting. [https://github.com/loco-rs/loco/pull/1036](https://github.com/loco-rs/loco/pull/1036) * seed cli command. [https://github.com/loco-rs/loco/pull/1046](https://github.com/loco-rs/loco/pull/1046) * Updated validator to 0.19. [https://github.com/loco-rs/loco/pull/993](https://github.com/loco-rs/loco/pull/993) + ### Breaking Changes + Bump validator to 0.19 in your local `Cargo.toml` * Testing helpers: simplified function calls + adding html selector. [https://github.com/loco-rs/loco/pull/1047](https://github.com/loco-rs/loco/pull/1047) ### Breaking Changes #### Updated Import Paths @@ -32,6 +34,13 @@ let boot = boot_test::().await.unwrap(); ``` * implement commands to manage background jobs. [https://github.com/loco-rs/loco/pull/1071](https://github.com/loco-rs/loco/pull/1071) +* magic link. [https://github.com/loco-rs/loco/pull/1085](https://github.com/loco-rs/loco/pull/1085) +* infer migration. [https://github.com/loco-rs/loco/pull/1086](https://github.com/loco-rs/loco/pull/1086) +* Remove unnecessary calls to 'register_tasks' functions in scheduler. [https://github.com/loco-rs/loco/pull/1100](https://github.com/loco-rs/loco/pull/1100) +* implement commands to manage background jobs. [https://github.com/loco-rs/loco/pull/1071](https://github.com/loco-rs/loco/pull/1071) +* expose hello_name for SMTP client config. [https://github.com/loco-rs/loco/pull/1057](https://github.com/loco-rs/loco/pull/1057) +* use reqwest with rustls rather than openssl. [https://github.com/loco-rs/loco/pull/1058](https://github.com/loco-rs/loco/pull/1058) +* more flexible config, take more values from ENV. [https://github.com/loco-rs/loco/pull/1058](https://github.com/loco-rs/loco/pull/1058) ## v0.13.2 From 337ced4457d8e3169fdad38774fa70aa71eb3ca3 Mon Sep 17 00:00:00 2001 From: Mikey Huang Date: Wed, 1 Jan 2025 21:18:30 +0800 Subject: [PATCH 09/11] fix template ci.yaml lost fronted build. (#1118) * fix template ci.yaml lost fronted build. * changes * windows --------- Co-authored-by: huangkun Co-authored-by: Elad Kaplan --- .../.github/workflows/{ci.yaml => ci.yaml.t} | 20 ++++++++++--- loco-new/setup.rhai | 2 +- loco-new/tests/assertion/string.rs | 29 +++++++++++++++++++ loco-new/tests/templates/asset.rs | 29 +++++++++++++++++++ 4 files changed, 75 insertions(+), 5 deletions(-) rename loco-new/base_template/.github/workflows/{ci.yaml => ci.yaml.t} (76%) diff --git a/loco-new/base_template/.github/workflows/ci.yaml b/loco-new/base_template/.github/workflows/ci.yaml.t similarity index 76% rename from loco-new/base_template/.github/workflows/ci.yaml rename to loco-new/base_template/.github/workflows/ci.yaml.t index 75ba8a5e3..cd2cd9fe3 100644 --- a/loco-new/base_template/.github/workflows/ci.yaml +++ b/loco-new/base_template/.github/workflows/ci.yaml.t @@ -23,7 +23,7 @@ jobs: uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable with: - toolchain: ${{ env.RUST_TOOLCHAIN }} + toolchain: ${% raw %}{{ env.RUST_TOOLCHAIN }}{% endraw %} components: rustfmt - name: Run cargo fmt uses: actions-rs/cargo@v1 @@ -43,7 +43,7 @@ jobs: uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable with: - toolchain: ${{ env.RUST_TOOLCHAIN }} + toolchain: ${% raw %}{{ env.RUST_TOOLCHAIN }}{% endraw %} - name: Setup Rust cache uses: Swatinem/rust-cache@v2 - name: Run cargo clippy @@ -88,7 +88,19 @@ jobs: uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable with: - toolchain: ${{ env.RUST_TOOLCHAIN }} + toolchain: ${% raw %}{{ env.RUST_TOOLCHAIN }}{% endraw %} + + {%- if settings.asset %} + {%- if settings.asset.kind == "client" %} + - name: Setup node + uses: actions/setup-node@v4 + with: + node-version: ${% raw %}{{matrix.node-version}}{% endraw %} + - name: Build frontend + run: npm install && npm run build + working-directory: ./frontend + {%- endif -%} + {%- endif %} - name: Setup Rust cache uses: Swatinem/rust-cache@v2 - name: Run cargo test @@ -97,6 +109,6 @@ jobs: command: test args: --all-features --all env: - REDIS_URL: redis://localhost:${{job.services.redis.ports[6379]}} + REDIS_URL: redis://localhost:${% raw %}{{job.services.redis.ports[6379]}}{% endraw %} DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres_test diff --git a/loco-new/setup.rhai b/loco-new/setup.rhai index 802e835b3..7622b222d 100644 --- a/loco-new/setup.rhai +++ b/loco-new/setup.rhai @@ -4,7 +4,7 @@ // Copy core project structure files and directories that are fundamental // to the Rust environment, GitHub actions, and formatting settings. -gen.copy_dirs([".github"]); +gen.copy_template(".github/workflows/ci.yaml.t"); // Actions ci template gen.copy_files([".gitignore", ".rustfmt.toml", "README.md"]); gen.copy_template_dir(".cargo"); diff --git a/loco-new/tests/assertion/string.rs b/loco-new/tests/assertion/string.rs index 585fe869a..8f33a9de4 100644 --- a/loco-new/tests/assertion/string.rs +++ b/loco-new/tests/assertion/string.rs @@ -1,6 +1,13 @@ #![allow(clippy::missing_panics_doc)] +use std::path::PathBuf; + use regex::Regex; +#[must_use] +pub fn load(path: PathBuf) -> String { + std::fs::read_to_string(path).expect("could not read file") +} + pub fn assert_line_regex(content: &str, expected: &str) { let re = Regex::new(expected).unwrap(); @@ -21,3 +28,25 @@ pub fn assert_str_not_exists(content: &str, expected: &str) { exist: '{expected}', content in:\n{content}", ); } + +pub fn assert_contains(content: &str, expected: &str) { + let content_sanitized = content.replace('\r', ""); + let expected_sanitized = expected.replace('\r', ""); + + assert!( + content_sanitized.contains(&expected_sanitized), + "Assertion failed: The content did not contain the expected string. \ + Expected: '{expected_sanitized}', content:\n{content_sanitized}" + ); +} + +pub fn assert_not_contains(content: &str, unexpected: &str) { + let content_sanitized = content.replace('\r', ""); + let unexpected_sanitized = unexpected.replace('\r', ""); + + assert!( + !content_sanitized.contains(&unexpected_sanitized), + "Assertion failed: The content unexpectedly contained the string. \ + Unexpected: '{unexpected_sanitized}', content:\n{content_sanitized}" + ); +} diff --git a/loco-new/tests/templates/asset.rs b/loco-new/tests/templates/asset.rs index 03ea18128..14135fda0 100644 --- a/loco-new/tests/templates/asset.rs +++ b/loco-new/tests/templates/asset.rs @@ -92,3 +92,32 @@ fn test_cargo_toml( content.get("dependencies").unwrap() ); } + +#[rstest] +fn test_github_ci_yaml( + #[values(AssetsOption::None, AssetsOption::Serverside, AssetsOption::Clientside)] + asset: AssetsOption, +) { + let generator: TestGenerator = run_generator(asset.clone()); + let content = + assertion::string::load(generator.path(".github").join("workflows").join("ci.yaml")); + + let frontend_section = r" - name: Setup node + uses: actions/setup-node@v4 + with: + node-version: ${{matrix.node-version}} + - name: Build frontend + run: npm install && npm run build + working-directory: ./frontend + - name: Setup Rust cache + uses: Swatinem/rust-cache@v2"; + + match asset { + AssetsOption::Serverside | AssetsOption::None => { + assertion::string::assert_not_contains(&content, frontend_section); + } + AssetsOption::Clientside => { + assertion::string::assert_contains(&content, frontend_section); + } + } +} From ab3bf0b48b6701f14a1f384f3a21f5912601d2a3 Mon Sep 17 00:00:00 2001 From: Elad Kaplan Date: Thu, 2 Jan 2025 01:14:33 -0800 Subject: [PATCH 10/11] allow override loco template (#1102) * allow override loco template * tests * remove generator check in all templates variation * add more inforation * add color * rrgen template tests * change snapshot name for window tests --- .github/workflows/loco-gen-e2e.yml | 3 +- .github/workflows/loco-new.yml | 2 + Cargo.toml | 4 +- examples/demo/tests/cmd/cli.trycmd | 1 + loco-gen/Cargo.toml | 7 +- loco-gen/src/controller.rs | 47 +-- loco-gen/src/lib.rs | 329 ++++++++++++------ loco-gen/src/migration.rs | 39 +-- loco-gen/src/model.rs | 138 ++------ loco-gen/src/scaffold.rs | 71 ++-- loco-gen/src/template.rs | 202 +++++++++++ .../src/templates/deployment/docker/docker.t | 2 +- loco-gen/src/templates/mailer/mailer.t | 4 + .../src/templates/migration/add_columns.t | 14 +- .../src/templates/migration/remove_columns.t | 16 +- loco-gen/src/templates/scaffold/html/view.t | 8 +- loco-gen/src/templates/scaffold/htmx/view.t | 8 +- loco-gen/src/templates/worker/test.t | 11 +- loco-gen/src/templates/worker/worker.t | 11 +- loco-gen/tests/mod.rs | 1 + loco-gen/tests/templates/controller.rs | 88 +++++ loco-gen/tests/templates/deployment.rs | 101 ++++++ loco-gen/tests/templates/mailer.rs | 75 ++++ loco-gen/tests/templates/migration.rs | 164 +++++++++ loco-gen/tests/templates/mod.rs | 13 + loco-gen/tests/templates/model.rs | 139 ++++++++ loco-gen/tests/templates/scaffold.rs | 137 ++++++++ loco-gen/tests/templates/scheduler.rs | 43 +++ ...erate[controller_file]@Api_controller.snap | 32 ++ ...enerate[controller_file]@Api_scaffold.snap | 78 +++++ ...rate[controller_file]@Html_controller.snap | 32 ++ ...nerate[controller_file]@Html_scaffold.snap | 117 +++++++ ...rate[controller_file]@Htmx_controller.snap | 32 ++ ...nerate[controller_file]@Htmx_scaffold.snap | 118 +++++++ .../generate[controller_file]@scheduler.snap | 17 + .../generate[controller_file]@task.snap | 20 ++ .../generate[controller_file]@worker.snap | 26 ++ ...er_file_[404_html]_[None]]@deployment.snap | 21 ++ ..._file_[404_html]_[assets]]@deployment.snap | 22 ++ ...docker_file_[None]_[None]]@deployment.snap | 20 ++ ...cker_file_[None]_[assets]]@deployment.snap | 21 ++ .../generate[html_t_file]@mailer.snap | 5 + .../generate[mailer_mod_rs]@mailer.snap | 37 ++ ...generate[migration_file]@Api_scaffold.snap | 53 +++ ...enerate[migration_file]@Html_scaffold.snap | 53 +++ ...enerate[migration_file]@Htmx_scaffold.snap | 53 +++ ...[migration_file]@add_column_migration.snap | 56 +++ ...gration_file]@add_reference_migration.snap | 56 +++ ...ion_file]@create_join_table_migration.snap | 72 ++++ ...igration_file]@create_table_migration.snap | 53 +++ ...erate[migration_file]@empty_migration.snap | 67 ++++ .../generate[migration_file]@model.snap | 37 ++ ...ration_file]@remove_columns_migration.snap | 60 ++++ .../snapshots/generate[nginx]@deployment.snap | 25 ++ .../generate[subject_t_file]@mailer.snap | 5 + .../generate[test_model]@Api_scaffold.snap | 35 ++ .../generate[test_model]@Html_scaffold.snap | 35 ++ .../generate[test_model]@Htmx_scaffold.snap | 35 ++ .../snapshots/generate[test_model]@model.snap | 35 ++ ...sts_controller_mod_rs]@Api_controller.snap | 40 +++ .../generate[tests_task_file]@task.snap | 21 ++ .../generate[tests_worker_file]@worker.snap | 24 ++ .../generate[text_t_file]@mailer.snap | 5 + ...enerate[views_[create]]@Html_scaffold.snap | 29 ++ ...enerate[views_[create]]@Htmx_scaffold.snap | 53 +++ .../generate[views_[edit]]@Html_scaffold.snap | 49 +++ .../generate[views_[edit]]@Htmx_scaffold.snap | 73 ++++ .../generate[views_[list]]@Html_scaffold.snap | 28 ++ .../generate[views_[list]]@Htmx_scaffold.snap | 28 ++ .../generate[views_[show]]@Html_scaffold.snap | 20 ++ .../generate[views_[show]]@Htmx_scaffold.snap | 20 ++ .../generate[views_rs]@Html_scaffold.snap | 43 +++ .../generate[views_rs]@Htmx_scaffold.snap | 43 +++ .../generate_result@add_column_migration.snap | 5 + ...nerate_result@add_reference_migration.snap | 5 + ...te_result@create_join_table_migration.snap | 5 + ...enerate_result@create_table_migration.snap | 5 + .../generate_result@empty_migration.snap | 5 + ...erate_result@remove_columns_migration.snap | 5 + .../generate_results@Api_controller.snap | 6 + .../generate_results@Api_scaffold.snap | 8 + .../generate_results@Html_controller.snap | 7 + .../generate_results@Html_scaffold.snap | 13 + .../generate_results@Htmx_controller.snap | 7 + .../generate_results@Htmx_scaffold.snap | 13 + .../inject[app_rs]@Api_controller.snap | 11 + .../inject[app_rs]@Api_scaffold.snap | 11 + .../inject[app_rs]@Html_controller.snap | 11 + .../inject[app_rs]@Html_scaffold.snap | 11 + .../inject[app_rs]@Htmx_controller.snap | 11 + .../inject[app_rs]@Htmx_scaffold.snap | 11 + .../snapshots/inject[app_rs]@task.snap | 10 + .../snapshots/inject[app_rs]@worker.snap | 9 + ...ect[controller_mod_rs]@Api_controller.snap | 5 + ...nject[controller_mod_rs]@Api_scaffold.snap | 5 + ...ct[controller_mod_rs]@Html_controller.snap | 5 + ...ject[controller_mod_rs]@Html_scaffold.snap | 5 + ...ct[controller_mod_rs]@Htmx_controller.snap | 5 + ...ject[controller_mod_rs]@Htmx_scaffold.snap | 5 + .../inject[mailer_mod_rs]@mailer.snap | 5 + .../inject[migration_lib]@Api_scaffold.snap | 22 ++ .../inject[migration_lib]@Html_scaffold.snap | 22 ++ .../inject[migration_lib]@Htmx_scaffold.snap | 22 ++ ...t[migration_lib]@add_column_migration.snap | 22 ++ ...igration_lib]@add_reference_migration.snap | 22 ++ ...tion_lib]@create_join_table_migration.snap | 22 ++ ...migration_lib]@create_table_migration.snap | 22 ++ ...inject[migration_lib]@empty_migration.snap | 22 ++ .../inject[migration_lib]@model.snap | 22 ++ ...gration_lib]@remove_columns_migration.snap | 22 ++ .../snapshots/inject[task_mod_rs]@task.snap | 5 + .../inject[test_mod]@Api_scaffold.snap | 5 + .../inject[test_mod]@Html_scaffold.snap | 5 + .../inject[test_mod]@Htmx_scaffold.snap | 5 + .../snapshots/inject[test_mod]@model.snap | 5 + ...sts_controller_mod_rs]@Api_controller.snap | 5 + .../inject[tests_task_mod]@task.snap | 5 + .../inject[tests_worker_mod]@worker.snap | 5 + .../inject[views_[GET]]@Html_controller.snap | 17 + .../inject[views_[GET]]@Htmx_controller.snap | 17 + .../inject[views_[POST]]@Html_controller.snap | 17 + .../inject[views_[POST]]@Htmx_controller.snap | 17 + .../inject[views_mod_rs]@Html_scaffold.snap | 5 + .../inject[views_mod_rs]@Htmx_scaffold.snap | 5 + .../inject[worker_mod_rs]@worker.snap | 5 + loco-gen/tests/templates/task.rs | 80 +++++ loco-gen/tests/templates/utils.rs | 62 ++++ loco-gen/tests/templates/worker.rs | 80 +++++ loco-new/Cargo.toml | 2 +- loco-new/base_template/config/test.yaml.t | 2 +- ...ig_test_yaml_config_database_Postgres.snap | 3 +- ...nfig_test_yaml_config_database_Sqlite.snap | 3 +- loco-new/tests/wizard/new.rs | 301 ++++++++++++---- src/cli.rs | 183 ++++++++-- src/logger.rs | 1 + 135 files changed, 4292 insertions(+), 454 deletions(-) create mode 100644 loco-gen/src/template.rs create mode 100644 loco-gen/tests/mod.rs create mode 100644 loco-gen/tests/templates/controller.rs create mode 100644 loco-gen/tests/templates/deployment.rs create mode 100644 loco-gen/tests/templates/mailer.rs create mode 100644 loco-gen/tests/templates/migration.rs create mode 100644 loco-gen/tests/templates/mod.rs create mode 100644 loco-gen/tests/templates/model.rs create mode 100644 loco-gen/tests/templates/scaffold.rs create mode 100644 loco-gen/tests/templates/scheduler.rs create mode 100644 loco-gen/tests/templates/snapshots/generate[controller_file]@Api_controller.snap create mode 100644 loco-gen/tests/templates/snapshots/generate[controller_file]@Api_scaffold.snap create mode 100644 loco-gen/tests/templates/snapshots/generate[controller_file]@Html_controller.snap create mode 100644 loco-gen/tests/templates/snapshots/generate[controller_file]@Html_scaffold.snap create mode 100644 loco-gen/tests/templates/snapshots/generate[controller_file]@Htmx_controller.snap create mode 100644 loco-gen/tests/templates/snapshots/generate[controller_file]@Htmx_scaffold.snap create mode 100644 loco-gen/tests/templates/snapshots/generate[controller_file]@scheduler.snap create mode 100644 loco-gen/tests/templates/snapshots/generate[controller_file]@task.snap create mode 100644 loco-gen/tests/templates/snapshots/generate[controller_file]@worker.snap create mode 100644 loco-gen/tests/templates/snapshots/generate[docker_file_[404_html]_[None]]@deployment.snap create mode 100644 loco-gen/tests/templates/snapshots/generate[docker_file_[404_html]_[assets]]@deployment.snap create mode 100644 loco-gen/tests/templates/snapshots/generate[docker_file_[None]_[None]]@deployment.snap create mode 100644 loco-gen/tests/templates/snapshots/generate[docker_file_[None]_[assets]]@deployment.snap create mode 100644 loco-gen/tests/templates/snapshots/generate[html_t_file]@mailer.snap create mode 100644 loco-gen/tests/templates/snapshots/generate[mailer_mod_rs]@mailer.snap create mode 100644 loco-gen/tests/templates/snapshots/generate[migration_file]@Api_scaffold.snap create mode 100644 loco-gen/tests/templates/snapshots/generate[migration_file]@Html_scaffold.snap create mode 100644 loco-gen/tests/templates/snapshots/generate[migration_file]@Htmx_scaffold.snap create mode 100644 loco-gen/tests/templates/snapshots/generate[migration_file]@add_column_migration.snap create mode 100644 loco-gen/tests/templates/snapshots/generate[migration_file]@add_reference_migration.snap create mode 100644 loco-gen/tests/templates/snapshots/generate[migration_file]@create_join_table_migration.snap create mode 100644 loco-gen/tests/templates/snapshots/generate[migration_file]@create_table_migration.snap create mode 100644 loco-gen/tests/templates/snapshots/generate[migration_file]@empty_migration.snap create mode 100644 loco-gen/tests/templates/snapshots/generate[migration_file]@model.snap create mode 100644 loco-gen/tests/templates/snapshots/generate[migration_file]@remove_columns_migration.snap create mode 100644 loco-gen/tests/templates/snapshots/generate[nginx]@deployment.snap create mode 100644 loco-gen/tests/templates/snapshots/generate[subject_t_file]@mailer.snap create mode 100644 loco-gen/tests/templates/snapshots/generate[test_model]@Api_scaffold.snap create mode 100644 loco-gen/tests/templates/snapshots/generate[test_model]@Html_scaffold.snap create mode 100644 loco-gen/tests/templates/snapshots/generate[test_model]@Htmx_scaffold.snap create mode 100644 loco-gen/tests/templates/snapshots/generate[test_model]@model.snap create mode 100644 loco-gen/tests/templates/snapshots/generate[tests_controller_mod_rs]@Api_controller.snap create mode 100644 loco-gen/tests/templates/snapshots/generate[tests_task_file]@task.snap create mode 100644 loco-gen/tests/templates/snapshots/generate[tests_worker_file]@worker.snap create mode 100644 loco-gen/tests/templates/snapshots/generate[text_t_file]@mailer.snap create mode 100644 loco-gen/tests/templates/snapshots/generate[views_[create]]@Html_scaffold.snap create mode 100644 loco-gen/tests/templates/snapshots/generate[views_[create]]@Htmx_scaffold.snap create mode 100644 loco-gen/tests/templates/snapshots/generate[views_[edit]]@Html_scaffold.snap create mode 100644 loco-gen/tests/templates/snapshots/generate[views_[edit]]@Htmx_scaffold.snap create mode 100644 loco-gen/tests/templates/snapshots/generate[views_[list]]@Html_scaffold.snap create mode 100644 loco-gen/tests/templates/snapshots/generate[views_[list]]@Htmx_scaffold.snap create mode 100644 loco-gen/tests/templates/snapshots/generate[views_[show]]@Html_scaffold.snap create mode 100644 loco-gen/tests/templates/snapshots/generate[views_[show]]@Htmx_scaffold.snap create mode 100644 loco-gen/tests/templates/snapshots/generate[views_rs]@Html_scaffold.snap create mode 100644 loco-gen/tests/templates/snapshots/generate[views_rs]@Htmx_scaffold.snap create mode 100644 loco-gen/tests/templates/snapshots/generate_result@add_column_migration.snap create mode 100644 loco-gen/tests/templates/snapshots/generate_result@add_reference_migration.snap create mode 100644 loco-gen/tests/templates/snapshots/generate_result@create_join_table_migration.snap create mode 100644 loco-gen/tests/templates/snapshots/generate_result@create_table_migration.snap create mode 100644 loco-gen/tests/templates/snapshots/generate_result@empty_migration.snap create mode 100644 loco-gen/tests/templates/snapshots/generate_result@remove_columns_migration.snap create mode 100644 loco-gen/tests/templates/snapshots/generate_results@Api_controller.snap create mode 100644 loco-gen/tests/templates/snapshots/generate_results@Api_scaffold.snap create mode 100644 loco-gen/tests/templates/snapshots/generate_results@Html_controller.snap create mode 100644 loco-gen/tests/templates/snapshots/generate_results@Html_scaffold.snap create mode 100644 loco-gen/tests/templates/snapshots/generate_results@Htmx_controller.snap create mode 100644 loco-gen/tests/templates/snapshots/generate_results@Htmx_scaffold.snap create mode 100644 loco-gen/tests/templates/snapshots/inject[app_rs]@Api_controller.snap create mode 100644 loco-gen/tests/templates/snapshots/inject[app_rs]@Api_scaffold.snap create mode 100644 loco-gen/tests/templates/snapshots/inject[app_rs]@Html_controller.snap create mode 100644 loco-gen/tests/templates/snapshots/inject[app_rs]@Html_scaffold.snap create mode 100644 loco-gen/tests/templates/snapshots/inject[app_rs]@Htmx_controller.snap create mode 100644 loco-gen/tests/templates/snapshots/inject[app_rs]@Htmx_scaffold.snap create mode 100644 loco-gen/tests/templates/snapshots/inject[app_rs]@task.snap create mode 100644 loco-gen/tests/templates/snapshots/inject[app_rs]@worker.snap create mode 100644 loco-gen/tests/templates/snapshots/inject[controller_mod_rs]@Api_controller.snap create mode 100644 loco-gen/tests/templates/snapshots/inject[controller_mod_rs]@Api_scaffold.snap create mode 100644 loco-gen/tests/templates/snapshots/inject[controller_mod_rs]@Html_controller.snap create mode 100644 loco-gen/tests/templates/snapshots/inject[controller_mod_rs]@Html_scaffold.snap create mode 100644 loco-gen/tests/templates/snapshots/inject[controller_mod_rs]@Htmx_controller.snap create mode 100644 loco-gen/tests/templates/snapshots/inject[controller_mod_rs]@Htmx_scaffold.snap create mode 100644 loco-gen/tests/templates/snapshots/inject[mailer_mod_rs]@mailer.snap create mode 100644 loco-gen/tests/templates/snapshots/inject[migration_lib]@Api_scaffold.snap create mode 100644 loco-gen/tests/templates/snapshots/inject[migration_lib]@Html_scaffold.snap create mode 100644 loco-gen/tests/templates/snapshots/inject[migration_lib]@Htmx_scaffold.snap create mode 100644 loco-gen/tests/templates/snapshots/inject[migration_lib]@add_column_migration.snap create mode 100644 loco-gen/tests/templates/snapshots/inject[migration_lib]@add_reference_migration.snap create mode 100644 loco-gen/tests/templates/snapshots/inject[migration_lib]@create_join_table_migration.snap create mode 100644 loco-gen/tests/templates/snapshots/inject[migration_lib]@create_table_migration.snap create mode 100644 loco-gen/tests/templates/snapshots/inject[migration_lib]@empty_migration.snap create mode 100644 loco-gen/tests/templates/snapshots/inject[migration_lib]@model.snap create mode 100644 loco-gen/tests/templates/snapshots/inject[migration_lib]@remove_columns_migration.snap create mode 100644 loco-gen/tests/templates/snapshots/inject[task_mod_rs]@task.snap create mode 100644 loco-gen/tests/templates/snapshots/inject[test_mod]@Api_scaffold.snap create mode 100644 loco-gen/tests/templates/snapshots/inject[test_mod]@Html_scaffold.snap create mode 100644 loco-gen/tests/templates/snapshots/inject[test_mod]@Htmx_scaffold.snap create mode 100644 loco-gen/tests/templates/snapshots/inject[test_mod]@model.snap create mode 100644 loco-gen/tests/templates/snapshots/inject[tests_controller_mod_rs]@Api_controller.snap create mode 100644 loco-gen/tests/templates/snapshots/inject[tests_task_mod]@task.snap create mode 100644 loco-gen/tests/templates/snapshots/inject[tests_worker_mod]@worker.snap create mode 100644 loco-gen/tests/templates/snapshots/inject[views_[GET]]@Html_controller.snap create mode 100644 loco-gen/tests/templates/snapshots/inject[views_[GET]]@Htmx_controller.snap create mode 100644 loco-gen/tests/templates/snapshots/inject[views_[POST]]@Html_controller.snap create mode 100644 loco-gen/tests/templates/snapshots/inject[views_[POST]]@Htmx_controller.snap create mode 100644 loco-gen/tests/templates/snapshots/inject[views_mod_rs]@Html_scaffold.snap create mode 100644 loco-gen/tests/templates/snapshots/inject[views_mod_rs]@Htmx_scaffold.snap create mode 100644 loco-gen/tests/templates/snapshots/inject[worker_mod_rs]@worker.snap create mode 100644 loco-gen/tests/templates/task.rs create mode 100644 loco-gen/tests/templates/utils.rs create mode 100644 loco-gen/tests/templates/worker.rs diff --git a/.github/workflows/loco-gen-e2e.yml b/.github/workflows/loco-gen-e2e.yml index 74b2fe97d..1a2d577aa 100644 --- a/.github/workflows/loco-gen-e2e.yml +++ b/.github/workflows/loco-gen-e2e.yml @@ -104,10 +104,9 @@ jobs: DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres_test - name: docker deployment - run: cargo run -- generate deployment + run: cargo run -- generate deployment --kind docker working-directory: ./examples/demo env: - LOCO_DEPLOYMENT_KIND: docker REDIS_URL: redis://localhost:${{job.services.redis.ports[6379]}} DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres_test diff --git a/.github/workflows/loco-new.yml b/.github/workflows/loco-new.yml index 4db13adee..1e23c6d73 100644 --- a/.github/workflows/loco-new.yml +++ b/.github/workflows/loco-new.yml @@ -6,9 +6,11 @@ on: - master paths: - "loco-new/**" + - "loco-gen/**" pull_request: paths: - "loco-new/**" + - "loco-gen/**" env: RUST_TOOLCHAIN: stable diff --git a/Cargo.toml b/Cargo.toml index be586f6e0..ff9bf118d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,7 +54,7 @@ backtrace_printer = { version = "1.3.0" } # cli clap = { version = "4.4.7", features = ["derive"], optional = true } -colored = "2" +colored = { workspace = true } reqwest = { version = "0.12.7", features = [ "charset", "http2", @@ -155,7 +155,7 @@ bb8 = { version = "0.8.1", optional = true } scraper = { version = "0.21.0", optional = true } [workspace.dependencies] - +colored = { version = "2" } chrono = { version = "0.4", features = ["serde"] } tracing = "0.1.40" regex = "1" diff --git a/examples/demo/tests/cmd/cli.trycmd b/examples/demo/tests/cmd/cli.trycmd index 6eeff130a..fa5de9471 100644 --- a/examples/demo/tests/cmd/cli.trycmd +++ b/examples/demo/tests/cmd/cli.trycmd @@ -68,6 +68,7 @@ Commands: worker Generate worker mailer Generate mailer deployment Generate a deployment infrastructure + override Override templates and allows you to take control of them. You can always go back when deleting the local template help Print this message or the help of the given subcommand(s) Options: diff --git a/loco-gen/Cargo.toml b/loco-gen/Cargo.toml index 56b7583e7..0c4c4003a 100644 --- a/loco-gen/Cargo.toml +++ b/loco-gen/Cargo.toml @@ -15,18 +15,21 @@ path = "src/lib.rs" [dependencies] cruet = "0.14.0" -rrgen = "0.5.3" +rrgen = "0.5.5" serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } regex = { workspace = true } tracing = { workspace = true } chrono = { workspace = true } +colored = { workspace = true } clap = { version = "4.4.7", features = ["derive"] } -dialoguer = "0.11" duct = "0.13" +include_dir = { version = "0.7.4" } [dev-dependencies] tree-fs = { version = "0.2.1" } syn = { version = "2", features = ["full"] } +insta = { version = "1.34.0", features = ["redactions", "yaml", "filters"] } +rstest = "0.23.0" diff --git a/loco-gen/src/controller.rs b/loco-gen/src/controller.rs index 892b443e2..189ad7fb6 100644 --- a/loco-gen/src/controller.rs +++ b/loco-gen/src/controller.rs @@ -1,18 +1,8 @@ +use super::{AppInfo, GenerateResults, Result}; +use crate as gen; use rrgen::RRgen; use serde_json::json; - -use crate as gen; - -const API_CONTROLLER_CONTROLLER_T: &str = include_str!("templates/controller/api/controller.t"); -const API_CONTROLLER_TEST_T: &str = include_str!("templates/controller/api/test.t"); - -const HTMX_CONTROLLER_CONTROLLER_T: &str = include_str!("templates/controller/htmx/controller.t"); -const HTMX_VIEW_T: &str = include_str!("templates/controller/htmx/view.t"); - -const HTML_CONTROLLER_CONTROLLER_T: &str = include_str!("templates/controller/html/controller.t"); -const HTML_VIEW_T: &str = include_str!("templates/controller/html/view.t"); - -use super::{collect_messages, AppInfo, Result}; +use std::path::Path; pub fn generate( rrgen: &RRgen, @@ -20,34 +10,31 @@ pub fn generate( actions: &[String], kind: &gen::ScaffoldKind, appinfo: &AppInfo, -) -> Result { +) -> Result { let vars = json!({"name": name, "actions": actions, "pkg_name": appinfo.app_name}); match kind { - gen::ScaffoldKind::Api => { - let res1 = rrgen.generate(API_CONTROLLER_CONTROLLER_T, &vars)?; - let res2 = rrgen.generate(API_CONTROLLER_TEST_T, &vars)?; - let messages = collect_messages(vec![res1, res2]); - Ok(messages) - } + gen::ScaffoldKind::Api => gen::render_template(rrgen, Path::new("controller/api"), &vars), gen::ScaffoldKind::Html => { - let mut messages = Vec::new(); - let res = rrgen.generate(HTML_CONTROLLER_CONTROLLER_T, &vars)?; - messages.push(res); + let mut gen_result = + gen::render_template(rrgen, Path::new("controller/html/controller.t"), &vars)?; for action in actions { let vars = json!({"name": name, "action": action, "pkg_name": appinfo.app_name}); - messages.push(rrgen.generate(HTML_VIEW_T, &vars)?); + let res = gen::render_template(rrgen, Path::new("controller/html/view.t"), &vars)?; + gen_result.rrgen.extend(res.rrgen); + gen_result.local_templates.extend(res.local_templates); } - Ok(collect_messages(messages)) + Ok(gen_result) } gen::ScaffoldKind::Htmx => { - let mut messages = Vec::new(); - let res = rrgen.generate(HTMX_CONTROLLER_CONTROLLER_T, &vars)?; - messages.push(res); + let mut gen_result = + gen::render_template(rrgen, Path::new("controller/htmx/controller.t"), &vars)?; for action in actions { let vars = json!({"name": name, "action": action, "pkg_name": appinfo.app_name}); - messages.push(rrgen.generate(HTMX_VIEW_T, &vars)?); + let res = gen::render_template(rrgen, Path::new("controller/htmx/view.t"), &vars)?; + gen_result.rrgen.extend(res.rrgen); + gen_result.local_templates.extend(res.local_templates); } - Ok(collect_messages(messages)) + Ok(gen_result) } } } diff --git a/loco-gen/src/lib.rs b/loco-gen/src/lib.rs index 0a1f20cf3..d62e1246d 100644 --- a/loco-gen/src/lib.rs +++ b/loco-gen/src/lib.rs @@ -2,11 +2,18 @@ // TODO: should be more properly aligned with extracting out the db-related gen // code and then feature toggling it #![allow(dead_code)] -use rrgen::{GenResult, RRgen}; +pub use rrgen::{GenResult, RRgen}; use serde::{Deserialize, Serialize}; -use serde_json::json; - +use serde_json::{json, Value}; mod controller; +use colored::Colorize; +use std::{ + fs, + path::{Path, PathBuf}, + str::FromStr, + sync::OnceLock, +}; + #[cfg(feature = "with-db")] mod infer; #[cfg(feature = "with-db")] @@ -15,30 +22,15 @@ mod migration; mod model; #[cfg(feature = "with-db")] mod scaffold; +pub mod template; #[cfg(test)] mod testutil; -use std::{str::FromStr, sync::OnceLock}; - -const MAILER_T: &str = include_str!("templates/mailer/mailer.t"); -const MAILER_SUB_T: &str = include_str!("templates/mailer/subject.t"); -const MAILER_TEXT_T: &str = include_str!("templates/mailer/text.t"); -const MAILER_HTML_T: &str = include_str!("templates/mailer/html.t"); - -const TASK_T: &str = include_str!("templates/task/task.t"); -const TASK_TEST_T: &str = include_str!("templates/task/test.t"); - -const SCHEDULER_T: &str = include_str!("templates/scheduler/scheduler.t"); - -const WORKER_T: &str = include_str!("templates/worker/worker.t"); -const WORKER_TEST_T: &str = include_str!("templates/worker/test.t"); - -// Deployment templates -const DEPLOYMENT_DOCKER_T: &str = include_str!("templates/deployment/docker/docker.t"); -const DEPLOYMENT_DOCKER_IGNORE_T: &str = include_str!("templates/deployment/docker/ignore.t"); -const DEPLOYMENT_SHUTTLE_T: &str = include_str!("templates/deployment/shuttle/shuttle.t"); -const DEPLOYMENT_SHUTTLE_CONFIG_T: &str = include_str!("templates/deployment/shuttle/config.t"); -const DEPLOYMENT_NGINX_T: &str = include_str!("templates/deployment/nginx/nginx.t"); +#[derive(Debug)] +pub struct GenerateResults { + rrgen: Vec, + local_templates: Vec, +} const DEPLOYMENT_SHUTTLE_RUNTIME_VERSION: &str = "0.46.0"; const DEPLOYMENT_OPTIONS: &[(&str, DeploymentKind)] = &[ @@ -51,6 +43,8 @@ const DEPLOYMENT_OPTIONS: &[(&str, DeploymentKind)] = &[ pub enum Error { #[error("{0}")] Message(String), + #[error("template {} not found", path.display())] + TemplateNotFound { path: PathBuf }, #[error(transparent)] RRgen(#[from] rrgen::Error), #[error(transparent)] @@ -123,7 +117,7 @@ pub enum ScaffoldKind { Htmx, } -#[derive(Debug, Clone)] +#[derive(clap::ValueEnum, Debug, Clone)] pub enum DeploymentKind { Docker, Shuttle, @@ -136,6 +130,7 @@ impl FromStr for DeploymentKind { match s.to_lowercase().as_str() { "docker" => Ok(Self::Docker), "shuttle" => Ok(Self::Shuttle), + "nginx" => Ok(Self::Nginx), _ => Err(()), } } @@ -197,6 +192,7 @@ pub enum Component { name: String, }, Deployment { + kind: DeploymentKind, fallback_file: Option, asset_folder: Option, host: String, @@ -212,9 +208,7 @@ pub struct AppInfo { /// # Errors /// /// This function will return an error if it fails -#[allow(clippy::too_many_lines)] -pub fn generate(component: Component, appinfo: &AppInfo) -> Result<()> { - let rrgen = RRgen::default(); +pub fn generate(rrgen: &RRgen, component: Component, appinfo: &AppInfo) -> Result { /* (1) XXX: remove hooks generic from child generator, materialize it here and pass it @@ -222,106 +216,114 @@ pub fn generate(component: Component, appinfo: &AppInfo) -> Result<()> { this will allow us to test without an app instance (2) proceed to test individual generators */ - match component { + let get_result = match component { #[cfg(feature = "with-db")] Component::Model { name, link, fields } => { - println!( - "{}", - model::generate(&rrgen, &name, link, &fields, appinfo)? - ); + model::generate(rrgen, &name, link, &fields, appinfo)? } #[cfg(feature = "with-db")] Component::Scaffold { name, fields, kind } => { - println!( - "{}", - scaffold::generate(&rrgen, &name, &fields, &kind, appinfo)? - ); + scaffold::generate(rrgen, &name, &fields, &kind, appinfo)? } #[cfg(feature = "with-db")] Component::Migration { name, fields } => { - migration::generate(&rrgen, &name, &fields, appinfo)?; + migration::generate(rrgen, &name, &fields, appinfo)? } Component::Controller { name, actions, kind, - } => { - println!( - "{}", - controller::generate(&rrgen, &name, &actions, &kind, appinfo)? - ); - } + } => controller::generate(rrgen, &name, &actions, &kind, appinfo)?, Component::Task { name } => { let vars = json!({"name": name, "pkg_name": appinfo.app_name}); - rrgen.generate(TASK_T, &vars)?; - rrgen.generate(TASK_TEST_T, &vars)?; + render_template(rrgen, Path::new("task"), &vars)? } Component::Scheduler {} => { let vars = json!({"pkg_name": appinfo.app_name}); - rrgen.generate(SCHEDULER_T, &vars)?; + render_template(rrgen, Path::new("scheduler"), &vars)? } Component::Worker { name } => { let vars = json!({"name": name, "pkg_name": appinfo.app_name}); - rrgen.generate(WORKER_T, &vars)?; - rrgen.generate(WORKER_TEST_T, &vars)?; + render_template(rrgen, Path::new("worker"), &vars)? } Component::Mailer { name } => { let vars = json!({ "name": name }); - rrgen.generate(MAILER_T, &vars)?; - rrgen.generate(MAILER_SUB_T, &vars)?; - rrgen.generate(MAILER_TEXT_T, &vars)?; - rrgen.generate(MAILER_HTML_T, &vars)?; + render_template(rrgen, Path::new("mailer"), &vars)? } Component::Deployment { + kind, fallback_file, asset_folder, host, port, - } => { - let deployment_kind = match std::env::var("LOCO_DEPLOYMENT_KIND") { - Ok(kind) => kind - .parse::() - .map_err(|_e| Error::Message(format!("deployment {kind} not supported")))?, - Err(_err) => prompt_deployment_selection().map_err(Box::from)?, - }; - - match deployment_kind { - DeploymentKind::Docker => { - let vars = json!({ - "pkg_name": appinfo.app_name, - "copy_asset_folder": asset_folder.unwrap_or_default(), - "fallback_file": fallback_file.unwrap_or_default() - }); - rrgen.generate(DEPLOYMENT_DOCKER_T, &vars)?; - rrgen.generate(DEPLOYMENT_DOCKER_IGNORE_T, &vars)?; - } - DeploymentKind::Shuttle => { - let vars = json!({ - "pkg_name": appinfo.app_name, - "shuttle_runtime_version": DEPLOYMENT_SHUTTLE_RUNTIME_VERSION, - "with_db": cfg!(feature = "with-db") - }); - rrgen.generate(DEPLOYMENT_SHUTTLE_T, &vars)?; - rrgen.generate(DEPLOYMENT_SHUTTLE_CONFIG_T, &vars)?; - } - DeploymentKind::Nginx => { - let host = host.replace("http://", "").replace("https://", ""); - let vars = json!({ - "pkg_name": appinfo.app_name, - "domain": host, - "port": port - }); - rrgen.generate(DEPLOYMENT_NGINX_T, &vars)?; - } + } => match kind { + DeploymentKind::Docker => { + let vars = json!({ + "pkg_name": appinfo.app_name, + "copy_asset_folder": asset_folder.unwrap_or_default(), + "fallback_file": fallback_file.unwrap_or_default() + }); + render_template(rrgen, Path::new("deployment/docker"), &vars)? } - } + DeploymentKind::Shuttle => { + let vars = json!({ + "pkg_name": appinfo.app_name, + "shuttle_runtime_version": DEPLOYMENT_SHUTTLE_RUNTIME_VERSION, + "with_db": cfg!(feature = "with-db") + }); + + render_template(rrgen, Path::new("deployment/shuttle"), &vars)? + } + DeploymentKind::Nginx => { + let host = host.replace("http://", "").replace("https://", ""); + let vars = json!({ + "pkg_name": appinfo.app_name, + "domain": host, + "port": port + }); + render_template(rrgen, Path::new("deployment/nginx"), &vars)? + } + }, + }; + + Ok(get_result) +} + +fn render_template(rrgen: &RRgen, template: &Path, vars: &Value) -> Result { + let template_files = template::collect_files_from_path(template)?; + + let mut gen_result = vec![]; + let mut local_templates = vec![]; + for template in template_files { + let custom_template = Path::new(template::DEFAULT_LOCAL_TEMPLATE).join(template.path()); + + if custom_template.exists() { + let content = fs::read_to_string(&custom_template).map_err(|err| { + tracing::error!(custom_template = %custom_template.display(), "could not read custom template"); + err + })?; + gen_result.push(rrgen.generate(&content, vars)?); + local_templates.push(custom_template); + } else { + let content = template.contents_utf8().ok_or(Error::Message(format!( + "could not get template content: {}", + template.path().display() + )))?; + gen_result.push(rrgen.generate(content, vars)?); + }; } - Ok(()) + + Ok(GenerateResults { + rrgen: gen_result, + local_templates, + }) } -fn collect_messages(results: Vec) -> String { +#[must_use] +pub fn collect_messages(results: &GenerateResults) -> String { let mut messages = String::new(); - for res in results { + + for res in &results.rrgen { if let rrgen::GenResult::Generated { message: Some(message), } = res @@ -329,19 +331,140 @@ fn collect_messages(results: Vec) -> String { messages.push_str(&format!("* {message}\n")); } } + + if !results.local_templates.is_empty() { + messages.push_str(&format!( + "{}", + "\nThe following templates were sourced from the local templates:\n".green() + )); + for f in &results.local_templates { + messages.push_str(&format!("* {}\n", f.display())); + } + } messages } -use dialoguer::{theme::ColorfulTheme, Select}; -fn prompt_deployment_selection() -> Result { - let options: Vec = DEPLOYMENT_OPTIONS.iter().map(|t| t.0.to_string()).collect(); +/// Copies template files to a specified destination directory. +/// +/// This function copies files from the specified template path to the destination directory. +/// If the specified path is `/` or `.`, it copies all files from the templates directory. +/// If the path does not exist in the templates, it returns an error. +/// +/// # Errors +/// when could not copy the given template path +pub fn copy_template(path: &Path, to: &Path) -> Result> { + let copy_template_path = if path == Path::new("/") || path == Path::new(".") { + None + } else if !template::exists(path) { + return Err(Error::TemplateNotFound { + path: path.to_path_buf(), + }); + } else { + Some(path) + }; + + let copy_files = if let Some(path) = copy_template_path { + template::collect_files_from_path(path)? + } else { + template::collect_files() + }; + + let mut copied_files = vec![]; + for f in copy_files { + let copy_to = to.join(f.path()); + if copy_to.exists() { + tracing::debug!( + template_file = %copy_to.display(), + "skipping copy template file. already exists" + ); + continue; + } + match copy_to.parent() { + Some(parent) => { + fs::create_dir_all(parent)?; + } + None => { + return Err(Error::Message(format!( + "could not get parent folder of {}", + copy_to.display() + ))) + } + } + + fs::write(©_to, f.contents())?; + tracing::trace!( + template = %copy_to.display(), + "copy template successfully" + ); + copied_files.push(copy_to); + } + Ok(copied_files) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + #[test] + fn test_template_not_found() { + let tree_fs = tree_fs::TreeBuilder::default() + .drop(true) + .create() + .expect("create temp file"); + let path = Path::new("nonexistent-template"); + + let result = copy_template(path, tree_fs.root.as_path()); + assert!(result.is_err()); + if let Err(Error::TemplateNotFound { path: p }) = result { + assert_eq!(p, path.to_path_buf()); + } else { + panic!("Expected TemplateNotFound error"); + } + } + + #[test] + fn test_copy_template_valid_folder_template() { + let temp_fs = tree_fs::TreeBuilder::default() + .drop(true) + .create() + .expect("Failed to create temporary file system"); + + let template_dir = template::tests::find_first_dir(); + + let copy_result = copy_template(template_dir.path(), temp_fs.root.as_path()); + assert!( + copy_result.is_ok(), + "Failed to copy template from directory {:?}", + template_dir.path() + ); + + let template_files = template::collect_files_from_path(template_dir.path()) + .expect("Failed to collect files from the template directory"); + + assert!( + !template_files.is_empty(), + "No files found in the template directory" + ); - let selection = Select::with_theme(&ColorfulTheme::default()) - .with_prompt("❯ Choose your deployment") - .items(&options) - .default(0) - .interact() - .map_err(Error::msg)?; + for template_file in template_files { + let copy_file_path = temp_fs.root.join(template_file.path()); - Ok(DEPLOYMENT_OPTIONS[selection].1.clone()) + assert!( + copy_file_path.exists(), + "Copy file does not exist: {copy_file_path:?}" + ); + + let copy_content = + fs::read_to_string(©_file_path).expect("Failed to read coped file content"); + + assert_eq!( + template_file + .contents_utf8() + .expect("Failed to get template file content"), + copy_content, + "Content mismatch in file: {copy_file_path:?}" + ); + } + } } diff --git a/loco-gen/src/migration.rs b/loco-gen/src/migration.rs index f53918c71..71f2b4e57 100644 --- a/loco-gen/src/migration.rs +++ b/loco-gen/src/migration.rs @@ -1,20 +1,10 @@ +use crate::{ + infer, model::get_columns_and_references, render_template, AppInfo, GenerateResults, Result, +}; use chrono::Utc; use rrgen::RRgen; use serde_json::json; - -use super::Result; -use crate::{ - infer, - model::{get_columns_and_references, MODEL_T}, -}; - -const MIGRATION_T: &str = include_str!("templates/migration/empty.t"); -const ADD_COLS_T: &str = include_str!("templates/migration/add_columns.t"); -const ADD_REFS_T: &str = include_str!("templates/migration/add_references.t"); -const REMOVE_COLS_T: &str = include_str!("templates/migration/remove_columns.t"); -const JOIN_TABLE_T: &str = include_str!("templates/migration/join_table.t"); - -use super::{collect_messages, AppInfo}; +use std::path::Path; /// skipping some fields from the generated models. /// For example, the `created_at` and `updated_at` fields are automatically @@ -26,32 +16,32 @@ pub fn generate( name: &str, fields: &[(String, String)], appinfo: &AppInfo, -) -> Result { +) -> Result { let pkg_name: &str = &appinfo.app_name; let ts = Utc::now(); let res = infer::guess_migration_type(name); - let migration_gen = match res { + match res { // NOTE: re-uses the 'new model' migration template! infer::MigrationType::CreateTable { table } => { let (columns, references) = get_columns_and_references(fields)?; let vars = json!({"name": table, "ts": ts, "pkg_name": pkg_name, "is_link": false, "columns": columns, "references": references}); - rrgen.generate(MODEL_T, &vars)? + render_template(rrgen, Path::new("model/model.t"), &vars) } infer::MigrationType::AddColumns { table } => { let (columns, references) = get_columns_and_references(fields)?; let vars = json!({"name": name, "table": table, "ts": ts, "pkg_name": pkg_name, "is_link": false, "columns": columns, "references": references}); - rrgen.generate(ADD_COLS_T, &vars)? + render_template(rrgen, Path::new("migration/add_columns.t"), &vars) } infer::MigrationType::RemoveColumns { table } => { let (columns, _references) = get_columns_and_references(fields)?; let vars = json!({"name": name, "table": table, "ts": ts, "pkg_name": pkg_name, "columns": columns}); - rrgen.generate(REMOVE_COLS_T, &vars)? + render_template(rrgen, Path::new("migration/remove_columns.t"), &vars) } infer::MigrationType::AddReference { table } => { let (columns, references) = get_columns_and_references(fields)?; let vars = json!({"name": name, "table": table, "ts": ts, "pkg_name": pkg_name, "columns": columns, "references": references}); - rrgen.generate(ADD_REFS_T, &vars)? + render_template(rrgen, Path::new("migration/add_references.t"), &vars) } infer::MigrationType::CreateJoinTable { table_a, table_b } => { let mut tables = [table_a.clone(), table_b.clone()]; @@ -62,14 +52,11 @@ pub fn generate( (table_b, "references".to_string()), ])?; let vars = json!({"name": name, "table": table, "ts": ts, "pkg_name": pkg_name, "columns": columns, "references": references}); - rrgen.generate(JOIN_TABLE_T, &vars)? + render_template(rrgen, Path::new("migration/join_table.t"), &vars) } infer::MigrationType::Empty => { let vars = json!({"name": name, "ts": ts, "pkg_name": pkg_name}); - rrgen.generate(MIGRATION_T, &vars)? + render_template(rrgen, Path::new("migration/empty.t"), &vars) } - }; - - let messages = collect_messages(vec![migration_gen]); - Ok(messages) + } } diff --git a/loco-gen/src/model.rs b/loco-gen/src/model.rs index 238feef03..20355db8e 100644 --- a/loco-gen/src/model.rs +++ b/loco-gen/src/model.rs @@ -1,17 +1,10 @@ -use std::{collections::HashMap, env::current_dir}; - +use crate::{get_mappings, render_template, AppInfo, Error, GenerateResults, Result}; use chrono::Utc; use duct::cmd; use rrgen::RRgen; use serde_json::json; - -use super::{Error, Result}; -use crate::get_mappings; - -pub const MODEL_T: &str = include_str!("templates/model/model.t"); -const MODEL_TEST_T: &str = include_str!("templates/model/test.t"); - -use super::{collect_messages, AppInfo}; +use std::path::Path; +use std::{collections::HashMap, env::current_dir}; /// skipping some fields from the generated models. /// For example, the `created_at` and `updated_at` fields are automatically @@ -59,113 +52,48 @@ pub fn get_columns_and_references( } Ok((columns, references)) } + pub fn generate( rrgen: &RRgen, name: &str, is_link: bool, fields: &[(String, String)], appinfo: &AppInfo, -) -> Result { +) -> Result { let pkg_name: &str = &appinfo.app_name; let ts = Utc::now(); let (columns, references) = get_columns_and_references(fields)?; let vars = json!({"name": name, "ts": ts, "pkg_name": pkg_name, "is_link": is_link, "columns": columns, "references": references}); - let res1 = rrgen.generate(MODEL_T, &vars)?; - let res2 = rrgen.generate(MODEL_TEST_T, &vars)?; - - // generate the model files by migrating and re-running seaorm - let cwd = current_dir()?; - let env_map: HashMap<_, _> = std::env::vars().collect(); - - let _ = cmd!("cargo", "loco-tool", "db", "migrate",) - .stderr_to_stdout() - .dir(cwd.as_path()) - .full_env(&env_map) - .run() - .map_err(|err| { - Error::Message(format!( - "failed to run loco db migration. error details: `{err}`", - )) - })?; - let _ = cmd!("cargo", "loco-tool", "db", "entities",) - .stderr_to_stdout() - .dir(cwd.as_path()) - .full_env(&env_map) - .run() - .map_err(|err| { - Error::Message(format!( - "failed to run loco db entities. error details: `{err}`", - )) - })?; - - let messages = collect_messages(vec![res1, res2]); - Ok(messages) -} - -#[cfg(test)] -mod tests { - use std::{env, process::Command}; - - use crate::{ - testutil::{self, assert_cargo_check, assert_file, assert_single_file_match}, - AppInfo, - }; - - fn with_new_app(app_name: &str, f: F) - where - F: FnOnce(), - { - testutil::with_temp_dir(|_previous, current| { - let status = Command::new("loco") - .args([ - "new", - "-n", - app_name, - "--db", - "sqlite", - "--bg", - "async", - "--assets", - "serverside", - "-a", - ]) - .status() - .expect("cannot run command"); - - assert!(status.success(), "Command failed: loco new -n {app_name}"); - env::set_current_dir(current.join(app_name)) - .expect("Failed to change directory to app"); - f(); // Execute the provided closure - }) - .expect("temp dir setup"); + let gen_result = render_template(rrgen, Path::new("model"), &vars)?; + + if std::env::var("SKIP_MIGRATION").is_err() { + // generate the model files by migrating and re-running seaorm + let cwd = current_dir()?; + let env_map: HashMap<_, _> = std::env::vars().collect(); + + let _ = cmd!("cargo", "loco-tool", "db", "migrate",) + .stderr_to_stdout() + .dir(cwd.as_path()) + .full_env(&env_map) + .run() + .map_err(|err| { + Error::Message(format!( + "failed to run loco db migration. error details: `{err}`", + )) + })?; + let _ = cmd!("cargo", "loco-tool", "db", "entities",) + .stderr_to_stdout() + .dir(cwd.as_path()) + .full_env(&env_map) + .run() + .map_err(|err| { + Error::Message(format!( + "failed to run loco db entities. error details: `{err}`", + )) + })?; } - #[test] - fn test_can_generate_model() { - let rrgen = rrgen::RRgen::default(); - with_new_app("saas", || { - super::generate( - &rrgen, - "movies", - false, - &[("title".to_string(), "string".to_string())], - &AppInfo { - app_name: "saas".to_string(), - }, - ) - .expect("generate"); - assert_file("migration/src/lib.rs", |content| { - content.assert_syntax(); - content.assert_regex_match("_movies::Migration"); - }); - let migration = assert_single_file_match("migration/src", ".*_movies.rs$"); - assert_file(migration.to_str().unwrap(), |content| { - content.assert_syntax(); - content.assert_regex_match("Title"); - }); - assert_cargo_check(); - }); - } + Ok(gen_result) } diff --git a/loco-gen/src/scaffold.rs b/loco-gen/src/scaffold.rs index b56f230d3..987b4f7c7 100644 --- a/loco-gen/src/scaffold.rs +++ b/loco-gen/src/scaffold.rs @@ -1,45 +1,26 @@ +use crate::{ + get_mappings, model, render_template, AppInfo, Error, GenerateResults, Result, ScaffoldKind, +}; use rrgen::RRgen; use serde_json::json; - -use crate::{self as gen, get_mappings}; - -const API_CONTROLLER_SCAFFOLD_T: &str = include_str!("templates/scaffold/api/controller.t"); -const API_CONTROLLER_TEST_T: &str = include_str!("templates/scaffold/api/test.t"); - -const HTMX_CONTROLLER_SCAFFOLD_T: &str = include_str!("templates/scaffold/htmx/controller.t"); -const HTMX_BASE_SCAFFOLD_T: &str = include_str!("templates/scaffold/htmx/base.t"); -const HTMX_VIEW_SCAFFOLD_T: &str = include_str!("templates/scaffold/htmx/view.t"); -const HTMX_VIEW_EDIT_SCAFFOLD_T: &str = include_str!("templates/scaffold/htmx/view_edit.t"); -const HTMX_VIEW_CREATE_SCAFFOLD_T: &str = include_str!("templates/scaffold/htmx/view_create.t"); -const HTMX_VIEW_SHOW_SCAFFOLD_T: &str = include_str!("templates/scaffold/htmx/view_show.t"); -const HTMX_VIEW_LIST_SCAFFOLD_T: &str = include_str!("templates/scaffold/htmx/view_list.t"); - -const HTML_CONTROLLER_SCAFFOLD_T: &str = include_str!("templates/scaffold/html/controller.t"); -const HTML_BASE_SCAFFOLD_T: &str = include_str!("templates/scaffold/html/base.t"); -const HTML_VIEW_SCAFFOLD_T: &str = include_str!("templates/scaffold/html/view.t"); -const HTML_VIEW_EDIT_SCAFFOLD_T: &str = include_str!("templates/scaffold/html/view_edit.t"); -const HTML_VIEW_CREATE_SCAFFOLD_T: &str = include_str!("templates/scaffold/html/view_create.t"); -const HTML_VIEW_SHOW_SCAFFOLD_T: &str = include_str!("templates/scaffold/html/view_show.t"); -const HTML_VIEW_LIST_SCAFFOLD_T: &str = include_str!("templates/scaffold/html/view_list.t"); - -use super::{collect_messages, model, AppInfo, Error, Result}; +use std::path::Path; pub fn generate( rrgen: &RRgen, name: &str, fields: &[(String, String)], - kind: &gen::ScaffoldKind, + kind: &ScaffoldKind, appinfo: &AppInfo, -) -> Result { +) -> Result { // - scaffold is never a link table // - never run with migration_only, because the controllers will refer to the // models. the models only arrive after migration and entities sync. - let model_messages = model::generate(rrgen, name, false, fields, appinfo)?; + let mut gen_result = model::generate(rrgen, name, false, fields, appinfo)?; let mappings = get_mappings(); let mut columns = Vec::new(); for (fname, ftype) in fields { - if gen::model::IGNORE_FIELDS.contains(&fname.as_str()) { + if model::IGNORE_FIELDS.contains(&fname.as_str()) { tracing::warn!( field = fname, "note that a redundant field was specified, it is already generated automatically" @@ -59,31 +40,21 @@ pub fn generate( } let vars = json!({"name": name, "columns": columns, "pkg_name": appinfo.app_name}); match kind { - gen::ScaffoldKind::Api => { - let res1 = rrgen.generate(API_CONTROLLER_SCAFFOLD_T, &vars)?; - let res2 = rrgen.generate(API_CONTROLLER_TEST_T, &vars)?; - let messages = collect_messages(vec![res1, res2]); - Ok(format!("{model_messages}{messages}")) + ScaffoldKind::Api => { + let res = render_template(rrgen, Path::new("scaffold/api"), &vars)?; + gen_result.rrgen.extend(res.rrgen); + gen_result.local_templates.extend(res.local_templates); } - gen::ScaffoldKind::Html => { - rrgen.generate(HTML_CONTROLLER_SCAFFOLD_T, &vars)?; - rrgen.generate(HTML_BASE_SCAFFOLD_T, &vars)?; - rrgen.generate(HTML_VIEW_EDIT_SCAFFOLD_T, &vars)?; - rrgen.generate(HTML_VIEW_CREATE_SCAFFOLD_T, &vars)?; - rrgen.generate(HTML_VIEW_SHOW_SCAFFOLD_T, &vars)?; - rrgen.generate(HTML_VIEW_LIST_SCAFFOLD_T, &vars)?; - rrgen.generate(HTML_VIEW_SCAFFOLD_T, &vars)?; - Ok(model_messages) + ScaffoldKind::Html => { + let res = render_template(rrgen, Path::new("scaffold/html"), &vars)?; + gen_result.rrgen.extend(res.rrgen); + gen_result.local_templates.extend(res.local_templates); } - gen::ScaffoldKind::Htmx => { - rrgen.generate(HTMX_CONTROLLER_SCAFFOLD_T, &vars)?; - rrgen.generate(HTMX_BASE_SCAFFOLD_T, &vars)?; - rrgen.generate(HTMX_VIEW_EDIT_SCAFFOLD_T, &vars)?; - rrgen.generate(HTMX_VIEW_CREATE_SCAFFOLD_T, &vars)?; - rrgen.generate(HTMX_VIEW_SHOW_SCAFFOLD_T, &vars)?; - rrgen.generate(HTMX_VIEW_LIST_SCAFFOLD_T, &vars)?; - rrgen.generate(HTMX_VIEW_SCAFFOLD_T, &vars)?; - Ok(model_messages) + ScaffoldKind::Htmx => { + let res = render_template(rrgen, Path::new("scaffold/htmx"), &vars)?; + gen_result.rrgen.extend(res.rrgen); + gen_result.local_templates.extend(res.local_templates); } } + Ok(gen_result) } diff --git a/loco-gen/src/template.rs b/loco-gen/src/template.rs new file mode 100644 index 000000000..926bd0613 --- /dev/null +++ b/loco-gen/src/template.rs @@ -0,0 +1,202 @@ +use crate::{Error, Result}; +use include_dir::{include_dir, Dir, DirEntry, File}; +use std::path::{Path, PathBuf}; + +static TEMPLATES: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/src/templates"); +pub const DEFAULT_LOCAL_TEMPLATE: &str = ".loco-templates"; + +/// Returns a list of paths that should be ignored during file collection. +#[must_use] +pub fn get_ignored_paths() -> Vec<&'static Path> { + vec![ + #[cfg(not(feature = "with-db"))] + Path::new("scaffold"), + #[cfg(not(feature = "with-db"))] + Path::new("migration"), + #[cfg(not(feature = "with-db"))] + Path::new("model"), + ] +} + +/// Checks whether a specific path exists in the included templates. +#[must_use] +pub fn exists(path: &Path) -> bool { + TEMPLATES.get_entry(path).is_some() +} + +/// Determines whether a given path should be ignored based on the ignored paths list. +#[must_use] +fn is_path_ignored(path: &Path, ignored_paths: &[&Path]) -> bool { + ignored_paths + .iter() + .any(|&ignored| path.starts_with(ignored)) +} + +/// Collects all file paths from the included templates directory recursively. +#[must_use] +pub fn collect() -> Vec { + collect_files_path_recursively(&TEMPLATES) +} + +/// Collects all files from the included templates directory recursively. +#[must_use] +pub fn collect_files() -> Vec<&'static File<'static>> { + collect_files_recursively(&TEMPLATES) +} + +/// Collects all file paths within a specific directory in the templates. +/// +/// # Errors +/// Returns [`Error::TemplateNotFound`] if the directory is not found. +pub fn collect_files_path(path: &Path) -> Result> { + TEMPLATES.get_entry(path).map_or_else( + || { + Err(Error::TemplateNotFound { + path: path.to_path_buf(), + }) + }, + |entry| match entry { + DirEntry::Dir(dir) => Ok(collect_files_path_recursively(dir)), + DirEntry::File(file) => Ok(vec![file.path().to_path_buf()]), + }, + ) +} + +/// Collects all files within a specific directory in the templates. +/// +/// # Errors +/// Returns [`Error::TemplateNotFound`] if the directory is not found. +pub fn collect_files_from_path(path: &Path) -> Result>> { + TEMPLATES.get_entry(path).map_or_else( + || { + Err(Error::TemplateNotFound { + path: path.to_path_buf(), + }) + }, + |entry| match entry { + DirEntry::Dir(dir) => Ok(collect_files_recursively(dir)), + DirEntry::File(file) => Ok(vec![file]), + }, + ) +} + +/// Recursively collects all file paths from a directory, skipping ignored paths. +fn collect_files_path_recursively(dir: &Dir<'_>) -> Vec { + let mut file_paths = Vec::new(); + + for entry in dir.entries() { + match entry { + DirEntry::File(file) => file_paths.push(file.path().to_path_buf()), + DirEntry::Dir(subdir) => { + if !is_path_ignored(subdir.path(), &get_ignored_paths()) { + file_paths.extend(collect_files_path_recursively(subdir)); + } + } + } + } + file_paths +} + +/// Recursively collects all files from a directory, skipping ignored paths. +fn collect_files_recursively<'a>(dir: &'a Dir<'a>) -> Vec<&'a File<'a>> { + let mut files = Vec::new(); + + for entry in dir.entries() { + match entry { + DirEntry::File(file) => files.push(file), + DirEntry::Dir(subdir) => { + if !is_path_ignored(subdir.path(), &get_ignored_paths()) { + files.extend(collect_files_recursively(subdir)); + } + } + } + } + files +} + +#[cfg(test)] +pub mod tests { + use super::*; + use std::path::Path; + + pub fn find_first_dir() -> &'static Dir<'static> { + TEMPLATES.dirs().next().expect("first folder") + } + pub fn find_first_file<'a>(dir: &'a Dir<'a>) -> Option<&'a File<'a>> { + for entry in dir.entries() { + match entry { + DirEntry::File(file) => return Some(file), + DirEntry::Dir(sub_dir) => { + if let Some(file) = find_first_file(sub_dir) { + return Some(file); + } + } + } + } + None + } + + #[test] + fn test_get_ignored_paths() { + let ignored_paths = get_ignored_paths(); + #[cfg(not(feature = "with-db"))] + { + assert!(ignored_paths.contains(&Path::new("scaffold"))); + assert!(ignored_paths.contains(&Path::new("migration"))); + assert!(ignored_paths.contains(&Path::new("model"))); + } + #[cfg(feature = "with-db")] + { + assert!(ignored_paths.is_empty()); + } + } + + #[test] + fn test_exists() { + // test existing folder + let test_folder = TEMPLATES.dirs().next().expect("first folder"); + assert!(exists(test_folder.path())); + assert!(!exists(Path::new("none-folder"))); + + // test existing file + let test_file = find_first_file(&TEMPLATES).expect("find file"); + println!("==== {:#?}", test_file.path()); + assert!(exists(test_file.path())); + assert!(!exists(Path::new("none.rs.t"))); + } + + #[test] + fn test_collect() { + let file_paths = collect(); + assert!(!file_paths.is_empty()); + for path in file_paths { + assert!(TEMPLATES.get_entry(&path).is_some()); + } + } + + #[test] + fn test_collect_files() { + let files = collect_files(); + assert!(!files.is_empty()); + for file in files { + assert!(TEMPLATES.get_entry(file.path()).is_some()); + } + } + + #[test] + fn test_is_path_ignored() { + let path = Path::new("/home/user/project/src/main.rs"); + let ignores = vec![ + Path::new("/home/user/project/target"), + Path::new("/home/user/project/src"), + ]; + + assert!(is_path_ignored(path, &ignores)); + + let non_ignored_path = Path::new("/home/user/project/docs/readme.md"); + assert!(!is_path_ignored(non_ignored_path, &ignores)); + + let empty_ignores: &[&Path] = &[]; + assert!(!is_path_ignored(path, empty_ignores)); + } +} diff --git a/loco-gen/src/templates/deployment/docker/docker.t b/loco-gen/src/templates/deployment/docker/docker.t index 8dcbfa877..9d8563595 100644 --- a/loco-gen/src/templates/deployment/docker/docker.t +++ b/loco-gen/src/templates/deployment/docker/docker.t @@ -2,7 +2,7 @@ to: "dockerfile" skip_exists: true message: "Dockerfile generated successfully." --- -FROM rust:1.74-slim as builder +FROM rust:1.83.0-slim as builder WORKDIR /usr/src/ diff --git a/loco-gen/src/templates/mailer/mailer.t b/loco-gen/src/templates/mailer/mailer.t index cd3f1baf8..dbf8f23dd 100644 --- a/loco-gen/src/templates/mailer/mailer.t +++ b/loco-gen/src/templates/mailer/mailer.t @@ -19,6 +19,10 @@ static welcome: Dir<'_> = include_dir!("src/mailers/{{module_name}}/welcome"); pub struct {{struct_name}} {} impl Mailer for {{struct_name}} {} impl {{struct_name}} { + /// Send an email + /// + /// # Errors + /// When email sending is failed pub async fn send_welcome(ctx: &AppContext, to: &str, msg: &str) -> Result<()> { Self::mail_template( ctx, diff --git a/loco-gen/src/templates/migration/add_columns.t b/loco-gen/src/templates/migration/add_columns.t index 4e97fdf81..8fdb85e04 100644 --- a/loco-gen/src/templates/migration/add_columns.t +++ b/loco-gen/src/templates/migration/add_columns.t @@ -22,31 +22,33 @@ pub struct Migration; #[async_trait::async_trait] impl MigrationTrait for Migration { async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + {% for column in columns -%} manager .alter_table( alter({{tbl_enum}}::Table) - {% for column in columns -%} {% if column.1 == "decimal_len_null" or column.1 == "decimal_len" -%} .add_column({{column.1}}({{tbl_enum}}::{{column.0 | pascal_case }}, 16, 4)) {% else -%} .add_column({{column.1}}({{tbl_enum}}::{{column.0 | pascal_case}})) {% endif -%} - {% endfor -%} .to_owned(), ) - .await + .await?; + {% endfor -%} + Ok(()) } async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + {% for column in columns -%} manager .alter_table( alter({{tbl_enum}}::Table) - {% for column in columns -%} .drop_column({{tbl_enum}}::{{column.0 | pascal_case}}) - {% endfor -%} .to_owned() ) - .await + .await?; + {% endfor -%} + Ok(()) } } diff --git a/loco-gen/src/templates/migration/remove_columns.t b/loco-gen/src/templates/migration/remove_columns.t index 1f6f959f4..c23c65864 100644 --- a/loco-gen/src/templates/migration/remove_columns.t +++ b/loco-gen/src/templates/migration/remove_columns.t @@ -22,31 +22,35 @@ pub struct Migration; #[async_trait::async_trait] impl MigrationTrait for Migration { async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + {% for column in columns -%} manager .alter_table( alter({{tbl_enum}}::Table) - {% for column in columns -%} + .drop_column({{tbl_enum}}::{{column.0 | pascal_case}}) - {% endfor -%} + .to_owned(), ) - .await + .await?; + {% endfor -%} + Ok(()) } async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + {% for column in columns -%} manager .alter_table( alter({{tbl_enum}}::Table) - {% for column in columns -%} {% if column.1 == "decimal_len_null" or column.1 == "decimal_len" -%} .add_column({{column.1}}({{tbl_enum}}::{{column.0 | pascal_case }}, 16, 4)) {% else -%} .add_column({{column.1}}({{tbl_enum}}::{{column.0 | pascal_case}})) {% endif -%} - {% endfor -%} .to_owned(), ) - .await + .await?; + {% endfor -%} + Ok(()) } } diff --git a/loco-gen/src/templates/scaffold/html/view.t b/loco-gen/src/templates/scaffold/html/view.t index 932a117b0..fcf150fd4 100644 --- a/loco-gen/src/templates/scaffold/html/view.t +++ b/loco-gen/src/templates/scaffold/html/view.t @@ -12,7 +12,7 @@ use loco_rs::prelude::*; use crate::models::_entities::{{file_name | plural}}; -/// Render a list view of {{name | plural}}. +/// Render a list view of `{{name | plural}}`. /// /// # Errors /// @@ -21,7 +21,7 @@ pub fn list(v: &impl ViewRenderer, items: &Vec<{{file_name | plural}}::Model>) - format::render().view(v, "{{file_name}}/list.html", data!({"items": items})) } -/// Render a single {{name}} view. +/// Render a single `{{name}}` view. /// /// # Errors /// @@ -30,7 +30,7 @@ pub fn show(v: &impl ViewRenderer, item: &{{file_name | plural}}::Model) -> Resu format::render().view(v, "{{file_name}}/show.html", data!({"item": item})) } -/// Render a {{name }} create form. +/// Render a `{{name}}` create form. /// /// # Errors /// @@ -39,7 +39,7 @@ pub fn create(v: &impl ViewRenderer) -> Result { format::render().view(v, "{{file_name}}/create.html", data!({})) } -/// Render a {{name}} edit form. +/// Render a `{{name}}` edit form. /// /// # Errors /// diff --git a/loco-gen/src/templates/scaffold/htmx/view.t b/loco-gen/src/templates/scaffold/htmx/view.t index 932a117b0..fcf150fd4 100644 --- a/loco-gen/src/templates/scaffold/htmx/view.t +++ b/loco-gen/src/templates/scaffold/htmx/view.t @@ -12,7 +12,7 @@ use loco_rs::prelude::*; use crate::models::_entities::{{file_name | plural}}; -/// Render a list view of {{name | plural}}. +/// Render a list view of `{{name | plural}}`. /// /// # Errors /// @@ -21,7 +21,7 @@ pub fn list(v: &impl ViewRenderer, items: &Vec<{{file_name | plural}}::Model>) - format::render().view(v, "{{file_name}}/list.html", data!({"items": items})) } -/// Render a single {{name}} view. +/// Render a single `{{name}}` view. /// /// # Errors /// @@ -30,7 +30,7 @@ pub fn show(v: &impl ViewRenderer, item: &{{file_name | plural}}::Model) -> Resu format::render().view(v, "{{file_name}}/show.html", data!({"item": item})) } -/// Render a {{name }} create form. +/// Render a `{{name}}` create form. /// /// # Errors /// @@ -39,7 +39,7 @@ pub fn create(v: &impl ViewRenderer) -> Result { format::render().view(v, "{{file_name}}/create.html", data!({})) } -/// Render a {{name}} edit form. +/// Render a `{{name}}` edit form. /// /// # Errors /// diff --git a/loco-gen/src/templates/worker/test.t b/loco-gen/src/templates/worker/test.t index 8fc46954c..551a9016a 100644 --- a/loco-gen/src/templates/worker/test.t +++ b/loco-gen/src/templates/worker/test.t @@ -8,14 +8,13 @@ injections: append: true content: "pub mod {{ name | snake_case }};" --- -use {{pkg_name}}::app::App; use loco_rs::{bgworker::BackgroundWorker, testing::prelude::*}; - -use {{pkg_name}}::workers::{{module_name}}::{{struct_name}}Worker; -use {{pkg_name}}::workers::{{module_name}}::{{struct_name}}WorkerArgs; +use {{pkg_name}}::{ + app::App, + workers::{{module_name}}::{Worker, WorkerArgs}, +}; use serial_test::serial; - #[tokio::test] #[serial] async fn test_run_{{module_name}}_worker() { @@ -23,7 +22,7 @@ async fn test_run_{{module_name}}_worker() { // Execute the worker ensuring that it operates in 'ForegroundBlocking' mode, which prevents the addition of your worker to the background assert!( - {{struct_name}}Worker::perform_later(&boot.app_context, {{struct_name}}WorkerArgs {}) + Worker::perform_later(&boot.app_context,WorkerArgs {}) .await .is_ok() ); diff --git a/loco-gen/src/templates/worker/worker.t b/loco-gen/src/templates/worker/worker.t index 990a58d0a..06df6402a 100644 --- a/loco-gen/src/templates/worker/worker.t +++ b/loco-gen/src/templates/worker/worker.t @@ -9,25 +9,24 @@ injections: content: "pub mod {{ module_name}};" - into: src/app.rs after: "fn connect_workers" - content: " queue.register(crate::workers::{{module_name}}::{{struct_name}}Worker::build(ctx)).await?;" ---- + content: " queue.register(crate::workers::{{module_name}}::Worker::build(ctx)).await?;"--- use serde::{Deserialize, Serialize}; use loco_rs::prelude::*; -pub struct {{struct_name}}Worker { +pub struct Worker { pub ctx: AppContext, } #[derive(Deserialize, Debug, Serialize)] -pub struct {{struct_name}}WorkerArgs { +pub struct WorkerArgs { } #[async_trait] -impl BackgroundWorker<{{struct_name}}WorkerArgs> for {{struct_name}}Worker { +impl BackgroundWorker for Worker { fn build(ctx: &AppContext) -> Self { Self { ctx: ctx.clone() } } - async fn perform(&self, _args: {{struct_name}}WorkerArgs) -> Result<()> { + async fn perform(&self, _args: WorkerArgs) -> Result<()> { println!("================={{struct_name}}======================="); // TODO: Some actual work goes here... Ok(()) diff --git a/loco-gen/tests/mod.rs b/loco-gen/tests/mod.rs new file mode 100644 index 000000000..6ccfc578e --- /dev/null +++ b/loco-gen/tests/mod.rs @@ -0,0 +1 @@ +mod templates; diff --git a/loco-gen/tests/templates/controller.rs b/loco-gen/tests/templates/controller.rs new file mode 100644 index 000000000..9924d89a6 --- /dev/null +++ b/loco-gen/tests/templates/controller.rs @@ -0,0 +1,88 @@ +use super::utils::APP_ROUTS; +use insta::assert_snapshot; +use loco_gen::{collect_messages, generate, AppInfo, Component, ScaffoldKind}; +use rrgen::RRgen; +use rstest::rstest; +use std::fs; + +#[rstest] +#[case(ScaffoldKind::Api)] +#[case(ScaffoldKind::Html)] +#[case(ScaffoldKind::Htmx)] +#[test] +fn can_generate(#[case] kind: ScaffoldKind) { + let actions = vec!["GET".to_string(), "POST".to_string()]; + let component = Component::Controller { + name: "movie".to_string(), + actions: actions.clone(), + kind: kind.clone(), + }; + + let mut settings = insta::Settings::clone_current(); + settings.set_prepend_module_to_snapshot(false); + settings.set_snapshot_suffix(format!("{kind:?}_controller")); + let _guard = settings.bind_to_scope(); + + let tree_fs = tree_fs::TreeBuilder::default() + .drop(true) + .add_empty("src/controllers/mod.rs") + .add_empty("tests/requests/mod.rs") + .add("src/app.rs", APP_ROUTS) + .create() + .unwrap(); + + let rrgen = RRgen::with_working_dir(&tree_fs.root); + + let gen_result = generate( + &rrgen, + component, + &AppInfo { + app_name: "tester".to_string(), + }, + ) + .expect("Generation failed"); + + assert_snapshot!("generate_results", collect_messages(&gen_result)); + + let controllers_path = tree_fs.root.join("src").join("controllers"); + assert_snapshot!( + "generate[controller_file]", + fs::read_to_string(controllers_path.join("movie.rs")).expect("controller file missing") + ); + assert_snapshot!( + "inject[controller_mod_rs]", + fs::read_to_string(controllers_path.join("mod.rs")).expect("mod.rs injection failed") + ); + assert_snapshot!( + "inject[app_rs]", + fs::read_to_string(tree_fs.root.join("src").join("app.rs")) + .expect("app.rs injection failed") + ); + + if matches!(kind, ScaffoldKind::Api) { + let test_controllers_path = tree_fs.root.join("tests").join("requests"); + assert_snapshot!( + "generate[tests_controller_mod_rs]", + fs::read_to_string(test_controllers_path.join("movie.rs")).expect("test file missing") + ); + assert_snapshot!( + "inject[tests_controller_mod_rs]", + fs::read_to_string(test_controllers_path.join("mod.rs")).expect("test mod.rs missing") + ); + } else { + for action in actions { + assert_snapshot!( + format!("inject[views_[{action}]]"), + fs::read_to_string( + tree_fs + .root + .join("assets") + .join("views") + .join("movie") + .join(format!("{}.html", action.to_uppercase())) + ) + .expect("view file missing") + ); + } + } +} diff --git a/loco-gen/tests/templates/deployment.rs b/loco-gen/tests/templates/deployment.rs new file mode 100644 index 000000000..7dea78da5 --- /dev/null +++ b/loco-gen/tests/templates/deployment.rs @@ -0,0 +1,101 @@ +use insta::assert_snapshot; +use loco_gen::{collect_messages, generate, AppInfo, Component, DeploymentKind}; +use rrgen::RRgen; +use std::fs; + +#[rstest::rstest] +fn can_generate_docker( + #[values(None, Some("404_html".to_string()))] fallback_file: Option, + #[values(None, Some("assets".to_string()))] asset_folder: Option, +) { + let mut settings = insta::Settings::clone_current(); + settings.set_prepend_module_to_snapshot(false); + settings.set_snapshot_suffix("deployment"); + let _guard = settings.bind_to_scope(); + + let component = Component::Deployment { + kind: DeploymentKind::Docker, + fallback_file: fallback_file.clone(), + asset_folder: asset_folder.clone(), + host: "localhost".to_string(), + port: 8080, + }; + + let tree_fs = tree_fs::TreeBuilder::default().drop(true).create().unwrap(); + let rrgen = RRgen::with_working_dir(&tree_fs.root); + + let gen_result = generate( + &rrgen, + component, + &AppInfo { + app_name: "tester".to_string(), + }, + ) + .expect("Generation failed"); + + // assert_snapshot!("generate_docker_result", collect_messages(&gen_result)); + + assert_eq!( + collect_messages(&gen_result), + r"* Dockerfile generated successfully. +* Dockerignore generated successfully. +" + ); + assert_snapshot!( + format!( + "generate[docker_file_[{}]_[{}]]", + fallback_file.as_ref().map_or("None", |f| f.as_str()), + asset_folder.as_ref().map_or("None", |a| a.as_str()) + ), + fs::read_to_string(tree_fs.root.join("dockerfile")).expect("dockerfile missing") + ); + + assert_eq!( + fs::read_to_string(tree_fs.root.join(".dockerignore")).expect(".dockerignore missing"), + r"target +dockerfile +.dockerignore +.git +.gitignore +" + ); +} + +#[test] +fn can_generate_nginx() { + let mut settings = insta::Settings::clone_current(); + settings.set_prepend_module_to_snapshot(false); + settings.set_snapshot_suffix("deployment"); + let _guard = settings.bind_to_scope(); + + let component = Component::Deployment { + kind: DeploymentKind::Nginx, + fallback_file: None, + asset_folder: None, + host: "localhost".to_string(), + port: 8080, + }; + + let tree_fs = tree_fs::TreeBuilder::default().drop(true).create().unwrap(); + let rrgen = RRgen::with_working_dir(&tree_fs.root); + + let gen_result = generate( + &rrgen, + component, + &AppInfo { + app_name: "tester".to_string(), + }, + ) + .expect("Generation failed"); + + assert_eq!( + collect_messages(&gen_result), + r"* Nginx generated successfully. +" + ); + assert_snapshot!( + "generate[nginx]", + fs::read_to_string(tree_fs.root.join("nginx").join("default.conf")) + .expect("nginx config missing") + ); +} diff --git a/loco-gen/tests/templates/mailer.rs b/loco-gen/tests/templates/mailer.rs new file mode 100644 index 000000000..2b8b13b53 --- /dev/null +++ b/loco-gen/tests/templates/mailer.rs @@ -0,0 +1,75 @@ +use insta::assert_snapshot; +use loco_gen::{collect_messages, generate, AppInfo, Component}; +use rrgen::RRgen; +use std::fs; + +#[test] +fn can_generate() { + let mut settings = insta::Settings::clone_current(); + settings.set_prepend_module_to_snapshot(false); + settings.set_snapshot_suffix("mailer"); + let _guard = settings.bind_to_scope(); + + let component = Component::Mailer { + name: "reset_password".to_string(), + }; + + let tree_fs = tree_fs::TreeBuilder::default() + .drop(true) + .add_empty("src/mailers/mod.rs") + .create() + .unwrap(); + + let rrgen = RRgen::with_working_dir(&tree_fs.root); + + let gen_result = generate( + &rrgen, + component, + &AppInfo { + app_name: "tester".to_string(), + }, + ) + .expect("Generation failed"); + + assert_eq!( + collect_messages(&gen_result), + r"* A mailer `ResetPassword` was added successfully. +" + ); + + let mailer_path = tree_fs.root.join("src").join("mailers"); + + for (name, path) in [ + ( + "generate[mailer_mod_rs]", + mailer_path.join("reset_password.rs"), + ), + ("inject[mailer_mod_rs]", mailer_path.join("mod.rs")), + ( + "generate[subject_t_file]", + mailer_path + .join("reset_password") + .join("welcome") + .join("subject.t"), + ), + ( + "generate[text_t_file]", + mailer_path + .join("reset_password") + .join("welcome") + .join("text.t"), + ), + ( + "generate[html_t_file]", + mailer_path + .join("reset_password") + .join("welcome") + .join("html.t"), + ), + ] { + assert_snapshot!( + name, + fs::read_to_string(path).unwrap_or_else(|_| panic!("{name} missing")) + ); + } +} diff --git a/loco-gen/tests/templates/migration.rs b/loco-gen/tests/templates/migration.rs new file mode 100644 index 000000000..acf4e4d2f --- /dev/null +++ b/loco-gen/tests/templates/migration.rs @@ -0,0 +1,164 @@ +use super::utils::{guess_file_by_time, MIGRATION_SRC_LIB}; +use insta::{assert_snapshot, with_settings}; +use loco_gen::{collect_messages, generate, AppInfo, Component}; +use rrgen::RRgen; +use rstest::rstest; +use std::fs; + +#[rstest] +#[case("create_table", Component::Migration { + name: "CreateMovies".to_string(), + fields: vec![ + ("title".to_string(), "string".to_string()), + ("user".to_string(), "references".to_string()), + ], + }, "movies.rs")] +#[case("add_column", Component::Migration { + name: "AddNameAndAgeToUsers".to_string(), + fields: vec![ + ("name".to_string(), "string".to_string()), + ("age".to_string(), "int".to_string()), + ], + }, "add_name_and_age_to_users.rs")] +#[case("remove_columns", Component::Migration { + name: "RemoveNameAndAgeFromUsers".to_string(), + fields: vec![ + ("name".to_string(), "string".to_string()), + ("age".to_string(), "int".to_string()), + ], + }, "remove_name_and_age_from_users.rs")] +#[case("add_reference", Component::Migration { + name: "AddUserRefToPosts".to_string(), + fields: vec![ + ("user".to_string(), "references".to_string()), + ], + }, "add_user_ref_to_posts.rs")] +#[case("create_join_table", Component::Migration { + name: "CreateJoinTableUsersAndGroups".to_string(), + fields: vec![ + ("count".to_string(), "int".to_string()), + ], + }, "create_join_table_users_and_groups.rs")] +#[case("empty", Component::Migration { + name: "FixUsersTable".to_string(), + fields: vec![ + ("count".to_string(), "int".to_string()), + ], + }, "fix_users_table.rs")] +#[test] +fn can_generate( + #[case] test_name: &str, + #[case] component: Component, + #[case] suffix_generate_file: &str, +) { + let mut settings = insta::Settings::clone_current(); + settings.set_prepend_module_to_snapshot(false); + settings.set_snapshot_suffix(format!("{test_name}_migration")); + let _guard = settings.bind_to_scope(); + + let tree_fs = tree_fs::TreeBuilder::default() + .drop(true) + .add("migration/src/lib.rs", MIGRATION_SRC_LIB) + .create() + .unwrap(); + + let rrgen = RRgen::with_working_dir(&tree_fs.root); + + let gen_result = generate( + &rrgen, + component, + &AppInfo { + app_name: "tester".to_string(), + }, + ) + .expect("Generation failed"); + + assert_snapshot!("generate_result", collect_messages(&gen_result)); + + let migration_path = tree_fs.root.join("migration").join("src"); + let migration_file = guess_file_by_time( + &migration_path, + &format!("m{{TIME}}_{suffix_generate_file}"), + 3, + ) + .expect("Failed to find the generated migration file"); + + assert_snapshot!( + "generate[migration_file]", + fs::read_to_string(&migration_file).expect("Failed to read the migration file") + ); + + with_settings!({ + filters => vec![(r"\d{8}_\d{6}", "[TIME]")] + }, { + assert_snapshot!( + "inject[migration_lib]", + fs::read_to_string(migration_path.join("lib.rs")).expect("Failed to read lib.rs") + ); + }); +} + +#[rstest] +#[case(Component::Migration { + name: "CreateMovies".to_string(), + fields: vec![ + ("title".to_string(), "string".to_string()), + ("user".to_string(), "references".to_string()), + ], + })] +#[case(Component::Migration { + name: "AddNameAndAgeToUsers".to_string(), + fields: vec![ + ("name".to_string(), "string".to_string()), + ("age".to_string(), "int".to_string()), + ], + })] +#[case(Component::Migration { + name: "RemoveNameAndAgeFromUsers".to_string(), + fields: vec![ + ("name".to_string(), "string".to_string()), + ("age".to_string(), "int".to_string()), + ], + })] +#[case(Component::Migration { + name: "AddUserRefToPosts".to_string(), + fields: vec![ + ("user".to_string(), "references".to_string()), + ], + })] +#[case(Component::Migration { + name: "CreateJoinTableUsersAndGroups".to_string(), + fields: vec![ + ("count".to_string(), "int".to_string()), + ], + })] +#[case(Component::Migration { + name: "FixUsersTable".to_string(), + fields: vec![ + ("count".to_string(), "int".to_string()), + ], + })] +#[test] +fn fail_when_migration_lib_not_exists(#[case] component: Component) { + let tree_fs = tree_fs::TreeBuilder::default() + .drop(true) + .add_empty("tests/models/mod.rs") + .create() + .unwrap(); + + let rrgen = RRgen::with_working_dir(&tree_fs.root); + + let err = generate( + &rrgen, + component, + &AppInfo { + app_name: "tester".to_string(), + }, + ) + .expect_err("Expected error when migration lib doesn't exist"); + + assert_eq!( + err.to_string(), + "cannot inject into migration/src/lib.rs: file does not exist" + ); +} diff --git a/loco-gen/tests/templates/mod.rs b/loco-gen/tests/templates/mod.rs new file mode 100644 index 000000000..ae31ce576 --- /dev/null +++ b/loco-gen/tests/templates/mod.rs @@ -0,0 +1,13 @@ +mod controller; +mod deployment; +mod mailer; +#[cfg(feature = "with-db")] +mod migration; +#[cfg(feature = "with-db")] +mod model; +#[cfg(feature = "with-db")] +mod scaffold; +mod scheduler; +mod task; +mod utils; +mod worker; diff --git a/loco-gen/tests/templates/model.rs b/loco-gen/tests/templates/model.rs new file mode 100644 index 000000000..5eb4aa735 --- /dev/null +++ b/loco-gen/tests/templates/model.rs @@ -0,0 +1,139 @@ +use super::utils::{guess_file_by_time, MIGRATION_SRC_LIB}; +use insta::{assert_snapshot, with_settings}; +use loco_gen::{collect_messages, generate, AppInfo, Component}; +use rrgen::RRgen; +use std::fs; + +macro_rules! configure_insta { + () => { + let mut settings = insta::Settings::clone_current(); + settings.set_prepend_module_to_snapshot(false); + settings.set_snapshot_suffix("model"); + let _guard = settings.bind_to_scope(); + }; +} + +#[test] +fn can_generate() { + std::env::set_var("SKIP_MIGRATION", ""); + configure_insta!(); + let tree_fs = tree_fs::TreeBuilder::default() + .drop(true) + .add("migration/src/lib.rs", MIGRATION_SRC_LIB) + .add_empty("tests/models/mod.rs") + .create() + .unwrap(); + + let rrgen = RRgen::with_working_dir(&tree_fs.root); + let component = Component::Model { + name: "movies".to_string(), + link: false, + fields: vec![("title".to_string(), "string".to_string())], + }; + + let gen_result = generate( + &rrgen, + component, + &AppInfo { + app_name: "tester".to_string(), + }, + ) + .expect("Generation failed"); + + assert_eq!( + collect_messages(&gen_result), + r"* Migration for `movies` added! You can now apply it with `$ cargo loco db migrate`. +* A test for model `Movies` was added. Run with `cargo test`. +" + ); + + let migration_path = tree_fs.root.join("migration/src"); + let migration_file = guess_file_by_time(&migration_path, "m{TIME}_movies.rs", 3) + .expect("Failed to find the generated migration file"); + + assert_snapshot!( + "generate[migration_file]", + fs::read_to_string(&migration_file).expect("Failed to read the migration file") + ); + + with_settings!({ + filters => vec![(r"\d{8}_\d{6}", "[TIME]")] + }, { + assert_snapshot!( + "inject[migration_lib]", + fs::read_to_string(migration_path.join("lib.rs")).expect("Failed to read lib.rs") + ); + }); + + let tests_path = tree_fs.root.join("tests/models"); + assert_snapshot!( + "generate[test_model]", + fs::read_to_string(tests_path.join("movies.rs")).expect("Failed to read movies.rs") + ); + assert_snapshot!( + "inject[test_mod]", + fs::read_to_string(tests_path.join("mod.rs")).expect("Failed to read mod.rs") + ); +} + +#[test] +fn fail_when_migration_lib_not_exists() { + std::env::set_var("SKIP_MIGRATION", ""); + let tree_fs = tree_fs::TreeBuilder::default() + .drop(true) + .add_empty("tests/models/mod.rs") + .create() + .unwrap(); + + let rrgen = RRgen::with_working_dir(&tree_fs.root); + let component = Component::Model { + name: "movies".to_string(), + link: false, + fields: vec![("title".to_string(), "string".to_string())], + }; + + let err = generate( + &rrgen, + component, + &AppInfo { + app_name: "tester".to_string(), + }, + ) + .expect_err("Expected error when model lib doesn't exist"); + + assert_eq!( + err.to_string(), + "cannot inject into migration/src/lib.rs: file does not exist" + ); +} + +#[test] +fn fail_when_test_models_mod_not_exists() { + std::env::set_var("SKIP_MIGRATION", ""); + let tree_fs = tree_fs::TreeBuilder::default() + .drop(true) + .add("migration/src/lib.rs", MIGRATION_SRC_LIB) + .create() + .unwrap(); + + let rrgen = RRgen::with_working_dir(&tree_fs.root); + let component = Component::Model { + name: "movies".to_string(), + link: false, + fields: vec![("title".to_string(), "string".to_string())], + }; + + let err = generate( + &rrgen, + component, + &AppInfo { + app_name: "tester".to_string(), + }, + ) + .expect_err("Expected error when migration src doesn't exist"); + + assert_eq!( + err.to_string(), + "cannot inject into tests/models/mod.rs: file does not exist" + ); +} diff --git a/loco-gen/tests/templates/scaffold.rs b/loco-gen/tests/templates/scaffold.rs new file mode 100644 index 000000000..b713b9c75 --- /dev/null +++ b/loco-gen/tests/templates/scaffold.rs @@ -0,0 +1,137 @@ +use super::utils::{guess_file_by_time, APP_ROUTS, MIGRATION_SRC_LIB}; +use insta::{assert_snapshot, with_settings}; +use loco_gen::{collect_messages, generate, AppInfo, Component, ScaffoldKind}; +use rrgen::RRgen; +use rstest::rstest; +use std::fs; + +#[rstest] +#[case(ScaffoldKind::Api)] +#[case(ScaffoldKind::Html)] +#[case(ScaffoldKind::Htmx)] +#[test] +fn can_generate(#[case] kind: ScaffoldKind) { + std::env::set_var("SKIP_MIGRATION", ""); + let mut settings = insta::Settings::clone_current(); + settings.set_prepend_module_to_snapshot(false); + settings.set_snapshot_suffix(format!("{kind:?}_scaffold")); + let _guard = settings.bind_to_scope(); + + let component = Component::Scaffold { + name: "movie".to_string(), + fields: vec![ + ("title".to_string(), "string".to_string()), + ("user".to_string(), "references".to_string()), + ], + kind: kind.clone(), + }; + + let tree_fs = tree_fs::TreeBuilder::default() + .drop(true) + .add_empty("src/controllers/mod.rs") + .add_empty("tests/models/mod.rs") + .add_empty("src/views/mod.rs") + .add_empty("tests/requests/mod.rs") + .add("migration/src/lib.rs", MIGRATION_SRC_LIB) + .add("src/app.rs", APP_ROUTS) + .create() + .unwrap(); + + let rrgen = RRgen::with_working_dir(&tree_fs.root); + + let gen_result = generate( + &rrgen, + component, + &AppInfo { + app_name: "tester".to_string(), + }, + ) + .expect("Generation failed"); + + assert_snapshot!("generate_results", collect_messages(&gen_result)); + + // MODELS + let migration_path = tree_fs.root.join("migration/src"); + let migration_file = guess_file_by_time(&migration_path, "m{TIME}_movies.rs", 3) + .expect("Failed to find the generated migration file"); + + assert_snapshot!( + "generate[migration_file]", + fs::read_to_string(&migration_file).expect("Failed to read the migration file") + ); + + with_settings!({ + filters => vec![(r"\d{8}_\d{6}", "[TIME]")] + }, { + assert_snapshot!( + "inject[migration_lib]", + fs::read_to_string(migration_path.join("lib.rs")).expect("Failed to read lib.rs") + ); + }); + with_settings!({ + filters => vec![(r"\d{8}_\d{6}", "[TIME]")] + }, { + assert_snapshot!( + "inject[migration_lib]", + fs::read_to_string(migration_path.join("lib.rs")).expect("Failed to read lib.rs") + ); + }); + + // CONTROLLER + let controllers_path = tree_fs.root.join("src").join("controllers"); + assert_snapshot!( + "generate[controller_file]", + fs::read_to_string(controllers_path.join("movie.rs")).expect("controller file missing") + ); + + assert_snapshot!( + "inject[controller_mod_rs]", + fs::read_to_string(controllers_path.join("mod.rs")).expect("mod.rs injection failed") + ); + + assert_snapshot!( + "inject[app_rs]", + fs::read_to_string(tree_fs.root.join("src").join("app.rs")) + .expect("app.rs injection failed") + ); + + // TESTS + let tests_path = tree_fs.root.join("tests/models"); + assert_snapshot!( + "generate[test_model]", + fs::read_to_string(tests_path.join("movies.rs")).expect("Failed to read movies.rs") + ); + assert_snapshot!( + "inject[test_mod]", + fs::read_to_string(tests_path.join("mod.rs")).expect("Failed to read mod.rs") + ); + + // VIEWS + match kind { + ScaffoldKind::Api => (), + ScaffoldKind::Html | ScaffoldKind::Htmx => { + let base_views_path = tree_fs.root.join("src").join("views"); + assert_snapshot!( + "generate[views_rs]", + fs::read_to_string(base_views_path.join("movie.rs")) + .expect("Failed to read mod.rs") + ); + assert_snapshot!( + "inject[views_mod_rs]", + fs::read_to_string(base_views_path.join("mod.rs")).expect("Failed to read mod.rs") + ); + + let views_path = tree_fs.root.join("assets").join("views").join("movie"); + let views = vec!["create", "edit", "list", "show"]; + for view in views { + assert_snapshot!( + format!("generate[views_[{view}]]"), + fs::read_to_string(views_path.join(format!("{view}.html"))) + .expect("view file missing") + ); + } + } + } +} + +// thread 'templates::scaffold::can_generate::case_1' panicked at loco-gen/tests/templates/scaffold.rs:48:6: diff --git a/loco-gen/tests/templates/scheduler.rs b/loco-gen/tests/templates/scheduler.rs new file mode 100644 index 000000000..46acdb364 --- /dev/null +++ b/loco-gen/tests/templates/scheduler.rs @@ -0,0 +1,43 @@ +use insta::assert_snapshot; +use loco_gen::{collect_messages, generate, AppInfo, Component}; +use rrgen::RRgen; +use std::fs; + +macro_rules! configure_insta { + ($($expr:expr),*) => { + let mut settings = insta::Settings::clone_current(); + settings.set_prepend_module_to_snapshot(false); + settings.set_snapshot_suffix("scheduler"); + let _guard = settings.bind_to_scope(); + }; +} +#[test] +fn can_generate() { + configure_insta!(); + let component = Component::Scheduler {}; + + let tree_fs: tree_fs::Tree = tree_fs::TreeBuilder::default().drop(true).create().unwrap(); + + let rrgen = RRgen::with_working_dir(&tree_fs.root); + + let gen_result = generate( + &rrgen, + component, + &AppInfo { + app_name: "tester".to_string(), + }, + ) + .expect("Failed to generated scheduler file"); + + assert_eq!( + collect_messages(&gen_result), + r"* A Scheduler job configuration was added successfully. Run with `cargo loco run scheduler --list`. +" + ); + + assert_snapshot!( + "generate[controller_file]", + fs::read_to_string(tree_fs.root.join("config").join("scheduler.yaml")) + .expect("Failed to read the scheduler.yaml") + ); +} diff --git a/loco-gen/tests/templates/snapshots/generate[controller_file]@Api_controller.snap b/loco-gen/tests/templates/snapshots/generate[controller_file]@Api_controller.snap new file mode 100644 index 000000000..a85d710db --- /dev/null +++ b/loco-gen/tests/templates/snapshots/generate[controller_file]@Api_controller.snap @@ -0,0 +1,32 @@ +--- +source: loco-gen/tests/templates/controller.rs +expression: "fs::read_to_string(controllers_path.join(\"movie.rs\")).expect(\"controller file missing\")" +--- +#![allow(clippy::missing_errors_doc)] +#![allow(clippy::unnecessary_struct_initialization)] +#![allow(clippy::unused_async)] +use loco_rs::prelude::*; +use axum::debug_handler; + +#[debug_handler] +pub async fn index(State(_ctx): State) -> Result { + format::empty() +} + +#[debug_handler] +pub async fn GET(State(_ctx): State) -> Result { + format::empty() +} + +#[debug_handler] +pub async fn POST(State(_ctx): State) -> Result { + format::empty() +} + +pub fn routes() -> Routes { + Routes::new() + .prefix("api/movies/") + .add("/", get(index)) + .add("GET", get(GET)) + .add("POST", get(POST)) +} diff --git a/loco-gen/tests/templates/snapshots/generate[controller_file]@Api_scaffold.snap b/loco-gen/tests/templates/snapshots/generate[controller_file]@Api_scaffold.snap new file mode 100644 index 000000000..5dfda158a --- /dev/null +++ b/loco-gen/tests/templates/snapshots/generate[controller_file]@Api_scaffold.snap @@ -0,0 +1,78 @@ +--- +source: loco-gen/tests/templates/scaffold.rs +expression: "fs::read_to_string(controllers_path.join(\"movie.rs\")).expect(\"controller file missing\")" +--- +#![allow(clippy::missing_errors_doc)] +#![allow(clippy::unnecessary_struct_initialization)] +#![allow(clippy::unused_async)] +use loco_rs::prelude::*; +use serde::{Deserialize, Serialize}; +use axum::debug_handler; + +use crate::models::_entities::movies::{ActiveModel, Entity, Model}; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Params { + pub title: Option, + } + +impl Params { + fn update(&self, item: &mut ActiveModel) { + item.title = Set(self.title.clone()); + } +} + +async fn load_item(ctx: &AppContext, id: i32) -> Result { + let item = Entity::find_by_id(id).one(&ctx.db).await?; + item.ok_or_else(|| Error::NotFound) +} + +#[debug_handler] +pub async fn list(State(ctx): State) -> Result { + format::json(Entity::find().all(&ctx.db).await?) +} + +#[debug_handler] +pub async fn add(State(ctx): State, Json(params): Json) -> Result { + let mut item = ActiveModel { + ..Default::default() + }; + params.update(&mut item); + let item = item.insert(&ctx.db).await?; + format::json(item) +} + +#[debug_handler] +pub async fn update( + Path(id): Path, + State(ctx): State, + Json(params): Json, +) -> Result { + let item = load_item(&ctx, id).await?; + let mut item = item.into_active_model(); + params.update(&mut item); + let item = item.update(&ctx.db).await?; + format::json(item) +} + +#[debug_handler] +pub async fn remove(Path(id): Path, State(ctx): State) -> Result { + load_item(&ctx, id).await?.delete(&ctx.db).await?; + format::empty() +} + +#[debug_handler] +pub async fn get_one(Path(id): Path, State(ctx): State) -> Result { + format::json(load_item(&ctx, id).await?) +} + +pub fn routes() -> Routes { + Routes::new() + .prefix("api/movies/") + .add("/", get(list)) + .add("/", post(add)) + .add(":id", get(get_one)) + .add(":id", delete(remove)) + .add(":id", put(update)) + .add(":id", patch(update)) +} diff --git a/loco-gen/tests/templates/snapshots/generate[controller_file]@Html_controller.snap b/loco-gen/tests/templates/snapshots/generate[controller_file]@Html_controller.snap new file mode 100644 index 000000000..31c6140a4 --- /dev/null +++ b/loco-gen/tests/templates/snapshots/generate[controller_file]@Html_controller.snap @@ -0,0 +1,32 @@ +--- +source: loco-gen/tests/templates/controller.rs +expression: "fs::read_to_string(controllers_path.join(\"movie.rs\")).expect(\"controller file missing\")" +--- +#![allow(clippy::missing_errors_doc)] +#![allow(clippy::unnecessary_struct_initialization)] +#![allow(clippy::unused_async)] +use loco_rs::prelude::*; +use axum::debug_handler; + +#[debug_handler] +pub async fn GET( + ViewEngine(v): ViewEngine, + State(_ctx): State +) -> Result { + format::render().view(&v, "movie/GET.html", data!({})) +} + +#[debug_handler] +pub async fn POST( + ViewEngine(v): ViewEngine, + State(_ctx): State +) -> Result { + format::render().view(&v, "movie/POST.html", data!({})) +} + +pub fn routes() -> Routes { + Routes::new() + .prefix("movies/") + .add("GET", get(GET)) + .add("POST", get(POST)) +} diff --git a/loco-gen/tests/templates/snapshots/generate[controller_file]@Html_scaffold.snap b/loco-gen/tests/templates/snapshots/generate[controller_file]@Html_scaffold.snap new file mode 100644 index 000000000..4691c2f15 --- /dev/null +++ b/loco-gen/tests/templates/snapshots/generate[controller_file]@Html_scaffold.snap @@ -0,0 +1,117 @@ +--- +source: loco-gen/tests/templates/scaffold.rs +expression: "fs::read_to_string(controllers_path.join(\"movie.rs\")).expect(\"controller file missing\")" +--- +#![allow(clippy::missing_errors_doc)] +#![allow(clippy::unnecessary_struct_initialization)] +#![allow(clippy::unused_async)] +use loco_rs::prelude::*; +use serde::{Deserialize, Serialize}; +use axum::{extract::Form, response::Redirect}; +use sea_orm::{sea_query::Order, QueryOrder}; +use axum::debug_handler; + +use crate::{ + models::_entities::movies::{ActiveModel, Column, Entity, Model}, + views, +}; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Params { + pub title: Option, + } + +impl Params { + fn update(&self, item: &mut ActiveModel) { + item.title = Set(self.title.clone()); + } +} + +async fn load_item(ctx: &AppContext, id: i32) -> Result { + let item = Entity::find_by_id(id).one(&ctx.db).await?; + item.ok_or_else(|| Error::NotFound) +} + +#[debug_handler] +pub async fn list( + ViewEngine(v): ViewEngine, + State(ctx): State, +) -> Result { + let item = Entity::find() + .order_by(Column::Id, Order::Desc) + .all(&ctx.db) + .await?; + views::movie::list(&v, &item) +} + +#[debug_handler] +pub async fn new( + ViewEngine(v): ViewEngine, + State(_ctx): State, +) -> Result { + views::movie::create(&v) +} + +#[debug_handler] +pub async fn update( + Path(id): Path, + State(ctx): State, + Form(params): Form, +) -> Result { + let item = load_item(&ctx, id).await?; + let mut item = item.into_active_model(); + params.update(&mut item); + item.update(&ctx.db).await?; + Ok(Redirect::to("../movies")) +} + +#[debug_handler] +pub async fn edit( + Path(id): Path, + ViewEngine(v): ViewEngine, + State(ctx): State, +) -> Result { + let item = load_item(&ctx, id).await?; + views::movie::edit(&v, &item) +} + +#[debug_handler] +pub async fn show( + Path(id): Path, + ViewEngine(v): ViewEngine, + State(ctx): State, +) -> Result { + let item = load_item(&ctx, id).await?; + views::movie::show(&v, &item) +} + +#[debug_handler] +pub async fn add( + State(ctx): State, + Form(params): Form, +) -> Result { + let mut item = ActiveModel { + ..Default::default() + }; + params.update(&mut item); + item.insert(&ctx.db).await?; + Ok(Redirect::to("movies")) +} + +#[debug_handler] +pub async fn remove(Path(id): Path, State(ctx): State) -> Result { + load_item(&ctx, id).await?.delete(&ctx.db).await?; + format::empty() +} + +pub fn routes() -> Routes { + Routes::new() + .prefix("movies/") + .add("/", get(list)) + .add("/", post(add)) + .add("new", get(new)) + .add(":id", get(show)) + .add(":id/edit", get(edit)) + .add(":id", delete(remove)) + .add(":id", post(update)) +} diff --git a/loco-gen/tests/templates/snapshots/generate[controller_file]@Htmx_controller.snap b/loco-gen/tests/templates/snapshots/generate[controller_file]@Htmx_controller.snap new file mode 100644 index 000000000..ecdcdf329 --- /dev/null +++ b/loco-gen/tests/templates/snapshots/generate[controller_file]@Htmx_controller.snap @@ -0,0 +1,32 @@ +--- +source: loco-gen/tests/templates/controller.rs +expression: "fs::read_to_string(controllers_path.join(\"movie.rs\")).expect(\"controller file missing\")" +--- +#![allow(clippy::missing_errors_doc)] +#![allow(clippy::unnecessary_struct_initialization)] +#![allow(clippy::unused_async)] +use loco_rs::prelude::*; +use axum::debug_handler; + +#[debug_handler] +pub async fn GET( + ViewEngine(v): ViewEngine, + State(_ctx): State +) -> Result { + format::render().view(&v, "movie/GET.html", data!({})) +} + +#[debug_handler] +pub async fn POST( + ViewEngine(v): ViewEngine, + State(_ctx): State +) -> Result { + format::render().view(&v, "movie/POST.html", data!({})) +} + +pub fn routes() -> Routes { + Routes::new() + .prefix("movies") + .add("GET", get(GET)) + .add("POST", get(POST)) +} diff --git a/loco-gen/tests/templates/snapshots/generate[controller_file]@Htmx_scaffold.snap b/loco-gen/tests/templates/snapshots/generate[controller_file]@Htmx_scaffold.snap new file mode 100644 index 000000000..dc91f1b81 --- /dev/null +++ b/loco-gen/tests/templates/snapshots/generate[controller_file]@Htmx_scaffold.snap @@ -0,0 +1,118 @@ +--- +source: loco-gen/tests/templates/scaffold.rs +expression: "fs::read_to_string(controllers_path.join(\"movie.rs\")).expect(\"controller file missing\")" +--- +#![allow(clippy::missing_errors_doc)] +#![allow(clippy::unnecessary_struct_initialization)] +#![allow(clippy::unused_async)] +use loco_rs::prelude::*; +use serde::{Deserialize, Serialize}; +use sea_orm::{sea_query::Order, QueryOrder}; +use axum::debug_handler; + +use crate::{ + models::_entities::movies::{ActiveModel, Column, Entity, Model}, + views, +}; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Params { + pub title: Option, + } + +impl Params { + fn update(&self, item: &mut ActiveModel) { + item.title = Set(self.title.clone()); + } +} + +async fn load_item(ctx: &AppContext, id: i32) -> Result { + let item = Entity::find_by_id(id).one(&ctx.db).await?; + item.ok_or_else(|| Error::NotFound) +} + +#[debug_handler] +pub async fn list( + ViewEngine(v): ViewEngine, + State(ctx): State, +) -> Result { + let item = Entity::find() + .order_by(Column::Id, Order::Desc) + .all(&ctx.db) + .await?; + views::movie::list(&v, &item) +} + +#[debug_handler] +pub async fn new( + ViewEngine(v): ViewEngine, + State(_ctx): State, +) -> Result { + views::movie::create(&v) +} + +#[debug_handler] +pub async fn update( + Path(id): Path, + State(ctx): State, + Json(params): Json, +) -> Result { + let item = load_item(&ctx, id).await?; + let mut item = item.into_active_model(); + params.update(&mut item); + let item = item.update(&ctx.db).await?; + format::json(item) +} + +#[debug_handler] +pub async fn edit( + Path(id): Path, + ViewEngine(v): ViewEngine, + State(ctx): State, +) -> Result { + let item = load_item(&ctx, id).await?; + views::movie::edit(&v, &item) +} + +#[debug_handler] +pub async fn show( + Path(id): Path, + ViewEngine(v): ViewEngine, + State(ctx): State, +) -> Result { + let item = load_item(&ctx, id).await?; + views::movie::show(&v, &item) +} + +#[debug_handler] +pub async fn add( + ViewEngine(v): ViewEngine, + State(ctx): State, + Json(params): Json, +) -> Result { + let mut item = ActiveModel { + ..Default::default() + }; + params.update(&mut item); + let item = item.insert(&ctx.db).await?; + views::movie::show(&v, &item) +} + +#[debug_handler] +pub async fn remove(Path(id): Path, State(ctx): State) -> Result { + load_item(&ctx, id).await?.delete(&ctx.db).await?; + format::empty() +} + +pub fn routes() -> Routes { + Routes::new() + .prefix("movies/") + .add("/", get(list)) + .add("/", post(add)) + .add("new", get(new)) + .add(":id", get(show)) + .add(":id/edit", get(edit)) + .add(":id", delete(remove)) + .add(":id", put(update)) + .add(":id", patch(update)) +} diff --git a/loco-gen/tests/templates/snapshots/generate[controller_file]@scheduler.snap b/loco-gen/tests/templates/snapshots/generate[controller_file]@scheduler.snap new file mode 100644 index 000000000..8aefb5afe --- /dev/null +++ b/loco-gen/tests/templates/snapshots/generate[controller_file]@scheduler.snap @@ -0,0 +1,17 @@ +--- +source: loco-gen/tests/templates/scheduler.rs +expression: "fs::read_to_string(tree_fs.root.join(\"config\").join(\"scheduler.yaml\")).expect(\"Failed to read the scheduler.yaml\")" +--- +output: stdout +jobs: + write_content: + shell: true + run: "echo loco >> ./scheduler.txt" + schedule: run every 1 second + # schedule: "* * * * * * *" + output: silent + tags: ['base', 'infra'] + + # run_task: + # run: "foo" + # schedule: "at 10:00 am" diff --git a/loco-gen/tests/templates/snapshots/generate[controller_file]@task.snap b/loco-gen/tests/templates/snapshots/generate[controller_file]@task.snap new file mode 100644 index 000000000..5d32cfa80 --- /dev/null +++ b/loco-gen/tests/templates/snapshots/generate[controller_file]@task.snap @@ -0,0 +1,20 @@ +--- +source: loco-gen/tests/templates/task.rs +expression: "fs::read_to_string(task_path.join(\"cleanup.rs\")).expect(\"Failed to read generated task file: cleanup.rs\")" +--- +use loco_rs::prelude::*; + +pub struct Cleanup; +#[async_trait] +impl Task for Cleanup { + fn task(&self) -> TaskInfo { + TaskInfo { + name: "cleanup".to_string(), + detail: "Task generator".to_string(), + } + } + async fn run(&self, _app_context: &AppContext, _vars: &task::Vars) -> Result<()> { + println!("Task Cleanup generated"); + Ok(()) + } +} diff --git a/loco-gen/tests/templates/snapshots/generate[controller_file]@worker.snap b/loco-gen/tests/templates/snapshots/generate[controller_file]@worker.snap new file mode 100644 index 000000000..87c7c1323 --- /dev/null +++ b/loco-gen/tests/templates/snapshots/generate[controller_file]@worker.snap @@ -0,0 +1,26 @@ +--- +source: loco-gen/tests/templates/worker.rs +expression: "fs::read_to_string(worker_path.join(\"register_email.rs\")).expect(\"Failed to read generated worker file: register_email.rs\")" +--- +use serde::{Deserialize, Serialize}; +use loco_rs::prelude::*; + +pub struct Worker { + pub ctx: AppContext, +} + +#[derive(Deserialize, Debug, Serialize)] +pub struct WorkerArgs { +} + +#[async_trait] +impl BackgroundWorker for Worker { + fn build(ctx: &AppContext) -> Self { + Self { ctx: ctx.clone() } + } + async fn perform(&self, _args: WorkerArgs) -> Result<()> { + println!("=================RegisterEmail======================="); + // TODO: Some actual work goes here... + Ok(()) + } +} diff --git a/loco-gen/tests/templates/snapshots/generate[docker_file_[404_html]_[None]]@deployment.snap b/loco-gen/tests/templates/snapshots/generate[docker_file_[404_html]_[None]]@deployment.snap new file mode 100644 index 000000000..2f2b97733 --- /dev/null +++ b/loco-gen/tests/templates/snapshots/generate[docker_file_[404_html]_[None]]@deployment.snap @@ -0,0 +1,21 @@ +--- +source: loco-gen/tests/templates/deployment.rs +expression: "fs::read_to_string(tree_fs.root.join(\"dockerfile\")).expect(\"dockerfile missing\")" +--- +FROM rust:1.83.0-slim as builder + +WORKDIR /usr/src/ + +COPY . . + +RUN cargo build --release + +FROM debian:bookworm-slim + +WORKDIR /usr/app + +COPY --from=builder /usr/src/404_html /usr/app/404_html +COPY --from=builder /usr/src/config /usr/app/config +COPY --from=builder /usr/src/target/release/tester-cli /usr/app/tester-cli + +ENTRYPOINT ["/usr/app/tester-cli"] diff --git a/loco-gen/tests/templates/snapshots/generate[docker_file_[404_html]_[assets]]@deployment.snap b/loco-gen/tests/templates/snapshots/generate[docker_file_[404_html]_[assets]]@deployment.snap new file mode 100644 index 000000000..9771babfe --- /dev/null +++ b/loco-gen/tests/templates/snapshots/generate[docker_file_[404_html]_[assets]]@deployment.snap @@ -0,0 +1,22 @@ +--- +source: loco-gen/tests/templates/deployment.rs +expression: "fs::read_to_string(tree_fs.root.join(\"dockerfile\")).expect(\"dockerfile missing\")" +--- +FROM rust:1.83.0-slim as builder + +WORKDIR /usr/src/ + +COPY . . + +RUN cargo build --release + +FROM debian:bookworm-slim + +WORKDIR /usr/app + +COPY --from=builder /usr/src/assets /usr/app/assets +COPY --from=builder /usr/src/404_html /usr/app/404_html +COPY --from=builder /usr/src/config /usr/app/config +COPY --from=builder /usr/src/target/release/tester-cli /usr/app/tester-cli + +ENTRYPOINT ["/usr/app/tester-cli"] diff --git a/loco-gen/tests/templates/snapshots/generate[docker_file_[None]_[None]]@deployment.snap b/loco-gen/tests/templates/snapshots/generate[docker_file_[None]_[None]]@deployment.snap new file mode 100644 index 000000000..1d13e04cd --- /dev/null +++ b/loco-gen/tests/templates/snapshots/generate[docker_file_[None]_[None]]@deployment.snap @@ -0,0 +1,20 @@ +--- +source: loco-gen/tests/templates/deployment.rs +expression: "fs::read_to_string(tree_fs.root.join(\"dockerfile\")).expect(\"dockerfile missing\")" +--- +FROM rust:1.83.0-slim as builder + +WORKDIR /usr/src/ + +COPY . . + +RUN cargo build --release + +FROM debian:bookworm-slim + +WORKDIR /usr/app + +COPY --from=builder /usr/src/config /usr/app/config +COPY --from=builder /usr/src/target/release/tester-cli /usr/app/tester-cli + +ENTRYPOINT ["/usr/app/tester-cli"] diff --git a/loco-gen/tests/templates/snapshots/generate[docker_file_[None]_[assets]]@deployment.snap b/loco-gen/tests/templates/snapshots/generate[docker_file_[None]_[assets]]@deployment.snap new file mode 100644 index 000000000..ca69e1070 --- /dev/null +++ b/loco-gen/tests/templates/snapshots/generate[docker_file_[None]_[assets]]@deployment.snap @@ -0,0 +1,21 @@ +--- +source: loco-gen/tests/templates/deployment.rs +expression: "fs::read_to_string(tree_fs.root.join(\"dockerfile\")).expect(\"dockerfile missing\")" +--- +FROM rust:1.83.0-slim as builder + +WORKDIR /usr/src/ + +COPY . . + +RUN cargo build --release + +FROM debian:bookworm-slim + +WORKDIR /usr/app + +COPY --from=builder /usr/src/assets /usr/app/assets +COPY --from=builder /usr/src/config /usr/app/config +COPY --from=builder /usr/src/target/release/tester-cli /usr/app/tester-cli + +ENTRYPOINT ["/usr/app/tester-cli"] diff --git a/loco-gen/tests/templates/snapshots/generate[html_t_file]@mailer.snap b/loco-gen/tests/templates/snapshots/generate[html_t_file]@mailer.snap new file mode 100644 index 000000000..a34aa8316 --- /dev/null +++ b/loco-gen/tests/templates/snapshots/generate[html_t_file]@mailer.snap @@ -0,0 +1,5 @@ +--- +source: loco-gen/tests/templates/mailer.rs +expression: "fs::read_to_string(path).unwrap_or_else(|_| panic!(\"{name} missing\"))" +--- +welcome to acmeworld! diff --git a/loco-gen/tests/templates/snapshots/generate[mailer_mod_rs]@mailer.snap b/loco-gen/tests/templates/snapshots/generate[mailer_mod_rs]@mailer.snap new file mode 100644 index 000000000..36f7dd077 --- /dev/null +++ b/loco-gen/tests/templates/snapshots/generate[mailer_mod_rs]@mailer.snap @@ -0,0 +1,37 @@ +--- +source: loco-gen/tests/templates/mailer.rs +expression: "fs::read_to_string(path).unwrap_or_else(|_| panic!(\"{name} missing\"))" +--- +#![allow(non_upper_case_globals)] + +use loco_rs::prelude::*; +use serde_json::json; + +static welcome: Dir<'_> = include_dir!("src/mailers/reset_password/welcome"); + +#[allow(clippy::module_name_repetitions)] +pub struct ResetPassword {} +impl Mailer for ResetPassword {} +impl ResetPassword { + /// Send an email + /// + /// # Errors + /// When email sending is failed + pub async fn send_welcome(ctx: &AppContext, to: &str, msg: &str) -> Result<()> { + Self::mail_template( + ctx, + &welcome, + mailer::Args { + to: to.to_string(), + locals: json!({ + "message": msg, + "domain": ctx.config.server.full_url() + }), + ..Default::default() + }, + ) + .await?; + + Ok(()) + } +} diff --git a/loco-gen/tests/templates/snapshots/generate[migration_file]@Api_scaffold.snap b/loco-gen/tests/templates/snapshots/generate[migration_file]@Api_scaffold.snap new file mode 100644 index 000000000..f5be92023 --- /dev/null +++ b/loco-gen/tests/templates/snapshots/generate[migration_file]@Api_scaffold.snap @@ -0,0 +1,53 @@ +--- +source: loco-gen/tests/templates/scaffold.rs +expression: "fs::read_to_string(&migration_file).expect(\"Failed to read the migration file\")" +--- +use loco_rs::schema::table_auto_tz; +use sea_orm_migration::{prelude::*, schema::*}; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + table_auto_tz(Movies::Table) + .col(pk_auto(Movies::Id)) + .col(string_null(Movies::Title)) + .col(integer(Movies::UserId)) + .foreign_key( + ForeignKey::create() + .name("fk-movies-user_ids") + .from(Movies::Table, Movies::UserId) + .to(Users::Table, Users::Id) + .on_delete(ForeignKeyAction::Cascade) + .on_update(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(Movies::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum Movies { + Table, + Id, + Title, + UserId, + +} + +#[derive(DeriveIden)] +enum Users { + Table, + Id, +} diff --git a/loco-gen/tests/templates/snapshots/generate[migration_file]@Html_scaffold.snap b/loco-gen/tests/templates/snapshots/generate[migration_file]@Html_scaffold.snap new file mode 100644 index 000000000..f5be92023 --- /dev/null +++ b/loco-gen/tests/templates/snapshots/generate[migration_file]@Html_scaffold.snap @@ -0,0 +1,53 @@ +--- +source: loco-gen/tests/templates/scaffold.rs +expression: "fs::read_to_string(&migration_file).expect(\"Failed to read the migration file\")" +--- +use loco_rs::schema::table_auto_tz; +use sea_orm_migration::{prelude::*, schema::*}; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + table_auto_tz(Movies::Table) + .col(pk_auto(Movies::Id)) + .col(string_null(Movies::Title)) + .col(integer(Movies::UserId)) + .foreign_key( + ForeignKey::create() + .name("fk-movies-user_ids") + .from(Movies::Table, Movies::UserId) + .to(Users::Table, Users::Id) + .on_delete(ForeignKeyAction::Cascade) + .on_update(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(Movies::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum Movies { + Table, + Id, + Title, + UserId, + +} + +#[derive(DeriveIden)] +enum Users { + Table, + Id, +} diff --git a/loco-gen/tests/templates/snapshots/generate[migration_file]@Htmx_scaffold.snap b/loco-gen/tests/templates/snapshots/generate[migration_file]@Htmx_scaffold.snap new file mode 100644 index 000000000..f5be92023 --- /dev/null +++ b/loco-gen/tests/templates/snapshots/generate[migration_file]@Htmx_scaffold.snap @@ -0,0 +1,53 @@ +--- +source: loco-gen/tests/templates/scaffold.rs +expression: "fs::read_to_string(&migration_file).expect(\"Failed to read the migration file\")" +--- +use loco_rs::schema::table_auto_tz; +use sea_orm_migration::{prelude::*, schema::*}; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + table_auto_tz(Movies::Table) + .col(pk_auto(Movies::Id)) + .col(string_null(Movies::Title)) + .col(integer(Movies::UserId)) + .foreign_key( + ForeignKey::create() + .name("fk-movies-user_ids") + .from(Movies::Table, Movies::UserId) + .to(Users::Table, Users::Id) + .on_delete(ForeignKeyAction::Cascade) + .on_update(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(Movies::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum Movies { + Table, + Id, + Title, + UserId, + +} + +#[derive(DeriveIden)] +enum Users { + Table, + Id, +} diff --git a/loco-gen/tests/templates/snapshots/generate[migration_file]@add_column_migration.snap b/loco-gen/tests/templates/snapshots/generate[migration_file]@add_column_migration.snap new file mode 100644 index 000000000..def7b1087 --- /dev/null +++ b/loco-gen/tests/templates/snapshots/generate[migration_file]@add_column_migration.snap @@ -0,0 +1,56 @@ +--- +source: loco-gen/tests/templates/migration.rs +expression: "fs::read_to_string(&migration_file).expect(\"Failed to read the migration file\")" +--- +use loco_rs::schema::*; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + alter(Users::Table) + .add_column(string_null(Users::Name)) + .to_owned(), + ) + .await?; + manager + .alter_table( + alter(Users::Table) + .add_column(integer_null(Users::Age)) + .to_owned(), + ) + .await?; + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + alter(Users::Table) + .drop_column(Users::Name) + .to_owned() + ) + .await?; + manager + .alter_table( + alter(Users::Table) + .drop_column(Users::Age) + .to_owned() + ) + .await?; + Ok(()) + } +} + +#[derive(DeriveIden)] +enum Users { + Table, + Name, + Age, + +} diff --git a/loco-gen/tests/templates/snapshots/generate[migration_file]@add_reference_migration.snap b/loco-gen/tests/templates/snapshots/generate[migration_file]@add_reference_migration.snap new file mode 100644 index 000000000..6f454f7bc --- /dev/null +++ b/loco-gen/tests/templates/snapshots/generate[migration_file]@add_reference_migration.snap @@ -0,0 +1,56 @@ +--- +source: loco-gen/tests/templates/migration.rs +expression: "fs::read_to_string(&migration_file).expect(\"Failed to read the migration file\")" +--- +use loco_rs::schema::*; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + alter(Posts::Table) + .add_column(integer(Posts::UserId)) + .add_foreign_key( + TableForeignKey::new() + .name("fk-posts-users") + .from_tbl(Posts::Table) + .from_col(Posts::UserId) + .to_tbl(Users::Table) + .to_col(Users::Id) + .on_delete(ForeignKeyAction::Cascade) + .on_update(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + alter(Posts::Table) + .drop_foreign_key(Alias::new("fk-posts-users")) + .drop_column(Posts::UserId) + .to_owned() + ) + .await + } +} + +#[derive(DeriveIden)] +enum Posts { + Table, + UserId, + +} + +#[derive(DeriveIden)] +enum Users { + Table, + Id, +} diff --git a/loco-gen/tests/templates/snapshots/generate[migration_file]@create_join_table_migration.snap b/loco-gen/tests/templates/snapshots/generate[migration_file]@create_join_table_migration.snap new file mode 100644 index 000000000..4626e2d07 --- /dev/null +++ b/loco-gen/tests/templates/snapshots/generate[migration_file]@create_join_table_migration.snap @@ -0,0 +1,72 @@ +--- +source: loco-gen/tests/templates/migration.rs +expression: "fs::read_to_string(&migration_file).expect(\"Failed to read the migration file\")" +--- +use loco_rs::schema::table_auto_tz; +use sea_orm_migration::{prelude::*, schema::*}; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + table_auto_tz(GroupUsers::Table) + .primary_key( + Index::create() + .name("idx-create_join_table_users_and_groups-refs-pk") + .table(GroupUsers::Table) + .col(GroupUsers::UserId) + .col(GroupUsers::GroupId) + , + ) + .col(integer(GroupUsers::UserId)) + .col(integer(GroupUsers::GroupId)) + .foreign_key( + ForeignKey::create() + .name("fk-create_join_table_users_and_groups-user_ids") + .from(GroupUsers::Table, GroupUsers::UserId) + .to(Users::Table, Users::Id) + .on_delete(ForeignKeyAction::Cascade) + .on_update(ForeignKeyAction::Cascade), + ) + .foreign_key( + ForeignKey::create() + .name("fk-create_join_table_users_and_groups-group_ids") + .from(GroupUsers::Table, GroupUsers::GroupId) + .to(Groups::Table, Groups::Id) + .on_delete(ForeignKeyAction::Cascade) + .on_update(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(GroupUsers::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum GroupUsers { + Table, + UserId, + GroupId, + +} + +#[derive(DeriveIden)] +enum Users { + Table, + Id, +} +#[derive(DeriveIden)] +enum Groups { + Table, + Id, +} diff --git a/loco-gen/tests/templates/snapshots/generate[migration_file]@create_table_migration.snap b/loco-gen/tests/templates/snapshots/generate[migration_file]@create_table_migration.snap new file mode 100644 index 000000000..1b3a1d60a --- /dev/null +++ b/loco-gen/tests/templates/snapshots/generate[migration_file]@create_table_migration.snap @@ -0,0 +1,53 @@ +--- +source: loco-gen/tests/templates/migration.rs +expression: "fs::read_to_string(&migration_file).expect(\"Failed to read the migration file\")" +--- +use loco_rs::schema::table_auto_tz; +use sea_orm_migration::{prelude::*, schema::*}; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + table_auto_tz(Movies::Table) + .col(pk_auto(Movies::Id)) + .col(string_null(Movies::Title)) + .col(integer(Movies::UserId)) + .foreign_key( + ForeignKey::create() + .name("fk-movies-user_ids") + .from(Movies::Table, Movies::UserId) + .to(Users::Table, Users::Id) + .on_delete(ForeignKeyAction::Cascade) + .on_update(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(Movies::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum Movies { + Table, + Id, + Title, + UserId, + +} + +#[derive(DeriveIden)] +enum Users { + Table, + Id, +} diff --git a/loco-gen/tests/templates/snapshots/generate[migration_file]@empty_migration.snap b/loco-gen/tests/templates/snapshots/generate[migration_file]@empty_migration.snap new file mode 100644 index 000000000..5e0bca7ab --- /dev/null +++ b/loco-gen/tests/templates/snapshots/generate[migration_file]@empty_migration.snap @@ -0,0 +1,67 @@ +--- +source: loco-gen/tests/templates/migration.rs +expression: "fs::read_to_string(&migration_file).expect(\"Failed to read the migration file\")" +--- +use sea_orm_migration::{prelude::*, schema::*}; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[derive(DeriveIden)] +enum Movies { + Table, + Rating, +} + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // + // add column + // + /* + manager + .alter_table( + Table::alter() + .table(Movies::Table) + .add_column_if_not_exists(integer(Movies::Rating)) + .to_owned(), + ) + .await + */ + + // + // delete column + // + /* + manager + .alter_table( + Table::alter() + .table(Movies::Table) + .drop_column(Movies::Rating) + .to_owned(), + ) + .await + */ + + // + // create index + // + /* + manager + .create_index( + Index::create() + .name("idx-movies-rating") + .table(Movies::Table) + .col(Movies::Rating) + .to_owned(), + ) + .await; + */ + todo!() + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + Ok(()) + } +} diff --git a/loco-gen/tests/templates/snapshots/generate[migration_file]@model.snap b/loco-gen/tests/templates/snapshots/generate[migration_file]@model.snap new file mode 100644 index 000000000..295347657 --- /dev/null +++ b/loco-gen/tests/templates/snapshots/generate[migration_file]@model.snap @@ -0,0 +1,37 @@ +--- +source: loco-gen/tests/templates/model.rs +expression: "fs::read_to_string(&migration_file).expect(\"Failed to read the migration file\")" +--- +use loco_rs::schema::table_auto_tz; +use sea_orm_migration::{prelude::*, schema::*}; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + table_auto_tz(Movies::Table) + .col(pk_auto(Movies::Id)) + .col(string_null(Movies::Title)) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(Movies::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum Movies { + Table, + Id, + Title, + +} diff --git a/loco-gen/tests/templates/snapshots/generate[migration_file]@remove_columns_migration.snap b/loco-gen/tests/templates/snapshots/generate[migration_file]@remove_columns_migration.snap new file mode 100644 index 000000000..85e665a00 --- /dev/null +++ b/loco-gen/tests/templates/snapshots/generate[migration_file]@remove_columns_migration.snap @@ -0,0 +1,60 @@ +--- +source: loco-gen/tests/templates/migration.rs +expression: "fs::read_to_string(&migration_file).expect(\"Failed to read the migration file\")" +--- +use loco_rs::schema::*; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + alter(Users::Table) + + .drop_column(Users::Name) + + .to_owned(), + ) + .await?; + manager + .alter_table( + alter(Users::Table) + + .drop_column(Users::Age) + + .to_owned(), + ) + .await?; + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + alter(Users::Table) + .add_column(string_null(Users::Name)) + .to_owned(), + ) + .await?; + manager + .alter_table( + alter(Users::Table) + .add_column(integer_null(Users::Age)) + .to_owned(), + ) + .await?; + Ok(()) + } +} + +#[derive(DeriveIden)] +enum Users { + Table, + Name, + Age, + +} diff --git a/loco-gen/tests/templates/snapshots/generate[nginx]@deployment.snap b/loco-gen/tests/templates/snapshots/generate[nginx]@deployment.snap new file mode 100644 index 000000000..c9b95d776 --- /dev/null +++ b/loco-gen/tests/templates/snapshots/generate[nginx]@deployment.snap @@ -0,0 +1,25 @@ +--- +source: loco-gen/tests/templates/deployment.rs +expression: "fs::read_to_string(tree_fs.root.join(\"nginx\").join(\"default.conf\")).expect(\"nginx config missing\")" +--- +server { + listen 80; + server_name ~^(?\w*)\.localhost$; + + location / { + if ($http_x_subdomain = "") { + set $http_x_subdomain $subdomain; + } + proxy_set_header X-Subdomain $http_x_subdomain; + proxy_pass http://localhost:8080/; + } +} + +server { + listen 80; + server_name localhost; + + location / { + proxy_pass http://localhost:8080/; + } +} diff --git a/loco-gen/tests/templates/snapshots/generate[subject_t_file]@mailer.snap b/loco-gen/tests/templates/snapshots/generate[subject_t_file]@mailer.snap new file mode 100644 index 000000000..5ee058cc2 --- /dev/null +++ b/loco-gen/tests/templates/snapshots/generate[subject_t_file]@mailer.snap @@ -0,0 +1,5 @@ +--- +source: loco-gen/tests/templates/mailer.rs +expression: "fs::read_to_string(path).unwrap_or_else(|_| panic!(\"{name} missing\"))" +--- +guess what? welcome! diff --git a/loco-gen/tests/templates/snapshots/generate[test_model]@Api_scaffold.snap b/loco-gen/tests/templates/snapshots/generate[test_model]@Api_scaffold.snap new file mode 100644 index 000000000..e9c641f3f --- /dev/null +++ b/loco-gen/tests/templates/snapshots/generate[test_model]@Api_scaffold.snap @@ -0,0 +1,35 @@ +--- +source: loco-gen/tests/templates/scaffold.rs +expression: "fs::read_to_string(tests_path.join(\"movies.rs\")).expect(\"Failed to read movies.rs\")" +--- +use tester::app::App; +use loco_rs::testing::prelude::*; +use serial_test::serial; + +macro_rules! configure_insta { + ($($expr:expr),*) => { + let mut settings = insta::Settings::clone_current(); + settings.set_prepend_module_to_snapshot(false); + let _guard = settings.bind_to_scope(); + }; +} + +#[tokio::test] +#[serial] +async fn test_model() { + configure_insta!(); + + let boot = boot_test::().await.unwrap(); + seed::(&boot.app_context.db).await.unwrap(); + + // query your model, e.g.: + // + // let item = models::posts::Model::find_by_pid( + // &boot.app_context.db, + // "11111111-1111-1111-1111-111111111111", + // ) + // .await; + + // snapshot the result: + // assert_debug_snapshot!(item); +} diff --git a/loco-gen/tests/templates/snapshots/generate[test_model]@Html_scaffold.snap b/loco-gen/tests/templates/snapshots/generate[test_model]@Html_scaffold.snap new file mode 100644 index 000000000..e9c641f3f --- /dev/null +++ b/loco-gen/tests/templates/snapshots/generate[test_model]@Html_scaffold.snap @@ -0,0 +1,35 @@ +--- +source: loco-gen/tests/templates/scaffold.rs +expression: "fs::read_to_string(tests_path.join(\"movies.rs\")).expect(\"Failed to read movies.rs\")" +--- +use tester::app::App; +use loco_rs::testing::prelude::*; +use serial_test::serial; + +macro_rules! configure_insta { + ($($expr:expr),*) => { + let mut settings = insta::Settings::clone_current(); + settings.set_prepend_module_to_snapshot(false); + let _guard = settings.bind_to_scope(); + }; +} + +#[tokio::test] +#[serial] +async fn test_model() { + configure_insta!(); + + let boot = boot_test::().await.unwrap(); + seed::(&boot.app_context.db).await.unwrap(); + + // query your model, e.g.: + // + // let item = models::posts::Model::find_by_pid( + // &boot.app_context.db, + // "11111111-1111-1111-1111-111111111111", + // ) + // .await; + + // snapshot the result: + // assert_debug_snapshot!(item); +} diff --git a/loco-gen/tests/templates/snapshots/generate[test_model]@Htmx_scaffold.snap b/loco-gen/tests/templates/snapshots/generate[test_model]@Htmx_scaffold.snap new file mode 100644 index 000000000..e9c641f3f --- /dev/null +++ b/loco-gen/tests/templates/snapshots/generate[test_model]@Htmx_scaffold.snap @@ -0,0 +1,35 @@ +--- +source: loco-gen/tests/templates/scaffold.rs +expression: "fs::read_to_string(tests_path.join(\"movies.rs\")).expect(\"Failed to read movies.rs\")" +--- +use tester::app::App; +use loco_rs::testing::prelude::*; +use serial_test::serial; + +macro_rules! configure_insta { + ($($expr:expr),*) => { + let mut settings = insta::Settings::clone_current(); + settings.set_prepend_module_to_snapshot(false); + let _guard = settings.bind_to_scope(); + }; +} + +#[tokio::test] +#[serial] +async fn test_model() { + configure_insta!(); + + let boot = boot_test::().await.unwrap(); + seed::(&boot.app_context.db).await.unwrap(); + + // query your model, e.g.: + // + // let item = models::posts::Model::find_by_pid( + // &boot.app_context.db, + // "11111111-1111-1111-1111-111111111111", + // ) + // .await; + + // snapshot the result: + // assert_debug_snapshot!(item); +} diff --git a/loco-gen/tests/templates/snapshots/generate[test_model]@model.snap b/loco-gen/tests/templates/snapshots/generate[test_model]@model.snap new file mode 100644 index 000000000..795cdb00a --- /dev/null +++ b/loco-gen/tests/templates/snapshots/generate[test_model]@model.snap @@ -0,0 +1,35 @@ +--- +source: loco-gen/tests/templates/model.rs +expression: "fs::read_to_string(tests_path.join(\"movies.rs\")).expect(\"Failed to read movies.rs\")" +--- +use tester::app::App; +use loco_rs::testing::prelude::*; +use serial_test::serial; + +macro_rules! configure_insta { + ($($expr:expr),*) => { + let mut settings = insta::Settings::clone_current(); + settings.set_prepend_module_to_snapshot(false); + let _guard = settings.bind_to_scope(); + }; +} + +#[tokio::test] +#[serial] +async fn test_model() { + configure_insta!(); + + let boot = boot_test::().await.unwrap(); + seed::(&boot.app_context.db).await.unwrap(); + + // query your model, e.g.: + // + // let item = models::posts::Model::find_by_pid( + // &boot.app_context.db, + // "11111111-1111-1111-1111-111111111111", + // ) + // .await; + + // snapshot the result: + // assert_debug_snapshot!(item); +} diff --git a/loco-gen/tests/templates/snapshots/generate[tests_controller_mod_rs]@Api_controller.snap b/loco-gen/tests/templates/snapshots/generate[tests_controller_mod_rs]@Api_controller.snap new file mode 100644 index 000000000..3cf9867ff --- /dev/null +++ b/loco-gen/tests/templates/snapshots/generate[tests_controller_mod_rs]@Api_controller.snap @@ -0,0 +1,40 @@ +--- +source: loco-gen/tests/templates/controller.rs +expression: "fs::read_to_string(test_controllers_path.join(\"movie.rs\")).expect(\"test file missing\")" +--- +use tester::app::App; +use loco_rs::testing::prelude::*; +use serial_test::serial; + +#[tokio::test] +#[serial] +async fn can_get_movies() { + request::(|request, _ctx| async move { + let res = request.get("/api/movies/").await; + assert_eq!(res.status_code(), 200); + + // you can assert content like this: + // assert_eq!(res.text(), "content"); + }) + .await; +} + +#[tokio::test] +#[serial] +async fn can_get_GET() { + request::(|request, _ctx| async move { + let res = request.get("/movies/GET").await; + assert_eq!(res.status_code(), 200); + }) + .await; +} + +#[tokio::test] +#[serial] +async fn can_get_POST() { + request::(|request, _ctx| async move { + let res = request.get("/movies/POST").await; + assert_eq!(res.status_code(), 200); + }) + .await; +} diff --git a/loco-gen/tests/templates/snapshots/generate[tests_task_file]@task.snap b/loco-gen/tests/templates/snapshots/generate[tests_task_file]@task.snap new file mode 100644 index 000000000..a950c42a1 --- /dev/null +++ b/loco-gen/tests/templates/snapshots/generate[tests_task_file]@task.snap @@ -0,0 +1,21 @@ +--- +source: loco-gen/tests/templates/task.rs +expression: "fs::read_to_string(tests_task_path.join(\"cleanup.rs\")).expect(\"Failed to read generated tests task file: cleanup.rs\")" +--- +use tester::app::App; +use loco_rs::{task, testing::prelude::*}; + +use loco_rs::boot::run_task; +use serial_test::serial; + +#[tokio::test] +#[serial] +async fn test_can_run_cleanup() { + let boot = boot_test::().await.unwrap(); + + assert!( + run_task::(&boot.app_context, Some(&"cleanup".to_string()), &task::Vars::default()) + .await + .is_ok() + ); +} diff --git a/loco-gen/tests/templates/snapshots/generate[tests_worker_file]@worker.snap b/loco-gen/tests/templates/snapshots/generate[tests_worker_file]@worker.snap new file mode 100644 index 000000000..155ddd1aa --- /dev/null +++ b/loco-gen/tests/templates/snapshots/generate[tests_worker_file]@worker.snap @@ -0,0 +1,24 @@ +--- +source: loco-gen/tests/templates/worker.rs +expression: "fs::read_to_string(tests_worker_path.join(\"register_email.rs\")).expect(\"Failed to read generated tests worker file: register_email.rs\")" +--- +use loco_rs::{bgworker::BackgroundWorker, testing::prelude::*}; +use tester::{ + app::App, + workers::register_email::{Worker, WorkerArgs}, +}; +use serial_test::serial; + +#[tokio::test] +#[serial] +async fn test_run_register_email_worker() { + let boot = boot_test::().await.unwrap(); + + // Execute the worker ensuring that it operates in 'ForegroundBlocking' mode, which prevents the addition of your worker to the background + assert!( + Worker::perform_later(&boot.app_context,WorkerArgs {}) + .await + .is_ok() + ); + // Include additional assert validations after the execution of the worker +} diff --git a/loco-gen/tests/templates/snapshots/generate[text_t_file]@mailer.snap b/loco-gen/tests/templates/snapshots/generate[text_t_file]@mailer.snap new file mode 100644 index 000000000..fbf556b9f --- /dev/null +++ b/loco-gen/tests/templates/snapshots/generate[text_t_file]@mailer.snap @@ -0,0 +1,5 @@ +--- +source: loco-gen/tests/templates/mailer.rs +expression: "fs::read_to_string(path).unwrap_or_else(|_| panic!(\"{name} missing\"))" +--- +welcome to acmeworld! diff --git a/loco-gen/tests/templates/snapshots/generate[views_[create]]@Html_scaffold.snap b/loco-gen/tests/templates/snapshots/generate[views_[create]]@Html_scaffold.snap new file mode 100644 index 000000000..2a9fd91b7 --- /dev/null +++ b/loco-gen/tests/templates/snapshots/generate[views_[create]]@Html_scaffold.snap @@ -0,0 +1,29 @@ +--- +source: loco-gen/tests/templates/scaffold.rs +expression: "fs::read_to_string(views_path.join(format!(\"{view}.html\"))).expect(\"view file missing\")" +--- +{% extends "base.html" %} + +{% block title %} +Create movie +{% endblock title %} + +{% block content %} +

Create new movie

+
+
+
+
+ +
+ +
+
+
+ +
+ +
+Back to movies +
+{% endblock content %} diff --git a/loco-gen/tests/templates/snapshots/generate[views_[create]]@Htmx_scaffold.snap b/loco-gen/tests/templates/snapshots/generate[views_[create]]@Htmx_scaffold.snap new file mode 100644 index 000000000..162098eac --- /dev/null +++ b/loco-gen/tests/templates/snapshots/generate[views_[create]]@Htmx_scaffold.snap @@ -0,0 +1,53 @@ +--- +source: loco-gen/tests/templates/scaffold.rs +expression: "fs::read_to_string(views_path.join(format!(\"{view}.html\"))).expect(\"view file missing\")" +--- +{% extends "base.html" %} + +{% block title %} +Create movie +{% endblock title %} + +{% block content %} +
+
+

Create new movie

+
+
+ +
+ +
+
+
+ +
+ +
+{% endblock content %} + +{% block js %} + +{% endblock js %} diff --git a/loco-gen/tests/templates/snapshots/generate[views_[edit]]@Html_scaffold.snap b/loco-gen/tests/templates/snapshots/generate[views_[edit]]@Html_scaffold.snap new file mode 100644 index 000000000..404bad6af --- /dev/null +++ b/loco-gen/tests/templates/snapshots/generate[views_[edit]]@Html_scaffold.snap @@ -0,0 +1,49 @@ +--- +source: loco-gen/tests/templates/scaffold.rs +expression: "fs::read_to_string(views_path.join(format!(\"{view}.html\"))).expect(\"view file missing\")" +--- +{% extends "base.html" %} + +{% block title %} +Edit movie: {{ item.id }} +{% endblock title %} + +{% block content %} +

Edit movie: {{ item.id }}

+
+
+
+
+ +
+ +
+
+
+ + +
+ +
+Back to movies +
+{% endblock content %} + +{% block js %} + +{% endblock js %} diff --git a/loco-gen/tests/templates/snapshots/generate[views_[edit]]@Htmx_scaffold.snap b/loco-gen/tests/templates/snapshots/generate[views_[edit]]@Htmx_scaffold.snap new file mode 100644 index 000000000..2c097aa50 --- /dev/null +++ b/loco-gen/tests/templates/snapshots/generate[views_[edit]]@Htmx_scaffold.snap @@ -0,0 +1,73 @@ +--- +source: loco-gen/tests/templates/scaffold.rs +expression: "fs::read_to_string(views_path.join(format!(\"{view}.html\"))).expect(\"view file missing\")" +--- +{% extends "base.html" %} + +{% block title %} +Edit movie: {{ item.id }} +{% endblock title %} + +{% block content %} +

Edit movie: {{ item.id }}

+
+
+
+
+ +
+ +
+
+
+
+ + +
+
+ +
+
+Back to movie +
+{% endblock content %} + +{% block js %} + +{% endblock js %} diff --git a/loco-gen/tests/templates/snapshots/generate[views_[list]]@Html_scaffold.snap b/loco-gen/tests/templates/snapshots/generate[views_[list]]@Html_scaffold.snap new file mode 100644 index 000000000..844f23a28 --- /dev/null +++ b/loco-gen/tests/templates/snapshots/generate[views_[list]]@Html_scaffold.snap @@ -0,0 +1,28 @@ +--- +source: loco-gen/tests/templates/scaffold.rs +expression: "fs::read_to_string(views_path.join(format!(\"{view}.html\"))).expect(\"view file missing\")" +--- +{% extends "base.html" %} + +{% block title %} +List of movie +{% endblock title %} + +{% block content %} +

movies

+
+{% for item in items %} +
+
+ + +
+ Edit + View +
+{% endfor %} +
+
+New movie +
+{% endblock content %} diff --git a/loco-gen/tests/templates/snapshots/generate[views_[list]]@Htmx_scaffold.snap b/loco-gen/tests/templates/snapshots/generate[views_[list]]@Htmx_scaffold.snap new file mode 100644 index 000000000..70c4c450c --- /dev/null +++ b/loco-gen/tests/templates/snapshots/generate[views_[list]]@Htmx_scaffold.snap @@ -0,0 +1,28 @@ +--- +source: loco-gen/tests/templates/scaffold.rs +expression: "fs::read_to_string(views_path.join(format!(\"{view}.html\"))).expect(\"view file missing\")" +--- +{% extends "base.html" %} + +{% block title %} +List of movie +{% endblock title %} + +{% block content %} +

movie

+
+ {% for item in items %} +
+
+ +
+ Edit + View +
+ {% endfor %} + +
+
+ New movie +
+{% endblock content %} diff --git a/loco-gen/tests/templates/snapshots/generate[views_[show]]@Html_scaffold.snap b/loco-gen/tests/templates/snapshots/generate[views_[show]]@Html_scaffold.snap new file mode 100644 index 000000000..4a61852b1 --- /dev/null +++ b/loco-gen/tests/templates/snapshots/generate[views_[show]]@Html_scaffold.snap @@ -0,0 +1,20 @@ +--- +source: loco-gen/tests/templates/scaffold.rs +expression: "fs::read_to_string(views_path.join(format!(\"{view}.html\"))).expect(\"view file missing\")" +--- +{% extends "base.html" %} + +{% block title %} +View movie: {{ item.id }} +{% endblock title %} + +{% block content %} +

View movie: {{ item.id }}

+
+
+ +
+
+Back to movies +
+{% endblock content %} diff --git a/loco-gen/tests/templates/snapshots/generate[views_[show]]@Htmx_scaffold.snap b/loco-gen/tests/templates/snapshots/generate[views_[show]]@Htmx_scaffold.snap new file mode 100644 index 000000000..60b7d03f9 --- /dev/null +++ b/loco-gen/tests/templates/snapshots/generate[views_[show]]@Htmx_scaffold.snap @@ -0,0 +1,20 @@ +--- +source: loco-gen/tests/templates/scaffold.rs +expression: "fs::read_to_string(views_path.join(format!(\"{view}.html\"))).expect(\"view file missing\")" +--- +{% extends "base.html" %} + +{% block title %} +View movie: {{ item.id }} +{% endblock title %} + +{% block content %} +

View movie: {{ item.id }}

+
+
+ +
+
+Back to movies +
+{% endblock content %} diff --git a/loco-gen/tests/templates/snapshots/generate[views_rs]@Html_scaffold.snap b/loco-gen/tests/templates/snapshots/generate[views_rs]@Html_scaffold.snap new file mode 100644 index 000000000..10e2d9230 --- /dev/null +++ b/loco-gen/tests/templates/snapshots/generate[views_rs]@Html_scaffold.snap @@ -0,0 +1,43 @@ +--- +source: loco-gen/tests/templates/scaffold.rs +expression: "fs::read_to_string(base_views_path.join(\"movie.rs\")).expect(\"Failed to read mod.rs\")" +--- +use loco_rs::prelude::*; + +use crate::models::_entities::movies; + +/// Render a list view of `movies`. +/// +/// # Errors +/// +/// When there is an issue with rendering the view. +pub fn list(v: &impl ViewRenderer, items: &Vec) -> Result { + format::render().view(v, "movie/list.html", data!({"items": items})) +} + +/// Render a single `movie` view. +/// +/// # Errors +/// +/// When there is an issue with rendering the view. +pub fn show(v: &impl ViewRenderer, item: &movies::Model) -> Result { + format::render().view(v, "movie/show.html", data!({"item": item})) +} + +/// Render a `movie` create form. +/// +/// # Errors +/// +/// When there is an issue with rendering the view. +pub fn create(v: &impl ViewRenderer) -> Result { + format::render().view(v, "movie/create.html", data!({})) +} + +/// Render a `movie` edit form. +/// +/// # Errors +/// +/// When there is an issue with rendering the view. +pub fn edit(v: &impl ViewRenderer, item: &movies::Model) -> Result { + format::render().view(v, "movie/edit.html", data!({"item": item})) +} diff --git a/loco-gen/tests/templates/snapshots/generate[views_rs]@Htmx_scaffold.snap b/loco-gen/tests/templates/snapshots/generate[views_rs]@Htmx_scaffold.snap new file mode 100644 index 000000000..10e2d9230 --- /dev/null +++ b/loco-gen/tests/templates/snapshots/generate[views_rs]@Htmx_scaffold.snap @@ -0,0 +1,43 @@ +--- +source: loco-gen/tests/templates/scaffold.rs +expression: "fs::read_to_string(base_views_path.join(\"movie.rs\")).expect(\"Failed to read mod.rs\")" +--- +use loco_rs::prelude::*; + +use crate::models::_entities::movies; + +/// Render a list view of `movies`. +/// +/// # Errors +/// +/// When there is an issue with rendering the view. +pub fn list(v: &impl ViewRenderer, items: &Vec) -> Result { + format::render().view(v, "movie/list.html", data!({"items": items})) +} + +/// Render a single `movie` view. +/// +/// # Errors +/// +/// When there is an issue with rendering the view. +pub fn show(v: &impl ViewRenderer, item: &movies::Model) -> Result { + format::render().view(v, "movie/show.html", data!({"item": item})) +} + +/// Render a `movie` create form. +/// +/// # Errors +/// +/// When there is an issue with rendering the view. +pub fn create(v: &impl ViewRenderer) -> Result { + format::render().view(v, "movie/create.html", data!({})) +} + +/// Render a `movie` edit form. +/// +/// # Errors +/// +/// When there is an issue with rendering the view. +pub fn edit(v: &impl ViewRenderer, item: &movies::Model) -> Result { + format::render().view(v, "movie/edit.html", data!({"item": item})) +} diff --git a/loco-gen/tests/templates/snapshots/generate_result@add_column_migration.snap b/loco-gen/tests/templates/snapshots/generate_result@add_column_migration.snap new file mode 100644 index 000000000..f58a3e8a6 --- /dev/null +++ b/loco-gen/tests/templates/snapshots/generate_result@add_column_migration.snap @@ -0,0 +1,5 @@ +--- +source: loco-gen/tests/templates/migration.rs +expression: collect_messages(&gen_result) +--- +* Migration `add_name_and_age_to_users` added! You can now apply it with `$ cargo loco db migrate`. diff --git a/loco-gen/tests/templates/snapshots/generate_result@add_reference_migration.snap b/loco-gen/tests/templates/snapshots/generate_result@add_reference_migration.snap new file mode 100644 index 000000000..72f0f020a --- /dev/null +++ b/loco-gen/tests/templates/snapshots/generate_result@add_reference_migration.snap @@ -0,0 +1,5 @@ +--- +source: loco-gen/tests/templates/migration.rs +expression: collect_messages(&gen_result) +--- +* Migration `add_user_ref_to_posts` added! You can now apply it with `$ cargo loco db migrate`. diff --git a/loco-gen/tests/templates/snapshots/generate_result@create_join_table_migration.snap b/loco-gen/tests/templates/snapshots/generate_result@create_join_table_migration.snap new file mode 100644 index 000000000..efb4cd183 --- /dev/null +++ b/loco-gen/tests/templates/snapshots/generate_result@create_join_table_migration.snap @@ -0,0 +1,5 @@ +--- +source: loco-gen/tests/templates/migration.rs +expression: collect_messages(&gen_result) +--- +* Migration for `CreateJoinTableUsersAndGroups` added! You can now apply it with `$ cargo loco db migrate`. diff --git a/loco-gen/tests/templates/snapshots/generate_result@create_table_migration.snap b/loco-gen/tests/templates/snapshots/generate_result@create_table_migration.snap new file mode 100644 index 000000000..3d7e1a523 --- /dev/null +++ b/loco-gen/tests/templates/snapshots/generate_result@create_table_migration.snap @@ -0,0 +1,5 @@ +--- +source: loco-gen/tests/templates/migration.rs +expression: collect_messages(&gen_result) +--- +* Migration for `movies` added! You can now apply it with `$ cargo loco db migrate`. diff --git a/loco-gen/tests/templates/snapshots/generate_result@empty_migration.snap b/loco-gen/tests/templates/snapshots/generate_result@empty_migration.snap new file mode 100644 index 000000000..5205c1502 --- /dev/null +++ b/loco-gen/tests/templates/snapshots/generate_result@empty_migration.snap @@ -0,0 +1,5 @@ +--- +source: loco-gen/tests/templates/migration.rs +expression: collect_messages(&gen_result) +--- +* Migration for `FixUsersTable` added! You can now apply it with `$ cargo loco db migrate`. diff --git a/loco-gen/tests/templates/snapshots/generate_result@remove_columns_migration.snap b/loco-gen/tests/templates/snapshots/generate_result@remove_columns_migration.snap new file mode 100644 index 000000000..34d3b08a6 --- /dev/null +++ b/loco-gen/tests/templates/snapshots/generate_result@remove_columns_migration.snap @@ -0,0 +1,5 @@ +--- +source: loco-gen/tests/templates/migration.rs +expression: collect_messages(&gen_result) +--- +* Migration `remove_name_and_age_from_users` added! You can now apply it with `$ cargo loco db migrate`. diff --git a/loco-gen/tests/templates/snapshots/generate_results@Api_controller.snap b/loco-gen/tests/templates/snapshots/generate_results@Api_controller.snap new file mode 100644 index 000000000..256297b81 --- /dev/null +++ b/loco-gen/tests/templates/snapshots/generate_results@Api_controller.snap @@ -0,0 +1,6 @@ +--- +source: loco-gen/tests/templates/controller.rs +expression: collect_messages(&gen_result) +--- +* Controller `Movie` was added successfully. +* Tests for controller `Movie` was added successfully. Run `cargo test`. diff --git a/loco-gen/tests/templates/snapshots/generate_results@Api_scaffold.snap b/loco-gen/tests/templates/snapshots/generate_results@Api_scaffold.snap new file mode 100644 index 000000000..ea85132dc --- /dev/null +++ b/loco-gen/tests/templates/snapshots/generate_results@Api_scaffold.snap @@ -0,0 +1,8 @@ +--- +source: loco-gen/tests/templates/scaffold.rs +expression: collect_messages(&gen_result) +--- +* Migration for `movie` added! You can now apply it with `$ cargo loco db migrate`. +* A test for model `Movies` was added. Run with `cargo test`. +* Controller `Movie` was added successfully. +* Tests for controller `Movie` was added successfully. Run `cargo test`. diff --git a/loco-gen/tests/templates/snapshots/generate_results@Html_controller.snap b/loco-gen/tests/templates/snapshots/generate_results@Html_controller.snap new file mode 100644 index 000000000..8d9b0e386 --- /dev/null +++ b/loco-gen/tests/templates/snapshots/generate_results@Html_controller.snap @@ -0,0 +1,7 @@ +--- +source: loco-gen/tests/templates/controller.rs +expression: collect_messages(&gen_result) +--- +* Controller `Movie` was added successfully. +* movie/GET view was added successfully. +* movie/POST view was added successfully. diff --git a/loco-gen/tests/templates/snapshots/generate_results@Html_scaffold.snap b/loco-gen/tests/templates/snapshots/generate_results@Html_scaffold.snap new file mode 100644 index 000000000..b3401007e --- /dev/null +++ b/loco-gen/tests/templates/snapshots/generate_results@Html_scaffold.snap @@ -0,0 +1,13 @@ +--- +source: loco-gen/tests/templates/scaffold.rs +expression: collect_messages(&gen_result) +--- +* Migration for `movie` added! You can now apply it with `$ cargo loco db migrate`. +* A test for model `Movies` was added. Run with `cargo test`. +* Base template was added successfully. +* Controller `Movie` was added successfully. +* movie view was added successfully. +* movie create view was added successfully. +* movie edit view was added successfully. +* movie list view was added successfully. +* movie view was added successfully. diff --git a/loco-gen/tests/templates/snapshots/generate_results@Htmx_controller.snap b/loco-gen/tests/templates/snapshots/generate_results@Htmx_controller.snap new file mode 100644 index 000000000..8d9b0e386 --- /dev/null +++ b/loco-gen/tests/templates/snapshots/generate_results@Htmx_controller.snap @@ -0,0 +1,7 @@ +--- +source: loco-gen/tests/templates/controller.rs +expression: collect_messages(&gen_result) +--- +* Controller `Movie` was added successfully. +* movie/GET view was added successfully. +* movie/POST view was added successfully. diff --git a/loco-gen/tests/templates/snapshots/generate_results@Htmx_scaffold.snap b/loco-gen/tests/templates/snapshots/generate_results@Htmx_scaffold.snap new file mode 100644 index 000000000..b3401007e --- /dev/null +++ b/loco-gen/tests/templates/snapshots/generate_results@Htmx_scaffold.snap @@ -0,0 +1,13 @@ +--- +source: loco-gen/tests/templates/scaffold.rs +expression: collect_messages(&gen_result) +--- +* Migration for `movie` added! You can now apply it with `$ cargo loco db migrate`. +* A test for model `Movies` was added. Run with `cargo test`. +* Base template was added successfully. +* Controller `Movie` was added successfully. +* movie view was added successfully. +* movie create view was added successfully. +* movie edit view was added successfully. +* movie list view was added successfully. +* movie view was added successfully. diff --git a/loco-gen/tests/templates/snapshots/inject[app_rs]@Api_controller.snap b/loco-gen/tests/templates/snapshots/inject[app_rs]@Api_controller.snap new file mode 100644 index 000000000..800f61a39 --- /dev/null +++ b/loco-gen/tests/templates/snapshots/inject[app_rs]@Api_controller.snap @@ -0,0 +1,11 @@ +--- +source: loco-gen/tests/templates/controller.rs +expression: "fs::read_to_string(tree_fs.root.join(\"src\").join(\"app.rs\")).expect(\"app.rs injection failed\")" +--- +impl Hooks for App { + fn routes(_ctx: &AppContext) -> AppRoutes { + AppRoutes::with_default_routes() // controller routes below + .add_route(controllers::movie::routes()) + .add_route(controllers::auth::routes()) + } + } diff --git a/loco-gen/tests/templates/snapshots/inject[app_rs]@Api_scaffold.snap b/loco-gen/tests/templates/snapshots/inject[app_rs]@Api_scaffold.snap new file mode 100644 index 000000000..f34e374c9 --- /dev/null +++ b/loco-gen/tests/templates/snapshots/inject[app_rs]@Api_scaffold.snap @@ -0,0 +1,11 @@ +--- +source: loco-gen/tests/templates/scaffold.rs +expression: "fs::read_to_string(tree_fs.root.join(\"src\").join(\"app.rs\")).expect(\"app.rs injection failed\")" +--- +impl Hooks for App { + fn routes(_ctx: &AppContext) -> AppRoutes { + AppRoutes::with_default_routes() // controller routes below + .add_route(controllers::movie::routes()) + .add_route(controllers::auth::routes()) + } + } diff --git a/loco-gen/tests/templates/snapshots/inject[app_rs]@Html_controller.snap b/loco-gen/tests/templates/snapshots/inject[app_rs]@Html_controller.snap new file mode 100644 index 000000000..800f61a39 --- /dev/null +++ b/loco-gen/tests/templates/snapshots/inject[app_rs]@Html_controller.snap @@ -0,0 +1,11 @@ +--- +source: loco-gen/tests/templates/controller.rs +expression: "fs::read_to_string(tree_fs.root.join(\"src\").join(\"app.rs\")).expect(\"app.rs injection failed\")" +--- +impl Hooks for App { + fn routes(_ctx: &AppContext) -> AppRoutes { + AppRoutes::with_default_routes() // controller routes below + .add_route(controllers::movie::routes()) + .add_route(controllers::auth::routes()) + } + } diff --git a/loco-gen/tests/templates/snapshots/inject[app_rs]@Html_scaffold.snap b/loco-gen/tests/templates/snapshots/inject[app_rs]@Html_scaffold.snap new file mode 100644 index 000000000..f34e374c9 --- /dev/null +++ b/loco-gen/tests/templates/snapshots/inject[app_rs]@Html_scaffold.snap @@ -0,0 +1,11 @@ +--- +source: loco-gen/tests/templates/scaffold.rs +expression: "fs::read_to_string(tree_fs.root.join(\"src\").join(\"app.rs\")).expect(\"app.rs injection failed\")" +--- +impl Hooks for App { + fn routes(_ctx: &AppContext) -> AppRoutes { + AppRoutes::with_default_routes() // controller routes below + .add_route(controllers::movie::routes()) + .add_route(controllers::auth::routes()) + } + } diff --git a/loco-gen/tests/templates/snapshots/inject[app_rs]@Htmx_controller.snap b/loco-gen/tests/templates/snapshots/inject[app_rs]@Htmx_controller.snap new file mode 100644 index 000000000..800f61a39 --- /dev/null +++ b/loco-gen/tests/templates/snapshots/inject[app_rs]@Htmx_controller.snap @@ -0,0 +1,11 @@ +--- +source: loco-gen/tests/templates/controller.rs +expression: "fs::read_to_string(tree_fs.root.join(\"src\").join(\"app.rs\")).expect(\"app.rs injection failed\")" +--- +impl Hooks for App { + fn routes(_ctx: &AppContext) -> AppRoutes { + AppRoutes::with_default_routes() // controller routes below + .add_route(controllers::movie::routes()) + .add_route(controllers::auth::routes()) + } + } diff --git a/loco-gen/tests/templates/snapshots/inject[app_rs]@Htmx_scaffold.snap b/loco-gen/tests/templates/snapshots/inject[app_rs]@Htmx_scaffold.snap new file mode 100644 index 000000000..f34e374c9 --- /dev/null +++ b/loco-gen/tests/templates/snapshots/inject[app_rs]@Htmx_scaffold.snap @@ -0,0 +1,11 @@ +--- +source: loco-gen/tests/templates/scaffold.rs +expression: "fs::read_to_string(tree_fs.root.join(\"src\").join(\"app.rs\")).expect(\"app.rs injection failed\")" +--- +impl Hooks for App { + fn routes(_ctx: &AppContext) -> AppRoutes { + AppRoutes::with_default_routes() // controller routes below + .add_route(controllers::movie::routes()) + .add_route(controllers::auth::routes()) + } + } diff --git a/loco-gen/tests/templates/snapshots/inject[app_rs]@task.snap b/loco-gen/tests/templates/snapshots/inject[app_rs]@task.snap new file mode 100644 index 000000000..82fef2651 --- /dev/null +++ b/loco-gen/tests/templates/snapshots/inject[app_rs]@task.snap @@ -0,0 +1,10 @@ +--- +source: loco-gen/tests/templates/task.rs +expression: "fs::read_to_string(tree_fs.root.join(\"src\").join(\"app.rs\")).expect(\"Failed to read updated app file: app.rs\")" +--- +impl Hooks for App { + #[allow(unused_variables)] + fn register_tasks(tasks: &mut Tasks) { + tasks.register(tasks::cleanup::Cleanup); + // tasks-inject (do not remove) + } diff --git a/loco-gen/tests/templates/snapshots/inject[app_rs]@worker.snap b/loco-gen/tests/templates/snapshots/inject[app_rs]@worker.snap new file mode 100644 index 000000000..2ef16e396 --- /dev/null +++ b/loco-gen/tests/templates/snapshots/inject[app_rs]@worker.snap @@ -0,0 +1,9 @@ +--- +source: loco-gen/tests/templates/worker.rs +expression: "fs::read_to_string(tree_fs.root.join(\"src\").join(\"app.rs\")).expect(\"Failed to read updated app file: app.rs\")" +--- +async fn connect_workers(ctx: &AppContext, queue: &Queue) -> Result<()> { + queue.register(crate::workers::register_email::Worker::build(ctx)).await?; + queue.register(DownloadWorker::build(ctx)).await?; + Ok(()) + } diff --git a/loco-gen/tests/templates/snapshots/inject[controller_mod_rs]@Api_controller.snap b/loco-gen/tests/templates/snapshots/inject[controller_mod_rs]@Api_controller.snap new file mode 100644 index 000000000..11382a19e --- /dev/null +++ b/loco-gen/tests/templates/snapshots/inject[controller_mod_rs]@Api_controller.snap @@ -0,0 +1,5 @@ +--- +source: loco-gen/tests/templates/controller.rs +expression: "fs::read_to_string(controllers_path.join(\"mod.rs\")).expect(\"mod.rs injection failed\")" +--- +pub mod movie; diff --git a/loco-gen/tests/templates/snapshots/inject[controller_mod_rs]@Api_scaffold.snap b/loco-gen/tests/templates/snapshots/inject[controller_mod_rs]@Api_scaffold.snap new file mode 100644 index 000000000..5c2677acf --- /dev/null +++ b/loco-gen/tests/templates/snapshots/inject[controller_mod_rs]@Api_scaffold.snap @@ -0,0 +1,5 @@ +--- +source: loco-gen/tests/templates/scaffold.rs +expression: "fs::read_to_string(controllers_path.join(\"mod.rs\")).expect(\"mod.rs injection failed\")" +--- +pub mod movie; diff --git a/loco-gen/tests/templates/snapshots/inject[controller_mod_rs]@Html_controller.snap b/loco-gen/tests/templates/snapshots/inject[controller_mod_rs]@Html_controller.snap new file mode 100644 index 000000000..11382a19e --- /dev/null +++ b/loco-gen/tests/templates/snapshots/inject[controller_mod_rs]@Html_controller.snap @@ -0,0 +1,5 @@ +--- +source: loco-gen/tests/templates/controller.rs +expression: "fs::read_to_string(controllers_path.join(\"mod.rs\")).expect(\"mod.rs injection failed\")" +--- +pub mod movie; diff --git a/loco-gen/tests/templates/snapshots/inject[controller_mod_rs]@Html_scaffold.snap b/loco-gen/tests/templates/snapshots/inject[controller_mod_rs]@Html_scaffold.snap new file mode 100644 index 000000000..5c2677acf --- /dev/null +++ b/loco-gen/tests/templates/snapshots/inject[controller_mod_rs]@Html_scaffold.snap @@ -0,0 +1,5 @@ +--- +source: loco-gen/tests/templates/scaffold.rs +expression: "fs::read_to_string(controllers_path.join(\"mod.rs\")).expect(\"mod.rs injection failed\")" +--- +pub mod movie; diff --git a/loco-gen/tests/templates/snapshots/inject[controller_mod_rs]@Htmx_controller.snap b/loco-gen/tests/templates/snapshots/inject[controller_mod_rs]@Htmx_controller.snap new file mode 100644 index 000000000..11382a19e --- /dev/null +++ b/loco-gen/tests/templates/snapshots/inject[controller_mod_rs]@Htmx_controller.snap @@ -0,0 +1,5 @@ +--- +source: loco-gen/tests/templates/controller.rs +expression: "fs::read_to_string(controllers_path.join(\"mod.rs\")).expect(\"mod.rs injection failed\")" +--- +pub mod movie; diff --git a/loco-gen/tests/templates/snapshots/inject[controller_mod_rs]@Htmx_scaffold.snap b/loco-gen/tests/templates/snapshots/inject[controller_mod_rs]@Htmx_scaffold.snap new file mode 100644 index 000000000..5c2677acf --- /dev/null +++ b/loco-gen/tests/templates/snapshots/inject[controller_mod_rs]@Htmx_scaffold.snap @@ -0,0 +1,5 @@ +--- +source: loco-gen/tests/templates/scaffold.rs +expression: "fs::read_to_string(controllers_path.join(\"mod.rs\")).expect(\"mod.rs injection failed\")" +--- +pub mod movie; diff --git a/loco-gen/tests/templates/snapshots/inject[mailer_mod_rs]@mailer.snap b/loco-gen/tests/templates/snapshots/inject[mailer_mod_rs]@mailer.snap new file mode 100644 index 000000000..73741d478 --- /dev/null +++ b/loco-gen/tests/templates/snapshots/inject[mailer_mod_rs]@mailer.snap @@ -0,0 +1,5 @@ +--- +source: loco-gen/tests/templates/mailer.rs +expression: "fs::read_to_string(path).unwrap_or_else(|_| panic!(\"{name} missing\"))" +--- +pub mod reset_password; diff --git a/loco-gen/tests/templates/snapshots/inject[migration_lib]@Api_scaffold.snap b/loco-gen/tests/templates/snapshots/inject[migration_lib]@Api_scaffold.snap new file mode 100644 index 000000000..362b2fe1b --- /dev/null +++ b/loco-gen/tests/templates/snapshots/inject[migration_lib]@Api_scaffold.snap @@ -0,0 +1,22 @@ +--- +source: loco-gen/tests/templates/scaffold.rs +expression: "fs::read_to_string(migration_path.join(\"lib.rs\")).expect(\"Failed to read lib.rs\")" +--- +#![allow(elided_lifetimes_in_paths)] +#![allow(clippy::wildcard_imports)] +pub use sea_orm_migration::prelude::*; +mod m[TIME]_users; + +mod m[TIME]_movies; +pub struct Migrator; + +#[async_trait::async_trait] +impl MigratorTrait for Migrator { + fn migrations() -> Vec> { + vec![ + Box::new(m[TIME]_users::Migration), + Box::new(m[TIME]_movies::Migration), + // inject-above (do not remove this comment) + ] + } +} diff --git a/loco-gen/tests/templates/snapshots/inject[migration_lib]@Html_scaffold.snap b/loco-gen/tests/templates/snapshots/inject[migration_lib]@Html_scaffold.snap new file mode 100644 index 000000000..362b2fe1b --- /dev/null +++ b/loco-gen/tests/templates/snapshots/inject[migration_lib]@Html_scaffold.snap @@ -0,0 +1,22 @@ +--- +source: loco-gen/tests/templates/scaffold.rs +expression: "fs::read_to_string(migration_path.join(\"lib.rs\")).expect(\"Failed to read lib.rs\")" +--- +#![allow(elided_lifetimes_in_paths)] +#![allow(clippy::wildcard_imports)] +pub use sea_orm_migration::prelude::*; +mod m[TIME]_users; + +mod m[TIME]_movies; +pub struct Migrator; + +#[async_trait::async_trait] +impl MigratorTrait for Migrator { + fn migrations() -> Vec> { + vec![ + Box::new(m[TIME]_users::Migration), + Box::new(m[TIME]_movies::Migration), + // inject-above (do not remove this comment) + ] + } +} diff --git a/loco-gen/tests/templates/snapshots/inject[migration_lib]@Htmx_scaffold.snap b/loco-gen/tests/templates/snapshots/inject[migration_lib]@Htmx_scaffold.snap new file mode 100644 index 000000000..362b2fe1b --- /dev/null +++ b/loco-gen/tests/templates/snapshots/inject[migration_lib]@Htmx_scaffold.snap @@ -0,0 +1,22 @@ +--- +source: loco-gen/tests/templates/scaffold.rs +expression: "fs::read_to_string(migration_path.join(\"lib.rs\")).expect(\"Failed to read lib.rs\")" +--- +#![allow(elided_lifetimes_in_paths)] +#![allow(clippy::wildcard_imports)] +pub use sea_orm_migration::prelude::*; +mod m[TIME]_users; + +mod m[TIME]_movies; +pub struct Migrator; + +#[async_trait::async_trait] +impl MigratorTrait for Migrator { + fn migrations() -> Vec> { + vec![ + Box::new(m[TIME]_users::Migration), + Box::new(m[TIME]_movies::Migration), + // inject-above (do not remove this comment) + ] + } +} diff --git a/loco-gen/tests/templates/snapshots/inject[migration_lib]@add_column_migration.snap b/loco-gen/tests/templates/snapshots/inject[migration_lib]@add_column_migration.snap new file mode 100644 index 000000000..979438194 --- /dev/null +++ b/loco-gen/tests/templates/snapshots/inject[migration_lib]@add_column_migration.snap @@ -0,0 +1,22 @@ +--- +source: loco-gen/tests/templates/migration.rs +expression: "fs::read_to_string(migration_path.join(\"lib.rs\")).expect(\"Failed to read lib.rs\")" +--- +#![allow(elided_lifetimes_in_paths)] +#![allow(clippy::wildcard_imports)] +pub use sea_orm_migration::prelude::*; +mod m[TIME]_users; + +mod m[TIME]_add_name_and_age_to_users; +pub struct Migrator; + +#[async_trait::async_trait] +impl MigratorTrait for Migrator { + fn migrations() -> Vec> { + vec![ + Box::new(m[TIME]_users::Migration), + Box::new(m[TIME]_add_name_and_age_to_users::Migration), + // inject-above (do not remove this comment) + ] + } +} diff --git a/loco-gen/tests/templates/snapshots/inject[migration_lib]@add_reference_migration.snap b/loco-gen/tests/templates/snapshots/inject[migration_lib]@add_reference_migration.snap new file mode 100644 index 000000000..a9e1dd4a8 --- /dev/null +++ b/loco-gen/tests/templates/snapshots/inject[migration_lib]@add_reference_migration.snap @@ -0,0 +1,22 @@ +--- +source: loco-gen/tests/templates/migration.rs +expression: "fs::read_to_string(migration_path.join(\"lib.rs\")).expect(\"Failed to read lib.rs\")" +--- +#![allow(elided_lifetimes_in_paths)] +#![allow(clippy::wildcard_imports)] +pub use sea_orm_migration::prelude::*; +mod m[TIME]_users; + +mod m[TIME]_add_user_ref_to_posts; +pub struct Migrator; + +#[async_trait::async_trait] +impl MigratorTrait for Migrator { + fn migrations() -> Vec> { + vec![ + Box::new(m[TIME]_users::Migration), + Box::new(m[TIME]_add_user_ref_to_posts::Migration), + // inject-above (do not remove this comment) + ] + } +} diff --git a/loco-gen/tests/templates/snapshots/inject[migration_lib]@create_join_table_migration.snap b/loco-gen/tests/templates/snapshots/inject[migration_lib]@create_join_table_migration.snap new file mode 100644 index 000000000..2796a3ffb --- /dev/null +++ b/loco-gen/tests/templates/snapshots/inject[migration_lib]@create_join_table_migration.snap @@ -0,0 +1,22 @@ +--- +source: loco-gen/tests/templates/migration.rs +expression: "fs::read_to_string(migration_path.join(\"lib.rs\")).expect(\"Failed to read lib.rs\")" +--- +#![allow(elided_lifetimes_in_paths)] +#![allow(clippy::wildcard_imports)] +pub use sea_orm_migration::prelude::*; +mod m[TIME]_users; + +mod m[TIME]_create_join_table_users_and_groups; +pub struct Migrator; + +#[async_trait::async_trait] +impl MigratorTrait for Migrator { + fn migrations() -> Vec> { + vec![ + Box::new(m[TIME]_users::Migration), + Box::new(m[TIME]_create_join_table_users_and_groups::Migration), + // inject-above (do not remove this comment) + ] + } +} diff --git a/loco-gen/tests/templates/snapshots/inject[migration_lib]@create_table_migration.snap b/loco-gen/tests/templates/snapshots/inject[migration_lib]@create_table_migration.snap new file mode 100644 index 000000000..82bfa541d --- /dev/null +++ b/loco-gen/tests/templates/snapshots/inject[migration_lib]@create_table_migration.snap @@ -0,0 +1,22 @@ +--- +source: loco-gen/tests/templates/migration.rs +expression: "fs::read_to_string(migration_path.join(\"lib.rs\")).expect(\"Failed to read lib.rs\")" +--- +#![allow(elided_lifetimes_in_paths)] +#![allow(clippy::wildcard_imports)] +pub use sea_orm_migration::prelude::*; +mod m[TIME]_users; + +mod m[TIME]_movies; +pub struct Migrator; + +#[async_trait::async_trait] +impl MigratorTrait for Migrator { + fn migrations() -> Vec> { + vec![ + Box::new(m[TIME]_users::Migration), + Box::new(m[TIME]_movies::Migration), + // inject-above (do not remove this comment) + ] + } +} diff --git a/loco-gen/tests/templates/snapshots/inject[migration_lib]@empty_migration.snap b/loco-gen/tests/templates/snapshots/inject[migration_lib]@empty_migration.snap new file mode 100644 index 000000000..27462454c --- /dev/null +++ b/loco-gen/tests/templates/snapshots/inject[migration_lib]@empty_migration.snap @@ -0,0 +1,22 @@ +--- +source: loco-gen/tests/templates/migration.rs +expression: "fs::read_to_string(migration_path.join(\"lib.rs\")).expect(\"Failed to read lib.rs\")" +--- +#![allow(elided_lifetimes_in_paths)] +#![allow(clippy::wildcard_imports)] +pub use sea_orm_migration::prelude::*; +mod m[TIME]_users; + +mod m[TIME]_fix_users_table; +pub struct Migrator; + +#[async_trait::async_trait] +impl MigratorTrait for Migrator { + fn migrations() -> Vec> { + vec![ + Box::new(m[TIME]_users::Migration), + Box::new(m[TIME]_fix_users_table::Migration), + // inject-above (do not remove this comment) + ] + } +} diff --git a/loco-gen/tests/templates/snapshots/inject[migration_lib]@model.snap b/loco-gen/tests/templates/snapshots/inject[migration_lib]@model.snap new file mode 100644 index 000000000..4d24a61e6 --- /dev/null +++ b/loco-gen/tests/templates/snapshots/inject[migration_lib]@model.snap @@ -0,0 +1,22 @@ +--- +source: loco-gen/tests/templates/model.rs +expression: "fs::read_to_string(migration_path.join(\"lib.rs\")).expect(\"Failed to read lib.rs\")" +--- +#![allow(elided_lifetimes_in_paths)] +#![allow(clippy::wildcard_imports)] +pub use sea_orm_migration::prelude::*; +mod m[TIME]_users; + +mod m[TIME]_movies; +pub struct Migrator; + +#[async_trait::async_trait] +impl MigratorTrait for Migrator { + fn migrations() -> Vec> { + vec![ + Box::new(m[TIME]_users::Migration), + Box::new(m[TIME]_movies::Migration), + // inject-above (do not remove this comment) + ] + } +} diff --git a/loco-gen/tests/templates/snapshots/inject[migration_lib]@remove_columns_migration.snap b/loco-gen/tests/templates/snapshots/inject[migration_lib]@remove_columns_migration.snap new file mode 100644 index 000000000..ad55620e2 --- /dev/null +++ b/loco-gen/tests/templates/snapshots/inject[migration_lib]@remove_columns_migration.snap @@ -0,0 +1,22 @@ +--- +source: loco-gen/tests/templates/migration.rs +expression: "fs::read_to_string(migration_path.join(\"lib.rs\")).expect(\"Failed to read lib.rs\")" +--- +#![allow(elided_lifetimes_in_paths)] +#![allow(clippy::wildcard_imports)] +pub use sea_orm_migration::prelude::*; +mod m[TIME]_users; + +mod m[TIME]_remove_name_and_age_from_users; +pub struct Migrator; + +#[async_trait::async_trait] +impl MigratorTrait for Migrator { + fn migrations() -> Vec> { + vec![ + Box::new(m[TIME]_users::Migration), + Box::new(m[TIME]_remove_name_and_age_from_users::Migration), + // inject-above (do not remove this comment) + ] + } +} diff --git a/loco-gen/tests/templates/snapshots/inject[task_mod_rs]@task.snap b/loco-gen/tests/templates/snapshots/inject[task_mod_rs]@task.snap new file mode 100644 index 000000000..73b231ea7 --- /dev/null +++ b/loco-gen/tests/templates/snapshots/inject[task_mod_rs]@task.snap @@ -0,0 +1,5 @@ +--- +source: loco-gen/tests/templates/task.rs +expression: "fs::read_to_string(task_path.join(\"mod.rs\")).expect(\"Failed to read updated task mod file: mod.rs\")" +--- +pub mod cleanup; diff --git a/loco-gen/tests/templates/snapshots/inject[test_mod]@Api_scaffold.snap b/loco-gen/tests/templates/snapshots/inject[test_mod]@Api_scaffold.snap new file mode 100644 index 000000000..276e714f8 --- /dev/null +++ b/loco-gen/tests/templates/snapshots/inject[test_mod]@Api_scaffold.snap @@ -0,0 +1,5 @@ +--- +source: loco-gen/tests/templates/scaffold.rs +expression: "fs::read_to_string(tests_path.join(\"mod.rs\")).expect(\"Failed to read mod.rs\")" +--- +mod movies; diff --git a/loco-gen/tests/templates/snapshots/inject[test_mod]@Html_scaffold.snap b/loco-gen/tests/templates/snapshots/inject[test_mod]@Html_scaffold.snap new file mode 100644 index 000000000..276e714f8 --- /dev/null +++ b/loco-gen/tests/templates/snapshots/inject[test_mod]@Html_scaffold.snap @@ -0,0 +1,5 @@ +--- +source: loco-gen/tests/templates/scaffold.rs +expression: "fs::read_to_string(tests_path.join(\"mod.rs\")).expect(\"Failed to read mod.rs\")" +--- +mod movies; diff --git a/loco-gen/tests/templates/snapshots/inject[test_mod]@Htmx_scaffold.snap b/loco-gen/tests/templates/snapshots/inject[test_mod]@Htmx_scaffold.snap new file mode 100644 index 000000000..276e714f8 --- /dev/null +++ b/loco-gen/tests/templates/snapshots/inject[test_mod]@Htmx_scaffold.snap @@ -0,0 +1,5 @@ +--- +source: loco-gen/tests/templates/scaffold.rs +expression: "fs::read_to_string(tests_path.join(\"mod.rs\")).expect(\"Failed to read mod.rs\")" +--- +mod movies; diff --git a/loco-gen/tests/templates/snapshots/inject[test_mod]@model.snap b/loco-gen/tests/templates/snapshots/inject[test_mod]@model.snap new file mode 100644 index 000000000..a4f87d8e0 --- /dev/null +++ b/loco-gen/tests/templates/snapshots/inject[test_mod]@model.snap @@ -0,0 +1,5 @@ +--- +source: loco-gen/tests/templates/model.rs +expression: "fs::read_to_string(tests_path.join(\"mod.rs\")).expect(\"Failed to read mod.rs\")" +--- +mod movies; diff --git a/loco-gen/tests/templates/snapshots/inject[tests_controller_mod_rs]@Api_controller.snap b/loco-gen/tests/templates/snapshots/inject[tests_controller_mod_rs]@Api_controller.snap new file mode 100644 index 000000000..6576a6b3f --- /dev/null +++ b/loco-gen/tests/templates/snapshots/inject[tests_controller_mod_rs]@Api_controller.snap @@ -0,0 +1,5 @@ +--- +source: loco-gen/tests/templates/controller.rs +expression: "fs::read_to_string(test_controllers_path.join(\"mod.rs\")).expect(\"test mod.rs missing\")" +--- +pub mod movie; diff --git a/loco-gen/tests/templates/snapshots/inject[tests_task_mod]@task.snap b/loco-gen/tests/templates/snapshots/inject[tests_task_mod]@task.snap new file mode 100644 index 000000000..6f5b4f380 --- /dev/null +++ b/loco-gen/tests/templates/snapshots/inject[tests_task_mod]@task.snap @@ -0,0 +1,5 @@ +--- +source: loco-gen/tests/templates/task.rs +expression: "fs::read_to_string(tests_task_path.join(\"mod.rs\")).expect(\"Failed to read updated tests task mod file: mod.rs\")" +--- +pub mod cleanup; diff --git a/loco-gen/tests/templates/snapshots/inject[tests_worker_mod]@worker.snap b/loco-gen/tests/templates/snapshots/inject[tests_worker_mod]@worker.snap new file mode 100644 index 000000000..c050cfb54 --- /dev/null +++ b/loco-gen/tests/templates/snapshots/inject[tests_worker_mod]@worker.snap @@ -0,0 +1,5 @@ +--- +source: loco-gen/tests/templates/worker.rs +expression: "fs::read_to_string(tests_worker_path.join(\"mod.rs\")).expect(\"Failed to read updated tests worker mod file: mod.rs\")" +--- +pub mod register_email; diff --git a/loco-gen/tests/templates/snapshots/inject[views_[GET]]@Html_controller.snap b/loco-gen/tests/templates/snapshots/inject[views_[GET]]@Html_controller.snap new file mode 100644 index 000000000..505079c3d --- /dev/null +++ b/loco-gen/tests/templates/snapshots/inject[views_[GET]]@Html_controller.snap @@ -0,0 +1,17 @@ +--- +source: loco-gen/tests/templates/controller.rs +expression: "fs::read_to_string(tree_fs.root.join(\"assets\").join(\"views\").join(\"movie\").join(format!(\"{}.html\",\naction.to_uppercase()))).expect(\"view file missing\")" +--- + + + + + + + + +

View GET

+ Find me in movie/GET + + + diff --git a/loco-gen/tests/templates/snapshots/inject[views_[GET]]@Htmx_controller.snap b/loco-gen/tests/templates/snapshots/inject[views_[GET]]@Htmx_controller.snap new file mode 100644 index 000000000..505079c3d --- /dev/null +++ b/loco-gen/tests/templates/snapshots/inject[views_[GET]]@Htmx_controller.snap @@ -0,0 +1,17 @@ +--- +source: loco-gen/tests/templates/controller.rs +expression: "fs::read_to_string(tree_fs.root.join(\"assets\").join(\"views\").join(\"movie\").join(format!(\"{}.html\",\naction.to_uppercase()))).expect(\"view file missing\")" +--- + + + + + + + + +

View GET

+ Find me in movie/GET + + + diff --git a/loco-gen/tests/templates/snapshots/inject[views_[POST]]@Html_controller.snap b/loco-gen/tests/templates/snapshots/inject[views_[POST]]@Html_controller.snap new file mode 100644 index 000000000..2d50b5e07 --- /dev/null +++ b/loco-gen/tests/templates/snapshots/inject[views_[POST]]@Html_controller.snap @@ -0,0 +1,17 @@ +--- +source: loco-gen/tests/templates/controller.rs +expression: "fs::read_to_string(tree_fs.root.join(\"assets\").join(\"views\").join(\"movie\").join(format!(\"{}.html\",\naction.to_uppercase()))).expect(\"view file missing\")" +--- + + + + + + + + +

View POST

+ Find me in movie/POST + + + diff --git a/loco-gen/tests/templates/snapshots/inject[views_[POST]]@Htmx_controller.snap b/loco-gen/tests/templates/snapshots/inject[views_[POST]]@Htmx_controller.snap new file mode 100644 index 000000000..2d50b5e07 --- /dev/null +++ b/loco-gen/tests/templates/snapshots/inject[views_[POST]]@Htmx_controller.snap @@ -0,0 +1,17 @@ +--- +source: loco-gen/tests/templates/controller.rs +expression: "fs::read_to_string(tree_fs.root.join(\"assets\").join(\"views\").join(\"movie\").join(format!(\"{}.html\",\naction.to_uppercase()))).expect(\"view file missing\")" +--- + + + + + + + + +

View POST

+ Find me in movie/POST + + + diff --git a/loco-gen/tests/templates/snapshots/inject[views_mod_rs]@Html_scaffold.snap b/loco-gen/tests/templates/snapshots/inject[views_mod_rs]@Html_scaffold.snap new file mode 100644 index 000000000..2577c364b --- /dev/null +++ b/loco-gen/tests/templates/snapshots/inject[views_mod_rs]@Html_scaffold.snap @@ -0,0 +1,5 @@ +--- +source: loco-gen/tests/templates/scaffold.rs +expression: "fs::read_to_string(base_views_path.join(\"mod.rs\")).expect(\"Failed to read mod.rs\")" +--- +pub mod movie; diff --git a/loco-gen/tests/templates/snapshots/inject[views_mod_rs]@Htmx_scaffold.snap b/loco-gen/tests/templates/snapshots/inject[views_mod_rs]@Htmx_scaffold.snap new file mode 100644 index 000000000..2577c364b --- /dev/null +++ b/loco-gen/tests/templates/snapshots/inject[views_mod_rs]@Htmx_scaffold.snap @@ -0,0 +1,5 @@ +--- +source: loco-gen/tests/templates/scaffold.rs +expression: "fs::read_to_string(base_views_path.join(\"mod.rs\")).expect(\"Failed to read mod.rs\")" +--- +pub mod movie; diff --git a/loco-gen/tests/templates/snapshots/inject[worker_mod_rs]@worker.snap b/loco-gen/tests/templates/snapshots/inject[worker_mod_rs]@worker.snap new file mode 100644 index 000000000..ecc24d0f3 --- /dev/null +++ b/loco-gen/tests/templates/snapshots/inject[worker_mod_rs]@worker.snap @@ -0,0 +1,5 @@ +--- +source: loco-gen/tests/templates/worker.rs +expression: "fs::read_to_string(worker_path.join(\"mod.rs\")).expect(\"Failed to read updated worker mod file: mod.rs\")" +--- +pub mod register_email; diff --git a/loco-gen/tests/templates/task.rs b/loco-gen/tests/templates/task.rs new file mode 100644 index 000000000..d54063819 --- /dev/null +++ b/loco-gen/tests/templates/task.rs @@ -0,0 +1,80 @@ +use super::utils::APP_TASK; +use insta::assert_snapshot; +use loco_gen::{collect_messages, generate, AppInfo, Component}; +use rrgen::RRgen; +use std::fs; + +macro_rules! configure_insta { + () => { + let mut settings = insta::Settings::clone_current(); + settings.set_prepend_module_to_snapshot(false); + settings.set_snapshot_suffix("task"); + let _guard = settings.bind_to_scope(); + }; +} + +#[test] +fn can_generate() { + configure_insta!(); + + let component = Component::Task { + name: "cleanup".to_string(), + }; + + let tree_fs = tree_fs::TreeBuilder::default() + .drop(true) + .add_empty("src/tasks/mod.rs") + .add_empty("tests/requests/mod.rs") + .add_empty("tests/tasks/mod.rs") + .add("src/app.rs", APP_TASK) + .create() + .expect("Failed to create tree_fs structure"); + + let rrgen = RRgen::with_working_dir(&tree_fs.root); + + let gen_result = generate( + &rrgen, + component, + &AppInfo { + app_name: "tester".to_string(), + }, + ) + .expect("Failed to generate components"); + + assert_eq!( + collect_messages(&gen_result), + r"* A Task `Cleanup` was added successfully. Run with `cargo run task cleanup`. +* Tests for task `Cleanup` was added successfully. Run `cargo test`. +" + ); + + let task_path = tree_fs.root.join("src").join("tasks"); + assert_snapshot!( + "generate[controller_file]", + fs::read_to_string(task_path.join("cleanup.rs")) + .expect("Failed to read generated task file: cleanup.rs") + ); + assert_snapshot!( + "inject[task_mod_rs]", + fs::read_to_string(task_path.join("mod.rs")) + .expect("Failed to read updated task mod file: mod.rs") + ); + assert_snapshot!( + "inject[app_rs]", + fs::read_to_string(tree_fs.root.join("src").join("app.rs")) + .expect("Failed to read updated app file: app.rs") + ); + + // Assertions for test files + let tests_task_path = tree_fs.root.join("tests").join("tasks"); + assert_snapshot!( + "generate[tests_task_file]", + fs::read_to_string(tests_task_path.join("cleanup.rs")) + .expect("Failed to read generated tests task file: cleanup.rs") + ); + assert_snapshot!( + "inject[tests_task_mod]", + fs::read_to_string(tests_task_path.join("mod.rs")) + .expect("Failed to read updated tests task mod file: mod.rs") + ); +} diff --git a/loco-gen/tests/templates/utils.rs b/loco-gen/tests/templates/utils.rs new file mode 100644 index 000000000..9cc9a1d05 --- /dev/null +++ b/loco-gen/tests/templates/utils.rs @@ -0,0 +1,62 @@ +use chrono::{Duration, Utc}; +use std::path::{Path, PathBuf}; + +pub const MIGRATION_SRC_LIB: &str = r" +#![allow(elided_lifetimes_in_paths)] +#![allow(clippy::wildcard_imports)] +pub use sea_orm_migration::prelude::*; +mod m20220101_000001_users; + +pub struct Migrator; + +#[async_trait::async_trait] +impl MigratorTrait for Migrator { + fn migrations() -> Vec> { + vec![ + Box::new(m20220101_000001_users::Migration), + // inject-above (do not remove this comment) + ] + } +} + "; + +pub const APP_ROUTS: &str = r" +impl Hooks for App { + fn routes(_ctx: &AppContext) -> AppRoutes { + AppRoutes::with_default_routes() // controller routes below + .add_route(controllers::auth::routes()) + } + } +"; + +pub const APP_TASK: &str = r" +impl Hooks for App { + #[allow(unused_variables)] + fn register_tasks(tasks: &mut Tasks) { + // tasks-inject (do not remove) + } +"; + +pub const APP_WORKER: &str = r" +async fn connect_workers(ctx: &AppContext, queue: &Queue) -> Result<()> { + queue.register(DownloadWorker::build(ctx)).await?; + Ok(()) + } +"; + +pub fn guess_file_by_time(path: &Path, file_format: &str, max_attempts: u32) -> Option { + let now = Utc::now(); + + for seconds_to_subtract in 0..=max_attempts { + let guessed_time = now - Duration::seconds(i64::from(seconds_to_subtract)); + let formatted_time = guessed_time.format("%Y%m%d_%H%M%S").to_string(); + let file_name = file_format.replace("{TIME}", &formatted_time); + + let file_path = path.join(file_name); + if file_path.exists() { + return Some(file_path); + } + } + + None +} diff --git a/loco-gen/tests/templates/worker.rs b/loco-gen/tests/templates/worker.rs new file mode 100644 index 000000000..e1f2a521a --- /dev/null +++ b/loco-gen/tests/templates/worker.rs @@ -0,0 +1,80 @@ +use super::utils::APP_WORKER; +use insta::assert_snapshot; +use loco_gen::{collect_messages, generate, AppInfo, Component}; +use rrgen::RRgen; +use std::fs; + +macro_rules! configure_insta { + () => { + let mut settings = insta::Settings::clone_current(); + settings.set_prepend_module_to_snapshot(false); + settings.set_snapshot_suffix("worker"); + let _guard = settings.bind_to_scope(); + }; +} + +#[test] +fn can_generate() { + configure_insta!(); + + let component = Component::Worker { + name: "register_email".to_string(), + }; + + let tree_fs = tree_fs::TreeBuilder::default() + .drop(true) + .add_empty("src/workers/mod.rs") + .add_empty("tests/workers/mod.rs") + .add("src/app.rs", APP_WORKER) + .create() + .expect("Failed to create tree_fs structure"); + + let rrgen = RRgen::with_working_dir(&tree_fs.root); + + let gen_result = generate( + &rrgen, + component, + &AppInfo { + app_name: "tester".to_string(), + }, + ) + .expect("Failed to generate components"); + + assert_eq!( + collect_messages(&gen_result), + r"* Test for worker `RegisterEmail` was added successfully. Run `cargo test`. +* A worker `RegisterEmail` was added successfully. Run with `cargo run start --worker`. +" + ); + + // Assertions for generated files + let worker_path = tree_fs.root.join("src").join("workers"); + assert_snapshot!( + "generate[controller_file]", + fs::read_to_string(worker_path.join("register_email.rs")) + .expect("Failed to read generated worker file: register_email.rs") + ); + assert_snapshot!( + "inject[worker_mod_rs]", + fs::read_to_string(worker_path.join("mod.rs")) + .expect("Failed to read updated worker mod file: mod.rs") + ); + assert_snapshot!( + "inject[app_rs]", + fs::read_to_string(tree_fs.root.join("src").join("app.rs")) + .expect("Failed to read updated app file: app.rs") + ); + + // Assertions for test files + let tests_worker_path = tree_fs.root.join("tests").join("workers"); + assert_snapshot!( + "generate[tests_worker_file]", + fs::read_to_string(tests_worker_path.join("register_email.rs")) + .expect("Failed to read generated tests worker file: register_email.rs") + ); + assert_snapshot!( + "inject[tests_worker_mod]", + fs::read_to_string(tests_worker_path.join("mod.rs")) + .expect("Failed to read updated tests worker mod file: mod.rs") + ); +} diff --git a/loco-new/Cargo.toml b/loco-new/Cargo.toml index 73b269a2a..03bee0710 100644 --- a/loco-new/Cargo.toml +++ b/loco-new/Cargo.toml @@ -50,7 +50,7 @@ uuid = { version = "1.11.0", features = ["v4", "fast-rng"] } serde_yaml = { version = "0.9" } insta = { version = "1.41.1", features = ["redactions", "yaml", "filters"] } rstest = { version = "0.23.0" } -tree-fs = "0.2.0" +tree-fs = "0.2.1" mockall = "0.13.0" toml = "0.8.19" regex = "1.11.1" diff --git a/loco-new/base_template/config/test.yaml.t b/loco-new/base_template/config/test.yaml.t index 267c60562..6d3f0ffb5 100644 --- a/loco-new/base_template/config/test.yaml.t +++ b/loco-new/base_template/config/test.yaml.t @@ -114,7 +114,7 @@ database: # Truncate database when application loaded. This is a dangerous operation, make sure that you using this flag only on dev environments or test mode dangerously_truncate: true # Recreating schema when application loaded. This is a dangerous operation, make sure that you using this flag only on dev environments or test mode - dangerously_recreate: false + dangerously_recreate: true {%- endif %} {%- if settings.auth %} diff --git a/loco-new/tests/templates/snapshots/r#mod__templates__db__config_test_yaml_config_database_Postgres.snap b/loco-new/tests/templates/snapshots/r#mod__templates__db__config_test_yaml_config_database_Postgres.snap index fc133c568..69809841a 100644 --- a/loco-new/tests/templates/snapshots/r#mod__templates__db__config_test_yaml_config_database_Postgres.snap +++ b/loco-new/tests/templates/snapshots/r#mod__templates__db__config_test_yaml_config_database_Postgres.snap @@ -1,7 +1,6 @@ --- source: tests/templates/db.rs expression: "format!(\"{:#?}\",\nassertion::yaml::get_value_at_path(&content, &[\"database\"]).unwrap())" -snapshot_kind: text --- Mapping { "uri": Mapping { @@ -37,5 +36,5 @@ Mapping { }, "auto_migrate": Bool(true), "dangerously_truncate": Bool(true), - "dangerously_recreate": Bool(false), + "dangerously_recreate": Bool(true), } diff --git a/loco-new/tests/templates/snapshots/r#mod__templates__db__config_test_yaml_config_database_Sqlite.snap b/loco-new/tests/templates/snapshots/r#mod__templates__db__config_test_yaml_config_database_Sqlite.snap index b45b01b9f..104dc69eb 100644 --- a/loco-new/tests/templates/snapshots/r#mod__templates__db__config_test_yaml_config_database_Sqlite.snap +++ b/loco-new/tests/templates/snapshots/r#mod__templates__db__config_test_yaml_config_database_Sqlite.snap @@ -1,7 +1,6 @@ --- source: tests/templates/db.rs expression: "format!(\"{:#?}\",\nassertion::yaml::get_value_at_path(&content, &[\"database\"]).unwrap())" -snapshot_kind: text --- Mapping { "uri": Mapping { @@ -37,5 +36,5 @@ Mapping { }, "auto_migrate": Bool(true), "dangerously_truncate": Bool(true), - "dangerously_recreate": Bool(false), + "dangerously_recreate": Bool(true), } diff --git a/loco-new/tests/wizard/new.rs b/loco-new/tests/wizard/new.rs index f23cd80e7..f57aa90a0 100644 --- a/loco-new/tests/wizard/new.rs +++ b/loco-new/tests/wizard/new.rs @@ -1,5 +1,3 @@ -use std::{fs, path::PathBuf, sync::Arc}; - use duct::cmd; use loco::{ generator::{executer::FileSystem, Generator}, @@ -7,28 +5,7 @@ use loco::{ wizard::{self, AssetsOption, BackgroundOption, DBOption}, OS, }; -use uuid::Uuid; - -struct TestDir { - pub path: PathBuf, -} - -impl TestDir { - fn new() -> Self { - let path = std::env::temp_dir() - .join("loco-test-generator") - .join(Uuid::new_v4().to_string()); - - fs::create_dir_all(&path).unwrap(); - Self { path } - } -} - -impl Drop for TestDir { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.path); - } -} +use std::{collections::HashMap, path::PathBuf, process::Output, sync::Arc}; #[cfg(feature = "test-wizard")] #[rstest::rstest] @@ -55,7 +32,7 @@ fn test_starter_combinations() { DBOption::None, BackgroundOption::None, AssetsOption::None, - false, + true, ); // REST API test_combination( @@ -91,74 +68,254 @@ fn test_combination( db: DBOption, background: BackgroundOption, asset: AssetsOption, - scaffold: bool, + test_generator: bool, ) { - use std::collections::HashMap; - - let test_dir = TestDir::new(); + let test_dir = tree_fs::TreeBuilder::default().drop(true); - let executor = FileSystem::new(&PathBuf::from("base_template"), &test_dir.path); + let executor = FileSystem::new(&PathBuf::from("base_template"), &test_dir.root); let wizard_selection = wizard::Selections { - db, - background, + db: db.clone(), + background: background.clone(), asset, }; let settings = settings::Settings::from_wizard("test-loco-template", &wizard_selection, OS::default()); - let res = Generator::new(Arc::new(executor), settings).run(); + let res = Generator::new(Arc::new(executor), settings.clone()).run(); assert!(res.is_ok()); let mut env_map: HashMap<_, _> = std::env::vars().collect(); env_map.insert("RUSTFLAGS".into(), "-D warnings".into()); - assert!(cmd!( - "cargo", - "clippy", - "--quiet", - "--", - "-W", - "clippy::pedantic", - "-W", - "clippy::nursery", - "-W", - "rust-2018-idioms" - ) - .full_env(&env_map) - // .stdout_null() - // .stderr_null() - .dir(test_dir.path.as_path()) - .run() - .is_ok()); - - cmd!("cargo", "test") - // .stdout_null() - // .stderr_null() - .full_env(&env_map) - .dir(test_dir.path.as_path()) - .run() - .expect("run test"); - if scaffold { + let tester = Tester { + dir: test_dir.root, + env_map, + }; + + tester + .run_clippy() + .expect("run clippy after create new project"); + + tester + .run_test() + .expect("run test after create new project"); + + if test_generator { + // Generate API controller + tester.run_generate(&vec![ + "controller", + "notes_api", + "--api", + "create_note", + "get_note", + ]); + + // Generate HTMX controller + tester.run_generate(&vec![ + "controller", + "notes_htmx", + "--htmx", + "create_note", + "get_note", + ]); + + // Generate HTML controller + tester.run_generate(&vec![ + "controller", + "notes_html", + "--html", + "create_note", + "get_note", + ]); + + // Generate Task + tester.run_generate(&vec!["task", "list_users"]); + + // Generate Scheduler + tester.run_generate(&vec!["scheduler"]); + + if background.enable() { + // Generate Worker + tester.run_generate(&vec!["worker", "cleanup"]); + } + + if settings.mailer { + // Generate Mailer + tester.run_generate(&vec!["mailer", "user_mailer"]); + } + + // Generate deployment nginx + tester.run_generate(&vec!["deployment", "--kind", "nginx"]); + + // Generate deployment nginx + tester.run_generate(&vec!["deployment", "--kind", "docker"]); + + if db.enable() { + // Generate Model + if !settings.auth { + tester.run_generate(&vec!["model", "users", "name:string", "email:string"]); + } + tester.run_generate(&vec!["model", "movies", "title:string", "user:references"]); + + // Generate HTMX Scaffold + tester.run_generate(&vec![ + "scaffold", + "movies_htmx", + "title:string", + "user:references", + "--htmx", + ]); + + // Generate HTML Scaffold + tester.run_generate(&vec![ + "scaffold", + "movies_html", + "title:string", + "user:references", + "--html", + ]); + + // Generate API Scaffold + tester.run_generate(&vec![ + "scaffold", + "movies_api", + "title:string", + "user:references", + "--api", + ]); + + // Generate CreatePosts migration + tester.run_generate_migration(&vec![ + "CreatePosts", + "title:string", + "user:references", + "movies:references", + ]); + + // Generate AddNameAndAgeToUsers migration + tester.run_generate_migration(&vec![ + "AddNameAndAgeToUsers", + "first_name:string", + "age:int", + ]); + + // Generate AddNameAndAgeToUsers migration + tester.run_generate_migration(&vec![ + "RemoveNameAndAgeFromUsers", + "first_name:string", + "age:int", + ]); + + // Generate AddUserRefToPosts migration + // TODO:: not working on sqlite. + // - thread 'main' panicked at 'Sqlite doesn't support multiple alter options' + // - Sqlite does not support modification of foreign key constraints to existing + // tester.run_generate_migration(&vec!["AddUserRefToPosts", "movies:references"]); + + // Generate CreateJoinTableUsersAndGroups migration + tester.run_generate_migration(&vec!["CreateJoinTableUsersAndGroups", "count:int"]); + } + } +} + +struct Tester { + dir: PathBuf, + env_map: HashMap, +} + +impl Tester { + fn run_clippy(&self) -> Result { cmd!( "cargo", - "loco", - "g", - "scaffold", - "movie", - "title:string", - "--htmx" + "clippy", + "--quiet", + "--", + "-W", + "clippy::pedantic", + "-W", + "clippy::nursery", + "-W", + "rust-2018-idioms" ) - .full_env(&env_map) - .dir(test_dir.path.as_path()) + .full_env(&self.env_map) + // .stdout_null() + // .stderr_null() + .dir(&self.dir) .run() - .expect("scaffold"); + } + + fn run_test(&self) -> Result { cmd!("cargo", "test") // .stdout_null() // .stderr_null() - .full_env(&env_map) - .dir(test_dir.path.as_path()) + .full_env(&self.env_map) + .dir(&self.dir) + .run() + } + + fn run_migrate(&self) -> Result { + cmd!("cargo", "loco", "db", "migrate") + // .stdout_null() + // .stderr_null() + .full_env(&self.env_map) + .dir(&self.dir) + .run() + } + + fn run_generate(&self, command: &Vec<&str>) { + let base_command = vec!["loco", "generate"]; + + // Concatenate base_command with the command vector + let mut args = base_command.clone(); + args.extend(command); + + duct::cmd("cargo", &args) + // .stdout_null() + // .stderr_null() + .full_env(&self.env_map) + .dir(&self.dir) .run() - .expect("test after scaffold"); + .unwrap_or_else(|_| panic!("generate `{}`", command.join(" "))); + + self.run_clippy() + .unwrap_or_else(|_| panic!("Run clippy after generate `{}`", command.join(" "))); + + self.run_test() + .unwrap_or_else(|_| panic!("Run Test after generate `{}`", command.join(" "))); + } + + fn run_generate_migration(&self, command: &Vec<&str>) { + let base_command = vec!["loco", "generate", "migration"]; + + // Concatenate base_command with the command vector + let mut args = base_command.clone(); + args.extend(command); + + duct::cmd("cargo", &args) + // .stdout_null() + // .stderr_null() + .full_env(&self.env_map) + .dir(&self.dir) + .run() + .unwrap_or_else(|_| panic!("generate `{}`", command.join(" "))); + + self.run_migrate().unwrap_or_else(|_| { + panic!( + "Run migrate after creating the migration `{}`", + command.join(" ") + ) + }); + + self.run_clippy().unwrap_or_else(|_| { + panic!( + "Run clippy after generate migration `{}`", + command.join(" ") + ) + }); + + self.run_test().unwrap_or_else(|_| { + panic!("Run Test after generate migration `{}`", command.join(" ")) + }); } } diff --git a/src/cli.rs b/src/cli.rs index 8fc4564d4..019e7b0e5 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -30,15 +30,17 @@ cfg_if::cfg_if! { feature = "with-db" ))] use std::process::exit; - -use std::{collections::BTreeMap, path::PathBuf}; +use std::{ + collections::BTreeMap, + path::{Path, PathBuf}, +}; #[cfg(any(feature = "bg_redis", feature = "bg_pg", feature = "bg_sqlt"))] use crate::bgworker::JobStatus; use clap::{ArgAction, Parser, Subcommand}; use colored::Colorize; use duct::cmd; -use loco_gen::{Component, ScaffoldKind}; +use loco_gen::{Component, DeploymentKind, ScaffoldKind}; use crate::{ app::{AppContext, Hooks}, @@ -264,7 +266,21 @@ enum ComponentArg { name: String, }, /// Generate a deployment infrastructure - Deployment {}, + Deployment { + // deployment kind. + #[clap(long, value_enum)] + kind: DeploymentKind, + }, + + /// Override templates and allows you to take control of them. You can always go back when deleting the local template. + Override { + /// The path to a specific template or directory to copy. + template_path: Option, + + /// Show available templates to copy under the specified directory without actually coping them. + #[arg(long, action)] + info: bool, + }, } impl ComponentArg { @@ -331,7 +347,7 @@ impl ComponentArg { Self::Scheduler {} => Ok(Component::Scheduler {}), Self::Worker { name } => Ok(Component::Worker { name }), Self::Mailer { name } => Ok(Component::Mailer { name }), - Self::Deployment {} => { + Self::Deployment { kind } => { let copy_asset_folder = &config .server .middlewares @@ -347,12 +363,19 @@ impl ComponentArg { .map(|a| a.fallback); Ok(Component::Deployment { + kind, asset_folder: copy_asset_folder.clone(), fallback_file: fallback_file.clone(), host: config.server.host.clone(), port: config.server.port, }) } + Self::Override { + template_path: _, + info: _, + } => Err(crate::Error::string( + "Error: Override could not be generated.", + )), } } } @@ -531,9 +554,6 @@ pub async fn playground() -> crate::Result { #[allow(clippy::too_many_lines)] #[allow(clippy::cognitive_complexity)] pub async fn main() -> crate::Result<()> { - use colored::Colorize; - use loco_gen::AppInfo; - let cli: Cli = Cli::parse(); let environment: Environment = cli.environment.unwrap_or_else(resolve_from_env).into(); @@ -619,12 +639,7 @@ pub async fn main() -> crate::Result<()> { run_scheduler::(&app_context, config.as_ref(), name, tag, list).await?; } Commands::Generate { component } => { - loco_gen::generate( - component.into_gen_component(&config)?, - &AppInfo { - app_name: H::app_name().to_string(), - }, - )?; + handle_generate_command::(component, &config)?; } Commands::Doctor { config: config_arg, @@ -677,9 +692,6 @@ pub async fn main() -> crate::Result<()> { #[cfg(not(feature = "with-db"))] pub async fn main() -> crate::Result<()> { - use colored::Colorize; - use loco_gen::AppInfo; - let cli = Cli::parse(); let environment: Environment = cli.environment.unwrap_or_else(resolve_from_env).into(); @@ -758,12 +770,7 @@ pub async fn main() -> crate::Result<()> { run_scheduler::(&app_context, config.as_ref(), name, tag, list).await?; } Commands::Generate { component } => { - loco_gen::generate( - component.into_gen_component(&config)?, - &AppInfo { - app_name: H::app_name().to_string(), - }, - )?; + handle_generate_command::(component, &config)?; } Commands::Version {} => { println!("{}", H::app_version(),); @@ -954,3 +961,133 @@ async fn handle_job_command( JobsCommands::Import { file } => queue.import(file.as_path()).await, } } + +fn handle_generate_command( + component: ComponentArg, + config: &Config, +) -> crate::Result<()> { + if let ComponentArg::Override { + template_path, + info, + } = component + { + match (template_path, info) { + // If no template path is provided, display the available templates, + // ignoring the `--info` flag. + (None, true | false) => { + let templates = loco_gen::template::collect(); + println!("{}", format_templates_as_tree(templates)); + } + // If a template path is provided and `--info` is enabled, + // display the templates from the specified path. + (Some(path), true) => { + let templates = loco_gen::template::collect_files_path(Path::new(&path)).unwrap(); + println!("{}", format_templates_as_tree(templates)); + } + // If a template path is provided and `--info` is disabled, + // copy the template to the default local template path. + (Some(path), false) => { + let copied_files = loco_gen::copy_template( + Path::new(&path), + Path::new(loco_gen::template::DEFAULT_LOCAL_TEMPLATE), + )?; + if copied_files.is_empty() { + println!("{}", "No templates were found to copy.".red()); + } else { + println!( + "{}", + "The following templates were successfully copied:".green() + ); + for f in copied_files { + println!(" * {}", f.display()); + } + } + } + } + } else { + let get_result = loco_gen::generate( + &loco_gen::RRgen::default(), + component.into_gen_component(config)?, + &loco_gen::AppInfo { + app_name: H::app_name().to_string(), + }, + )?; + let messages = loco_gen::collect_messages(&get_result); + println!("{messages}"); + }; + Ok(()) +} + +#[must_use] +pub fn format_templates_as_tree(paths: Vec) -> String { + let mut categories: BTreeMap>> = BTreeMap::new(); + + for path in paths { + if let Some(parent) = path.parent() { + let parent_str = parent.to_string_lossy().to_string(); + let mut components = parent_str.split('/'); + if let Some(top_level) = components.next() { + let top_key = top_level.to_string(); + let sub_key = components.next().unwrap_or("").to_string(); + + categories + .entry(top_key) + .or_default() + .entry(sub_key) + .or_default() + .push(path); + } + } + } + + let mut output = String::new(); + output.push_str("Available templates and directories to copy:\n\n"); + + for (top_level, sub_categories) in &categories { + output.push_str(&format!("{}", format!("{top_level}\n").yellow())); + + for (sub_category, paths) in sub_categories { + if !sub_category.is_empty() { + output.push_str(&format!("{}", format!(" └── {sub_category}\n").yellow())); + } + + for path in paths { + output.push_str(&format!( + " └── {}\n", + path.file_name().unwrap_or_default().to_string_lossy() + )); + } + } + } + + output.push_str(&format!("\n\n{}\n\n", "Usage Examples:".bold().green())); + output.push_str(&format!("{}", "Override a Specific File:\n".bold())); + output.push_str(&format!( + " * cargo loco generate override {}\n", + "scaffold/api/controller.t".yellow() + )); + output.push_str(&format!( + " * cargo loco generate override {}", + "migration/add_columns.t".yellow() + )); + output.push_str(&format!( + "{}", + "\n\nOverride All Files in a Folder:\n".bold() + )); + output.push_str(&format!( + " * cargo loco generate override {}\n", + "scaffold/htmx".yellow() + )); + output.push_str(&format!( + " * cargo loco generate override {}", + "task".yellow() + )); + // output.push_str(" * cargo loco generate override task"); + output.push_str(&format!("{}", "\n\nOverride All templates:\n".bold())); + output.push_str(&format!( + " * cargo loco generate override {}\n", + ".".yellow() + )); + + output +} diff --git a/src/logger.rs b/src/logger.rs index 8bcd53c0f..651d53f6e 100644 --- a/src/logger.rs +++ b/src/logger.rs @@ -76,6 +76,7 @@ const MODULE_WHITELIST: &[&str] = &[ "sqlx::query", "sidekiq", "playground", + "loco_gen", ]; // Keep nonblocking file appender work guard From 22844ee39a63051f74c65ae32a6da043d53b950e Mon Sep 17 00:00:00 2001 From: Elad Kaplan Date: Thu, 2 Jan 2025 01:38:33 -0800 Subject: [PATCH 11/11] order selector attributes (#1124) --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index ff9bf118d..bcaf1876b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -152,7 +152,7 @@ ulid = { version = "1", optional = true } rusty-sidekiq = { version = "0.11.0", default-features = false, optional = true } bb8 = { version = "0.8.1", optional = true } -scraper = { version = "0.21.0", optional = true } +scraper = { version = "0.21.0", features = ["deterministic"], optional = true } [workspace.dependencies] colored = { version = "2" }