diff --git a/sqlx-cli/tests/prepare.rs b/sqlx-cli/tests/prepare.rs new file mode 100644 index 0000000000..db71f1ee12 --- /dev/null +++ b/sqlx-cli/tests/prepare.rs @@ -0,0 +1,91 @@ +use assert_cmd::Command; +use std::fs; +use std::path::{Path, PathBuf}; + +fn run_prepare(project_dir: &Path, target_dir: &Path) { + Command::cargo_bin("cargo-sqlx") + .unwrap() + .current_dir(project_dir) + .env("CARGO_TARGET_DIR", target_dir) + .args([ + "sqlx", + "prepare", + "--database-url", + "sqlite::memory:", + "--", + "--lib", + ]) + .assert() + .success(); +} + +fn query_file(project_dir: &Path) -> PathBuf { + let mut query_files = fs::read_dir(project_dir.join(".sqlx")) + .unwrap() + .map(|entry| entry.unwrap().path()) + .filter(|path| { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("query-") && name.ends_with(".json")) + }); + let query_file = query_files + .next() + .expect("prepare did not create query data"); + + assert!( + query_files.next().is_none(), + "prepare created more than one query file" + ); + + query_file +} + +#[test] +fn repeated_sqlite_prepare_is_byte_stable() { + let workspace_root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap(); + let temp_dir = tempfile::Builder::new() + .prefix("sqlx-cli-prepare-") + .tempdir_in(workspace_root.join("target")) + .unwrap(); + let project_dir = temp_dir.path().join("fixture"); + let target_dir = temp_dir.path().join("target"); + fs::create_dir_all(project_dir.join("src")).unwrap(); + fs::write( + project_dir.join("Cargo.toml"), + format!( + r#"[package] +name = "sqlite-prepare-fixture" +version = "0.0.0" +edition = "2021" + +[workspace] + +[dependencies] +sqlx = {{ path = {:?}, default-features = false, features = ["macros", "runtime-tokio", "sqlite"] }} +"#, + workspace_root + ), + ) + .unwrap(); + fs::write( + project_dir.join("src/lib.rs"), + r#"pub fn query() { + let _ = sqlx::query!("SELECT 1 AS value"); +} +"#, + ) + .unwrap(); + + run_prepare(&project_dir, &target_dir); + let first = fs::read(query_file(&project_dir)).unwrap(); + run_prepare(&project_dir, &target_dir); + let second = fs::read(query_file(&project_dir)).unwrap(); + let expected = include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../sqlx-macros-core/tests/fixtures/sqlite-query.json" + )); + + assert_eq!(first, expected); + assert_eq!(second, expected); + assert_eq!(first, second); +} diff --git a/sqlx-macros-core/Cargo.toml b/sqlx-macros-core/Cargo.toml index 42bf0f5e8c..e4656c8754 100644 --- a/sqlx-macros-core/Cargo.toml +++ b/sqlx-macros-core/Cargo.toml @@ -80,6 +80,9 @@ syn = { version = "2.0.87", default-features = false, features = ["full", "deriv quote = { version = "1.0.35", default-features = false } url = { version = "2.2.2" } +[dev-dependencies] +serde_json = { version = "1.0.142", features = ["preserve_order"] } + [lints.rust.unexpected_cfgs] level = "warn" check-cfg = ['cfg(sqlx_macros_unstable)', 'cfg(procmacro2_semver_exempt)'] diff --git a/sqlx-macros-core/src/query/data.rs b/sqlx-macros-core/src/query/data.rs index 912236ae37..b40676ce36 100644 --- a/sqlx-macros-core/src/query/data.rs +++ b/sqlx-macros-core/src/query/data.rs @@ -182,18 +182,7 @@ where } }; - // From a quick survey of the files generated by `examples/postgres/axum-social-with-tests`, - // which are generally in the 1-2 KiB range, this seems like a safe bet to avoid - // lots of reallocations without using too much memory. - // - // As of writing, `serde_json::to_vec_pretty()` only allocates 128 bytes up-front. - let mut data = Vec::with_capacity(4096); - - serde_json::to_writer_pretty(&mut data, self).expect("BUG: failed to serialize query data"); - - // Ensure there is a newline at the end of the JSON file to avoid - // accidental modification by IDE and make github diff tool happier. - data.push(b'\n'); + let data = serialize_query_data(self); // This ideally writes the data in as few syscalls as possible. file.write_all(&data) @@ -206,9 +195,137 @@ where } } +fn serialize_query_data(data: &impl Serialize) -> Vec { + // From a quick survey of the files generated by `examples/postgres/axum-social-with-tests`, + // which are generally in the 1-2 KiB range, this seems like a safe bet to avoid + // lots of reallocations without using too much memory. + // + // As of writing, `serde_json::to_vec_pretty()` only allocates 128 bytes up-front. + let mut serialized = Vec::with_capacity(4096); + let mut data = + serde_json::to_value(data).expect("BUG: failed to convert query data to JSON value"); + + data.sort_all_objects(); + + serde_json::to_writer_pretty(&mut serialized, &data) + .expect("BUG: failed to serialize query data"); + + // Ensure there is a newline at the end of the JSON file to avoid + // accidental modification by IDE and make github diff tool happier. + serialized.push(b'\n'); + serialized +} + pub(super) fn hash_string(query: &str) -> String { // picked `sha2` because it's already in the dependency tree for both MySQL and Postgres use sha2::{Digest, Sha256}; hex::encode(Sha256::digest(query.as_bytes())) } + +#[cfg(test)] +mod tests { + use super::serialize_query_data; + use serde::ser::SerializeMap; + use serde::{Serialize, Serializer}; + + enum OrderedValue<'a> { + Number(u64), + Object(Vec<(&'a str, OrderedValue<'a>)>), + } + + impl Serialize for OrderedValue<'_> { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + match self { + Self::Number(number) => serializer.serialize_u64(*number), + Self::Object(entries) => { + let mut map = serializer.serialize_map(Some(entries.len()))?; + for (key, value) in entries { + map.serialize_entry(key, value)?; + } + map.end() + } + } + } + } + + #[test] + fn query_data_json_keys_are_sorted_recursively() { + let first = OrderedValue::Object(vec![ + ( + "z", + OrderedValue::Object(vec![ + ("second", OrderedValue::Number(2)), + ("first", OrderedValue::Number(1)), + ]), + ), + ("a", OrderedValue::Number(0)), + ]); + let second = OrderedValue::Object(vec![ + ("a", OrderedValue::Number(0)), + ( + "z", + OrderedValue::Object(vec![ + ("first", OrderedValue::Number(1)), + ("second", OrderedValue::Number(2)), + ]), + ), + ]); + + let expected = + b"{\n \"a\": 0,\n \"z\": {\n \"first\": 1,\n \"second\": 2\n }\n}\n"; + let first = serialize_query_data(&first); + let second = serialize_query_data(&second); + + assert_eq!(first, expected); + assert_eq!(second, expected); + assert_eq!(first, second); + } + + #[cfg(feature = "_sqlite")] + #[test] + fn sqlite_query_data_is_stable_across_repeated_saves() { + use super::{DynQueryData, QueryData}; + use std::path::{Path, PathBuf}; + use std::sync::atomic::{AtomicU64, Ordering}; + + struct RemoveOnDrop(PathBuf); + + impl Drop for RemoveOnDrop { + fn drop(&mut self) { + std::fs::remove_dir_all(&self.0).ok(); + } + } + + static NEXT_TEST_DIR_ID: AtomicU64 = AtomicU64::new(0); + + let fixture = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/sqlite-query.json" + )); + let dyn_data: DynQueryData = serde_json::from_str(fixture).unwrap(); + let query_data = QueryData::::from_dyn_data(dyn_data).unwrap(); + let test_dir = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../target/sqlx-macros-core-tests") + .join(format!( + "{}-{}", + std::process::id(), + NEXT_TEST_DIR_ID.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir_all(&test_dir).unwrap(); + let _remove_on_drop = RemoveOnDrop(test_dir.clone()); + let query_path = test_dir.join(format!("query-{}.json", query_data.hash)); + + query_data.save_in(&test_dir).unwrap(); + let first = std::fs::read(&query_path).unwrap(); + query_data.save_in(&test_dir).unwrap(); + let second = std::fs::read(query_path).unwrap(); + + assert_eq!(first, fixture.as_bytes()); + assert_eq!(second, fixture.as_bytes()); + assert_eq!(first, second); + } +} diff --git a/sqlx-macros-core/tests/fixtures/sqlite-query.json b/sqlx-macros-core/tests/fixtures/sqlite-query.json new file mode 100644 index 0000000000..34c37e7930 --- /dev/null +++ b/sqlx-macros-core/tests/fixtures/sqlite-query.json @@ -0,0 +1,21 @@ +{ + "db_name": "SQLite", + "describe": { + "columns": [ + { + "name": "value", + "ordinal": 0, + "origin": "Expression", + "type_info": "Integer" + } + ], + "nullable": [ + false + ], + "parameters": { + "Right": 0 + } + }, + "hash": "b8ccac726e885e4bae2a0596e87cc88c5113c16e87c3bdec2ffb0e09e49cb29e", + "query": "SELECT 1 AS value" +}