use std::io::Write;
use std::process::Command;
const BIN: &str = env!("CARGO_BIN_EXE_hprof-analyzer");
fn philosophers() -> Option<String> {
let p = format!(
"{}/tests/fixtures/dump_4_philosophers.hprof",
env!("CARGO_MANIFEST_DIR")
);
match std::fs::metadata(&p) {
Ok(m) if m.len() >= 1024 => Some(p),
_ => None,
}
}
fn mnemonics() -> Option<String> {
let p = format!(
"{}/tests/fixtures/dump_1_mnemonics.hprof",
env!("CARGO_MANIFEST_DIR")
);
match std::fs::metadata(&p) {
Ok(m) if m.len() >= 1024 => Some(p),
_ => None,
}
}
fn gauss_mix() -> Option<String> {
let p = format!(
"{}/tests/fixtures/dump_7_gauss-mix.hprof",
env!("CARGO_MANIFEST_DIR")
);
match std::fs::metadata(&p) {
Ok(m) if m.len() >= 1024 => Some(p),
_ => None,
}
}
fn is_separator_line(line: &str) -> bool {
!line.is_empty() && line.chars().all(|c| c == '-' || c == '+' || c == ' ')
}
#[test]
fn query_subcommand_count_prints_table() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args(["--query", "SELECT COUNT(*) FROM java.lang.String"])
.output()
.unwrap();
assert!(
out.status.success(),
"query subcommand failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("COUNT(*)"),
"missing COUNT header:\n{stdout}"
);
assert!(
stdout.contains("1 row"),
"missing row-count line:\n{stdout}"
);
let has_count = stdout.lines().any(|l| l.trim().parse::<u64>().is_ok());
assert!(has_count, "no integer count row found:\n{stdout}");
assert!(
stdout.contains("== java.lang.String =="),
"missing FROM-target label header:\n{stdout}"
);
}
#[test]
fn query_subcommand_without_any_query_fails_with_hint() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN).arg("query").arg(&hprof).output().unwrap();
assert!(
!out.status.success(),
"expected non-zero exit for a query run with no OQL"
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("no query given"),
"missing 'no query given' hint:\n{stderr}"
);
for needle in ["--query", "--query-file", "--repl", "--server"] {
assert!(
stderr.contains(needle),
"hint should mention `{needle}`:\n{stderr}"
);
}
assert!(
String::from_utf8_lossy(&out.stdout).trim().is_empty(),
"expected empty stdout on the no-query error path"
);
}
#[test]
fn repl_with_inline_query_warns_it_is_ignored() {
use std::process::Stdio;
let Some(hprof) = philosophers() else { return };
let mut child = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args(["--repl", "--query", "SELECT 1"])
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.unwrap();
drop(child.stdin.take());
let out = child.wait_with_output().unwrap();
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("--query/--query-file are ignored"),
"expected an ignored-query warning under --repl:\n{stderr}"
);
}
#[test]
fn query_from_unknown_class_notes_it_is_absent() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args(["--query", "SELECT * FROM com.example.DoesNotExist"])
.output()
.unwrap();
assert!(
out.status.success(),
"unknown-class query should still exit 0: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("0 rows"),
"unknown class should yield zero rows:\n{stdout}"
);
assert!(
stdout.contains("com.example.DoesNotExist") && stdout.to_lowercase().contains("no class"),
"expected a 'no class named ...' note:\n{stdout}"
);
}
#[test]
fn query_from_typo_class_suggests_did_you_mean() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args(["--query", "SELECT * FROM java.lang.Stirng"])
.output()
.unwrap();
assert!(out.status.success(), "typo query should exit 0");
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("did you mean") && stdout.contains("java.lang.String"),
"expected 'did you mean: java.lang.String' in note:\n{stdout}"
);
}
#[test]
fn query_instanceof_typo_class_suggests_did_you_mean() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args(["--query", "SELECT * FROM INSTANCEOF java.lang.Stirng"])
.output()
.unwrap();
assert!(out.status.success(), "typo instanceof query should exit 0");
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("did you mean") && stdout.contains("java.lang.String"),
"expected did-you-mean for INSTANCEOF typo:\n{stdout}"
);
}
#[test]
fn query_unknown_field_close_match_suggests_did_you_mean() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args(["--query", "SELECT s.valu FROM java.lang.String s LIMIT 1"])
.output()
.unwrap();
assert!(!out.status.success(), "bad field should exit non-zero");
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("did you mean") && stdout.contains("value"),
"expected 'did you mean: value' in output:\n{stdout}"
);
}
#[test]
fn query_from_known_class_has_no_absent_note() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args([
"--query",
"SELECT * FROM java.lang.String WHERE @objectId = 0",
])
.output()
.unwrap();
assert!(out.status.success(), "known-class query failed");
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
!stdout.to_lowercase().contains("no class"),
"known class must not get an absent-class note:\n{stdout}"
);
}
fn run_query_stdout(hprof: &str, oql: &str) -> String {
let out = Command::new(BIN)
.arg("query")
.arg(hprof)
.args(["--query", oql])
.output()
.unwrap();
assert!(
out.status.success(),
"query failed ({oql}): {}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).into_owned()
}
fn run_query_args(hprof: &str, extra: &[&str], oql: &str) -> String {
let out = Command::new(BIN)
.arg("query")
.arg(hprof)
.args(extra)
.args(["--query", oql])
.output()
.unwrap();
assert!(
out.status.success(),
"query failed ({oql} {extra:?}): {}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).into_owned()
}
fn parse_single_count(stdout: &str) -> u64 {
stdout
.lines()
.find_map(|l| l.trim().parse::<u64>().ok())
.unwrap_or_else(|| panic!("no integer count row in:\n{stdout}"))
}
fn parse_row_count(stdout: &str) -> u64 {
for l in stdout.lines() {
let t = l.trim();
if let Some(rest) = t.strip_prefix('(') {
if let Some(num) = rest.split_whitespace().next() {
if let Ok(n) = num.parse::<u64>() {
return n;
}
}
}
}
panic!("no `(N rows)` footer in:\n{stdout}");
}
#[test]
fn refpath_query_resolves_in_query_subcommand() {
let Some(hprof) = philosophers() else { return };
let out = run_query_stdout(&hprof, "SELECT t.name.hash FROM java.lang.Thread t LIMIT 5");
assert!(
!out.to_lowercase().contains("the full analysis pipeline"),
"still erroring:\n{out}"
);
assert!(
out.contains("name.hash") || out.contains("hash"),
"missing projection column:\n{out}"
);
}
#[test]
fn mixed_refpath_and_retained_query_auto_escalates() {
let Some(hprof) = philosophers() else { return };
let pure = run_query_stdout(
&hprof,
"SELECT @retainedHeapSize FROM java.lang.Thread LIMIT 2",
);
assert!(
!pure.to_lowercase().contains("the full analysis pipeline"),
"pure retained query must auto-escalate, not error:\n{pure}"
);
let mixed = run_query_stdout(
&hprof,
"SELECT t.name.hash, t.@retainedHeapSize FROM java.lang.Thread t LIMIT 3",
);
assert!(
!mixed.to_lowercase().contains("the full analysis pipeline"),
"mixed refpath+retained query must auto-escalate, not error:\n{mixed}"
);
}
#[test]
fn query_retained_heap_size_auto_escalates() {
let Some(hprof) = philosophers() else { return };
let out = run_query_stdout(
&hprof,
"SELECT c.@retainedHeapSize FROM INSTANCEOF java.lang.Thread c",
);
assert!(
!out.contains("requires the full analysis pipeline"),
"must auto-escalate, not error:\n{out}"
);
assert!(
out.lines()
.any(|l| { l.trim().chars().next().is_some_and(|c| c.is_ascii_digit()) }),
"expected at least one numeric retained-size row:\n{out}"
);
}
#[test]
fn query_escalated_respects_reachable_only_default() {
let Some(hprof) = philosophers() else { return };
let count_numeric = |s: &str| -> usize {
s.lines()
.filter(|l| l.trim().chars().next().is_some_and(|c| c.is_ascii_digit()))
.count()
};
let def = run_query_stdout(&hprof, "SELECT c.@retainedHeapSize FROM java.lang.Thread c");
let all = run_query_args(
&hprof,
&["--all"],
"SELECT c.@retainedHeapSize FROM java.lang.Thread c",
);
let nd = count_numeric(&def);
let na = count_numeric(&all);
assert!(
na >= nd,
"escalated --all ({na}) must be a superset of reachable-only ({nd})"
);
}
#[test]
fn query_count_matches_select_star_for_class_objects() {
let Some(hprof) = philosophers() else { return };
let count = parse_single_count(&run_query_args(
&hprof,
&["--all"],
"SELECT COUNT(*) FROM java.lang.Class",
));
let rows = parse_row_count(&run_query_args(
&hprof,
&["--all"],
"SELECT * FROM java.lang.Class",
));
assert_eq!(
count, rows,
"COUNT(*) ({count}) must equal SELECT * row count ({rows}) for java.lang.Class"
);
let count_re = parse_single_count(&run_query_args(
&hprof,
&["--all"],
"SELECT COUNT(*) FROM \"java.lang.*\"",
));
let rows_re = parse_row_count(&run_query_args(
&hprof,
&["--all"],
"SELECT * FROM \"java.lang.*\"",
));
assert_eq!(
count_re, rows_re,
"COUNT(*) ({count_re}) must equal SELECT * row count ({rows_re}) for java.lang.*"
);
let s_count = parse_single_count(&run_query_args(
&hprof,
&["--all"],
"SELECT COUNT(*) FROM java.lang.String",
));
let s_rows = parse_row_count(&run_query_args(
&hprof,
&["--all"],
"SELECT * FROM java.lang.String",
));
assert_eq!(s_count, s_rows, "java.lang.String count must match rows");
assert!(s_count > 0, "String count must be positive");
}
#[test]
fn query_subcommand_tostring_where_filters_in_late_phase() {
let Some(hprof) = philosophers() else { return };
let rows = parse_row_count(&run_query_stdout(
&hprof,
"SELECT s FROM java.lang.String s WHERE toString(s) LIKE \".*philosopher.*\" LIMIT 3",
));
assert!(
rows > 0,
"toString(s) LIKE positive pattern must return rows (was {rows})"
);
let projected = run_query_stdout(
&hprof,
"SELECT toString(s) FROM java.lang.String s WHERE toString(s) LIKE \".*philosopher.*\" LIMIT 5",
);
let value_lines: Vec<&str> = projected
.lines()
.map(str::trim)
.filter(|l| {
!l.is_empty()
&& !l.starts_with("==")
&& !l.starts_with("SELECT")
&& !l.starts_with('(')
&& !is_separator_line(l)
&& *l != "toString(s)"
})
.collect();
assert!(
!value_lines.is_empty(),
"expected at least one projected toString value:\n{projected}"
);
for v in &value_lines {
assert!(
v.contains("philosopher"),
"every matched row must contain 'philosopher', got: {v:?}\n{projected}"
);
}
let none = parse_row_count(&run_query_stdout(
&hprof,
"SELECT s FROM java.lang.String s WHERE toString(s) LIKE \".*zzzzzz_no_match_xyzzy.*\" LIMIT 5",
));
assert_eq!(
none, 0,
"toString(s) LIKE impossible pattern must return 0 rows (was {none})"
);
let all = parse_single_count(&run_query_stdout(
&hprof,
"SELECT COUNT(*) FROM java.lang.String",
));
let matched = parse_single_count(&run_query_stdout(
&hprof,
"SELECT COUNT(*) FROM java.lang.String s WHERE toString(s) LIKE \".*philosopher.*\"",
));
assert!(
matched > 0 && matched < all,
"filtered count ({matched}) must be >0 and < total ({all})"
);
let matched_rows = parse_row_count(&run_query_stdout(
&hprof,
"SELECT s FROM java.lang.String s WHERE toString(s) LIKE \".*philosopher.*\"",
));
assert_eq!(
matched, matched_rows,
"COUNT(*) ({matched}) must equal projected row count ({matched_rows}) under the same toString filter"
);
}
#[test]
fn tohex_formats_address() {
let Some(hprof) = philosophers() else { return };
let out = run_query_stdout(&hprof, "SELECT toHex(255) FROM java.lang.Thread LIMIT 1");
assert!(out.contains("0xff"), "got: {out}");
let out2 = run_query_stdout(&hprof, "SELECT toHex(\"x\") FROM java.lang.Thread LIMIT 1");
assert!(
!out2.contains("0x"),
"non-int arg should be Null, got: {out2}"
);
}
#[test]
fn tohex_over_expr_and_multi_column() {
let Some(hprof) = philosophers() else { return };
let out = run_query_stdout(
&hprof,
"SELECT toHex(@objectAddress + 0) FROM java.lang.Thread LIMIT 1",
);
assert!(
out.contains("0x"),
"toHex over arithmetic expr should format hex, got: {out}"
);
let multi = run_query_stdout(
&hprof,
"SELECT @objectId, toHex(@objectAddress) FROM java.lang.Thread LIMIT 1",
);
assert!(
multi.contains("0x"),
"toHex in multi-column SELECT should format hex, got: {multi}"
);
}
#[test]
fn object_address_nonzero_in_tostring_carry_mode() {
let Some(hprof) = philosophers() else { return };
let out = run_query_args(
&hprof,
&["--all"],
"SELECT @objectAddress, toString(s) AS value FROM java.lang.String s LIMIT 5",
);
let data_lines: Vec<&str> = out
.lines()
.filter(|l| {
let l = l.trim();
!l.is_empty()
&& !l.starts_with("==")
&& !l.starts_with("SELECT")
&& !l.starts_with('(')
&& !is_separator_line(l)
&& l != "@objectAddress | value"
})
.collect();
assert!(!data_lines.is_empty(), "expected data rows, got:\n{out}");
for line in &data_lines {
let addr_str = line.split('|').next().unwrap_or("").trim();
let addr: i64 = addr_str.parse().unwrap_or(0);
assert!(
addr != 0,
"@objectAddress must be non-zero in carry mode, got {addr_str:?} in:\n{out}"
);
}
}
#[test]
fn tostring_non_string_shows_class_and_address() {
let Some(hprof) = philosophers() else { return };
let out = run_query_stdout(&hprof, "SELECT toString(t) FROM java.lang.Thread t LIMIT 1");
assert!(
out.contains("java.lang.Thread @ 0x"),
"non-String toString must render `<class> @ 0x<addr>`, got:\n{out}"
);
}
#[test]
fn tostring_non_string_in_where() {
let Some(hprof) = philosophers() else { return };
let out = run_query_stdout(
&hprof,
r#"SELECT @objectId FROM java.lang.Thread t WHERE toString(t) LIKE "java\.lang\.Thread.*" LIMIT 5"#,
);
let rows = parse_row_count(&out);
assert!(
rows > 0,
"non-String toString WHERE must match rows at scan time, got:\n{out}"
);
let none = parse_row_count(&run_query_stdout(
&hprof,
r#"SELECT @objectId FROM java.lang.Thread t WHERE toString(t) LIKE ".*zzzzz_no_such_class.*""#,
));
assert_eq!(
none, 0,
"impossible display-form pattern must return 0 rows, got:\n{out}"
);
}
#[test]
fn tostring_non_string_in_multi_column_select() {
let Some(hprof) = philosophers() else { return };
let out = run_query_stdout(
&hprof,
"SELECT @objectId, toString(t) FROM java.lang.Thread t LIMIT 1",
);
assert!(
out.contains("@ 0x"),
"multi-column non-String toString must include the display address, got:\n{out}"
);
}
#[test]
fn tostring_non_string_plain_star_unchanged() {
let Some(hprof) = philosophers() else { return };
let rows = parse_row_count(&run_query_stdout(
&hprof,
"SELECT * FROM java.lang.Thread LIMIT 5",
));
assert!(
rows > 0,
"plain SELECT * over java.lang.Thread must still return rows"
);
}
#[test]
fn query_subcommand_tostring_where_rejects_unsupported_aggregates() {
let Some(hprof) = philosophers() else { return };
for oql in [
"SELECT SUM(@usedHeapSize) FROM java.lang.String s WHERE toString(s) LIKE \".*a.*\"",
"SELECT AVG(@usedHeapSize) FROM java.lang.String s WHERE toString(s) LIKE \".*a.*\"",
"SELECT MIN(@usedHeapSize) FROM java.lang.String s WHERE toString(s) LIKE \".*a.*\"",
"SELECT MAX(@usedHeapSize) FROM java.lang.String s WHERE toString(s) LIKE \".*a.*\"",
] {
let out = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args(["--query", oql])
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
!out.status.success(),
"expected rejection for unsupported aggregate + toString WHERE: {oql}"
);
assert!(
stderr.contains("toString") && stderr.contains("COUNT"),
"error must explain the toString aggregate restriction, got:\n{stderr}"
);
}
}
#[test]
fn query_subcommand_tostring_late_select_star_uses_dense_index() {
let Some(hprof) = philosophers() else { return };
let filter = "WHERE toString(s) LIKE \".*philosopher.*\" LIMIT 5";
let star = run_query_stdout(
&hprof,
&format!("SELECT * FROM java.lang.String s {filter}"),
);
let star_ids: Vec<u64> = star
.lines()
.filter_map(|l| l.trim().rsplit_once('@'))
.filter_map(|(_, n)| {
if let Some(hex) = n.strip_prefix("0x").or_else(|| n.strip_prefix("0X")) {
u64::from_str_radix(hex, 16).ok()
} else {
n.parse::<u64>().ok()
}
})
.collect();
assert!(
!star_ids.is_empty(),
"SELECT * must yield at least one object-ref row:\n{star}"
);
assert!(
star_ids.iter().all(|&id| id != 0),
"late SELECT * must not render Class@0 (valid address/index expected):\n{star}"
);
let ids = run_query_stdout(
&hprof,
&format!("SELECT @objectId FROM java.lang.String s {filter}"),
);
let obj_ids: Vec<u64> = ids
.lines()
.filter_map(|l| l.trim().parse::<u64>().ok())
.collect();
assert_eq!(
star_ids.len(),
obj_ids.len(),
"SELECT * row count ({}) must equal @objectId row count ({})",
star_ids.len(),
obj_ids.len()
);
}
#[test]
fn query_subcommand_alias_qualified_at_attr_parses() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args([
"--query",
"SELECT s.@objectId FROM java.lang.String s LIMIT 3",
])
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
!stderr.contains("OQL parse error"),
"alias-qualified @attr should not be a parse error:\n{stderr}"
);
assert!(
out.status.success(),
"alias-qualified @attr query failed: {stderr}"
);
}
#[test]
fn query_subcommand_parenthesized_union_branch_parses() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args([
"--query",
"SELECT * FROM java.lang.String UNION (SELECT * FROM java.lang.Object)",
])
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
!stderr.contains("OQL parse error"),
"parenthesized UNION branch should not be a parse error:\n{stderr}"
);
assert!(
out.status.success(),
"parenthesized UNION branch query failed: {stderr}"
);
}
#[test]
fn query_subcommand_two_queries_get_sequential_labels() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args(["--query", "SELECT COUNT(*) FROM java.lang.String"])
.args(["--query", "SELECT COUNT(*) FROM java.lang.Object"])
.output()
.unwrap();
assert!(
out.status.success(),
"two-query subcommand failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("== java.lang.String ==") && stdout.contains("== java.lang.Object =="),
"missing FROM-target label headers:\n{stdout}"
);
}
#[test]
fn query_subcommand_malformed_errors_with_text() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args(["--query", "SELCT * FROM C"])
.output()
.unwrap();
assert!(
!out.status.success(),
"malformed query unexpectedly succeeded"
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("OQL parse error"),
"missing parse-error indication:\n{stderr}"
);
assert!(
stderr.contains("SELCT * FROM C"),
"error did not echo the offending query text:\n{stderr}"
);
}
#[test]
fn query_subcommand_distinct_deduplicates_rows() {
let Some(hprof) = philosophers() else { return };
let non_distinct = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args(["--query", "SELECT @displayName FROM java.lang.Thread"])
.output()
.unwrap();
assert!(non_distinct.status.success());
let nd_out = String::from_utf8_lossy(&non_distinct.stdout);
let nd_rows: Vec<&str> = nd_out
.lines()
.filter(|l| l.trim() == "java.lang.Thread")
.collect();
assert!(
nd_rows.len() > 1,
"non-distinct Thread query must yield multiple rows:\n{nd_out}"
);
let distinct = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args([
"--query",
"SELECT DISTINCT @displayName FROM java.lang.Thread",
])
.output()
.unwrap();
assert!(
distinct.status.success(),
"DISTINCT query must succeed: {}",
String::from_utf8_lossy(&distinct.stderr)
);
let d_out = String::from_utf8_lossy(&distinct.stdout);
assert!(
d_out.contains("(1 row)"),
"DISTINCT over identical values must yield 1 row:\n{d_out}"
);
}
#[test]
fn query_subcommand_distinct_limit_returns_exactly_n_distinct() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args([
"--query",
"SELECT DISTINCT @objectId FROM java.lang.String LIMIT 5",
])
.output()
.unwrap();
assert!(
out.status.success(),
"DISTINCT LIMIT query failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("(5 rows, truncated)"),
"DISTINCT LIMIT 5 must return exactly 5 rows:\n{stdout}"
);
}
#[test]
fn query_subcommand_non_distinct_unchanged() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args(["--query", "SELECT COUNT(*) FROM java.lang.String"])
.output()
.unwrap();
assert!(out.status.success());
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("COUNT(*)"), "header present:\n{stdout}");
assert!(stdout.contains("(1 row)"), "row count present:\n{stdout}");
}
#[test]
fn query_subcommand_edge_query_auto_escalates() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args(["--query", "SELECT @inbounds FROM java.lang.String LIMIT 5"])
.output()
.unwrap();
assert!(
out.status.success(),
"edge query escalation failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
!stdout.contains("the full analysis pipeline"),
"edge query should auto-escalate, not error:\n{stdout}"
);
}
#[test]
fn query_subcommand_query_file_skips_comments_and_blanks() {
let Some(hprof) = philosophers() else { return };
let path =
std::env::temp_dir().join(format!("hprof_cli_query_file_{}.oql", std::process::id()));
std::fs::write(
&path,
"# leading comment\n\n # indented comment\nSELECT COUNT(*) FROM java.lang.String\n\n",
)
.unwrap();
let out = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args(["--query-file", path.to_str().unwrap()])
.output()
.unwrap();
let _ = std::fs::remove_file(&path);
assert!(
out.status.success(),
"query-file run failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("COUNT(*)"),
"missing COUNT header:\n{stdout}"
);
assert_eq!(
stdout.lines().filter(|l| l.starts_with("== ")).count(),
1,
"expected exactly one query result:\n{stdout}"
);
}
#[test]
fn query_subcommand_missing_query_file_errors() {
let Some(hprof) = philosophers() else { return };
let missing = "/nonexistent/path/does_not_exist_hprof_queries.oql";
let out = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args(["--query-file", missing])
.output()
.unwrap();
assert!(!out.status.success(), "missing query-file should fail");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("--query-file") && stderr.contains(missing),
"error did not name the missing query file:\n{stderr}"
);
}
#[test]
fn query_subcommand_rejects_non_hprof_input() {
let out = Command::new(BIN)
.arg("query")
.arg("some_report.json")
.args(["--query", "SELECT COUNT(*) FROM java.lang.String"])
.output()
.unwrap();
assert!(!out.status.success(), "non-hprof input should be rejected");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("not an HPROF dump"),
"missing non-HPROF hint:\n{stderr}"
);
}
#[test]
fn analyze_with_query_flag_still_produces_report() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg(&hprof)
.args(["--query", "SELECT COUNT(*) FROM java.lang.String"])
.args(["-f", "md"])
.output()
.unwrap();
assert!(
out.status.success(),
"analyze with --query failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let md = String::from_utf8_lossy(&out.stdout);
assert!(
md.contains("## System Overview"),
"normal analysis output missing:\n{}",
&md[..md.len().min(200)]
);
assert!(
md.contains("## Custom Queries"),
"rendered Custom Queries section missing:\n{md}"
);
assert!(
md.contains("COUNT(*)"),
"query result table missing COUNT(*) column:\n{md}"
);
assert!(
md.contains("### java.lang.String"),
"query heading missing its FROM-target name:\n{md}"
);
}
#[test]
fn analyze_with_bad_query_flag_fails_fast() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg(&hprof)
.args(["--query", "SELCT * FROM C"])
.output()
.unwrap();
assert!(!out.status.success(), "bad --query should fail analyze");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("OQL parse error") && stderr.contains("SELCT * FROM C"),
"analyze bad-query error not actionable:\n{stderr}"
);
}
#[test]
fn analyze_query_flag_renders_custom_queries_section_in_md_graphs() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg(&hprof)
.args(["--query", "SELECT COUNT(*) FROM java.lang.String"])
.args(["-f", "md-graphs"])
.output()
.unwrap();
assert!(
out.status.success(),
"analyze --query -f md-graphs failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let md = String::from_utf8_lossy(&out.stdout);
assert!(
md.contains("## System Overview"),
"normal md-graphs output missing:\n{}",
&md[..md.len().min(200)]
);
assert!(
md.contains("## Custom Queries"),
"md-graphs dropped the Custom Queries section:\n{md}"
);
assert!(
md.contains("COUNT(*)"),
"md-graphs query result table missing COUNT(*) column:\n{md}"
);
}
#[test]
fn analyze_query_flag_html_is_valid_document() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg(&hprof)
.args(["--query", "SELECT COUNT(*) FROM java.lang.String"])
.args(["-f", "html"])
.output()
.unwrap();
assert!(
out.status.success(),
"analyze --query -f html failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let html = String::from_utf8_lossy(&out.stdout);
assert!(
html.contains("<!DOCTYPE html>"),
"output is not an HTML document:\n{}",
&html[..html.len().min(200)]
);
assert!(
html.contains("id=\"report-data\""),
"HTML missing the embedded report-data blob:\n{}",
&html[..html.len().min(400)]
);
}
#[test]
fn analyze_multiple_query_flags_render_all() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg(&hprof)
.args(["--query", "SELECT COUNT(*) FROM java.lang.String"])
.args(["--query", "SELECT COUNT(*) FROM java.lang.Object"])
.args(["-f", "md"])
.output()
.unwrap();
assert!(
out.status.success(),
"analyze with two --query flags failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let md = String::from_utf8_lossy(&out.stdout);
assert_eq!(
md.matches("## Custom Queries").count(),
1,
"expected exactly one Custom Queries heading:\n{md}"
);
assert!(
md.matches("COUNT(*)").count() >= 2,
"expected both queries to render (>=2 COUNT(*) columns):\n{md}"
);
assert!(
md.contains("### java.lang.String") && md.contains("### java.lang.Object"),
"expected both queries to render with FROM-target headings:\n{md}"
);
}
#[test]
fn analyze_mixed_kind_queries_render_in_input_order() {
let Some(hprof) = philosophers() else { return };
let agg = "SELECT COUNT(*) FROM java.lang.String";
let scan = "SELECT * FROM java.lang.String WHERE hash > 0 LIMIT 2";
let out = Command::new(BIN)
.arg(&hprof)
.args(["--query", agg])
.args(["--query", scan])
.args(["-f", "md"])
.output()
.unwrap();
assert!(
out.status.success(),
"analyze with mixed-kind queries failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let md = String::from_utf8_lossy(&out.stdout);
let q2 = md
.find("### java.lang.String (2)")
.expect("missing de-duped `### java.lang.String (2)` heading");
let q1 = md
.find("### java.lang.String")
.expect("missing `### java.lang.String` heading");
assert!(q1 < q2, "q1 heading must precede q2 heading:\n{md}");
let q1_block = &md[q1..q2];
let q2_block = &md[q2..];
assert!(
q1_block.contains(agg),
"q1 heading is not above the aggregate OQL — ordering desync:\n{md}"
);
assert!(
q1_block.contains("| COUNT(*) |"),
"q1 block missing the aggregate result column — ordering desync:\n{md}"
);
assert!(
q1_block.contains("_1 row(s)_"),
"q1 block missing the aggregate's 1-row footer — ordering desync:\n{md}"
);
assert!(
!q1_block.contains("java.lang.String@"),
"scan's object rows leaked into the q1 block — ordering desync:\n{md}"
);
assert!(
q2_block.contains(scan),
"q2 heading is not above the scan OQL — ordering desync:\n{md}"
);
assert!(
q2_block.contains("java.lang.String@"),
"q2 block missing the scan's object rows — ordering desync:\n{md}"
);
assert!(
q2_block.contains("_2 row(s), truncated_"),
"q2 block missing the scan's truncated 2-row footer — ordering desync:\n{md}"
);
}
#[test]
fn inbounds_query_returns_referrer_rows_via_analyze_path() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg(&hprof)
.args(["--query", "SELECT @inbounds FROM java.lang.String LIMIT 5"])
.args(["-f", "md"])
.output()
.unwrap();
assert!(
out.status.success(),
"analyze with @inbounds query failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let md = String::from_utf8_lossy(&out.stdout);
assert!(
md.contains("## Custom Queries"),
"@inbounds query section missing:\n{md}"
);
assert!(
md.contains("@inbounds"),
"@inbounds result column header missing:\n{md}"
);
let section = &md[md.find("## Custom Queries").unwrap()..];
assert!(
!section.contains("**Error:**"),
"@inbounds query rendered an error block:\n{section}"
);
assert!(
!section.contains("requires the full analysis pipeline"),
"@inbounds query hit the query-only error path in the FULL analyze path:\n{section}"
);
let has_obj_row = section.lines().any(|l| {
let t = l.trim();
t.starts_with('|') && t.ends_with('|') && t.contains('@') && !t.contains("null")
});
assert!(
has_obj_row,
"no real referrer object row (`class@index`) found — EdgeLookup not wired \
end-to-end (all cells null?):\n{section}"
);
}
#[test]
fn outbounds_query_returns_target_rows_via_analyze_path() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg(&hprof)
.args(["--query", "SELECT @outbounds FROM java.lang.String LIMIT 5"])
.args(["-f", "md"])
.output()
.unwrap();
assert!(
out.status.success(),
"analyze with @outbounds query failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let md = String::from_utf8_lossy(&out.stdout);
assert!(
md.contains("## Custom Queries"),
"@outbounds query section missing:\n{md}"
);
assert!(
md.contains("@outbounds"),
"@outbounds result column header missing:\n{md}"
);
let section = &md[md.find("## Custom Queries").unwrap()..];
assert!(
!section.contains("**Error:**"),
"@outbounds query rendered an error block:\n{section}"
);
assert!(
!section.contains("requires the full analysis pipeline"),
"@outbounds query hit the query-only error path in the FULL analyze path:\n{section}"
);
}
#[test]
fn query_subcommand_address_and_heap_size_are_non_null() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args([
"--query",
"SELECT @objectAddress, @usedHeapSize FROM java.lang.String LIMIT 5",
])
.output()
.unwrap();
assert!(
out.status.success(),
"address/heap-size query failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("@objectAddress") && stdout.contains("@usedHeapSize"),
"missing attribute headers:\n{stdout}"
);
assert!(
!stdout.contains("null"),
"attributes projected null — LiveResolver addr_of/shallow_of not wired:\n{stdout}"
);
let has_int_row = stdout.lines().any(|l| {
let cells: Vec<&str> = l.split(" | ").map(str::trim).collect();
cells.len() == 2 && cells[0].parse::<u64>().is_ok() && cells[1].parse::<u64>().is_ok()
});
assert!(
has_int_row,
"no row with two integer cells (address, size) found:\n{stdout}"
);
}
#[test]
fn query_subcommand_array_length_is_non_null() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args(["--query", "SELECT @length FROM char[] LIMIT 5"])
.output()
.unwrap();
assert!(
out.status.success(),
"array @length query failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("@length"),
"missing @length header:\n{stdout}"
);
assert!(
!stdout.contains("error:"),
"array @length query reported an error:\n{stdout}"
);
assert!(
!stdout.contains("null"),
"array @length projected null — visit_array not wired:\n{stdout}"
);
let has_len = stdout.lines().any(|l| {
let t = l.trim();
t.parse::<u64>().is_ok()
});
assert!(has_len, "no integer @length row found:\n{stdout}");
}
#[test]
fn refpath_length_tail_resolves() {
let Some(hprof) = philosophers() else { return };
let out = run_query_stdout(
&hprof,
"SELECT s.value.@length FROM java.lang.String s LIMIT 3",
);
assert!(
!out.to_lowercase().contains("the full analysis pipeline"),
"RefPath @length tail should resolve in the query subcommand:\n{out}"
);
assert!(
!out.contains("error:"),
"RefPath @length tail query reported an error:\n{out}"
);
let has_len = out.lines().any(|l| l.trim().parse::<u64>().is_ok());
assert!(
has_len,
"no numeric @length row found — RefPath @length tail projected null:\n{out}"
);
}
#[test]
fn plain_count_query_unaffected_by_length_tail_wiring() {
let Some(hprof) = philosophers() else { return };
let out = run_query_stdout(&hprof, "SELECT COUNT(*) FROM java.lang.String");
assert!(out.contains("COUNT(*)"), "missing COUNT header:\n{out}");
let count = parse_single_count(&out);
assert!(count > 0, "philosophers dump has some Strings:\n{out}");
}
#[test]
fn refpath_length_tail_filters_in_where() {
let Some(hprof) = philosophers() else { return };
let out = run_query_stdout(
&hprof,
"SELECT s.value.@length FROM java.lang.String s WHERE s.value.@length > 4 LIMIT 20",
);
assert!(
!out.contains("error:") && !out.to_lowercase().contains("the full analysis pipeline"),
"WHERE @length tail query should resolve:\n{out}"
);
assert!(
parse_row_count(&out) > 0,
"WHERE s.value.@length > 4 must match Strings, got 0 rows:\n{out}"
);
for l in out.lines() {
if let Ok(n) = l.trim().parse::<u64>() {
assert!(n > 4, "row length {n} violates WHERE @length > 4:\n{out}");
}
}
}
#[test]
fn refpath_length_tail_in_where_filters() {
let Some(hprof) = philosophers() else { return };
let out = run_query_stdout(
&hprof,
"SELECT s FROM java.lang.String s WHERE s.value.@length > 0 LIMIT 5",
);
assert!(
!out.contains("error:") && !out.to_lowercase().contains("the full analysis pipeline"),
"WHERE @length > 0 query should resolve:\n{out}"
);
let matched = parse_row_count(&out);
assert!(
matched > 0,
"WHERE s.value.@length > 0 must match Strings, got 0 rows:\n{out}"
);
let out_hi = run_query_stdout(
&hprof,
"SELECT s FROM java.lang.String s WHERE s.value.@length > 1000000 LIMIT 5",
);
assert_eq!(
parse_row_count(&out_hi),
0,
"WHERE s.value.@length > 1000000 must match nothing:\n{out_hi}"
);
}
#[test]
fn refpath_length_tail_where_only_arms_capture() {
let Some(hprof) = philosophers() else { return };
let low = run_query_stdout(
&hprof,
"SELECT s FROM java.lang.String s WHERE s.value.@length > 0",
);
let high = run_query_stdout(
&hprof,
"SELECT s FROM java.lang.String s WHERE s.value.@length > 20",
);
assert!(
!low.contains("error:") && !high.contains("error:"),
"WHERE-only @length queries should resolve:\nlow:\n{low}\nhigh:\n{high}"
);
let low_n = parse_row_count(&low);
let high_n = parse_row_count(&high);
assert!(
low_n > 0,
"WHERE-only @length > 0 must match (capture must arm without SELECT):\n{low}"
);
assert!(
high_n < low_n,
"a higher @length threshold must match fewer rows ({high_n} !< {low_n}); \
capture/filter is not discriminating:\nlow:\n{low}\nhigh:\n{high}"
);
}
#[test]
fn refpath_length_tail_multi_hop_cross_class() {
let Some(hprof) = philosophers() else { return };
let out = run_query_stdout(
&hprof,
"SELECT t.name.value.@length FROM java.lang.Thread t LIMIT 5",
);
assert!(
!out.contains("error:") && !out.to_lowercase().contains("the full analysis pipeline"),
"multi-hop @length tail should resolve:\n{out}"
);
let has_len = out.lines().any(|l| l.trim().parse::<u64>().is_ok());
assert!(
has_len,
"no numeric @length row for multi-hop RefPath tail:\n{out}"
);
}
#[test]
fn method_getkey_getvalue_lower_to_refhop_addresses() {
let Some(hprof) = philosophers() else { return };
let out = run_query_stdout(
&hprof,
"SELECT e.getKey(), e.getValue() FROM java.util.HashMap$Node e LIMIT 20",
);
assert!(
!out.contains("error:") && !out.to_lowercase().contains("the full analysis pipeline"),
"getKey()/getValue() should resolve via the RefWalk pipeline:\n{out}"
);
assert!(
parse_row_count(&out) > 0,
"HashMap$Node must exist and produce rows in the fixture:\n{out}"
);
let mut non_null_pairs = 0usize;
for l in out.lines() {
let cells: Vec<&str> = l.split('|').map(str::trim).collect();
if cells.len() != 2 {
continue;
}
let (Ok(k), Ok(v)) = (cells[0].parse::<u64>(), cells[1].parse::<u64>()) else {
continue;
};
if k > 0 && v > 0 {
non_null_pairs += 1;
}
}
assert!(
non_null_pairs > 0,
"getKey()/getValue() projected all-null/zero addresses — the ref hop did \
not resolve to a live object identity:\n{out}"
);
}
#[test]
fn scan_time_methods_still_work_after_getkey_lowering() {
let Some(hprof) = philosophers() else { return };
let ints = run_query_stdout(
&hprof,
"SELECT i.intValue() FROM java.lang.Integer i LIMIT 5",
);
assert!(
!ints.contains("error:"),
"intValue() must still dispatch at scan time:\n{ints}"
);
assert!(
ints.lines().any(|l| l.trim().parse::<i64>().is_ok()),
"intValue() projected no integer — lowering leaked into scan-time dispatch:\n{ints}"
);
}
#[test]
fn query_subcommand_unknown_field_reports_error() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args(["--query", "SELECT bogusfield FROM java.lang.String"])
.output()
.unwrap();
assert!(
!out.status.success(),
"unknown-field query should exit non-zero: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("unknown field"),
"missing unknown-field error:\n{stdout}"
);
assert!(
stdout.contains("bogusfield"),
"error did not name the offending field:\n{stdout}"
);
assert!(
stdout.contains("java.lang.String"),
"error did not name the FROM class:\n{stdout}"
);
}
#[test]
fn query_subcommand_unknown_alias_field_reports_error() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args(["--query", "SELECT s.bogus FROM java.lang.String s"])
.output()
.unwrap();
assert!(
!out.status.success(),
"unknown alias-field query should exit non-zero: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("unknown field `bogus`"),
"error should name the alias-stripped bare field `bogus`:\n{stdout}"
);
}
#[test]
fn retained_query_returns_rows_via_stage_runner() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg(&hprof)
.args([
"--query",
"SELECT @objectId, @retainedHeapSize FROM java.lang.String \
ORDER BY @retainedHeapSize DESC LIMIT 5",
])
.args(["-f", "md"])
.output()
.unwrap();
assert!(
out.status.success(),
"analyze with retained query failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let md = String::from_utf8_lossy(&out.stdout);
assert!(
md.contains("## Custom Queries"),
"retained query section missing:\n{md}"
);
assert!(
md.contains("@retainedHeapSize"),
"retained query column header missing:\n{md}"
);
assert!(
!md.contains("@retainedHeapSize requires the full analysis pipeline"),
"retained query hit the query-only error path in the FULL analyze path:\n{md}"
);
let has_int_row = md.lines().any(|l| {
let cells: Vec<&str> = l
.trim()
.trim_matches('|')
.split('|')
.map(str::trim)
.collect();
cells.len() == 2 && cells[0].parse::<u64>().is_ok() && cells[1].parse::<u64>().is_ok()
});
assert!(
has_int_row,
"no rendered row with (objectId, retainedHeapSize) integers found:\n{md}"
);
}
fn query_row_count(hprof: &str, oql: &str) -> Option<u64> {
let out = Command::new(BIN)
.arg("query")
.arg(hprof)
.args(["--query", oql])
.output()
.unwrap();
if !out.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&out.stdout);
stdout.lines().rev().find_map(|l| {
let t = l.trim();
if !t.contains("row") {
return None;
}
let digits: String = t
.trim_start_matches('(')
.chars()
.take_while(|c| c.is_ascii_digit())
.collect();
digits.parse::<u64>().ok()
})
}
#[test]
fn dominators_query_returns_rows_via_analyze_path() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg(&hprof)
.args(["--query", "SELECT dominators(s) FROM java.lang.String s"])
.args(["-f", "md"])
.output()
.unwrap();
assert!(
out.status.success(),
"analyze with dominators query failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let md = String::from_utf8_lossy(&out.stdout);
assert!(
md.contains("## Custom Queries"),
"dominators query section missing:\n{md}"
);
assert!(
md.contains("dominators(s)"),
"dominators result column header missing:\n{md}"
);
assert!(
!md.contains("requires the full analysis pipeline"),
"dominators query hit the query-only error path in the FULL analyze path:\n{md}"
);
let section = &md[md.find("## Custom Queries").unwrap()..];
assert!(
!section.contains("**Error:**"),
"dominators query rendered an error block:\n{section}"
);
}
#[test]
fn retained_set_query_returns_rows_via_analyze_path() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg(&hprof)
.args([
"--query",
"SELECT s AS RETAINED SET FROM java.lang.String s \
WHERE @retainedHeapSize > 0 LIMIT 5",
])
.args(["-f", "md"])
.output()
.unwrap();
assert!(
out.status.success(),
"analyze with RETAINED SET query failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let md = String::from_utf8_lossy(&out.stdout);
assert!(
md.contains("## Custom Queries"),
"RETAINED SET query section missing:\n{md}"
);
assert!(
!md.contains("requires the full analysis pipeline"),
"RETAINED SET query hit the query-only error path in the FULL analyze path:\n{md}"
);
let section = &md[md.find("## Custom Queries").unwrap()..];
assert!(
!section.contains("**Error:**"),
"RETAINED SET query rendered an error block (bare alias `s` mis-validated \
as an unknown field?):\n{section}"
);
}
#[test]
fn escalated_row_expanding_ops_survive_reachable_only() {
let Some(hprof) = philosophers() else { return };
for oql in [
"SELECT dominators(t) FROM java.lang.Thread t LIMIT 3",
"SELECT s AS RETAINED SET FROM java.lang.Thread s LIMIT 2",
] {
let def = run_query_stdout(&hprof, oql);
let all = run_query_args(&hprof, &["--all"], oql);
assert!(
!def.to_lowercase().contains("the full analysis pipeline"),
"row-expanding escalated op hit the pipeline error ({oql}):\n{def}"
);
assert!(
!def.contains("error:"),
"row-expanding escalated op errored under reachable-only ({oql}):\n{def}"
);
assert_eq!(
parse_row_count(&def),
parse_row_count(&all),
"row-expanding ops skip reachability pruning, so default and --all \
row counts must match ({oql})"
);
}
}
#[test]
fn union_row_count_is_at_least_each_branch() {
let Some(hprof) = philosophers() else { return };
let a = query_row_count(&hprof, "SELECT @objectId FROM java.lang.String")
.expect("branch A (String) query failed or had no row count");
let b = query_row_count(&hprof, "SELECT @objectId FROM java.lang.Object")
.expect("branch B (Object) query failed or had no row count");
let u = query_row_count(
&hprof,
"SELECT @objectId FROM java.lang.String \
UNION SELECT @objectId FROM java.lang.Object",
)
.expect("UNION query failed or had no row count");
assert!(
u >= a && u >= b,
"UNION count {u} must be >= max(branch A {a}, branch B {b})"
);
}
#[test]
fn union_wide_limit_caps_total_rows_bare_form() {
let Some(hprof) = philosophers() else { return };
let full = query_row_count(
&hprof,
"SELECT @objectId FROM java.lang.String \
UNION SELECT @objectId FROM java.lang.Object",
)
.expect("plain UNION query failed or had no row count");
assert!(full > 3, "fixture UNION must exceed the LIMIT (got {full})");
let limited = query_row_count(
&hprof,
"SELECT @objectId FROM java.lang.String \
UNION SELECT @objectId FROM java.lang.Object LIMIT 3",
)
.expect("union-wide LIMIT query failed or had no row count");
assert_eq!(limited, 3, "union-wide LIMIT 3 must return EXACTLY 3 rows");
}
#[test]
fn union_wide_limit_caps_total_rows_parenthesized_form() {
let Some(hprof) = philosophers() else { return };
let limited = query_row_count(
&hprof,
"SELECT @objectId FROM java.lang.String \
UNION (SELECT @objectId FROM java.lang.Object) LIMIT 2",
)
.expect("parenthesized union-wide LIMIT query failed or had no row count");
assert_eq!(limited, 2, "union-wide LIMIT 2 must return EXACTLY 2 rows");
}
#[test]
fn from_subquery_semijoin_is_bounded_by_outer() {
let Some(hprof) = philosophers() else { return };
let outer = query_row_count(&hprof, "SELECT @objectId FROM java.lang.String")
.expect("outer-alone query failed or had no row count");
let semi = query_row_count(
&hprof,
"SELECT @objectId FROM (SELECT * FROM java.lang.String s) x",
)
.expect("FROM-subquery semi-join failed or had no row count");
assert!(
semi <= outer,
"semi-join count {semi} must be <= outer-alone count {outer}"
);
}
#[test]
fn from_subquery_limit_returns_exactly_n() {
let Some(hprof) = philosophers() else { return };
let full = query_row_count(
&hprof,
"SELECT @objectId FROM (SELECT * FROM java.lang.String s) x",
)
.expect("unbounded FROM-subquery failed or had no row count");
assert!(full >= 100, "fixture must have >= 100 strings; got {full}");
for n in [1u64, 5, 100] {
let got = query_row_count(
&hprof,
&format!("SELECT @objectId FROM (SELECT * FROM java.lang.String s) x LIMIT {n}"),
)
.expect("FROM-subquery + LIMIT failed or had no row count");
assert_eq!(
got, n,
"FROM-subquery LIMIT {n} must return EXACTLY {n} rows (SW-6), got {got}"
);
}
}
#[test]
fn from_subquery_limit_above_set_returns_whole_set() {
let Some(hprof) = philosophers() else { return };
let threads = query_row_count(&hprof, "SELECT @objectId FROM java.lang.Thread")
.expect("plain thread count failed");
let semi_limited = query_row_count(
&hprof,
"SELECT @objectId FROM (SELECT * FROM java.lang.Thread s) x LIMIT 1000000",
)
.expect("FROM-subquery + huge LIMIT failed or had no row count");
assert_eq!(
semi_limited, threads,
"FROM-subquery LIMIT above the set size must return the whole semi-joined set \
({threads}), got {semi_limited}"
);
}
#[test]
fn from_subquery_order_by_limit_returns_exactly_n() {
let Some(hprof) = philosophers() else { return };
let got = query_row_count(
&hprof,
"SELECT @objectId, @usedHeapSize FROM (SELECT * FROM java.lang.String s) x \
ORDER BY @usedHeapSize DESC LIMIT 5",
)
.expect("FROM-subquery + ORDER BY + LIMIT failed or had no row count");
assert_eq!(
got, 5,
"FROM-subquery ORDER BY ... LIMIT 5 must return EXACTLY 5 rows, got {got}"
);
}
#[test]
fn in_subquery_is_bounded_by_unfiltered() {
let Some(hprof) = philosophers() else { return };
let unfiltered = query_row_count(&hprof, "SELECT @objectAddress FROM java.lang.String")
.expect("unfiltered query failed or had no row count");
let filtered = query_row_count(
&hprof,
"SELECT @objectAddress FROM java.lang.String \
WHERE @objectAddress IN (SELECT @objectAddress FROM java.lang.String)",
)
.expect("IN-subquery query failed or had no row count");
assert!(
filtered <= unfiltered,
"IN-subquery count {filtered} must be <= unfiltered count {unfiltered}"
);
}
#[test]
fn in_subquery_inner_set_is_reachability_filtered() {
let Some(hprof) = philosophers() else { return };
let oql = "SELECT s.@objectAddress FROM INSTANCEOF java.lang.Object s \
WHERE s.@objectAddress IN (SELECT @objectAddress FROM java.lang.Thread)";
let reachable = parse_row_count(&run_query_stdout(&hprof, oql));
let all = parse_row_count(&run_query_args(&hprof, &["--all"], oql));
assert_eq!(
reachable, 27,
"reachable-only inner Thread set must be MAT's 27 reachable Threads, got {reachable}"
);
assert_eq!(
all, 29,
"--all inner Thread set must be all 29 Threads, got {all}"
);
assert!(
reachable < all,
"inner-set reachability pruning must drop the 2 unreachable Threads \
(reachable {reachable} vs all {all})"
);
}
#[test]
fn correlated_subquery_is_a_plan_error() {
let Some(hprof) = philosophers() else { return };
let oql = "SELECT @objectId FROM java.lang.String s \
WHERE @objectAddress IN \
(SELECT @objectAddress FROM java.lang.Object o WHERE s.hash > 0)";
let out = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args(["--query", oql])
.output()
.unwrap();
assert!(
!out.status.success(),
"correlated subquery should be rejected"
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("OQL plan error"),
"missing plan-error indication:\n{stderr}"
);
assert!(
stderr.contains("correlated"),
"plan error should name the correlation problem:\n{stderr}"
);
}
#[test]
fn retained_query_in_query_subcommand_auto_escalates() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args([
"--query",
"SELECT @objectId, @retainedHeapSize FROM java.lang.String",
])
.output()
.unwrap();
assert!(
out.status.success(),
"escalated retained query should exit 0: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
!stdout.contains("error:") && !stdout.contains("full analysis pipeline"),
"escalated retained query must not error:\n{stdout}"
);
assert!(
stdout.lines().any(|l| {
let cells: Vec<&str> = l.split('|').map(str::trim).collect();
cells.len() == 2 && cells.iter().all(|c| c.parse::<i64>().is_ok())
}),
"expected an (objectId, retainedHeapSize) integer row:\n{stdout}"
);
}
#[test]
fn refwalk_primitive_tail_returns_real_values_via_analyze_path() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg(&hprof)
.args([
"--query",
"SELECT n.next.hash FROM java.util.HashMap$Node n LIMIT 200",
])
.args(["-f", "md"])
.output()
.unwrap();
assert!(
out.status.success(),
"analyze with RefWalk query failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let md = String::from_utf8_lossy(&out.stdout);
assert!(
md.contains("## Custom Queries"),
"RefWalk query section missing:\n{md}"
);
assert!(
md.contains("next.hash"),
"RefWalk projected column header missing:\n{md}"
);
let section = &md[md.find("## Custom Queries").unwrap()..];
assert!(
!section.contains("**Error:**"),
"RefWalk query rendered an error block:\n{section}"
);
assert!(
!section.contains("requires the full analysis pipeline"),
"RefWalk query hit the query-only error path in the FULL analyze path:\n{section}"
);
let has_int_cell = section.lines().any(|l| {
let t = l.trim();
t.starts_with('|') && t.ends_with('|') && t.trim_matches('|').trim().parse::<i64>().is_ok()
});
assert!(
has_int_cell,
"no real integer RefWalk tail value found — CSR/tail capture not wired \
end-to-end (all cells null?):\n{section}"
);
}
#[test]
fn refwalk_object_ref_tail_projects_null_without_crashing() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg(&hprof)
.args(["--query", "SELECT s.value FROM java.lang.String s LIMIT 5"])
.args(["-f", "md"])
.output()
.unwrap();
assert!(
out.status.success(),
"analyze with object-ref-tail RefWalk query failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let md = String::from_utf8_lossy(&out.stdout);
assert!(
md.contains("## Custom Queries"),
"object-ref-tail RefWalk section missing:\n{md}"
);
let section = &md[md.find("## Custom Queries").unwrap()..];
assert!(
!section.contains("**Error:**"),
"object-ref-tail RefWalk rendered an error block (should be Null, not error):\n{section}"
);
assert!(
section.contains("| value |") || section.contains("value"),
"object-ref-tail RefWalk column header missing:\n{section}"
);
}
#[test]
fn refwalk_query_in_query_only_path_resolves() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args([
"--query",
"SELECT n.next.hash FROM java.util.HashMap$Node n LIMIT 5",
])
.output()
.unwrap();
assert!(
out.status.success(),
"query-only RefWalk query should exit 0: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
!stdout.contains("error:"),
"RefWalk query should no longer surface an error line:\n{stdout}"
);
assert!(
!stdout.to_lowercase().contains("the full analysis pipeline"),
"RefWalk query must no longer require the full pipeline:\n{stdout}"
);
assert!(
stdout.contains("next.hash") || stdout.contains("hash"),
"missing RefWalk projection column:\n{stdout}"
);
}
fn query_count_value(hprof: &str, oql: &str) -> Option<u64> {
let out = Command::new(BIN)
.arg("query")
.arg(hprof)
.args(["--query", oql])
.output()
.unwrap();
if !out.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&out.stdout);
stdout.lines().find_map(|l| l.trim().parse::<u64>().ok())
}
#[test]
fn query_count_star_char_array_is_positive() {
let Some(hprof) = philosophers() else { return };
let n = query_count_value(&hprof, "SELECT COUNT(*) FROM char[]")
.expect("COUNT(*) FROM char[] query failed or printed no numeric cell");
assert!(
n > 0,
"COUNT(*) FROM char[] must be > 0 (fixture has char arrays); got {n} — \
raw-vs-pretty class-name asymmetry between scan and histogram paths"
);
}
#[test]
fn query_count_star_char_array_equals_scan_row_count() {
let Some(hprof) = philosophers() else { return };
let count = query_count_value(&hprof, "SELECT COUNT(*) FROM char[]")
.expect("COUNT(*) FROM char[] failed or printed no numeric cell");
let rows = query_row_count(&hprof, "SELECT * FROM char[]")
.expect("SELECT * FROM char[] failed or had no row-count footer");
assert!(count > 0, "COUNT(*) FROM char[] must be > 0; got {count}");
assert!(
rows > 0,
"SELECT * FROM char[] must return rows; got {rows}"
);
assert_eq!(
count, rows,
"histogram COUNT(*) ({count}) must equal scan row count ({rows}) for char[] \
— scan/histogram class-name normalization must agree"
);
}
#[test]
fn query_count_star_int_array_matches_scan() {
let Some(hprof) = philosophers() else { return };
let count = parse_single_count(&run_query_args(
&hprof,
&["--all"],
"SELECT COUNT(*) FROM int[]",
));
let rows = parse_row_count(&run_query_args(&hprof, &["--all"], "SELECT * FROM int[]"));
assert!(count > 0, "COUNT(*) FROM int[] must be > 0; got {count}");
assert_eq!(
count, rows,
"histogram COUNT(*) ({count}) must equal scan row count ({rows}) for int[]"
);
}
#[test]
fn no_edge_run_baseline_unchanged() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg(&hprof)
.args(["--format", "json"])
.output()
.unwrap();
assert!(
out.status.success(),
"no-query analyze failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let v: serde_json::Value =
serde_json::from_slice(&out.stdout).expect("analyzer stdout was not valid JSON");
assert!(
v.get("queries").is_none(),
"no-edge run must not emit a `queries` section, got: {:?}",
v.get("queries")
);
let text = String::from_utf8_lossy(&out.stdout);
assert!(
!text.contains("edge query: retaining"),
"no-edge run must not surface any edge-retention note:\n{text}"
);
}
#[test]
fn regex_from_matches_many_java_lang_classes() {
let Some(hprof) = philosophers() else { return };
let regex_count = query_count_value(&hprof, r#"SELECT COUNT(*) FROM "java\.lang\..*""#)
.expect("regex FROM query must succeed");
let string_count = query_count_value(&hprof, "SELECT COUNT(*) FROM java.lang.String")
.expect("exact FROM query must succeed");
assert!(
regex_count > 0,
"regex FROM java.lang.* should match instances, got {regex_count}"
);
assert!(
regex_count >= string_count,
"regex over java.lang.* ({regex_count}) must be >= java.lang.String alone ({string_count})"
);
}
#[test]
fn regex_from_trailing_string_matches_java_lang_string() {
let Some(hprof) = philosophers() else { return };
let re_count = query_count_value(&hprof, r#"SELECT COUNT(*) FROM ".*String""#)
.expect(".*String regex query must succeed");
let exact = query_count_value(&hprof, "SELECT COUNT(*) FROM java.lang.String")
.expect("exact query must succeed");
assert!(
re_count >= exact && exact > 0,
".*String ({re_count}) must include java.lang.String ({exact})"
);
}
#[test]
fn regex_from_matching_nothing_is_zero() {
let Some(hprof) = philosophers() else { return };
let n = query_count_value(&hprof, r#"SELECT COUNT(*) FROM "no\.such\.Class\d+""#)
.expect("no-match regex must still succeed with COUNT 0");
assert_eq!(n, 0, "regex matching nothing must yield COUNT 0");
}
#[test]
fn bare_glob_from_still_matches() {
let Some(hprof) = philosophers() else { return };
let n = query_count_value(&hprof, "SELECT COUNT(*) FROM java.util.*")
.expect("bare glob query must succeed");
assert!(n > 0, "bare glob java.util.* should match, got {n}");
}
#[test]
fn bad_regex_from_is_actionable_cli_error() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args(["--query", r#"SELECT COUNT(*) FROM "[""#])
.output()
.unwrap();
let combined = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(
combined.contains("invalid regex"),
"bad regex must surface an actionable 'invalid regex' error, got:\n{combined}"
);
}
#[test]
fn like_full_match_on_display_name_matches_all() {
let Some(hprof) = philosophers() else { return };
let all = query_row_count(&hprof, "SELECT @objectId FROM java.lang.String")
.expect("baseline java.lang.String count must succeed");
let liked = query_row_count(
&hprof,
r#"SELECT @objectId FROM java.lang.String WHERE @displayName LIKE "java\.lang\.String""#,
)
.expect("LIKE full-match query must succeed");
assert_eq!(
liked, all,
"LIKE on the exact class name must match all {all} instances, got {liked}"
);
}
#[test]
fn like_is_anchored_partial_pattern_matches_nothing() {
let Some(hprof) = philosophers() else { return };
let n = query_row_count(
&hprof,
r#"SELECT @objectId FROM java.lang.String WHERE @displayName LIKE "String""#,
)
.expect("anchored LIKE query must succeed");
assert_eq!(
n, 0,
"partial LIKE pattern must not match (full-match), got {n}"
);
}
#[test]
fn not_like_exact_name_matches_nothing() {
let Some(hprof) = philosophers() else { return };
let n = query_row_count(
&hprof,
r#"SELECT @objectId FROM java.lang.String WHERE @displayName NOT LIKE "java\.lang\.String""#,
)
.expect("NOT LIKE query must succeed");
assert_eq!(
n, 0,
"NOT LIKE the exact class name must match nothing, got {n}"
);
}
#[test]
fn like_wildcard_matches_all() {
let Some(hprof) = philosophers() else { return };
let all = query_row_count(&hprof, "SELECT @objectId FROM java.lang.String")
.expect("baseline count must succeed");
let liked = query_row_count(
&hprof,
r#"SELECT @objectId FROM java.lang.String WHERE @displayName LIKE ".*""#,
)
.expect("wildcard LIKE query must succeed");
assert_eq!(
liked, all,
"LIKE \".*\" must match all {all} rows, got {liked}"
);
}
#[test]
fn bad_like_regex_is_actionable_cli_error() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args([
"--query",
r#"SELECT @objectId FROM java.lang.String WHERE @displayName LIKE "[""#,
])
.output()
.unwrap();
let combined = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(
combined.contains("invalid regex in LIKE"),
"bad LIKE regex must surface an actionable error, got:\n{combined}"
);
}
#[test]
fn tostring_non_string_from_now_renders_display_form() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args([
"--query",
"SELECT toString(s) FROM java.lang.Object s LIMIT 1",
])
.output()
.unwrap();
assert!(
out.status.success(),
"non-String toString must now succeed: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("@ 0x"),
"non-String toString must render `<class> @ 0x<addr>`, got:\n{stdout}"
);
}
#[test]
fn tostring_select_returns_string_values() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args([
"--query",
"SELECT toString(s) FROM java.lang.String s LIMIT 20",
])
.output()
.unwrap();
assert!(
out.status.success(),
"toString SELECT query must succeed: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("toString"),
"toString column header missing:\n{stdout}"
);
assert!(
stdout.contains("row"),
"toString query must return rows:\n{stdout}"
);
assert!(
!stdout.contains("error:"),
"toString SELECT query surfaced an unexpected error:\n{stdout}"
);
}
#[test]
fn tostring_where_like_filters_strings() {
let Some(hprof) = philosophers() else { return };
let all_strings = query_row_count(&hprof, "SELECT @objectId FROM java.lang.String")
.expect("baseline String count must succeed");
let filtered = query_row_count(
&hprof,
r#"SELECT @objectId FROM java.lang.String s WHERE toString(s) LIKE "java\..*""#,
)
.expect("WHERE toString LIKE query must succeed");
assert!(
filtered < all_strings,
"WHERE toString LIKE filter must exclude some rows \
(filtered={filtered} must be < all_strings={all_strings})"
);
}
#[test]
fn tostring_works_via_full_analyze_path() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg(&hprof)
.args([
"--query",
"SELECT toString(s) FROM java.lang.String s LIMIT 5",
])
.args(["-f", "md"])
.output()
.unwrap();
assert!(
out.status.success(),
"analyze with toString query failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let md = String::from_utf8_lossy(&out.stdout);
assert!(
md.contains("## Custom Queries"),
"toString query section missing in analyze output:\n{md}"
);
let section = &md[md.find("## Custom Queries").unwrap()..];
assert!(
!section.contains("**Error:**"),
"toString query rendered an error block in analyze path:\n{section}"
);
assert!(
section.contains("toString"),
"toString column header missing in analyze output section:\n{section}"
);
}
#[test]
fn tostring_broad_regex_from_renders_display_form() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args([
"--query",
r#"SELECT toString(s) FROM "java\.lang\..*" s LIMIT 5"#,
])
.output()
.unwrap();
assert!(
out.status.success(),
"broad-regex toString must now succeed: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("@ 0x"),
"broad-regex toString must render the `<class> @ 0x<addr>` display form; got:\n{stdout}"
);
}
#[test]
fn from_objects_count_equals_plain_from() {
let Some(hprof) = philosophers() else { return };
let with_objects = query_count_value(&hprof, "SELECT COUNT(*) FROM OBJECTS java.lang.String")
.expect("FROM OBJECTS query must succeed and return a numeric cell");
let without = query_count_value(&hprof, "SELECT COUNT(*) FROM java.lang.String")
.expect("plain FROM query must succeed and return a numeric cell");
assert!(
with_objects > 0,
"FROM OBJECTS java.lang.String must return > 0 rows (not silently 0); got {with_objects}"
);
assert!(
without > 0,
"FROM java.lang.String must return > 0 rows; got {without}"
);
assert_eq!(
with_objects, without,
"COUNT(*) FROM OBJECTS java.lang.String ({with_objects}) must equal \
COUNT(*) FROM java.lang.String ({without}) — OBJECTS is a no-op marker"
);
}
#[test]
fn from_objects_row_count_equals_plain_from() {
let Some(hprof) = philosophers() else { return };
let with_objects = query_row_count(&hprof, "SELECT * FROM OBJECTS java.lang.String")
.expect("FROM OBJECTS SELECT * must succeed and return a row count");
let without = query_row_count(&hprof, "SELECT * FROM java.lang.String")
.expect("plain SELECT * FROM must succeed and return a row count");
assert!(
with_objects > 0,
"FROM OBJECTS java.lang.String must return > 0 object rows; got {with_objects}"
);
assert_eq!(
with_objects, without,
"SELECT * FROM OBJECTS ({with_objects}) must return the same rows as \
SELECT * FROM ({without})"
);
}
#[test]
fn from_objects_instanceof_is_accepted_end_to_end() {
let Some(hprof) = philosophers() else { return };
let with_objects = query_row_count(&hprof, "SELECT * FROM OBJECTS INSTANCEOF java.lang.String")
.expect("FROM OBJECTS INSTANCEOF must succeed");
let without = query_row_count(&hprof, "SELECT * FROM INSTANCEOF java.lang.String")
.expect("FROM INSTANCEOF must succeed");
assert!(
with_objects > 0,
"FROM OBJECTS INSTANCEOF java.lang.String must return > 0 rows; got {with_objects}"
);
assert_eq!(
with_objects, without,
"FROM OBJECTS INSTANCEOF ({with_objects}) must equal FROM INSTANCEOF ({without})"
);
}
#[test]
fn from_instanceof_includes_subclasses() {
let Some(hprof) = philosophers() else { return };
let exact = query_count_value(&hprof, "SELECT COUNT(*) FROM java.lang.Thread")
.expect("exact FROM Thread count must succeed");
let subclasses = query_count_value(&hprof, "SELECT COUNT(*) FROM INSTANCEOF java.lang.Thread")
.expect("INSTANCEOF Thread count must succeed");
assert!(exact > 0, "fixture must have some Threads; got {exact}");
assert!(
subclasses > exact,
"INSTANCEOF java.lang.Thread ({subclasses}) must include subclasses and \
therefore exceed exact FROM java.lang.Thread ({exact}); equal counts mean \
the hierarchy walk regressed to exact-only matching"
);
}
#[test]
fn exact_from_excludes_subclasses() {
let Some(hprof) = philosophers() else { return };
let subclass_exact = query_count_value(
&hprof,
r#"SELECT COUNT(*) FROM "org\.renaissance\.scala\.stm\..*PhilosopherThread""#,
)
.expect("exact subclass count must succeed");
let thread_exact = query_count_value(&hprof, "SELECT COUNT(*) FROM java.lang.Thread")
.expect("exact Thread count must succeed");
assert!(
subclass_exact > 0,
"fixture must contain PhilosopherThread instances; got {subclass_exact}"
);
assert!(
thread_exact < subclass_exact,
"exact FROM java.lang.Thread ({thread_exact}) must exclude the \
{subclass_exact} PhilosopherThread subclass instances"
);
}
#[test]
fn instanceof_count_matches_projection_row_count() {
let Some(hprof) = philosophers() else { return };
let count = parse_single_count(&run_query_args(
&hprof,
&["--all"],
"SELECT COUNT(*) FROM INSTANCEOF java.lang.Thread",
));
let rows = parse_row_count(&run_query_args(
&hprof,
&["--all"],
"SELECT @objectAddress FROM INSTANCEOF java.lang.Thread",
));
assert_eq!(
count, rows,
"COUNT(*) INSTANCEOF Thread ({count}) must equal the projection row count \
({rows}); a mismatch means the histogram fast path counted a different \
(exact-only) universe than the scan"
);
}
#[test]
fn instanceof_object_spans_full_universe() {
let Some(hprof) = philosophers() else { return };
let count = parse_single_count(&run_query_args(
&hprof,
&["--all"],
"SELECT COUNT(*) FROM INSTANCEOF java.lang.Object",
));
let rows = parse_row_count(&run_query_args(
&hprof,
&["--all"],
"SELECT * FROM INSTANCEOF java.lang.Object",
));
let threads = parse_single_count(&run_query_args(
&hprof,
&["--all"],
"SELECT COUNT(*) FROM INSTANCEOF java.lang.Thread",
));
assert_eq!(
count, rows,
"COUNT(*) INSTANCEOF Object ({count}) must equal its projection row count ({rows})"
);
assert!(
count > threads,
"INSTANCEOF Object ({count}) must span far more than INSTANCEOF Thread ({threads})"
);
}
#[test]
fn where_instanceof_walks_hierarchy() {
let Some(hprof) = philosophers() else { return };
let via_where = query_row_count(
&hprof,
"SELECT @objectAddress FROM INSTANCEOF java.lang.Object o WHERE o INSTANCEOF java.lang.Thread",
)
.expect("WHERE INSTANCEOF query must succeed");
let via_from = query_row_count(
&hprof,
"SELECT @objectAddress FROM INSTANCEOF java.lang.Thread",
)
.expect("FROM INSTANCEOF Thread must succeed");
assert!(
via_from > 0,
"fixture must have Thread-subtype objects; got {via_from}"
);
assert_eq!(
via_where, via_from,
"WHERE o INSTANCEOF Thread ({via_where}) must match FROM INSTANCEOF Thread \
({via_from}) — the WHERE predicate must walk the superclass chain too"
);
}
#[test]
fn from_objects_case_insensitive_end_to_end() {
let Some(hprof) = philosophers() else { return };
let baseline = query_count_value(&hprof, "SELECT COUNT(*) FROM java.lang.String")
.expect("baseline query must succeed");
for variant in &[
"SELECT COUNT(*) FROM OBJECTS java.lang.String",
"SELECT COUNT(*) FROM objects java.lang.String",
"SELECT COUNT(*) FROM Objects java.lang.String",
] {
let n = query_count_value(&hprof, variant)
.unwrap_or_else(|| panic!("query failed or printed no count: {variant}"));
assert_eq!(
n, baseline,
"FROM OBJECTS case variant {variant:?} must yield the same count as the baseline {baseline}"
);
}
}
fn query_stdout(hprof: &str, oql: &str) -> String {
let out = Command::new(BIN)
.arg("query")
.arg(hprof)
.args(["--query", oql])
.output()
.unwrap();
assert!(
out.status.success(),
"query failed for {oql:?}: {}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).into_owned()
}
#[test]
fn alias_bytes_column_name_in_output() {
let Some(hprof) = philosophers() else { return };
let stdout = query_stdout(
&hprof,
"SELECT @usedHeapSize AS bytes FROM java.lang.String LIMIT 1",
);
assert!(
stdout.contains("bytes"),
"expected column header 'bytes' in output:\n{stdout}"
);
assert!(
!stdout.lines().any(|l| l.trim() == "@usedHeapSize"),
"a line must not consist solely of derived name @usedHeapSize:\n{stdout}"
);
}
#[test]
fn alias_aggregate_column_name_in_output() {
let Some(hprof) = philosophers() else { return };
let stdout = query_stdout(&hprof, "SELECT COUNT(*) AS n FROM java.lang.String");
assert!(
stdout.lines().any(|l| l == "n"),
"expected a line exactly 'n' as column header in output:\n{stdout}"
);
}
#[test]
fn no_alias_derived_column_name_preserved() {
let Some(hprof) = philosophers() else { return };
let stdout = query_stdout(&hprof, "SELECT COUNT(*) FROM java.lang.String");
assert!(
stdout.contains("COUNT(*)"),
"derived name COUNT(*) must appear when no alias is set:\n{stdout}"
);
}
#[test]
fn multiple_aliases_both_appear_in_output() {
let Some(hprof) = philosophers() else { return };
let stdout = query_stdout(
&hprof,
"SELECT @objectId AS id, @usedHeapSize AS bytes FROM java.lang.String LIMIT 1",
);
assert!(
stdout.contains("id"),
"expected column header 'id' in output:\n{stdout}"
);
assert!(
stdout.contains("bytes"),
"expected column header 'bytes' in output:\n{stdout}"
);
}
#[test]
fn alias_union_head_branch_wins() {
let Some(hprof) = philosophers() else { return };
let stdout = query_stdout(
&hprof,
"SELECT @objectId AS id FROM java.lang.String LIMIT 1 \
UNION SELECT @objectId FROM java.lang.Object LIMIT 1",
);
assert!(
stdout.contains("id"),
"expected alias 'id' (head-branch wins) in output:\n{stdout}"
);
}
#[test]
fn alias_quoted_name_appears_in_output() {
let Some(hprof) = philosophers() else { return };
let stdout = query_stdout(
&hprof,
r#"SELECT @usedHeapSize AS "heap_size" FROM java.lang.String LIMIT 1"#,
);
assert!(
stdout.contains("heap_size"),
"expected quoted alias 'heap_size' in output:\n{stdout}"
);
}
#[test]
fn histogram_alias_column_header_is_alias_not_derived() {
let Some(hprof) = philosophers() else { return };
let stdout = query_stdout(&hprof, "SELECT COUNT(*) AS n FROM java.lang.String");
assert!(
stdout.lines().any(|l| l == "n"),
"histogram alias: expected a line exactly 'n' as column header, got:\n{stdout}"
);
assert!(
!stdout.lines().any(|l| l == "COUNT(*)"),
"histogram alias: column header must not be derived 'COUNT(*)' when alias is set:\n{stdout}"
);
}
#[test]
fn as_retained_set_does_not_produce_retained_column() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args([
"--query",
"SELECT s AS RETAINED SET FROM java.lang.String s LIMIT 1",
])
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
!stderr.contains("OQL parse error"),
"AS RETAINED SET must not cause a parse error:\n{stderr}"
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
!stdout.contains("| RETAINED |") && !stdout.starts_with("RETAINED"),
"output must not have a column named RETAINED:\n{stdout}"
);
}
#[test]
fn select_objects_noop_rows_identical_to_plain_select() {
let Some(hprof) = philosophers() else { return };
let with_objects = query_stdout(&hprof, "SELECT OBJECTS s FROM java.lang.String s LIMIT 3");
let without_objects = query_stdout(&hprof, "SELECT s FROM java.lang.String s LIMIT 3");
let data_rows = |s: &str| -> Vec<String> {
s.lines()
.filter(|l| !l.trim_start().starts_with("SELECT") && !l.starts_with("=="))
.map(|l| l.to_owned())
.collect()
};
assert_eq!(
data_rows(&with_objects),
data_rows(&without_objects),
"SELECT OBJECTS must produce identical data rows to SELECT"
);
}
#[test]
fn leading_as_retained_set_end_to_end() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args([
"--query",
"SELECT AS RETAINED SET s FROM java.lang.String s LIMIT 1",
])
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
!stderr.contains("OQL parse error"),
"leading AS RETAINED SET must not cause a parse error:\n{stderr}"
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
!stdout.contains("| RETAINED |") && !stdout.starts_with("RETAINED"),
"output must not have a column named RETAINED:\n{stdout}"
);
}
#[test]
fn path_query_returns_rows_via_analyze_path() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg(&hprof)
.args([
"--query",
"SELECT path(a, b) FROM java.lang.String a LIMIT 5",
])
.args(["-f", "md"])
.output()
.unwrap();
assert!(
out.status.success(),
"analyze with path(a,b) query failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let md = String::from_utf8_lossy(&out.stdout);
assert!(
md.contains("## Custom Queries"),
"path(a,b) query section missing:\n{md}"
);
let section = &md[md.find("## Custom Queries").unwrap()..];
assert!(
!section.contains("**Error:**"),
"path(a,b) query rendered an error block:\n{section}"
);
assert!(
!section.contains("requires the full analysis pipeline"),
"path(a,b) query hit the query-only error path in the FULL analyze path:\n{section}"
);
assert!(
section.contains("path(a, b)"),
"BoundedPath op did not finalize — no column header in output:\n{section}"
);
}
fn query(hprof: &str, oql: &str) -> std::process::Output {
Command::new(BIN)
.arg("query")
.arg(hprof)
.args(["--query", oql])
.output()
.unwrap()
}
#[test]
fn arith_projection_double_used_heap_size() {
let Some(hprof) = philosophers() else { return };
let out = query(
&hprof,
"SELECT @usedHeapSize * 2 FROM java.lang.String LIMIT 3",
);
assert!(
out.status.success(),
"arithmetic projection failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("@usedHeapSize * 2"),
"missing arithmetic column header:\n{stdout}"
);
let count_48 = stdout.lines().filter(|l| l.trim() == "48").count();
assert!(
count_48 >= 3,
"expected at least 3 rows with value 48, got:\n{stdout}"
);
}
#[test]
fn arith_where_division_filters_rows() {
let Some(hprof) = philosophers() else { return };
let out = query(
&hprof,
"SELECT @usedHeapSize FROM java.lang.String WHERE @usedHeapSize / 8 > 2 LIMIT 3",
);
assert!(
out.status.success(),
"arithmetic WHERE query failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("(3 rows, truncated)"),
"expected 3 truncated rows:\n{stdout}"
);
}
#[test]
fn arith_projection_with_alias() {
let Some(hprof) = philosophers() else { return };
let out = query(
&hprof,
"SELECT @usedHeapSize * 2 AS kb FROM java.lang.String LIMIT 1",
);
assert!(
out.status.success(),
"aliased arithmetic projection failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("kb"),
"alias `kb` missing from header:\n{stdout}"
);
let has_kb_header = stdout.lines().any(|l| l.trim() == "kb");
assert!(
has_kb_header,
"column header should be exactly `kb`:\n{stdout}"
);
assert!(
stdout.lines().any(|l| l.trim() == "48"),
"expected value 48 in output:\n{stdout}"
);
assert!(
stdout.contains("(1 row"),
"expected (1 row...) footer:\n{stdout}"
);
}
#[test]
fn scan_agg_sum_over_expression_equals_2x_plain_sum() {
let Some(hprof) = philosophers() else { return };
let out = query(
&hprof,
"SELECT SUM(@usedHeapSize * 2) FROM java.lang.String",
);
assert!(
out.status.success(),
"SUM over expression failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("SUM(@usedHeapSize * 2)"),
"missing aggregate column header:\n{stdout}"
);
assert!(
stdout.lines().any(|l| l.trim() == "1188480"),
"expected value 1188480, got:\n{stdout}"
);
assert!(
!stdout.lines().any(|l| l.trim() == "null"),
"null in output — accumulator is broken:\n{stdout}"
);
assert!(
stdout.contains("(1 row)"),
"expected (1 row) footer:\n{stdout}"
);
}
#[test]
fn histogram_sum_regression_still_correct() {
let Some(hprof) = philosophers() else { return };
let out = query(&hprof, "SELECT SUM(@usedHeapSize) FROM java.lang.String");
assert!(out.status.success());
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.lines().any(|l| l.trim() == "594240"),
"histogram SUM regression: expected 594240, got:\n{stdout}"
);
}
#[test]
fn histogram_count_regression_still_correct() {
let Some(hprof) = philosophers() else { return };
let out = query(&hprof, "SELECT COUNT(*) FROM java.lang.String");
assert!(out.status.success());
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.lines().any(|l| l.trim() == "24760"),
"histogram COUNT regression: expected 24760, got:\n{stdout}"
);
}
#[test]
fn scan_agg_avg_over_expression() {
let Some(hprof) = philosophers() else { return };
let out = query(
&hprof,
"SELECT AVG(@usedHeapSize * 2) FROM java.lang.String",
);
assert!(
out.status.success(),
"AVG over expression failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("AVG(@usedHeapSize * 2)"),
"missing AVG column header:\n{stdout}"
);
let has_48 = stdout.lines().any(|l| {
let t = l.trim();
t == "48" || t == "48.0" || t.starts_with("48.")
});
assert!(has_48, "expected AVG ≈ 48, got:\n{stdout}");
assert!(
!stdout.lines().any(|l| l.trim() == "null"),
"null in AVG output — accumulator broken:\n{stdout}"
);
}
#[test]
fn scan_agg_sum_with_where_matches_histogram_total() {
let Some(hprof) = philosophers() else { return };
let out = query(
&hprof,
"SELECT SUM(@usedHeapSize) FROM java.lang.String WHERE @usedHeapSize > 0",
);
assert!(
out.status.success(),
"WHERE-filtered SUM failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.lines().any(|l| l.trim() == "594240"),
"scan SUM with WHERE must equal histogram SUM 594240, got:\n{stdout}"
);
assert!(
!stdout.lines().any(|l| l.trim() == "null"),
"null in WHERE-filtered SUM — accumulator broken:\n{stdout}"
);
}
#[test]
fn scan_agg_min_max_are_sane() {
let Some(hprof) = philosophers() else { return };
let out = query(
&hprof,
"SELECT MIN(@usedHeapSize), MAX(@usedHeapSize) FROM java.lang.String WHERE @usedHeapSize > 0",
);
assert!(
out.status.success(),
"MIN/MAX query failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("MIN(@usedHeapSize)") && stdout.contains("MAX(@usedHeapSize)"),
"missing MIN/MAX column headers:\n{stdout}"
);
assert!(
!stdout.lines().any(|l| l.trim() == "null"),
"null in MIN/MAX output:\n{stdout}"
);
assert!(
stdout.contains("(1 row)"),
"MIN/MAX must emit exactly one row:\n{stdout}"
);
let nums: Vec<i64> = stdout
.lines()
.find_map(|l| {
let parts: Vec<&str> = l.split('|').collect();
let parsed: Option<Vec<i64>> =
parts.iter().map(|s| s.trim().parse::<i64>().ok()).collect();
parsed
})
.expect("no numeric data row found in MIN/MAX output");
assert!(
nums.len() >= 2,
"expected at least 2 numeric values (min, max), got {:?}:\n{stdout}",
nums
);
let mn = *nums.iter().min().unwrap();
let mx = *nums.iter().max().unwrap();
assert!(
mn >= 1,
"MIN must be ≥ 1 (WHERE @usedHeapSize > 0), got {mn}"
);
assert!(mx >= mn, "MAX must be ≥ MIN, got min={mn} max={mx}");
}
#[test]
fn scan_agg_double_sum_equals_2x_histogram() {
let Some(hprof) = philosophers() else { return };
let hist_out = query(&hprof, "SELECT SUM(@usedHeapSize) FROM java.lang.String");
let hist_stdout = String::from_utf8_lossy(&hist_out.stdout);
let hist_sum: i64 = hist_stdout
.lines()
.find_map(|l| l.trim().parse::<i64>().ok())
.expect("histogram SUM must be a parseable integer");
let scan_out = query(
&hprof,
"SELECT SUM(@usedHeapSize * 2) FROM java.lang.String",
);
let scan_stdout = String::from_utf8_lossy(&scan_out.stdout);
let scan_sum: i64 = scan_stdout
.lines()
.find_map(|l| l.trim().parse::<i64>().ok())
.expect("scan SUM(*2) must be a parseable integer, not null");
assert_eq!(
scan_sum,
hist_sum * 2,
"SUM(@usedHeapSize * 2) must equal 2 × SUM(@usedHeapSize): {} ≠ 2×{}",
scan_sum,
hist_sum
);
}
#[test]
fn bare_alias_select_returns_non_null_objects() {
let Some(hprof) = philosophers() else { return };
let out = query(&hprof, "SELECT s FROM java.lang.String s LIMIT 3");
assert!(
out.status.success(),
"bare-alias query failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("(3 rows, truncated)"),
"bare alias SELECT s must return 3 rows, got:\n{stdout}"
);
let null_rows = stdout.lines().filter(|l| l.trim() == "null").count();
assert_eq!(
null_rows, 0,
"bare alias SELECT s must not produce null rows, got:\n{stdout}"
);
assert!(
stdout.contains("java.lang.String"),
"bare alias SELECT s must display String object refs:\n{stdout}"
);
}
#[test]
fn count_alias_equals_count_star() {
let Some(hprof) = philosophers() else { return };
let out_star = query(&hprof, "SELECT COUNT(*) FROM java.lang.String");
assert!(out_star.status.success());
let stdout_star = String::from_utf8_lossy(&out_star.stdout);
let count_star: u64 = stdout_star
.lines()
.find_map(|l| l.trim().parse::<u64>().ok())
.expect("COUNT(*) must produce a parseable integer");
assert!(
count_star > 0,
"COUNT(*) must be non-zero, got {count_star}"
);
let out_alias = query(&hprof, "SELECT COUNT(s) FROM java.lang.String s");
assert!(
out_alias.status.success(),
"COUNT(s) query failed: {}",
String::from_utf8_lossy(&out_alias.stderr)
);
let stdout_alias = String::from_utf8_lossy(&out_alias.stdout);
let count_alias: u64 = stdout_alias
.lines()
.find_map(|l| l.trim().parse::<u64>().ok())
.expect("COUNT(s) must produce a parseable integer (not 0 / null)");
assert_eq!(
count_alias, count_star,
"COUNT(s) must equal COUNT(*): {} ≠ {}",
count_alias, count_star
);
}
#[test]
fn bare_alias_on_subquery_from_returns_objects() {
let Some(hprof) = philosophers() else { return };
let out = query(
&hprof,
"SELECT z FROM OBJECTS (SELECT * FROM java.lang.String) z",
);
assert!(
out.status.success(),
"subquery bare-alias query failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
let ref_out = query(&hprof, "SELECT * FROM java.lang.String");
let ref_stdout = String::from_utf8_lossy(&ref_out.stdout);
let ref_row_count = ref_stdout
.lines()
.rev()
.find_map(|l| {
let l = l.trim();
if l.starts_with('(') && l.ends_with("rows)") {
l[1..l.len() - 6].trim().parse::<u64>().ok()
} else if l.starts_with('(') && l.ends_with("row)") {
l[1..l.len() - 5].trim().parse::<u64>().ok()
} else {
None
}
})
.expect("SELECT * reference query must have a row-count footer");
assert!(ref_row_count > 0, "reference query must return rows");
let row_count = stdout
.lines()
.rev()
.find_map(|l| {
let l = l.trim();
if l.starts_with('(') && l.ends_with("rows)") {
l[1..l.len() - 6].trim().parse::<u64>().ok()
} else if l.starts_with('(') && l.ends_with("row)") {
l[1..l.len() - 5].trim().parse::<u64>().ok()
} else {
None
}
})
.expect("SELECT z subquery must have a row-count footer (got 0 rows)");
assert_eq!(
row_count, ref_row_count,
"SELECT z FROM OBJECTS subquery must return {} rows (same as SELECT *), got {}",
ref_row_count, row_count
);
let null_rows = stdout.lines().filter(|l| l.trim() == "null").count();
assert_eq!(
null_rows, 0,
"bare alias SELECT z must not produce null rows, got:\n{stdout}"
);
}
#[test]
fn count_alias_on_subquery_equals_count_star() {
let Some(hprof) = philosophers() else { return };
let out_star = query(&hprof, "SELECT COUNT(*) FROM java.lang.String");
assert!(out_star.status.success());
let stdout_star = String::from_utf8_lossy(&out_star.stdout);
let count_star: u64 = stdout_star
.lines()
.find_map(|l| l.trim().parse::<u64>().ok())
.expect("COUNT(*) on class must parse");
let out_alias = query(&hprof, "SELECT COUNT(s) FROM java.lang.String s");
assert!(out_alias.status.success());
let stdout_alias = String::from_utf8_lossy(&out_alias.stdout);
let count_alias: u64 = stdout_alias
.lines()
.find_map(|l| l.trim().parse::<u64>().ok())
.expect("COUNT(s) must parse (bare alias rewrites to Star)");
assert_eq!(
count_alias, count_star,
"COUNT(s) must equal COUNT(*): {} ≠ {}",
count_alias, count_star
);
}
#[test]
fn dotted_alias_at_attr_unaffected_by_bare_alias_fix() {
let Some(hprof) = philosophers() else { return };
let out = query(&hprof, "SELECT s.@objectId FROM java.lang.String s LIMIT 3");
assert!(
out.status.success(),
"dotted alias @objectId query failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("(3 rows, truncated)"),
"dotted alias s.@objectId must return 3 rows:\n{stdout}"
);
let has_int = stdout.lines().any(|l| l.trim().parse::<u64>().is_ok());
assert!(
has_int,
"dotted alias s.@objectId must yield integer rows:\n{stdout}"
);
}
#[test]
fn sum_alias_does_not_crash() {
let Some(hprof) = philosophers() else { return };
let out = query(&hprof, "SELECT SUM(s) FROM java.lang.String s");
assert!(
out.status.success(),
"SUM(s) crashed or failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("SUM(*)"),
"SUM(s) column must be renamed SUM(*) after rewrite, got:\n{stdout}"
);
assert!(
stdout.contains("(1 row)"),
"SUM(s) must yield 1 aggregate row, got:\n{stdout}"
);
}
fn query_single_i64(hprof: &str, oql: &str) -> Option<i64> {
let out = Command::new(BIN)
.arg("query")
.arg(hprof)
.args(["--query", oql])
.output()
.unwrap();
if !out.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&out.stdout);
stdout.lines().find_map(|l| l.trim().parse::<i64>().ok())
}
#[test]
fn min_used_heap_size_is_non_null_positive() {
let Some(hprof) = philosophers() else { return };
let val = query_single_i64(
&hprof,
"SELECT MIN(s.@usedHeapSize) FROM java.lang.String s",
)
.expect("MIN(s.@usedHeapSize) must return a non-null integer (was null before fix)");
assert!(
val > 0,
"MIN(@usedHeapSize) must be a positive integer (shallow size > 0); got {val}"
);
}
#[test]
fn max_used_heap_size_is_non_null_positive() {
let Some(hprof) = philosophers() else { return };
let val = query_single_i64(
&hprof,
"SELECT MAX(s.@usedHeapSize) FROM java.lang.String s",
)
.expect("MAX(s.@usedHeapSize) must return a non-null integer (was null before fix)");
assert!(
val > 0,
"MAX(@usedHeapSize) must be a positive integer; got {val}"
);
}
#[test]
fn min_avg_max_used_heap_size_ordering() {
let Some(hprof) = philosophers() else { return };
let min = query_single_i64(
&hprof,
"SELECT MIN(s.@usedHeapSize) FROM java.lang.String s",
)
.expect("MIN(@usedHeapSize) must not be null");
let max = query_single_i64(
&hprof,
"SELECT MAX(s.@usedHeapSize) FROM java.lang.String s",
)
.expect("MAX(@usedHeapSize) must not be null");
let out = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args([
"--query",
"SELECT AVG(s.@usedHeapSize) FROM java.lang.String s",
])
.output()
.unwrap();
assert!(out.status.success(), "AVG(@usedHeapSize) failed");
let avg_str = String::from_utf8_lossy(&out.stdout);
let avg: f64 = avg_str
.lines()
.find_map(|l| l.trim().parse::<f64>().ok())
.expect("AVG(@usedHeapSize) must print a numeric value");
assert!(
min <= max,
"MIN({min}) must be <= MAX({max}) for @usedHeapSize"
);
assert!(
(min as f64) <= avg,
"MIN({min}) must be <= AVG({avg}) for @usedHeapSize"
);
assert!(
avg <= (max as f64) + 1.0,
"AVG({avg}) must be <= MAX({max}) for @usedHeapSize"
);
}
#[test]
fn min_object_id_is_non_null_non_negative() {
let Some(hprof) = philosophers() else { return };
let val = query_single_i64(&hprof, "SELECT MIN(s.@objectId) FROM java.lang.String s")
.expect("MIN(s.@objectId) must return a non-null integer");
assert!(
val >= 0,
"MIN(@objectId) must be a non-negative integer; got {val}"
);
}
#[test]
fn max_object_id_geq_min_object_id() {
let Some(hprof) = philosophers() else { return };
let min = query_single_i64(&hprof, "SELECT MIN(s.@objectId) FROM java.lang.String s")
.expect("MIN(@objectId) must not be null");
let max = query_single_i64(&hprof, "SELECT MAX(s.@objectId) FROM java.lang.String s")
.expect("MAX(@objectId) must not be null");
assert!(min <= max, "MIN({min}) must be <= MAX({max}) for @objectId");
}
#[test]
fn sum_and_count_still_work_after_routing_fix() {
let Some(hprof) = philosophers() else { return };
let sum = query_single_i64(
&hprof,
"SELECT SUM(s.@usedHeapSize) FROM java.lang.String s",
)
.expect("SUM(@usedHeapSize) must still return a value after routing fix");
assert!(sum > 0, "SUM(@usedHeapSize) must be > 0; got {sum}");
let count = query_count_value(&hprof, "SELECT COUNT(*) FROM java.lang.String")
.expect("COUNT(*) must still return a value after routing fix");
assert!(count > 0, "COUNT(*) must be > 0; got {count}");
assert!(
sum >= count as i64,
"SUM({sum}) must be >= COUNT({count}) (shallow size >= 1 per object)"
);
}
fn ordered_int_column(stdout: &str) -> Vec<i64> {
stdout
.lines()
.filter_map(|l| {
let t = l.trim();
if t.starts_with('(') {
return None;
}
t.parse::<i64>().ok()
})
.collect()
}
#[test]
fn order_by_scan_attr_desc_is_sorted() {
let Some(hprof) = philosophers() else { return };
let out = run_query_stdout(
&hprof,
"SELECT s.@usedHeapSize FROM java.lang.String s ORDER BY s.@usedHeapSize DESC LIMIT 50",
);
let vals = ordered_int_column(&out);
assert!(
vals.len() >= 2,
"need at least 2 rows to check ordering:\n{out}"
);
for w in vals.windows(2) {
assert!(
w[0] >= w[1],
"DESC ORDER BY not sorted: {} came before {}\n{out}",
w[0],
w[1]
);
}
}
#[test]
fn order_by_scan_attr_asc_is_sorted() {
let Some(hprof) = philosophers() else { return };
let out = run_query_stdout(
&hprof,
"SELECT s.@usedHeapSize FROM java.lang.String s ORDER BY s.@usedHeapSize ASC LIMIT 50",
);
let vals = ordered_int_column(&out);
assert!(vals.len() >= 2, "need at least 2 rows:\n{out}");
for w in vals.windows(2) {
assert!(
w[0] <= w[1],
"ASC not sorted: {} before {}\n{out}",
w[0],
w[1]
);
}
}
#[test]
fn order_by_limit_returns_true_top_n() {
let Some(hprof) = philosophers() else { return };
let top5 = ordered_int_column(&run_query_stdout(
&hprof,
"SELECT s.@usedHeapSize FROM java.lang.String s ORDER BY s.@usedHeapSize DESC LIMIT 5",
));
let top500 = ordered_int_column(&run_query_stdout(
&hprof,
"SELECT s.@usedHeapSize FROM java.lang.String s ORDER BY s.@usedHeapSize DESC LIMIT 500",
));
assert!(!top5.is_empty() && !top500.is_empty(), "expected rows");
assert_eq!(
top5[0], top500[0],
"top-5 max ({}) != top-500 max ({}): LIMIT applied before sort",
top5[0], top500[0]
);
assert_eq!(&top5[..], &top500[..top5.len().min(top500.len())]);
}
#[test]
fn percentile_p95_at_least_median() {
let Some(hprof) = philosophers() else { return };
let p95 = query_single_i64(
&hprof,
"SELECT PERCENTILE(s.@usedHeapSize, 95) FROM java.lang.String s",
)
.expect("p95 must return a numeric value");
let median = query_single_i64(
&hprof,
"SELECT MEDIAN(s.@usedHeapSize) FROM java.lang.String s",
)
.expect("median must return a numeric value");
assert!(p95 >= median, "p95 ({p95}) must be >= median ({median})");
}
#[test]
fn median_equals_percentile_50() {
let Some(hprof) = philosophers() else { return };
let median = query_single_i64(
&hprof,
"SELECT MEDIAN(s.@usedHeapSize) FROM java.lang.String s",
)
.expect("median value");
let p50 = query_single_i64(
&hprof,
"SELECT PERCENTILE(s.@usedHeapSize, 50) FROM java.lang.String s",
)
.expect("p50 value");
assert_eq!(median, p50, "MEDIAN must equal PERCENTILE(_, 50)");
}
#[test]
fn percentile_100_equals_max() {
let Some(hprof) = philosophers() else { return };
let p100 = query_single_i64(
&hprof,
"SELECT PERCENTILE(s.@usedHeapSize, 100) FROM java.lang.String s",
)
.expect("p100 value");
let max = query_single_i64(
&hprof,
"SELECT MAX(s.@usedHeapSize) FROM java.lang.String s",
)
.expect("max value");
assert_eq!(p100, max, "PERCENTILE(_, 100) must equal MAX");
}
#[test]
fn percentile_1_near_min() {
let Some(hprof) = philosophers() else { return };
let p1 = query_single_i64(
&hprof,
"SELECT PERCENTILE(s.@usedHeapSize, 1) FROM java.lang.String s",
)
.expect("p1 value");
let min = query_single_i64(
&hprof,
"SELECT MIN(s.@usedHeapSize) FROM java.lang.String s",
)
.expect("min value");
let median = query_single_i64(
&hprof,
"SELECT MEDIAN(s.@usedHeapSize) FROM java.lang.String s",
)
.expect("median value");
assert!(p1 >= min, "p1 ({p1}) must be >= min ({min})");
assert!(p1 <= median, "p1 ({p1}) must be <= median ({median})");
}
#[test]
fn percentile_empty_set_is_null() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args([
"--query",
"SELECT PERCENTILE(@usedHeapSize, 95) FROM com.example.DoesNotExist",
])
.output()
.unwrap();
assert!(
out.status.success(),
"empty-set percentile must not error: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.to_lowercase().contains("null") || stdout.contains("(1 row)"),
"empty-set percentile should render a null aggregate row, got:\n{stdout}"
);
}
#[test]
fn percentile_out_of_range_is_cli_error() {
let Some(hprof) = philosophers() else { return };
for bad in ["0", "101"] {
let oql = format!("SELECT PERCENTILE(@usedHeapSize, {bad}) FROM java.lang.String");
let out = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args(["--query", &oql])
.output()
.unwrap();
assert!(!out.status.success(), "PERCENTILE p={bad} must be rejected");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("between 1 and 100"),
"error must name the valid p range, got:\n{stderr}"
);
}
}
#[test]
fn percentile_over_retained_is_cli_error() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args([
"--query",
"SELECT PERCENTILE(@retainedHeapSize, 95) FROM java.lang.String",
])
.output()
.unwrap();
assert!(
!out.status.success(),
"percentile over @retainedHeapSize must be rejected"
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("@retainedHeapSize"),
"error must explain the retained-size restriction, got:\n{stderr}"
);
}
#[test]
fn viz_directive_serializes_into_report_json() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg(&hprof)
.args(["--format", "json"])
.args([
"--query=-- @viz histogram label=name value=bytes\nSELECT @displayName AS name, @usedHeapSize AS bytes FROM java.lang.Thread LIMIT 5",
])
.output()
.unwrap();
assert!(
out.status.success(),
"analyze with viz query failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("\"viz\""),
"report JSON must carry a viz object:\n{}",
&stdout[..stdout.len().min(4000)]
);
assert!(
stdout.contains("\"histogram\""),
"viz kind histogram must appear:\n{}",
&stdout[..stdout.len().min(4000)]
);
}
#[test]
fn viz_block_without_name_uses_from_target() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg(&hprof)
.args(["--format", "json"])
.args([
"--query=-- @viz histogram label=name value=bytes\nSELECT @displayName AS name, @usedHeapSize AS bytes FROM java.lang.Thread LIMIT 5",
])
.output()
.unwrap();
assert!(
out.status.success(),
"analyze with unnamed viz query failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("\"name\": \"java.lang.Thread\"")
|| stdout.contains("\"name\":\"java.lang.Thread\""),
"unnamed block must derive its name from the FROM target:\n{}",
&stdout[..stdout.len().min(4000)]
);
assert!(
!stdout.contains("\"name\": \"q1\"") && !stdout.contains("\"name\":\"q1\""),
"unnamed block must NOT fall back to the positional q1 label:\n{}",
&stdout[..stdout.len().min(4000)]
);
}
#[test]
fn viz_explicit_name_still_wins_over_from_target() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg(&hprof)
.args(["--format", "json"])
.args([
"--query=-- @viz histogram name=\"My Threads\" label=name value=bytes\nSELECT @displayName AS name, @usedHeapSize AS bytes FROM java.lang.Thread LIMIT 5",
])
.output()
.unwrap();
assert!(
out.status.success(),
"analyze with named viz query failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("\"name\": \"My Threads\"") || stdout.contains("\"name\":\"My Threads\""),
"explicit @viz name must win over the FROM-target default:\n{}",
&stdout[..stdout.len().min(4000)]
);
assert!(
!stdout.contains("java.lang.Thread\"") || stdout.contains("\"My Threads\""),
"explicit name present:\n{}",
&stdout[..stdout.len().min(4000)]
);
}
#[test]
fn viz_duplicate_from_targets_are_deduped() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg(&hprof)
.args(["--format", "json"])
.args(["--query", "SELECT COUNT(*) FROM java.lang.String"])
.args(["--query", "SELECT COUNT(*) FROM java.lang.String"])
.output()
.unwrap();
assert!(
out.status.success(),
"analyze with duplicate FROM targets failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("\"name\": \"java.lang.String\"")
|| stdout.contains("\"name\":\"java.lang.String\""),
"first block keeps the bare FROM-target name:\n{}",
&stdout[..stdout.len().min(4000)]
);
assert!(
stdout.contains("\"name\": \"java.lang.String (2)\"")
|| stdout.contains("\"name\":\"java.lang.String (2)\""),
"second identical block must be de-duped to `... (2)`:\n{}",
&stdout[..stdout.len().min(4000)]
);
}
#[test]
fn malformed_viz_directive_falls_back_with_note() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg(&hprof)
.args(["--format", "json"])
.args(["--query=-- @viz boguskind\nSELECT COUNT(*) FROM java.lang.String"])
.output()
.unwrap();
assert!(
out.status.success(),
"malformed viz must not fail the query: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("\"note\""),
"malformed directive must produce a note:\n{stdout}"
);
assert!(
stdout.contains("unknown chart kind"),
"note must explain the malformed kind:\n{stdout}"
);
assert!(
!stdout.contains("\"viz\""),
"malformed directive must not attach a viz object:\n{stdout}"
);
}
#[test]
fn viz_unchartable_columns_downgrade_to_table_note() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg(&hprof)
.args(["--format", "json"])
.args([
"--query=-- @viz histogram value=@displayName\nSELECT @displayName FROM java.lang.Thread LIMIT 5",
])
.output()
.unwrap();
assert!(
out.status.success(),
"unchartable viz must not fail the query: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("\"note\""),
"unchartable column must produce a note:\n{stdout}"
);
assert!(
!stdout.contains("\"viz\""),
"unchartable directive must not attach a viz object:\n{stdout}"
);
}
#[test]
fn config_query_entry_is_run_and_named() {
let Some(hprof) = philosophers() else { return };
let dir = std::env::temp_dir().join(format!("hprof_cfgq_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let cfg = dir.join(".hprof-analyzer.toml");
std::fs::write(
&cfg,
"[[query]]\nname = \"strcount\"\noql = \"SELECT COUNT(*) FROM java.lang.String\"\n",
)
.unwrap();
let out = Command::new(BIN)
.current_dir(&dir)
.arg("query")
.arg(&hprof)
.output()
.unwrap();
let _ = std::fs::remove_file(&cfg);
let _ = std::fs::remove_dir(&dir);
assert!(
out.status.success(),
"config-query run failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("== strcount =="),
"config query name must label the result:\n{stdout}"
);
assert!(
stdout.contains("COUNT(*)"),
"config query must actually run:\n{stdout}"
);
}
#[test]
fn viz_directive_line_in_query_file_attaches_to_next() {
let Some(hprof) = philosophers() else { return };
let qf = std::env::temp_dir().join(format!("hprof_vizqf_{}.oql", std::process::id()));
std::fs::write(
&qf,
"-- @viz histogram label=name value=bytes\nSELECT @displayName AS name, @usedHeapSize AS bytes FROM java.lang.Thread LIMIT 5\n",
)
.unwrap();
let out = Command::new(BIN)
.arg(&hprof)
.args(["--format", "json"])
.arg("--query-file")
.arg(&qf)
.output()
.unwrap();
let _ = std::fs::remove_file(&qf);
assert!(
out.status.success(),
"query-file with viz directive failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("\"viz\"") && stdout.contains("\"histogram\""),
"query-file directive must attach a histogram viz:\n{stdout}"
);
}
#[test]
fn from_objects_single_address_returns_one_row() {
let Some(hprof) = philosophers() else { return };
let seed = run_query_stdout(
&hprof,
"SELECT @objectAddress FROM java.lang.Thread LIMIT 1",
);
let addr = seed
.lines()
.find_map(|l| {
let t = l.trim();
if t.starts_with('(') {
return None;
}
t.parse::<u64>().ok().filter(|&n| n != 0)
})
.unwrap_or_else(|| panic!("no object address in seed output:\n{seed}"));
let by_addr = run_query_stdout(
&hprof,
&format!("SELECT @objectAddress FROM OBJECTS {addr}"),
);
assert!(
by_addr.lines().any(|l| l.trim() == addr.to_string()),
"FROM OBJECTS {addr} did not return that address:\n{by_addr}"
);
assert_eq!(
parse_row_count(&by_addr),
1,
"FROM OBJECTS <real addr> must return exactly one row:\n{by_addr}"
);
let count = run_query_stdout(&hprof, &format!("SELECT COUNT(*) FROM OBJECTS {addr}"));
assert_eq!(
parse_single_count(&count),
1,
"COUNT(*) FROM OBJECTS <real addr> must be 1:\n{count}"
);
let bogus = run_query_stdout(&hprof, "SELECT @objectAddress FROM OBJECTS 0x1");
assert_eq!(
parse_row_count(&bogus),
0,
"FROM OBJECTS 0x1 (bogus) must return zero rows:\n{bogus}"
);
}
fn extract_data_rows(stdout: &str) -> Vec<&str> {
stdout
.lines()
.map(str::trim)
.filter(|l| {
!l.is_empty()
&& !l.starts_with("==")
&& !l.starts_with("SELECT")
&& !l.starts_with('(')
&& !is_separator_line(l)
})
.skip(1) .collect()
}
#[test]
fn method_alias_getname_equals_class() {
let Some(hprof) = philosophers() else { return };
let a = run_query_stdout(&hprof, "SELECT s.getName() FROM java.lang.Thread s LIMIT 1");
let b = run_query_stdout(
&hprof,
"SELECT @displayName FROM java.lang.Thread s LIMIT 1",
);
let av = extract_data_rows(&a);
let bv = extract_data_rows(&b);
assert_eq!(
av, bv,
"getName() values must match @displayName values\na={a}\nb={b}"
);
}
#[test]
fn method_equals_returns_bool() {
let Some(hprof) = philosophers() else { return };
let out = run_query_stdout(
&hprof,
"SELECT i.equals(1) FROM java.lang.Integer i LIMIT 3",
);
let rows = extract_data_rows(&out);
assert!(!rows.is_empty(), "equals(1) produced no rows:\n{out}");
assert!(
rows.iter().all(|r| *r == "true" || *r == "false"),
"equals(1) column must be all bool literals; got {rows:?}\n{out}"
);
let selfeq = run_query_stdout(
&hprof,
"SELECT i.equals(i) FROM java.lang.Integer i LIMIT 3",
);
let self_rows = extract_data_rows(&selfeq);
assert!(
!self_rows.is_empty(),
"equals(i) produced no rows:\n{selfeq}"
);
assert!(
self_rows.iter().all(|r| *r == "true"),
"i.equals(i) must be true for every row (identity); got {self_rows:?}\n{selfeq}"
);
}
#[test]
fn method_contains_string() {
let Some(hprof) = philosophers() else { return };
let out = run_query_stdout(
&hprof,
"SELECT s.contains(\"a\") FROM java.lang.String s LIMIT 3",
);
assert!(
out.contains("contains(\"a\")"),
"missing contains() column header:\n{out}"
);
let rows = extract_data_rows(&out);
assert!(!rows.is_empty(), "contains produced no rows:\n{out}");
assert!(
rows.iter().all(|r| *r == "null"),
"String.contains is Null-limited at scan time; got {rows:?}\n{out}"
);
}
#[test]
fn method_get_rejected_nonzero_exit() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args(["--query", "SELECT a.get(0) FROM java.util.ArrayList a"])
.output()
.unwrap();
assert!(
!out.status.success(),
"get(0) must exit non-zero (plan rejection)"
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("requires a live JVM") && stderr.contains("elementData"),
"get(0) rejection must be actionable (live-JVM + elementData hint):\n{stderr}"
);
}
#[test]
fn method_alias_getobjectaddress_equals_attr() {
let Some(hprof) = philosophers() else { return };
let a = run_query_stdout(
&hprof,
"SELECT s.getObjectAddress() FROM java.lang.Thread s LIMIT 3",
);
let b = run_query_stdout(
&hprof,
"SELECT @objectAddress FROM java.lang.Thread s LIMIT 3",
);
let av = extract_data_rows(&a);
let bv = extract_data_rows(&b);
assert_eq!(
av, bv,
"getObjectAddress() values must match @objectAddress values\na={a}\nb={b}"
);
}
#[test]
fn method_emulate_self_class_fields() {
let Some(hprof) = philosophers() else { return };
let iv = run_query_stdout(
&hprof,
"SELECT i.intValue() FROM java.lang.Integer i LIMIT 5",
);
let vf = run_query_stdout(&hprof, "SELECT value FROM java.lang.Integer LIMIT 5");
assert_eq!(
extract_data_rows(&iv),
extract_data_rows(&vf),
"intValue() must equal the `value` field\niv={iv}\nvf={vf}"
);
let sz = run_query_stdout(&hprof, "SELECT a.size() FROM java.util.ArrayList a LIMIT 5");
let sf = run_query_stdout(&hprof, "SELECT size FROM java.util.ArrayList LIMIT 5");
assert_eq!(
extract_data_rows(&sz),
extract_data_rows(&sf),
"size() must equal the `size` field\nsz={sz}\nsf={sf}"
);
}
#[test]
fn method_in_arithmetic() {
let Some(hprof) = philosophers() else { return };
let a = run_query_stdout(
&hprof,
"SELECT i.intValue() * 2 FROM java.lang.Integer i LIMIT 3",
);
assert!(!a.trim().is_empty());
}
#[test]
fn gcroot_query_only_mode_auto_escalates() {
let Some(hprof) = philosophers() else { return };
for attr in &["@GCRoots", "@GCRootInfo", "@info"] {
let out = run_query_stdout(&hprof, &format!("SELECT {attr} FROM java.lang.Thread"));
assert!(
!out.to_lowercase().contains("the full analysis pipeline"),
"{attr} query must auto-escalate, not error:\n{out}"
);
}
}
#[test]
fn gcroot_attrs_resolve_in_analyze_mode() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg(&hprof)
.args([
"--query",
"SELECT @GCRootInfo, @objectId FROM java.lang.Thread LIMIT 200",
])
.output()
.unwrap();
assert!(
out.status.success(),
"analyze --query failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let md = String::from_utf8_lossy(&out.stdout);
assert!(
!md.to_lowercase().contains("the full analysis pipeline"),
"analyze-mode gc-root query must not be rejected:\n{md}"
);
assert!(
md.contains("| Thread |") || md.contains("Thread |"),
"expected a root Thread descriptor in analyze-mode output:\n{md}"
);
assert!(
md.contains("| null |"),
"expected at least one non-root Thread (null descriptor):\n{md}"
);
let out2 = Command::new(BIN)
.arg(&hprof)
.args([
"--query",
"SELECT @GCRootInfo FROM java.lang.String LIMIT 3",
])
.output()
.unwrap();
assert!(out2.status.success());
let md2 = String::from_utf8_lossy(&out2.stdout);
if let Some(section) = md2.split("## Custom Queries").nth(1) {
let section = section.split("## Glossary").next().unwrap_or(section);
assert!(
!section.contains("Thread")
&& !section.contains("JNI")
&& !section.contains("System Class"),
"non-root String rows must all be null:\n{section}"
);
}
}
#[test]
fn query_reachable_only_is_default_and_all_is_superset() {
let Some(hprof) = philosophers() else { return };
let def = run_query_stdout(&hprof, "SELECT @objectAddress FROM java.lang.Thread");
let all = run_query_args(
&hprof,
&["--all"],
"SELECT @objectAddress FROM java.lang.Thread",
);
let n_def = parse_row_count(&def);
let n_all = parse_row_count(&all);
assert!(
n_all > n_def,
"--all ({n_all}) must be a superset of reachable-only ({n_def})"
);
assert_eq!(n_def, 27, "reachable-only Thread count must match MAT (27)");
assert_eq!(n_all, 29, "raw-heap Thread count is 29");
}
fn run_analyze_md(hprof: &str, extra: &[&str], oql: &str) -> String {
let out = Command::new(BIN)
.arg(hprof)
.args(extra)
.args(["--query", oql])
.args(["-f", "md"])
.output()
.unwrap();
assert!(
out.status.success(),
"analyze failed ({oql} {extra:?}): {}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).into_owned()
}
fn count_addr_rows_in_report(md: &str) -> usize {
let Some(section) = md.split("## Custom Queries").nth(1) else {
return 0;
};
let section = section.split("\n## ").next().unwrap_or(section);
section
.lines()
.filter(|l| {
let t = l.trim();
let inner = t.trim_start_matches('|').trim_end_matches('|').trim();
!inner.is_empty() && !inner.contains('|') && inner.parse::<u64>().is_ok()
})
.count()
}
#[test]
fn analyze_reachable_only_filters_oql_rows() {
let Some(hprof) = philosophers() else { return };
let def = run_analyze_md(&hprof, &[], "SELECT @objectAddress FROM java.lang.Thread");
let ro = run_analyze_md(
&hprof,
&["--reachable-only"],
"SELECT @objectAddress FROM java.lang.Thread",
);
let n_def = count_addr_rows_in_report(&def);
let n_ro = count_addr_rows_in_report(&ro);
assert_eq!(n_def, 29, "raw-heap analyze Thread count is 29:\n{def}");
assert_eq!(
n_ro, 27,
"--reachable-only analyze must prune Thread rows to MAT parity (27):\n{ro}"
);
}
mod server_cli {
use super::{BIN, philosophers};
use std::io::{Read, Write};
use std::net::TcpStream;
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
struct Server {
child: Child,
port: u16,
}
impl Drop for Server {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
fn free_port() -> u16 {
std::net::TcpListener::bind("127.0.0.1:0")
.unwrap()
.local_addr()
.unwrap()
.port()
}
#[allow(clippy::zombie_processes)]
fn spawn(hprof: &str) -> Server {
let port = free_port();
let child = Command::new(BIN)
.arg("query")
.arg(hprof)
.args(["--server", "--port", &port.to_string()])
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("spawn server");
let deadline = Instant::now() + Duration::from_secs(15);
while Instant::now() < deadline {
if TcpStream::connect(("127.0.0.1", port)).is_ok() {
return Server { child, port };
}
std::thread::sleep(Duration::from_millis(50));
}
panic!("server on port {port} never came up");
}
fn http(port: u16, method: &str, path: &str, body: &str) -> String {
let mut stream = TcpStream::connect(("127.0.0.1", port)).expect("connect");
stream
.set_read_timeout(Some(Duration::from_secs(10)))
.unwrap();
let req = format!(
"{method} {path} HTTP/1.1\r\nHost: localhost\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
stream.write_all(req.as_bytes()).expect("write");
let mut resp = String::new();
stream.read_to_string(&mut resp).expect("read");
resp
}
fn parse_resp(resp: &str) -> (String, String, String) {
let (head, body) = resp.split_once("\r\n\r\n").unwrap_or((resp, ""));
let mut lines = head.lines();
let status = lines.next().unwrap_or("").to_string();
let headers = lines.collect::<Vec<_>>().join("\n").to_lowercase();
(status, headers, body.to_string())
}
#[test]
fn server_post_query_returns_json_with_content_type() {
let Some(hprof) = philosophers() else { return };
let srv = spawn(&hprof);
let resp = http(
srv.port,
"POST",
"/",
"SELECT @objectAddress FROM java.lang.Thread",
);
let (status, headers, body) = parse_resp(&resp);
assert!(
status.contains("200"),
"expected 200, got status {status:?}\n{resp}"
);
assert!(
headers.contains("content-type: application/json"),
"worker must set JSON content-type, headers:\n{headers}"
);
let v: serde_json::Value =
serde_json::from_str(&body).unwrap_or_else(|e| panic!("body not JSON ({e}): {body}"));
assert_eq!(v["ok"], serde_json::json!(true), "expected ok: {v}");
assert!(
v["result"]["row_count"].as_u64().unwrap() > 0,
"some rows: {v}"
);
}
#[test]
fn server_parse_error_is_400_json() {
let Some(hprof) = philosophers() else { return };
let srv = spawn(&hprof);
let resp = http(srv.port, "POST", "/", "SELCT bad");
let (status, _headers, body) = parse_resp(&resp);
assert!(
status.contains("400"),
"expected 400, got {status:?}\n{resp}"
);
let v: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(v["ok"], serde_json::json!(false), "failure: {v}");
assert_eq!(
v["error"]["kind"],
serde_json::json!("parse"),
"parse kind: {v}"
);
}
#[test]
fn server_get_help_returns_language_reference() {
let Some(hprof) = philosophers() else { return };
let srv = spawn(&hprof);
let resp = http(srv.port, "GET", "/help", "");
let (status, _headers, body) = parse_resp(&resp);
assert!(
status.contains("200"),
"expected 200, got {status:?}\n{resp}"
);
let v: serde_json::Value = serde_json::from_str(&body).unwrap();
assert!(v["keywords"].is_array(), "keywords listed: {v}");
assert!(v["usage"].is_object(), "usage present: {v}");
}
#[test]
fn server_survives_many_sequential_requests() {
let Some(hprof) = philosophers() else { return };
let srv = spawn(&hprof);
for i in 0..8 {
let oql = if i % 2 == 0 {
"SELECT @objectAddress FROM java.lang.Thread"
} else {
"TOTALLY INVALID"
};
let resp = http(srv.port, "POST", "/", oql);
let (status, _h, _b) = parse_resp(&resp);
let want = if i % 2 == 0 { "200" } else { "400" };
assert!(
status.contains(want),
"req {i} expected {want}, got {status:?}"
);
}
}
#[test]
fn server_star_obj_ref_carries_address() {
let Some(hprof) = philosophers() else { return };
let srv = spawn(&hprof);
let resp = http(srv.port, "POST", "/", "SELECT * FROM java.lang.Thread");
let (status, _h, body) = parse_resp(&resp);
assert!(status.contains("200"), "200: {resp}");
let v: serde_json::Value = serde_json::from_str(&body).unwrap();
let first = &v["result"]["rows"][0][0];
assert_eq!(
first["kind"],
serde_json::json!("obj_ref"),
"obj_ref value: {first}"
);
assert!(
first["v"]["addr"].is_u64(),
"obj_ref carries a numeric addr: {first}"
);
assert!(
first["v"]["index"].is_u64() && first["v"]["class"].is_string(),
"index+class still present"
);
}
#[test]
fn server_schema_endpoint_returns_json_schema() {
let Some(hprof) = philosophers() else { return };
let srv = spawn(&hprof);
let (status, _h, body) = parse_resp(&http(srv.port, "GET", "/schema", ""));
assert!(status.contains("200"), "200: {body}");
let v: serde_json::Value = serde_json::from_str(&body).unwrap();
let s = serde_json::to_string(&v).unwrap();
assert!(
s.contains("QueryResult"),
"schema mentions QueryResult: {}",
&s[..s.len().min(200)]
);
}
#[test]
fn server_version_endpoint_lists_endpoints() {
let Some(hprof) = philosophers() else { return };
let srv = spawn(&hprof);
let (status, _h, body) = parse_resp(&http(srv.port, "GET", "/version", ""));
assert!(status.contains("200"), "200: {body}");
let v: serde_json::Value = serde_json::from_str(&body).unwrap();
assert!(
v["version"].as_str().is_some_and(|s| !s.is_empty()),
"version present: {v}"
);
assert!(
v["endpoints"].is_array() && !v["endpoints"].as_array().unwrap().is_empty(),
"endpoint list: {v}"
);
}
#[test]
fn server_wrong_method_on_schema_is_405() {
let Some(hprof) = philosophers() else { return };
let srv = spawn(&hprof);
let (status, _h, _body) = parse_resp(&http(srv.port, "POST", "/schema", ""));
assert!(status.contains("405"), "405 on POST /schema: {status}");
}
#[test]
fn server_stream_emits_ndjson_lines() {
let Some(hprof) = philosophers() else { return };
let srv = spawn(&hprof);
let (status, headers, body) = parse_resp(&http(
srv.port,
"POST",
"/stream",
"SELECT @objectAddress FROM java.lang.Thread",
));
assert!(status.contains("200"), "200: {body}");
assert!(
headers.contains("content-type: application/x-ndjson"),
"ndjson content-type: {headers}"
);
let mut lines = body.lines().filter(|l| !l.trim().is_empty());
let meta: serde_json::Value = serde_json::from_str(lines.next().unwrap()).unwrap();
assert_eq!(
meta["kind"],
serde_json::json!("meta"),
"first line is meta: {meta}"
);
assert!(
meta["row_count"].as_u64().unwrap() > 0,
"row_count in meta: {meta}"
);
let row: serde_json::Value = serde_json::from_str(lines.next().unwrap()).unwrap();
assert_eq!(
row["kind"],
serde_json::json!("row"),
"second line is a row: {row}"
);
assert!(row["v"].is_array(), "row carries a value array: {row}");
}
#[test]
fn server_stream_parse_error_is_one_ndjson_error_line() {
let Some(hprof) = philosophers() else { return };
let srv = spawn(&hprof);
let (status, _h, body) = parse_resp(&http(srv.port, "POST", "/stream", "SELCT bad"));
assert!(status.contains("400"), "400: {body}");
let first: serde_json::Value = serde_json::from_str(body.lines().next().unwrap()).unwrap();
assert_eq!(
first["kind"],
serde_json::json!("error"),
"error line: {first}"
);
}
#[test]
fn server_stream_row_count_matches_emitted_rows() {
let Some(hprof) = philosophers() else { return };
let srv = spawn(&hprof);
let (status, _h, body) = parse_resp(&http(
srv.port,
"POST",
"/stream",
"SELECT @objectAddress FROM java.lang.Thread",
));
assert!(status.contains("200"), "200: {body}");
let mut lines = body.lines().filter(|l| !l.trim().is_empty());
let meta: serde_json::Value = serde_json::from_str(lines.next().unwrap()).unwrap();
let declared = meta["row_count"].as_u64().unwrap();
let emitted = lines
.filter(|l| {
serde_json::from_str::<serde_json::Value>(l)
.map(|v| v["kind"] == serde_json::json!("row"))
.unwrap_or(false)
})
.count() as u64;
assert_eq!(
declared, emitted,
"meta row_count must equal number of row lines"
);
}
#[test]
fn server_stream_wrong_method_is_405() {
let Some(hprof) = philosophers() else { return };
let srv = spawn(&hprof);
let (status, _h, _b) = parse_resp(&http(srv.port, "GET", "/stream", ""));
assert!(status.contains("405"), "405 on GET /stream: {status}");
}
}
#[test]
fn group_by_count_per_class() {
let Some(hprof) = philosophers() else { return };
let out = run_query_stdout(
&hprof,
"SELECT @displayName, COUNT(*) AS n FROM INSTANCEOF java.lang.Object \
GROUP BY @displayName ORDER BY n DESC LIMIT 5",
);
assert!(out.contains("n"), "should have column 'n', got: {out}");
let lines: Vec<_> = out.lines().collect();
assert!(lines.len() >= 3, "expected header + data rows, got: {out}");
}
#[test]
fn group_by_having_filters_groups() {
let Some(hprof) = philosophers() else { return };
let out = run_query_stdout(
&hprof,
"SELECT @displayName, COUNT(*) AS n FROM INSTANCEOF java.lang.Object \
GROUP BY @displayName HAVING COUNT(*) > 0",
);
assert!(!out.contains("error"), "unexpected error: {out}");
let lines: Vec<_> = out.lines().filter(|l| !l.trim().is_empty()).collect();
assert!(
lines.len() >= 2,
"expected rows with HAVING COUNT(*) > 0, got: {out}"
);
}
#[test]
fn group_by_empty_result() {
let Some(hprof) = philosophers() else { return };
let out = run_query_stdout(
&hprof,
"SELECT @displayName, COUNT(*) AS n FROM java.lang.Thread \
GROUP BY @displayName HAVING COUNT(*) > 999999",
);
assert!(out.contains("(0 rows)"), "got: {out}");
}
#[test]
fn group_by_null_key_is_valid_group() {
let Some(hprof) = philosophers() else { return };
let out = run_query_stdout(
&hprof,
"SELECT COUNT(*) AS n FROM java.lang.Thread t GROUP BY t.name",
);
assert!(!out.contains("error"), "got: {out}");
}
#[test]
fn case_when_in_select() {
let Some(hprof) = philosophers() else { return };
let out = run_query_stdout(
&hprof,
r#"SELECT CASE WHEN @usedHeapSize > 1000 THEN "large" ELSE "small" END AS sz FROM java.lang.String LIMIT 3"#,
);
assert!(out.contains("sz"), "column alias not found: {out}");
assert!(
out.contains("large") || out.contains("small"),
"expected 'large' or 'small' in output (got null?): {out}"
);
assert!(
!out.contains("null"),
"got null — CASE stub not replaced: {out}"
);
}
#[test]
fn case_when_no_match_no_else_is_null() {
let Some(hprof) = philosophers() else { return };
let out = run_query_stdout(
&hprof,
r#"SELECT CASE WHEN @usedHeapSize < 0 THEN "neg" END AS x FROM java.lang.String LIMIT 1"#,
);
assert!(out.contains("x"), "got: {out}");
assert!(!out.contains("error"), "got: {out}");
assert!(
out.contains("null"),
"expected null for no-match CASE: {out}"
);
}
#[test]
fn case_when_in_group_by_key() {
let Some(hprof) = philosophers() else { return };
let out = run_query_stdout(
&hprof,
r#"SELECT CASE WHEN @usedHeapSize > 1000 THEN "large" ELSE "small" END AS sz, COUNT(*) AS n FROM java.lang.String GROUP BY CASE WHEN @usedHeapSize > 1000 THEN "large" ELSE "small" END"#,
);
assert!(out.contains("sz") && out.contains("n"), "got: {out}");
assert!(out.contains("large") || out.contains("small"), "got: {out}");
}
#[test]
fn group_by_tostring_counts_distinct_values() {
let Some(hprof) = philosophers() else { return };
let out = run_query_args(
&hprof,
&["--all"],
"SELECT toString(s) AS value, COUNT(*) AS count FROM java.lang.String s \
GROUP BY toString(s) ORDER BY count DESC LIMIT 5",
);
assert!(!out.contains("error"), "unexpected error: {out}");
assert!(
out.contains("value") && out.contains("count"),
"missing columns: {out}"
);
let data_lines: Vec<_> = out
.lines()
.skip_while(|l| !l.contains("value"))
.skip(1)
.filter(|l| !l.trim().is_empty())
.collect();
assert!(
data_lines.len() >= 2,
"expected multiple distinct groups, got: {out}"
);
}
#[test]
fn coalesce_returns_first_nonnull() {
let Some(hprof) = philosophers() else { return };
let out = run_query_stdout(
&hprof,
"SELECT COALESCE(@usedHeapSize, 0) AS sz FROM java.lang.String LIMIT 3",
);
assert!(out.contains("sz"), "got: {out}");
assert!(!out.contains("error"), "got: {out}");
}
#[test]
fn nullif_returns_null_on_equal() {
let Some(hprof) = philosophers() else { return };
let out = run_query_stdout(
&hprof,
"SELECT NULLIF(@usedHeapSize, 0) AS sz FROM java.lang.String LIMIT 3",
);
assert!(out.contains("sz"), "got: {out}");
assert!(!out.contains("error"), "got: {out}");
}
#[test]
fn between_filters_rows() {
let Some(hprof) = philosophers() else { return };
let out = run_query_stdout(
&hprof,
"SELECT COUNT(*) FROM java.lang.String WHERE @usedHeapSize BETWEEN 20 AND 30",
);
assert!(!out.contains("error"), "got: {out}");
assert!(out.contains("COUNT(*)"), "missing COUNT(*) header: {out}");
let has_count = out.lines().any(|l| l.trim().parse::<u64>().is_ok());
assert!(has_count, "expected numeric result, got: {out}");
}
#[test]
fn exists_true_passes_all_rows() {
let Some(hprof) = philosophers() else { return };
let baseline = parse_single_count(&run_query_stdout(
&hprof,
"SELECT COUNT(*) FROM java.lang.String",
));
let with_exists = parse_single_count(&run_query_stdout(
&hprof,
"SELECT COUNT(*) FROM java.lang.String WHERE EXISTS (SELECT * FROM java.lang.Thread)",
));
assert!(
baseline > 0,
"baseline String count must be > 0, got {baseline}"
);
assert_eq!(
with_exists, baseline,
"EXISTS (true inner) must not filter any rows: baseline={baseline}, with_exists={with_exists}"
);
}
#[test]
fn not_exists_false_filters_all_rows() {
let Some(hprof) = philosophers() else { return };
let count = parse_single_count(&run_query_stdout(
&hprof,
"SELECT COUNT(*) FROM java.lang.String \
WHERE NOT EXISTS (SELECT * FROM java.lang.Thread)",
));
assert_eq!(
count, 0,
"NOT EXISTS (Threads exist) must return 0 rows, got {count}"
);
}
#[test]
fn exists_empty_inner_filters_all_rows() {
let Some(hprof) = philosophers() else { return };
let count = parse_single_count(&run_query_stdout(
&hprof,
"SELECT COUNT(*) FROM java.lang.String \
WHERE EXISTS (SELECT * FROM com.NoSuchClass)",
));
assert_eq!(
count, 0,
"EXISTS (empty inner) must return 0 rows, got {count}"
);
}
#[test]
fn not_exists_empty_inner_passes_all_rows() {
let Some(hprof) = philosophers() else { return };
let baseline = parse_single_count(&run_query_stdout(
&hprof,
"SELECT COUNT(*) FROM java.lang.String",
));
let with_not_exists = parse_single_count(&run_query_stdout(
&hprof,
"SELECT COUNT(*) FROM java.lang.String \
WHERE NOT EXISTS (SELECT * FROM com.NoSuchClass)",
));
assert!(
baseline > 0,
"baseline String count must be > 0, got {baseline}"
);
assert_eq!(
with_not_exists, baseline,
"NOT EXISTS (empty inner) must pass all rows: baseline={baseline}, got {with_not_exists}"
);
}
#[test]
fn intersect_keeps_common_rows() {
let Some(hprof) = philosophers() else { return };
let out = run_query_stdout(
&hprof,
"SELECT @displayName FROM java.lang.Thread \
INTERSECT \
SELECT @displayName FROM java.lang.Thread",
);
assert!(!out.to_lowercase().contains("error"), "got: {out}");
assert!(out.contains("Thread"), "expected Thread rows, got: {out}");
}
#[test]
fn intersect_empty_when_disjoint() {
let Some(hprof) = philosophers() else { return };
let out = run_query_stdout(
&hprof,
"SELECT @displayName FROM java.lang.Thread \
INTERSECT \
SELECT @displayName FROM java.lang.String",
);
assert!(
out.contains("(0 rows)") || out.contains("0 rows"),
"Thread INTERSECT String display names should be empty, got: {out}"
);
}
#[test]
fn except_removes_right_rows() {
let Some(hprof) = philosophers() else { return };
let out = run_query_stdout(
&hprof,
"SELECT @displayName FROM java.lang.Thread \
EXCEPT \
SELECT @displayName FROM java.lang.Thread",
);
assert!(
out.contains("(0 rows)") || out.contains("0 rows"),
"A EXCEPT A should be empty, got: {out}"
);
}
#[test]
fn array_index_out_of_bounds_is_null() {
let Some(hprof) = philosophers() else { return };
let out = run_query_stdout(
&hprof,
"SELECT s.value[999999] AS elem FROM java.lang.String s LIMIT 3",
);
assert!(
out.contains("elem"),
"expected 'elem' column header, got: {out}"
);
assert!(
!out.to_lowercase().contains("error"),
"unexpected error in output, got: {out}"
);
}
#[test]
fn array_slice_returns_subarray_string() {
let Some(hprof) = philosophers() else { return };
let out = run_query_stdout(
&hprof,
"SELECT s.value[0:3] AS slc FROM java.lang.String s LIMIT 5",
);
assert!(
out.contains("slc"),
"expected 'slc' column header, got: {out}"
);
assert!(
!out.to_lowercase().contains("error"),
"unexpected error in output, got: {out}"
);
}
#[test]
fn array_slice_oob_end_clamps_gracefully() {
let Some(hprof) = philosophers() else { return };
let out = run_query_stdout(
&hprof,
"SELECT s.value[0:999999] AS slc FROM java.lang.String s LIMIT 3",
);
assert!(
out.contains("slc"),
"expected 'slc' column header, got: {out}"
);
assert!(
!out.to_lowercase().contains("error"),
"unexpected error in output, got: {out}"
);
}
#[test]
fn query_file_parse_error_includes_line_number() {
let Some(hprof) = philosophers() else { return };
let mut f = tempfile::NamedTempFile::new().unwrap();
writeln!(f, "SELECT COUNT(*) FROM java.lang.String").unwrap();
writeln!(f, "SELEC * FROM java.lang.Object").unwrap(); let out = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args(["--query-file", f.path().to_str().unwrap()])
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
!out.status.success(),
"expected non-zero exit for parse error, got success"
);
assert!(
stderr.contains("line 2") || stderr.contains(":2"),
"expected line number in error output:\n{stderr}"
);
}
#[test]
fn named_query_empty_collections_uses_size_method() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg("query")
.arg("--all")
.arg(&hprof)
.args(["--run", "empty-collections"])
.output()
.unwrap();
assert!(
out.status.success(),
"empty-collections named query failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
!stdout.contains("unknown field"),
"got unknown field error: {stdout}"
);
assert!(stdout.contains("class"), "missing 'class' column: {stdout}");
}
#[test]
fn named_query_large_collections_uses_size_method() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg("query")
.arg("--all")
.arg(&hprof)
.args(["--run", "large-collections"])
.output()
.unwrap();
assert!(
out.status.success(),
"large-collections named query failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
!stdout.contains("unknown field"),
"got unknown field error: {stdout}"
);
}
#[test]
fn size_method_works_on_linked_hash_map() {
let Some(hprof) = mnemonics() else { return };
let out = Command::new(BIN)
.arg("query")
.arg("--all")
.arg(&hprof)
.args([
"--query",
"SELECT SUM(x.size()) AS total_sz FROM java.util.LinkedHashMap x",
])
.output()
.unwrap();
assert!(
out.status.success(),
"LinkedHashMap size() query failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
let total: i64 = stdout
.lines()
.find(|l| {
!l.trim().is_empty()
&& !l.contains("total_sz")
&& !l.starts_with("==")
&& !l.starts_with(" SELECT")
&& !l.starts_with('(')
&& !is_separator_line(l.trim())
})
.and_then(|s| s.trim().parse().ok())
.unwrap_or(0);
assert!(
total > 0,
"expected non-zero SUM(size()) for LinkedHashMap, got: {stdout}"
);
}
#[test]
fn size_method_works_on_tree_map() {
let Some(hprof) = gauss_mix() else { return };
let out = Command::new(BIN)
.arg("query")
.arg("--all")
.arg(&hprof)
.args([
"--query",
"SELECT SUM(x.size()) AS total_sz FROM java.util.TreeMap x",
])
.output()
.unwrap();
assert!(
out.status.success(),
"TreeMap size() query failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
let total: i64 = stdout
.lines()
.find(|l| {
!l.trim().is_empty()
&& !l.contains("total_sz")
&& !l.starts_with("==")
&& !l.starts_with(" SELECT")
&& !l.starts_with('(')
&& !is_separator_line(l.trim())
})
.and_then(|s| s.trim().parse().ok())
.unwrap_or(0);
assert!(
total > 0,
"expected non-zero SUM(size()) for TreeMap, got: {stdout}"
);
}
#[test]
fn union_order_by_sorts_globally_across_branches() {
let Some(hprof) = gauss_mix() else { return };
let out = Command::new(BIN)
.arg("query")
.arg("--all")
.arg(&hprof)
.args([
"--query",
"SELECT @objectAddress, @usedHeapSize AS bytes FROM byte[] x WHERE @usedHeapSize > 65536 \
UNION SELECT @objectAddress, @usedHeapSize AS bytes FROM int[] x WHERE @usedHeapSize > 65536 \
ORDER BY bytes DESC LIMIT 5",
])
.output()
.unwrap();
assert!(
out.status.success(),
"UNION ORDER BY query failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
let values: Vec<i64> = stdout
.lines()
.filter(|l| l.contains('|') && !l.contains("@objectAddress") && !l.contains("bytes"))
.filter_map(|l| l.split('|').nth(1).and_then(|s| s.trim().parse().ok()))
.collect();
assert!(!values.is_empty(), "expected result rows, got: {stdout}");
let is_sorted_desc = values.windows(2).all(|w| w[0] >= w[1]);
assert!(
is_sorted_desc,
"UNION result not sorted DESC: {values:?}\nstdout: {stdout}"
);
}
#[test]
fn in_value_list_filters_correctly() {
let Some(hprof) = mnemonics() else { return };
let out = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args([
"--query",
r#"SELECT toString(s) FROM java.lang.String s WHERE toString(s) IN ("MONDAY", "TUESDAY", "WEDNESDAY")"#,
])
.output()
.unwrap();
assert!(
out.status.success(),
"IN value list query failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("MONDAY"), "MONDAY missing: {stdout}");
assert!(stdout.contains("TUESDAY"), "TUESDAY missing: {stdout}");
assert!(stdout.contains("WEDNESDAY"), "WEDNESDAY missing: {stdout}");
assert!(
stdout.contains("(3 rows)"),
"expected 3 rows, got: {stdout}"
);
}
#[test]
fn not_in_value_list_excludes_correctly() {
let Some(hprof) = mnemonics() else { return };
let out = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args([
"--query",
r#"SELECT toString(s) FROM java.lang.String s WHERE toString(s) IN ("MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY", "SUNDAY") AND toString(s) NOT IN ("SATURDAY", "SUNDAY")"#,
])
.output()
.unwrap();
assert!(
out.status.success(),
"NOT IN query failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
let rows: Vec<&str> = stdout
.lines()
.filter(|l| {
!l.starts_with("==")
&& !l.starts_with(" ")
&& !l.starts_with("(")
&& !l.trim().is_empty()
})
.collect();
let rows_str = rows.join("\n");
assert!(
!rows_str.contains("SATURDAY"),
"SATURDAY should be excluded: {rows_str}"
);
assert!(
!rows_str.contains("SUNDAY"),
"SUNDAY should be excluded: {rows_str}"
);
assert!(
rows_str.contains("MONDAY"),
"MONDAY should be included: {rows_str}"
);
}
#[test]
fn is_null_and_is_not_null_filter() {
let Some(hprof) = mnemonics() else { return };
let out_not_null = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args([
"--query",
"SELECT COUNT(*) AS n FROM java.lang.String s WHERE toString(s) IS NOT NULL",
])
.output()
.unwrap();
assert!(
out_not_null.status.success(),
"IS NOT NULL failed: {}",
String::from_utf8_lossy(&out_not_null.stderr)
);
let stdout = String::from_utf8_lossy(&out_not_null.stdout);
let count: i64 = stdout
.lines()
.filter(|l| !l.contains("n") && !l.contains("="))
.flat_map(|l| l.trim().parse().ok())
.next()
.unwrap_or(0);
assert!(count > 0, "IS NOT NULL returned 0, expected >0: {stdout}");
let out_null = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args([
"--query",
"SELECT COUNT(*) AS n FROM java.lang.String s WHERE toString(s) IS NULL",
])
.output()
.unwrap();
assert!(
out_null.status.success(),
"IS NULL failed: {}",
String::from_utf8_lossy(&out_null.stderr)
);
let stdout2 = String::from_utf8_lossy(&out_null.stdout);
assert!(
stdout2.contains("0"),
"IS NULL should return 0 for all-non-null strings: {stdout2}"
);
}
#[test]
fn limit_offset_paginates_correctly() {
let Some(hprof) = philosophers() else { return };
let page1 = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args([
"--query",
"SELECT classof(x) AS class FROM INSTANCEOF java.lang.Object x \
ORDER BY class ASC LIMIT 3",
])
.output()
.unwrap();
assert!(
page1.status.success(),
"{}",
String::from_utf8_lossy(&page1.stderr)
);
let page2 = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args([
"--query",
"SELECT classof(x) AS class FROM INSTANCEOF java.lang.Object x \
ORDER BY class ASC LIMIT 3 OFFSET 3",
])
.output()
.unwrap();
assert!(
page2.status.success(),
"{}",
String::from_utf8_lossy(&page2.stderr)
);
let s1 = String::from_utf8_lossy(&page1.stdout);
let s2 = String::from_utf8_lossy(&page2.stdout);
let data_rows = |s: &str| -> Vec<String> {
s.lines()
.filter(|l| {
!l.starts_with("==")
&& !l.starts_with(" ")
&& !l.starts_with('(')
&& !l.is_empty()
&& !l.contains("class")
&& !l.starts_with('-')
})
.map(|l| l.trim().to_string())
.collect()
};
let rows1 = data_rows(&s1);
let rows2 = data_rows(&s2);
assert_eq!(rows1.len(), 3, "page1 should have 3 rows, got: {s1}");
assert_eq!(rows2.len(), 3, "page2 should have 3 rows, got: {s2}");
for r in &rows1 {
assert!(!rows2.contains(r), "row {r:?} appeared in both pages");
}
}
#[test]
fn not_between_filters_correctly() {
let Some(hprof) = mnemonics() else { return };
let out = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args([
"--query",
"SELECT COUNT(*) AS n FROM java.lang.String s \
WHERE @usedHeapSize NOT BETWEEN 10 AND 100",
])
.output()
.unwrap();
assert!(
out.status.success(),
"NOT BETWEEN query failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
let count: u64 = stdout
.lines()
.filter_map(|l| l.trim().parse().ok())
.next()
.unwrap_or(u64::MAX);
assert_eq!(
count, 0,
"NOT BETWEEN 10 AND 100 should return 0 (all strings have @usedHeapSize=24): {stdout}"
);
let out2 = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args([
"--query",
"SELECT COUNT(*) AS n FROM java.lang.String s \
WHERE @usedHeapSize BETWEEN 10 AND 100",
])
.output()
.unwrap();
assert!(
out2.status.success(),
"BETWEEN query failed: {}",
String::from_utf8_lossy(&out2.stderr)
);
let stdout2 = String::from_utf8_lossy(&out2.stdout);
let count2: u64 = stdout2
.lines()
.filter_map(|l| l.trim().parse().ok())
.next()
.unwrap_or(0);
assert!(
count2 > 0,
"BETWEEN 10 AND 100 should return >0 rows: {stdout2}"
);
}
#[test]
fn limit_offset_with_where_returns_correct_page() {
let Some(hprof) = mnemonics() else { return };
let page1 = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args(["--query",
r#"SELECT toString(s) AS name FROM java.lang.String s WHERE toString(s) LIKE ".*DAY" ORDER BY name ASC LIMIT 3"#])
.output()
.unwrap();
assert!(
page1.status.success(),
"{}",
String::from_utf8_lossy(&page1.stderr)
);
let page2 = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args(["--query",
r#"SELECT toString(s) AS name FROM java.lang.String s WHERE toString(s) LIKE ".*DAY" ORDER BY name ASC LIMIT 3 OFFSET 3"#])
.output()
.unwrap();
assert!(
page2.status.success(),
"{}",
String::from_utf8_lossy(&page2.stderr)
);
let s1 = String::from_utf8_lossy(&page1.stdout);
let s2 = String::from_utf8_lossy(&page2.stdout);
let data_rows = |s: &str| -> Vec<String> {
s.lines()
.filter(|l| {
!l.starts_with("==")
&& !l.starts_with(" ")
&& !l.starts_with('(')
&& !l.is_empty()
&& !l.contains("name")
&& !is_separator_line(l.trim())
})
.map(|l| l.trim().to_string())
.collect()
};
let rows1 = data_rows(&s1);
let rows2 = data_rows(&s2);
assert_eq!(
rows1.len(),
3,
"page1 with WHERE should have 3 rows, got: {s1}"
);
assert_eq!(
rows2.len(),
3,
"page2 with WHERE+OFFSET should have 3 rows, got: {s2}"
);
for r in &rows1 {
assert!(
!rows2.contains(r),
"row {r:?} appeared in both pages (OFFSET with WHERE broken)"
);
}
}
#[test]
fn query_classof_with_retained_heap_size_is_non_null() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args([
"--query",
"SELECT classof(x), @retainedHeapSize FROM java.lang.String x LIMIT 5",
])
.output()
.unwrap();
assert!(out.status.success());
let stdout = String::from_utf8_lossy(&out.stdout);
let data_rows: Vec<&str> = stdout
.lines()
.filter(|l| l.contains("java.lang.String"))
.collect();
assert!(
!data_rows.is_empty(),
"expected classof(x) to return java.lang.String rows, got:\n{stdout}"
);
for row in &data_rows {
assert!(
!row.contains("null") && !row.contains("Null"),
"classof(x) returned null in row: {row}"
);
}
}
#[test]
fn query_group_by_retained_heap_size_sum_is_non_null() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args([
"--query",
"SELECT classof(x) AS class, SUM(@retainedHeapSize) AS retained_bytes \
FROM INSTANCEOF java.lang.Object x \
GROUP BY classof(x) ORDER BY retained_bytes DESC LIMIT 5",
])
.output()
.unwrap();
assert!(out.status.success());
let stdout = String::from_utf8_lossy(&out.stdout);
let data_rows: Vec<&str> = stdout
.lines()
.filter(|l| l.contains('|') && !l.starts_with("class") && !l.contains("---"))
.collect();
assert!(
!data_rows.is_empty(),
"expected GROUP BY rows, got:\n{stdout}"
);
let first_row = data_rows[0];
let retained_col = first_row.split('|').nth(1).unwrap_or("").trim();
assert!(
retained_col != "null" && retained_col != "Null" && retained_col.parse::<i64>().is_ok(),
"expected a numeric retained_bytes in first row, got: {retained_col:?}\nfull output:\n{stdout}"
);
let values: Vec<i64> = data_rows
.iter()
.filter_map(|row| row.split('|').nth(1).and_then(|s| s.trim().parse().ok()))
.collect();
for w in values.windows(2) {
assert!(
w[0] >= w[1],
"ORDER BY retained_bytes DESC is not sorted: {w:?}\nfull output:\n{stdout}"
);
}
}
#[test]
fn query_used_heap_size_with_retained_is_non_null() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args([
"--query",
"SELECT @usedHeapSize, @retainedHeapSize FROM java.lang.String x LIMIT 5",
])
.output()
.unwrap();
assert!(out.status.success());
let stdout = String::from_utf8_lossy(&out.stdout);
let data_rows: Vec<&str> = stdout
.lines()
.filter(|l| l.contains('|') && !l.contains("---") && !l.starts_with("@used"))
.collect();
assert!(!data_rows.is_empty(), "expected data rows, got:\n{stdout}");
for row in &data_rows {
let cells: Vec<&str> = row.split('|').skip(1).collect();
for cell in &cells[..2.min(cells.len())] {
let v = cell.trim();
assert!(
v != "null" && v != "Null" && v.parse::<i64>().is_ok(),
"@usedHeapSize or @retainedHeapSize was null/non-numeric: {v:?} in row: {row}"
);
}
}
}
#[test]
fn query_group_by_sum_used_heap_size_with_retained_is_non_null() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args([
"--query",
"SELECT classof(x) AS c, SUM(@usedHeapSize) AS sh, SUM(@retainedHeapSize) AS ret \
FROM INSTANCEOF java.lang.Object x \
GROUP BY classof(x) ORDER BY ret DESC LIMIT 5",
])
.output()
.unwrap();
assert!(out.status.success());
let stdout = String::from_utf8_lossy(&out.stdout);
let data_rows: Vec<&str> = stdout
.lines()
.filter(|l| l.contains('|') && !l.starts_with('c') && !l.contains("---"))
.collect();
assert!(
!data_rows.is_empty(),
"expected GROUP BY rows, got:\n{stdout}"
);
let first = data_rows[0];
let cols: Vec<&str> = first.split('|').map(|s| s.trim()).collect();
let sh = cols.get(2).copied().unwrap_or("MISSING");
assert!(
sh != "null" && sh != "Null" && sh.parse::<i64>().is_ok(),
"SUM(@usedHeapSize) was null/non-numeric: {sh:?} in first row: {first}\noutput:\n{stdout}"
);
}
#[test]
fn query_classof_in_retained_path_uses_dotted_format() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args([
"--query",
"SELECT classof(x), @retainedHeapSize FROM java.lang.String x LIMIT 3",
])
.output()
.unwrap();
assert!(out.status.success());
let stdout = String::from_utf8_lossy(&out.stdout);
let data_rows: Vec<&str> = stdout
.lines()
.filter(|l| l.contains('|') && !l.contains("classof") && !l.contains("---"))
.collect();
assert!(!data_rows.is_empty(), "expected data rows, got:\n{stdout}");
for row in &data_rows {
assert!(
row.contains("java.lang.String"),
"classof returned slash form or wrong name in row: {row}\nfull output:\n{stdout}"
);
assert!(
!row.contains("java/lang/String"),
"classof returned JVM slash form instead of dotted: {row}"
);
}
}
#[test]
fn query_having_like_filters_groups_in_retained_path() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args([
"--query",
"SELECT classof(x), SUM(@retainedHeapSize) AS ret \
FROM INSTANCEOF java.lang.Object x \
GROUP BY classof(x) HAVING classof(x) LIKE \"java.lang.*\" \
ORDER BY ret DESC LIMIT 3",
])
.output()
.unwrap();
assert!(out.status.success());
let stdout = String::from_utf8_lossy(&out.stdout);
let data_rows: Vec<&str> = stdout
.lines()
.filter(|l| l.contains('|') && !l.contains("classof") && !l.contains("---"))
.collect();
assert!(
!data_rows.is_empty(),
"HAVING classof LIKE returned 0 rows (LIKE was silently broken):\n{stdout}"
);
for row in &data_rows {
assert!(
row.contains("java.lang."),
"HAVING LIKE let through a row that doesn't match java.lang.*: {row}"
);
}
}
#[test]
fn query_having_like_filters_groups_non_retained() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args([
"--query",
"SELECT classof(x), COUNT(*) AS n \
FROM INSTANCEOF java.lang.Object x \
GROUP BY classof(x) HAVING classof(x) LIKE \"java.lang.*\" \
ORDER BY n DESC LIMIT 3",
])
.output()
.unwrap();
assert!(out.status.success());
let stdout = String::from_utf8_lossy(&out.stdout);
let data_rows: Vec<&str> = stdout
.lines()
.filter(|l| l.contains('|') && !l.contains("classof") && !l.contains("---"))
.collect();
assert!(
!data_rows.is_empty(),
"HAVING classof LIKE returned 0 rows (LIKE was silently broken):\n{stdout}"
);
for row in &data_rows {
assert!(
row.contains("java.lang."),
"HAVING LIKE let through a non-matching row: {row}"
);
}
}
#[test]
fn query_case_when_with_retained_heap_size_is_non_null() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args([
"--query",
"SELECT classof(x), @retainedHeapSize, \
CASE WHEN @retainedHeapSize > 100000 THEN \"large\" ELSE \"small\" END AS size \
FROM INSTANCEOF java.lang.Object x ORDER BY @retainedHeapSize DESC LIMIT 5",
])
.output()
.unwrap();
assert!(out.status.success());
let stdout = String::from_utf8_lossy(&out.stdout);
let data_rows: Vec<&str> = stdout
.lines()
.filter(|l| l.contains('|') && !l.contains("classof") && !l.contains("---"))
.collect();
assert!(!data_rows.is_empty(), "expected data rows, got:\n{stdout}");
let first = data_rows[0];
assert!(
first.contains("large"),
"CASE WHEN @retainedHeapSize > 100000 THEN \"large\" returned null or wrong value: {first}"
);
assert!(
!first.contains("null") && !first.contains("Null"),
"CASE WHEN returned null in first row: {first}"
);
}
#[test]
fn query_arithmetic_expr_with_retained_heap_size_is_non_null() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args([
"--query",
"SELECT @retainedHeapSize, @retainedHeapSize * 2 AS double_ret \
FROM INSTANCEOF java.lang.Object x ORDER BY @retainedHeapSize DESC LIMIT 3",
])
.output()
.unwrap();
assert!(out.status.success());
let stdout = String::from_utf8_lossy(&out.stdout);
let data_rows: Vec<&str> = stdout
.lines()
.filter(|l| l.contains('|') && !l.contains("@retained") && !l.contains("---"))
.collect();
assert!(!data_rows.is_empty(), "expected data rows, got:\n{stdout}");
let first = data_rows[0];
let cols: Vec<&str> = first.split('|').map(|s| s.trim()).collect();
let retained = cols
.first()
.and_then(|s| s.parse::<i64>().ok())
.unwrap_or(0);
let double = cols
.get(1)
.and_then(|s| s.parse::<i64>().ok())
.unwrap_or(-1);
assert_eq!(
double,
retained * 2,
"@retainedHeapSize * 2 should be double the retained size; got {double} vs {retained}*2={}",
retained * 2
);
}
#[test]
fn query_order_by_classof_in_retained_path_is_sorted() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args([
"--query",
"SELECT classof(x), @retainedHeapSize FROM INSTANCEOF java.lang.Object x \
WHERE @retainedHeapSize > 200000 ORDER BY classof(x) ASC LIMIT 5",
])
.output()
.unwrap();
assert!(out.status.success());
let stdout = String::from_utf8_lossy(&out.stdout);
let class_names: Vec<&str> = stdout
.lines()
.filter(|l| l.contains('|') && !l.contains("classof") && !l.contains("---"))
.map(|l| l.split('|').next().unwrap_or("").trim())
.collect();
assert!(
!class_names.is_empty(),
"expected data rows, got:\n{stdout}"
);
for window in class_names.windows(2) {
assert!(
window[0] <= window[1],
"ORDER BY classof ASC not sorted: {:?} > {:?}\nfull output:\n{stdout}",
window[0],
window[1]
);
}
}
#[test]
fn query_classof_in_tostring_path_is_non_null() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args([
"--query",
"SELECT classof(x), @retainedHeapSize FROM java.lang.String x \
WHERE toString(x) LIKE \"java.*\" ORDER BY @retainedHeapSize DESC LIMIT 3",
])
.output()
.unwrap();
assert!(out.status.success());
let stdout = String::from_utf8_lossy(&out.stdout);
let data_rows: Vec<&str> = stdout
.lines()
.filter(|l| l.contains('|') && !l.contains("classof") && !l.contains("---"))
.collect();
assert!(!data_rows.is_empty(), "expected data rows, got:\n{stdout}");
for row in &data_rows {
assert!(
row.contains("java.lang.String"),
"classof(x) returned null or wrong name in toString path: {row}\nfull output:\n{stdout}"
);
}
}
#[test]
fn query_case_expr_in_tostring_path_is_non_null() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args([
"--query",
"SELECT CASE WHEN @retainedHeapSize > 0 THEN \"large\" ELSE \"empty\" END, \
@retainedHeapSize * 2 \
FROM java.lang.String x WHERE toString(x) LIKE \"java.*\" LIMIT 3",
])
.output()
.unwrap();
assert!(out.status.success());
let stdout = String::from_utf8_lossy(&out.stdout);
let data_rows: Vec<&str> = stdout
.lines()
.filter(|l| l.contains('|') && !l.contains("CASE") && !l.contains("---"))
.collect();
assert!(!data_rows.is_empty(), "expected data rows, got:\n{stdout}");
for row in &data_rows {
assert!(
row.contains("large") || row.contains("empty"),
"CASE expr returned null in toString path: {row}\nfull output:\n{stdout}"
);
}
}
#[test]
fn query_group_by_expr_key_in_retained_path_is_non_null() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args([
"--query",
"SELECT classof(x), COUNT(*) AS n, SUM(@retainedHeapSize) AS total \
FROM java.lang.Object x GROUP BY classof(x) ORDER BY total DESC LIMIT 5",
])
.output()
.unwrap();
assert!(out.status.success());
let stdout = String::from_utf8_lossy(&out.stdout);
let data_rows: Vec<&str> = stdout
.lines()
.filter(|l| l.contains('|') && !l.contains("classof") && !l.contains("---"))
.collect();
assert!(
!data_rows.is_empty(),
"expected GROUP BY rows, got:\n{stdout}"
);
for row in &data_rows {
let cols: Vec<&str> = row.split('|').collect();
let class_col = cols.get(1).map(|s| s.trim()).unwrap_or("");
assert!(
!class_col.is_empty() && class_col != "null",
"GROUP BY classof(x) key was null in retained path: {row}\nfull output:\n{stdout}"
);
}
}
#[test]
fn query_refpath_with_extra_attrs_is_non_null() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args([
"--query",
"SELECT x.value.@length, classof(x), @usedHeapSize \
FROM java.lang.String x WHERE x.value.@length > 5 LIMIT 3",
])
.output()
.unwrap();
assert!(out.status.success());
let stdout = String::from_utf8_lossy(&out.stdout);
let data_rows: Vec<&str> = stdout
.lines()
.filter(|l| l.contains('|') && !l.contains("value.@length") && !l.contains("---"))
.collect();
assert!(
!data_rows.is_empty(),
"expected refpath rows, got:\n{stdout}"
);
for row in &data_rows {
let cols: Vec<&str> = row.split('|').collect();
let classof = cols.get(1).map(|s| s.trim()).unwrap_or("");
let used = cols.get(2).map(|s| s.trim()).unwrap_or("");
assert!(
classof.contains("java.lang.String"),
"classof null in refpath SELECT: {row}\nfull output:\n{stdout}"
);
assert!(
used.parse::<i64>().is_ok(),
"@usedHeapSize null/invalid in refpath SELECT: {row}\nfull output:\n{stdout}"
);
}
}
#[test]
fn query_ungrouped_aggregate_with_retained_is_single_row() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args([
"--query",
"SELECT COUNT(*) AS n, AVG(@retainedHeapSize) AS avg_ret, \
MIN(@retainedHeapSize) AS min_ret, MAX(@retainedHeapSize) AS max_ret \
FROM java.lang.Object x",
])
.output()
.unwrap();
assert!(out.status.success());
let stdout = String::from_utf8_lossy(&out.stdout);
let data_rows: Vec<&str> = stdout
.lines()
.filter(|l| l.contains('|') && !l.contains("avg_ret") && !l.contains("---"))
.collect();
assert_eq!(
data_rows.len(),
1,
"expected exactly 1 aggregate row, got:\n{stdout}"
);
let row = data_rows[0];
let cols: Vec<&str> = row.split('|').collect();
let n = cols
.first()
.map(|s| s.trim())
.unwrap_or("")
.parse::<i64>()
.unwrap_or(-1);
let min_ret = cols
.get(2)
.map(|s| s.trim())
.unwrap_or("")
.parse::<i64>()
.unwrap_or(-1);
let max_ret = cols
.get(3)
.map(|s| s.trim())
.unwrap_or("")
.parse::<i64>()
.unwrap_or(-1);
assert!(n > 0, "COUNT(*) was 0 or non-integer: {row}");
assert!(min_ret >= 0, "MIN(@retainedHeapSize) was negative: {row}");
assert!(max_ret >= min_ret, "MAX < MIN: {row}");
}
#[test]
fn query_ungrouped_sum_with_retained_filter_is_single_row() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args([
"--query",
"SELECT SUM(@retainedHeapSize) AS total FROM java.lang.Object x \
WHERE @retainedHeapSize > 0",
])
.output()
.unwrap();
assert!(out.status.success());
let stdout = String::from_utf8_lossy(&out.stdout);
let data_rows: Vec<&str> = stdout
.lines()
.filter(|l| {
!l.is_empty()
&& !l.contains("total")
&& !l.contains("---")
&& !l.contains("SUM")
&& !l.starts_with('(')
&& !l.starts_with("==")
&& !l.starts_with(" SELECT")
})
.collect();
assert_eq!(
data_rows.len(),
1,
"expected exactly 1 aggregate row, got:\n{stdout}"
);
let total = data_rows[0].trim().parse::<i64>().unwrap_or(-1);
assert!(
total > 0,
"SUM(@retainedHeapSize) was non-positive: {stdout}"
);
}
#[test]
fn query_to_hex_in_retained_path_is_non_null() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args([
"--query",
"SELECT toHex(@objectId), classof(x) FROM java.lang.String x \
ORDER BY @retainedHeapSize DESC LIMIT 3",
])
.output()
.unwrap();
assert!(out.status.success());
let stdout = String::from_utf8_lossy(&out.stdout);
let data_rows: Vec<&str> = stdout
.lines()
.filter(|l| l.contains('|') && !l.contains("toHex") && !l.contains("---"))
.collect();
assert!(!data_rows.is_empty(), "expected data rows, got:\n{stdout}");
for row in &data_rows {
let cols: Vec<&str> = row.split('|').collect();
let hex_val = cols.first().map(|s| s.trim()).unwrap_or("");
assert!(
hex_val.starts_with("0x"),
"toHex(@objectId) did not return hex string in retained path: {row}\nfull output:\n{stdout}"
);
}
}
#[test]
fn query_tostring_with_retained_where_applies_both_filters() {
let Some(hprof) = philosophers() else { return };
let loose = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args([
"--query",
"SELECT toString(x), @retainedHeapSize FROM java.lang.String x \
WHERE toString(x) LIKE \".*renaissance.*\" AND @retainedHeapSize > 0 \
ORDER BY @retainedHeapSize DESC LIMIT 10",
])
.output()
.unwrap();
assert!(loose.status.success());
let loose_stdout = String::from_utf8_lossy(&loose.stdout);
let tight = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args([
"--query",
"SELECT toString(x), @retainedHeapSize FROM java.lang.String x \
WHERE toString(x) LIKE \".*renaissance.*\" AND @retainedHeapSize > 235 \
ORDER BY @retainedHeapSize DESC LIMIT 10",
])
.output()
.unwrap();
assert!(tight.status.success());
let tight_stdout = String::from_utf8_lossy(&tight.stdout);
let loose_rows: Vec<&str> = loose_stdout
.lines()
.filter(|l| l.contains('|') && !l.contains("toString") && !l.contains("---"))
.collect();
let tight_rows: Vec<&str> = tight_stdout
.lines()
.filter(|l| l.contains('|') && !l.contains("toString") && !l.contains("---"))
.collect();
assert!(
loose_rows.len() > tight_rows.len(),
"tight filter (>235) should return fewer rows than loose filter (>0)\n\
loose ({} rows):\n{loose_stdout}\ntight ({} rows):\n{tight_stdout}",
loose_rows.len(),
tight_rows.len()
);
for row in &tight_rows {
let cols: Vec<&str> = row.split('|').collect();
if let Some(ret_str) = cols.get(1) {
let ret: i64 = ret_str.trim().parse().unwrap_or(0);
assert!(
ret > 235,
"@retainedHeapSize filter was not applied: row has ret={ret} which should be >235\nrow: {row}"
);
}
}
}
#[test]
fn query_array_index_with_retained_where_applies_filter() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args([
"--query",
"SELECT @objectId, x[0], @retainedHeapSize FROM char[] x \
WHERE @retainedHeapSize > 200 ORDER BY @retainedHeapSize DESC LIMIT 10",
])
.output()
.unwrap();
assert!(out.status.success());
let stdout = String::from_utf8_lossy(&out.stdout);
let data_rows: Vec<&str> = stdout
.lines()
.filter(|l| l.contains('|') && !l.contains("@objectId") && !l.contains("---"))
.collect();
assert!(
!data_rows.is_empty(),
"expected rows for char[] with ret > 200:\n{stdout}"
);
for row in &data_rows {
let cols: Vec<&str> = row.split('|').collect();
if let Some(ret_str) = cols.get(2) {
let ret: i64 = ret_str.trim().parse().unwrap_or(0);
assert!(
ret > 200,
"@retainedHeapSize filter was not applied in array_index_rows: ret={ret} <= 200\nrow: {row}"
);
}
}
}
#[test]
fn query_refpath_with_retained_where_applies_filter() {
let Some(hprof) = philosophers() else { return };
let loose = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args([
"--query",
"SELECT @objectId, x.table.length, @retainedHeapSize \
FROM java.util.HashMap x \
WHERE @retainedHeapSize > 0 ORDER BY @retainedHeapSize DESC LIMIT 100",
])
.output()
.unwrap();
assert!(loose.status.success());
let tight = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args([
"--query",
"SELECT @objectId, x.table.length, @retainedHeapSize \
FROM java.util.HashMap x \
WHERE @retainedHeapSize > 100000 ORDER BY @retainedHeapSize DESC LIMIT 100",
])
.output()
.unwrap();
assert!(tight.status.success());
let loose_stdout = String::from_utf8_lossy(&loose.stdout);
let tight_stdout = String::from_utf8_lossy(&tight.stdout);
let loose_rows: Vec<&str> = loose_stdout
.lines()
.filter(|l| l.contains('|') && !l.contains("@objectId") && !l.contains("---"))
.collect();
let tight_rows: Vec<&str> = tight_stdout
.lines()
.filter(|l| l.contains('|') && !l.contains("@objectId") && !l.contains("---"))
.collect();
assert!(
loose_rows.len() > tight_rows.len(),
"tight filter (>100000) should return fewer rows than loose (>0)\n\
loose ({} rows):\n{loose_stdout}\ntight ({} rows):\n{tight_stdout}",
loose_rows.len(),
tight_rows.len()
);
for row in &tight_rows {
let cols: Vec<&str> = row.split('|').collect();
if let Some(ret_str) = cols.get(2) {
let ret: i64 = ret_str.trim().parse().unwrap_or(0);
assert!(
ret > 100000,
"@retainedHeapSize filter not applied in refpath_rows: ret={ret} <= 100000\nrow: {row}"
);
}
}
}
#[test]
fn query_dominators_with_retained_where_applies_filter() {
let Some(hprof) = philosophers() else { return };
let all = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args(["--query", "SELECT dominators(x) FROM java.lang.String x"])
.output()
.unwrap();
assert!(all.status.success());
let filtered = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args([
"--query",
"SELECT dominators(x) FROM java.lang.String x WHERE @retainedHeapSize > 5000",
])
.output()
.unwrap();
assert!(filtered.status.success());
let all_stdout = String::from_utf8_lossy(&all.stdout);
let filtered_stdout = String::from_utf8_lossy(&filtered.stdout);
let count_rows = |s: &str| {
s.lines()
.filter(|l| l.starts_with("?@") || (l.contains('|') && !l.contains("---")))
.count()
};
let all_count = count_rows(&all_stdout);
let filtered_count = count_rows(&filtered_stdout);
assert!(
filtered_count < all_count,
"dominators with retained WHERE >5000 should return fewer rows than without filter\n\
all ({all_count} rows):\n{all_stdout}\nfiltered ({filtered_count} rows):\n{filtered_stdout}"
);
assert!(
filtered_count > 0,
"dominators with retained WHERE >5000 returned 0 rows (expected some):\n{filtered_stdout}"
);
}
#[test]
fn query_or_predicate_with_retained_and_used_heap_filters_correctly() {
let Some(hprof) = gauss_mix() else { return };
let retained_only = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args([
"--query",
"SELECT COUNT(*) FROM java.util.HashMap WHERE @retainedHeapSize > 100000",
])
.output()
.unwrap();
assert!(retained_only.status.success());
let or_query = Command::new(BIN)
.arg("query").arg(&hprof)
.args(["--query",
"SELECT COUNT(*) FROM java.util.HashMap WHERE @retainedHeapSize > 100000 OR @usedHeapSize > 100"])
.output().unwrap();
assert!(or_query.status.success());
let total_rows = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args(["--query", "SELECT COUNT(*) FROM java.util.HashMap"])
.output()
.unwrap();
assert!(total_rows.status.success());
let parse_count = |out: &[u8]| -> u64 {
String::from_utf8_lossy(out)
.lines()
.filter(|l| l.trim().parse::<u64>().is_ok())
.filter_map(|l| l.trim().parse::<u64>().ok())
.next()
.unwrap_or(0)
};
let retained_count = parse_count(&retained_only.stdout);
let or_count = parse_count(&or_query.stdout);
let total = parse_count(&total_rows.stdout);
assert_eq!(
or_count, retained_count,
"OR(retained>100000, usedHeap>100) should equal retained-only count ({retained_count}) \
since no rows satisfy usedHeap>100; got {or_count} (total {total})"
);
assert!(
or_count < total,
"OR filter should return fewer rows than the total {total}; got {or_count}"
);
assert!(
or_count > 0,
"OR filter returned 0 rows — retained arm should return some rows"
);
}
#[test]
fn query_object_address_in_retained_path_is_non_zero() {
let Some(hprof) = philosophers() else { return };
let out = Command::new(BIN)
.arg("query")
.arg(&hprof)
.args([
"--query",
"SELECT @objectAddress, @retainedHeapSize FROM java.lang.String \
ORDER BY @retainedHeapSize DESC LIMIT 5",
])
.output()
.unwrap();
assert!(out.status.success());
let stdout = String::from_utf8_lossy(&out.stdout);
let data_rows: Vec<&str> = stdout
.lines()
.filter(|l| l.contains('|') && !l.contains("@objectAddress") && !l.contains("---"))
.collect();
assert!(!data_rows.is_empty(), "expected data rows:\n{stdout}");
for row in &data_rows {
let cols: Vec<&str> = row.split('|').collect();
let addr: u64 = cols
.first()
.map(|s| s.trim())
.unwrap_or("0")
.parse()
.unwrap_or(0);
assert!(
addr > 0,
"@objectAddress was 0 in retained path — id_map not populated: {row}\nfull output:\n{stdout}"
);
}
}