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 color_eyre::eyre::{Result, WrapErr, bail};
30
31use crate::config::{self, OrmConfig};
32
33pub fn run(root: &Path, cfg: &OrmConfig, database_url: Option<&str>) -> Result<()> {
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        .wrap_err_with(|| format!("cannot create {}", 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).wrap_err_with(|| format!("cannot remove {}", file.display()))?;
44    }
45
46    touch_rs_files(&root.join("src"))?;
47    touch_rs_files(&root.join("tests"))?;
48
49    let cache_dir_abs = cache_dir
50        .canonicalize()
51        .wrap_err_with(|| format!("cannot resolve {}", cache_dir.display()))?;
52
53    println!("refreshing query cache ...");
54    let status = Command::new("cargo")
55        .arg("check")
56        .arg("--tests")
57        .current_dir(root)
58        .env("DATABASE_URL", &url)
59        .env("SQLX_OFFLINE", "false")
60        .env("SQLX_OFFLINE_DIR", &cache_dir_abs)
61        .status()
62        .wrap_err("failed to run `cargo check`")?;
63
64    if !status.success() {
65        bail!(
66            "`cargo check` failed while refreshing the query cache — fix the build error and rerun"
67        );
68    }
69
70    let count = query_files(&cache_dir)?.len();
71    if count == 0 {
72        println!(
73            "warning: no queries found — nothing written to .sqlx (no find!/insert!/update!/query! call sites?)"
74        );
75    } else {
76        let plural = if count == 1 { "query" } else { "queries" };
77        println!(
78            "wrote {count} {plural} to .sqlx — commit this directory so Docker builds work without a live database."
79        );
80    }
81    Ok(())
82}
83
84fn query_files(dir: &Path) -> Result<Vec<PathBuf>> {
85    if !dir.exists() {
86        return Ok(Vec::new());
87    }
88    let mut out = Vec::new();
89    for entry in fs::read_dir(dir).wrap_err_with(|| dir.display().to_string())? {
90        let path = entry.wrap_err_with(|| dir.display().to_string())?.path();
91        let is_query_file = path
92            .file_name()
93            .and_then(|n| n.to_str())
94            .is_some_and(|n| n.starts_with("query-") && n.ends_with(".json"));
95        if is_query_file {
96            out.push(path);
97        }
98    }
99    Ok(out)
100}
101
102/// Bumps the mtime of every `.rs` file under `dir` so `cargo check` treats
103/// them as changed and re-expands their macros, including `query!`-family
104/// calls whose SQL text hasn't changed — cargo's fingerprint has no way to
105/// know `SQLX_OFFLINE_DIR` changed, and would otherwise skip them via
106/// incremental compilation, silently never running the capture side effect.
107fn touch_rs_files(dir: &Path) -> Result<()> {
108    if !dir.exists() {
109        return Ok(());
110    }
111    let now = SystemTime::now();
112    let mut stack = vec![dir.to_path_buf()];
113    while let Some(current) = stack.pop() {
114        for entry in fs::read_dir(&current).wrap_err_with(|| current.display().to_string())? {
115            let path = entry.wrap_err_with(|| current.display().to_string())?.path();
116            if path.is_dir() {
117                stack.push(path);
118            } else if path.extension().is_some_and(|e| e == "rs") {
119                let file = fs::OpenOptions::new()
120                    .write(true)
121                    .open(&path)
122                    .wrap_err_with(|| format!("cannot open {}", path.display()))?;
123                file.set_modified(now)
124                    .wrap_err_with(|| format!("cannot touch {}", path.display()))?;
125            }
126        }
127    }
128    Ok(())
129}