Skip to content

Commit

Permalink
Add fixtures_path in sqlx::test args (#2545)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
ripa1995 authored Nov 16, 2023
1 parent 9a6ebd0 commit 16eeea8
Show file tree
Hide file tree
Showing 8 changed files with 391 additions and 18 deletions.
200 changes: 184 additions & 16 deletions sqlx-macros-core/src/test_attr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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") =>
Expand Down Expand Up @@ -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")
}
}
14 changes: 12 additions & 2 deletions src/macros/test.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"))]
Expand All @@ -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.
Expand All @@ -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
Expand Down
9 changes: 9 additions & 0 deletions tests/fixtures/mysql/posts.sql
Original file line number Diff line number Diff line change
@@ -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 changes: 2 additions & 0 deletions tests/fixtures/mysql/users.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
insert into user(user_id, username)
values (1, 'alice'), (2, 'bob');
14 changes: 14 additions & 0 deletions tests/fixtures/postgres/posts.sql
Original file line number Diff line number Diff line change
@@ -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 changes: 2 additions & 0 deletions tests/fixtures/postgres/users.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
insert into "user"(user_id, username)
values ('6592b7c0-b531-4613-ace5-94246b7ce0c3', 'alice'), ('297923c5-a83c-4052-bab0-030887154e52', 'bob');
82 changes: 82 additions & 0 deletions tests/mysql/test-attr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<()> {
Expand Down
Loading

0 comments on commit 16eeea8

Please sign in to comment.