Add fixtures_path in sqlx::test args (#2545)

* feat: add fixtures_path

* test: add test for fixtures_path

* docs: expand test docs with fixtures_path

* test: add new test instead of co-opting and old one.

* feat: add explicit path operating mode for fixtures parameters and allow combining multiple fixtures parameters

* fix: require .sql extension for explicit path fixtures

* feat: add custom relative path style to fixtures argument

* fix: missing cfg feature

* docs: update

* fix: explicit fixtures styling checks for paths. Remove strict sql extension requirement for explicit path, they still need an extension. Add .sql extension to implicit fixtures style only if missing.

* style: cargo fmt

* docs: update documentation
This commit is contained in:
Roberto Ripamonti 2023-11-16 01:08:24 +01:00 committed by GitHub
parent 9a6ebd0a74
commit 16eeea8611
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 391 additions and 18 deletions

View file

@ -3,10 +3,18 @@ use quote::quote;
#[cfg(feature = "migrate")]
struct Args {
fixtures: Vec<syn::LitStr>,
fixtures: Vec<(FixturesType, Vec<syn::LitStr>)>,
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<Args> {
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("<file_1>","<file_2>") or fixtures("<path/file_1.sql>","<path/file_2.sql>")
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 = "<path>", scripts("<file_1>","<file_2>")) 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 = "<path>", scripts("<file_1>","<file_2>")) 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<Args> {
migrations,
})
}
#[cfg(feature = "migrate")]
fn parse_fixtures_args(
fixtures_type: &mut FixturesType,
litstr: syn::LitStr,
fixtures_local: &mut Vec<syn::LitStr>,
) -> syn::Result<()> {
// fixtures(path = "<path>", scripts("<file_1>","<file_2>")) 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 = "<path>", scripts("<file_1>","<file_2>")) 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 = "<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::LitStr>,
) -> syn::Result<()> {
// fixtures(path = "<path>", scripts("<file_1>","<file_2>")) 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")
}
}

View file

@ -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 order<sup>3</sup>:
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 = <path to folder>` parameter and a `scripts(<filename_1>, <filename_2>, ...)` 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 order<sup>3</sup>:
```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.
<sup>3</sup>Ordering 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

9
tests/fixtures/mysql/posts.sql vendored Normal file
View file

@ -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'));

2
tests/fixtures/mysql/users.sql vendored Normal file
View file

@ -0,0 +1,2 @@
insert into user(user_id, username)
values (1, 'alice'), (2, 'bob');

14
tests/fixtures/postgres/posts.sql vendored Normal file
View file

@ -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
);

2
tests/fixtures/postgres/users.sql vendored Normal file
View file

@ -0,0 +1,2 @@
insert into "user"(user_id, username)
values ('6592b7c0-b531-4613-ace5-94246b7ce0c3', 'alice'), ('297923c5-a83c-4052-bab0-030887154e52', 'bob');

View file

@ -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<String> =
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<String> =
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<String> =
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<()> {

View file

@ -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<String> =
@ -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<String> =
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<String> =
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<String> =
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<()> {