Skip to content

Commit 16eeea8

Browse files
authored
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
1 parent 9a6ebd0 commit 16eeea8

File tree

8 files changed

+391
-18
lines changed

8 files changed

+391
-18
lines changed

sqlx-macros-core/src/test_attr.rs

+184-16
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,18 @@ use quote::quote;
33

44
#[cfg(feature = "migrate")]
55
struct Args {
6-
fixtures: Vec<syn::LitStr>,
6+
fixtures: Vec<(FixturesType, Vec<syn::LitStr>)>,
77
migrations: MigrationsOpt,
88
}
99

10+
#[cfg(feature = "migrate")]
11+
enum FixturesType {
12+
None,
13+
RelativePath,
14+
CustomRelativePath(syn::LitStr),
15+
ExplicitPath,
16+
}
17+
1018
#[cfg(feature = "migrate")]
1119
enum MigrationsOpt {
1220
InferredPath,
@@ -73,16 +81,59 @@ fn expand_advanced(args: syn::AttributeArgs, input: syn::ItemFn) -> crate::Resul
7381

7482
let fn_arg_types = inputs.iter().map(|_| quote! { _ });
7583

76-
let fixtures = args.fixtures.into_iter().map(|fixture| {
77-
let path = format!("fixtures/{}.sql", fixture.value());
84+
let mut fixtures = Vec::new();
7885

79-
quote! {
80-
::sqlx::testing::TestFixture {
81-
path: #path,
82-
contents: include_str!(#path),
83-
}
84-
}
85-
});
86+
for (fixture_type, fixtures_local) in args.fixtures {
87+
let mut res = match fixture_type {
88+
FixturesType::None => vec![],
89+
FixturesType::RelativePath => fixtures_local
90+
.into_iter()
91+
.map(|fixture| {
92+
let mut fixture_str = fixture.value();
93+
add_sql_extension_if_missing(&mut fixture_str);
94+
95+
let path = format!("fixtures/{}", fixture_str);
96+
97+
quote! {
98+
::sqlx::testing::TestFixture {
99+
path: #path,
100+
contents: include_str!(#path),
101+
}
102+
}
103+
})
104+
.collect(),
105+
FixturesType::CustomRelativePath(path) => fixtures_local
106+
.into_iter()
107+
.map(|fixture| {
108+
let mut fixture_str = fixture.value();
109+
add_sql_extension_if_missing(&mut fixture_str);
110+
111+
let path = format!("{}/{}", path.value(), fixture_str);
112+
113+
quote! {
114+
::sqlx::testing::TestFixture {
115+
path: #path,
116+
contents: include_str!(#path),
117+
}
118+
}
119+
})
120+
.collect(),
121+
FixturesType::ExplicitPath => fixtures_local
122+
.into_iter()
123+
.map(|fixture| {
124+
let path = fixture.value();
125+
126+
quote! {
127+
::sqlx::testing::TestFixture {
128+
path: #path,
129+
contents: include_str!(#path),
130+
}
131+
}
132+
})
133+
.collect(),
134+
};
135+
fixtures.append(&mut res)
136+
}
86137

87138
let migrations = match args.migrations {
88139
MigrationsOpt::ExplicitPath(path) => {
@@ -130,24 +181,37 @@ fn expand_advanced(args: syn::AttributeArgs, input: syn::ItemFn) -> crate::Resul
130181

131182
#[cfg(feature = "migrate")]
132183
fn parse_args(attr_args: syn::AttributeArgs) -> syn::Result<Args> {
133-
let mut fixtures = vec![];
184+
let mut fixtures = Vec::new();
134185
let mut migrations = MigrationsOpt::InferredPath;
135186

136187
for arg in attr_args {
137188
match arg {
138189
syn::NestedMeta::Meta(syn::Meta::List(list)) if list.path.is_ident("fixtures") => {
139-
if !fixtures.is_empty() {
140-
return Err(syn::Error::new_spanned(list, "duplicate `fixtures` arg"));
141-
}
190+
let mut fixtures_local = vec![];
191+
let mut fixtures_type = FixturesType::None;
142192

143193
for nested in list.nested {
144194
match nested {
145-
syn::NestedMeta::Lit(syn::Lit::Str(litstr)) => fixtures.push(litstr),
195+
syn::NestedMeta::Lit(syn::Lit::Str(litstr)) => {
196+
// fixtures("<file_1>","<file_2>") or fixtures("<path/file_1.sql>","<path/file_2.sql>")
197+
parse_fixtures_args(&mut fixtures_type, litstr, &mut fixtures_local)?;
198+
},
199+
syn::NestedMeta::Meta(syn::Meta::NameValue(namevalue))
200+
if namevalue.path.is_ident("path") =>
201+
{
202+
// fixtures(path = "<path>", scripts("<file_1>","<file_2>")) checking `path` argument
203+
parse_fixtures_path_args(&mut fixtures_type, namevalue)?;
204+
},
205+
syn::NestedMeta::Meta(syn::Meta::List(list)) if list.path.is_ident("scripts") => {
206+
// fixtures(path = "<path>", scripts("<file_1>","<file_2>")) checking `scripts` argument
207+
parse_fixtures_scripts_args(&mut fixtures_type, list, &mut fixtures_local)?;
208+
}
146209
other => {
147210
return Err(syn::Error::new_spanned(other, "expected string literal"))
148211
}
149-
}
212+
};
150213
}
214+
fixtures.push((fixtures_type, fixtures_local));
151215
}
152216
syn::NestedMeta::Meta(syn::Meta::NameValue(namevalue))
153217
if namevalue.path.is_ident("migrations") =>
@@ -217,3 +281,107 @@ fn parse_args(attr_args: syn::AttributeArgs) -> syn::Result<Args> {
217281
migrations,
218282
})
219283
}
284+
285+
#[cfg(feature = "migrate")]
286+
fn parse_fixtures_args(
287+
fixtures_type: &mut FixturesType,
288+
litstr: syn::LitStr,
289+
fixtures_local: &mut Vec<syn::LitStr>,
290+
) -> syn::Result<()> {
291+
// fixtures(path = "<path>", scripts("<file_1>","<file_2>")) checking `path` argument
292+
let path_str = litstr.value();
293+
let path = std::path::Path::new(&path_str);
294+
// This will be `true` if there's at least one path separator (`/` or `\`)
295+
// It's also true for all absolute paths, even e.g. `/foo.sql` as the root directory is counted as a component.
296+
let is_explicit_path = path.components().count() > 1;
297+
match fixtures_type {
298+
FixturesType::None => {
299+
if is_explicit_path {
300+
*fixtures_type = FixturesType::ExplicitPath;
301+
} else {
302+
*fixtures_type = FixturesType::RelativePath;
303+
}
304+
}
305+
FixturesType::RelativePath => {
306+
if is_explicit_path {
307+
return Err(syn::Error::new_spanned(
308+
litstr,
309+
"expected only relative path fixtures",
310+
));
311+
}
312+
}
313+
FixturesType::ExplicitPath => {
314+
if !is_explicit_path {
315+
return Err(syn::Error::new_spanned(
316+
litstr,
317+
"expected only explicit path fixtures",
318+
));
319+
}
320+
}
321+
FixturesType::CustomRelativePath(_) => {
322+
return Err(syn::Error::new_spanned(
323+
litstr,
324+
"custom relative path fixtures must be defined in `scripts` argument",
325+
))
326+
}
327+
}
328+
if (matches!(fixtures_type, FixturesType::ExplicitPath) && !is_explicit_path) {
329+
return Err(syn::Error::new_spanned(
330+
litstr,
331+
"expected explicit path fixtures to have `.sql` extension",
332+
));
333+
}
334+
fixtures_local.push(litstr);
335+
Ok(())
336+
}
337+
338+
#[cfg(feature = "migrate")]
339+
fn parse_fixtures_path_args(
340+
fixtures_type: &mut FixturesType,
341+
namevalue: syn::MetaNameValue,
342+
) -> syn::Result<()> {
343+
// fixtures(path = "<path>", scripts("<file_1>","<file_2>")) checking `path` argument
344+
if !matches!(fixtures_type, FixturesType::None) {
345+
return Err(syn::Error::new_spanned(
346+
namevalue,
347+
"`path` must be the first argument of `fixtures`",
348+
));
349+
}
350+
*fixtures_type = match namevalue.lit {
351+
// path = "<path>"
352+
syn::Lit::Str(litstr) => FixturesType::CustomRelativePath(litstr),
353+
_ => return Err(syn::Error::new_spanned(namevalue, "expected string")),
354+
};
355+
Ok(())
356+
}
357+
358+
#[cfg(feature = "migrate")]
359+
fn parse_fixtures_scripts_args(
360+
fixtures_type: &mut FixturesType,
361+
list: syn::MetaList,
362+
fixtures_local: &mut Vec<syn::LitStr>,
363+
) -> syn::Result<()> {
364+
// fixtures(path = "<path>", scripts("<file_1>","<file_2>")) checking `scripts` argument
365+
if !matches!(fixtures_type, FixturesType::CustomRelativePath(_)) {
366+
return Err(syn::Error::new_spanned(
367+
list,
368+
"`scripts` must be the second argument of `fixtures` and used together with `path`",
369+
));
370+
}
371+
for nested in list.nested {
372+
let litstr = match nested {
373+
syn::NestedMeta::Lit(syn::Lit::Str(litstr)) => litstr,
374+
other => return Err(syn::Error::new_spanned(other, "expected string literal")),
375+
};
376+
fixtures_local.push(litstr);
377+
}
378+
Ok(())
379+
}
380+
381+
#[cfg(feature = "migrate")]
382+
fn add_sql_extension_if_missing(fixture: &mut String) {
383+
let has_extension = std::path::Path::new(&fixture).extension().is_some();
384+
if !has_extension {
385+
fixture.push_str(".sql")
386+
}
387+
}

src/macros/test.md

+12-2
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,13 @@ similarly to migrations but are solely intended to insert test data and be arbit
185185
Imagine a basic social app that has users, posts and comments. To test the comment routes, you'd want
186186
the database to already have users and posts in it so the comments tests don't have to duplicate that work.
187187

188-
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>:
188+
You can either pass a list of fixture to the attribute `fixtures` in three different operating modes:
189+
190+
1) Pass a list of references files in `./fixtures` (resolved as `./fixtures/{name}.sql`, `.sql` added only if extension is missing);
191+
2) Pass a list of file paths (including associated extension), in which case they can either be absolute, or relative to the current file;
192+
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).
193+
194+
In any case they will be applied in the given order<sup>3</sup>:
189195

190196
```rust,no_run
191197
# #[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
195201
use sqlx::PgPool;
196202
use serde_json::json;
197203
204+
// Alternatives:
205+
// #[sqlx::test(fixtures("./fixtures/users.sql", "./fixtures/users.sql"))]
206+
// or
207+
// #[sqlx::test(fixtures(path = "./fixtures", scripts("users", "posts")))]
198208
#[sqlx::test(fixtures("users", "posts"))]
199209
async fn test_create_comment(pool: PgPool) -> sqlx::Result<()> {
200210
// 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<()> {
211221
# }
212222
```
213223

214-
Fixtures are resolved relative to the current file as `./fixtures/{name}.sql`.
224+
Multiple `fixtures` attributes can be used to combine different operating modes.
215225

216226
<sup>3</sup>Ordering for test fixtures is entirely up to the application, and each test may choose which fixtures to
217227
apply and which to omit. However, since each fixture is applied separately (sent as a single command string, so wrapped

tests/fixtures/mysql/posts.sql

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
insert into post(post_id, user_id, content, created_at)
2+
values (1,
3+
1,
4+
'This new computer is lightning-fast!',
5+
timestamp(now(), '-1:00:00')),
6+
(2,
7+
2,
8+
'@alice is a haxxor :(',
9+
timestamp(now(), '-0:30:00'));

tests/fixtures/mysql/users.sql

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
insert into user(user_id, username)
2+
values (1, 'alice'), (2, 'bob');

tests/fixtures/postgres/posts.sql

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
insert into post(post_id, user_id, content, created_at)
2+
values
3+
(
4+
'252c1d98-a9b0-4f18-8298-e59058bdfe16',
5+
'6592b7c0-b531-4613-ace5-94246b7ce0c3',
6+
'This new computer is lightning-fast!',
7+
now() + '1 hour ago'::interval
8+
),
9+
(
10+
'844265f7-2472-4689-9a2e-b21f40dbf401',
11+
'6592b7c0-b531-4613-ace5-94246b7ce0c3',
12+
'@alice is a haxxor :(',
13+
now() + '30 minutes ago'::interval
14+
);

tests/fixtures/postgres/users.sql

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

tests/mysql/test-attr.rs

+82
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,88 @@ async fn it_gets_posts(pool: MySqlPool) -> sqlx::Result<()> {
7070
Ok(())
7171
}
7272

73+
#[sqlx::test(
74+
migrations = "tests/mysql/migrations",
75+
fixtures("../fixtures/mysql/users.sql", "../fixtures/mysql/posts.sql")
76+
)]
77+
async fn it_gets_posts_explicit_fixtures_path(pool: MySqlPool) -> sqlx::Result<()> {
78+
let post_contents: Vec<String> =
79+
sqlx::query_scalar("SELECT content FROM post ORDER BY created_at")
80+
.fetch_all(&pool)
81+
.await?;
82+
83+
assert_eq!(
84+
post_contents,
85+
[
86+
"This new computer is lightning-fast!",
87+
"@alice is a haxxor :("
88+
]
89+
);
90+
91+
let comment_exists: bool = sqlx::query_scalar("SELECT exists(SELECT 1 FROM comment)")
92+
.fetch_one(&pool)
93+
.await?;
94+
95+
assert!(!comment_exists);
96+
97+
Ok(())
98+
}
99+
100+
#[sqlx::test(
101+
migrations = "tests/mysql/migrations",
102+
fixtures("../fixtures/mysql/users.sql"),
103+
fixtures("posts")
104+
)]
105+
async fn it_gets_posts_mixed_fixtures_path(pool: MySqlPool) -> sqlx::Result<()> {
106+
let post_contents: Vec<String> =
107+
sqlx::query_scalar("SELECT content FROM post ORDER BY created_at")
108+
.fetch_all(&pool)
109+
.await?;
110+
111+
assert_eq!(
112+
post_contents,
113+
[
114+
"This new computer is lightning-fast!",
115+
"@alice is a haxxor :("
116+
]
117+
);
118+
119+
let comment_exists: bool = sqlx::query_scalar("SELECT exists(SELECT 1 FROM comment)")
120+
.fetch_one(&pool)
121+
.await?;
122+
123+
assert!(!comment_exists);
124+
125+
Ok(())
126+
}
127+
128+
#[sqlx::test(
129+
migrations = "tests/mysql/migrations",
130+
fixtures(path = "../fixtures/mysql", scripts("users", "posts"))
131+
)]
132+
async fn it_gets_posts_custom_relative_fixtures_path(pool: MySqlPool) -> sqlx::Result<()> {
133+
let post_contents: Vec<String> =
134+
sqlx::query_scalar("SELECT content FROM post ORDER BY created_at")
135+
.fetch_all(&pool)
136+
.await?;
137+
138+
assert_eq!(
139+
post_contents,
140+
[
141+
"This new computer is lightning-fast!",
142+
"@alice is a haxxor :("
143+
]
144+
);
145+
146+
let comment_exists: bool = sqlx::query_scalar("SELECT exists(SELECT 1 FROM comment)")
147+
.fetch_one(&pool)
148+
.await?;
149+
150+
assert!(!comment_exists);
151+
152+
Ok(())
153+
}
154+
73155
// Try `migrator`
74156
#[sqlx::test(migrator = "MIGRATOR", fixtures("users", "posts", "comments"))]
75157
async fn it_gets_comments(pool: MySqlPool) -> sqlx::Result<()> {

0 commit comments

Comments
 (0)