Skip to content

Commit 58992b6

Browse files
committed
Fix CRUD generator multi-table db.rs and server e2e registration
1 parent 071fd2f commit 58992b6

2 files changed

Lines changed: 202 additions & 70 deletions

File tree

crates/cargo-rustapi/src/commands/generate.rs

Lines changed: 100 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -188,8 +188,9 @@ async fn generate_crud(name: &str) -> Result<()> {
188188
);
189189
println!();
190190

191+
let singular = singularize(name);
191192
ensure_crud_dependencies().await?;
192-
ensure_db_module(&table).await?;
193+
upsert_db_resource(&table, &singular).await?;
193194
generate_sqlx_model(name, &type_name).await?;
194195
generate_sqlx_handler(name, &type_name, &table).await?;
195196

@@ -204,7 +205,8 @@ async fn generate_crud(name: &str) -> Result<()> {
204205
" let pool = db::init_pool(\"sqlite:{}.db\").await?;",
205206
table
206207
);
207-
println!(" RustApi::auto().state(pool).run(\"127.0.0.1:8080\").await?;");
208+
println!(" 3. Mount generated handlers (or use RustApi::auto()):");
209+
print_route_registration_hints(name);
208210

209211
Ok(())
210212
}
@@ -256,56 +258,106 @@ async fn ensure_crud_dependencies() -> Result<()> {
256258
Ok(())
257259
}
258260

259-
async fn ensure_db_module(table: &str) -> Result<()> {
260-
let db_path = Path::new("src/db.rs");
261-
if db_path.exists() {
262-
return Ok(());
263-
}
264-
265-
let singular = singularize(table);
266-
let content = format!(
267-
r#"//! Database bootstrap for generated CRUD resources.
261+
const DB_RS_HEADER: &str = r#"//! Database bootstrap for generated CRUD resources.
262+
//!
263+
//! Each `cargo rustapi generate crud <name>` call adds a table-specific module below.
264+
//! Default columns are the standard scaffold (`id`, `name`, `description`, timestamps).
268265
269266
use sqlx::sqlite::SqlitePoolOptions;
270267
use sqlx::SqlitePool;
271268
272-
/// Table name used by generated CRUD handlers.
273-
pub const TABLE: &str = "{table}";
274-
275-
/// Singular resource label for error messages.
276-
pub const SINGULAR: &str = "{singular}";
277-
278-
const COLUMNS_DDL: &str = "
269+
/// Default CRUD column layout for generated resources (customize per model as needed).
270+
macro_rules! crud_columns {
271+
() => {
272+
"
279273
id INTEGER PRIMARY KEY AUTOINCREMENT,
280274
name TEXT NOT NULL,
281275
description TEXT,
282276
created_at TEXT NOT NULL,
283277
updated_at TEXT NOT NULL
284-
";
278+
"
279+
};
280+
}
281+
282+
"#;
283+
284+
async fn upsert_db_resource(table: &str, singular: &str) -> Result<()> {
285+
let db_path = Path::new("src/db.rs");
286+
let table_mod = table_module_name(table);
287+
let module_block = format!(
288+
r#"pub mod {table_mod} {{
289+
pub const TABLE: &str = "{table}";
290+
pub const SINGULAR: &str = "{singular}";
291+
pub(crate) const COLUMNS_DDL: &str = crud_columns!();
292+
}}
285293
286-
/// Open a SQLite pool and ensure the resource table exists.
294+
"#,
295+
table_mod = table_mod,
296+
table = table,
297+
singular = singular,
298+
);
299+
let ensure_line = format!(
300+
" ensure_table(&pool, {table_mod}::TABLE, {table_mod}::COLUMNS_DDL).await?;\n",
301+
table_mod = table_mod,
302+
);
303+
304+
if !db_path.exists() {
305+
let content = format!(
306+
"{header}{module_block}\
307+
async fn ensure_table(pool: &SqlitePool, table: &str, columns: &str) -> Result<(), sqlx::Error> {{
308+
let schema = format!(\"CREATE TABLE IF NOT EXISTS {{}} ({{}})\", table, columns.trim());
309+
sqlx::query(&schema).execute(pool).await?;
310+
Ok(())
311+
}}
312+
313+
/// Open a SQLite pool and ensure all generated resource tables exist.
287314
pub async fn init_pool(database_url: &str) -> Result<SqlitePool, sqlx::Error> {{
288315
let pool = SqlitePoolOptions::new()
289316
.max_connections(5)
290317
.connect(database_url)
291318
.await?;
292-
let schema = format!(
293-
"CREATE TABLE IF NOT EXISTS {{}} ({{}})",
294-
TABLE,
295-
COLUMNS_DDL.trim()
296-
);
297-
sqlx::query(&schema).execute(&pool).await?;
298-
Ok(pool)
319+
{ensure_lines} Ok(pool)
299320
}}
300-
"#,
301-
table = table,
302-
singular = singular,
303-
);
321+
",
322+
header = DB_RS_HEADER,
323+
module_block = module_block,
324+
ensure_lines = ensure_line,
325+
);
326+
fs::write(db_path, content).await?;
327+
println!(
328+
"{} Generated database module: {}",
329+
style("✓").green(),
330+
db_path.display()
331+
);
332+
return Ok(());
333+
}
334+
335+
let mut content = fs::read_to_string(db_path).await?;
336+
let mod_marker = format!("pub mod {table_mod} ");
337+
if content.contains(&mod_marker) {
338+
println!(
339+
"{} db.rs already tracks table '{}'",
340+
style("•").yellow(),
341+
table
342+
);
343+
return Ok(());
344+
}
345+
346+
let insert_at = content
347+
.find("async fn ensure_table")
348+
.ok_or_else(|| anyhow::anyhow!("src/db.rs is missing ensure_table helper"))?;
349+
content.insert_str(insert_at, &module_block);
350+
351+
let ok_pool = content
352+
.rfind(" Ok(pool)")
353+
.ok_or_else(|| anyhow::anyhow!("src/db.rs is missing init_pool tail"))?;
354+
content.insert_str(ok_pool, &ensure_line);
304355

305356
fs::write(db_path, content).await?;
306357
println!(
307-
"{} Generated database module: {}",
358+
"{} Updated database module for table '{}': {}",
308359
style("✓").green(),
360+
table,
309361
db_path.display()
310362
);
311363
Ok(())
@@ -358,15 +410,16 @@ pub struct Update{type_name} {{
358410
Ok(())
359411
}
360412

361-
async fn generate_sqlx_handler(name: &str, type_name: &str, _table: &str) -> Result<()> {
413+
async fn generate_sqlx_handler(name: &str, type_name: &str, table: &str) -> Result<()> {
362414
let handlers_dir = Path::new("src/handlers");
363415
ensure_handlers_module(handlers_dir, name).await?;
364416

365417
let singular = singularize(name);
418+
let table_mod = table_module_name(table);
366419
let handler_content = format!(
367420
r#"//! {} handlers (SQLx SQLite)
368421
369-
use crate::db::{{SINGULAR, TABLE}};
422+
use crate::db::{table_mod}::{{SINGULAR, TABLE}};
370423
use crate::models::{{Create{type_name}, Update{type_name}, {type_name}}};
371424
use rustapi_rs::prelude::*;
372425
use sqlx::SqlitePool;
@@ -580,26 +633,20 @@ async fn ensure_models_module(models_dir: &Path, name: &str) -> Result<()> {
580633

581634
fn print_route_registration_hints(name: &str) {
582635
println!();
583-
println!("Don't forget to register the routes in main.rs:");
584-
println!(
585-
" {}",
586-
style(format!(".mount(handlers::{}::list)", name)).cyan()
587-
);
588-
println!(
589-
" {}",
590-
style(format!(".mount(handlers::{}::get)", name)).cyan()
591-
);
592-
println!(
593-
" {}",
594-
style(format!(".mount(handlers::{}::create)", name)).cyan()
595-
);
636+
println!("Register the generated handlers in main.rs:");
596637
println!(
597638
" {}",
598-
style(format!(".mount(handlers::{}::update)", name)).cyan()
639+
style(format!(
640+
".route(\"/{name}\", get(handlers::{name}::list).post(handlers::{name}::create))"
641+
))
642+
.cyan()
599643
);
600644
println!(
601645
" {}",
602-
style(format!(".mount(handlers::{}::delete)", name)).cyan()
646+
style(format!(
647+
".route(\"/{name}/{{id}}\", get(handlers::{name}::get).put(handlers::{name}::update).delete(handlers::{name}::delete))"
648+
))
649+
.cyan()
603650
);
604651
}
605652

@@ -616,6 +663,10 @@ fn to_pascal_case(s: &str) -> String {
616663
s.split(&['-', '_'][..]).map(capitalize).collect()
617664
}
618665

666+
fn table_module_name(table: &str) -> String {
667+
table.replace('-', "_")
668+
}
669+
619670
fn singularize(s: &str) -> String {
620671
if let Some(stripped) = s.strip_suffix("ies") {
621672
format!("{}y", stripped)

crates/cargo-rustapi/tests/cli_tests.rs

Lines changed: 102 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -349,8 +349,13 @@ mod generate_command {
349349
);
350350
assert!(handler.contains("sqlx::query_as"));
351351
assert!(
352-
handler.contains("crate::db::{SINGULAR, TABLE}"),
353-
"generated handler must use db::TABLE and db::SINGULAR"
352+
handler.contains("crate::db::items::{SINGULAR, TABLE}"),
353+
"generated handler must use per-table db module constants"
354+
);
355+
let db_rs = fs::read_to_string(project_path.join("src/db.rs")).expect("read db.rs");
356+
assert!(
357+
db_rs.contains("pub mod items"),
358+
"db.rs must define a per-resource items module"
354359
);
355360

356361
let rustapi_path = workspace_root
@@ -382,6 +387,7 @@ path = "src/lib.rs"
382387
383388
[dev-dependencies]
384389
rustapi-testing = {{ path = "{testing_path}" }}
390+
reqwest = {{ version = "0.12", default-features = false, features = ["json", "rustls-tls"] }}
385391
"#
386392
));
387393
}
@@ -410,45 +416,120 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
410416

411417
fs::create_dir_all(project_path.join("tests")).expect("create tests dir");
412418
let e2e_test = r#"use rustapi_rs::prelude::*;
413-
use rustapi_testing::{TestClient, TestRequest};
414419
use test_crud_sqlx::db;
420+
use test_crud_sqlx::handlers::items::{create, delete, get as get_one, list, update};
421+
use std::time::Duration;
422+
use tokio::sync::oneshot;
415423
416424
#[tokio::test]
417-
async fn generated_items_routes_create_and_list() {
425+
async fn generated_items_routes_create_and_list_via_server() {
418426
let pool = db::init_pool("sqlite::memory:").await.expect("pool");
419-
let app = RustApi::auto().state(pool);
420-
let client = TestClient::new(app);
421-
422-
let create = client
423-
.request(
424-
TestRequest::post("/items")
425-
.header("content-type", "application/json")
426-
.body("{\"name\":\"widget\",\"description\":\"demo\"}"),
427-
)
428-
.await;
429-
create.assert_status(StatusCode::CREATED);
430-
431-
let list = client.request(TestRequest::get("/items")).await;
432-
list.assert_status(StatusCode::OK);
433-
let body = list.text();
427+
let app = RustApi::new()
428+
.state(pool)
429+
.route("/items", get(list).post(create))
430+
.route("/items/{id}", get(get_one).put(update).delete(delete));
431+
432+
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind ephemeral port");
433+
let port = listener.local_addr().expect("local addr").port();
434+
drop(listener);
435+
436+
let addr = format!("127.0.0.1:{port}");
437+
let (shutdown_tx, shutdown_rx) = oneshot::channel();
438+
let server = tokio::spawn(async move {
439+
app.run_with_shutdown(&addr, async {
440+
shutdown_rx.await.ok();
441+
})
442+
.await
443+
});
444+
445+
tokio::time::sleep(Duration::from_millis(400)).await;
446+
447+
let client = reqwest::Client::new();
448+
let base = format!("http://127.0.0.1:{port}");
449+
450+
let create_res = client
451+
.post(format!("{base}/items"))
452+
.header("content-type", "application/json")
453+
.body("{\"name\":\"widget\",\"description\":\"demo\"}")
454+
.send()
455+
.await
456+
.expect("create request");
457+
assert_eq!(create_res.status(), 201, "POST /items should return 201");
458+
459+
let list_res = client
460+
.get(format!("{base}/items"))
461+
.send()
462+
.await
463+
.expect("list request");
464+
assert_eq!(list_res.status(), 200, "GET /items should return 200");
465+
let body = list_res.text().await.expect("list body");
434466
assert!(body.contains("widget"), "list response must include created item");
467+
468+
shutdown_tx.send(()).ok();
469+
server.await.expect("server task").expect("server run");
435470
}
436471
"#;
437472
fs::write(project_path.join("tests/crud_e2e.rs"), e2e_test).expect("write e2e test");
438473

439474
let output = std::process::Command::new("cargo")
440475
.current_dir(&project_path)
441-
.args(["test", "--test", "crud_e2e"])
476+
.args(["test", "--test", "crud_e2e", "--", "--nocapture"])
442477
.output()
443478
.expect("cargo test status");
444479
assert!(
445480
output.status.success(),
446-
"generated CRUD project must pass e2e route test:\n{}\n{}",
481+
"generated CRUD project must pass server e2e route test:\n{}\n{}",
447482
String::from_utf8_lossy(&output.stdout),
448483
String::from_utf8_lossy(&output.stderr)
449484
);
450485
}
451486

487+
#[test]
488+
fn test_generate_crud_multiple_resources_share_db_module() {
489+
let dir = tempdir().expect("Failed to create temp dir");
490+
let project_name = "test-crud-multi";
491+
let project_path = dir.path().join(project_name);
492+
493+
cargo_rustapi()
494+
.current_dir(dir.path())
495+
.args(["new", project_name, "--template", "minimal", "--yes"])
496+
.assert()
497+
.success();
498+
499+
cargo_rustapi()
500+
.current_dir(&project_path)
501+
.args(["generate", "crud", "items"])
502+
.assert()
503+
.success();
504+
505+
cargo_rustapi()
506+
.current_dir(&project_path)
507+
.args(["generate", "crud", "products"])
508+
.assert()
509+
.success();
510+
511+
let db_rs = fs::read_to_string(project_path.join("src/db.rs")).expect("read db.rs");
512+
assert!(
513+
db_rs.contains("pub mod items"),
514+
"db.rs must track items table"
515+
);
516+
assert!(
517+
db_rs.contains("pub mod products"),
518+
"db.rs must track products table"
519+
);
520+
assert!(
521+
db_rs.matches("ensure_table(&pool").count() >= 2,
522+
"init_pool must ensure every generated table"
523+
);
524+
525+
let products_handler = fs::read_to_string(project_path.join("src/handlers/products.rs"))
526+
.expect("read handler");
527+
assert!(
528+
products_handler.contains("crate::db::products::{SINGULAR, TABLE}"),
529+
"second resource must use its own db module"
530+
);
531+
}
532+
452533
#[test]
453534
fn test_generate_model() {
454535
let dir = tempdir().expect("Failed to create temp dir");

0 commit comments

Comments
 (0)