Skip to main content

fse_cli/
prepare.rs

1//! Native replacement for `cargo sqlx prepare`, so a user of the framework
2//! never needs `sqlx-cli` installed — just `fse`.
3//!
4//! Mechanism (see sqlx-macros-core's `query/mod.rs`): when a `query!`/
5//! `query_as!`/`query_scalar!` call expands against a *live* `DATABASE_URL`
6//! (i.e. not `SQLX_OFFLINE=true`), it writes its resolved metadata into
7//! `SQLX_OFFLINE_DIR` as a side effect, if that env var points at an
8//! existing directory. So all `cargo sqlx prepare` does — and all this
9//! does — is: clear the old cache, force every query!-family call site to
10//! re-expand (cargo's fingerprinting has no way to know an env var changed,
11//! so source files are touched to force it), and run `cargo check --tests`
12//! with that env var set.
13//!
14//! `--tests` (rather than a bare `cargo check`) matters because `cfg(test)`
15//! is only enabled when checking test targets: integration tests under
16//! `tests/` are separate targets that a plain `cargo check` never builds at
17//! all, and `#[cfg(test)]` unit-test modules inside `src/` are compiled out
18//! the same way. Either kind of query!-family call site — a fixture helper
19//! in `tests/common/mod.rs`, or a `#[cfg(test)]` block in `src/` — would
20//! silently never run its capture side effect without it. So both `src/`
21//! and `tests/` need their `.rs` files touched, and `cargo check` needs
22//! `--tests` to actually compile that code.
23
24use std::fs;
25use std::path::{Path, PathBuf};
26use std::process::Command;
27use std::time::SystemTime;
28
29use fse_schema::Error;
30
31use crate::config::{self, OrmConfig};
32
33pub fn run(root: &Path, cfg: &OrmConfig, database_url: Option<&str>) -> Result<(), Error> {
34    let url = config::resolve_database_url(root, cfg, database_url)?;
35
36    let cache_dir = root.join(".sqlx");
37    fs::create_dir_all(&cache_dir)
38        .map_err(|e| Error::new(format!("cannot create {}: {e}", cache_dir.display())))?;
39
40    // Only delete our own query-*.json files, never touch anything else a
41    // user may have placed in .sqlx.
42    for file in query_files(&cache_dir)? {
43        fs::remove_file(&file)
44            .map_err(|e| Error::new(format!("cannot remove {}: {e}", file.display())))?;
45    }
46
47    touch_rs_files(&root.join("src"))?;
48    touch_rs_files(&root.join("tests"))?;
49
50    let cache_dir_abs = cache_dir
51        .canonicalize()
52        .map_err(|e| Error::new(format!("cannot resolve {}: {e}", cache_dir.display())))?;
53
54    println!("refreshing query cache ...");
55    let status = Command::new("cargo")
56        .arg("check")
57        .arg("--tests")
58        .current_dir(root)
59        .env("DATABASE_URL", &url)
60        .env("SQLX_OFFLINE", "false")
61        .env("SQLX_OFFLINE_DIR", &cache_dir_abs)
62        .status()
63        .map_err(|e| Error::new(format!("failed to run `cargo check`: {e}")))?;
64
65    if !status.success() {
66        return Err(Error::new(
67            "`cargo check` failed while refreshing the query cache — fix the build error and rerun",
68        ));
69    }
70
71    let count = query_files(&cache_dir)?.len();
72    if count == 0 {
73        println!(
74            "warning: no queries found — nothing written to .sqlx (no find!/insert!/update!/query! call sites?)"
75        );
76    } else {
77        let plural = if count == 1 { "query" } else { "queries" };
78        println!(
79            "wrote {count} {plural} to .sqlx — commit this directory so Docker builds work without a live database."
80        );
81    }
82    Ok(())
83}
84
85fn query_files(dir: &Path) -> Result<Vec<PathBuf>, Error> {
86    if !dir.exists() {
87        return Ok(Vec::new());
88    }
89    let mut out = Vec::new();
90    for entry in fs::read_dir(dir).map_err(|e| Error::new(format!("{}: {e}", dir.display())))? {
91        let path = entry.map_err(|e| Error::new(e.to_string()))?.path();
92        let is_query_file = path
93            .file_name()
94            .and_then(|n| n.to_str())
95            .is_some_and(|n| n.starts_with("query-") && n.ends_with(".json"));
96        if is_query_file {
97            out.push(path);
98        }
99    }
100    Ok(out)
101}
102
103/// Bumps the mtime of every `.rs` file under `dir` so `cargo check` treats
104/// them as changed and re-expands their macros, including `query!`-family
105/// calls whose SQL text hasn't changed — cargo's fingerprint has no way to
106/// know `SQLX_OFFLINE_DIR` changed, and would otherwise skip them via
107/// incremental compilation, silently never running the capture side effect.
108fn touch_rs_files(dir: &Path) -> Result<(), Error> {
109    if !dir.exists() {
110        return Ok(());
111    }
112    let now = SystemTime::now();
113    let mut stack = vec![dir.to_path_buf()];
114    while let Some(current) = stack.pop() {
115        for entry in
116            fs::read_dir(&current).map_err(|e| Error::new(format!("{}: {e}", current.display())))?
117        {
118            let path = entry.map_err(|e| Error::new(e.to_string()))?.path();
119            if path.is_dir() {
120                stack.push(path);
121            } else if path.extension().is_some_and(|e| e == "rs") {
122                let file = fs::OpenOptions::new()
123                    .write(true)
124                    .open(&path)
125                    .map_err(|e| Error::new(format!("cannot open {}: {e}", path.display())))?;
126                file.set_modified(now)
127                    .map_err(|e| Error::new(format!("cannot touch {}: {e}", path.display())))?;
128            }
129        }
130    }
131    Ok(())
132}