Vendor dependencies

This commit is contained in:
2026-08-01 16:11:49 +03:00
parent 7f139a0241
commit 6b5e7f0f8b
29706 changed files with 9575646 additions and 0 deletions
File diff suppressed because one or more lines are too long
+6
View File
@@ -0,0 +1,6 @@
{
"git": {
"sha1": "471a499a68f901eb43ef8f3123f4efe2711a05e1"
},
"path_in_vcs": "loco-gen"
}
+1835
View File
File diff suppressed because it is too large Load Diff
+112
View File
@@ -0,0 +1,112 @@
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
#
# When uploading crates to the registry Cargo will automatically
# "normalize" Cargo.toml files for maximal compatibility
# with all versions of Cargo and also rewrite `path` dependencies
# to registry (e.g., crates.io) dependencies.
#
# If you are reading this file be aware that the original Cargo.toml
# will likely look very different (and much more reasonable).
# See Cargo.toml.orig for the original contents.
[package]
edition = "2024"
rust-version = "1.94"
name = "loco-gen"
version = "1.0.0"
build = false
autolib = false
autobins = false
autoexamples = false
autotests = false
autobenches = false
description = "Loco generators"
readme = false
license = "Apache-2.0"
[features]
with-db = []
[lib]
name = "loco_gen"
path = "src/lib.rs"
[[test]]
name = "db"
path = "tests/db.rs"
[[test]]
name = "mod"
path = "tests/mod.rs"
[dependencies.chrono]
version = "0.4"
features = ["serde"]
[dependencies.clap]
version = "4.4.7"
features = ["derive"]
[dependencies.colored]
version = "3.0"
[dependencies.cruet]
version = "1.0"
[dependencies.duct]
version = "1.0.0"
[dependencies.heck]
version = "0.5.0"
[dependencies.include_dir]
version = "0.7.4"
[dependencies.regex]
version = "1"
[dependencies.rrgen]
version = "0.5.6"
[dependencies.serde]
version = "1"
[dependencies.serde_json]
version = "1"
[dependencies.tera]
version = "1.19.1"
[dependencies.thiserror]
version = "2"
[dependencies.tracing]
version = "0.1.40"
[dev-dependencies.insta]
version = "1.41.1"
features = [
"redactions",
"yaml",
"filters",
]
[dev-dependencies.rstest]
version = "0.26.1"
[dev-dependencies.serial_test]
version = "3.1.1"
[dev-dependencies.syn]
version = "2"
features = ["full"]
[dev-dependencies.tree-fs]
version = "0.3"
[dev-dependencies.uuid]
version = "1.11.0"
features = [
"v4",
"fast-rng",
]
+38
View File
@@ -0,0 +1,38 @@
[package]
name = "loco-gen"
version = "1.0.0"
description = "Loco generators"
license.workspace = true
edition.workspace = true
rust-version.workspace = true
[features]
with-db = []
[lib]
path = "src/lib.rs"
[dependencies]
cruet = "1.0"
rrgen = "0.5.6"
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
regex = { workspace = true }
tracing = { workspace = true }
chrono = { workspace = true }
colored = { workspace = true }
heck = { workspace = true }
tera = { workspace = true }
duct = { workspace = true }
clap = { version = "4.4.7", features = ["derive"] }
include_dir = { version = "0.7.4" }
[dev-dependencies]
tree-fs = { version = "0.3" }
syn = { version = "2", features = ["full"] }
serial_test = "3.1.1"
uuid = { version = "1.11.0", features = ["v4", "fast-rng"] }
insta = { version = "1.41.1", features = ["redactions", "yaml", "filters"] }
rstest = "0.26.1"
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
use super::{AppInfo, GenerateResults, Result};
use crate as r#gen;
use rrgen::RRgen;
use serde_json::json;
use std::path::Path;
/// Loco 1.0 ships a single controller flavor (JSON API). `actions` names the
/// handlers to stub out in the generated controller.
pub fn generate(
rrgen: &RRgen,
name: &str,
actions: &[String],
appinfo: &AppInfo,
) -> Result<GenerateResults> {
let vars = json!({"name": name, "actions": actions, "pkg_name": appinfo.app_name});
r#gen::render_template(rrgen, Path::new("controller/api"), &vars)
}
+203
View File
@@ -0,0 +1,203 @@
//! Migration-name inference.
//!
//! Inflection convention (workspace-wide): **`cruet` is used only for
//! pluralization/singularization** (`to_plural`/`to_singular`), and **`heck` is
//! used for all case conversion** (snake/Pascal/UpperCamel). The two are NOT
//! interchangeable and must not be "consolidated" onto one crate: their casing
//! rules differ on acronyms and digits — e.g. `i32`→`i_32`, `f64`→`f_64`,
//! `HTTPServer`→`Httpserver` under `cruet` vs `i32`/`f64`/`HttpServer` under
//! `heck` — which would corrupt generated type names and identifiers.
//!
//! The one exception is right here: `guess_migration_type` normalizes the raw
//! migration *command* name with `cruet`'s snake-casing before splitting it into
//! `create`/`add`/`to`/... keyword parts. This is deliberate and load-bearing —
//! it is tuned to the tokens this parser matches; do not swap it for `heck`.
use cruet::{case::snake::to_snake_case, Inflector};
#[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", parts @ ..] => parts
.iter()
.position(|&part| part == "and")
.map_or(MigrationType::Empty, |and_index| {
let first_parts = &parts[..and_index];
let second_parts = &parts[and_index + 1..];
if first_parts.is_empty() || second_parts.is_empty() {
return MigrationType::Empty;
}
let table_a = first_parts.join("_");
let table_b = second_parts.join("_");
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_infer_create_join_table_with_underscores() {
// Test the specific case that was failing
assert_eq!(
guess_migration_type("CreateJoinTableGlobal_recipesAndGlobal_materials"),
MigrationType::CreateJoinTable {
table_a: "global_recipe".to_string(),
table_b: "global_material".to_string()
}
);
}
#[test]
fn test_infer_create_join_table_complex_names() {
// Test more complex table names with multiple underscores
assert_eq!(
guess_migration_type("CreateJoinTableUser_profilesAndGroup_members"),
MigrationType::CreateJoinTable {
table_a: "user_profile".to_string(),
table_b: "group_member".to_string()
}
);
}
#[test]
fn test_infer_create_join_table_mixed_names() {
// Test one simple name and one complex name
assert_eq!(
guess_migration_type("CreateJoinTableUsersAndGroup_members"),
MigrationType::CreateJoinTable {
table_a: "user".to_string(),
table_b: "group_member".to_string()
}
);
}
#[test]
fn test_empty_migration() {
assert_eq!(
guess_migration_type("UnknownMigrationType"),
MigrationType::Empty
);
}
#[test]
fn test_infer_create_join_table_no_and_separator() {
// Test case where there's no "and" separator
assert_eq!(
guess_migration_type("CreateJoinTableUsersGroups"),
MigrationType::Empty
);
}
#[test]
fn test_infer_create_join_table_empty_after_and() {
// Test case where there are no parts after "and"
assert_eq!(
guess_migration_type("CreateJoinTableUsersAnd"),
MigrationType::Empty
);
}
#[test]
fn test_infer_create_join_table_empty_before_and() {
// Test case where there are no parts before "and"
assert_eq!(
guess_migration_type("CreateJoinTableAndGroups"),
MigrationType::Empty
);
}
#[test]
fn test_infer_create_join_table_multiple_ands() {
// Test case with multiple "and" separators (should use first one)
assert_eq!(
guess_migration_type("CreateJoinTableUsersAndGroupsAndMore"),
MigrationType::CreateJoinTable {
table_a: "user".to_string(),
table_b: "groups_and_more".to_string()
}
);
}
}
+444
View File
@@ -0,0 +1,444 @@
// this is because not using with-db renders some of the structs below unused
// TODO: should be more properly aligned with extracting out the db-related gen
// code and then feature toggling it
#![allow(dead_code)]
pub use rrgen::{GenResult, RRgen};
use serde_json::{json, Value};
mod controller;
use colored::Colorize;
use std::fmt::Write;
use std::{
fs,
path::{Path, PathBuf},
};
pub mod column;
#[cfg(feature = "with-db")]
mod infer;
#[cfg(feature = "with-db")]
mod migration;
#[cfg(feature = "with-db")]
mod model;
#[cfg(feature = "with-db")]
mod scaffold;
pub mod template;
#[cfg(test)]
mod testutil;
#[derive(Debug)]
pub struct GenerateResults {
rrgen: Vec<rrgen::GenResult>,
local_templates: Vec<PathBuf>,
}
#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error("{0}")]
Message(String),
#[error("template {} not found", path.display())]
TemplateNotFound { path: PathBuf },
#[error(transparent)]
RRgen(#[from] rrgen::Error),
#[error(transparent)]
IO(#[from] std::io::Error),
#[error(transparent)]
Any(#[from] Box<dyn std::error::Error + Send + Sync>),
}
impl Error {
pub fn msg(err: impl std::error::Error + Send + Sync + 'static) -> Self {
Self::Message(err.to_string()) //.bt()
}
}
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, Clone)]
pub enum DeploymentKind {
Docker {
copy_paths: Vec<PathBuf>,
is_client_side_rendering: bool,
},
Nginx {
host: String,
port: i32,
},
}
#[derive(Debug)]
pub enum Component {
#[cfg(feature = "with-db")]
Model {
/// Name of the thing to generate
name: String,
/// Whether to include timestamps (`created_at``updated_at`at columns) in the model
with_tz: bool,
/// Model fields, eg. title:string hits:int
fields: Vec<(String, String)>,
},
#[cfg(feature = "with-db")]
Migration {
/// Name of the migration file
name: String,
/// Whether to include timestamps (`created_at`, `updated_at` columns) in the migration
with_tz: bool,
/// Params fields, eg. title:string hits:int
fields: Vec<(String, String)>,
},
#[cfg(feature = "with-db")]
Scaffold {
/// Name of the thing to generate
name: String,
/// Whether to include timestamps (`created_at``updated_at`at columns) in the scaffold
with_tz: bool,
/// Model and params fields, eg. title:string hits:int
fields: Vec<(String, String)>,
/// Whether to also emit the React-SPA frontend (hooks + pages + routes
/// injection). Set when the app has a clientside `frontend/`; when
/// `false` only the typed backend (DTO + controller) is emitted.
frontend: bool,
},
Controller {
/// Name of the thing to generate
name: String,
/// Action names
actions: Vec<String>,
},
Task {
/// Name of the thing to generate
name: String,
},
Scheduler {},
Worker {
/// Name of the thing to generate
name: String,
},
Mailer {
/// Name of the thing to generate
name: String,
},
Data {
/// Name of the thing to generate
name: String,
},
Deployment {
kind: DeploymentKind,
},
}
pub struct AppInfo {
pub app_name: String,
}
#[must_use]
pub fn new_generator() -> RRgen {
RRgen::default()
}
/// Generate a component
///
/// # Errors
///
/// This function will return an error if it fails
pub fn generate(rrgen: &RRgen, component: Component, appinfo: &AppInfo) -> Result<GenerateResults> {
/*
(1)
XXX: remove hooks generic from child generator, materialize it here and pass it
means each generator accepts a [component, config, context] tuple
this will allow us to test without an app instance
(2) proceed to test individual generators
*/
let get_result = match component {
#[cfg(feature = "with-db")]
Component::Model {
name,
with_tz,
fields,
} => model::generate(rrgen, &name, with_tz, &fields, appinfo)?,
#[cfg(feature = "with-db")]
Component::Scaffold {
name,
with_tz,
fields,
frontend,
} => scaffold::generate(rrgen, &name, with_tz, &fields, frontend, appinfo)?,
#[cfg(feature = "with-db")]
Component::Migration {
name,
with_tz,
fields,
} => migration::generate(rrgen, &name, with_tz, &fields, appinfo)?,
Component::Controller { name, actions } => {
controller::generate(rrgen, &name, &actions, appinfo)?
}
Component::Task { name } => {
let vars = json!({"name": name, "pkg_name": appinfo.app_name});
render_template(rrgen, Path::new("task"), &vars)?
}
Component::Scheduler {} => {
let vars = json!({"pkg_name": appinfo.app_name});
render_template(rrgen, Path::new("scheduler"), &vars)?
}
Component::Worker { name } => {
let vars = json!({"name": name, "pkg_name": appinfo.app_name});
render_template(rrgen, Path::new("worker"), &vars)?
}
Component::Mailer { name } => {
let vars = json!({ "name": name });
render_template(rrgen, Path::new("mailer"), &vars)?
}
Component::Deployment { kind } => match kind {
DeploymentKind::Docker {
copy_paths,
is_client_side_rendering,
} => {
let vars = json!({
"pkg_name": appinfo.app_name,
"copy_paths": copy_paths,
"is_client_side_rendering": is_client_side_rendering,
});
render_template(rrgen, Path::new("deployment/docker"), &vars)?
}
DeploymentKind::Nginx { host, port } => {
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)?
}
},
Component::Data { name } => {
let vars = json!({ "name": name });
render_template(rrgen, Path::new("data"), &vars)?
}
};
Ok(get_result)
}
fn render_template(rrgen: &RRgen, template: &Path, vars: &Value) -> Result<GenerateResults> {
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).inspect_err(|_err| {
tracing::error!(custom_template = %custom_template.display(), "could not read custom template");
})?;
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(GenerateResults {
rrgen: gen_result,
local_templates,
})
}
#[must_use]
pub fn collect_messages(results: &GenerateResults) -> String {
let mut messages = String::new();
for res in &results.rrgen {
if let rrgen::GenResult::Generated {
message: Some(message),
} = res
{
let _ = writeln!(messages, "* {message}");
}
}
if !results.local_templates.is_empty() {
let _ = writeln!(messages);
let _ = writeln!(
messages,
"{}",
"The following templates were sourced from the local templates:".green()
);
for f in &results.local_templates {
let _ = writeln!(messages, "* {}", f.display());
}
}
messages
}
/// 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<Vec<PathBuf>> {
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(&copy_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 std::path::Path;
use super::*;
#[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"
);
for template_file in template_files {
let copy_file_path = temp_fs.root.join(template_file.path());
assert!(
copy_file_path.exists(),
"Copy file does not exist: {copy_file_path:?}"
);
let copy_content =
fs::read_to_string(&copy_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:?}"
);
}
}
#[test]
fn can_collect_messages() {
let gen_result = GenerateResults {
rrgen: vec![
GenResult::Skipped,
GenResult::Generated {
message: Some("test".to_string()),
},
GenResult::Generated {
message: Some("test2".to_string()),
},
GenResult::Generated { message: None },
],
local_templates: vec![
PathBuf::from("template").join("scheduler.t"),
PathBuf::from("template").join("task.t"),
],
};
let re = regex::Regex::new(r"\x1b\[[0-9;]*m").unwrap();
assert_eq!(
re.replace_all(&collect_messages(&gen_result), ""),
r"* test
* test2
The following templates were sourced from the local templates:
* template/scheduler.t
* template/task.t
"
);
}
}
+66
View File
@@ -0,0 +1,66 @@
use std::path::Path;
use chrono::Utc;
use rrgen::RRgen;
use serde_json::json;
use crate::{
infer, model::get_columns_and_references, render_template, AppInfo, GenerateResults, Result,
};
/// 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,
with_tz: bool,
fields: &[(String, String)],
appinfo: &AppInfo,
) -> Result<GenerateResults> {
let pkg_name: &str = &appinfo.app_name;
let ts = Utc::now();
let res = infer::guess_migration_type(name);
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, "with_tz": with_tz,"pkg_name": pkg_name, "is_link": false, "columns": columns, "references": references});
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});
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});
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});
render_template(rrgen, Path::new("migration/add_references.t"), &vars)
}
infer::MigrationType::CreateJoinTable { table_a, table_b } => {
let table = format!("{table_a}_{table_b}");
let (columns, extra_references) = get_columns_and_references(fields)?;
let references = [(table_a, String::new()), (table_b, String::new())]
.into_iter()
.chain(extra_references)
.collect::<Vec<_>>();
let vars = json!({"name": name, "table": table, "ts": ts, "pkg_name": pkg_name, "columns": columns, "references": references});
render_template(rrgen, Path::new("migration/join_table.t"), &vars)
}
infer::MigrationType::Empty => {
let vars = json!({"name": name, "ts": ts, "pkg_name": pkg_name});
render_template(rrgen, Path::new("migration/empty.t"), &vars)
}
}
}
+238
View File
@@ -0,0 +1,238 @@
use std::{collections::HashMap, env::current_dir, path::Path};
use chrono::Utc;
use duct::cmd;
use rrgen::RRgen;
use serde_json::json;
use crate::{
column::{self, ColumnKind},
render_template, AppInfo, Error, GenerateResults, Result,
};
/// 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"];
/// columns are <name>, <dbtype>: ("content", "string")
/// references are <to table, id col in from table>: ("user", `user_id`)
/// parsed from e.g.: model article content:string user:references
/// puts a `user_id` in articles, then fk to users
///
/// Parses every field through [`column::parse_column`] (via
/// [`column::columns_from_fields`]), the single source of truth for column
/// type information, then partitions the result: references never appear in
/// the returned `columns` list -- they are always emitted as a `BigInteger`
/// (i64) foreign key by the `create_table`/schema helper's own fk path, which
/// reads the `references` tuples returned here.
#[allow(clippy::type_complexity)]
pub fn get_columns_and_references(
fields: &[(String, String)],
) -> Result<(Vec<(String, String)>, Vec<(String, String)>)> {
let mut columns = Vec::new();
let mut references = Vec::new();
for col in column::columns_from_fields(fields)? {
match &col.kind {
ColumnKind::Reference { target, fk_field } => {
// A trailing `?` on the reference name is how the
// `create_table` schema helper is told the FK is nullable;
// see `column::parse_column`'s doc comment for the DSL.
let ref_name = if col.nullable {
format!("{target}?")
} else {
target.clone()
};
references.push((ref_name, fk_field.clone().unwrap_or_default()));
}
_ => {
columns.push((col.name.clone(), col.col_type()));
}
}
}
Ok((columns, references))
}
pub fn generate(
rrgen: &RRgen,
name: &str,
with_tz: bool,
fields: &[(String, String)],
appinfo: &AppInfo,
) -> Result<GenerateResults> {
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, "with_tz": with_tz,"pkg_name": pkg_name, "columns": columns, "references": references});
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}`",
))
})?;
}
Ok(gen_result)
}
#[cfg(test)]
mod tests {
use super::*;
fn to_field(name: &str, field_type: &str) -> (String, String) {
(name.to_string(), field_type.to_string())
}
#[test]
fn test_get_columns_with_field_types() {
let fields = [
to_field("expect_string_null", "string"),
to_field("expect_string", "string!"),
to_field("expect_unique", "string^"),
];
let res = get_columns_and_references(&fields).expect("Failed to parse fields");
let expected_columns = vec![
to_field("expect_string_null", "StringNull"),
to_field("expect_string", "String"),
to_field("expect_unique", "StringUniq"),
];
let expected_references: Vec<(String, String)> = vec![];
assert_eq!(res, (expected_columns, expected_references));
}
#[test]
fn test_get_columns_with_array_types() {
// Note: the `!`/`^` suffix now trails the *whole* spec (matching
// every other type's grammar in `column::parse_column`), not the
// `array` keyword -- i.e. `array:string!`, not the old `array!:string`.
let fields = [
to_field("expect_array_null", "array:string"),
to_field("expect_array", "array:string!"),
to_field("expect_array_uniq", "array:string^"),
];
let res = get_columns_and_references(&fields).expect("Failed to parse fields");
let expected_columns = vec![
to_field("expect_array_null", "array_null(ArrayColType::String)"),
to_field("expect_array", "array(ArrayColType::String)"),
to_field("expect_array_uniq", "array_uniq(ArrayColType::String)"),
];
let expected_references: Vec<(String, String)> = vec![];
assert_eq!(res, (expected_columns, expected_references));
}
#[test]
fn test_get_references_from_fields() {
let fields = [
to_field("user", "references"),
to_field("post", "references"),
];
let res = get_columns_and_references(&fields).expect("Failed to parse fields");
let expected_columns: Vec<(String, String)> = vec![];
let expected_references = vec![to_field("user", ""), to_field("post", "")];
assert_eq!(res, (expected_columns, expected_references));
}
#[test]
fn test_ignore_fields_are_filtered_out() {
let mut fields = vec![to_field("name", "string")];
for ignore_field in IGNORE_FIELDS {
fields.push(to_field(ignore_field, "string"));
}
let res = get_columns_and_references(&fields).expect("Failed to parse fields");
let expected_columns = vec![to_field("name", "StringNull")];
let expected_references: Vec<(String, String)> = vec![];
assert_eq!(res, (expected_columns, expected_references));
}
#[test]
fn unknown_or_malformed_types_are_rejected() {
// `string` takes no parameters; `column::parse_column` doesn't
// recognize the extra `:2` segment and rejects it as an unknown type
// rather than an arity mismatch (there is no longer a generic
// per-type arity table -- `parse_column`'s grammar is matched
// exhaustively per type).
let fields = vec![to_field("name", "string:2")];
assert!(get_columns_and_references(&fields).is_err());
// an empty spec is not a recognized base type name either.
let fields = vec![to_field("post", "")];
assert!(get_columns_and_references(&fields).is_err());
}
// ---- 1.0 canonical-value fixes -----------------------------------------
#[test]
fn test_int_is_64_bit_big_integer() {
// `int` maps to a 64-bit `BigInteger`. An earlier 1.0 iteration made it
// a 32-bit `Integer`, but that broke on SQLite (32-bit DTO vs the i64
// entity sea-orm generates → a non-compiling scaffold), so `int` was
// settled as i64, equal to `big_int`. Only `small_int` is 16-bit.
// Documented in the CHANGELOG ("the `int`/`unsigned` field types
// generate 64-bit columns").
let fields = [to_field("hits", "int!")];
let res = get_columns_and_references(&fields).expect("Failed to parse fields");
assert_eq!(res, (vec![to_field("hits", "BigInteger")], vec![]));
}
#[test]
fn test_big_int_is_64_bit_big_integer() {
let fields = [to_field("views", "big_int!")];
let res = get_columns_and_references(&fields).expect("Failed to parse fields");
assert_eq!(res, (vec![to_field("views", "BigInteger")], vec![]));
}
#[test]
fn test_reference_is_never_a_column_and_fk_is_64_bit() {
let fields = [to_field("user", "references")];
let res = get_columns_and_references(&fields).expect("Failed to parse fields");
// references never appear in the columns list -- the schema helper's
// fk path (fed by the `references` tuples) always emits the fk
// column itself, as a 64-bit `BigInteger`.
assert_eq!(res, (vec![], vec![to_field("user", "")]));
assert_eq!(
crate::column::parse_column("user", "references")
.expect("failed to parse")
.col_type(),
"BigInteger"
);
}
#[test]
fn test_decimal_required() {
let fields = [to_field("price", "decimal!")];
let res = get_columns_and_references(&fields).expect("Failed to parse fields");
assert_eq!(res, (vec![to_field("price", "Decimal")], vec![]));
}
}
+335
View File
@@ -0,0 +1,335 @@
use std::{collections::BTreeSet, path::Path};
use cruet::Inflector;
use heck::{ToLowerCamelCase, ToUpperCamelCase};
use rrgen::RRgen;
use serde_json::{json, Value};
use crate::{
column::{self, Column, ColumnKind, ScalarType},
model, render_template, AppInfo, GenerateResults, Result,
};
/// Loco 1.0 ships a single scaffold flavor built straight from
/// `column::Column` (the single source of truth for column type
/// information) via [`build_api_context`]. It is **adaptive**: the typed
/// backend (`src/dtos/<plural>.rs` + `src/controllers/<plural>.rs`) is always
/// emitted, so a headless/REST app gets a typed resource; the React-SPA
/// frontend (`frontend/src/api/<plural>.ts` + `frontend/src/pages/<plural>/
/// *.tsx` + the `routes.tsx` injection) is emitted only when `frontend` is
/// `true` -- i.e. the app has a clientside `frontend/` -- so non-SPA apps get
/// no orphan frontend files.
pub fn generate(
rrgen: &RRgen,
name: &str,
with_tz: bool,
fields: &[(String, String)],
frontend: bool,
appinfo: &AppInfo,
) -> Result<GenerateResults> {
// - 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 mut gen_result = model::generate(rrgen, name, with_tz, fields, appinfo)?;
let api_columns = column::columns_from_fields(fields)?;
let api_vars = build_api_context(name, &api_columns, with_tz, appinfo);
// Backend (DTO + controller) -- always emitted.
let res = render_template(rrgen, Path::new("scaffold/api"), &api_vars)?;
gen_result.rrgen.extend(res.rrgen);
gen_result.local_templates.extend(res.local_templates);
// Frontend (React Query hooks + pages + routes injection) -- only when the
// app has a clientside frontend.
if frontend {
let fres = render_template(rrgen, Path::new("scaffold/frontend"), &api_vars)?;
gen_result.rrgen.extend(fres.rrgen);
gen_result.local_templates.extend(fres.local_templates);
}
Ok(gen_result)
}
/// The `sea_orm::prelude` type this column's Rust type needs imported, or
/// `None` when it maps to a primitive/`String`/enum type that needs no
/// prelude import.
const fn prelude_import_for(col: &Column) -> Option<&'static str> {
let scalar = match &col.kind {
ColumnKind::Reference { .. } => return None,
ColumnKind::Array(scalar) | ColumnKind::Scalar(scalar) => scalar,
};
match scalar {
ScalarType::Decimal | ScalarType::DecimalLen { .. } | ScalarType::Money => Some("Decimal"),
ScalarType::DateTimeTz => Some("DateTimeWithTimeZone"),
ScalarType::DateTime => Some("DateTime"),
ScalarType::Date => Some("Date"),
ScalarType::Time => Some("Time"),
ScalarType::Uuid => Some("Uuid"),
_ => None,
}
}
/// Human-friendly label for a column, used by the frontend List/New/Edit/Show
/// templates, e.g. `published_at` -> `Published at` (only the leading
/// character is capitalized -- matches `examples/reference_spa`'s
/// hand-written labels such as "Created at").
fn humanize_label(field_name: &str) -> String {
let mut label = field_name.replace('_', " ");
if let Some(first) = label.get_mut(0..1) {
first.make_ascii_uppercase();
}
label
}
/// The HTML form control the frontend New/Edit templates should render for
/// this column. Finer-grained than `column::FormKind`: splits `Date` from
/// `Time`/`DateTime`/`DateTimeTz` (only a bare `Date` column gets
/// `type="date"`, the rest get `type="datetime-local"`), and splits
/// `Decimal`/`DecimalLen`/`Money` -- which round-trip through TS as plain
/// strings, so the reference SPA edits them as text, not `type="number"` --
/// from true numeric types.
const fn frontend_input_kind(col: &Column) -> &'static str {
match &col.kind {
ColumnKind::Reference { .. } => "number",
ColumnKind::Array(_) => "textarea",
ColumnKind::Scalar(scalar) => match scalar {
ScalarType::Enum { .. } => "select",
ScalarType::Bool => "checkbox",
ScalarType::Date => "date",
ScalarType::Time | ScalarType::DateTime | ScalarType::DateTimeTz => "datetime",
ScalarType::Decimal | ScalarType::DecimalLen { .. } | ScalarType::Money => {
"text_number"
}
ScalarType::Text
| ScalarType::Json
| ScalarType::Jsonb
| ScalarType::Blob
| ScalarType::VarBinary { .. }
| ScalarType::BinaryLen { .. } => "textarea",
ScalarType::SmallInt
| ScalarType::Int
| ScalarType::BigInt
| ScalarType::SmallUnsigned
| ScalarType::Unsigned
| ScalarType::BigUnsigned
| ScalarType::Float
| ScalarType::Double => "number",
ScalarType::String | ScalarType::Uuid => "text",
},
}
}
/// The `New.tsx` initial-state JS literal for a non-enum column: `bool` gets
/// `false`, any nullable column gets `null`, numeric-input columns get `0`,
/// everything else (`text/textarea/text_number/date/datetime`) gets `""`. Enum
/// columns are handled separately by the caller (first enum value wins,
/// ahead of this nullable/number/string fallback chain).
fn frontend_initial_value(col: &Column, input_kind: &str) -> String {
if matches!(&col.kind, ColumnKind::Scalar(ScalarType::Bool)) {
"false".to_string()
} else if col.nullable {
"null".to_string()
} else if input_kind == "number" {
"0".to_string()
} else {
"\"\"".to_string()
}
}
/// Builds the per-column template context for one user column (`fields` in
/// the DTO/controller/frontend templates), plus the enum definition it
/// carries when it's an enum column.
///
/// See the module-level docs on the generator rebuild plan for the exact
/// derivation rules: field naming, resource-prefixed enum types
/// (`{PascalSingular}{UpperCamel(column)}`, e.g. `PostStatus`), and the
/// `From<Model>`/`Set(...)` expressions per column shape. The `label`/
/// `is_enum`/`enum_type`/`options_const`/`nullable`/`input_kind`/
/// `initial_value` keys are frontend-only additions consumed by the
/// `frontend_*.t` templates (`loco-gen/src/templates/scaffold/api/`).
fn build_field(col: &Column, pascal_singular: &str) -> (Value, Option<Value>) {
let field_name = match &col.kind {
ColumnKind::Reference { target, .. } => format!("{target}_id"),
_ => col.name.clone(),
};
let label = humanize_label(&field_name);
if let ColumnKind::Scalar(ScalarType::Enum { values }) = &col.kind {
let enum_base = col.name.to_singular().to_upper_camel_case();
let enum_type = format!("{pascal_singular}{enum_base}");
let options_const = format!("{}_OPTIONS", col.name.to_uppercase());
let variants: Vec<Value> = values
.iter()
.map(|v| json!({ "variant": v.to_upper_camel_case(), "value": v }))
.collect();
let match_arms: Vec<Value> = values[1..]
.iter()
.map(|v| json!({ "variant": v.to_upper_camel_case(), "value": v }))
.collect();
let fallback_variant = values[0].to_upper_camel_case();
let rust_type = if col.nullable {
format!("Option<{enum_type}>")
} else {
enum_type.clone()
};
let from_expr = if col.nullable {
format!("m.{field_name}.map({enum_type}::from)")
} else {
format!("{enum_type}::from(m.{field_name})")
};
let set_expr = if col.nullable {
format!("params.{field_name}.map(|v| v.as_str().to_string())")
} else {
format!("params.{field_name}.as_str().to_string()")
};
let field = json!({
"field_name": field_name,
"rust_type": rust_type,
"ts_override": Value::Null,
"from_expr": from_expr,
"set_expr": set_expr,
"label": label,
"nullable": col.nullable,
"is_enum": true,
"enum_type": enum_type,
"options_const": options_const,
"input_kind": "select",
"initial_value": format!("\"{}\"", values[0]),
});
let enum_entry = json!({
"enum_type": enum_type,
"variants": variants,
"match_arms": match_arms,
"fallback_variant": fallback_variant,
"options_const": options_const,
});
return (field, Some(enum_entry));
}
let input_kind = frontend_input_kind(col);
let initial_value = frontend_initial_value(col, input_kind);
let field = json!({
"field_name": field_name,
"rust_type": col.dto_rust_type(),
"ts_override": col.ts_type(),
"from_expr": format!("m.{field_name}"),
"set_expr": format!("params.{field_name}"),
"label": label,
"nullable": col.nullable,
"is_enum": false,
"enum_type": Value::Null,
"options_const": Value::Null,
"input_kind": input_kind,
"initial_value": initial_value,
});
(field, None)
}
/// Builds the full Tera context for the API scaffold's `dto.t`/`controller.t`
/// templates: resource name forms (`cruet` for plural/singular, `heck` for
/// case -- see `infer.rs`'s inflection note), per-column field data, enum
/// definitions, and the `sea_orm::prelude` import line.
fn build_api_context(name: &str, columns: &[Column], with_tz: bool, appinfo: &AppInfo) -> Value {
let singular_raw = name.to_singular();
let plural_raw = name.to_plural();
let pascal_singular = singular_raw.to_upper_camel_case();
// `cruet::Inflector` also has a `to_snake_case`, which would otherwise
// shadow this call via method resolution -- fully-qualify to guarantee
// `heck`'s casing (see `infer.rs`'s inflection note: heck owns casing,
// cruet owns plural/singular only).
let snake_plural = heck::ToSnakeCase::to_snake_case(plural_raw.as_str());
let snake_singular = heck::ToSnakeCase::to_snake_case(singular_raw.as_str());
// Frontend-only resource-name forms: `pascal_plural` for
// `ListPostsParams`/`useListPosts`/the `List.tsx` `<h1>`, `camel_singular`
// for the `postKeys` query-key object and the `List.tsx` row-lambda
// binding.
let pascal_plural = plural_raw.to_upper_camel_case();
let camel_singular = singular_raw.to_lower_camel_case();
let mut fields = Vec::new();
let mut enums = Vec::new();
let mut prelude_imports: BTreeSet<&'static str> = BTreeSet::new();
for col in columns {
if let Some(import) = prelude_import_for(col) {
prelude_imports.insert(import);
}
let (field, enum_entry) = build_field(col, &pascal_singular);
fields.push(field);
if let Some(enum_entry) = enum_entry {
enums.push(enum_entry);
}
}
if with_tz {
prelude_imports.insert("DateTimeWithTimeZone");
}
let prelude_use = if prelude_imports.is_empty() {
Value::Null
} else {
let joined = prelude_imports.into_iter().collect::<Vec<_>>().join(", ");
Value::String(format!("use sea_orm::prelude::{{{joined}}};"))
};
// `Show.tsx`'s `<h1>` picks the first bare (non-`text`, non-nullable-vs-
// nullable-agnostic) `string` column as the "title" -- e.g. `title` in
// the reference `post` resource -- and the `<dl>` below it renders every
// *other* field. Falls back to the first column at all when no bare
// `string` column exists, so the template always has something to head
// the page with.
let title_field_name = columns
.iter()
.find(|c| matches!(c.kind, ColumnKind::Scalar(ScalarType::String)))
.or_else(|| columns.first())
.map(|c| c.name.clone());
// Route-injection payloads for `frontend/src/routes.tsx` (see
// `frontend_list.t`'s `injections`): built here, rather than looped in
// the template, because YAML block-scalar indentation inside a Tera
// frontmatter is fragile -- a single quoted flow scalar with `\n`
// (literal backslash-n, decoded to a real newline by `serde_yaml`) per
// line sidesteps that entirely. `frontend/src/routes.tsx` must carry the
// `// scaffold:imports` / `// scaffold:routes` anchor comments (a 2c
// dependency on the once-per-app base `routes.tsx`).
let frontend_imports_injection = ["Edit", "List", "New", "Show"]
.iter()
.map(|component| {
format!("import {{ {component} }} from './pages/{snake_plural}/{component}'")
})
.collect::<Vec<_>>()
.join("\\n");
let frontend_routes_injection = [
(String::new(), "List"),
("/new".to_string(), "New"),
("/:id".to_string(), "Show"),
("/:id/edit".to_string(), "Edit"),
]
.iter()
.map(|(suffix, component)| {
format!(" {{ path: '{snake_plural}{suffix}', element: <{component} /> }},")
})
.collect::<Vec<_>>()
.join("\\n");
json!({
"pascal_singular": pascal_singular,
"pascal_plural": pascal_plural,
"camel_singular": camel_singular,
"snake_plural": snake_plural,
"snake_singular": snake_singular,
"pkg_name": appinfo.app_name,
"with_tz": with_tz,
"prelude_use": prelude_use,
"fields": fields,
"enums": enums,
"title_field_name": title_field_name,
"frontend_imports_injection": frontend_imports_injection,
"frontend_routes_injection": frontend_routes_injection,
})
}
+216
View File
@@ -0,0 +1,216 @@
use std::path::{Path, PathBuf};
use include_dir::{include_dir, Dir, DirEntry, File};
use crate::{Error, Result};
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<PathBuf> {
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<Vec<PathBuf>> {
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<Vec<&File<'_>>> {
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<PathBuf> {
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 std::path::Path;
use super::*;
/// .
///
/// # Panics
///
/// Panics if .
#[must_use]
pub fn find_first_dir() -> &'static Dir<'static> {
TEMPLATES.dirs().next().expect("first folder")
}
/// .
#[must_use]
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));
}
}
@@ -0,0 +1,39 @@
{% set file_name = name | snake_case -%}
{% set module_name = file_name | pascal_case -%}
to: src/controllers/{{ file_name }}.rs
skip_exists: true
message: "Controller `{{module_name}}` was added successfully."
injections:
- into: src/controllers/mod.rs
append: true
content: "pub mod {{ file_name }};"
- into: src/app.rs
after: "AppRoutes::"
content: " .add_route(controllers::{{ file_name }}::routes())"
---
#![allow(clippy::missing_errors_doc)]
#![allow(clippy::unnecessary_struct_initialization)]
#![allow(clippy::unused_async)]
use loco_rs::prelude::*;
#[debug_handler]
pub async fn index(State(_ctx): State<AppContext>) -> Result<Response> {
format::empty()
}
{% for action in actions -%}
#[debug_handler]
pub async fn {{action}}(State(_ctx): State<AppContext>) -> Result<Response> {
format::empty()
}
{% endfor -%}
pub fn routes() -> Routes {
Routes::new()
.prefix("api/{{file_name | plural}}/")
.add("/", get(index))
{%- for action in actions %}
.add("{{action}}", get({{action}}))
{%- endfor %}
}
@@ -0,0 +1,39 @@
{% set file_name = name | snake_case -%}
{% set module_name = file_name | pascal_case -%}
to: tests/requests/{{ file_name }}.rs
skip_exists: true
message: "Tests for controller `{{module_name}}` was added successfully. Run `cargo test`."
injections:
- into: tests/requests/mod.rs
append: true
content: "pub mod {{ file_name }};"
---
use {{pkg_name}}::app::App;
use loco_rs::testing::prelude::*;
use serial_test::serial;
#[tokio::test]
#[serial]
async fn can_get_{{ name | plural | snake_case }}() {
request::<App, _, _>(|request, _ctx| async move {
let res = request.get("/api/{{ name | plural | snake_case }}").await;
assert_eq!(res.status_code(), 200);
// you can assert content like this:
// assert_eq!(res.text(), "content");
})
.await;
}
{% for action in actions -%}
#[tokio::test]
#[serial]
async fn can_get_{{action}}() {
request::<App, _, _>(|request, _ctx| async move {
let res = request.get("/api/{{ name | plural | snake_case }}/{{action}}").await;
assert_eq!(res.status_code(), 200);
})
.await;
}
{% endfor -%}
@@ -0,0 +1,10 @@
{% set module_name = name | snake_case -%}
{% set struct_name = module_name | pascal_case -%}
to: "src/data/mod.rs"
skip_exists: true
message: "Data module added"
injections:
- into: "src/lib.rs"
append: true
content: "pub mod data;"
---
@@ -0,0 +1,7 @@
{% set module_name = name | snake_case -%}
to: "data/{{module_name}}/data.json"
skip_exists: true
---
{
"is_loaded": true
}
+45
View File
@@ -0,0 +1,45 @@
{% set module_name = name | snake_case -%}
{% set struct_name = module_name | pascal_case -%}
to: "src/data/{{module_name}}.rs"
skip_exists: true
message: "Data loader `{{struct_name}}` was added successfully."
injections:
- into: "src/data/mod.rs"
append: true
content: "pub mod {{ module_name }};"
---
use loco_rs::{data, Result};
use serde::{Deserialize, Serialize};
use std::sync::OnceLock;
const DATA_FILE: &str = "{{module_name}}/data.json";
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct {{struct_name}} {
pub is_loaded: bool,
}
#[allow(dead_code)]
/// Reads the data from the JSON file.
///
/// # Errors
/// This function returns an error if the file cannot be read or deserialized.
pub async fn read() -> Result<{{struct_name}}> {
data::load_json_file(DATA_FILE).await
}
static DATA: OnceLock<{{struct_name}}> = OnceLock::new();
#[allow(dead_code)]
pub fn get() -> &'static {{struct_name}} {
DATA.get_or_init(|| data::load_json_file_sync(DATA_FILE).unwrap_or_default())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_access() {
assert!(&get().is_loaded);
}
}
@@ -0,0 +1,41 @@
{% set module_name = name | snake_case -%}
{% set struct_name = module_name | pascal_case -%}
to: "src/data/{{module_name}}.rs"
skip_exists: true
message: "Data loader `{{struct_name}}` was added successfully."
injections:
- into: "src/data/mod.rs"
append: true
content: "pub mod {{ module_name }};"
---
use loco_rs::{data, Result};
use serde::{Deserialize, Serialize};
use std::sync::OnceLock;
const DATA_FILE: &str = "{{module_name}}/data.json";
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct {{struct_name}} {
pub is_loaded: bool,
}
#[allow(dead_code)]
pub async fn read() -> Result<{{struct_name}}> {
data::load_json_file(DATA_FILE).await
}
static DATA: OnceLock<{{struct_name}}> = OnceLock::new();
#[allow(dead_code)]
pub fn get() -> &'static {{struct_name}} {
DATA.get_or_init(|| data::load_json_file_sync(DATA_FILE).unwrap_or_default())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_access() {
assert!(&get().is_loaded);
}
}
@@ -0,0 +1,34 @@
to: "Dockerfile"
skip_exists: true
message: "Dockerfile generated successfully."
---
FROM rust:1.92.0-slim AS builder
WORKDIR /usr/src/
COPY . .
{% if is_client_side_rendering -%}
RUN apt-get update && apt-get install -y curl ca-certificates
# Install Node.js using the latest available version from NodeSource.
# In production, replace "setup_current.x" with a specific version
# to avoid unexpected breaking changes in future releases.
RUN curl -fsSL https://deb.nodesource.com/setup_current.x | bash - && \
apt-get install -y nodejs
RUN cd frontend && npm install && npm run build
{% endif -%}
RUN cargo build --release
FROM debian:bookworm-slim
WORKDIR /usr/app
{% for path in copy_paths -%}
COPY --from=builder /usr/src/{{path}} {{path}}
{% endfor -%}
COPY --from=builder /usr/src/config config
COPY --from=builder /usr/src/target/release/{{pkg_name}}-cli {{pkg_name}}-cli
ENTRYPOINT ["/usr/app/{{pkg_name}}-cli"]
@@ -0,0 +1,9 @@
to: ".dockerignore"
skip_exists: true
message: "Dockerignore generated successfully."
---
target
Dockerfile
.dockerignore
.git
.gitignore
@@ -0,0 +1,25 @@
to: "nginx/default.conf"
skip_exists: true
message: "Nginx generated successfully."
---
server {
listen 80;
server_name ~^(?<subdomain>\w*)\.{{domain}}$;
location / {
if ($http_x_subdomain = "") {
set $http_x_subdomain $subdomain;
}
proxy_set_header X-Subdomain $http_x_subdomain;
proxy_pass http://{{domain}}:{{port}}/;
}
}
server {
listen 80;
server_name {{domain}};
location / {
proxy_pass http://{{domain}}:{{port}}/;
}
}
@@ -0,0 +1,11 @@
{% set module_name = name | snake_case -%}
{% set struct_name = module_name | pascal_case -%}
to: "src/mailers/{{module_name}}/welcome/html.t"
skip_exists: true
---
{% raw %}{% extends "base.t" %}{% endraw %}
{% raw %}{% block title %}{% endraw %}Welcome!{% raw %}{% endblock %}{% endraw %}
{% raw %}{% block body %}{% endraw %}
<h1>Welcome!</h1>
<p>welcome to <em>acmeworld!</em></p>
{% raw %}{% endblock %}{% endraw %}
@@ -0,0 +1,45 @@
{% set module_name = name | snake_case -%}
{% set struct_name = module_name | pascal_case -%}
to: "src/mailers/{{module_name}}.rs"
skip_exists: true
message: "A mailer `{{struct_name}}` was added successfully."
injections:
- into: "src/mailers/mod.rs"
append: true
content: "pub mod {{ module_name }};"
---
#![allow(non_upper_case_globals)]
use loco_rs::prelude::*;
use serde_json::json;
static shared: Dir<'_> = include_dir!("src/mailers/shared");
static welcome: Dir<'_> = include_dir!("src/mailers/{{module_name}}/welcome");
#[allow(clippy::module_name_repetitions)]
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_with_shared(
ctx,
&welcome,
&[&shared],
mailer::Args {
to: to.to_string(),
locals: json!({
"message": msg,
"domain": ctx.config.server.full_url()
}),
..Default::default()
},
)
.await?;
Ok(())
}
}
@@ -0,0 +1,24 @@
{% set module_name = name | snake_case -%}
{% set struct_name = module_name | pascal_case -%}
to: "src/mailers/shared/base.t"
skip_exists: true
---
<!DOCTYPE html>
<html>
<head>
<title>{% raw %}{% block title %}{% endraw %}Email{% raw %}{% endblock %}{% endraw %}</title>
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.footer { margin-top: 40px; padding-top: 20px; border-top: 1px solid #eee; color: #666; font-size: 12px; }
</style>
</head>
<body>
<div class="container">
{% raw %}{% block body %}{% endraw %}{% raw %}{% endblock %}{% endraw %}
<div class="footer">
{% raw %}{% block footer %}{% endraw %}{% raw %}{% endblock %}{% endraw %}
</div>
</div>
</body>
</html>
@@ -0,0 +1,6 @@
{% set module_name = name | snake_case -%}
{% set struct_name = module_name | pascal_case -%}
to: "src/mailers/shared/subject.t"
skip_exists: true
---
{% raw %}{% block subject %}{% endraw %}Email{% raw %}{% endblock %}{% endraw %}
@@ -0,0 +1,6 @@
{% set module_name = name | snake_case -%}
{% set struct_name = module_name | pascal_case -%}
to: "src/mailers/shared/text.t"
skip_exists: true
---
{% raw %}{% block text %}{% endraw %}{% raw %}{% endblock %}{% endraw %}
@@ -0,0 +1,6 @@
{% set module_name = name | snake_case -%}
{% set struct_name = module_name | pascal_case -%}
to: "src/mailers/{{module_name}}/welcome/subject.t"
skip_exists: true
---
guess what? welcome!
@@ -0,0 +1,6 @@
{% set module_name = name | snake_case -%}
{% set struct_name = module_name | pascal_case -%}
to: "src/mailers/{{module_name}}/welcome/text.t"
skip_exists: true
---
welcome to acmeworld!
@@ -0,0 +1,37 @@
{% set mig_ts = ts | date(format="%Y%m%d_%H%M%S") -%}
{% set mig_name = name | snake_case -%}
{% set plural_snake = table | plural | snake_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 && cargo loco db entities`."
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, m: &SchemaManager) -> Result<(), DbErr> {
{% for column in columns -%}
add_column(m, "{{plural_snake}}", "{{column.0}}", ColType::{{column.1}}).await?;
{% endfor -%}
Ok(())
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
{% for column in columns -%}
remove_column(m, "{{plural_snake}}", "{{column.0}}").await?;
{% endfor -%}
Ok(())
}
}
@@ -0,0 +1,47 @@
{% set mig_ts = ts | date(format="%Y%m%d_%H%M%S") -%}
{% set mig_name = name | snake_case -%}
{% set plural_snake = table | plural | snake_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 && cargo loco db entities`."
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, m: &SchemaManager) -> Result<(), DbErr> {
{% for column in columns -%}
add_column(m, "{{plural_snake}}", "{{column.0}}", ColType::{{column.1}}).await?;
{% endfor -%}
{% for ref in references -%}
add_reference(m, "{{plural_snake}}", "{{ref.0}}", "{{ref.1}}").await?;
{% endfor -%}
Ok(())
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
{% for ref in references -%}
remove_reference(m, "{{plural_snake}}", "{{ref.0}}", "{{ref.1}}").await?;
{% endfor -%}
{% for column in columns -%}
remove_column(m, "{{plural_snake}}", "{{column.0}}").await?;
{% endfor -%}
Ok(())
}
}
@@ -0,0 +1,30 @@
{% set mig_ts = ts | date(format="%Y%m%d_%H%M%S") -%}
{% set mig_name = name | snake_case -%}
{% set module_name = "m" ~ mig_ts ~ "_" ~ mig_name -%}
to: "migration/src/{{module_name}}.rs"
skip_glob: "migration/src/*_{{mig_name}}.rs"
message: "Migration for `{{name}}` added! You can now apply it with `$ cargo loco db migrate && cargo loco db entities`."
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 sea_orm_migration::{prelude::*, schema::*};
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
todo!()
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
Ok(())
}
}
@@ -0,0 +1,47 @@
{% 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 plural_snake = table | plural | snake_case -%}
{% if with_tz %}
{% set join_table_func = "create_join_table" %}
{% else %}
{% set join_table_func = "create_join_table_without_timestamps" %}
{% endif %}
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 && cargo loco db entities`."
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, m: &SchemaManager) -> Result<(), DbErr> {
{{join_table_func}}(m, "{{plural_snake}}",
&[
{% for column in columns -%}
("{{column.0}}", ColType::{{column.1}}),
{% endfor -%}
],
&[
{% for ref in references -%}
("{{ref.0}}", "{{ref.1}}"),
{% endfor -%}
]
).await
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
drop_table(m, "{{plural_snake}}").await
}
}
@@ -0,0 +1,37 @@
{% set mig_ts = ts | date(format="%Y%m%d_%H%M%S") -%}
{% set mig_name = name | snake_case -%}
{% set plural_snake = table | plural | snake_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 && cargo loco db entities`."
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, m: &SchemaManager) -> Result<(), DbErr> {
{% for column in columns -%}
remove_column(m, "{{plural_snake}}", "{{column.0}}").await?;
{% endfor -%}
Ok(())
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
{% for column in columns -%}
add_column(m, "{{plural_snake}}", "{{column.0}}", ColType::{{column.1}}).await?;
{% endfor -%}
Ok(())
}
}
@@ -0,0 +1,48 @@
{% 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 model = name | plural | pascal_case -%}
{% if with_tz %}
{% set create_table_func = "create_table" %}
{% else %}
{% set create_table_func = "create_table_without_timestamps" %}
{% endif %}
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 && cargo loco db entities`."
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, m: &SchemaManager) -> Result<(), DbErr> {
{{create_table_func}}(m, "{{plural_snake}}",
&[
("id", ColType::PkAuto),
{% for column in columns -%}
("{{column.0}}", ColType::{{column.1}}),
{% endfor -%}
],
&[
{% for ref in references -%}
("{{ref.0}}", "{{ref.1}}"),
{% endfor -%}
]
).await
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
drop_table(m, "{{plural_snake}}").await
}
}
@@ -0,0 +1,41 @@
{% set plural_snake = name | plural | snake_case -%}
{% set model = name | plural | pascal_case -%}
to: "tests/models/{{plural_snake}}.rs"
message: "A test for model `{{model}}` was added. Run with `cargo test`."
skip_exists: true
injections:
- into: "tests/models/mod.rs"
append: true
content: "mod {{plural_snake}};"
---
use {{pkg_name}}::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::<App>().await.unwrap();
seed::<App>(&boot.app_context).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);
}
@@ -0,0 +1,147 @@
to: src/controllers/{{ snake_plural }}.rs
skip_exists: true
message: "Controller `{{ pascal_singular }}` was added successfully."
injections:
- into: src/controllers/mod.rs
append: true
content: "pub mod {{ snake_plural }};"
- into: src/app.rs
after: "AppRoutes::"
content: " .add_route(controllers::{{ snake_plural }}::routes())"
---
#![allow(clippy::unused_async)]
use axum::http::StatusCode;
use loco_rs::prelude::*;
use sea_orm::{PaginatorTrait, QueryOrder};
use serde::Deserialize;
use crate::{
dtos::{
common::{ApiError, Page},
{{ snake_plural }}::{Create{{ pascal_singular }}, {{ pascal_singular }}Dto, Update{{ pascal_singular }}},
},
models::_entities::{{ snake_plural }}::{ActiveModel, Column, Entity},
};
const fn default_page() -> u64 {
1
}
const fn default_per_page() -> u64 {
25
}
#[derive(Debug, Deserialize)]
pub struct ListParams {
#[serde(default = "default_page")]
pub page: u64,
#[serde(default = "default_per_page")]
pub per_page: u64,
}
/// Build a 404 response shaped as the [`ApiError`] envelope.
fn not_found(message: &str) -> Response {
let error = ApiError {
code: "not_found".to_string(),
message: message.to_string(),
details: None,
};
(StatusCode::NOT_FOUND, Json(error)).into_response()
}
#[debug_handler]
async fn list(
_auth: auth::JWT,
State(ctx): State<AppContext>,
Query(params): Query<ListParams>,
) -> Result<Json<Page<{{ pascal_singular }}Dto>>> {
let page = params.page.max(1);
let per_page = params.per_page.max(1);
let query = Entity::find().order_by_asc(Column::Id);
let paginator = query.paginate(&ctx.db, per_page);
let total = paginator.num_items_and_pages().await?.number_of_items;
let items = paginator.fetch_page(page - 1).await?;
Ok(Json(Page {
items: items.into_iter().map({{ pascal_singular }}Dto::from).collect(),
total: i64::try_from(total).unwrap_or(i64::MAX),
page: i64::try_from(page).unwrap_or(i64::MAX),
per_page: i64::try_from(per_page).unwrap_or(i64::MAX),
}))
}
#[debug_handler]
async fn get_one(
_auth: auth::JWT,
State(ctx): State<AppContext>,
Path(id): Path<i64>,
) -> Result<Response> {
let Some(model) = Entity::find_by_id(id).one(&ctx.db).await? else {
return Ok(not_found("{{ snake_singular }} not found"));
};
Ok(Json({{ pascal_singular }}Dto::from(model)).into_response())
}
#[debug_handler]
async fn create(
_auth: auth::JWT,
State(ctx): State<AppContext>,
Json(params): Json<Create{{ pascal_singular }}>,
) -> Result<Response> {
let item = ActiveModel {
{% for f in fields -%}
{{ f.field_name }}: Set({{ f.set_expr }}),
{% endfor -%}
..Default::default()
};
let item = item.insert(&ctx.db).await?;
Ok((StatusCode::CREATED, Json({{ pascal_singular }}Dto::from(item))).into_response())
}
#[debug_handler]
async fn update(
_auth: auth::JWT,
State(ctx): State<AppContext>,
Path(id): Path<i64>,
Json(params): Json<Update{{ pascal_singular }}>,
) -> Result<Response> {
let Some(model) = Entity::find_by_id(id).one(&ctx.db).await? else {
return Ok(not_found("{{ snake_singular }} not found"));
};
let mut item = model.into_active_model();
{% for f in fields -%}
item.{{ f.field_name }} = Set({{ f.set_expr }});
{% endfor -%}
let item = item.update(&ctx.db).await?;
Ok(Json({{ pascal_singular }}Dto::from(item)).into_response())
}
#[debug_handler]
async fn remove(
_auth: auth::JWT,
State(ctx): State<AppContext>,
Path(id): Path<i64>,
) -> Result<Response> {
let Some(model) = Entity::find_by_id(id).one(&ctx.db).await? else {
return Ok(not_found("{{ snake_singular }} not found"));
};
model.into_active_model().delete(&ctx.db).await?;
Ok(StatusCode::NO_CONTENT.into_response())
}
pub fn routes() -> Routes {
Routes::new()
.prefix("/api/{{ snake_plural }}")
.add("/", get(list))
.add("/", post(create))
.add("/{id}", get(get_one))
.add("/{id}", put(update))
.add("/{id}", delete(remove))
}
@@ -0,0 +1,74 @@
to: src/dtos/{{ snake_plural }}.rs
skip_exists: true
message: "DTO `{{ pascal_singular }}Dto` was added successfully."
injections:
- into: src/dtos/mod.rs
append: true
content: "pub mod {{ snake_plural }};"
---
{% if prelude_use %}{{ prelude_use }}
{% endif %}use ts_rs::TS;
{% for e in enums %}#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize, TS)]
#[ts(export, export_to = "../frontend/src/bindings/")]
#[serde(rename_all = "snake_case")]
pub enum {{ e.enum_type }} {
{% for v in e.variants %} {{ v.variant }},
{% endfor %}}
impl From<String> for {{ e.enum_type }} {
fn from(value: String) -> Self {
match value.as_str() {
{% for v in e.match_arms %} "{{ v.value }}" => Self::{{ v.variant }},
{% endfor %} _ => Self::{{ e.fallback_variant }},
}
}
}
impl {{ e.enum_type }} {
#[must_use]
pub const fn as_str(&self) -> &'static str {
match self {
{% for v in e.variants %} Self::{{ v.variant }} => "{{ v.value }}",
{% endfor %} }
}
}
{% endfor -%}
#[derive(serde::Serialize, serde::Deserialize, TS)]
#[ts(export, export_to = "../frontend/src/bindings/")]
pub struct {{ pascal_singular }}Dto {
#[ts(type = "number")]
pub id: i64,
{% for f in fields %}{% if f.ts_override %} #[ts(type = "{{ f.ts_override }}")]
{% endif %} pub {{ f.field_name }}: {{ f.rust_type }},
{% endfor -%}{% if with_tz %} #[ts(type = "string")]
pub created_at: DateTimeWithTimeZone,
#[ts(type = "string")]
pub updated_at: DateTimeWithTimeZone,
{% endif %}}
impl From<crate::models::_entities::{{ snake_plural }}::Model> for {{ pascal_singular }}Dto {
fn from(m: crate::models::_entities::{{ snake_plural }}::Model) -> Self {
Self {
id: m.id,
{% for f in fields %} {{ f.field_name }}: {{ f.from_expr }},
{% endfor -%}{% if with_tz %} created_at: m.created_at,
updated_at: m.updated_at,
{% endif %} }
}
}
#[derive(serde::Serialize, serde::Deserialize, TS)]
#[ts(export, export_to = "../frontend/src/bindings/")]
pub struct Create{{ pascal_singular }} {
{% for f in fields %}{% if f.ts_override %} #[ts(type = "{{ f.ts_override }}")]
{% endif %} pub {{ f.field_name }}: {{ f.rust_type }},
{% endfor %}}
#[derive(serde::Serialize, serde::Deserialize, TS)]
#[ts(export, export_to = "../frontend/src/bindings/")]
pub struct Update{{ pascal_singular }} {
{% for f in fields %}{% if f.ts_override %} #[ts(type = "{{ f.ts_override }}")]
{% endif %} pub {{ f.field_name }}: {{ f.rust_type }},
{% endfor %}}
@@ -0,0 +1,111 @@
to: frontend/src/api/{{ snake_plural }}.ts
skip_exists: true
message: "Frontend API hooks `{{ snake_plural }}.ts` were added successfully."
---
import {
useMutation,
useQuery,
useQueryClient,
type UseMutationResult,
type UseQueryResult,
} from "@tanstack/react-query";
import type { Create{{ pascal_singular }} } from "../bindings/Create{{ pascal_singular }}";
import type { Page } from "../bindings/Page";
import type { {{ pascal_singular }}Dto } from "../bindings/{{ pascal_singular }}Dto";
import type { Update{{ pascal_singular }} } from "../bindings/Update{{ pascal_singular }}";
import { ApiClientError, del, get, post, put } from "./client";
export interface List{{ pascal_plural }}Params {
page?: number;
per_page?: number;
}
export const {{ camel_singular }}Keys = {
all: ["{{ snake_plural }}"] as const,
list: (params?: List{{ pascal_plural }}Params) =>
["{{ snake_plural }}", "list", params ?? {}] as const,
detail: (id: number) => ["{{ snake_plural }}", "detail", id] as const,
};
function buildQueryString(params?: List{{ pascal_plural }}Params): string {
if (!params) {
return "";
}
const search = new URLSearchParams();
if (params.page !== undefined) {
search.set("page", String(params.page));
}
if (params.per_page !== undefined) {
search.set("per_page", String(params.per_page));
}
const qs = search.toString();
return qs ? `?${qs}` : "";
}
export function useList{{ pascal_plural }}(
params?: List{{ pascal_plural }}Params,
): UseQueryResult<Page<{{ pascal_singular }}Dto>, ApiClientError> {
return useQuery({
queryKey: {{ camel_singular }}Keys.list(params),
queryFn: () => get<Page<{{ pascal_singular }}Dto>>(`/api/{{ snake_plural }}${buildQueryString(params)}`),
});
}
export function use{{ pascal_singular }}(id: number): UseQueryResult<{{ pascal_singular }}Dto, ApiClientError> {
return useQuery({
queryKey: {{ camel_singular }}Keys.detail(id),
queryFn: () => get<{{ pascal_singular }}Dto>(`/api/{{ snake_plural }}/${id}`),
enabled: Number.isFinite(id) && id > 0,
});
}
export function useCreate{{ pascal_singular }}(): UseMutationResult<
{{ pascal_singular }}Dto,
ApiClientError,
Create{{ pascal_singular }}
> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: Create{{ pascal_singular }}) => post<{{ pascal_singular }}Dto>("/api/{{ snake_plural }}", data),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: {{ camel_singular }}Keys.all });
},
});
}
export interface Update{{ pascal_singular }}Variables {
id: number;
data: Update{{ pascal_singular }};
}
export function useUpdate{{ pascal_singular }}(): UseMutationResult<
{{ pascal_singular }}Dto,
ApiClientError,
Update{{ pascal_singular }}Variables
> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: Update{{ pascal_singular }}Variables) =>
put<{{ pascal_singular }}Dto>(`/api/{{ snake_plural }}/${id}`, data),
onSuccess: (_data, variables) => {
void queryClient.invalidateQueries({ queryKey: {{ camel_singular }}Keys.all });
void queryClient.invalidateQueries({
queryKey: {{ camel_singular }}Keys.detail(variables.id),
});
},
});
}
export function useRemove{{ pascal_singular }}(): UseMutationResult<
void,
ApiClientError,
number
> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => del(`/api/{{ snake_plural }}/${id}`),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: {{ camel_singular }}Keys.all });
},
});
}
@@ -0,0 +1,174 @@
to: frontend/src/pages/{{ snake_plural }}/Edit.tsx
skip_exists: true
message: "Frontend `Edit` page for `{{ pascal_singular }}` was added successfully."
---
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router";
import { use{{ pascal_singular }}, useUpdate{{ pascal_singular }} } from "../../api/{{ snake_plural }}";
{%- for e in enums %}
import type { {{ e.enum_type }} } from "../../bindings/{{ e.enum_type }}";
{%- endfor %}
import type { Update{{ pascal_singular }} } from "../../bindings/Update{{ pascal_singular }}";
{% for e in enums %}
const {{ e.options_const }}: {{ e.enum_type }}[] = [{% for v in e.variants %}"{{ v.value }}"{% if not loop.last %}, {% endif %}{% endfor %}];
{% endfor %}
export function Edit() {
const navigate = useNavigate();
const { id: idParam } = useParams<{ id: string }>();
const id = Number(idParam);
const isValidId = Number.isFinite(id) && id > 0;
const { data, isLoading, isError, error } = use{{ pascal_singular }}(isValidId ? id : NaN);
const update{{ pascal_singular }} = useUpdate{{ pascal_singular }}();
const [form, setForm] = useState<Update{{ pascal_singular }} | null>(null);
useEffect(() => {
if (data) {
setForm({
{%- for f in fields %}
{{ f.field_name }}: data.{{ f.field_name }},
{%- endfor %}
});
}
}, [data]);
if (!isValidId) {
return <p role="alert">Invalid {{ snake_singular }} id.</p>;
}
if (isLoading || !form) {
return <p>Loading…</p>;
}
if (isError) {
return <p role="alert">{error.message}</p>;
}
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!form) {
return;
}
update{{ pascal_singular }}.mutate(
{ id, data: form },
{
onSuccess: () => {
navigate(`/{{ snake_plural }}/${id}`);
},
},
);
}
return (
<div>
<h1>Edit {{ pascal_singular }}</h1>
<form onSubmit={handleSubmit}>
{%- for f in fields %}
<div>
<label htmlFor="{{ f.field_name }}">{{ f.label }}</label>
{%- if f.input_kind == "select" %}
<select
id="{{ f.field_name }}"
value={form.{{ f.field_name }}}
onChange={(e) =>
setForm({ ...form, {{ f.field_name }}: e.target.value as {{ f.enum_type }} })
}
>
{{ "{" }}{{ f.options_const }}.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
{%- elif f.input_kind == "checkbox" %}
<input
id="{{ f.field_name }}"
type="checkbox"
checked={form.{{ f.field_name }}}
onChange={(e) =>
setForm({ ...form, {{ f.field_name }}: e.target.checked })
}
/>
{%- elif f.input_kind == "date" %}
<input
id="{{ f.field_name }}"
type="date"
value={form.{{ f.field_name }} ?? ""}
onChange={(e) =>
setForm({
...form,
{{ f.field_name }}: {% if f.nullable %}e.target.value || null{% else %}e.target.value{% endif %},
})
}
{%- if not f.nullable %}
required
{%- endif %}
/>
{%- elif f.input_kind == "datetime" %}
<input
id="{{ f.field_name }}"
type="datetime-local"
value={form.{{ f.field_name }} ?? ""}
onChange={(e) =>
setForm({
...form,
{{ f.field_name }}: {% if f.nullable %}e.target.value || null{% else %}e.target.value{% endif %},
})
}
{%- if not f.nullable %}
required
{%- endif %}
/>
{%- elif f.input_kind == "number" %}
<input
id="{{ f.field_name }}"
type="number"
value={form.{{ f.field_name }}}
onChange={(e) =>
setForm({ ...form, {{ f.field_name }}: Number(e.target.value) })
}
{%- if not f.nullable %}
required
{%- endif %}
/>
{%- elif f.input_kind == "text_number" %}
<input
id="{{ f.field_name }}"
type="text"
value={form.{{ f.field_name }}}
onChange={(e) => setForm({ ...form, {{ f.field_name }}: e.target.value })}
{%- if not f.nullable %}
required
{%- endif %}
/>
{%- elif f.input_kind == "textarea" %}
<textarea
id="{{ f.field_name }}"
value={form.{{ f.field_name }}}
onChange={(e) => setForm({ ...form, {{ f.field_name }}: e.target.value })}
{%- if not f.nullable %}
required
{%- endif %}
/>
{%- else %}
<input
id="{{ f.field_name }}"
type="text"
value={form.{{ f.field_name }}}
onChange={(e) => setForm({ ...form, {{ f.field_name }}: e.target.value })}
{%- if not f.nullable %}
required
{%- endif %}
/>
{%- endif %}
</div>
{%- endfor %}
<button type="submit" disabled={update{{ pascal_singular }}.isPending}>
{update{{ pascal_singular }}.isPending ? "Saving…" : "Save"}
</button>
{update{{ pascal_singular }}.error && <p role="alert">{update{{ pascal_singular }}.error.message}</p>}
</form>
</div>
);
}
@@ -0,0 +1,71 @@
to: frontend/src/pages/{{ snake_plural }}/List.tsx
skip_exists: true
message: "Frontend `List` page for `{{ pascal_singular }}` was added successfully."
injections:
- into: frontend/src/routes.tsx
after: "// scaffold:imports"
content: "{{ frontend_imports_injection }}"
- into: frontend/src/routes.tsx
after: "// scaffold:routes"
content: "{{ frontend_routes_injection }}"
---
import { Link } from "react-router";
import { useList{{ pascal_plural }}, useRemove{{ pascal_singular }} } from "../../api/{{ snake_plural }}";
export function List() {
const { data, isLoading, isError, error } = useList{{ pascal_plural }}();
const remove{{ pascal_singular }} = useRemove{{ pascal_singular }}();
if (isLoading) {
return <p>Loading…</p>;
}
if (isError) {
return <p role="alert">{error.message}</p>;
}
return (
<div>
<h1>{{ pascal_plural }}</h1>
<p>
<Link to="/{{ snake_plural }}/new">New {{ pascal_singular }}</Link>
</p>
<table>
<thead>
<tr>
{%- for f in fields %}
<th>{{ f.label }}</th>
{%- endfor %}
<th>Actions</th>
</tr>
</thead>
<tbody>
{data?.items.map(({{ camel_singular }}) => (
<tr key={{ "{" }}{{ camel_singular }}.id}>
{%- for f in fields %}
{%- if loop.first %}
<td>
<Link to={`/{{ snake_plural }}/${{ "{" }}{{ camel_singular }}.id}`}>{{ "{" }}{{ camel_singular }}.{{ f.field_name }}}</Link>
</td>
{%- else %}
<td>{{ "{" }}{{ camel_singular }}.{{ f.field_name }}}</td>
{%- endif %}
{%- endfor %}
<td>
<Link to={`/{{ snake_plural }}/${{ "{" }}{{ camel_singular }}.id}/edit`}>Edit</Link>{" "}
<button
type="button"
disabled={remove{{ pascal_singular }}.isPending}
onClick={() => remove{{ pascal_singular }}.mutate({{ camel_singular }}.id)}
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
{remove{{ pascal_singular }}.isError && <p role="alert">{remove{{ pascal_singular }}.error.message}</p>}
</div>
);
}
@@ -0,0 +1,145 @@
to: frontend/src/pages/{{ snake_plural }}/New.tsx
skip_exists: true
message: "Frontend `New` page for `{{ pascal_singular }}` was added successfully."
---
import { useState } from "react";
import { useNavigate } from "react-router";
import { useCreate{{ pascal_singular }} } from "../../api/{{ snake_plural }}";
import type { Create{{ pascal_singular }} } from "../../bindings/Create{{ pascal_singular }}";
{%- for e in enums %}
import type { {{ e.enum_type }} } from "../../bindings/{{ e.enum_type }}";
{%- endfor %}
{% for e in enums %}
const {{ e.options_const }}: {{ e.enum_type }}[] = [{% for v in e.variants %}"{{ v.value }}"{% if not loop.last %}, {% endif %}{% endfor %}];
{% endfor %}
export function New() {
const navigate = useNavigate();
const create{{ pascal_singular }} = useCreate{{ pascal_singular }}();
const [form, setForm] = useState<Create{{ pascal_singular }}>({
{%- for f in fields %}
{{ f.field_name }}: {{ f.initial_value }},
{%- endfor %}
});
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
create{{ pascal_singular }}.mutate(form, {
onSuccess: (created) => {
navigate(`/{{ snake_plural }}/${created.id}`);
},
});
}
return (
<div>
<h1>New {{ pascal_singular }}</h1>
<form onSubmit={handleSubmit}>
{%- for f in fields %}
<div>
<label htmlFor="{{ f.field_name }}">{{ f.label }}</label>
{%- if f.input_kind == "select" %}
<select
id="{{ f.field_name }}"
value={form.{{ f.field_name }}}
onChange={(e) =>
setForm({ ...form, {{ f.field_name }}: e.target.value as {{ f.enum_type }} })
}
>
{{ "{" }}{{ f.options_const }}.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
{%- elif f.input_kind == "checkbox" %}
<input
id="{{ f.field_name }}"
type="checkbox"
checked={form.{{ f.field_name }}}
onChange={(e) =>
setForm({ ...form, {{ f.field_name }}: e.target.checked })
}
/>
{%- elif f.input_kind == "date" %}
<input
id="{{ f.field_name }}"
type="date"
value={form.{{ f.field_name }} ?? ""}
onChange={(e) =>
setForm({
...form,
{{ f.field_name }}: {% if f.nullable %}e.target.value || null{% else %}e.target.value{% endif %},
})
}
{%- if not f.nullable %}
required
{%- endif %}
/>
{%- elif f.input_kind == "datetime" %}
<input
id="{{ f.field_name }}"
type="datetime-local"
value={form.{{ f.field_name }} ?? ""}
onChange={(e) =>
setForm({
...form,
{{ f.field_name }}: {% if f.nullable %}e.target.value || null{% else %}e.target.value{% endif %},
})
}
{%- if not f.nullable %}
required
{%- endif %}
/>
{%- elif f.input_kind == "number" %}
<input
id="{{ f.field_name }}"
type="number"
value={form.{{ f.field_name }}}
onChange={(e) =>
setForm({ ...form, {{ f.field_name }}: Number(e.target.value) })
}
{%- if not f.nullable %}
required
{%- endif %}
/>
{%- elif f.input_kind == "text_number" %}
<input
id="{{ f.field_name }}"
type="text"
value={form.{{ f.field_name }}}
onChange={(e) => setForm({ ...form, {{ f.field_name }}: e.target.value })}
{%- if not f.nullable %}
required
{%- endif %}
/>
{%- elif f.input_kind == "textarea" %}
<textarea
id="{{ f.field_name }}"
value={form.{{ f.field_name }}}
onChange={(e) => setForm({ ...form, {{ f.field_name }}: e.target.value })}
{%- if not f.nullable %}
required
{%- endif %}
/>
{%- else %}
<input
id="{{ f.field_name }}"
type="text"
value={form.{{ f.field_name }}}
onChange={(e) => setForm({ ...form, {{ f.field_name }}: e.target.value })}
{%- if not f.nullable %}
required
{%- endif %}
/>
{%- endif %}
</div>
{%- endfor %}
<button type="submit" disabled={create{{ pascal_singular }}.isPending}>
{create{{ pascal_singular }}.isPending ? "Creating…" : "Create"}
</button>
{create{{ pascal_singular }}.error && <p role="alert">{create{{ pascal_singular }}.error.message}</p>}
</form>
</div>
);
}
@@ -0,0 +1,54 @@
to: frontend/src/pages/{{ snake_plural }}/Show.tsx
skip_exists: true
message: "Frontend `Show` page for `{{ pascal_singular }}` was added successfully."
---
import { Link, useParams } from "react-router";
import { use{{ pascal_singular }} } from "../../api/{{ snake_plural }}";
export function Show() {
const { id: idParam } = useParams<{ id: string }>();
const id = Number(idParam);
const isValidId = Number.isFinite(id) && id > 0;
const { data, isLoading, isError, error } = use{{ pascal_singular }}(isValidId ? id : NaN);
if (!isValidId) {
return <p role="alert">Invalid {{ snake_singular }} id.</p>;
}
if (isLoading) {
return <p>Loading…</p>;
}
if (isError) {
return <p role="alert">{error.message}</p>;
}
if (!data) {
return <p>{{ pascal_singular }} not found.</p>;
}
return (
<div>
<h1>{data.{{ title_field_name }}}</h1>
<dl>
{%- for f in fields %}
{%- if f.field_name != title_field_name %}
<dt>{{ f.label }}</dt>
<dd>{data.{{ f.field_name }}{% if f.nullable %} ?? "—"{% endif %}}</dd>
{%- endif %}
{%- endfor %}
{%- if with_tz %}
<dt>Created at</dt>
<dd>{data.created_at}</dd>
<dt>Updated at</dt>
<dd>{data.updated_at}</dd>
{%- endif %}
</dl>
<p>
<Link to={`/{{ snake_plural }}/${data.id}/edit`}>Edit</Link>{" "}
<Link to="/{{ snake_plural }}">Back to list</Link>
</p>
</div>
);
}
@@ -0,0 +1,18 @@
to: "config/scheduler.yaml"
skip_exists: true
message: "A Scheduler job configuration was added successfully. Run with `cargo loco scheduler --list`."
---
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"
+29
View File
@@ -0,0 +1,29 @@
{% set file_name = name | snake_case -%}
{% set module_name = file_name | pascal_case -%}
to: "src/tasks/{{file_name}}.rs"
skip_exists: true
message: "A Task `{{module_name}}` was added successfully. Run with `cargo loco task {{name}}`."
injections:
- into: "src/tasks/mod.rs"
append: true
content: "pub mod {{ file_name }};"
- into: src/app.rs
before: "// tasks-inject"
content: " tasks.register(tasks::{{file_name}}::{{module_name}});"
---
use loco_rs::prelude::*;
pub struct {{module_name}};
#[async_trait]
impl Task for {{module_name}} {
fn task(&self) -> TaskInfo {
TaskInfo {
name: "{{name}}".to_string(),
detail: "Task generator".to_string(),
}
}
async fn run(&self, _app_context: &AppContext, _vars: &task::Vars) -> Result<()> {
println!("Task {{module_name}} generated");
Ok(())
}
}
+27
View File
@@ -0,0 +1,27 @@
{% set file_name = name | snake_case -%}
{% set module_name = file_name | pascal_case -%}
to: tests/tasks/{{ file_name }}.rs
skip_exists: true
message: "Tests for task `{{module_name}}` was added successfully. Run `cargo test`."
injections:
- into: tests/tasks/mod.rs
append: true
content: "pub mod {{ file_name }};"
---
use {{pkg_name}}::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_{{name | snake_case}}() {
let boot = boot_test::<App>().await.unwrap();
assert!(
run_task::<App>(&boot.app_context, Some(&"{{name}}".to_string()), &task::Vars::default())
.await
.is_ok()
);
}
@@ -0,0 +1,30 @@
{% set module_name = name | snake_case -%}
{% set struct_name = module_name | pascal_case -%}
to: "tests/workers/{{module_name}}.rs"
skip_exists: true
message: "Test for worker `{{struct_name}}` was added successfully. Run `cargo test`."
injections:
- into: tests/workers/mod.rs
append: true
content: "pub mod {{ name | snake_case }};"
---
use loco_rs::{bgworker::BackgroundWorker, testing::prelude::*};
use {{pkg_name}}::{
app::App,
workers::{{module_name}}::{Worker, WorkerArgs},
};
use serial_test::serial;
#[tokio::test]
#[serial]
async fn test_run_{{module_name}}_worker() {
let boot = boot_test::<App>().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
}
@@ -0,0 +1,64 @@
{% set module_name = name | snake_case -%}
{% set struct_name = module_name | pascal_case -%}
to: "src/workers/{{module_name}}.rs"
skip_exists: true
message: "A worker `{{struct_name}}` was added successfully. Run with `cargo loco start --worker`."
injections:
- into: "src/workers/mod.rs"
append: true
content: "pub mod {{ module_name}};"
- into: src/app.rs
after: "fn connect_workers"
content: " queue.register(crate::workers::{{module_name}}::Worker::build(ctx)).await?;"---
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<WorkerArgs> for Worker {
/// Creates a new instance of the Worker with the given application context.
///
/// This function is called when registering the worker with the queue system.
///
/// # Parameters
/// * `ctx` - The application context containing shared resources
fn build(ctx: &AppContext) -> Self {
Self { ctx: ctx.clone() }
}
/// Returns the class name of the worker.
///
/// This name is used when enqueueing jobs and identifying the worker in logs.
/// The implementation returns the struct name as a string.
fn class_name() -> String {
"{{struct_name}}".to_string()
}
/// Returns tags associated with this worker.
///
/// Tags can be used to filter which workers run during startup.
/// The default implementation returns an empty vector (no tags).
fn tags() -> Vec<String> {
Vec::new()
}
/// Performs the actual work when a job is processed.
///
/// This is the main function that contains the worker's logic.
/// It gets executed when a job is dequeued from the job queue.
///
/// # Returns
/// * `Result<()>` - Ok if the job completed successfully, Err otherwise
async fn perform(&self, _args: WorkerArgs) -> Result<()> {
println!("================={{struct_name}}=======================");
// TODO: Some actual work goes here...
Ok(())
}
}
+264
View File
@@ -0,0 +1,264 @@
//
// generator test toolkit
// to be extracted to a library later.
//
use std::{
env,
error::Error,
fs,
path::{Path, PathBuf},
process::Command,
};
use regex::Regex;
// Define the custom struct to encapsulate file content
pub struct FileContent {
content: String,
}
impl FileContent {
// Method to load content from a file into the struct
pub fn from_file(file_path: &str) -> Result<Self, Box<dyn Error>> {
let content = fs::read_to_string(file_path)?;
Ok(Self { content })
}
// Method to check that the content contains a specific string
pub fn check_contains(&self, pattern: &str) -> Result<(), Box<dyn Error>> {
if self.content.contains(pattern) {
Ok(())
} else {
Err(Box::from(format!("Content does not contain '{pattern}'")))
}
}
// Assert method for check_contains
pub fn assert_contains(&self, pattern: &str) {
self.check_contains(pattern)
.unwrap_or_else(|e| panic!("{}", e));
}
// Method to check that the content matches a regular expression
pub fn check_regex_match(&self, pattern: &str) -> Result<(), Box<dyn Error>> {
let re = Regex::new(pattern)?;
if re.is_match(&self.content) {
Ok(())
} else {
Err(Box::from(format!(
"Content does not match regex '{pattern}'"
)))
}
}
// Assert method for check_regex_match
pub fn assert_regex_match(&self, pattern: &str) {
self.check_regex_match(pattern)
.unwrap_or_else(|e| panic!("{}", e));
}
// Method to check that the content does not contain a specific string
pub fn check_not_contains(&self, pattern: &str) -> Result<(), Box<dyn Error>> {
#[allow(clippy::if_not_else)]
if !self.content.contains(pattern) {
Ok(())
} else {
Err(Box::from(format!("Content should not contain '{pattern}'")))
}
}
// Assert method for check_not_contains
pub fn assert_not_contains(&self, pattern: &str) {
self.check_not_contains(pattern)
.unwrap_or_else(|e| panic!("{}", e));
}
// Method to check the length of the content
pub fn check_length(&self, expected_length: usize) -> Result<(), Box<dyn Error>> {
if self.content.len() == expected_length {
Ok(())
} else {
Err(Box::from(format!(
"Content length is {}, expected {}",
self.content.len(),
expected_length
)))
}
}
// Assert method for check_length
pub fn assert_length(&self, expected_length: usize) {
self.check_length(expected_length)
.unwrap_or_else(|e| panic!("{}", e));
}
// Method to check the syntax using rustfmt without creating a temp file
pub fn check_syntax(&self) -> Result<(), Box<dyn Error>> {
// Parse the file using `syn` to check for valid Rust syntax
match syn::parse_file(&self.content) {
Ok(_) => Ok(()),
Err(err) => Err(Box::from(format!("Syntax error: {err}"))),
}
}
// Assert method for check_syntax
pub fn assert_syntax(&self) {
self.check_syntax().unwrap_or_else(|e| panic!("{}", e));
}
}
// Function that loads the file and applies the provided closure for assertions
pub fn check_file<F>(file_path: &str, assertions: F) -> Result<(), Box<dyn Error>>
where
F: Fn(&FileContent) -> Result<(), Box<dyn Error>>,
{
let content = FileContent::from_file(file_path)?;
assertions(&content)?;
Ok(())
}
// Assert function for checking the file with a closure for custom assertions
pub fn assert_file<F>(file_path: &str, assertions: F)
where
F: Fn(&FileContent),
{
check_file(file_path, |content| {
assertions(content);
Ok(())
})
.unwrap_or_else(|e| panic!("{}", e));
}
pub fn check_no_warnings() -> Result<(), Box<dyn std::error::Error>> {
let output = Command::new("cargo")
.arg("check")
.arg("--message-format=json")
.output()?;
let stdout = String::from_utf8(output.stdout)?;
if stdout.contains("warning:") {
Err(Box::from("Compilation produced warnings"))
} else {
Ok(())
}
}
pub fn assert_no_warnings() {
check_no_warnings().unwrap_or_else(|e| panic!("{}", e));
}
pub fn check_cargo_check() -> Result<(), Box<dyn Error>> {
let output = Command::new("cargo").arg("check").output()?; // Execute the command and get the output
// Check if cargo check was successful
if output.status.success() {
Ok(())
} else {
// Capture and return the error output if the command failed
let error_message = String::from_utf8_lossy(&output.stderr);
Err(Box::from(format!("cargo check failed: {error_message}")))
}
}
pub fn assert_cargo_check() {
check_cargo_check().unwrap_or_else(|e| panic!("{}", e));
}
pub fn check_file_not_exists(file_path: &str) -> Result<(), Box<dyn std::error::Error>> {
if std::path::Path::new(file_path).exists() {
Err(Box::from(format!("File {file_path} should not exist")))
} else {
Ok(())
}
}
pub fn assert_file_not_exists(file_path: &str) {
check_file_not_exists(file_path).unwrap_or_else(|e| panic!("{}", e));
}
pub fn check_file_exists(file_path: &str) -> Result<(), Box<dyn std::error::Error>> {
if std::path::Path::new(file_path).exists() {
Ok(())
} else {
Err(Box::from(format!("File {file_path} does not exist")))
}
}
pub fn assert_file_exists(file_path: &str) {
check_file_exists(file_path).unwrap_or_else(|e| panic!("{}", e));
}
pub fn check_dir_exists(dir_path: &str) -> Result<(), Box<dyn std::error::Error>> {
if std::path::Path::new(dir_path).is_dir() {
Ok(())
} else {
Err(Box::from(format!("Directory {dir_path} does not exist")))
}
}
pub fn assert_dir_exists(dir_path: &str) {
check_dir_exists(dir_path).unwrap_or_else(|e| panic!("{}", e));
}
/// Checks if there exists exactly one file in the given directory whose name
/// matches the provided regex pattern.
pub fn check_single_file_match<P: AsRef<Path>>(
dir: P,
pattern: &str,
) -> Result<PathBuf, Box<dyn Error>> {
// Compile the provided regex pattern
let re = Regex::new(pattern)?;
// Filter files that match the regex
let matched_files: Vec<PathBuf> = fs::read_dir(dir)?
.filter_map(std::result::Result::ok)
.filter_map(|entry| {
let path = entry.path();
#[allow(clippy::option_if_let_else)]
if let Some(file_name) = path.file_name().and_then(|f| f.to_str()) {
if re.is_match(file_name) {
Some(path) // Return the path if the regex matches
} else {
None
}
} else {
None
}
})
.collect();
// Ensure that there is exactly one match
match matched_files.len() {
0 => Err(Box::from("No file found matching the given pattern.")),
1 => Ok(matched_files.into_iter().next().unwrap()), /* Return the single matching file's */
// path
_ => Err(Box::from("More than one file matches the given pattern.")),
}
}
pub fn assert_single_file_match<P: AsRef<Path>>(dir: P, pattern: &str) -> PathBuf {
check_single_file_match(dir, pattern).unwrap_or_else(|e| panic!("{}", e))
}
pub fn with_temp_dir<F>(f: F) -> Result<(), Box<dyn Error>>
where
F: FnOnce(&Path, &Path),
{
let previous = env::current_dir()?; // Get the current directory
println!("Current directory: {previous:?}");
let tree_fs = tree_fs::TreeBuilder::default().drop(true).create()?; // Create a temporary directory
let current = &tree_fs.root;
println!("Temporary directory: {current:?}");
env::set_current_dir(current)?; // Set the current directory to the temp directory
// Use catch_unwind to handle panics gracefully
f(previous.as_path(), current); // Execute the provided closure
// Restore the original directory
env::set_current_dir(previous)?;
Ok(())
}
+137
View File
@@ -0,0 +1,137 @@
use duct::cmd;
use insta::assert_snapshot;
use rstest::rstest;
use serial_test::serial;
use std::{collections::HashMap, env::current_dir, fs::read_to_string};
#[rstest]
#[serial]
fn test_migrations_flow(#[values("postgres", "sqlite")] db_kind: &str) {
if db_kind == "postgres" && std::env::var("DATABASE_URL").is_err() {
return;
}
let tree_fs = tree_fs::TreeBuilder::default()
.drop(true)
.create()
.expect("Should create temp folder");
let loco_dev_path = current_dir().unwrap();
let loco_dev_path = loco_dev_path.parent().unwrap();
// 1. install most recent dev cli: cd loco-new; cargo install --path . --force
// 2. when running locally set LOCO_DEV_MODE_PATH=<to local loco path>
// LOCO_DEV_MODE_PATH=../../ cargo run -- new
let mut env_map: HashMap<String, String> = std::env::vars().collect();
env_map.insert(
"LOCO_DEV_MODE_PATH".into(),
loco_dev_path.to_str().unwrap().to_string(),
);
if db_kind == "sqlite" {
env_map.remove("DATABASE_URL");
}
cmd!(
"loco",
"new",
"-n",
"myapp",
"--db",
db_kind,
"--bg",
"async",
"--assets",
"serverside",
"-a"
)
.full_env(&env_map)
.dir(&tree_fs.root)
.run()
.expect("new");
// build a mega long all-types "title:string ..." pairs for all types from
// `column::scalar_from_base_name`'s recognized base names (the successor to
// the now-removed `mappings.json`); name of column is name of type
// adjusted with unique, or nonnull, etc. arity arguments get manual
// treatment. `bool`/`tstz` have no `^` (unique) variant -- see
// `column::parse_column`'s rejection of `bool^`/`tstz^`.
let base_names = [
"string",
"text",
"uuid",
"bool",
"date",
"time",
"date_time",
"tstz",
"json",
"jsonb",
"blob",
"money",
"decimal",
"float",
"double",
"small_int",
"small_unsigned",
"unsigned",
"int",
"big_int",
];
let mut type_names = Vec::new();
for base in base_names {
type_names.push(format!("{base}:{base}"));
type_names.push(format!("{base}_nonull:{base}!"));
// bool/tstz have no unique column type; `json` has no btree operator
// class on Postgres (use `jsonb`). See `column::parse_column`'s rejects.
if base != "bool" && base != "tstz" && base != "json" {
type_names.push(format!("{base}_uniq:{base}^"));
}
}
// push arity arguments manually
type_names.push("age:decimal_len:8:24".to_string());
type_names.push("age_nonull:decimal_len!:8:24".to_string());
if db_kind == "postgres" {
type_names.push("array_string:array:string".to_string());
type_names.push("array_float:array:float".to_string());
type_names.push("array_int:array:int".to_string());
type_names.push("array_double:array:double".to_string());
type_names.push("array_bool:array:bool".to_string());
}
let types_line = type_names.join(" ");
let script = [
"loco db reset",
"loco g model user name:string",
"loco g model user_without_tz name:string --without-tz",
&format!("loco g scaffold playlists {types_line}"),
&format!("loco g scaffold playlists_without_tz {types_line} --without-tz"),
&format!("loco g model movies {types_line} playlist:references user:references?"),
"loco g migration AddContentToMovies content:string",
"loco g migration CreateActors foobar:string",
// TBD this errors under sqlite because they don`t support alter and uniq
// &format!("loco g migration AddAllToActors {types_line}"),
"loco g migration CreateJoinTableActorsAndMovies minutes:int",
"loco g migration CreateJoinTableUser_without_tzAndMovies minutes:int --without-tz",
"loco g migration CreateAwards name:string actor:references",
"loco g migration RemoveContentFromMovies content:string",
"loco g migration AddRatingToMovies rating:int",
"loco db migrate",
"loco db entities",
"loco db schema",
];
for line in script {
cmd("cargo", line.split(' '))
.full_env(&env_map)
.dir(tree_fs.root.join("myapp"))
.run()
.unwrap_or_else(|_| panic!("command {line} should run successfully"));
}
// cargo loco build
assert_snapshot!(
format!("migrations_flow_{db_kind}"),
read_to_string(tree_fs.root.join("myapp").join("schema_dump.json")).unwrap()
);
}
+1
View File
@@ -0,0 +1 @@
mod templates;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,47 @@
---
source: loco-gen/tests/db.rs
assertion_line: 133
expression: "read_to_string(tree_fs.root.join(\"myapp\").join(\"schema_dump.json\")).unwrap()"
---
[
{
"sql": "CREATE TABLE \"actor_movies\" ( \"minutes\" integer NULL, \"actor_id\" integer NOT NULL, \"movie_id\" integer NOT NULL, CONSTRAINT \"idx-actor_movies-refs-pk\" PRIMARY KEY (\"actor_id\", \"movie_id\"), FOREIGN KEY (\"actor_id\") REFERENCES \"actors\" (\"id\") ON DELETE CASCADE ON UPDATE CASCADE, FOREIGN KEY (\"movie_id\") REFERENCES \"movies\" (\"id\") ON DELETE CASCADE ON UPDATE CASCADE )",
"table": "actor_movies"
},
{
"sql": "CREATE TABLE \"actors\" ( \"created_at\" timestamp_with_timezone_text NOT NULL DEFAULT CURRENT_TIMESTAMP, \"updated_at\" timestamp_with_timezone_text NOT NULL DEFAULT CURRENT_TIMESTAMP, \"id\" integer NOT NULL PRIMARY KEY AUTOINCREMENT, \"foobar\" varchar NULL )",
"table": "actors"
},
{
"sql": "CREATE TABLE \"awards\" ( \"created_at\" timestamp_with_timezone_text NOT NULL DEFAULT CURRENT_TIMESTAMP, \"updated_at\" timestamp_with_timezone_text NOT NULL DEFAULT CURRENT_TIMESTAMP, \"id\" integer NOT NULL PRIMARY KEY AUTOINCREMENT, \"name\" varchar NULL, \"actor_id\" integer NOT NULL, FOREIGN KEY (\"actor_id\") REFERENCES \"actors\" (\"id\") ON DELETE CASCADE ON UPDATE CASCADE )",
"table": "awards"
},
{
"sql": "CREATE TABLE \"movies\" ( \"created_at\" timestamp_with_timezone_text NOT NULL DEFAULT CURRENT_TIMESTAMP, \"updated_at\" timestamp_with_timezone_text NOT NULL DEFAULT CURRENT_TIMESTAMP, \"id\" integer NOT NULL PRIMARY KEY AUTOINCREMENT, \"string\" varchar NULL, \"string_nonull\" varchar NOT NULL, \"string_uniq\" varchar NOT NULL UNIQUE, \"text\" text NULL, \"text_nonull\" text NOT NULL, \"text_uniq\" text NOT NULL UNIQUE, \"uuid\" uuid_text NULL, \"uuid_nonull\" uuid_text NOT NULL, \"uuid_uniq\" uuid_text NOT NULL UNIQUE, \"bool\" boolean NULL, \"bool_nonull\" boolean NOT NULL, \"date\" date_text NULL, \"date_nonull\" date_text NOT NULL, \"date_uniq\" date_text NOT NULL UNIQUE, \"time\" time_text NULL, \"time_nonull\" time_text NOT NULL, \"time_uniq\" time_text NOT NULL UNIQUE, \"date_time\" datetime_text NULL, \"date_time_nonull\" datetime_text NOT NULL, \"date_time_uniq\" datetime_text NOT NULL UNIQUE, \"tstz\" timestamp_with_timezone_text NULL, \"tstz_nonull\" timestamp_with_timezone_text NOT NULL, \"json\" json_text NULL, \"json_nonull\" json_text NOT NULL, \"jsonb\" jsonb_text NULL, \"jsonb_nonull\" jsonb_text NOT NULL, \"jsonb_uniq\" jsonb_text NOT NULL UNIQUE, \"blob\" blob NULL, \"blob_nonull\" blob NOT NULL, \"blob_uniq\" blob NOT NULL UNIQUE, \"money\" real_money NULL, \"money_nonull\" real_money NOT NULL, \"money_uniq\" real_money NOT NULL UNIQUE, \"decimal\" real_decimal NULL, \"decimal_nonull\" real_decimal NOT NULL, \"decimal_uniq\" real_decimal NOT NULL UNIQUE, \"float\" float NULL, \"float_nonull\" float NOT NULL, \"float_uniq\" float NOT NULL UNIQUE, \"double\" double NULL, \"double_nonull\" double NOT NULL, \"double_uniq\" double NOT NULL UNIQUE, \"small_int\" smallint NULL, \"small_int_nonull\" smallint NOT NULL, \"small_int_uniq\" smallint NOT NULL UNIQUE, \"small_unsigned\" smallint NULL, \"small_unsigned_nonull\" smallint NOT NULL, \"small_unsigned_uniq\" smallint NOT NULL UNIQUE, \"unsigned\" integer NULL, \"unsigned_nonull\" integer NOT NULL, \"unsigned_uniq\" integer NOT NULL UNIQUE, \"int\" integer NULL, \"int_nonull\" integer NOT NULL, \"int_uniq\" integer NOT NULL UNIQUE, \"big_int\" integer NULL, \"big_int_nonull\" integer NOT NULL, \"big_int_uniq\" integer NOT NULL UNIQUE, \"age\" real(8, 24) NULL, \"age_nonull\" real(8, 24) NOT NULL, \"playlist_id\" integer NOT NULL, \"user_id\" integer NULL, \"rating\" integer NULL, FOREIGN KEY (\"playlist_id\") REFERENCES \"playlists\" (\"id\") ON DELETE CASCADE ON UPDATE CASCADE, FOREIGN KEY (\"user_id\") REFERENCES \"users\" (\"id\") ON DELETE SET NULL ON UPDATE NO ACTION )",
"table": "movies"
},
{
"sql": "CREATE TABLE \"playlists\" ( \"created_at\" timestamp_with_timezone_text NOT NULL DEFAULT CURRENT_TIMESTAMP, \"updated_at\" timestamp_with_timezone_text NOT NULL DEFAULT CURRENT_TIMESTAMP, \"id\" integer NOT NULL PRIMARY KEY AUTOINCREMENT, \"string\" varchar NULL, \"string_nonull\" varchar NOT NULL, \"string_uniq\" varchar NOT NULL UNIQUE, \"text\" text NULL, \"text_nonull\" text NOT NULL, \"text_uniq\" text NOT NULL UNIQUE, \"uuid\" uuid_text NULL, \"uuid_nonull\" uuid_text NOT NULL, \"uuid_uniq\" uuid_text NOT NULL UNIQUE, \"bool\" boolean NULL, \"bool_nonull\" boolean NOT NULL, \"date\" date_text NULL, \"date_nonull\" date_text NOT NULL, \"date_uniq\" date_text NOT NULL UNIQUE, \"time\" time_text NULL, \"time_nonull\" time_text NOT NULL, \"time_uniq\" time_text NOT NULL UNIQUE, \"date_time\" datetime_text NULL, \"date_time_nonull\" datetime_text NOT NULL, \"date_time_uniq\" datetime_text NOT NULL UNIQUE, \"tstz\" timestamp_with_timezone_text NULL, \"tstz_nonull\" timestamp_with_timezone_text NOT NULL, \"json\" json_text NULL, \"json_nonull\" json_text NOT NULL, \"jsonb\" jsonb_text NULL, \"jsonb_nonull\" jsonb_text NOT NULL, \"jsonb_uniq\" jsonb_text NOT NULL UNIQUE, \"blob\" blob NULL, \"blob_nonull\" blob NOT NULL, \"blob_uniq\" blob NOT NULL UNIQUE, \"money\" real_money NULL, \"money_nonull\" real_money NOT NULL, \"money_uniq\" real_money NOT NULL UNIQUE, \"decimal\" real_decimal NULL, \"decimal_nonull\" real_decimal NOT NULL, \"decimal_uniq\" real_decimal NOT NULL UNIQUE, \"float\" float NULL, \"float_nonull\" float NOT NULL, \"float_uniq\" float NOT NULL UNIQUE, \"double\" double NULL, \"double_nonull\" double NOT NULL, \"double_uniq\" double NOT NULL UNIQUE, \"small_int\" smallint NULL, \"small_int_nonull\" smallint NOT NULL, \"small_int_uniq\" smallint NOT NULL UNIQUE, \"small_unsigned\" smallint NULL, \"small_unsigned_nonull\" smallint NOT NULL, \"small_unsigned_uniq\" smallint NOT NULL UNIQUE, \"unsigned\" integer NULL, \"unsigned_nonull\" integer NOT NULL, \"unsigned_uniq\" integer NOT NULL UNIQUE, \"int\" integer NULL, \"int_nonull\" integer NOT NULL, \"int_uniq\" integer NOT NULL UNIQUE, \"big_int\" integer NULL, \"big_int_nonull\" integer NOT NULL, \"big_int_uniq\" integer NOT NULL UNIQUE, \"age\" real(8, 24) NULL, \"age_nonull\" real(8, 24) NOT NULL )",
"table": "playlists"
},
{
"sql": "CREATE TABLE \"playlists_without_tzs\" ( \"id\" integer NOT NULL PRIMARY KEY AUTOINCREMENT, \"string\" varchar NULL, \"string_nonull\" varchar NOT NULL, \"string_uniq\" varchar NOT NULL UNIQUE, \"text\" text NULL, \"text_nonull\" text NOT NULL, \"text_uniq\" text NOT NULL UNIQUE, \"uuid\" uuid_text NULL, \"uuid_nonull\" uuid_text NOT NULL, \"uuid_uniq\" uuid_text NOT NULL UNIQUE, \"bool\" boolean NULL, \"bool_nonull\" boolean NOT NULL, \"date\" date_text NULL, \"date_nonull\" date_text NOT NULL, \"date_uniq\" date_text NOT NULL UNIQUE, \"time\" time_text NULL, \"time_nonull\" time_text NOT NULL, \"time_uniq\" time_text NOT NULL UNIQUE, \"date_time\" datetime_text NULL, \"date_time_nonull\" datetime_text NOT NULL, \"date_time_uniq\" datetime_text NOT NULL UNIQUE, \"tstz\" timestamp_with_timezone_text NULL, \"tstz_nonull\" timestamp_with_timezone_text NOT NULL, \"json\" json_text NULL, \"json_nonull\" json_text NOT NULL, \"jsonb\" jsonb_text NULL, \"jsonb_nonull\" jsonb_text NOT NULL, \"jsonb_uniq\" jsonb_text NOT NULL UNIQUE, \"blob\" blob NULL, \"blob_nonull\" blob NOT NULL, \"blob_uniq\" blob NOT NULL UNIQUE, \"money\" real_money NULL, \"money_nonull\" real_money NOT NULL, \"money_uniq\" real_money NOT NULL UNIQUE, \"decimal\" real_decimal NULL, \"decimal_nonull\" real_decimal NOT NULL, \"decimal_uniq\" real_decimal NOT NULL UNIQUE, \"float\" float NULL, \"float_nonull\" float NOT NULL, \"float_uniq\" float NOT NULL UNIQUE, \"double\" double NULL, \"double_nonull\" double NOT NULL, \"double_uniq\" double NOT NULL UNIQUE, \"small_int\" smallint NULL, \"small_int_nonull\" smallint NOT NULL, \"small_int_uniq\" smallint NOT NULL UNIQUE, \"small_unsigned\" smallint NULL, \"small_unsigned_nonull\" smallint NOT NULL, \"small_unsigned_uniq\" smallint NOT NULL UNIQUE, \"unsigned\" integer NULL, \"unsigned_nonull\" integer NOT NULL, \"unsigned_uniq\" integer NOT NULL UNIQUE, \"int\" integer NULL, \"int_nonull\" integer NOT NULL, \"int_uniq\" integer NOT NULL UNIQUE, \"big_int\" integer NULL, \"big_int_nonull\" integer NOT NULL, \"big_int_uniq\" integer NOT NULL UNIQUE, \"age\" real(8, 24) NULL, \"age_nonull\" real(8, 24) NOT NULL )",
"table": "playlists_without_tzs"
},
{
"sql": "CREATE TABLE \"seaql_migrations\" ( \"version\" varchar NOT NULL PRIMARY KEY, \"applied_at\" integer NOT NULL )",
"table": "seaql_migrations"
},
{
"sql": "CREATE TABLE \"user_without_tz_movies\" ( \"minutes\" integer NULL, \"user_without_tz_id\" integer NOT NULL, \"movie_id\" integer NOT NULL, CONSTRAINT \"idx-user_without_tz_movies-refs-pk\" PRIMARY KEY (\"user_without_tz_id\", \"movie_id\"), FOREIGN KEY (\"user_without_tz_id\") REFERENCES \"user_without_tzs\" (\"id\") ON DELETE CASCADE ON UPDATE CASCADE, FOREIGN KEY (\"movie_id\") REFERENCES \"movies\" (\"id\") ON DELETE CASCADE ON UPDATE CASCADE )",
"table": "user_without_tz_movies"
},
{
"sql": "CREATE TABLE \"user_without_tzs\" ( \"id\" integer NOT NULL PRIMARY KEY AUTOINCREMENT, \"name\" varchar NULL )",
"table": "user_without_tzs"
},
{
"sql": "CREATE TABLE \"users\" ( \"created_at\" timestamp_with_timezone_text NOT NULL DEFAULT CURRENT_TIMESTAMP, \"updated_at\" timestamp_with_timezone_text NOT NULL DEFAULT CURRENT_TIMESTAMP, \"id\" integer NOT NULL PRIMARY KEY AUTOINCREMENT, \"pid\" uuid_text NOT NULL, \"email\" varchar NOT NULL UNIQUE, \"password\" varchar NOT NULL, \"api_key\" varchar NOT NULL UNIQUE, \"name\" varchar NOT NULL, \"reset_token\" varchar NULL, \"reset_sent_at\" timestamp_with_timezone_text NULL, \"email_verification_token\" varchar NULL, \"email_verification_sent_at\" timestamp_with_timezone_text NULL, \"email_verified_at\" timestamp_with_timezone_text NULL, \"magic_link_token\" varchar NULL, \"magic_link_expiration\" timestamp_with_timezone_text NULL )",
"table": "users"
}
]
@@ -0,0 +1,836 @@
---
source: loco-gen/tests/db.rs
expression: "read_to_string(tree_fs.root.join(\"myapp\").join(\"schema_dump.json\")).unwrap()"
---
[
{
"column": "created_at",
"table": "actor_movies",
"type": "timestamp with time zone"
},
{
"column": "updated_at",
"table": "actor_movies",
"type": "timestamp with time zone"
},
{
"column": "actor_id",
"table": "actor_movies",
"type": "integer"
},
{
"column": "movie_id",
"table": "actor_movies",
"type": "integer"
},
{
"column": "created_at",
"table": "actors",
"type": "timestamp with time zone"
},
{
"column": "updated_at",
"table": "actors",
"type": "timestamp with time zone"
},
{
"column": "id",
"table": "actors",
"type": "integer"
},
{
"column": "foobar",
"table": "actors",
"type": "character varying"
},
{
"column": "created_at",
"table": "awards",
"type": "timestamp with time zone"
},
{
"column": "updated_at",
"table": "awards",
"type": "timestamp with time zone"
},
{
"column": "id",
"table": "awards",
"type": "integer"
},
{
"column": "name",
"table": "awards",
"type": "character varying"
},
{
"column": "actor_id",
"table": "awards",
"type": "integer"
},
{
"column": "created_at",
"table": "movies",
"type": "timestamp with time zone"
},
{
"column": "updated_at",
"table": "movies",
"type": "timestamp with time zone"
},
{
"column": "id",
"table": "movies",
"type": "integer"
},
{
"column": "uuid_uniq",
"table": "movies",
"type": "uuid"
},
{
"column": "uuid",
"table": "movies",
"type": "uuid"
},
{
"column": "uuid_nonull",
"table": "movies",
"type": "uuid"
},
{
"column": "string",
"table": "movies",
"type": "character varying"
},
{
"column": "string_nonull",
"table": "movies",
"type": "character varying"
},
{
"column": "string_uniq",
"table": "movies",
"type": "character varying"
},
{
"column": "text",
"table": "movies",
"type": "text"
},
{
"column": "text_nonull",
"table": "movies",
"type": "text"
},
{
"column": "text_uniq",
"table": "movies",
"type": "text"
},
{
"column": "small_unsigned",
"table": "movies",
"type": "smallint"
},
{
"column": "small_unsigned_nonull",
"table": "movies",
"type": "smallint"
},
{
"column": "small_unsigned_uniq",
"table": "movies",
"type": "smallint"
},
{
"column": "big_unsigned",
"table": "movies",
"type": "bigint"
},
{
"column": "big_unsigned_nonull",
"table": "movies",
"type": "bigint"
},
{
"column": "big_unsigned_uniq",
"table": "movies",
"type": "bigint"
},
{
"column": "small_int",
"table": "movies",
"type": "smallint"
},
{
"column": "small_int_nonull",
"table": "movies",
"type": "smallint"
},
{
"column": "small_int_uniq",
"table": "movies",
"type": "smallint"
},
{
"column": "int",
"table": "movies",
"type": "integer"
},
{
"column": "int_nonull",
"table": "movies",
"type": "integer"
},
{
"column": "int_uniq",
"table": "movies",
"type": "integer"
},
{
"column": "big_int",
"table": "movies",
"type": "bigint"
},
{
"column": "big_int_nonull",
"table": "movies",
"type": "bigint"
},
{
"column": "big_int_uniq",
"table": "movies",
"type": "bigint"
},
{
"column": "float",
"table": "movies",
"type": "real"
},
{
"column": "float_nonull",
"table": "movies",
"type": "real"
},
{
"column": "float_uniq",
"table": "movies",
"type": "real"
},
{
"column": "double",
"table": "movies",
"type": "double precision"
},
{
"column": "double_nonull",
"table": "movies",
"type": "double precision"
},
{
"column": "double_uniq",
"table": "movies",
"type": "double precision"
},
{
"column": "decimal",
"table": "movies",
"type": "numeric"
},
{
"column": "decimal_nonull",
"table": "movies",
"type": "numeric"
},
{
"column": "decimal_uniq",
"table": "movies",
"type": "numeric"
},
{
"column": "bool",
"table": "movies",
"type": "boolean"
},
{
"column": "bool_nonull",
"table": "movies",
"type": "boolean"
},
{
"column": "tstz",
"table": "movies",
"type": "timestamp with time zone"
},
{
"column": "tstz_nonull",
"table": "movies",
"type": "timestamp with time zone"
},
{
"column": "date",
"table": "movies",
"type": "date"
},
{
"column": "date_nonull",
"table": "movies",
"type": "date"
},
{
"column": "date_uniq",
"table": "movies",
"type": "date"
},
{
"column": "date_time",
"table": "movies",
"type": "timestamp without time zone"
},
{
"column": "date_time_nonull",
"table": "movies",
"type": "timestamp without time zone"
},
{
"column": "date_time_uniq",
"table": "movies",
"type": "timestamp without time zone"
},
{
"column": "json",
"table": "movies",
"type": "json"
},
{
"column": "json_nonull",
"table": "movies",
"type": "json"
},
{
"column": "jsonb",
"table": "movies",
"type": "jsonb"
},
{
"column": "jsonb_nonull",
"table": "movies",
"type": "jsonb"
},
{
"column": "jsonb_uniq",
"table": "movies",
"type": "jsonb"
},
{
"column": "blob",
"table": "movies",
"type": "bytea"
},
{
"column": "blob_nonull",
"table": "movies",
"type": "bytea"
},
{
"column": "blob_uniq",
"table": "movies",
"type": "bytea"
},
{
"column": "money",
"table": "movies",
"type": "money"
},
{
"column": "money_nonull",
"table": "movies",
"type": "money"
},
{
"column": "money_uniq",
"table": "movies",
"type": "money"
},
{
"column": "unsigned_nonull",
"table": "movies",
"type": "integer"
},
{
"column": "unsigned",
"table": "movies",
"type": "integer"
},
{
"column": "unsigned_uniq",
"table": "movies",
"type": "integer"
},
{
"column": "age",
"table": "movies",
"type": "numeric"
},
{
"column": "age_nonull",
"table": "movies",
"type": "numeric"
},
{
"column": "array_string",
"table": "movies",
"type": "ARRAY"
},
{
"column": "array_float",
"table": "movies",
"type": "ARRAY"
},
{
"column": "array_int",
"table": "movies",
"type": "ARRAY"
},
{
"column": "array_double",
"table": "movies",
"type": "ARRAY"
},
{
"column": "array_bool",
"table": "movies",
"type": "ARRAY"
},
{
"column": "playlist_id",
"table": "movies",
"type": "integer"
},
{
"column": "rating",
"table": "movies",
"type": "integer"
},
{
"column": "created_at",
"table": "playlists",
"type": "timestamp with time zone"
},
{
"column": "updated_at",
"table": "playlists",
"type": "timestamp with time zone"
},
{
"column": "id",
"table": "playlists",
"type": "integer"
},
{
"column": "uuid_uniq",
"table": "playlists",
"type": "uuid"
},
{
"column": "uuid",
"table": "playlists",
"type": "uuid"
},
{
"column": "uuid_nonull",
"table": "playlists",
"type": "uuid"
},
{
"column": "string",
"table": "playlists",
"type": "character varying"
},
{
"column": "string_nonull",
"table": "playlists",
"type": "character varying"
},
{
"column": "string_uniq",
"table": "playlists",
"type": "character varying"
},
{
"column": "text",
"table": "playlists",
"type": "text"
},
{
"column": "text_nonull",
"table": "playlists",
"type": "text"
},
{
"column": "text_uniq",
"table": "playlists",
"type": "text"
},
{
"column": "small_unsigned",
"table": "playlists",
"type": "smallint"
},
{
"column": "small_unsigned_nonull",
"table": "playlists",
"type": "smallint"
},
{
"column": "small_unsigned_uniq",
"table": "playlists",
"type": "smallint"
},
{
"column": "big_unsigned",
"table": "playlists",
"type": "bigint"
},
{
"column": "big_unsigned_nonull",
"table": "playlists",
"type": "bigint"
},
{
"column": "big_unsigned_uniq",
"table": "playlists",
"type": "bigint"
},
{
"column": "small_int",
"table": "playlists",
"type": "smallint"
},
{
"column": "small_int_nonull",
"table": "playlists",
"type": "smallint"
},
{
"column": "small_int_uniq",
"table": "playlists",
"type": "smallint"
},
{
"column": "int",
"table": "playlists",
"type": "integer"
},
{
"column": "int_nonull",
"table": "playlists",
"type": "integer"
},
{
"column": "int_uniq",
"table": "playlists",
"type": "integer"
},
{
"column": "big_int",
"table": "playlists",
"type": "bigint"
},
{
"column": "big_int_nonull",
"table": "playlists",
"type": "bigint"
},
{
"column": "big_int_uniq",
"table": "playlists",
"type": "bigint"
},
{
"column": "float",
"table": "playlists",
"type": "real"
},
{
"column": "float_nonull",
"table": "playlists",
"type": "real"
},
{
"column": "float_uniq",
"table": "playlists",
"type": "real"
},
{
"column": "double",
"table": "playlists",
"type": "double precision"
},
{
"column": "double_nonull",
"table": "playlists",
"type": "double precision"
},
{
"column": "double_uniq",
"table": "playlists",
"type": "double precision"
},
{
"column": "decimal",
"table": "playlists",
"type": "numeric"
},
{
"column": "decimal_nonull",
"table": "playlists",
"type": "numeric"
},
{
"column": "decimal_uniq",
"table": "playlists",
"type": "numeric"
},
{
"column": "bool",
"table": "playlists",
"type": "boolean"
},
{
"column": "bool_nonull",
"table": "playlists",
"type": "boolean"
},
{
"column": "tstz",
"table": "playlists",
"type": "timestamp with time zone"
},
{
"column": "tstz_nonull",
"table": "playlists",
"type": "timestamp with time zone"
},
{
"column": "date",
"table": "playlists",
"type": "date"
},
{
"column": "date_nonull",
"table": "playlists",
"type": "date"
},
{
"column": "date_uniq",
"table": "playlists",
"type": "date"
},
{
"column": "date_time",
"table": "playlists",
"type": "timestamp without time zone"
},
{
"column": "date_time_nonull",
"table": "playlists",
"type": "timestamp without time zone"
},
{
"column": "date_time_uniq",
"table": "playlists",
"type": "timestamp without time zone"
},
{
"column": "json",
"table": "playlists",
"type": "json"
},
{
"column": "json_nonull",
"table": "playlists",
"type": "json"
},
{
"column": "jsonb",
"table": "playlists",
"type": "jsonb"
},
{
"column": "jsonb_nonull",
"table": "playlists",
"type": "jsonb"
},
{
"column": "jsonb_uniq",
"table": "playlists",
"type": "jsonb"
},
{
"column": "blob",
"table": "playlists",
"type": "bytea"
},
{
"column": "blob_nonull",
"table": "playlists",
"type": "bytea"
},
{
"column": "blob_uniq",
"table": "playlists",
"type": "bytea"
},
{
"column": "money",
"table": "playlists",
"type": "money"
},
{
"column": "money_nonull",
"table": "playlists",
"type": "money"
},
{
"column": "money_uniq",
"table": "playlists",
"type": "money"
},
{
"column": "unsigned_nonull",
"table": "playlists",
"type": "integer"
},
{
"column": "unsigned",
"table": "playlists",
"type": "integer"
},
{
"column": "unsigned_uniq",
"table": "playlists",
"type": "integer"
},
{
"column": "age",
"table": "playlists",
"type": "numeric"
},
{
"column": "age_nonull",
"table": "playlists",
"type": "numeric"
},
{
"column": "array_string",
"table": "playlists",
"type": "ARRAY"
},
{
"column": "array_float",
"table": "playlists",
"type": "ARRAY"
},
{
"column": "array_int",
"table": "playlists",
"type": "ARRAY"
},
{
"column": "array_double",
"table": "playlists",
"type": "ARRAY"
},
{
"column": "array_bool",
"table": "playlists",
"type": "ARRAY"
},
{
"column": "version",
"table": "seaql_migrations",
"type": "character varying"
},
{
"column": "applied_at",
"table": "seaql_migrations",
"type": "bigint"
},
{
"column": "created_at",
"table": "users",
"type": "timestamp with time zone"
},
{
"column": "updated_at",
"table": "users",
"type": "timestamp with time zone"
},
{
"column": "id",
"table": "users",
"type": "integer"
},
{
"column": "pid",
"table": "users",
"type": "uuid"
},
{
"column": "email",
"table": "users",
"type": "character varying"
},
{
"column": "password",
"table": "users",
"type": "character varying"
},
{
"column": "api_key",
"table": "users",
"type": "character varying"
},
{
"column": "name",
"table": "users",
"type": "character varying"
},
{
"column": "reset_token",
"table": "users",
"type": "character varying"
},
{
"column": "reset_sent_at",
"table": "users",
"type": "timestamp with time zone"
},
{
"column": "email_verification_token",
"table": "users",
"type": "character varying"
},
{
"column": "email_verification_sent_at",
"table": "users",
"type": "timestamp with time zone"
},
{
"column": "email_verified_at",
"table": "users",
"type": "timestamp with time zone"
},
{
"column": "magic_link_token",
"table": "users",
"type": "character varying"
},
{
"column": "magic_link_expiration",
"table": "users",
"type": "timestamp with time zone"
}
]
@@ -0,0 +1,34 @@
---
source: loco-gen/tests/db.rs
expression: "read_to_string(tree_fs.root.join(\"myapp\").join(\"schema_dump.json\")).unwrap()"
---
[
{
"sql": "CREATE TABLE \"actor_movies\" ( \"created_at\" timestamp_with_timezone_text NOT NULL DEFAULT CURRENT_TIMESTAMP, \"updated_at\" timestamp_with_timezone_text NOT NULL DEFAULT CURRENT_TIMESTAMP, \"actor_id\" integer NOT NULL, \"movie_id\" integer NOT NULL, CONSTRAINT \"idx-actor_movies-refs-pk\" PRIMARY KEY (\"actor_id\", \"movie_id\"), FOREIGN KEY (\"actor_id\") REFERENCES \"actors\" (\"id\") ON DELETE CASCADE ON UPDATE CASCADE, FOREIGN KEY (\"movie_id\") REFERENCES \"movies\" (\"id\") ON DELETE CASCADE ON UPDATE CASCADE )",
"table": "actor_movies"
},
{
"sql": "CREATE TABLE \"actors\" ( \"created_at\" timestamp_with_timezone_text NOT NULL DEFAULT CURRENT_TIMESTAMP, \"updated_at\" timestamp_with_timezone_text NOT NULL DEFAULT CURRENT_TIMESTAMP, \"id\" integer NOT NULL PRIMARY KEY AUTOINCREMENT, \"foobar\" varchar NULL )",
"table": "actors"
},
{
"sql": "CREATE TABLE \"awards\" ( \"created_at\" timestamp_with_timezone_text NOT NULL DEFAULT CURRENT_TIMESTAMP, \"updated_at\" timestamp_with_timezone_text NOT NULL DEFAULT CURRENT_TIMESTAMP, \"id\" integer NOT NULL PRIMARY KEY AUTOINCREMENT, \"name\" varchar NULL, \"actor_id\" integer NOT NULL, FOREIGN KEY (\"actor_id\") REFERENCES \"actors\" (\"id\") ON DELETE CASCADE ON UPDATE CASCADE )",
"table": "awards"
},
{
"sql": "CREATE TABLE \"movies\" ( \"created_at\" timestamp_with_timezone_text NOT NULL DEFAULT CURRENT_TIMESTAMP, \"updated_at\" timestamp_with_timezone_text NOT NULL DEFAULT CURRENT_TIMESTAMP, \"id\" integer NOT NULL PRIMARY KEY AUTOINCREMENT, \"uuid_uniq\" uuid_text NOT NULL UNIQUE, \"uuid\" uuid_text NULL, \"uuid_nonull\" uuid_text NOT NULL, \"string\" varchar NULL, \"string_nonull\" varchar NOT NULL, \"string_uniq\" varchar NOT NULL UNIQUE, \"text\" text NULL, \"text_nonull\" text NOT NULL, \"text_uniq\" text NOT NULL UNIQUE, \"small_unsigned\" smallint NULL, \"small_unsigned_nonull\" smallint NOT NULL, \"small_unsigned_uniq\" smallint NOT NULL UNIQUE, \"big_unsigned\" bigint NULL, \"big_unsigned_nonull\" bigint NOT NULL, \"big_unsigned_uniq\" bigint NOT NULL UNIQUE, \"small_int\" smallint NULL, \"small_int_nonull\" smallint NOT NULL, \"small_int_uniq\" smallint NOT NULL UNIQUE, \"int\" integer NULL, \"int_nonull\" integer NOT NULL, \"int_uniq\" integer NOT NULL UNIQUE, \"big_int\" bigint NULL, \"big_int_nonull\" bigint NOT NULL, \"big_int_uniq\" bigint NOT NULL UNIQUE, \"float\" float NULL, \"float_nonull\" float NOT NULL, \"float_uniq\" float NOT NULL UNIQUE, \"double\" double NULL, \"double_nonull\" double NOT NULL, \"double_uniq\" double NOT NULL UNIQUE, \"decimal\" real NULL, \"decimal_nonull\" real NOT NULL, \"decimal_uniq\" real NOT NULL UNIQUE, \"bool\" boolean NULL, \"bool_nonull\" boolean NOT NULL, \"tstz\" timestamp_with_timezone_text, \"tstz_nonull\" timestamp_with_timezone_text NOT NULL, \"date\" date_text NULL, \"date_nonull\" date_text NOT NULL, \"date_uniq\" date_text NOT NULL UNIQUE, \"date_time\" datetime_text NULL, \"date_time_nonull\" datetime_text NOT NULL, \"date_time_uniq\" datetime_text NOT NULL UNIQUE, \"json\" json_text NULL, \"json_nonull\" json_text NOT NULL, \"jsonb\" jsonb_text NULL, \"jsonb_nonull\" jsonb_text NOT NULL, \"jsonb_uniq\" jsonb_text NOT NULL UNIQUE, \"blob\" blob NULL, \"blob_nonull\" blob NOT NULL, \"blob_uniq\" blob NOT NULL UNIQUE, \"money\" real_money NULL, \"money_nonull\" real_money NOT NULL, \"money_uniq\" real_money NOT NULL UNIQUE, \"unsigned_nonull\" integer NOT NULL, \"unsigned\" integer NULL, \"unsigned_uniq\" integer NOT NULL UNIQUE, \"age\" real(8, 24) NULL, \"age_nonull\" real(8, 24) NOT NULL, \"playlist_id\" integer NOT NULL, \"rating\" integer NULL, FOREIGN KEY (\"playlist_id\") REFERENCES \"playlists\" (\"id\") ON DELETE CASCADE ON UPDATE CASCADE )",
"table": "movies"
},
{
"sql": "CREATE TABLE \"playlists\" ( \"created_at\" timestamp_with_timezone_text NOT NULL DEFAULT CURRENT_TIMESTAMP, \"updated_at\" timestamp_with_timezone_text NOT NULL DEFAULT CURRENT_TIMESTAMP, \"id\" integer NOT NULL PRIMARY KEY AUTOINCREMENT, \"uuid_uniq\" uuid_text NOT NULL UNIQUE, \"uuid\" uuid_text NULL, \"uuid_nonull\" uuid_text NOT NULL, \"string\" varchar NULL, \"string_nonull\" varchar NOT NULL, \"string_uniq\" varchar NOT NULL UNIQUE, \"text\" text NULL, \"text_nonull\" text NOT NULL, \"text_uniq\" text NOT NULL UNIQUE, \"small_unsigned\" smallint NULL, \"small_unsigned_nonull\" smallint NOT NULL, \"small_unsigned_uniq\" smallint NOT NULL UNIQUE, \"big_unsigned\" bigint NULL, \"big_unsigned_nonull\" bigint NOT NULL, \"big_unsigned_uniq\" bigint NOT NULL UNIQUE, \"small_int\" smallint NULL, \"small_int_nonull\" smallint NOT NULL, \"small_int_uniq\" smallint NOT NULL UNIQUE, \"int\" integer NULL, \"int_nonull\" integer NOT NULL, \"int_uniq\" integer NOT NULL UNIQUE, \"big_int\" bigint NULL, \"big_int_nonull\" bigint NOT NULL, \"big_int_uniq\" bigint NOT NULL UNIQUE, \"float\" float NULL, \"float_nonull\" float NOT NULL, \"float_uniq\" float NOT NULL UNIQUE, \"double\" double NULL, \"double_nonull\" double NOT NULL, \"double_uniq\" double NOT NULL UNIQUE, \"decimal\" real NULL, \"decimal_nonull\" real NOT NULL, \"decimal_uniq\" real NOT NULL UNIQUE, \"bool\" boolean NULL, \"bool_nonull\" boolean NOT NULL, \"tstz\" timestamp_with_timezone_text, \"tstz_nonull\" timestamp_with_timezone_text NOT NULL, \"date\" date_text NULL, \"date_nonull\" date_text NOT NULL, \"date_uniq\" date_text NOT NULL UNIQUE, \"date_time\" datetime_text NULL, \"date_time_nonull\" datetime_text NOT NULL, \"date_time_uniq\" datetime_text NOT NULL UNIQUE, \"json\" json_text NULL, \"json_nonull\" json_text NOT NULL, \"jsonb\" jsonb_text NULL, \"jsonb_nonull\" jsonb_text NOT NULL, \"jsonb_uniq\" jsonb_text NOT NULL UNIQUE, \"blob\" blob NULL, \"blob_nonull\" blob NOT NULL, \"blob_uniq\" blob NOT NULL UNIQUE, \"money\" real_money NULL, \"money_nonull\" real_money NOT NULL, \"money_uniq\" real_money NOT NULL UNIQUE, \"unsigned_nonull\" integer NOT NULL, \"unsigned\" integer NULL, \"unsigned_uniq\" integer NOT NULL UNIQUE, \"age\" real(8, 24) NULL, \"age_nonull\" real(8, 24) NOT NULL )",
"table": "playlists"
},
{
"sql": "CREATE TABLE \"seaql_migrations\" ( \"version\" varchar NOT NULL PRIMARY KEY, \"applied_at\" bigint NOT NULL )",
"table": "seaql_migrations"
},
{
"sql": "CREATE TABLE \"users\" ( \"created_at\" timestamp_with_timezone_text NOT NULL DEFAULT CURRENT_TIMESTAMP, \"updated_at\" timestamp_with_timezone_text NOT NULL DEFAULT CURRENT_TIMESTAMP, \"id\" integer NOT NULL PRIMARY KEY AUTOINCREMENT, \"pid\" uuid_text NOT NULL, \"email\" varchar NOT NULL UNIQUE, \"password\" varchar NOT NULL, \"api_key\" varchar NOT NULL UNIQUE, \"name\" varchar NOT NULL, \"reset_token\" varchar NULL, \"reset_sent_at\" timestamp_with_timezone_text NULL, \"email_verification_token\" varchar NULL, \"email_verification_sent_at\" timestamp_with_timezone_text NULL, \"email_verified_at\" timestamp_with_timezone_text NULL, \"magic_link_token\" varchar NULL, \"magic_link_expiration\" timestamp_with_timezone_text NULL )",
"table": "users"
}
]
@@ -0,0 +1,65 @@
use super::utils::APP_ROUTS;
use insta::assert_snapshot;
use loco_gen::{collect_messages, generate, AppInfo, Component};
use rrgen::RRgen;
use std::fs;
#[test]
fn can_generate() {
let actions = vec!["GET".to_string(), "POST".to_string()];
let component = Component::Controller {
name: "movie".to_string(),
actions: actions.clone(),
};
let mut settings = insta::Settings::clone_current();
settings.set_prepend_module_to_snapshot(false);
settings.set_snapshot_suffix("Api_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")
);
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")
);
}
@@ -0,0 +1,104 @@
use insta::assert_snapshot;
use loco_gen::{collect_messages, generate, AppInfo, Component, DeploymentKind};
use rrgen::RRgen;
use std::{fs, path::PathBuf};
#[rstest::rstest]
fn can_generate_docker(
#[values(vec![], vec![std::path::PathBuf::from("404.html"), PathBuf::from("asset")])]
copy_paths: Vec<PathBuf>,
#[values(true, false)] is_client_side_rendering: bool,
) {
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 {
copy_paths: copy_paths.clone(),
is_client_side_rendering,
},
};
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"* Dockerfile generated successfully.
* Dockerignore generated successfully.
"
);
insta::with_settings!({
filters => vec![
(r"FROM rust:\d+\.\d+\.\d+-slim", "FROM rust:[version]-slim"),
]
}, {
assert_snapshot!(
format!(
"generate[docker_file_[{}]_[{}]]",
copy_paths.len(),
is_client_side_rendering
),
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 {
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")
);
}
+87
View File
@@ -0,0 +1,87 @@
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"),
),
(
"generate[shared_base_t_file]",
mailer_path.join("shared").join("base.t"),
),
(
"generate[shared_subject_t_file]",
mailer_path.join("shared").join("subject.t"),
),
(
"generate[shared_text_t_file]",
mailer_path.join("shared").join("text.t"),
),
] {
assert_snapshot!(
name,
fs::read_to_string(path).unwrap_or_else(|_| panic!("{name} missing"))
);
}
}
@@ -0,0 +1,191 @@
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(),
with_tz: true,
fields: vec![
("title".to_string(), "string".to_string()),
("user".to_string(), "references".to_string()),
],
}, "movies.rs")]
#[case("create_table_without_tz", Component::Migration {
name: "CreateMovies".to_string(),
with_tz: false,
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(),
with_tz: true,
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(),
with_tz: true,
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(),
with_tz: true,
fields: vec![
("user".to_string(), "references".to_string()),
],
}, "add_user_ref_to_posts.rs")]
#[case("create_join_table_without_tz", Component::Migration {
name: "CreateJoinTableUsersAndGroups".to_string(),
with_tz: false,
fields: vec![
("count".to_string(), "int".to_string()),
],
}, "create_join_table_users_and_groups.rs")]
#[case("create_join_table", Component::Migration {
name: "CreateJoinTableUsersAndGroups".to_string(),
with_tz: true,
fields: vec![
("count".to_string(), "int".to_string()),
],
}, "create_join_table_users_and_groups.rs")]
#[case("empty", Component::Migration {
name: "FixUsersTable".to_string(),
with_tz: true,
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(),
with_tz: true,
fields: vec![
("title".to_string(), "string".to_string()),
("user".to_string(), "references".to_string()),
],
})]
#[case(Component::Migration {
name: "AddNameAndAgeToUsers".to_string(),
with_tz: true,
fields: vec![
("name".to_string(), "string".to_string()),
("age".to_string(), "int".to_string()),
],
})]
#[case(Component::Migration {
name: "RemoveNameAndAgeFromUsers".to_string(),
with_tz: true,
fields: vec![
("name".to_string(), "string".to_string()),
("age".to_string(), "int".to_string()),
],
})]
#[case(Component::Migration {
name: "AddUserRefToPosts".to_string(),
with_tz: true,
fields: vec![
("user".to_string(), "references".to_string()),
],
})]
#[case(Component::Migration {
name: "CreateJoinTableUsersAndGroups".to_string(),
with_tz: true,
fields: vec![
("count".to_string(), "int".to_string()),
],
})]
#[case(Component::Migration {
name: "FixUsersTable".to_string(),
with_tz: true,
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"
);
}
+13
View File
@@ -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;
+183
View File
@@ -0,0 +1,183 @@
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() {
// SAFETY: test-local env setup; no other thread reads the environment during this test.
unsafe { 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(),
with_tz: true,
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 && cargo loco db entities`.
* 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")
);
}
// Regression for #1755: a model generated with no fields must still get its
// `id` primary-key column, otherwise sea-orm produces an entity with no primary
// key that fails to compile ("Entity must have a primary key column").
#[test]
fn generate_without_fields_still_emits_primary_key() {
// SAFETY: test-local env setup; no other thread reads the environment during this test.
unsafe { std::env::set_var("SKIP_MIGRATION", "") };
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: "posts".to_string(),
with_tz: true,
fields: vec![],
};
generate(
&rrgen,
component,
&AppInfo {
app_name: "tester".to_string(),
},
)
.expect("Generation failed");
let migration_path = tree_fs.root.join("migration/src");
let migration_file = guess_file_by_time(&migration_path, "m{TIME}_posts.rs", 3)
.expect("Failed to find the generated migration file");
let migration = fs::read_to_string(&migration_file).expect("read migration");
assert!(
migration.contains("(\"id\", ColType::PkAuto)"),
"a field-less model must still emit its id primary key, got:\n{migration}"
);
}
#[test]
fn fail_when_migration_lib_not_exists() {
// SAFETY: test-local env setup; no other thread reads the environment during this test.
unsafe { 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(),
with_tz: true,
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() {
// SAFETY: test-local env setup; no other thread reads the environment during this test.
unsafe { 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(),
with_tz: true,
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"
);
}
@@ -0,0 +1,198 @@
use super::utils::{guess_file_by_time, APP_ROUTS, MIGRATION_SRC_LIB, ROUTES_TSX_FIXTURE};
use insta::{assert_snapshot, with_settings};
use loco_gen::{collect_messages, generate, AppInfo, Component};
use rrgen::RRgen;
use std::fs;
/// Loco 1.0 ships a single scaffold flavor: DTO + controller + React-SPA
/// frontend, built straight from `column::Column`. The field set here is the
/// reference target from `examples/reference_spa`'s `post` resource
/// (`title:string! content:text! status:enum:draft,published,archived!
/// price:decimal! published_at:tstz`), so this snapshot doubles as the
/// golden-output check for the DTO + controller generator.
#[test]
fn can_generate() {
// SAFETY: test-local env setup; no other thread reads the environment during this test.
unsafe { std::env::set_var("SKIP_MIGRATION", "") };
let mut settings = insta::Settings::clone_current();
settings.set_prepend_module_to_snapshot(false);
settings.set_snapshot_suffix("Api_scaffold");
let _guard = settings.bind_to_scope();
let component = Component::Scaffold {
name: "post".to_string(),
with_tz: true,
fields: vec![
("title".to_string(), "string!".to_string()),
("content".to_string(), "text!".to_string()),
(
"status".to_string(),
"enum:draft,published,archived!".to_string(),
),
("price".to_string(), "decimal!".to_string()),
("published_at".to_string(), "tstz".to_string()),
],
frontend: true,
};
let tree_fs = tree_fs::TreeBuilder::default()
.drop(true)
.add_empty("src/controllers/mod.rs")
.add_empty("src/dtos/mod.rs")
.add_empty("tests/models/mod.rs")
.add("migration/src/lib.rs", MIGRATION_SRC_LIB)
.add("src/app.rs", APP_ROUTS)
.add("frontend/src/routes.tsx", ROUTES_TSX_FIXTURE)
.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));
// MIGRATION
let migration_path = tree_fs.root.join("migration/src");
let migration_file = guess_file_by_time(&migration_path, "m{TIME}_posts.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")
);
});
// MODEL TEST
let tests_path = tree_fs.root.join("tests/models");
assert_snapshot!(
"generate[test_model]",
fs::read_to_string(tests_path.join("posts.rs")).expect("Failed to read posts.rs")
);
assert_snapshot!(
"inject[test_mod]",
fs::read_to_string(tests_path.join("mod.rs")).expect("Failed to read mod.rs")
);
// DTO
let dtos_path = tree_fs.root.join("src").join("dtos");
assert_snapshot!(
"generate[dto_file]",
fs::read_to_string(dtos_path.join("posts.rs")).expect("dto file missing")
);
assert_snapshot!(
"inject[dtos_mod_rs]",
fs::read_to_string(dtos_path.join("mod.rs")).expect("mod.rs injection failed")
);
// CONTROLLER
let controllers_path = tree_fs.root.join("src").join("controllers");
assert_snapshot!(
"generate[controller_file]",
fs::read_to_string(controllers_path.join("posts.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")
);
// FRONTEND
let frontend_path = tree_fs.root.join("frontend").join("src");
assert_snapshot!(
"generate[frontend_api]",
fs::read_to_string(frontend_path.join("api").join("posts.ts"))
.expect("frontend api file missing")
);
let pages_path = frontend_path.join("pages").join("posts");
for page in ["List", "New", "Edit", "Show"] {
assert_snapshot!(
format!("generate[frontend_page_{page}]"),
fs::read_to_string(pages_path.join(format!("{page}.tsx")))
.unwrap_or_else(|_| panic!("frontend {page}.tsx file missing"))
);
}
assert_snapshot!(
"inject[routes_tsx]",
fs::read_to_string(frontend_path.join("routes.tsx")).expect("routes.tsx injection failed")
);
// no `tests/requests/<plural>.rs` is generated for the API scaffold: a
// non-compiling generated controller test is worse than none (see
// `api/test.t`'s removal).
assert!(!tree_fs.root.join("tests/requests").exists());
}
/// Adaptive scaffold, headless path: with `frontend: false` (a non-clientside
/// app, no `frontend/`), only the typed backend (DTO + controller) is emitted
/// and NO frontend files/dir are created.
#[test]
fn can_generate_backend_only_without_frontend() {
// SAFETY: test-local env setup; no other thread reads the environment during this test.
unsafe { std::env::set_var("SKIP_MIGRATION", "") };
let component = Component::Scaffold {
name: "post".to_string(),
with_tz: true,
fields: vec![
("title".to_string(), "string!".to_string()),
("content".to_string(), "text!".to_string()),
(
"status".to_string(),
"enum:draft,published,archived!".to_string(),
),
("price".to_string(), "decimal!".to_string()),
("published_at".to_string(), "tstz".to_string()),
],
frontend: false,
};
// note: NO `frontend/src/routes.tsx` fixture -- this is a headless app.
let tree_fs = tree_fs::TreeBuilder::default()
.drop(true)
.add_empty("src/controllers/mod.rs")
.add_empty("src/dtos/mod.rs")
.add_empty("tests/models/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);
generate(
&rrgen,
component,
&AppInfo {
app_name: "tester".to_string(),
},
)
.expect("Generation failed");
// backend emitted
assert!(tree_fs.root.join("src/dtos/posts.rs").exists());
assert!(tree_fs.root.join("src/controllers/posts.rs").exists());
// frontend NOT emitted -- no orphan files or `frontend/` dir
assert!(!tree_fs.root.join("frontend").exists());
}
@@ -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_2021),*) => {
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 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")
);
}
@@ -0,0 +1,31 @@
---
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::*;
#[debug_handler]
pub async fn index(State(_ctx): State<AppContext>) -> Result<Response> {
format::empty()
}
#[debug_handler]
pub async fn GET(State(_ctx): State<AppContext>) -> Result<Response> {
format::empty()
}
#[debug_handler]
pub async fn POST(State(_ctx): State<AppContext>) -> Result<Response> {
format::empty()
}
pub fn routes() -> Routes {
Routes::new()
.prefix("api/movies/")
.add("/", get(index))
.add("GET", get(GET))
.add("POST", get(POST))
}
@@ -0,0 +1,144 @@
---
source: loco-gen/tests/templates/scaffold.rs
expression: "fs::read_to_string(controllers_path.join(\"posts.rs\")).expect(\"controller file missing\")"
---
#![allow(clippy::unused_async)]
use axum::http::StatusCode;
use loco_rs::prelude::*;
use sea_orm::{PaginatorTrait, QueryOrder};
use serde::Deserialize;
use crate::{
dtos::{
common::{ApiError, Page},
posts::{CreatePost, PostDto, UpdatePost},
},
models::_entities::posts::{ActiveModel, Column, Entity},
};
const fn default_page() -> u64 {
1
}
const fn default_per_page() -> u64 {
25
}
#[derive(Debug, Deserialize)]
pub struct ListParams {
#[serde(default = "default_page")]
pub page: u64,
#[serde(default = "default_per_page")]
pub per_page: u64,
}
/// Build a 404 response shaped as the [`ApiError`] envelope.
fn not_found(message: &str) -> Response {
let error = ApiError {
code: "not_found".to_string(),
message: message.to_string(),
details: None,
};
(StatusCode::NOT_FOUND, Json(error)).into_response()
}
#[debug_handler]
async fn list(
_auth: auth::JWT,
State(ctx): State<AppContext>,
Query(params): Query<ListParams>,
) -> Result<Json<Page<PostDto>>> {
let page = params.page.max(1);
let per_page = params.per_page.max(1);
let query = Entity::find().order_by_asc(Column::Id);
let paginator = query.paginate(&ctx.db, per_page);
let total = paginator.num_items_and_pages().await?.number_of_items;
let items = paginator.fetch_page(page - 1).await?;
Ok(Json(Page {
items: items.into_iter().map(PostDto::from).collect(),
total: i64::try_from(total).unwrap_or(i64::MAX),
page: i64::try_from(page).unwrap_or(i64::MAX),
per_page: i64::try_from(per_page).unwrap_or(i64::MAX),
}))
}
#[debug_handler]
async fn get_one(
_auth: auth::JWT,
State(ctx): State<AppContext>,
Path(id): Path<i64>,
) -> Result<Response> {
let Some(model) = Entity::find_by_id(id).one(&ctx.db).await? else {
return Ok(not_found("post not found"));
};
Ok(Json(PostDto::from(model)).into_response())
}
#[debug_handler]
async fn create(
_auth: auth::JWT,
State(ctx): State<AppContext>,
Json(params): Json<CreatePost>,
) -> Result<Response> {
let item = ActiveModel {
title: Set(params.title),
content: Set(params.content),
status: Set(params.status.as_str().to_string()),
price: Set(params.price),
published_at: Set(params.published_at),
..Default::default()
};
let item = item.insert(&ctx.db).await?;
Ok((StatusCode::CREATED, Json(PostDto::from(item))).into_response())
}
#[debug_handler]
async fn update(
_auth: auth::JWT,
State(ctx): State<AppContext>,
Path(id): Path<i64>,
Json(params): Json<UpdatePost>,
) -> Result<Response> {
let Some(model) = Entity::find_by_id(id).one(&ctx.db).await? else {
return Ok(not_found("post not found"));
};
let mut item = model.into_active_model();
item.title = Set(params.title);
item.content = Set(params.content);
item.status = Set(params.status.as_str().to_string());
item.price = Set(params.price);
item.published_at = Set(params.published_at);
let item = item.update(&ctx.db).await?;
Ok(Json(PostDto::from(item)).into_response())
}
#[debug_handler]
async fn remove(
_auth: auth::JWT,
State(ctx): State<AppContext>,
Path(id): Path<i64>,
) -> Result<Response> {
let Some(model) = Entity::find_by_id(id).one(&ctx.db).await? else {
return Ok(not_found("post not found"));
};
model.into_active_model().delete(&ctx.db).await?;
Ok(StatusCode::NO_CONTENT.into_response())
}
pub fn routes() -> Routes {
Routes::new()
.prefix("/api/posts")
.add("/", get(list))
.add("/", post(create))
.add("/{id}", get(get_one))
.add("/{id}", put(update))
.add("/{id}", delete(remove))
}
@@ -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"
@@ -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(())
}
}
@@ -0,0 +1,57 @@
---
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\")"
snapshot_kind: text
---
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<WorkerArgs> for Worker {
/// Creates a new instance of the Worker with the given application context.
///
/// This function is called when registering the worker with the queue system.
///
/// # Parameters
/// * `ctx` - The application context containing shared resources
fn build(ctx: &AppContext) -> Self {
Self { ctx: ctx.clone() }
}
/// Returns the class name of the worker.
///
/// This name is used when enqueueing jobs and identifying the worker in logs.
/// The implementation returns the struct name as a string.
fn class_name() -> String {
"RegisterEmail".to_string()
}
/// Returns tags associated with this worker.
///
/// Tags can be used to filter which workers run during startup.
/// The default implementation returns an empty vector (no tags).
fn tags() -> Vec<String> {
Vec::new()
}
/// Performs the actual work when a job is processed.
///
/// This is the main function that contains the worker's logic.
/// It gets executed when a job is dequeued from the job queue.
///
/// # Returns
/// * `Result<()>` - Ok if the job completed successfully, Err otherwise
async fn perform(&self, _args: WorkerArgs) -> Result<()> {
println!("=================RegisterEmail=======================");
// TODO: Some actual work goes here...
Ok(())
}
}
@@ -0,0 +1,21 @@
---
source: loco-gen/tests/templates/deployment.rs
expression: "fs::read_to_string(tree_fs.root.join(\"Dockerfile\")).expect(\"Dockerfile missing\")"
snapshot_kind: text
---
FROM rust:[version]-slim AS builder
WORKDIR /usr/src/
COPY . .
RUN cargo build --release
FROM debian:bookworm-slim
WORKDIR /usr/app
COPY --from=builder /usr/src/config config
COPY --from=builder /usr/src/target/release/tester-cli tester-cli
ENTRYPOINT ["/usr/app/tester-cli"]
@@ -0,0 +1,29 @@
---
source: loco-gen/tests/templates/deployment.rs
expression: "fs::read_to_string(tree_fs.root.join(\"Dockerfile\")).expect(\"Dockerfile missing\")"
snapshot_kind: text
---
FROM rust:[version]-slim AS builder
WORKDIR /usr/src/
COPY . .
RUN apt-get update && apt-get install -y curl ca-certificates
# Install Node.js using the latest available version from NodeSource.
# In production, replace "setup_current.x" with a specific version
# to avoid unexpected breaking changes in future releases.
RUN curl -fsSL https://deb.nodesource.com/setup_current.x | bash - && \
apt-get install -y nodejs
RUN cd frontend && npm install && npm run build
RUN cargo build --release
FROM debian:bookworm-slim
WORKDIR /usr/app
COPY --from=builder /usr/src/config config
COPY --from=builder /usr/src/target/release/tester-cli tester-cli
ENTRYPOINT ["/usr/app/tester-cli"]
@@ -0,0 +1,23 @@
---
source: loco-gen/tests/templates/deployment.rs
expression: "fs::read_to_string(tree_fs.root.join(\"Dockerfile\")).expect(\"Dockerfile missing\")"
snapshot_kind: text
---
FROM rust:[version]-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 404.html
COPY --from=builder /usr/src/asset asset
COPY --from=builder /usr/src/config config
COPY --from=builder /usr/src/target/release/tester-cli tester-cli
ENTRYPOINT ["/usr/app/tester-cli"]
@@ -0,0 +1,31 @@
---
source: loco-gen/tests/templates/deployment.rs
expression: "fs::read_to_string(tree_fs.root.join(\"Dockerfile\")).expect(\"Dockerfile missing\")"
snapshot_kind: text
---
FROM rust:[version]-slim AS builder
WORKDIR /usr/src/
COPY . .
RUN apt-get update && apt-get install -y curl ca-certificates
# Install Node.js using the latest available version from NodeSource.
# In production, replace "setup_current.x" with a specific version
# to avoid unexpected breaking changes in future releases.
RUN curl -fsSL https://deb.nodesource.com/setup_current.x | bash - && \
apt-get install -y nodejs
RUN cd frontend && npm install && npm run build
RUN cargo build --release
FROM debian:bookworm-slim
WORKDIR /usr/app
COPY --from=builder /usr/src/404.html 404.html
COPY --from=builder /usr/src/asset asset
COPY --from=builder /usr/src/config config
COPY --from=builder /usr/src/target/release/tester-cli tester-cli
ENTRYPOINT ["/usr/app/tester-cli"]
@@ -0,0 +1,93 @@
---
source: loco-gen/tests/templates/scaffold.rs
expression: "fs::read_to_string(dtos_path.join(\"posts.rs\")).expect(\"dto file missing\")"
---
use sea_orm::prelude::{DateTimeWithTimeZone, Decimal};
use ts_rs::TS;
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize, TS)]
#[ts(export, export_to = "../frontend/src/bindings/")]
#[serde(rename_all = "snake_case")]
pub enum PostStatus {
Draft,
Published,
Archived,
}
impl From<String> for PostStatus {
fn from(value: String) -> Self {
match value.as_str() {
"published" => Self::Published,
"archived" => Self::Archived,
_ => Self::Draft,
}
}
}
impl PostStatus {
#[must_use]
pub const fn as_str(&self) -> &'static str {
match self {
Self::Draft => "draft",
Self::Published => "published",
Self::Archived => "archived",
}
}
}
#[derive(serde::Serialize, serde::Deserialize, TS)]
#[ts(export, export_to = "../frontend/src/bindings/")]
pub struct PostDto {
#[ts(type = "number")]
pub id: i64,
pub title: String,
pub content: String,
pub status: PostStatus,
#[ts(type = "string")]
pub price: Decimal,
#[ts(type = "string | null")]
pub published_at: Option<DateTimeWithTimeZone>,
#[ts(type = "string")]
pub created_at: DateTimeWithTimeZone,
#[ts(type = "string")]
pub updated_at: DateTimeWithTimeZone,
}
impl From<crate::models::_entities::posts::Model> for PostDto {
fn from(m: crate::models::_entities::posts::Model) -> Self {
Self {
id: m.id,
title: m.title,
content: m.content,
status: PostStatus::from(m.status),
price: m.price,
published_at: m.published_at,
created_at: m.created_at,
updated_at: m.updated_at,
}
}
}
#[derive(serde::Serialize, serde::Deserialize, TS)]
#[ts(export, export_to = "../frontend/src/bindings/")]
pub struct CreatePost {
pub title: String,
pub content: String,
pub status: PostStatus,
#[ts(type = "string")]
pub price: Decimal,
#[ts(type = "string | null")]
pub published_at: Option<DateTimeWithTimeZone>,
}
#[derive(serde::Serialize, serde::Deserialize, TS)]
#[ts(export, export_to = "../frontend/src/bindings/")]
pub struct UpdatePost {
pub title: String,
pub content: String,
pub status: PostStatus,
#[ts(type = "string")]
pub price: Decimal,
#[ts(type = "string | null")]
pub published_at: Option<DateTimeWithTimeZone>,
}
@@ -0,0 +1,111 @@
---
source: loco-gen/tests/templates/scaffold.rs
expression: "fs::read_to_string(frontend_path.join(\"api\").join(\"posts.ts\")).expect(\"frontend api file missing\")"
---
import {
useMutation,
useQuery,
useQueryClient,
type UseMutationResult,
type UseQueryResult,
} from "@tanstack/react-query";
import type { CreatePost } from "../bindings/CreatePost";
import type { Page } from "../bindings/Page";
import type { PostDto } from "../bindings/PostDto";
import type { UpdatePost } from "../bindings/UpdatePost";
import { ApiClientError, del, get, post, put } from "./client";
export interface ListPostsParams {
page?: number;
per_page?: number;
}
export const postKeys = {
all: ["posts"] as const,
list: (params?: ListPostsParams) =>
["posts", "list", params ?? {}] as const,
detail: (id: number) => ["posts", "detail", id] as const,
};
function buildQueryString(params?: ListPostsParams): string {
if (!params) {
return "";
}
const search = new URLSearchParams();
if (params.page !== undefined) {
search.set("page", String(params.page));
}
if (params.per_page !== undefined) {
search.set("per_page", String(params.per_page));
}
const qs = search.toString();
return qs ? `?${qs}` : "";
}
export function useListPosts(
params?: ListPostsParams,
): UseQueryResult<Page<PostDto>, ApiClientError> {
return useQuery({
queryKey: postKeys.list(params),
queryFn: () => get<Page<PostDto>>(`/api/posts${buildQueryString(params)}`),
});
}
export function usePost(id: number): UseQueryResult<PostDto, ApiClientError> {
return useQuery({
queryKey: postKeys.detail(id),
queryFn: () => get<PostDto>(`/api/posts/${id}`),
enabled: Number.isFinite(id) && id > 0,
});
}
export function useCreatePost(): UseMutationResult<
PostDto,
ApiClientError,
CreatePost
> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreatePost) => post<PostDto>("/api/posts", data),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: postKeys.all });
},
});
}
export interface UpdatePostVariables {
id: number;
data: UpdatePost;
}
export function useUpdatePost(): UseMutationResult<
PostDto,
ApiClientError,
UpdatePostVariables
> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: UpdatePostVariables) =>
put<PostDto>(`/api/posts/${id}`, data),
onSuccess: (_data, variables) => {
void queryClient.invalidateQueries({ queryKey: postKeys.all });
void queryClient.invalidateQueries({
queryKey: postKeys.detail(variables.id),
});
},
});
}
export function useRemovePost(): UseMutationResult<
void,
ApiClientError,
number
> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => del(`/api/posts/${id}`),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: postKeys.all });
},
});
}
@@ -0,0 +1,133 @@
---
source: loco-gen/tests/templates/scaffold.rs
expression: "fs::read_to_string(pages_path.join(format!(\"{page}.tsx\"))).unwrap_or_else(|_|\npanic!(\"frontend {page}.tsx file missing\"))"
---
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router";
import { usePost, useUpdatePost } from "../../api/posts";
import type { PostStatus } from "../../bindings/PostStatus";
import type { UpdatePost } from "../../bindings/UpdatePost";
const STATUS_OPTIONS: PostStatus[] = ["draft", "published", "archived"];
export function Edit() {
const navigate = useNavigate();
const { id: idParam } = useParams<{ id: string }>();
const id = Number(idParam);
const isValidId = Number.isFinite(id) && id > 0;
const { data, isLoading, isError, error } = usePost(isValidId ? id : NaN);
const updatePost = useUpdatePost();
const [form, setForm] = useState<UpdatePost | null>(null);
useEffect(() => {
if (data) {
setForm({
title: data.title,
content: data.content,
status: data.status,
price: data.price,
published_at: data.published_at,
});
}
}, [data]);
if (!isValidId) {
return <p role="alert">Invalid post id.</p>;
}
if (isLoading || !form) {
return <p>Loading…</p>;
}
if (isError) {
return <p role="alert">{error.message}</p>;
}
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!form) {
return;
}
updatePost.mutate(
{ id, data: form },
{
onSuccess: () => {
navigate(`/posts/${id}`);
},
},
);
}
return (
<div>
<h1>Edit Post</h1>
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="title">Title</label>
<input
id="title"
type="text"
value={form.title}
onChange={(e) => setForm({ ...form, title: e.target.value })}
required
/>
</div>
<div>
<label htmlFor="content">Content</label>
<textarea
id="content"
value={form.content}
onChange={(e) => setForm({ ...form, content: e.target.value })}
required
/>
</div>
<div>
<label htmlFor="status">Status</label>
<select
id="status"
value={form.status}
onChange={(e) =>
setForm({ ...form, status: e.target.value as PostStatus })
}
>
{STATUS_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</div>
<div>
<label htmlFor="price">Price</label>
<input
id="price"
type="text"
value={form.price}
onChange={(e) => setForm({ ...form, price: e.target.value })}
required
/>
</div>
<div>
<label htmlFor="published_at">Published at</label>
<input
id="published_at"
type="datetime-local"
value={form.published_at ?? ""}
onChange={(e) =>
setForm({
...form,
published_at: e.target.value || null,
})
}
/>
</div>
<button type="submit" disabled={updatePost.isPending}>
{updatePost.isPending ? "Saving…" : "Save"}
</button>
{updatePost.error && <p role="alert">{updatePost.error.message}</p>}
</form>
</div>
);
}
@@ -0,0 +1,64 @@
---
source: loco-gen/tests/templates/scaffold.rs
expression: "fs::read_to_string(pages_path.join(format!(\"{page}.tsx\"))).unwrap_or_else(|_|\npanic!(\"frontend {page}.tsx file missing\"))"
---
import { Link } from "react-router";
import { useListPosts, useRemovePost } from "../../api/posts";
export function List() {
const { data, isLoading, isError, error } = useListPosts();
const removePost = useRemovePost();
if (isLoading) {
return <p>Loading…</p>;
}
if (isError) {
return <p role="alert">{error.message}</p>;
}
return (
<div>
<h1>Posts</h1>
<p>
<Link to="/posts/new">New Post</Link>
</p>
<table>
<thead>
<tr>
<th>Title</th>
<th>Content</th>
<th>Status</th>
<th>Price</th>
<th>Published at</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{data?.items.map((post) => (
<tr key={post.id}>
<td>
<Link to={`/posts/${post.id}`}>{post.title}</Link>
</td>
<td>{post.content}</td>
<td>{post.status}</td>
<td>{post.price}</td>
<td>{post.published_at}</td>
<td>
<Link to={`/posts/${post.id}/edit`}>Edit</Link>{" "}
<button
type="button"
disabled={removePost.isPending}
onClick={() => removePost.mutate(post.id)}
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
{removePost.isError && <p role="alert">{removePost.error.message}</p>}
</div>
);
}
@@ -0,0 +1,104 @@
---
source: loco-gen/tests/templates/scaffold.rs
expression: "fs::read_to_string(pages_path.join(format!(\"{page}.tsx\"))).unwrap_or_else(|_|\npanic!(\"frontend {page}.tsx file missing\"))"
---
import { useState } from "react";
import { useNavigate } from "react-router";
import { useCreatePost } from "../../api/posts";
import type { CreatePost } from "../../bindings/CreatePost";
import type { PostStatus } from "../../bindings/PostStatus";
const STATUS_OPTIONS: PostStatus[] = ["draft", "published", "archived"];
export function New() {
const navigate = useNavigate();
const createPost = useCreatePost();
const [form, setForm] = useState<CreatePost>({
title: "",
content: "",
status: "draft",
price: "",
published_at: null,
});
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
createPost.mutate(form, {
onSuccess: (created) => {
navigate(`/posts/${created.id}`);
},
});
}
return (
<div>
<h1>New Post</h1>
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="title">Title</label>
<input
id="title"
type="text"
value={form.title}
onChange={(e) => setForm({ ...form, title: e.target.value })}
required
/>
</div>
<div>
<label htmlFor="content">Content</label>
<textarea
id="content"
value={form.content}
onChange={(e) => setForm({ ...form, content: e.target.value })}
required
/>
</div>
<div>
<label htmlFor="status">Status</label>
<select
id="status"
value={form.status}
onChange={(e) =>
setForm({ ...form, status: e.target.value as PostStatus })
}
>
{STATUS_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</div>
<div>
<label htmlFor="price">Price</label>
<input
id="price"
type="text"
value={form.price}
onChange={(e) => setForm({ ...form, price: e.target.value })}
required
/>
</div>
<div>
<label htmlFor="published_at">Published at</label>
<input
id="published_at"
type="datetime-local"
value={form.published_at ?? ""}
onChange={(e) =>
setForm({
...form,
published_at: e.target.value || null,
})
}
/>
</div>
<button type="submit" disabled={createPost.isPending}>
{createPost.isPending ? "Creating…" : "Create"}
</button>
{createPost.error && <p role="alert">{createPost.error.message}</p>}
</form>
</div>
);
}
@@ -0,0 +1,54 @@
---
source: loco-gen/tests/templates/scaffold.rs
expression: "fs::read_to_string(pages_path.join(format!(\"{page}.tsx\"))).unwrap_or_else(|_|\npanic!(\"frontend {page}.tsx file missing\"))"
---
import { Link, useParams } from "react-router";
import { usePost } from "../../api/posts";
export function Show() {
const { id: idParam } = useParams<{ id: string }>();
const id = Number(idParam);
const isValidId = Number.isFinite(id) && id > 0;
const { data, isLoading, isError, error } = usePost(isValidId ? id : NaN);
if (!isValidId) {
return <p role="alert">Invalid post id.</p>;
}
if (isLoading) {
return <p>Loading…</p>;
}
if (isError) {
return <p role="alert">{error.message}</p>;
}
if (!data) {
return <p>Post not found.</p>;
}
return (
<div>
<h1>{data.title}</h1>
<dl>
<dt>Content</dt>
<dd>{data.content}</dd>
<dt>Status</dt>
<dd>{data.status}</dd>
<dt>Price</dt>
<dd>{data.price}</dd>
<dt>Published at</dt>
<dd>{data.published_at ?? "—"}</dd>
<dt>Created at</dt>
<dd>{data.created_at}</dd>
<dt>Updated at</dt>
<dd>{data.updated_at}</dd>
</dl>
<p>
<Link to={`/posts/${data.id}/edit`}>Edit</Link>{" "}
<Link to="/posts">Back to list</Link>
</p>
</div>
);
}
@@ -0,0 +1,10 @@
---
source: loco-gen/tests/templates/mailer.rs
expression: "fs::read_to_string(path).unwrap_or_else(|_| panic!(\"{name} missing\"))"
---
{% extends "base.t" %}
{% block title %}Welcome!{% endblock %}
{% block body %}
<h1>Welcome!</h1>
<p>welcome to <em>acmeworld!</em></p>
{% endblock %}
@@ -0,0 +1,39 @@
---
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 shared: Dir<'_> = include_dir!("src/mailers/shared");
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_with_shared(
ctx,
&welcome,
&[&shared],
mailer::Args {
to: to.to_string(),
locals: json!({
"message": msg,
"domain": ctx.config.server.full_url()
}),
..Default::default()
},
)
.await?;
Ok(())
}
}
@@ -0,0 +1,31 @@
---
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::*;
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
create_table(m, "posts",
&[
("id", ColType::PkAuto),
("title", ColType::String),
("content", ColType::Text),
("status", ColType::String),
("price", ColType::Decimal),
("published_at", ColType::TimestampWithTimeZoneNull),
],
&[
]
).await
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
drop_table(m, "posts").await
}
}
@@ -0,0 +1,24 @@
---
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, m: &SchemaManager) -> Result<(), DbErr> {
add_column(m, "users", "name", ColType::StringNull).await?;
add_column(m, "users", "age", ColType::BigIntegerNull).await?;
Ok(())
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
remove_column(m, "users", "name").await?;
remove_column(m, "users", "age").await?;
Ok(())
}
}
@@ -0,0 +1,23 @@
---
source: loco-gen/tests/templates/migration.rs
expression: "fs::read_to_string(&migration_file).expect(\"Failed to read the migration file\")"
snapshot_kind: text
---
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, m: &SchemaManager) -> Result<(), DbErr> {
add_reference(m, "posts", "user", "").await?;
Ok(())
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
remove_reference(m, "posts", "user", "").await?;
Ok(())
}
}
@@ -0,0 +1,28 @@
---
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, m: &SchemaManager) -> Result<(), DbErr> {
create_join_table_without_timestamps(m, "user_groups",
&[
("count", ColType::BigIntegerNull),
],
&[
("user", ""),
("group", ""),
]
).await
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
drop_table(m, "user_groups").await
}
}
@@ -0,0 +1,28 @@
---
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, m: &SchemaManager) -> Result<(), DbErr> {
create_join_table_without_timestamps(m, "user_groups",
&[
("count", ColType::BigIntegerNull),
],
&[
("user", ""),
("group", ""),
]
).await
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
drop_table(m, "user_groups").await
}
}
@@ -0,0 +1,28 @@
---
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, m: &SchemaManager) -> Result<(), DbErr> {
create_table(m, "movies",
&[
("id", ColType::PkAuto),
("title", ColType::StringNull),
],
&[
("user", ""),
]
).await
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
drop_table(m, "movies").await
}
}
@@ -0,0 +1,28 @@
---
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, m: &SchemaManager) -> Result<(), DbErr> {
create_table_without_timestamps(m, "movies",
&[
("id", ColType::PkAuto),
("title", ColType::StringNull),
],
&[
("user", ""),
]
).await
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
drop_table(m, "movies").await
}
}
@@ -0,0 +1,20 @@
---
source: loco-gen/tests/templates/migration.rs
expression: "fs::read_to_string(&migration_file).expect(\"Failed to read the migration file\")"
snapshot_kind: text
---
use sea_orm_migration::{prelude::*, schema::*};
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
todo!()
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
Ok(())
}
}
@@ -0,0 +1,27 @@
---
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::*;
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
create_table(m, "movies",
&[
("id", ColType::PkAuto),
("title", ColType::StringNull),
],
&[
]
).await
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
drop_table(m, "movies").await
}
}
@@ -0,0 +1,24 @@
---
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, m: &SchemaManager) -> Result<(), DbErr> {
remove_column(m, "users", "name").await?;
remove_column(m, "users", "age").await?;
Ok(())
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
add_column(m, "users", "name", ColType::StringNull).await?;
add_column(m, "users", "age", ColType::BigIntegerNull).await?;
Ok(())
}
}
@@ -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 ~^(?<subdomain>\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/;
}
}
@@ -0,0 +1,23 @@
---
source: loco-gen/tests/templates/mailer.rs
expression: "fs::read_to_string(path).unwrap_or_else(|_| panic!(\"{name} missing\"))"
---
<!DOCTYPE html>
<html>
<head>
<title>{% block title %}Email{% endblock %}</title>
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.footer { margin-top: 40px; padding-top: 20px; border-top: 1px solid #eee; color: #666; font-size: 12px; }
</style>
</head>
<body>
<div class="container">
{% block body %}{% endblock %}
<div class="footer">
{% block footer %}{% endblock %}
</div>
</div>
</body>
</html>
@@ -0,0 +1,5 @@
---
source: loco-gen/tests/templates/mailer.rs
expression: "fs::read_to_string(path).unwrap_or_else(|_| panic!(\"{name} missing\"))"
---
{% block subject %}Email{% endblock %}
@@ -0,0 +1,5 @@
---
source: loco-gen/tests/templates/mailer.rs
expression: "fs::read_to_string(path).unwrap_or_else(|_| panic!(\"{name} missing\"))"
---
{% block text %}{% endblock %}
@@ -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!
@@ -0,0 +1,35 @@
---
source: loco-gen/tests/templates/scaffold.rs
expression: "fs::read_to_string(tests_path.join(\"posts.rs\")).expect(\"Failed to read posts.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::<App>().await.unwrap();
seed::<App>(&boot.app_context).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);
}
@@ -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::<App>().await.unwrap();
seed::<App>(&boot.app_context).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);
}
@@ -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::<App, _, _>(|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::<App, _, _>(|request, _ctx| async move {
let res = request.get("/api/movies/GET").await;
assert_eq!(res.status_code(), 200);
})
.await;
}
#[tokio::test]
#[serial]
async fn can_get_POST() {
request::<App, _, _>(|request, _ctx| async move {
let res = request.get("/api/movies/POST").await;
assert_eq!(res.status_code(), 200);
})
.await;
}
@@ -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::<App>().await.unwrap();
assert!(
run_task::<App>(&boot.app_context, Some(&"cleanup".to_string()), &task::Vars::default())
.await
.is_ok()
);
}
@@ -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::<App>().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
}

Some files were not shown because too many files have changed in this diff Show More