diff --git a/sqlx-macros-core/src/test_attr.rs b/sqlx-macros-core/src/test_attr.rs index 01eca812a2..f3bd53c56d 100644 --- a/sqlx-macros-core/src/test_attr.rs +++ b/sqlx-macros-core/src/test_attr.rs @@ -3,10 +3,18 @@ use quote::quote; #[cfg(feature = "migrate")] struct Args { - fixtures: Vec, + fixtures: Vec<(FixturesType, Vec)>, migrations: MigrationsOpt, } +#[cfg(feature = "migrate")] +enum FixturesType { + None, + RelativePath, + CustomRelativePath(syn::LitStr), + ExplicitPath, +} + #[cfg(feature = "migrate")] enum MigrationsOpt { InferredPath, @@ -73,16 +81,59 @@ fn expand_advanced(args: syn::AttributeArgs, input: syn::ItemFn) -> crate::Resul let fn_arg_types = inputs.iter().map(|_| quote! { _ }); - let fixtures = args.fixtures.into_iter().map(|fixture| { - let path = format!("fixtures/{}.sql", fixture.value()); + let mut fixtures = Vec::new(); - quote! { - ::sqlx::testing::TestFixture { - path: #path, - contents: include_str!(#path), - } - } - }); + for (fixture_type, fixtures_local) in args.fixtures { + let mut res = match fixture_type { + FixturesType::None => vec![], + FixturesType::RelativePath => fixtures_local + .into_iter() + .map(|fixture| { + let mut fixture_str = fixture.value(); + add_sql_extension_if_missing(&mut fixture_str); + + let path = format!("fixtures/{}", fixture_str); + + quote! { + ::sqlx::testing::TestFixture { + path: #path, + contents: include_str!(#path), + } + } + }) + .collect(), + FixturesType::CustomRelativePath(path) => fixtures_local + .into_iter() + .map(|fixture| { + let mut fixture_str = fixture.value(); + add_sql_extension_if_missing(&mut fixture_str); + + let path = format!("{}/{}", path.value(), fixture_str); + + quote! { + ::sqlx::testing::TestFixture { + path: #path, + contents: include_str!(#path), + } + } + }) + .collect(), + FixturesType::ExplicitPath => fixtures_local + .into_iter() + .map(|fixture| { + let path = fixture.value(); + + quote! { + ::sqlx::testing::TestFixture { + path: #path, + contents: include_str!(#path), + } + } + }) + .collect(), + }; + fixtures.append(&mut res) + } let migrations = match args.migrations { MigrationsOpt::ExplicitPath(path) => { @@ -130,24 +181,37 @@ fn expand_advanced(args: syn::AttributeArgs, input: syn::ItemFn) -> crate::Resul #[cfg(feature = "migrate")] fn parse_args(attr_args: syn::AttributeArgs) -> syn::Result { - let mut fixtures = vec![]; + let mut fixtures = Vec::new(); let mut migrations = MigrationsOpt::InferredPath; for arg in attr_args { match arg { syn::NestedMeta::Meta(syn::Meta::List(list)) if list.path.is_ident("fixtures") => { - if !fixtures.is_empty() { - return Err(syn::Error::new_spanned(list, "duplicate `fixtures` arg")); - } + let mut fixtures_local = vec![]; + let mut fixtures_type = FixturesType::None; for nested in list.nested { match nested { - syn::NestedMeta::Lit(syn::Lit::Str(litstr)) => fixtures.push(litstr), + syn::NestedMeta::Lit(syn::Lit::Str(litstr)) => { + // fixtures("","") or fixtures("","") + parse_fixtures_args(&mut fixtures_type, litstr, &mut fixtures_local)?; + }, + syn::NestedMeta::Meta(syn::Meta::NameValue(namevalue)) + if namevalue.path.is_ident("path") => + { + // fixtures(path = "", scripts("","")) checking `path` argument + parse_fixtures_path_args(&mut fixtures_type, namevalue)?; + }, + syn::NestedMeta::Meta(syn::Meta::List(list)) if list.path.is_ident("scripts") => { + // fixtures(path = "", scripts("","")) checking `scripts` argument + parse_fixtures_scripts_args(&mut fixtures_type, list, &mut fixtures_local)?; + } other => { return Err(syn::Error::new_spanned(other, "expected string literal")) } - } + }; } + fixtures.push((fixtures_type, fixtures_local)); } syn::NestedMeta::Meta(syn::Meta::NameValue(namevalue)) if namevalue.path.is_ident("migrations") => @@ -217,3 +281,107 @@ fn parse_args(attr_args: syn::AttributeArgs) -> syn::Result { migrations, }) } + +#[cfg(feature = "migrate")] +fn parse_fixtures_args( + fixtures_type: &mut FixturesType, + litstr: syn::LitStr, + fixtures_local: &mut Vec, +) -> syn::Result<()> { + // fixtures(path = "", scripts("","")) checking `path` argument + let path_str = litstr.value(); + let path = std::path::Path::new(&path_str); + // This will be `true` if there's at least one path separator (`/` or `\`) + // It's also true for all absolute paths, even e.g. `/foo.sql` as the root directory is counted as a component. + let is_explicit_path = path.components().count() > 1; + match fixtures_type { + FixturesType::None => { + if is_explicit_path { + *fixtures_type = FixturesType::ExplicitPath; + } else { + *fixtures_type = FixturesType::RelativePath; + } + } + FixturesType::RelativePath => { + if is_explicit_path { + return Err(syn::Error::new_spanned( + litstr, + "expected only relative path fixtures", + )); + } + } + FixturesType::ExplicitPath => { + if !is_explicit_path { + return Err(syn::Error::new_spanned( + litstr, + "expected only explicit path fixtures", + )); + } + } + FixturesType::CustomRelativePath(_) => { + return Err(syn::Error::new_spanned( + litstr, + "custom relative path fixtures must be defined in `scripts` argument", + )) + } + } + if (matches!(fixtures_type, FixturesType::ExplicitPath) && !is_explicit_path) { + return Err(syn::Error::new_spanned( + litstr, + "expected explicit path fixtures to have `.sql` extension", + )); + } + fixtures_local.push(litstr); + Ok(()) +} + +#[cfg(feature = "migrate")] +fn parse_fixtures_path_args( + fixtures_type: &mut FixturesType, + namevalue: syn::MetaNameValue, +) -> syn::Result<()> { + // fixtures(path = "", scripts("","")) checking `path` argument + if !matches!(fixtures_type, FixturesType::None) { + return Err(syn::Error::new_spanned( + namevalue, + "`path` must be the first argument of `fixtures`", + )); + } + *fixtures_type = match namevalue.lit { + // path = "" + syn::Lit::Str(litstr) => FixturesType::CustomRelativePath(litstr), + _ => return Err(syn::Error::new_spanned(namevalue, "expected string")), + }; + Ok(()) +} + +#[cfg(feature = "migrate")] +fn parse_fixtures_scripts_args( + fixtures_type: &mut FixturesType, + list: syn::MetaList, + fixtures_local: &mut Vec, +) -> syn::Result<()> { + // fixtures(path = "", scripts("","")) checking `scripts` argument + if !matches!(fixtures_type, FixturesType::CustomRelativePath(_)) { + return Err(syn::Error::new_spanned( + list, + "`scripts` must be the second argument of `fixtures` and used together with `path`", + )); + } + for nested in list.nested { + let litstr = match nested { + syn::NestedMeta::Lit(syn::Lit::Str(litstr)) => litstr, + other => return Err(syn::Error::new_spanned(other, "expected string literal")), + }; + fixtures_local.push(litstr); + } + Ok(()) +} + +#[cfg(feature = "migrate")] +fn add_sql_extension_if_missing(fixture: &mut String) { + let has_extension = std::path::Path::new(&fixture).extension().is_some(); + if !has_extension { + fixture.push_str(".sql") + } +} diff --git a/src/macros/test.md b/src/macros/test.md index 720fe51c91..5336aced69 100644 --- a/src/macros/test.md +++ b/src/macros/test.md @@ -185,7 +185,13 @@ similarly to migrations but are solely intended to insert test data and be arbit Imagine a basic social app that has users, posts and comments. To test the comment routes, you'd want the database to already have users and posts in it so the comments tests don't have to duplicate that work. -You can pass a list of fixture names to the attribute like so, and they will be applied in the given order3: +You can either pass a list of fixture to the attribute `fixtures` in three different operating modes: + +1) Pass a list of references files in `./fixtures` (resolved as `./fixtures/{name}.sql`, `.sql` added only if extension is missing); +2) Pass a list of file paths (including associated extension), in which case they can either be absolute, or relative to the current file; +3) Pass a `path = ` parameter and a `scripts(, , ...)` parameter that are relative to the provided path (resolved as `{path}/{filename_x}.sql`, `.sql` added only if extension is missing). + +In any case they will be applied in the given order3: ```rust,no_run # #[cfg(all(feature = "migrate", feature = "postgres"))] @@ -195,6 +201,10 @@ You can pass a list of fixture names to the attribute like so, and they will be use sqlx::PgPool; use serde_json::json; +// Alternatives: +// #[sqlx::test(fixtures("./fixtures/users.sql", "./fixtures/users.sql"))] +// or +// #[sqlx::test(fixtures(path = "./fixtures", scripts("users", "posts")))] #[sqlx::test(fixtures("users", "posts"))] async fn test_create_comment(pool: PgPool) -> sqlx::Result<()> { // See examples/postgres/social-axum-with-tests for a more in-depth example. @@ -211,7 +221,7 @@ async fn test_create_comment(pool: PgPool) -> sqlx::Result<()> { # } ``` -Fixtures are resolved relative to the current file as `./fixtures/{name}.sql`. +Multiple `fixtures` attributes can be used to combine different operating modes. 3Ordering for test fixtures is entirely up to the application, and each test may choose which fixtures to apply and which to omit. However, since each fixture is applied separately (sent as a single command string, so wrapped diff --git a/tests/fixtures/mysql/posts.sql b/tests/fixtures/mysql/posts.sql new file mode 100644 index 0000000000..d692f3a1bd --- /dev/null +++ b/tests/fixtures/mysql/posts.sql @@ -0,0 +1,9 @@ +insert into post(post_id, user_id, content, created_at) +values (1, + 1, + 'This new computer is lightning-fast!', + timestamp(now(), '-1:00:00')), + (2, + 2, + '@alice is a haxxor :(', + timestamp(now(), '-0:30:00')); diff --git a/tests/fixtures/mysql/users.sql b/tests/fixtures/mysql/users.sql new file mode 100644 index 0000000000..9c4813c027 --- /dev/null +++ b/tests/fixtures/mysql/users.sql @@ -0,0 +1,2 @@ +insert into user(user_id, username) +values (1, 'alice'), (2, 'bob'); diff --git a/tests/fixtures/postgres/posts.sql b/tests/fixtures/postgres/posts.sql new file mode 100644 index 0000000000..b563ec0839 --- /dev/null +++ b/tests/fixtures/postgres/posts.sql @@ -0,0 +1,14 @@ +insert into post(post_id, user_id, content, created_at) +values + ( + '252c1d98-a9b0-4f18-8298-e59058bdfe16', + '6592b7c0-b531-4613-ace5-94246b7ce0c3', + 'This new computer is lightning-fast!', + now() + '1 hour ago'::interval + ), + ( + '844265f7-2472-4689-9a2e-b21f40dbf401', + '6592b7c0-b531-4613-ace5-94246b7ce0c3', + '@alice is a haxxor :(', + now() + '30 minutes ago'::interval + ); diff --git a/tests/fixtures/postgres/users.sql b/tests/fixtures/postgres/users.sql new file mode 100644 index 0000000000..571fb829ed --- /dev/null +++ b/tests/fixtures/postgres/users.sql @@ -0,0 +1,2 @@ +insert into "user"(user_id, username) +values ('6592b7c0-b531-4613-ace5-94246b7ce0c3', 'alice'), ('297923c5-a83c-4052-bab0-030887154e52', 'bob'); diff --git a/tests/mysql/test-attr.rs b/tests/mysql/test-attr.rs index e5e21e7a39..158be8f816 100644 --- a/tests/mysql/test-attr.rs +++ b/tests/mysql/test-attr.rs @@ -70,6 +70,88 @@ async fn it_gets_posts(pool: MySqlPool) -> sqlx::Result<()> { Ok(()) } +#[sqlx::test( + migrations = "tests/mysql/migrations", + fixtures("../fixtures/mysql/users.sql", "../fixtures/mysql/posts.sql") +)] +async fn it_gets_posts_explicit_fixtures_path(pool: MySqlPool) -> sqlx::Result<()> { + let post_contents: Vec = + sqlx::query_scalar("SELECT content FROM post ORDER BY created_at") + .fetch_all(&pool) + .await?; + + assert_eq!( + post_contents, + [ + "This new computer is lightning-fast!", + "@alice is a haxxor :(" + ] + ); + + let comment_exists: bool = sqlx::query_scalar("SELECT exists(SELECT 1 FROM comment)") + .fetch_one(&pool) + .await?; + + assert!(!comment_exists); + + Ok(()) +} + +#[sqlx::test( + migrations = "tests/mysql/migrations", + fixtures("../fixtures/mysql/users.sql"), + fixtures("posts") +)] +async fn it_gets_posts_mixed_fixtures_path(pool: MySqlPool) -> sqlx::Result<()> { + let post_contents: Vec = + sqlx::query_scalar("SELECT content FROM post ORDER BY created_at") + .fetch_all(&pool) + .await?; + + assert_eq!( + post_contents, + [ + "This new computer is lightning-fast!", + "@alice is a haxxor :(" + ] + ); + + let comment_exists: bool = sqlx::query_scalar("SELECT exists(SELECT 1 FROM comment)") + .fetch_one(&pool) + .await?; + + assert!(!comment_exists); + + Ok(()) +} + +#[sqlx::test( + migrations = "tests/mysql/migrations", + fixtures(path = "../fixtures/mysql", scripts("users", "posts")) +)] +async fn it_gets_posts_custom_relative_fixtures_path(pool: MySqlPool) -> sqlx::Result<()> { + let post_contents: Vec = + sqlx::query_scalar("SELECT content FROM post ORDER BY created_at") + .fetch_all(&pool) + .await?; + + assert_eq!( + post_contents, + [ + "This new computer is lightning-fast!", + "@alice is a haxxor :(" + ] + ); + + let comment_exists: bool = sqlx::query_scalar("SELECT exists(SELECT 1 FROM comment)") + .fetch_one(&pool) + .await?; + + assert!(!comment_exists); + + Ok(()) +} + // Try `migrator` #[sqlx::test(migrator = "MIGRATOR", fixtures("users", "posts", "comments"))] async fn it_gets_comments(pool: MySqlPool) -> sqlx::Result<()> { diff --git a/tests/postgres/test-attr.rs b/tests/postgres/test-attr.rs index b58012c642..070262c93d 100644 --- a/tests/postgres/test-attr.rs +++ b/tests/postgres/test-attr.rs @@ -42,6 +42,7 @@ async fn it_gets_users(pool: PgPool) -> sqlx::Result<()> { Ok(()) } +// This should apply migrations and then fixtures `fixtures/users.sql` and `fixtures/posts.sql` #[sqlx::test(migrations = "tests/postgres/migrations", fixtures("users", "posts"))] async fn it_gets_posts(pool: PgPool) -> sqlx::Result<()> { let post_contents: Vec = @@ -66,6 +67,91 @@ async fn it_gets_posts(pool: PgPool) -> sqlx::Result<()> { Ok(()) } +// This should apply migrations and then `../fixtures/postgres/users.sql` and `../fixtures/postgres/posts.sql` +#[sqlx::test( + migrations = "tests/postgres/migrations", + fixtures("../fixtures/postgres/users.sql", "../fixtures/postgres/posts.sql") +)] +async fn it_gets_posts_explicit_fixtures_path(pool: PgPool) -> sqlx::Result<()> { + let post_contents: Vec = + sqlx::query_scalar("SELECT content FROM post ORDER BY created_at") + .fetch_all(&pool) + .await?; + + assert_eq!( + post_contents, + [ + "This new computer is lightning-fast!", + "@alice is a haxxor :(" + ] + ); + + let comment_exists: bool = sqlx::query_scalar("SELECT exists(SELECT 1 FROM comment)") + .fetch_one(&pool) + .await?; + + assert!(!comment_exists); + + Ok(()) +} + +// This should apply migrations and then `../fixtures/postgres/users.sql` and `fixtures/posts.sql` +#[sqlx::test( + migrations = "tests/postgres/migrations", + fixtures("../fixtures/postgres/users.sql"), + fixtures("posts") +)] +async fn it_gets_posts_mixed_fixtures_path(pool: PgPool) -> sqlx::Result<()> { + let post_contents: Vec = + sqlx::query_scalar("SELECT content FROM post ORDER BY created_at") + .fetch_all(&pool) + .await?; + + assert_eq!( + post_contents, + [ + "This new computer is lightning-fast!", + "@alice is a haxxor :(" + ] + ); + + let comment_exists: bool = sqlx::query_scalar("SELECT exists(SELECT 1 FROM comment)") + .fetch_one(&pool) + .await?; + + assert!(!comment_exists); + + Ok(()) +} + +// This should apply migrations and then `../fixtures/postgres/users.sql` and `../fixtures/postgres/posts.sql` +#[sqlx::test( + migrations = "tests/postgres/migrations", + fixtures(path = "../fixtures/postgres", scripts("users.sql", "posts")) +)] +async fn it_gets_posts_custom_relative_fixtures_path(pool: PgPool) -> sqlx::Result<()> { + let post_contents: Vec = + sqlx::query_scalar("SELECT content FROM post ORDER BY created_at") + .fetch_all(&pool) + .await?; + + assert_eq!( + post_contents, + [ + "This new computer is lightning-fast!", + "@alice is a haxxor :(" + ] + ); + + let comment_exists: bool = sqlx::query_scalar("SELECT exists(SELECT 1 FROM comment)") + .fetch_one(&pool) + .await?; + + assert!(!comment_exists); + + Ok(()) +} + // Try `migrator` #[sqlx::test(migrator = "MIGRATOR", fixtures("users", "posts", "comments"))] async fn it_gets_comments(pool: PgPool) -> sqlx::Result<()> {