diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 394dac8..d796f3b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,18 @@ jobs: cargo check --workspace --features sqlite,postgres,mysql,testing --exclude ui-tests cargo check --workspace --features sqlite,testing --package ui-tests + packaging: + name: Packaging + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: Install Rust + uses: actions-rust-lang/setup-rust-toolchain@v1 + + - name: Verify crates can be packaged + run: cargo package --workspace --all-features --exclude ui-tests + codestyle-markdown: name: Codestyle (Markdown) runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 0b745e2..5f32e70 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,2 @@ -/target +target/ .env \ No newline at end of file diff --git a/docs/README.md b/docs/README.md index 4a2f9df..bc427e3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -78,7 +78,7 @@ All code examples should be executable via `mdbook test`. Use the ```rust # #[fabrique::doctest] -# async fn main(pool: Pool) -> Result<(), fabrique::Error> { +# async fn main(pool: Pool) -> Result<(), fabrique::Error> { let user = User::factory().create(&pool).await?; assert_eq!(user.name, "Test User"); # Ok(()) diff --git a/docs/src/concepts/factories.md b/docs/src/concepts/factories.md index 2bcead9..e081327 100644 --- a/docs/src/concepts/factories.md +++ b/docs/src/concepts/factories.md @@ -30,7 +30,7 @@ any fields you care about, then call `create()` to persist: # } # # #[fabrique::doctest] -# async fn main(pool: Pool) -> Result<(), fabrique::Error> { +# async fn main(pool: Pool) -> Result<(), fabrique::Error> { let product = Product::factory() .name("Anvil 3000".to_string()) // Override name .create(&pool) // id and price_cents: defaults @@ -111,7 +111,7 @@ creates the parent record if none is specified: # } # # #[fabrique::doctest] -# async fn main(pool: Pool) -> Result<(), fabrique::Error> { +# async fn main(pool: Pool) -> Result<(), fabrique::Error> { // A User is auto-created — no manual setup needed let order = Order::factory() .create(&pool) @@ -157,7 +157,7 @@ existing one: # } # # #[fabrique::doctest] -# async fn main(pool: Pool) -> Result<(), fabrique::Error> { +# async fn main(pool: Pool) -> Result<(), fabrique::Error> { // Pass a factory: creates a new user with specific attributes let order = Order::factory() .for_user(User::factory().name("Wile E.".to_string())) @@ -202,7 +202,7 @@ an instance shares the **same** parent: # } # # #[fabrique::doctest] -# async fn main(pool: Pool) -> Result<(), fabrique::Error> { +# async fn main(pool: Pool) -> Result<(), fabrique::Error> { // A conversation between two specific people let wile = User::factory() .name("Wile E.".to_string()) @@ -251,7 +251,7 @@ children for a parent: # } # # #[fabrique::doctest] -# async fn main(pool: Pool) -> Result<(), fabrique::Error> { +# async fn main(pool: Pool) -> Result<(), fabrique::Error> { let user = User::factory() .has_orders(Order::factory(), 3) .create(&pool) @@ -309,7 +309,7 @@ Missing parents are auto-created at every level of the chain: # } # # #[fabrique::doctest] -# async fn main(pool: Pool) -> Result<(), fabrique::Error> { +# async fn main(pool: Pool) -> Result<(), fabrique::Error> { // 1 order, 3 order lines, each with its own product let order = Order::factory() .has_order_lines(OrderLine::factory(), 3) diff --git a/docs/src/concepts/models.md b/docs/src/concepts/models.md index 766c380..2f928b6 100644 --- a/docs/src/concepts/models.md +++ b/docs/src/concepts/models.md @@ -240,7 +240,7 @@ query builder for common operations. For instance: # } # # #[fabrique::doctest] -# async fn main(pool: Pool) -> Result<(), fabrique::Error> { +# async fn main(pool: Pool) -> Result<(), fabrique::Error> { // Insert a new record let anvil = Product { id: Uuid::new_v4(), diff --git a/docs/src/concepts/query-builder.md b/docs/src/concepts/query-builder.md index ae66a85..1d5d8d3 100644 --- a/docs/src/concepts/query-builder.md +++ b/docs/src/concepts/query-builder.md @@ -42,7 +42,7 @@ clauses, then execute the query by passing a connection: # } # # #[fabrique::doctest] -# async fn main(pool: Pool) -> Result<(), fabrique::Error> { +# async fn main(pool: Pool) -> Result<(), fabrique::Error> { # Product::factory().price_cents(3000).in_stock(true).create(&pool).await?; let deals: Vec = Product::query() .r#where(Product::IN_STOCK, "=", true) @@ -83,7 +83,7 @@ Fabrique provides dedicated methods: # } # # #[fabrique::doctest] -# async fn main(pool: Pool) -> Result<(), fabrique::Error> { +# async fn main(pool: Pool) -> Result<(), fabrique::Error> { # User::factory().deleted_at(None).create(&pool).await?; let active = User::query() .where_null(User::DELETED_AT) @@ -123,7 +123,7 @@ how many records are returned and where to start: # } # # #[fabrique::doctest] -# async fn main(pool: Pool) -> Result<(), fabrique::Error> { +# async fn main(pool: Pool) -> Result<(), fabrique::Error> { # Product::factory().in_stock(true).create(&pool).await?; let page = Product::query() .r#where(Product::IN_STOCK, "=", true) @@ -159,7 +159,7 @@ a `Result`: # } # # #[fabrique::doctest] -# async fn main(pool: Pool) -> Result<(), fabrique::Error> { +# async fn main(pool: Pool) -> Result<(), fabrique::Error> { # Product::factory().price_cents(3000).create(&pool).await?; let cheapest: Option = Product::query() .r#where(Product::PRICE_CENTS, "<=", 5000) @@ -199,7 +199,7 @@ data: # } # # #[fabrique::doctest] -# async fn main(pool: Pool) -> Result<(), fabrique::Error> { +# async fn main(pool: Pool) -> Result<(), fabrique::Error> { Product::insert() .set(Product::ID, Uuid::new_v4()) .set(Product::NAME, "Anvil 3000") @@ -231,7 +231,7 @@ the executor — this avoids a separate SELECT roundtrip: # } # # #[fabrique::doctest] -# async fn main(pool: Pool) -> Result<(), fabrique::Error> { +# async fn main(pool: Pool) -> Result<(), fabrique::Error> { let anvil: Product = Product::insert() .set(Product::ID, Uuid::new_v4()) .set(Product::NAME, "Anvil 3000") @@ -265,7 +265,7 @@ let anvil: Product = Product::insert() # } # # #[fabrique::doctest] -# async fn main(pool: Pool) -> Result<(), fabrique::Error> { +# async fn main(pool: Pool) -> Result<(), fabrique::Error> { # Product::factory().price_cents(30).create(&pool).await?; Product::update() .set(Product::PRICE_CENTS, 100) @@ -314,7 +314,7 @@ no relationship is declared between the models, it won't compile: # } # # #[fabrique::doctest] -# async fn main(pool: Pool) -> Result<(), fabrique::Error> { +# async fn main(pool: Pool) -> Result<(), fabrique::Error> { # Order::factory().create(&pool).await?; // Both directions work — the relation is declared once let users = User::query() @@ -376,7 +376,7 @@ chain must be valid at each step: # } # # #[fabrique::doctest] -# async fn main(pool: Pool) -> Result<(), fabrique::Error> { +# async fn main(pool: Pool) -> Result<(), fabrique::Error> { # Order::factory().create(&pool).await?; // Order → OrderLine (direct) → Product (through OrderLine) let orders = Order::query() @@ -420,7 +420,7 @@ Each alias is a marker type generated by the `alias` attribute # } # #[fabrique::doctest] # async fn main( -# pool: Pool, +# pool: Pool, # ) -> Result<(), fabrique::Error> { let messages = Message::query() .join_as::() @@ -478,7 +478,7 @@ to build: # } # # #[fabrique::doctest] -# async fn main(pool: Pool) -> Result<(), fabrique::Error> { +# async fn main(pool: Pool) -> Result<(), fabrique::Error> { # Product::factory().create(&pool).await?; let products: Vec = Product::query() .select_as::() @@ -508,7 +508,7 @@ do: # } # # #[fabrique::doctest] -# async fn main(pool: Pool) -> Result<(), fabrique::Error> { +# async fn main(pool: Pool) -> Result<(), fabrique::Error> { # Product::factory().create(&pool).await?; // Equivalent — Product is inferred let products: Vec = Product::query() @@ -544,7 +544,7 @@ model: # } # # #[fabrique::doctest] -# async fn main(pool: Pool) -> Result<(), fabrique::Error> { +# async fn main(pool: Pool) -> Result<(), fabrique::Error> { # Order::factory().create(&pool).await?; let orders: Vec = User::query() .join::() @@ -575,7 +575,7 @@ constants — the return type matches: # } # # #[fabrique::doctest] -# async fn main(pool: Pool) -> Result<(), fabrique::Error> { +# async fn main(pool: Pool) -> Result<(), fabrique::Error> { # Product::factory().create(&pool).await?; let rows: Vec<(String, i32)> = Product::query() .select((Product::NAME, Product::PRICE_CENTS)) @@ -611,7 +611,7 @@ present: # } # # #[fabrique::doctest] -# async fn main(pool: Pool) -> Result<(), fabrique::Error> { +# async fn main(pool: Pool) -> Result<(), fabrique::Error> { # Order::factory().create(&pool).await?; let rows: Vec<(String, String)> = User::query() .join::() diff --git a/docs/src/concepts/relations.md b/docs/src/concepts/relations.md index b38365a..8a523ca 100644 --- a/docs/src/concepts/relations.md +++ b/docs/src/concepts/relations.md @@ -70,7 +70,7 @@ primary key — no declaration needed on the parent side: # status: String, # } # #[fabrique::doctest] -# async fn main(pool: Pool) -> Result<(), fabrique::Error> { +# async fn main(pool: Pool) -> Result<(), fabrique::Error> { # let user = User::factory().create(&pool).await?; let orders = user.orders::<_>().get(&pool).await?; # Ok(()) @@ -136,7 +136,7 @@ specify which foreign key to use: # recipient_id: Uuid, # } # #[fabrique::doctest] -# async fn main(pool: Pool) -> Result<(), fabrique::Error> { +# async fn main(pool: Pool) -> Result<(), fabrique::Error> { # let user = User::factory().create(&pool).await?; let sent = user.messages::<_, Sender>().get(&pool).await?; let received = user.messages::<_, Recipient>().get(&pool).await?; @@ -168,7 +168,7 @@ through a specific alias: # } # #[fabrique::doctest] # async fn main( -# pool: Pool, +# pool: Pool, # ) -> Result<(), fabrique::Error> { let messages = Message::query() .join_as::() diff --git a/docs/src/cookbook/bulk-update-and-upsert.md b/docs/src/cookbook/bulk-update-and-upsert.md index 0fecc5f..594d216 100644 --- a/docs/src/cookbook/bulk-update-and-upsert.md +++ b/docs/src/cookbook/bulk-update-and-upsert.md @@ -28,7 +28,7 @@ touches every matching row: # } # # #[fabrique::doctest] -# async fn main(pool: Pool) -> Result<(), fabrique::Error> { +# async fn main(pool: Pool) -> Result<(), fabrique::Error> { # Product::factory() # .price_cents(15000) # .in_stock(true) @@ -68,7 +68,7 @@ Chain multiple `.set()` calls to update several columns at once: # } # # #[fabrique::doctest] -# async fn main(pool: Pool) -> Result<(), fabrique::Error> { +# async fn main(pool: Pool) -> Result<(), fabrique::Error> { # Product::factory() # .name("Anvil 3000".to_string()) # .price_cents(100) @@ -113,7 +113,7 @@ separate SELECT: # } # # #[fabrique::doctest] -# async fn main(pool: Pool) -> Result<(), fabrique::Error> { +# async fn main(pool: Pool) -> Result<(), fabrique::Error> { # Product::factory() # .price_cents(15000) # .in_stock(true) @@ -162,7 +162,7 @@ statement: # } # # #[fabrique::doctest] -# async fn main(pool: Pool) -> Result<(), fabrique::Error> { +# async fn main(pool: Pool) -> Result<(), fabrique::Error> { # let product = Product::factory() # .name("Old Name".to_string()) # .create(&pool) @@ -209,7 +209,7 @@ If you only want to skip duplicates without updating, use # } # # #[fabrique::doctest] -# async fn main(pool: Pool) -> Result<(), fabrique::Error> { +# async fn main(pool: Pool) -> Result<(), fabrique::Error> { # let id = Uuid::new_v4(); // Insert if not exists, silently skip if exists Product::insert() diff --git a/docs/src/cookbook/handle-ambiguous-relations.md b/docs/src/cookbook/handle-ambiguous-relations.md index 13bbea5..75be1d3 100644 --- a/docs/src/cookbook/handle-ambiguous-relations.md +++ b/docs/src/cookbook/handle-ambiguous-relations.md @@ -69,7 +69,7 @@ With aliases, you can join the same model multiple times using `join_as`: # recipient_id: Uuid, # } # #[fabrique::doctest] -# async fn main(pool: Pool) -> Result<(), fabrique::Error> { +# async fn main(pool: Pool) -> Result<(), fabrique::Error> { let messages = Message::query() .join_as::() .join_as::() @@ -111,7 +111,7 @@ Use `where_on` to filter on a specific alias: # recipient_id: Uuid, # } # #[fabrique::doctest] -# async fn main(pool: Pool) -> Result<(), fabrique::Error> { +# async fn main(pool: Pool) -> Result<(), fabrique::Error> { // Find messages sent by Alice let messages = Message::query() .join_as::() @@ -145,7 +145,7 @@ Use `order_by_on` to sort by a column from a specific alias: # recipient_id: Uuid, # } # #[fabrique::doctest] -# async fn main(pool: Pool) -> Result<(), fabrique::Error> { +# async fn main(pool: Pool) -> Result<(), fabrique::Error> { // Order messages by sender name let messages = Message::query() .join_as::() @@ -179,7 +179,7 @@ Aliases generate `for_` methods on the factory: # recipient_id: Uuid, # } # #[fabrique::doctest] -# async fn main(pool: Pool) -> Result<(), fabrique::Error> { +# async fn main(pool: Pool) -> Result<(), fabrique::Error> { let alice = User::factory() .name("Alice".to_string()) .create(&pool) @@ -228,7 +228,7 @@ to specify which foreign key to follow: # email: String, # } # #[fabrique::doctest] -# async fn main(pool: Pool) -> Result<(), fabrique::Error> { +# async fn main(pool: Pool) -> Result<(), fabrique::Error> { # let user = User::factory().create(&pool).await?; // Get messages sent by this user let sent = user.messages::<_, Sender>().get(&pool).await?; diff --git a/docs/src/cookbook/simplify-test-setup-with-factories.md b/docs/src/cookbook/simplify-test-setup-with-factories.md index 88ca00e..01ae62d 100644 --- a/docs/src/cookbook/simplify-test-setup-with-factories.md +++ b/docs/src/cookbook/simplify-test-setup-with-factories.md @@ -57,7 +57,7 @@ them. With factories, the entire graph is built in one chain # } # # #[fabrique::doctest] -# async fn main(pool: Pool) -> Result<(), fabrique::Error> { +# async fn main(pool: Pool) -> Result<(), fabrique::Error> { let order = Order::factory() .has_order_lines(OrderLine::factory(), 3) .create(&pool) @@ -99,7 +99,7 @@ next ones reuse the same record: # } # # #[fabrique::doctest] -# async fn main(pool: Pool) -> Result<(), fabrique::Error> { +# async fn main(pool: Pool) -> Result<(), fabrique::Error> { let user = User::factory() .name("Wile E. Coyote".to_string()) .create(&pool) @@ -153,7 +153,7 @@ generate everything else: # } # # #[fabrique::doctest] -# async fn main(pool: Pool) -> Result<(), fabrique::Error> { +# async fn main(pool: Pool) -> Result<(), fabrique::Error> { // Arrange — only the status matters for this test Order::factory() .status("shipped".to_string()) @@ -216,7 +216,7 @@ Here, Alice and Bob each send 100 messages to each other — # } # # #[fabrique::doctest] -# async fn main(pool: Pool) -> Result<(), fabrique::Error> { +# async fn main(pool: Pool) -> Result<(), fabrique::Error> { let alice = User::factory() .name("Alice".to_string()) .create(&pool) diff --git a/docs/src/cookbook/soft-delete-and-restore-records.md b/docs/src/cookbook/soft-delete-and-restore-records.md index 8cc66c8..8e1daba 100644 --- a/docs/src/cookbook/soft-delete-and-restore-records.md +++ b/docs/src/cookbook/soft-delete-and-restore-records.md @@ -66,7 +66,7 @@ line references — stays intact: # } # # #[fabrique::doctest] -# async fn main(pool: Pool) -> Result<(), fabrique::Error> { +# async fn main(pool: Pool) -> Result<(), fabrique::Error> { let anvil = Product::factory() .name("Anvil 3000".to_string()) .create(&pool) @@ -122,7 +122,7 @@ clear `deleted_at` and bring the product back: # } # # #[fabrique::doctest] -# async fn main(pool: Pool) -> Result<(), fabrique::Error> { +# async fn main(pool: Pool) -> Result<(), fabrique::Error> { # let product = Product::factory().create(&pool).await?; # let id = product.id; # product.delete(&pool).await?; @@ -163,7 +163,7 @@ without a prior SELECT: # } # # #[fabrique::doctest] -# async fn main(pool: Pool) -> Result<(), fabrique::Error> { +# async fn main(pool: Pool) -> Result<(), fabrique::Error> { # let product = Product::factory().create(&pool).await?; # let id = product.id; // DELETE /products/:id handler @@ -200,7 +200,7 @@ real `DELETE` — bypassing the soft delete mechanism: # } # # #[fabrique::doctest] -# async fn main(pool: Pool) -> Result<(), fabrique::Error> { +# async fn main(pool: Pool) -> Result<(), fabrique::Error> { # let product = Product::factory().create(&pool).await?; # let id = product.id; // Permanently remove the record diff --git a/docs/src/introduction.md b/docs/src/introduction.md index c5ad736..ef01345 100644 --- a/docs/src/introduction.md +++ b/docs/src/introduction.md @@ -53,7 +53,7 @@ constants. Use them to build queries that read like SQL: # } # # #[fabrique::doctest] -# async fn main(pool: Pool) -> Result<(), fabrique::Error> { +# async fn main(pool: Pool) -> Result<(), fabrique::Error> { let deals = Product::query() .r#where(Product::IN_STOCK, "=", true) .r#where(Product::PRICE_CENTS, "<=", 5000) @@ -85,7 +85,7 @@ the query builder, so you don't have to assemble it yourself: # } # # #[fabrique::doctest] -# async fn main(pool: Pool) -> Result<(), fabrique::Error> { +# async fn main(pool: Pool) -> Result<(), fabrique::Error> { let anvil = Product { id: Uuid::new_v4(), name: "Anvil 3000".to_string(), diff --git a/fabrique-core/src/__private.rs b/fabrique-core/src/__private.rs index adc37a7..6e122bd 100644 --- a/fabrique-core/src/__private.rs +++ b/fabrique-core/src/__private.rs @@ -1,40 +1,199 @@ //! Internal module for macro support, not part of the public API. //! -//! Proc-macro crates can only export macros, not runtime functions. The -//! `#[fabrique::doctest]` macro generates code that calls functions from this -//! module to set up test databases with migrations. The module must be public -//! for the generated code to access it, but is marked `#[doc(hidden)]` to -//! exclude it from the public documentation. +//! The `#[fabrique::test]` and `#[fabrique::doctest]` macros generate +//! code that calls functions from this module to set up test databases +//! with migrations. The module must be public for the generated code +//! to access it, but is marked `#[doc(hidden)]` to exclude it from +//! the public documentation. use crate::{database::Pool, error::Error}; +#[cfg(any(feature = "postgres", feature = "mysql"))] +use sqlx::AssertSqlSafe; +use sqlx::migrate::Migrator; +use std::path::Path; -/// Creates an in-memory SQLite test pool with migrations applied. -/// -/// The `sqlx::migrate!()` macro embeds migration files at compile time, -/// resolving the path when this crate is compiled. Inlining the migrate call in -/// macro-generated code would fail since doctests compile in a temporary -/// directory without access to the migrations folder. By defining this function -/// here, migrations are embedded into the library and available at runtime. +/// Creates an in-memory SQLite pool with migrations applied. #[cfg(feature = "sqlite")] -pub async fn doctest_pool() -> Result, Error> { +pub async fn create_sqlite_pool(migration_path: &str) -> Result, Error> { let pool = sqlx::SqlitePool::connect("sqlite::memory:").await?; - sqlx::migrate!("../migrations") + Migrator::new(Path::new(migration_path)) + .await + .map_err(|e| Error::Other(Box::new(e)))? .run(&pool) .await .map_err(|e| Error::Other(Box::new(e)))?; Ok(pool) } +/// Creates a temporary Postgres database with migrations applied. +/// +/// Reads the connection URL from `POSTGRES_URL`. Returns the pool, +/// the base URL, and the temporary database name (for cleanup). +#[cfg(feature = "postgres")] +pub async fn create_postgres_pool( + migration_path: &str, +) -> Result<(Pool, String, String), Error> { + let base_url = + std::env::var("POSTGRES_URL").expect("POSTGRES_URL must be set for postgres tests"); + let db_name = temp_db_name(); + + let base_pool = sqlx::PgPool::connect(&base_url).await?; + sqlx::raw_sql(AssertSqlSafe(format!("CREATE DATABASE \"{db_name}\""))) + .execute(&base_pool) + .await + .map_err(|e| Error::Other(Box::new(e)))?; + drop(base_pool); + + let test_url = replace_db_in_url(&base_url, &db_name); + let pool = sqlx::PgPool::connect(&test_url).await?; + Migrator::new(Path::new(migration_path)) + .await + .map_err(|e| Error::Other(Box::new(e)))? + .run(&pool) + .await + .map_err(|e| Error::Other(Box::new(e)))?; + + Ok((pool, base_url, db_name)) +} + +/// Drops a temporary Postgres database. +#[cfg(feature = "postgres")] +pub async fn cleanup_test_db_postgres(base_url: &str, db_name: &str) { + let pool = sqlx::PgPool::connect(base_url) + .await + .expect("cleanup: failed to connect"); + sqlx::raw_sql(AssertSqlSafe(format!( + "DROP DATABASE IF EXISTS \"{db_name}\" WITH (FORCE)" + ))) + .execute(&pool) + .await + .expect("cleanup: failed to drop test database"); +} + +/// Creates a temporary MySQL database with migrations applied. +/// +/// Reads the connection URL from `MYSQL_URL`. Returns the pool, +/// the base URL, and the temporary database name (for cleanup). +#[cfg(feature = "mysql")] +pub async fn create_mysql_pool( + migration_path: &str, +) -> Result<(Pool, String, String), Error> { + let base_url = std::env::var("MYSQL_URL").expect("MYSQL_URL must be set for mysql tests"); + let db_name = temp_db_name(); + + let base_pool = sqlx::MySqlPool::connect(&base_url).await?; + sqlx::raw_sql(AssertSqlSafe(format!("CREATE DATABASE `{db_name}`"))) + .execute(&base_pool) + .await + .map_err(|e| Error::Other(Box::new(e)))?; + drop(base_pool); + + let test_url = replace_db_in_url(&base_url, &db_name); + let pool = sqlx::MySqlPool::connect(&test_url).await?; + Migrator::new(Path::new(migration_path)) + .await + .map_err(|e| Error::Other(Box::new(e)))? + .run(&pool) + .await + .map_err(|e| Error::Other(Box::new(e)))?; + + Ok((pool, base_url, db_name)) +} + +/// Drops a temporary MySQL database. +#[cfg(feature = "mysql")] +pub async fn cleanup_test_db_mysql(base_url: &str, db_name: &str) { + let pool = sqlx::MySqlPool::connect(base_url) + .await + .expect("cleanup: failed to connect"); + sqlx::raw_sql(AssertSqlSafe(format!( + "DROP DATABASE IF EXISTS `{db_name}`" + ))) + .execute(&pool) + .await + .expect("cleanup: failed to drop test database"); +} + +#[cfg(any(feature = "postgres", feature = "mysql"))] +fn temp_db_name() -> String { + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + format!("fabrique_test_{}_{}", std::process::id(), ts) +} + +#[cfg(any(feature = "postgres", feature = "mysql"))] +fn replace_db_in_url(url: &str, db_name: &str) -> String { + let (url_without_query, query) = match url.find('?') { + Some(pos) => (&url[..pos], &url[pos..]), + None => (url, ""), + }; + + let after_scheme = url_without_query.find("://").map(|i| i + 3).unwrap_or(0); + + match url_without_query[after_scheme..].rfind('/') { + Some(pos) => format!( + "{}/{db_name}{query}", + &url_without_query[..after_scheme + pos] + ), + None => format!("{url_without_query}/{db_name}{query}"), + } +} + #[cfg(test)] mod tests { use super::*; + #[cfg(feature = "sqlite")] #[tokio::test] - async fn doctest_pool_creates_pool_with_migrations() { - let pool = doctest_pool().await.expect("should create pool"); - // Verify the pool is functional by running a simple query - let result: Result<(i64,), _> = sqlx::query_as("SELECT 1").fetch_one(&pool).await; - assert!(result.is_ok()); - assert_eq!(result.unwrap().0, 1); + async fn create_sqlite_pool_applies_migrations() { + let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../migrations/sqlite"); + let pool = create_sqlite_pool(path).await.expect("should create pool"); + let result: (i64,) = sqlx::query_as("SELECT 1").fetch_one(&pool).await.unwrap(); + assert_eq!(result.0, 1); + } + + #[cfg(any(feature = "postgres", feature = "mysql"))] + #[test] + fn temp_db_name_has_expected_prefix() { + let name = temp_db_name(); + assert!(name.starts_with("fabrique_test_")); + } + + #[cfg(any(feature = "postgres", feature = "mysql"))] + #[test] + fn replace_db_in_url_swaps_last_segment() { + assert_eq!( + replace_db_in_url("postgres://user:pass@host/olddb", "newdb",), + "postgres://user:pass@host/newdb" + ); + } + + #[cfg(any(feature = "postgres", feature = "mysql"))] + #[test] + fn replace_db_in_url_appends_when_no_slash() { + assert_eq!( + replace_db_in_url("postgres://host", "newdb"), + "postgres://host/newdb" + ); + } + + #[cfg(any(feature = "postgres", feature = "mysql"))] + #[test] + fn replace_db_in_url_preserves_query_params() { + assert_eq!( + replace_db_in_url("postgres://user:pass@host/olddb?sslmode=require", "newdb"), + "postgres://user:pass@host/newdb?sslmode=require" + ); + } + + #[cfg(any(feature = "postgres", feature = "mysql"))] + #[test] + fn replace_db_in_url_preserves_query_params_without_path() { + assert_eq!( + replace_db_in_url("postgres://host?sslmode=require", "newdb"), + "postgres://host/newdb?sslmode=require" + ); } } diff --git a/fabrique-core/src/lib.rs b/fabrique-core/src/lib.rs index b33dc7a..32e9b1e 100644 --- a/fabrique-core/src/lib.rs +++ b/fabrique-core/src/lib.rs @@ -1,7 +1,7 @@ #[cfg(not(any(feature = "postgres", feature = "sqlite", feature = "mysql")))] compile_error!("one of the features \"postgres\", \"sqlite\", or \"mysql\" must be enabled"); -#[cfg(all(feature = "testing", feature = "sqlite"))] +#[cfg(feature = "testing")] #[doc(hidden)] pub mod __private; pub mod database; diff --git a/fabrique-derive/src/lib.rs b/fabrique-derive/src/lib.rs index 044543f..a8b2b43 100644 --- a/fabrique-derive/src/lib.rs +++ b/fabrique-derive/src/lib.rs @@ -9,11 +9,13 @@ //! operations use proc_macro::TokenStream; -use syn::{DeriveInput, ItemFn, parse_macro_input}; +use syn::{DeriveInput, parse_macro_input}; mod analysis; mod codegen; mod error; +#[cfg(feature = "testing")] +mod test; use crate::analysis::Analysis; use crate::codegen::*; @@ -100,17 +102,42 @@ pub fn derive_factory(input: TokenStream) -> TokenStream { .into() } -/// Creates an in-memory SQLite database with migrations for documentation -/// examples. +/// Sets up a test database with migrations for the detected backend. +/// +/// Transforms an async test function into a `#[tokio::test]` function +/// that creates a pool, runs migrations, and cleans up temporary +/// databases (Postgres, MySQL) after the test completes. +/// +/// The backend is detected from the `Pool` parameter type. +/// +/// ```rust,ignore +/// #[fabrique::test] +/// async fn test_create(pool: Pool) { +/// let product = Product::factory().create(&pool).await.unwrap(); +/// assert_eq!(product.name.is_empty(), false); +/// } +/// ``` +#[cfg(feature = "testing")] +#[cfg_attr(coverage_nightly, coverage(off))] +#[proc_macro_attribute] +pub fn test(attr: TokenStream, item: TokenStream) -> TokenStream { + match test::expand_test(attr.into(), item.into()) { + Ok(tokens) => tokens.into(), + Err(e) => e.into_compile_error().into(), + } +} + +/// Creates an in-memory SQLite database with migrations for +/// documentation examples. /// -/// This macro transforms an async function into an executable doctest. It sets -/// up a Tokio runtime, creates an in-memory SQLite database, runs migrations, -/// and provides the connection pool to your test code. Use `pool` as the -/// parameter name. +/// This macro transforms an async function into an executable +/// doctest. It sets up a Tokio runtime, creates an in-memory SQLite +/// database, runs migrations, and provides the connection pool to +/// your test code. /// -/// Requires the `testing` and `sqlite` features. Doctests will fail to compile -/// without them — use `--lib --tests` to skip doctests when running against -/// other backends. +/// Requires the `testing` and `sqlite` features. Doctests will fail +/// to compile without them — use `--lib --tests` to skip doctests +/// when running against other backends. /// /// ```rust,ignore /// # extern crate fabrique; @@ -126,27 +153,12 @@ pub fn derive_factory(input: TokenStream) -> TokenStream { /// Ok(()) /// } /// ``` -// Tested via mdbook doctests, not unit tests - coverage measured separately +#[cfg(feature = "testing")] #[cfg_attr(coverage_nightly, coverage(off))] #[proc_macro_attribute] pub fn doctest(_attr: TokenStream, item: TokenStream) -> TokenStream { - let input = parse_macro_input!(item as ItemFn); - let block = &input.block; - - quote::quote! { - fn main() { - ::tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("Failed to create Tokio runtime") - .block_on(async { - let pool = ::fabrique::__private::doctest_pool() - .await - .expect("Failed to create doctest pool"); - let __result: Result<(), ::fabrique::Error> = async #block.await; - __result.expect("Doctest failed"); - }); - } + match test::expand_doctest(item.into()) { + Ok(tokens) => tokens.into(), + Err(e) => e.into_compile_error().into(), } - .into() } diff --git a/fabrique-derive/src/test.rs b/fabrique-derive/src/test.rs new file mode 100644 index 0000000..49cc21f --- /dev/null +++ b/fabrique-derive/src/test.rs @@ -0,0 +1,883 @@ +//! Code generation for `#[fabrique::test]` and `#[fabrique::doctest]`. +//! +//! Parses an async test function, detects the database backend from +//! the `Pool` parameter, and generates a `#[tokio::test]` function +//! that creates a temporary pool with migrations applied. + +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; +use syn::{ + FnArg, GenericArgument, GenericParam, Generics, Ident, ItemFn, Pat, PathArguments, Type, +}; + +// ── Analysis ──────────────────────────────────────────────────── + +/// A concrete database backend. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum ConcreteBackend { + Sqlite, + Postgres, + MySql, +} + +/// Database backend detected from the `Pool` parameter type. +#[derive(Debug, PartialEq)] +pub enum Backend { + Concrete(ConcreteBackend), + /// Generic backend from ``. Generates one test + /// per backend, each cfg-gated by feature. + Multi(Ident), +} + +/// Parsed representation of a `#[fabrique::test]` function. +/// +/// Extracts the function name, parameter name, body, and database +/// backend from the function signature. +#[derive(Debug)] +pub struct TestAnalysis { + pub fn_name: Ident, + pub param_name: Ident, + pub body: syn::Block, + pub backend: Backend, +} + +impl TestAnalysis { + /// Parses an async test function into its components. + /// + /// Expects exactly one parameter of type `Pool`, + /// `Pool`, `Pool`, or `Pool` where `DB` + /// is a generic type parameter (multi-backend). + pub fn try_from_item(input: &ItemFn) -> Result { + let fn_name = input.sig.ident.clone(); + let body = (*input.block).clone(); + + let param = input.sig.inputs.first().ok_or_else(|| { + syn::Error::new_spanned( + &input.sig, + "expected a pool parameter, \ + e.g. `pool: Pool`", + ) + })?; + + let (param_name, backend) = match param { + FnArg::Typed(pat_type) => { + let name = match &*pat_type.pat { + Pat::Ident(pi) => pi.ident.clone(), + _ => { + return Err(syn::Error::new_spanned( + &pat_type.pat, + "expected a simple identifier", + )); + } + }; + let backend = Self::parse_backend(&pat_type.ty, &input.sig.generics)?; + (name, backend) + } + _ => return Err(syn::Error::new_spanned(param, "expected a typed parameter")), + }; + + Ok(Self { + fn_name, + param_name, + body, + backend, + }) + } + + /// Extracts the backend variant from a `Pool` type. + /// + /// Recognises `Sqlite`, `Postgres`, and `MySql` as the last + /// segment of the inner type path (e.g. `sqlx::Sqlite`). + /// If the inner type matches a generic type parameter from the + /// function signature, returns `Backend::Multi`. + fn parse_backend(ty: &Type, generics: &Generics) -> Result { + let type_path = match ty { + Type::Path(tp) => tp, + _ => { + return Err(syn::Error::new_spanned( + ty, + "expected Pool", + )); + } + }; + + let segment = type_path + .path + .segments + .last() + .ok_or_else(|| syn::Error::new_spanned(ty, "expected Pool<…>"))?; + + if segment.ident != "Pool" { + return Err(syn::Error::new_spanned( + &segment.ident, + "expected Pool type", + )); + } + + let args = match &segment.arguments { + PathArguments::AngleBracketed(a) => a, + _ => { + return Err(syn::Error::new_spanned( + segment, + "expected Pool<…> with angle brackets", + )); + } + }; + + let arg = args + .args + .first() + .ok_or_else(|| syn::Error::new_spanned(args, "expected a type argument"))?; + + let inner = match arg { + GenericArgument::Type(Type::Path(tp)) => tp, + _ => { + return Err(syn::Error::new_spanned( + arg, + "expected a path type argument", + )); + } + }; + + let ident = &inner + .path + .segments + .last() + .ok_or_else(|| syn::Error::new_spanned(inner, "empty path"))? + .ident; + + match ident.to_string().as_str() { + "Sqlite" => Ok(Backend::Concrete(ConcreteBackend::Sqlite)), + "Postgres" => Ok(Backend::Concrete(ConcreteBackend::Postgres)), + "MySql" => Ok(Backend::Concrete(ConcreteBackend::MySql)), + _ => { + let is_generic = generics + .params + .iter() + .any(|p| matches!(p, GenericParam::Type(tp) if tp.ident == *ident)); + if is_generic { + Ok(Backend::Multi(ident.clone())) + } else { + Err(syn::Error::new_spanned( + ident, + format!("unsupported backend: {ident}"), + )) + } + } + } + } +} + +// ── Codegen ───────────────────────────────────────────────────── + +/// All concrete backends, in generation order. +const CONCRETE_BACKENDS: [ConcreteBackend; 3] = [ + ConcreteBackend::Sqlite, + ConcreteBackend::Postgres, + ConcreteBackend::MySql, +]; + +impl ConcreteBackend { + /// Feature flag name for cfg-gating. + fn feature(&self) -> &'static str { + match self { + ConcreteBackend::Sqlite => "sqlite", + ConcreteBackend::Postgres => "postgres", + ConcreteBackend::MySql => "mysql", + } + } + + /// Fully-qualified sqlx type path. + fn sqlx_type(&self) -> TokenStream { + match self { + ConcreteBackend::Sqlite => quote! { ::sqlx::Sqlite }, + ConcreteBackend::Postgres => quote! { ::sqlx::Postgres }, + ConcreteBackend::MySql => quote! { ::sqlx::MySql }, + } + } +} + +/// Generates pool creation tokens for a concrete backend. +fn pool_setup_tokens(backend: &ConcreteBackend, param: &Ident, path: &str) -> TokenStream { + match backend { + ConcreteBackend::Sqlite => quote! { + let #param = + ::fabrique::__private::create_sqlite_pool(#path) + .await + .expect("Failed to create test pool"); + }, + ConcreteBackend::Postgres => quote! { + let (#param, __base_url, __db_name) = + ::fabrique::__private::create_postgres_pool(#path) + .await + .expect("Failed to create test pool"); + }, + ConcreteBackend::MySql => quote! { + let (#param, __base_url, __db_name) = + ::fabrique::__private::create_mysql_pool(#path) + .await + .expect("Failed to create test pool"); + }, + } +} + +/// Generates cleanup tokens for a concrete backend. +fn cleanup_tokens(backend: &ConcreteBackend, param: &Ident) -> TokenStream { + match backend { + ConcreteBackend::Sqlite => quote! {}, + ConcreteBackend::Postgres => quote! { + drop(#param); + ::fabrique::__private::cleanup_test_db_postgres( + &__base_url, + &__db_name, + ) + .await; + }, + ConcreteBackend::MySql => quote! { + drop(#param); + ::fabrique::__private::cleanup_test_db_mysql( + &__base_url, + &__db_name, + ) + .await; + }, + } +} + +/// Code generator for `#[fabrique::test]` and `#[fabrique::doctest]`. +/// +/// Takes a parsed test function and generates a `#[tokio::test]` +/// wrapper that creates a pool with migrations applied and runs +/// cleanup when needed. +pub struct TestCodegen<'a> { + analysis: &'a TestAnalysis, + migration_path: String, +} + +impl<'a> TestCodegen<'a> { + /// Creates a code generator, resolving the migration path + /// from the workspace root. + pub fn new(analysis: &'a TestAnalysis) -> Self { + let migration_path = match &analysis.backend { + Backend::Multi(_) => String::new(), + Backend::Concrete(concrete) => Self::resolve_migration_path(concrete), + }; + Self { + analysis, + migration_path, + } + } + + /// Creates a code generator with an explicit migration path. + /// + /// Useful for testing without filesystem dependency. + #[cfg(test)] + fn with_path(analysis: &'a TestAnalysis, path: &str) -> Self { + Self { + analysis, + migration_path: path.to_owned(), + } + } + + /// Generates test function(s). For a concrete backend, emits + /// a single `#[tokio::test]`. For `Multi`, emits one per + /// backend, each cfg-gated. + pub fn generate(&self) -> TokenStream { + match &self.analysis.backend { + Backend::Multi(type_param) => self.generate_multi(type_param), + Backend::Concrete(concrete) => self.generate_single(concrete), + } + } + + /// Generates a doctest wrapper with a blocking Tokio runtime. + /// + /// Unlike `generate()`, this wraps the body in a manual Tokio + /// runtime since doctests cannot use `#[tokio::test]`. + /// Multi-backend is not supported for doctests. + pub fn generate_doctest(&self) -> TokenStream { + if !matches!( + self.analysis.backend, + Backend::Concrete(ConcreteBackend::Sqlite) + ) { + return syn::Error::new_spanned( + &self.analysis.fn_name, + "#[fabrique::doctest] only supports Sqlite", + ) + .into_compile_error(); + } + + let body = &self.analysis.body; + let param = &self.analysis.param_name; + let path = &self.migration_path; + + quote! { + fn main() { + ::tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("Failed to create Tokio runtime") + .block_on(async { + let #param = + ::fabrique::__private::create_sqlite_pool(#path) + .await + .expect("Failed to create doctest pool"); + let __result: Result<(), ::fabrique::Error> = + async #body .await; + __result.expect("Doctest failed"); + }); + } + } + } + + /// Generates a single `#[tokio::test]` for a concrete backend. + fn generate_single(&self, backend: &ConcreteBackend) -> TokenStream { + let fn_name = &self.analysis.fn_name; + let stmts = &self.analysis.body.stmts; + let param = &self.analysis.param_name; + let setup = pool_setup_tokens(backend, param, &self.migration_path); + let cleanup = cleanup_tokens(backend, param); + + quote! { + #[::tokio::test] + async fn #fn_name() { + #setup + #(#stmts)* + #cleanup + } + } + } + + /// Generates three cfg-gated `#[tokio::test]` functions, + /// one per backend. Each introduces a `type DB = ...;` alias + /// so the body can reference the generic type parameter. + fn generate_multi(&self, type_param: &Ident) -> TokenStream { + let stmts = &self.analysis.body.stmts; + let param = &self.analysis.param_name; + let ws_root = workspace_root(); + + let fns = CONCRETE_BACKENDS.iter().map(|backend| { + let feature = backend.feature(); + let db_type = backend.sqlx_type(); + let suffix = feature; + let fn_name = format_ident!("{}_{}", self.analysis.fn_name, suffix,); + let path = ws_root + .join("migrations") + .join(backend.feature()) + .to_string_lossy() + .into_owned(); + let setup = pool_setup_tokens(backend, param, &path); + let cleanup = cleanup_tokens(backend, param); + + quote! { + #[cfg(feature = #feature)] + #[::tokio::test] + async fn #fn_name() { + type #type_param = #db_type; + #setup + #(#stmts)* + #cleanup + } + } + }); + + quote! { #(#fns)* } + } + + /// Returns the absolute migration path for the given backend. + fn resolve_migration_path(backend: &ConcreteBackend) -> String { + let subdir = backend.feature(); + workspace_root() + .join("migrations") + .join(subdir) + .to_string_lossy() + .into_owned() + } +} + +// ── Helpers ───────────────────────────────────────────────────── + +/// Finds the workspace root by walking up from +/// `CARGO_MANIFEST_DIR`. +/// +/// Looks for a `Cargo.toml` containing a `[workspace]` section. +/// Panics if no workspace root is found. +fn workspace_root() -> std::path::PathBuf { + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR must be set"); + find_workspace_root(std::path::PathBuf::from(manifest_dir)) +} + +/// Walks up from `start` looking for a `Cargo.toml` containing +/// a `[workspace]` section. Panics if none is found. +fn find_workspace_root(mut path: std::path::PathBuf) -> std::path::PathBuf { + loop { + let cargo_toml = path.join("Cargo.toml"); + if cargo_toml.exists() { + let content = std::fs::read_to_string(&cargo_toml).expect("failed to read Cargo.toml"); + if content.contains("[workspace]") { + return path; + } + } + if !path.pop() { + panic!( + "could not find workspace root \ + (Cargo.toml with [workspace])" + ); + } + } +} + +// ── Entry points ──────────────────────────────────────────────── + +/// Entry point for `#[fabrique::test]`. +pub fn expand_test(attr: TokenStream, item: TokenStream) -> Result { + if !attr.is_empty() { + return Err(syn::Error::new_spanned( + attr, + "#[fabrique::test] does not accept arguments", + )); + } + let input: ItemFn = syn::parse2(item)?; + let analysis = TestAnalysis::try_from_item(&input)?; + let codegen = TestCodegen::new(&analysis); + Ok(codegen.generate()) +} + +/// Entry point for `#[fabrique::doctest]`. +pub fn expand_doctest(item: TokenStream) -> Result { + let input: ItemFn = syn::parse2(item)?; + let analysis = TestAnalysis::try_from_item(&input)?; + let codegen = TestCodegen::new(&analysis); + Ok(codegen.generate_doctest()) +} + +// ── Tests ─────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use syn::parse_quote; + + #[test] + fn test_analysis_parses_sqlite_backend() { + let input: ItemFn = parse_quote! { + async fn my_test(pool: Pool) {} + }; + let analysis = TestAnalysis::try_from_item(&input).unwrap(); + + assert_eq!(analysis.fn_name, "my_test"); + assert_eq!(analysis.param_name, "pool"); + assert_eq!(analysis.backend, Backend::Concrete(ConcreteBackend::Sqlite)); + } + + #[test] + fn test_analysis_parses_postgres_backend() { + let input: ItemFn = parse_quote! { + async fn my_test(pool: Pool) {} + }; + let analysis = TestAnalysis::try_from_item(&input).unwrap(); + + assert_eq!( + analysis.backend, + Backend::Concrete(ConcreteBackend::Postgres) + ); + } + + #[test] + fn test_analysis_parses_mysql_backend() { + let input: ItemFn = parse_quote! { + async fn my_test(pool: Pool) {} + }; + let analysis = TestAnalysis::try_from_item(&input).unwrap(); + + assert_eq!(analysis.backend, Backend::Concrete(ConcreteBackend::MySql)); + } + + #[test] + fn test_analysis_parses_qualified_path() { + let input: ItemFn = parse_quote! { + async fn my_test(pool: Pool) {} + }; + let analysis = TestAnalysis::try_from_item(&input).unwrap(); + + assert_eq!(analysis.backend, Backend::Concrete(ConcreteBackend::Sqlite)); + } + + #[test] + fn test_analysis_rejects_missing_param() { + let input: ItemFn = parse_quote! { + async fn my_test() {} + }; + let result = TestAnalysis::try_from_item(&input); + + assert!(result.is_err()); + } + + #[test] + fn test_analysis_rejects_unsupported_backend() { + let input: ItemFn = parse_quote! { + async fn my_test(pool: Pool) {} + }; + let result = TestAnalysis::try_from_item(&input); + + assert!(result.is_err()); + } + + #[test] + fn test_generate_sqlite_test() { + let input: ItemFn = parse_quote! { + async fn test_create(pool: Pool) { + let result = Product::all(&pool).await; + assert!(result.is_ok()); + } + }; + let analysis = TestAnalysis::try_from_item(&input).unwrap(); + let codegen = TestCodegen::with_path(&analysis, "/ws/migrations/sqlite"); + + let generated = codegen.generate(); + + assert_eq!( + generated.to_string(), + quote! { + #[::tokio::test] + async fn test_create() { + let pool = + ::fabrique::__private::create_sqlite_pool( + "/ws/migrations/sqlite" + ) + .await + .expect("Failed to create test pool"); + let result = Product::all(&pool).await; + assert!(result.is_ok()); + } + } + .to_string() + ); + } + + #[test] + fn test_generate_postgres_test() { + let input: ItemFn = parse_quote! { + async fn test_create(pool: Pool) { + let result = Product::all(&pool).await; + assert!(result.is_ok()); + } + }; + let analysis = TestAnalysis::try_from_item(&input).unwrap(); + let codegen = TestCodegen::with_path(&analysis, "/ws/migrations/postgres"); + + let generated = codegen.generate(); + + assert_eq!( + generated.to_string(), + quote! { + #[::tokio::test] + async fn test_create() { + let (pool, __base_url, __db_name) = + ::fabrique::__private::create_postgres_pool( + "/ws/migrations/postgres" + ) + .await + .expect("Failed to create test pool"); + let result = Product::all(&pool).await; + assert!(result.is_ok()); + drop(pool); + ::fabrique::__private::cleanup_test_db_postgres( + &__base_url, + &__db_name, + ) + .await; + } + } + .to_string() + ); + } + + #[test] + fn test_generate_mysql_test() { + let input: ItemFn = parse_quote! { + async fn test_create(pool: Pool) { + let result = Product::all(&pool).await; + assert!(result.is_ok()); + } + }; + let analysis = TestAnalysis::try_from_item(&input).unwrap(); + let codegen = TestCodegen::with_path(&analysis, "/ws/migrations/mysql"); + + let generated = codegen.generate(); + + assert_eq!( + generated.to_string(), + quote! { + #[::tokio::test] + async fn test_create() { + let (pool, __base_url, __db_name) = + ::fabrique::__private::create_mysql_pool( + "/ws/migrations/mysql" + ) + .await + .expect("Failed to create test pool"); + let result = Product::all(&pool).await; + assert!(result.is_ok()); + drop(pool); + ::fabrique::__private::cleanup_test_db_mysql( + &__base_url, + &__db_name, + ) + .await; + } + } + .to_string() + ); + } + + #[test] + fn test_analysis_parses_multi_backend() { + let input: ItemFn = parse_quote! { + async fn my_test(pool: Pool) {} + }; + let analysis = TestAnalysis::try_from_item(&input).unwrap(); + + assert_eq!(analysis.fn_name, "my_test"); + assert_eq!(analysis.param_name, "pool"); + assert!(matches!(&analysis.backend, Backend::Multi(id) if id == "DB"),); + } + + #[test] + fn test_analysis_parses_multi_custom_name() { + let input: ItemFn = parse_quote! { + async fn my_test(pool: Pool) {} + }; + let analysis = TestAnalysis::try_from_item(&input).unwrap(); + + assert!(matches!(&analysis.backend, Backend::Multi(id) if id == "T"),); + } + + #[test] + fn test_generate_multi_backend() { + let input: ItemFn = parse_quote! { + async fn test_create(pool: Pool) { + let result = Product::all(&pool).await; + assert!(result.is_ok()); + } + }; + let analysis = TestAnalysis::try_from_item(&input).unwrap(); + let codegen = TestCodegen::new(&analysis); + + let output = codegen.generate().to_string(); + + // Three cfg-gated functions are generated + assert!(output.contains("fn test_create_sqlite")); + assert!(output.contains("fn test_create_postgres")); + assert!(output.contains("fn test_create_mysql")); + assert!(output.contains("# [cfg (feature = \"sqlite\")]")); + assert!(output.contains("# [cfg (feature = \"postgres\")]")); + assert!(output.contains("# [cfg (feature = \"mysql\")]")); + + // Type aliases for the generic parameter + assert!(output.contains("type DB = :: sqlx :: Sqlite")); + assert!(output.contains("type DB = :: sqlx :: Postgres")); + assert!(output.contains("type DB = :: sqlx :: MySql")); + } + + #[test] + fn test_doctest_rejects_multi_backend() { + let input: ItemFn = parse_quote! { + async fn main( + pool: Pool, + ) -> Result<(), fabrique::Error> { + Ok(()) + } + }; + let analysis = TestAnalysis::try_from_item(&input).unwrap(); + let codegen = TestCodegen::with_path(&analysis, "/ws/migrations/sqlite"); + + let output = codegen.generate_doctest().to_string(); + assert!(output.contains("compile_error")); + } + + #[test] + fn test_doctest_rejects_non_sqlite_backend() { + let input: ItemFn = parse_quote! { + async fn main( + pool: Pool, + ) -> Result<(), fabrique::Error> { + Ok(()) + } + }; + let analysis = TestAnalysis::try_from_item(&input).unwrap(); + let codegen = TestCodegen::with_path(&analysis, "/ws/migrations/postgres"); + + let output = codegen.generate_doctest().to_string(); + assert!(output.contains("compile_error")); + } + + #[test] + fn test_analysis_rejects_non_ident_pattern() { + let input: ItemFn = parse_quote! { + async fn my_test((a, b): Pool) {} + }; + let err = TestAnalysis::try_from_item(&input).unwrap_err(); + assert_eq!(err.to_string(), "expected a simple identifier"); + } + + #[test] + fn test_analysis_rejects_self_param() { + let input: ItemFn = parse_quote! { + async fn my_test(&self) {} + }; + let err = TestAnalysis::try_from_item(&input).unwrap_err(); + assert_eq!(err.to_string(), "expected a typed parameter"); + } + + #[test] + fn test_analysis_rejects_non_path_type() { + let input: ItemFn = parse_quote! { + async fn my_test(pool: &Pool) {} + }; + let err = TestAnalysis::try_from_item(&input).unwrap_err(); + assert_eq!(err.to_string(), "expected Pool"); + } + + #[test] + fn test_analysis_rejects_non_pool_type() { + let input: ItemFn = parse_quote! { + async fn my_test(pool: NotPool) {} + }; + let err = TestAnalysis::try_from_item(&input).unwrap_err(); + assert_eq!(err.to_string(), "expected Pool type"); + } + + #[test] + fn test_analysis_rejects_pool_without_angle_brackets() { + let input: ItemFn = parse_quote! { + async fn my_test(pool: Pool) {} + }; + let err = TestAnalysis::try_from_item(&input).unwrap_err(); + assert_eq!( + err.to_string(), + "expected Pool<\u{2026}> with angle brackets" + ); + } + + #[test] + fn test_analysis_rejects_non_path_type_argument() { + let input: ItemFn = parse_quote! { + async fn my_test(pool: Pool<&Sqlite>) {} + }; + let err = TestAnalysis::try_from_item(&input).unwrap_err(); + assert_eq!(err.to_string(), "expected a path type argument"); + } + + #[test] + fn test_expand_test_delegates_correctly() { + let input: TokenStream = quote! { + async fn my_test(pool: Pool) { + let result = Product::all(&pool).await; + assert!(result.is_ok()); + } + }; + let output = expand_test(TokenStream::new(), input).unwrap().to_string(); + + assert!(output.contains("fn my_test")); + assert!(output.contains("create_sqlite_pool")); + } + + #[test] + fn test_expand_test_rejects_attributes() { + let attr: TokenStream = quote! { some_config }; + let input: TokenStream = quote! { + async fn my_test(pool: Pool) {} + }; + let err = expand_test(attr, input).unwrap_err(); + assert_eq!( + err.to_string(), + "#[fabrique::test] does not accept arguments" + ); + } + + #[test] + fn test_expand_doctest_delegates_correctly() { + let input: TokenStream = quote! { + async fn main( + pool: Pool, + ) -> Result<(), fabrique::Error> { + Ok(()) + } + }; + let output = expand_doctest(input).unwrap().to_string(); + + assert!(output.contains("fn main")); + assert!(output.contains("block_on")); + assert!(output.contains("create_sqlite_pool")); + } + + #[test] + fn test_generate_doctest() { + let input: ItemFn = parse_quote! { + async fn main( + pool: Pool, + ) -> Result<(), fabrique::Error> { + let _user = User::factory().create(&pool).await?; + Ok(()) + } + }; + let analysis = TestAnalysis::try_from_item(&input).unwrap(); + let codegen = TestCodegen::with_path(&analysis, "/ws/migrations/sqlite"); + + let generated = codegen.generate_doctest(); + + assert_eq!( + generated.to_string(), + quote! { + fn main() { + ::tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("Failed to create Tokio runtime") + .block_on(async { + let pool = + ::fabrique::__private::create_sqlite_pool( + "/ws/migrations/sqlite" + ) + .await + .expect("Failed to create doctest pool"); + let __result: Result<(), ::fabrique::Error> = + async { + let _user = User::factory() + .create(&pool).await?; + Ok(()) + }.await; + __result.expect("Doctest failed"); + }); + } + } + .to_string() + ); + } + + #[test] + fn test_find_workspace_root_from_subcrate() { + let start = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let root = find_workspace_root(start); + assert!(root.join("Cargo.toml").exists()); + let content = std::fs::read_to_string(root.join("Cargo.toml")).unwrap(); + assert!(content.contains("[workspace]")); + } + + #[test] + #[should_panic(expected = "could not find workspace root")] + fn test_find_workspace_root_panics_without_workspace() { + let dir = std::env::temp_dir().join("fabrique_test_no_workspace"); + std::fs::create_dir_all(&dir).unwrap(); + + struct Cleanup(std::path::PathBuf); + impl Drop for Cleanup { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + let _guard = Cleanup(dir.clone()); + + find_workspace_root(dir); + } +} diff --git a/fabrique/src/lib.rs b/fabrique/src/lib.rs index 10ad799..f1ac3ec 100644 --- a/fabrique/src/lib.rs +++ b/fabrique/src/lib.rs @@ -117,8 +117,11 @@ pub mod model; pub mod prelude; pub mod relation; pub mod sql; -#[cfg(all(feature = "testing", feature = "sqlite"))] +#[cfg(feature = "testing")] #[doc(hidden)] pub use fabrique_core::__private; +#[cfg(feature = "testing")] pub use fabrique_derive::doctest; +#[cfg(feature = "testing")] +pub use fabrique_derive::test; diff --git a/fabrique/tests/composite_primary_keys.rs b/fabrique/tests/composite_primary_keys.rs index bdf2aae..b59e211 100644 --- a/fabrique/tests/composite_primary_keys.rs +++ b/fabrique/tests/composite_primary_keys.rs @@ -37,8 +37,8 @@ pub struct OrderLine { unit_price_cents: i32, } -#[sqlx::test(migrations = "../migrations")] -async fn test_composite_primary_key(connection: Pool) { +#[fabrique::test] +async fn test_composite_primary_key(connection: Pool) { let user = User::factory() .id(Uuid::new_v4()) .create(&connection) diff --git a/fabrique/tests/conversion.rs b/fabrique/tests/conversion.rs index e609280..68c9fc1 100644 --- a/fabrique/tests/conversion.rs +++ b/fabrique/tests/conversion.rs @@ -49,8 +49,8 @@ pub struct User { pub email: String, } -#[sqlx::test(migrations = "../migrations")] -async fn test_where_accepts_a_rust_type(pool: Pool) { +#[fabrique::test] +async fn test_where_accepts_a_rust_type(pool: Pool) { // Arrange a fixture Order::factory() .status(Status::Pending) @@ -69,8 +69,8 @@ async fn test_where_accepts_a_rust_type(pool: Pool) { assert_eq!(result.iter().len(), 1); } -#[sqlx::test(migrations = "../migrations")] -async fn test_set_accepts_a_rust_type(pool: Pool) { +#[fabrique::test] +async fn test_set_accepts_a_rust_type(pool: Pool) { // Arrange a fixture let order = Order::factory().create(&pool).await.unwrap(); diff --git a/fabrique/tests/factory.rs b/fabrique/tests/factory.rs index 133a704..8fd29e4 100644 --- a/fabrique/tests/factory.rs +++ b/fabrique/tests/factory.rs @@ -47,8 +47,8 @@ pub struct OrderLine { pub unit_price_cents: i32, } -#[sqlx::test(migrations = "../migrations")] -async fn test_auto_creates_belongs_to(connection: Pool) { +#[fabrique::test] +async fn test_auto_creates_belongs_to(connection: Pool) { // Order has belongs_to User — create without for_user() let order = Order::factory().create(&connection).await.unwrap(); @@ -57,8 +57,8 @@ async fn test_auto_creates_belongs_to(connection: Pool) { assert_eq!(user.id, order.user_id); } -#[sqlx::test(migrations = "../migrations")] -async fn test_factory_for_relations_accept_models(connection: Pool) { +#[fabrique::test] +async fn test_factory_for_relations_accept_models(connection: Pool) { let user = User::factory().create(&connection).await.unwrap(); let product = Product::factory().create(&connection).await.unwrap(); let order = Order::factory() @@ -75,8 +75,8 @@ async fn test_factory_for_relations_accept_models(connection: Pool) { .unwrap(); } -#[sqlx::test(migrations = "../migrations")] -async fn test_factory_for_relations_accept_factories(connection: Pool) { +#[fabrique::test] +async fn test_factory_for_relations_accept_factories(connection: Pool) { OrderLine::factory() .for_order(Order::factory()) .for_product(Product::factory()) @@ -85,8 +85,8 @@ async fn test_factory_for_relations_accept_factories(connection: Pool) { .unwrap(); } -#[sqlx::test(migrations = "../migrations")] -async fn test_has_many_creates_children(connection: Pool) { +#[fabrique::test] +async fn test_has_many_creates_children(connection: Pool) { // Arrange a User with 1 Address via has_addresses (generated from Address's // belongs_to) let user = User::factory() @@ -107,8 +107,8 @@ async fn test_has_many_creates_children(connection: Pool) { assert_eq!(count.0, 1); } -#[sqlx::test(migrations = "../migrations")] -async fn test_has_many_through_join_model(connection: Pool) { +#[fabrique::test] +async fn test_has_many_through_join_model(connection: Pool) { // Arrange an Order with 1 OrderLine (which auto-creates a Product via // belongs_to) let order = Order::factory() diff --git a/fabrique/tests/has_many.rs b/fabrique/tests/has_many.rs index b1bce5e..60be7f9 100644 --- a/fabrique/tests/has_many.rs +++ b/fabrique/tests/has_many.rs @@ -23,8 +23,8 @@ pub struct Message { // messages::() is now auto-generated by Message's derive(Model) // via HasManyCodegen from the child side. -#[sqlx::test(migrations = "../migrations")] -async fn test_load_messages_by_alias(pool: Pool) { +#[fabrique::test] +async fn test_load_messages_by_alias(pool: Pool) { let sender = User::factory().create(&pool).await.unwrap(); let recipient = User::factory().create(&pool).await.unwrap(); diff --git a/fabrique/tests/model.rs b/fabrique/tests/model.rs index 6281362..67cd499 100644 --- a/fabrique/tests/model.rs +++ b/fabrique/tests/model.rs @@ -10,19 +10,19 @@ pub struct Product { pub in_stock: bool, } -#[sqlx::test(migrations = "../migrations")] -async fn test_save(connection: Pool) { - let result = Product::default().save(&connection).await; +#[fabrique::test] +async fn test_save(pool: Pool) { + let result = Product::default().save(&pool).await; assert!(result.is_ok()); } -#[sqlx::test(migrations = "../migrations")] -async fn test_update(connection: Pool) { - let product = Product::factory().create(&connection).await.unwrap(); +#[fabrique::test] +async fn test_update(pool: Pool) { + let product = Product::factory().create(&pool).await.unwrap(); let result = Product::update() .set(Product::NAME, "Anvil 3000") .r#where(Product::ID, "=", product.id) - .execute(&connection) + .execute(&pool) .await; assert!(result.is_ok()); } diff --git a/fabrique/tests/query_builder.rs b/fabrique/tests/query_builder.rs index b935b82..8f4a89c 100644 --- a/fabrique/tests/query_builder.rs +++ b/fabrique/tests/query_builder.rs @@ -75,16 +75,16 @@ mod initial { let _qb = QueryBuilder::<(), _, fabrique::model::Joined<(Product, ()), ()>>::default(); } - #[sqlx::test(migrations = "../migrations")] - async fn select_as_transitions_to_selected(pool: Pool) { + #[fabrique::test] + async fn select_as_transitions_to_selected(pool: Pool) { // select_as on Initial can only select the base model (no joins available) let result: Result, _> = Product::query().select_as::().get(&pool).await; assert!(result.is_ok()); } - #[sqlx::test(migrations = "../migrations")] - async fn insert_transitions_to_inserting(pool: Pool) { + #[fabrique::test] + async fn insert_transitions_to_inserting(pool: Pool) { let result = Product::insert() .set(Product::ID, Uuid::new_v4()) .set(Product::NAME, "Test") @@ -95,8 +95,8 @@ mod initial { assert!(result.is_ok()); } - #[sqlx::test(migrations = "../migrations")] - async fn update_transitions_to_updating(pool: Pool) { + #[fabrique::test] + async fn update_transitions_to_updating(pool: Pool) { Product::factory().create(&pool).await.expect("setup"); let result = Product::update() @@ -106,14 +106,14 @@ mod initial { assert!(result.is_ok()); } - #[sqlx::test(migrations = "../migrations")] - async fn join_transitions_to_joining(pool: Pool) { + #[fabrique::test] + async fn join_transitions_to_joining(pool: Pool) { let result: Result, _> = User::query().join::().get(&pool).await; assert!(result.is_ok()); } - #[sqlx::test(migrations = "../migrations")] - async fn select_columns_transitions_to_selected(pool: Pool) { + #[fabrique::test] + async fn select_columns_transitions_to_selected(pool: Pool) { let result: Result, _> = Product::query() .select((Product::NAME, Product::PRICE_CENTS)) .get(&pool) @@ -121,8 +121,8 @@ mod initial { assert!(result.is_ok()); } - #[sqlx::test(migrations = "../migrations")] - async fn where_implicit_select_transitions_to_filtered(pool: Pool) { + #[fabrique::test] + async fn where_implicit_select_transitions_to_filtered(pool: Pool) { let result: Result, _> = Product::query() .r#where(Product::PRICE_CENTS, ">=", 1000) .get(&pool) @@ -130,8 +130,8 @@ mod initial { assert!(result.is_ok()); } - #[sqlx::test(migrations = "../migrations")] - async fn where_not_null_implicit_select_transitions_to_filtered(pool: Pool) { + #[fabrique::test] + async fn where_not_null_implicit_select_transitions_to_filtered(pool: Pool) { let result: Result, _> = Product::query() .where_not_null(Product::NAME) .get(&pool) @@ -139,8 +139,8 @@ mod initial { assert!(result.is_ok()); } - #[sqlx::test(migrations = "../migrations")] - async fn order_by_implicit_select_transitions_to_ordered(pool: Pool) { + #[fabrique::test] + async fn order_by_implicit_select_transitions_to_ordered(pool: Pool) { let result: Result, _> = Product::query() .order_by(Product::NAME, "ASC") .get(&pool) @@ -148,28 +148,28 @@ mod initial { assert!(result.is_ok()); } - #[sqlx::test(migrations = "../migrations")] - async fn limit_implicit_select_transitions_to_limited(pool: Pool) { + #[fabrique::test] + async fn limit_implicit_select_transitions_to_limited(pool: Pool) { let result: Result, _> = Product::query().limit(10).get(&pool).await; assert!(result.is_ok()); } - #[sqlx::test(migrations = "../migrations")] - async fn first_implicit_select_executes(pool: Pool) { + #[fabrique::test] + async fn first_implicit_select_executes(pool: Pool) { let result: Result, _> = Product::query().first(&pool).await; assert!(result.is_ok()); } - #[sqlx::test(migrations = "../migrations")] - async fn first_or_fail_implicit_select_executes(pool: Pool) { + #[fabrique::test] + async fn first_or_fail_implicit_select_executes(pool: Pool) { Product::factory().create(&pool).await.expect("setup"); let result: Result = Product::query().first_or_fail(&pool).await; assert!(result.is_ok()); } - #[sqlx::test(migrations = "../migrations")] - async fn get_implicit_select_executes(pool: Pool) { + #[fabrique::test] + async fn get_implicit_select_executes(pool: Pool) { let result: Result, _> = Product::query().get(&pool).await; assert!(result.is_ok()); } @@ -182,8 +182,8 @@ mod initial { mod joining { use super::*; - #[sqlx::test(migrations = "../migrations")] - async fn join_chains(pool: Pool) { + #[fabrique::test] + async fn join_chains(pool: Pool) { let result: Result, _> = Order::query() .join::() .join::() @@ -192,8 +192,8 @@ mod joining { assert!(result.is_ok()); } - #[sqlx::test(migrations = "../migrations")] - async fn join_as_chains(pool: Pool) { + #[fabrique::test] + async fn join_as_chains(pool: Pool) { let result: Result, _> = Message::query() .join_as::() .join_as::() @@ -202,8 +202,8 @@ mod joining { assert!(result.is_ok()); } - #[sqlx::test(migrations = "../migrations")] - async fn join_through_chains(pool: Pool) { + #[fabrique::test] + async fn join_through_chains(pool: Pool) { let result: Result, _> = Order::query() .join::() .join_through::() @@ -212,8 +212,8 @@ mod joining { assert!(result.is_ok()); } - #[sqlx::test(migrations = "../migrations")] - async fn select_as_transitions_to_selected(pool: Pool) { + #[fabrique::test] + async fn select_as_transitions_to_selected(pool: Pool) { let result: Result, _> = User::query() .join::() .select_as::() @@ -222,8 +222,8 @@ mod joining { assert!(result.is_ok()); } - #[sqlx::test(migrations = "../migrations")] - async fn select_columns_transitions_to_selected(pool: Pool) { + #[fabrique::test] + async fn select_columns_transitions_to_selected(pool: Pool) { let result: Result, _> = Order::query() .join::() .join_through::() @@ -233,8 +233,8 @@ mod joining { assert!(result.is_ok()); } - #[sqlx::test(migrations = "../migrations")] - async fn where_implicit_select_transitions_to_filtered(pool: Pool) { + #[fabrique::test] + async fn where_implicit_select_transitions_to_filtered(pool: Pool) { let result: Result, _> = User::query() .join::() .r#where(User::EMAIL, "=", "test@example.com") @@ -243,8 +243,8 @@ mod joining { assert!(result.is_ok()); } - #[sqlx::test(migrations = "../migrations")] - async fn where_null_implicit_select_transitions_to_filtered(pool: Pool) { + #[fabrique::test] + async fn where_null_implicit_select_transitions_to_filtered(pool: Pool) { let result: Result, _> = User::query() .join::() .where_null(User::EMAIL) @@ -253,8 +253,8 @@ mod joining { assert!(result.is_ok()); } - #[sqlx::test(migrations = "../migrations")] - async fn where_not_null_implicit_select_transitions_to_filtered(pool: Pool) { + #[fabrique::test] + async fn where_not_null_implicit_select_transitions_to_filtered(pool: Pool) { let result: Result, _> = User::query() .join::() .where_not_null(User::EMAIL) @@ -263,8 +263,8 @@ mod joining { assert!(result.is_ok()); } - #[sqlx::test(migrations = "../migrations")] - async fn order_by_implicit_select_transitions_to_ordered(pool: Pool) { + #[fabrique::test] + async fn order_by_implicit_select_transitions_to_ordered(pool: Pool) { let result: Result, _> = User::query() .join::() .order_by(User::NAME, "ASC") @@ -273,20 +273,20 @@ mod joining { assert!(result.is_ok()); } - #[sqlx::test(migrations = "../migrations")] - async fn limit_implicit_select_transitions_to_limited(pool: Pool) { + #[fabrique::test] + async fn limit_implicit_select_transitions_to_limited(pool: Pool) { let result: Result, _> = User::query().join::().limit(10).get(&pool).await; assert!(result.is_ok()); } - #[sqlx::test(migrations = "../migrations")] - async fn first_implicit_select_executes(pool: Pool) { + #[fabrique::test] + async fn first_implicit_select_executes(pool: Pool) { let result: Result, _> = User::query().join::().first(&pool).await; assert!(result.is_ok()); } - #[sqlx::test(migrations = "../migrations")] - async fn first_or_fail_implicit_select_executes(pool: Pool) { + #[fabrique::test] + async fn first_or_fail_implicit_select_executes(pool: Pool) { User::factory() .has_orders(Order::factory(), 1) .create(&pool) @@ -297,14 +297,14 @@ mod joining { assert!(result.is_ok()); } - #[sqlx::test(migrations = "../migrations")] - async fn get_implicit_select_executes(pool: Pool) { + #[fabrique::test] + async fn get_implicit_select_executes(pool: Pool) { let result: Result, _> = User::query().join::().get(&pool).await; assert!(result.is_ok()); } - #[sqlx::test(migrations = "../migrations")] - async fn where_on_implicit_select_transitions_to_filtered(pool: Pool) { + #[fabrique::test] + async fn where_on_implicit_select_transitions_to_filtered(pool: Pool) { let result: Result, _> = Message::query() .join_as::() .where_on::(User::NAME, "=", "Alice".to_string()) @@ -313,8 +313,8 @@ mod joining { assert!(result.is_ok()); } - #[sqlx::test(migrations = "../migrations")] - async fn where_null_on_implicit_select_transitions_to_filtered(pool: Pool) { + #[fabrique::test] + async fn where_null_on_implicit_select_transitions_to_filtered(pool: Pool) { let result: Result, _> = Message::query() .join_as::() .where_null_on::(User::NAME) @@ -323,8 +323,10 @@ mod joining { assert!(result.is_ok()); } - #[sqlx::test(migrations = "../migrations")] - async fn where_not_null_on_implicit_select_transitions_to_filtered(pool: Pool) { + #[fabrique::test] + async fn where_not_null_on_implicit_select_transitions_to_filtered( + pool: Pool, + ) { let result: Result, _> = Message::query() .join_as::() .where_not_null_on::(User::NAME) @@ -333,8 +335,8 @@ mod joining { assert!(result.is_ok()); } - #[sqlx::test(migrations = "../migrations")] - async fn order_by_on_implicit_select_transitions_to_ordered(pool: Pool) { + #[fabrique::test] + async fn order_by_on_implicit_select_transitions_to_ordered(pool: Pool) { let result: Result, _> = Message::query() .join_as::() .order_by_on::(User::NAME, "ASC") @@ -351,8 +353,8 @@ mod joining { mod selected { use super::*; - #[sqlx::test(migrations = "../migrations")] - async fn where_transitions_to_filtered(pool: Pool) { + #[fabrique::test] + async fn where_transitions_to_filtered(pool: Pool) { Product::factory() .in_stock(true) .create(&pool) @@ -368,8 +370,8 @@ mod selected { assert_eq!(result.unwrap().len(), 1); } - #[sqlx::test(migrations = "../migrations")] - async fn where_null_transitions_to_filtered(pool: Pool) { + #[fabrique::test] + async fn where_null_transitions_to_filtered(pool: Pool) { let result: Result, _> = Product::query() .select_as::() .where_null(Product::NAME) @@ -378,8 +380,8 @@ mod selected { assert!(result.is_ok()); } - #[sqlx::test(migrations = "../migrations")] - async fn where_not_null_transitions_to_filtered(pool: Pool) { + #[fabrique::test] + async fn where_not_null_transitions_to_filtered(pool: Pool) { let result: Result, _> = Product::query() .select_as::() .where_not_null(Product::NAME) @@ -388,8 +390,8 @@ mod selected { assert!(result.is_ok()); } - #[sqlx::test(migrations = "../migrations")] - async fn order_by_transitions_to_ordered(pool: Pool) { + #[fabrique::test] + async fn order_by_transitions_to_ordered(pool: Pool) { let result: Result, _> = Product::query() .select_as::() .order_by(Product::NAME, "ASC") @@ -398,8 +400,8 @@ mod selected { assert!(result.is_ok()); } - #[sqlx::test(migrations = "../migrations")] - async fn limit_transitions_to_limited(pool: Pool) { + #[fabrique::test] + async fn limit_transitions_to_limited(pool: Pool) { let result: Result, _> = Product::query() .select_as::() .limit(10) @@ -408,8 +410,8 @@ mod selected { assert!(result.is_ok()); } - #[sqlx::test(migrations = "../migrations")] - async fn get_executes(pool: Pool) { + #[fabrique::test] + async fn get_executes(pool: Pool) { Product::factory().create(&pool).await.expect("setup"); let result: Result, _> = @@ -418,8 +420,8 @@ mod selected { assert_eq!(result.unwrap().len(), 1); } - #[sqlx::test(migrations = "../migrations")] - async fn first_executes(pool: Pool) { + #[fabrique::test] + async fn first_executes(pool: Pool) { let result: Result, _> = Product::query() .select_as::() .first(&pool) @@ -427,8 +429,8 @@ mod selected { assert!(result.is_ok()); } - #[sqlx::test(migrations = "../migrations")] - async fn first_or_fail_executes(pool: Pool) { + #[fabrique::test] + async fn first_or_fail_executes(pool: Pool) { Product::factory().create(&pool).await.expect("setup"); let result: Result = Product::query() @@ -438,8 +440,8 @@ mod selected { assert!(result.is_ok()); } - #[sqlx::test(migrations = "../migrations")] - async fn first_or_fail_fails_when_empty(pool: Pool) { + #[fabrique::test] + async fn first_or_fail_fails_when_empty(pool: Pool) { let result: Result = Product::query() .select_as::() .first_or_fail(&pool) @@ -455,8 +457,8 @@ mod selected { mod joined_selected { use super::*; - #[sqlx::test(migrations = "../migrations")] - async fn where_transitions_to_filtered(pool: Pool) { + #[fabrique::test] + async fn where_transitions_to_filtered(pool: Pool) { let user = User::factory() .has_orders(Order::factory(), 1) .create(&pool) @@ -473,8 +475,8 @@ mod joined_selected { assert_eq!(result.unwrap().len(), 1); } - #[sqlx::test(migrations = "../migrations")] - async fn where_on_joined_model_column(pool: Pool) { + #[fabrique::test] + async fn where_on_joined_model_column(pool: Pool) { User::factory() .has_orders(Order::factory().status("pending".to_string()), 1) .create(&pool) @@ -491,8 +493,8 @@ mod joined_selected { assert_eq!(result.unwrap().len(), 1); } - #[sqlx::test(migrations = "../migrations")] - async fn where_on_named_join(pool: Pool) { + #[fabrique::test] + async fn where_on_named_join(pool: Pool) { let result: Result, _> = Message::query() .join_as::() .select_as::() @@ -502,8 +504,8 @@ mod joined_selected { assert!(result.is_ok()); } - #[sqlx::test(migrations = "../migrations")] - async fn where_null_on_named_join(pool: Pool) { + #[fabrique::test] + async fn where_null_on_named_join(pool: Pool) { let result: Result, _> = Message::query() .join_as::() .select_as::() @@ -513,8 +515,8 @@ mod joined_selected { assert!(result.is_ok()); } - #[sqlx::test(migrations = "../migrations")] - async fn where_not_null_on_named_join(pool: Pool) { + #[fabrique::test] + async fn where_not_null_on_named_join(pool: Pool) { let result: Result, _> = Message::query() .join_as::() .select_as::() @@ -524,8 +526,8 @@ mod joined_selected { assert!(result.is_ok()); } - #[sqlx::test(migrations = "../migrations")] - async fn order_by_transitions_to_ordered(pool: Pool) { + #[fabrique::test] + async fn order_by_transitions_to_ordered(pool: Pool) { let result: Result, _> = User::query() .join::() .select_as::() @@ -535,8 +537,8 @@ mod joined_selected { assert!(result.is_ok()); } - #[sqlx::test(migrations = "../migrations")] - async fn order_by_on_named_join(pool: Pool) { + #[fabrique::test] + async fn order_by_on_named_join(pool: Pool) { let result: Result, _> = Message::query() .join_as::() .select_as::() @@ -546,8 +548,8 @@ mod joined_selected { assert!(result.is_ok()); } - #[sqlx::test(migrations = "../migrations")] - async fn limit_transitions_to_limited(pool: Pool) { + #[fabrique::test] + async fn limit_transitions_to_limited(pool: Pool) { let result: Result, _> = User::query() .join::() .select_as::() @@ -557,8 +559,8 @@ mod joined_selected { assert!(result.is_ok()); } - #[sqlx::test(migrations = "../migrations")] - async fn get_executes(pool: Pool) { + #[fabrique::test] + async fn get_executes(pool: Pool) { User::factory() .has_orders(Order::factory(), 1) .create(&pool) @@ -574,8 +576,8 @@ mod joined_selected { assert_eq!(result.unwrap().len(), 1); } - #[sqlx::test(migrations = "../migrations")] - async fn first_executes(pool: Pool) { + #[fabrique::test] + async fn first_executes(pool: Pool) { let result: Result, _> = User::query() .join::() .select_as::() @@ -584,8 +586,8 @@ mod joined_selected { assert!(result.is_ok()); } - #[sqlx::test(migrations = "../migrations")] - async fn first_or_fail_executes(pool: Pool) { + #[fabrique::test] + async fn first_or_fail_executes(pool: Pool) { User::factory() .has_orders(Order::factory(), 1) .create(&pool) @@ -608,8 +610,8 @@ mod joined_selected { mod filtered_selected { use super::*; - #[sqlx::test(migrations = "../migrations")] - async fn where_chains(pool: Pool) { + #[fabrique::test] + async fn where_chains(pool: Pool) { Product::factory() .in_stock(true) .price_cents(100) @@ -627,8 +629,8 @@ mod filtered_selected { assert_eq!(result.unwrap().len(), 1); } - #[sqlx::test(migrations = "../migrations")] - async fn where_null_chains(pool: Pool) { + #[fabrique::test] + async fn where_null_chains(pool: Pool) { let result: Result, _> = Product::query() .select_as::() .r#where(Product::IN_STOCK, "=", true) @@ -638,8 +640,8 @@ mod filtered_selected { assert!(result.is_ok()); } - #[sqlx::test(migrations = "../migrations")] - async fn where_not_null_chains(pool: Pool) { + #[fabrique::test] + async fn where_not_null_chains(pool: Pool) { let result: Result, _> = Product::query() .select_as::() .r#where(Product::IN_STOCK, "=", true) @@ -649,8 +651,8 @@ mod filtered_selected { assert!(result.is_ok()); } - #[sqlx::test(migrations = "../migrations")] - async fn order_by_transitions_to_ordered(pool: Pool) { + #[fabrique::test] + async fn order_by_transitions_to_ordered(pool: Pool) { let result: Result, _> = Product::query() .select_as::() .r#where(Product::IN_STOCK, "=", true) @@ -660,8 +662,8 @@ mod filtered_selected { assert!(result.is_ok()); } - #[sqlx::test(migrations = "../migrations")] - async fn limit_transitions_to_limited(pool: Pool) { + #[fabrique::test] + async fn limit_transitions_to_limited(pool: Pool) { let result: Result, _> = Product::query() .select_as::() .r#where(Product::IN_STOCK, "=", true) @@ -671,8 +673,8 @@ mod filtered_selected { assert!(result.is_ok()); } - #[sqlx::test(migrations = "../migrations")] - async fn get_executes(pool: Pool) { + #[fabrique::test] + async fn get_executes(pool: Pool) { Product::factory() .in_stock(true) .create(&pool) @@ -688,8 +690,8 @@ mod filtered_selected { assert_eq!(result.unwrap().len(), 1); } - #[sqlx::test(migrations = "../migrations")] - async fn first_executes(pool: Pool) { + #[fabrique::test] + async fn first_executes(pool: Pool) { let result: Result, _> = Product::query() .select_as::() .r#where(Product::IN_STOCK, "=", true) @@ -698,8 +700,8 @@ mod filtered_selected { assert!(result.is_ok()); } - #[sqlx::test(migrations = "../migrations")] - async fn first_or_fail_executes(pool: Pool) { + #[fabrique::test] + async fn first_or_fail_executes(pool: Pool) { Product::factory() .in_stock(true) .create(&pool) @@ -722,8 +724,8 @@ mod filtered_selected { mod ordered { use super::*; - #[sqlx::test(migrations = "../migrations")] - async fn limit_transitions_to_limited(pool: Pool) { + #[fabrique::test] + async fn limit_transitions_to_limited(pool: Pool) { let result: Result, _> = Product::query() .select_as::() .order_by(Product::NAME, "ASC") @@ -733,8 +735,8 @@ mod ordered { assert!(result.is_ok()); } - #[sqlx::test(migrations = "../migrations")] - async fn get_executes(pool: Pool) { + #[fabrique::test] + async fn get_executes(pool: Pool) { Product::factory().create(&pool).await.expect("setup"); let result: Result, _> = Product::query() @@ -746,8 +748,8 @@ mod ordered { assert_eq!(result.unwrap().len(), 1); } - #[sqlx::test(migrations = "../migrations")] - async fn first_executes(pool: Pool) { + #[fabrique::test] + async fn first_executes(pool: Pool) { let result: Result, _> = Product::query() .select_as::() .order_by(Product::NAME, "ASC") @@ -756,8 +758,8 @@ mod ordered { assert!(result.is_ok()); } - #[sqlx::test(migrations = "../migrations")] - async fn first_or_fail_executes(pool: Pool) { + #[fabrique::test] + async fn first_or_fail_executes(pool: Pool) { Product::factory().create(&pool).await.expect("setup"); let result: Result = Product::query() @@ -776,8 +778,8 @@ mod ordered { mod limited { use super::*; - #[sqlx::test(migrations = "../migrations")] - async fn offset_transitions_to_offsetted(pool: Pool) { + #[fabrique::test] + async fn offset_transitions_to_offsetted(pool: Pool) { let result: Result, _> = Product::query() .select_as::() .limit(10) @@ -787,8 +789,8 @@ mod limited { assert!(result.is_ok()); } - #[sqlx::test(migrations = "../migrations")] - async fn get_executes(pool: Pool) { + #[fabrique::test] + async fn get_executes(pool: Pool) { Product::factory().create(&pool).await.expect("setup"); let result: Result, _> = Product::query() @@ -808,8 +810,8 @@ mod limited { mod offsetted { use super::*; - #[sqlx::test(migrations = "../migrations")] - async fn get_executes(pool: Pool) { + #[fabrique::test] + async fn get_executes(pool: Pool) { for _ in 0..3 { Product::factory().create(&pool).await.expect("setup"); } @@ -838,8 +840,8 @@ mod offsetted { mod updating { use super::*; - #[sqlx::test(migrations = "../migrations")] - async fn set_transitions_to_updated(pool: Pool) { + #[fabrique::test] + async fn set_transitions_to_updated(pool: Pool) { Product::factory().create(&pool).await.expect("setup"); let result = Product::update() @@ -857,8 +859,8 @@ mod updating { mod updated { use super::*; - #[sqlx::test(migrations = "../migrations")] - async fn set_chains(pool: Pool) { + #[fabrique::test] + async fn set_chains(pool: Pool) { Product::factory().create(&pool).await.expect("setup"); let result = Product::update() @@ -869,8 +871,8 @@ mod updated { assert!(result.is_ok()); } - #[sqlx::test(migrations = "../migrations")] - async fn where_transitions_to_filtered(pool: Pool) { + #[fabrique::test] + async fn where_transitions_to_filtered(pool: Pool) { let product = Product::factory().create(&pool).await.expect("setup"); let result = Product::update() @@ -881,7 +883,8 @@ mod updated { assert!(result.is_ok()); } - #[sqlx::test(migrations = "../migrations")] + // MySQL does not support RETURNING — keep this test sqlite-only. + #[fabrique::test] async fn returning_transitions_to_returned(pool: Pool) { Product::factory().create(&pool).await.expect("setup"); @@ -904,8 +907,8 @@ mod updated { mod filtered_updated { use super::*; - #[sqlx::test(migrations = "../migrations")] - async fn where_chains(pool: Pool) { + #[fabrique::test] + async fn where_chains(pool: Pool) { let product = Product::factory() .in_stock(true) .create(&pool) @@ -921,7 +924,8 @@ mod filtered_updated { assert!(result.is_ok()); } - #[sqlx::test(migrations = "../migrations")] + // MySQL does not support RETURNING — keep this test sqlite-only. + #[fabrique::test] async fn returning_transitions_to_returned(pool: Pool) { let product = Product::factory().create(&pool).await.expect("setup"); @@ -937,8 +941,8 @@ mod filtered_updated { assert_eq!(updated[0].name, "Updated"); } - #[sqlx::test(migrations = "../migrations")] - async fn execute_executes(pool: Pool) { + #[fabrique::test] + async fn execute_executes(pool: Pool) { let product = Product::factory().create(&pool).await.expect("setup"); let result = Product::update() @@ -963,8 +967,8 @@ mod filtered_updated { mod inserting { use super::*; - #[sqlx::test(migrations = "../migrations")] - async fn set_transitions_to_inserted(pool: Pool) { + #[fabrique::test] + async fn set_transitions_to_inserted(pool: Pool) { let result = Product::insert() .set(Product::ID, Uuid::new_v4()) .set(Product::NAME, "Test") @@ -983,8 +987,8 @@ mod inserting { mod inserted { use super::*; - #[sqlx::test(migrations = "../migrations")] - async fn set_chains(pool: Pool) { + #[fabrique::test] + async fn set_chains(pool: Pool) { let result = Product::insert() .set(Product::ID, Uuid::new_v4()) .set(Product::NAME, "Test") @@ -995,8 +999,8 @@ mod inserted { assert!(result.is_ok()); } - #[sqlx::test(migrations = "../migrations")] - async fn on_conflict_transitions_to_conflicted(pool: Pool) { + #[fabrique::test] + async fn on_conflict_transitions_to_conflicted(pool: Pool) { let id = Uuid::new_v4(); // First insert @@ -1019,7 +1023,8 @@ mod inserted { assert!(result.is_ok()); } - #[sqlx::test(migrations = "../migrations")] + // MySQL does not support RETURNING — keep this test sqlite-only. + #[fabrique::test] async fn returning_transitions_to_returned(pool: Pool) { let result: Result, _> = Product::insert() .set(Product::ID, Uuid::new_v4()) @@ -1033,8 +1038,8 @@ mod inserted { assert!(result.unwrap().is_some()); } - #[sqlx::test(migrations = "../migrations")] - async fn execute_executes(pool: Pool) { + #[fabrique::test] + async fn execute_executes(pool: Pool) { let result = Product::insert() .set(Product::ID, Uuid::new_v4()) .set(Product::NAME, "Original") @@ -1053,7 +1058,8 @@ mod inserted { mod conflicted { use super::*; - #[sqlx::test(migrations = "../migrations")] + // MySQL does not support RETURNING — keep this test sqlite-only. + #[fabrique::test] async fn do_update_transitions_to_upserted(pool: Pool) { let id = Uuid::new_v4(); @@ -1081,8 +1087,8 @@ mod conflicted { assert_eq!(updated[0].name, "Updated"); } - #[sqlx::test(migrations = "../migrations")] - async fn do_nothing_transitions_to_upserted(pool: Pool) { + #[fabrique::test] + async fn do_nothing_transitions_to_upserted(pool: Pool) { let id = Uuid::new_v4(); // First insert @@ -1113,7 +1119,8 @@ mod conflicted { mod upserted { use super::*; - #[sqlx::test(migrations = "../migrations")] + // MySQL does not support RETURNING — keep this test sqlite-only. + #[fabrique::test] async fn returning_transitions_to_returned(pool: Pool) { let id = Uuid::new_v4(); @@ -1139,8 +1146,8 @@ mod upserted { assert_eq!(result.unwrap().len(), 1); } - #[sqlx::test(migrations = "../migrations")] - async fn execute_executes(pool: Pool) { + #[fabrique::test] + async fn execute_executes(pool: Pool) { let id = Uuid::new_v4(); // First insert @@ -1174,10 +1181,11 @@ mod upserted { // Returned // ============================================================================ +// MySQL does not support RETURNING — keep these tests sqlite-only. mod returned { use super::*; - #[sqlx::test(migrations = "../migrations")] + #[fabrique::test] async fn get_executes(pool: Pool) { let result: Result, _> = Product::insert() .set(Product::ID, Uuid::new_v4()) @@ -1191,7 +1199,7 @@ mod returned { assert_eq!(result.unwrap().len(), 1); } - #[sqlx::test(migrations = "../migrations")] + #[fabrique::test] async fn first_executes(pool: Pool) { let result: Result, _> = Product::insert() .set(Product::ID, Uuid::new_v4()) @@ -1205,7 +1213,7 @@ mod returned { assert!(result.unwrap().is_some()); } - #[sqlx::test(migrations = "../migrations")] + #[fabrique::test] async fn first_or_fail_executes(pool: Pool) { let result: Result = Product::insert() .set(Product::ID, Uuid::new_v4()) diff --git a/fabrique/tests/soft_delete.rs b/fabrique/tests/soft_delete.rs index 9afcbee..e480f43 100644 --- a/fabrique/tests/soft_delete.rs +++ b/fabrique/tests/soft_delete.rs @@ -11,8 +11,8 @@ pub struct User { pub deleted_at: Option>, } -#[sqlx::test(migrations = "../migrations")] -async fn test_soft_delete(connection: Pool) { +#[fabrique::test] +async fn test_soft_delete(connection: Pool) { // Create a new row let id = Uuid::new_v4(); let user = User::factory() diff --git a/fabrique/tests/test.rs b/fabrique/tests/test.rs index 9dcec90..5dfb594 100644 --- a/fabrique/tests/test.rs +++ b/fabrique/tests/test.rs @@ -10,15 +10,15 @@ pub struct Product { pub in_stock: bool, } -#[sqlx::test(migrations = "../migrations")] -async fn test_persistable_macro_compiles(connection: Pool) { +#[fabrique::test] +async fn test_persistable_macro_compiles(connection: Pool) { let result = Product::all(&connection).await; assert!(result.is_ok()); assert_eq!(result.unwrap().len(), 0); } -#[sqlx::test(migrations = "../migrations")] -async fn test_create(connection: Pool) { +#[fabrique::test] +async fn test_create(connection: Pool) { let result = Product::factory() .name("Anvil 3000".to_owned()) .price_cents(9999) @@ -33,8 +33,8 @@ async fn test_create(connection: Pool) { assert!(product.in_stock); } -#[sqlx::test(migrations = "../migrations")] -async fn test_delete(connection: Pool) { +#[fabrique::test] +async fn test_delete(connection: Pool) { let product = Product::factory().create(&connection).await.unwrap(); let existing = Product::all(&connection).await.unwrap(); assert!(!existing.is_empty()); @@ -44,8 +44,8 @@ async fn test_delete(connection: Pool) { assert!(existing.is_empty()); } -#[sqlx::test(migrations = "../migrations")] -async fn test_destroy(connection: Pool) { +#[fabrique::test] +async fn test_destroy(connection: Pool) { let id = Uuid::new_v4(); Product::factory().id(id).create(&connection).await.unwrap(); let result = Product::destroy(&connection, id).await; @@ -54,8 +54,8 @@ async fn test_destroy(connection: Pool) { assert_eq!(products.len(), 0); } -#[sqlx::test(migrations = "../migrations")] -async fn test_all(connection: Pool) { +#[fabrique::test] +async fn test_all(connection: Pool) { let product = Product::factory().create(&connection).await.unwrap(); let result = Product::all(&connection).await; @@ -63,8 +63,8 @@ async fn test_all(connection: Pool) { assert_eq!(result.unwrap(), vec![product]); } -#[sqlx::test(migrations = "../migrations")] -async fn test_query_builder(connection: Pool) { +#[fabrique::test] +async fn test_query_builder(connection: Pool) { Product::factory() .name("Anvil 3000".to_owned()) .price_cents(9999) diff --git a/release-plz.toml b/release-plz.toml index adf0a3c..9aa6ab5 100644 --- a/release-plz.toml +++ b/release-plz.toml @@ -6,14 +6,14 @@ changelog_path = "./CHANGELOG.md" name = "fabrique" changelog_update = true version_group = "fabrique" -publish_features = ["sqlite", "testing"] +publish_all_features = true [[package]] name = "fabrique-core" version_group = "fabrique" -publish_features = ["sqlite"] +publish_all_features = true [[package]] name = "fabrique-derive" version_group = "fabrique" -publish_features = ["sqlite", "testing"] +publish_all_features = true