Skip to content

Commit 4a01a94

Browse files
radimclaude
andcommitted
test(rust): cover regresql-stub format + emit
Ports the relevant cases from regresql_stub_test.go: rewrite_params on repeated, no, and double-digit param numbers. Adds three for stub_slug paths Go didn't cover (defensive short-fingerprint, missing sha1: prefix) and two integration tests on TempDir asserting file emission, header content, param rewriting, and --top / --min-calls filtering. Skips TestSampleValuesForParams, TestSampleValuesSkipsUnattributed, TestYAMLScalarStringEscaping — they cover the plans/ generation path that's TODO-disabled in Go and not ported. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d05950e commit 4a01a94

3 files changed

Lines changed: 140 additions & 0 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/qshape-cli/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,6 @@ qshape-core = { path = "../qshape-core" }
1717
regex.workspace = true
1818
serde.workspace = true
1919
serde_json.workspace = true
20+
21+
[dev-dependencies]
22+
tempfile = "3"

crates/qshape-cli/src/regresql_stub.rs

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,142 @@ fn rewrite_params(sql: &str) -> String {
4848
re.replace_all(sql, ":param$1").into_owned()
4949
}
5050

51+
#[cfg(test)]
52+
mod tests {
53+
use std::fs;
54+
55+
use qshape_core::{CURRENT_SCHEMA_VERSION, ClustersDoc, Query};
56+
use tempfile::TempDir;
57+
58+
use super::*;
59+
60+
#[test]
61+
fn rewrites_params_to_named() {
62+
let got =
63+
rewrite_params("SELECT id FROM users WHERE id = $1 AND tenant_id = $2 AND id = $1");
64+
assert_eq!(
65+
got,
66+
"SELECT id FROM users WHERE id = :param1 AND tenant_id = :param2 AND id = :param1"
67+
);
68+
}
69+
70+
#[test]
71+
fn rewrite_no_params_passthrough() {
72+
let sql = "SELECT 1";
73+
assert_eq!(rewrite_params(sql), sql);
74+
}
75+
76+
#[test]
77+
fn rewrite_double_digit_params() {
78+
let got = rewrite_params("SELECT $10 + $2");
79+
assert_eq!(got, "SELECT :param10 + :param2");
80+
}
81+
82+
#[test]
83+
fn slug_strips_sha1_prefix_and_truncates() {
84+
assert_eq!(stub_slug(1, "sha1:abc12345def"), "q01-abc12345");
85+
// rank zero-pads to 2 digits
86+
assert_eq!(stub_slug(7, "sha1:0123456789ab"), "q07-01234567");
87+
}
88+
89+
#[test]
90+
fn slug_handles_short_fingerprint() {
91+
// pathological — fingerprint shorter than 8 hex chars
92+
assert_eq!(stub_slug(3, "sha1:abc"), "q03-abc");
93+
}
94+
95+
#[test]
96+
fn slug_handles_missing_prefix() {
97+
// fingerprint without sha1: prefix (defensive)
98+
assert_eq!(stub_slug(2, "fedcba9876543210"), "q02-fedcba98");
99+
}
100+
101+
#[test]
102+
fn run_writes_expected_files() {
103+
let tmp = TempDir::new().unwrap();
104+
let in_path = tmp.path().join("clusters.json");
105+
let out_dir = tmp.path().join("stubs");
106+
107+
let doc = ClustersDoc {
108+
schema_version: CURRENT_SCHEMA_VERSION.to_string(),
109+
clusters: vec![
110+
Cluster {
111+
fingerprint: "sha1:aaaaaaaa11111111".to_string(),
112+
canonical: "SELECT id FROM users WHERE id = $1".to_string(),
113+
members: vec![Query::default()],
114+
total_calls: 100,
115+
..Cluster::default()
116+
},
117+
Cluster {
118+
fingerprint: "sha1:bbbbbbbb22222222".to_string(),
119+
canonical: "SELECT 1".to_string(),
120+
total_calls: 5,
121+
..Cluster::default()
122+
},
123+
// skipped: empty fingerprint
124+
Cluster {
125+
fingerprint: String::new(),
126+
canonical: "junk".to_string(),
127+
total_calls: 999,
128+
..Cluster::default()
129+
},
130+
],
131+
};
132+
fs::write(&in_path, serde_json::to_vec(&doc).unwrap()).unwrap();
133+
134+
run(Some(in_path.to_str().unwrap()), out_dir.to_str().unwrap(), 10, 0).unwrap();
135+
136+
let sql_dir = out_dir.join("sql");
137+
let entries: Vec<_> = fs::read_dir(&sql_dir)
138+
.unwrap()
139+
.map(|e| e.unwrap().file_name().into_string().unwrap())
140+
.collect();
141+
assert_eq!(entries.len(), 2, "third cluster has empty fingerprint, must be skipped");
142+
143+
let first = fs::read_to_string(sql_dir.join("q01-aaaaaaaa.sql")).unwrap();
144+
assert!(first.contains("-- name: q01-aaaaaaaa"));
145+
assert!(first.contains("-- Generated from qshape cluster sha1:aaaaaaaa11111111"));
146+
assert!(first.contains("Total calls (prod): 100 across 1 member variants"));
147+
assert!(first.contains("SELECT id FROM users WHERE id = :param1"));
148+
assert!(first.ends_with('\n'), "trailing newline must be present");
149+
}
150+
151+
#[test]
152+
fn run_honours_top_and_min_calls() {
153+
let tmp = TempDir::new().unwrap();
154+
let in_path = tmp.path().join("clusters.json");
155+
let out_dir = tmp.path().join("stubs");
156+
157+
let mk = |fp: &str, calls: i64| Cluster {
158+
fingerprint: fp.to_string(),
159+
canonical: "SELECT 1".to_string(),
160+
total_calls: calls,
161+
..Cluster::default()
162+
};
163+
let doc = ClustersDoc {
164+
schema_version: CURRENT_SCHEMA_VERSION.to_string(),
165+
clusters: vec![
166+
mk("sha1:aa11", 100),
167+
mk("sha1:bb22", 50),
168+
mk("sha1:cc33", 10),
169+
mk("sha1:dd44", 5),
170+
],
171+
};
172+
fs::write(&in_path, serde_json::to_vec(&doc).unwrap()).unwrap();
173+
174+
// top=2 caps emission
175+
run(Some(in_path.to_str().unwrap()), out_dir.to_str().unwrap(), 2, 0).unwrap();
176+
let n = fs::read_dir(out_dir.join("sql")).unwrap().count();
177+
assert_eq!(n, 2);
178+
179+
// min_calls=20 filters
180+
let out2 = tmp.path().join("stubs2");
181+
run(Some(in_path.to_str().unwrap()), out2.to_str().unwrap(), 10, 20).unwrap();
182+
let n2 = fs::read_dir(out2.join("sql")).unwrap().count();
183+
assert_eq!(n2, 2, "only sha1:aa11 (100) and sha1:bb22 (50) survive min_calls=20");
184+
}
185+
}
186+
51187
fn write_sql_stub(path: &Path, slug: &str, c: &Cluster, sql: &str) -> Result<()> {
52188
let trailing = if sql.ends_with('\n') { "" } else { "\n" };
53189
let content = format!(

0 commit comments

Comments
 (0)