use spg_engine::{Engine, QueryResult};
use spg_storage::Value;
fn rows_of(res: QueryResult) -> Vec<Vec<Value<'static>>> {
match res {
QueryResult::Rows { rows, .. } => rows.into_iter().map(|r| r.values).collect(),
_ => panic!("expected Rows"),
}
}
fn columns_of(res: QueryResult) -> Vec<String> {
match res {
QueryResult::Rows { columns, .. } => columns.into_iter().map(|c| c.name).collect(),
_ => panic!("expected Rows"),
}
}
#[test]
fn spg_statistic_has_cold_row_count_column() {
let mut eng = Engine::new();
eng.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
let cols = columns_of(eng.execute("SELECT * FROM spg_statistic").unwrap());
assert_eq!(
cols,
vec![
"table_name".to_string(),
"column_name".to_string(),
"null_frac".to_string(),
"n_distinct".to_string(),
"histogram_bounds".to_string(),
"cold_row_count".to_string(),
]
);
}
#[test]
fn spg_stat_segment_has_table_name_column() {
let mut eng = Engine::new();
let cols = columns_of(eng.execute("SELECT * FROM spg_stat_segment").unwrap());
assert_eq!(
cols,
vec![
"segment_id".to_string(),
"table_name".to_string(),
"num_rows".to_string(),
"num_pages".to_string(),
"total_bytes".to_string(),
]
);
}
#[test]
fn analyze_populates_cold_row_count_after_freeze() {
let mut eng = Engine::new();
eng.execute("CREATE TABLE t (id INT NOT NULL, name TEXT NOT NULL)")
.unwrap();
eng.execute("CREATE INDEX ix_t_id ON t (id)").unwrap();
for i in 0..20 {
eng.execute(&format!("INSERT INTO t VALUES ({i}, 'r{i}')"))
.unwrap();
}
let report = eng
.freeze_oldest_to_cold("t", "ix_t_id", 8)
.expect("freeze");
assert_eq!(report.frozen_rows, 8);
eng.execute("ANALYZE t").unwrap();
let stats = rows_of(eng.execute("SELECT * FROM spg_statistic").unwrap());
assert!(!stats.is_empty(), "ANALYZE produced spg_statistic rows");
for row in &stats {
assert_eq!(
row[5],
Value::BigInt(8),
"expected cold_row_count = 8 across every column row"
);
}
}
#[test]
fn spg_stat_segment_resolves_table_name() {
let mut eng = Engine::new();
eng.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
eng.execute("CREATE INDEX ix_t ON t (id)").unwrap();
for i in 0..10 {
eng.execute(&format!("INSERT INTO t VALUES ({i})")).unwrap();
}
let _ = eng.freeze_oldest_to_cold("t", "ix_t", 5).expect("freeze");
let rows = rows_of(eng.execute("SELECT * FROM spg_stat_segment").unwrap());
assert!(!rows.is_empty(), "expected at least one segment row");
for row in &rows {
assert_eq!(
row[1],
Value::text("t"),
"expected table_name='t' for segment owned by t"
);
}
}
#[test]
fn join_walker_spans_hot_cold_tier_on_order_by_limit() {
let mut eng = Engine::new();
eng.execute("CREATE TABLE mailboxes (id BIGINT NOT NULL PRIMARY KEY, name TEXT NOT NULL)")
.unwrap();
eng.execute(
"CREATE TABLE messages (id BIGINT NOT NULL PRIMARY KEY, mailbox_id BIGINT NOT NULL, \
size BIGINT NOT NULL)",
)
.unwrap();
for i in 0..4 {
eng.execute(&format!("INSERT INTO mailboxes VALUES ({i}, 'mb{i}')"))
.unwrap();
}
for i in 1..=100 {
eng.execute(&format!(
"INSERT INTO messages VALUES ({i}, {}, {})",
i % 4,
if i % 17 == 0 { 0 } else { 1024 }
))
.unwrap();
}
let sql = "SELECT m.id, mb.name FROM messages m \
JOIN mailboxes mb ON m.mailbox_id = mb.id \
WHERE m.size > 0 \
ORDER BY m.id DESC LIMIT 10";
let baseline = rows_of(eng.execute(sql).unwrap());
assert_eq!(baseline.len(), 10);
let ids: Vec<i64> = baseline
.iter()
.map(|r| match r[0] {
Value::BigInt(n) => n,
_ => panic!("expected BigInt id"),
})
.collect();
assert_eq!(ids, vec![100, 99, 98, 97, 96, 95, 94, 93, 92, 91]);
let report = eng
.freeze_oldest_to_cold("messages", "messages_pkey", 50)
.expect("freeze cold");
assert_eq!(report.frozen_rows, 50);
let after_freeze_top = rows_of(eng.execute(sql).unwrap());
let ids_top: Vec<i64> = after_freeze_top
.iter()
.map(|r| match r[0] {
Value::BigInt(n) => n,
_ => panic!("expected BigInt id"),
})
.collect();
assert_eq!(ids_top, vec![100, 99, 98, 97, 96, 95, 94, 93, 92, 91]);
let asc_sql = "SELECT m.id, mb.name FROM messages m \
JOIN mailboxes mb ON m.mailbox_id = mb.id \
WHERE m.size > 0 \
ORDER BY m.id ASC LIMIT 10";
let asc = rows_of(eng.execute(asc_sql).unwrap());
let ids_asc: Vec<i64> = asc
.iter()
.map(|r| match r[0] {
Value::BigInt(n) => n,
_ => panic!("expected BigInt id"),
})
.collect();
assert_eq!(
ids_asc,
vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
"ASC walker must traverse cold-tier rows correctly"
);
}
#[test]
fn single_table_walker_spans_hot_cold_tier_on_order_by_limit() {
let mut eng = Engine::new();
eng.execute("CREATE TABLE t (id BIGINT NOT NULL PRIMARY KEY, payload TEXT NOT NULL)")
.unwrap();
for i in 1..=100 {
eng.execute(&format!("INSERT INTO t VALUES ({i}, 'row-{i}')"))
.unwrap();
}
let report = eng
.freeze_oldest_to_cold("t", "t_pkey", 50)
.expect("freeze cold");
assert_eq!(report.frozen_rows, 50);
let asc = rows_of(
eng.execute("SELECT id FROM t WHERE id > 0 ORDER BY id ASC LIMIT 10")
.unwrap(),
);
let ids_asc: Vec<i64> = asc
.iter()
.map(|r| match r[0] {
Value::BigInt(n) => n,
_ => panic!("expected BigInt id"),
})
.collect();
assert_eq!(
ids_asc,
vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
"single-table ASC walker must traverse cold-tier rows"
);
let desc = rows_of(
eng.execute("SELECT id FROM t WHERE id > 0 ORDER BY id DESC LIMIT 10")
.unwrap(),
);
let ids_desc: Vec<i64> = desc
.iter()
.map(|r| match r[0] {
Value::BigInt(n) => n,
_ => panic!("expected BigInt id"),
})
.collect();
assert_eq!(ids_desc, vec![100, 99, 98, 97, 96, 95, 94, 93, 92, 91]);
}
#[test]
fn peer_table_cold_tier_rows_visible_to_join() {
let mut eng = Engine::new();
eng.execute("CREATE TABLE mailboxes (id BIGINT NOT NULL PRIMARY KEY, name TEXT NOT NULL)")
.unwrap();
eng.execute(
"CREATE TABLE messages (id BIGINT NOT NULL PRIMARY KEY, mailbox_id BIGINT NOT NULL)",
)
.unwrap();
for i in 1..=100 {
eng.execute(&format!("INSERT INTO mailboxes VALUES ({i}, 'mb-{i}')"))
.unwrap();
eng.execute(&format!("INSERT INTO messages VALUES ({i}, {i})"))
.unwrap();
}
let report = eng
.freeze_oldest_to_cold("mailboxes", "mailboxes_pkey", 50)
.expect("freeze cold");
assert_eq!(report.frozen_rows, 50);
let mb_all = rows_of(eng.execute("SELECT id FROM mailboxes").unwrap());
assert_eq!(mb_all.len(), 100, "SELECT * lost cold-tier rows");
let rows = rows_of(
eng.execute("SELECT COUNT(*) FROM messages m JOIN mailboxes mb ON m.mailbox_id = mb.id")
.unwrap(),
);
assert_eq!(
rows[0][0],
Value::BigInt(100),
"join lost cold-tier peer rows"
);
let by_id = rows_of(
eng.execute(
"SELECT mb.name FROM messages m JOIN mailboxes mb ON m.mailbox_id = mb.id \
WHERE m.id = 5",
)
.unwrap(),
);
assert_eq!(by_id.len(), 1);
assert_eq!(by_id[0][0], Value::text("mb-5"));
}
#[test]
fn streamed_topn_join_primary_spans_hot_cold_tier() {
let mut eng = Engine::new();
eng.execute("CREATE TABLE mailboxes (id BIGINT NOT NULL PRIMARY KEY, name TEXT NOT NULL)")
.unwrap();
eng.execute(
"CREATE TABLE messages (id BIGINT NOT NULL PRIMARY KEY, mailbox_id BIGINT NOT NULL, \
score BIGINT NOT NULL)",
)
.unwrap();
for i in 1..=4 {
eng.execute(&format!("INSERT INTO mailboxes VALUES ({i}, 'mb-{i}')"))
.unwrap();
}
for i in 1..=100 {
eng.execute(&format!(
"INSERT INTO messages VALUES ({i}, {}, {})",
(i % 4) + 1,
i
))
.unwrap();
}
let report = eng
.freeze_oldest_to_cold("messages", "messages_pkey", 50)
.expect("freeze cold");
assert_eq!(report.frozen_rows, 50);
let cnt = rows_of(eng.execute("SELECT COUNT(*) FROM messages").unwrap());
assert_eq!(
cnt[0][0],
Value::BigInt(100),
"SELECT COUNT(*) lost cold-tier rows"
);
let all = rows_of(eng.execute("SELECT id FROM messages WHERE id > 0").unwrap());
assert_eq!(all.len(), 100, "bare SELECT lost cold-tier rows");
let rows = rows_of(
eng.execute(
"SELECT m.id, m.score FROM messages m JOIN mailboxes mb ON m.mailbox_id = mb.id \
ORDER BY m.score ASC LIMIT 10",
)
.unwrap(),
);
let scores: Vec<i64> = rows
.iter()
.map(|r| match r[1] {
Value::BigInt(n) => n,
_ => panic!("expected BigInt score"),
})
.collect();
assert_eq!(
scores,
vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
"streamed-topN primary scan must traverse cold-tier rows"
);
}
#[test]
fn composite_fk_check_sees_cold_parent_rows() {
let mut eng = Engine::new();
eng.execute(
"CREATE TABLE parent (\
tenant_id BIGINT NOT NULL, \
entity_id BIGINT NOT NULL, \
name TEXT NOT NULL, \
id BIGINT NOT NULL PRIMARY KEY, \
UNIQUE(tenant_id, entity_id))",
)
.unwrap();
eng.execute(
"CREATE TABLE child (\
id BIGINT NOT NULL PRIMARY KEY, \
tenant_id BIGINT NOT NULL, \
entity_id BIGINT NOT NULL, \
FOREIGN KEY (tenant_id, entity_id) REFERENCES parent (tenant_id, entity_id))",
)
.unwrap();
for i in 1..=10 {
eng.execute(&format!("INSERT INTO parent VALUES (1, {i}, 'p-{i}', {i})"))
.unwrap();
}
let report = eng
.freeze_oldest_to_cold("parent", "parent_pkey", 5)
.expect("freeze parent");
assert_eq!(report.frozen_rows, 5);
eng.execute("INSERT INTO child VALUES (100, 1, 7)")
.expect("hot parent reference must pass");
eng.execute("INSERT INTO child VALUES (101, 1, 2)")
.expect("cold parent reference must pass");
let res = eng.execute("INSERT INTO child VALUES (102, 1, 999)");
assert!(res.is_err(), "missing parent reference must still error");
}
#[test]
fn composite_on_conflict_key_check_sees_cold_rows() {
let mut eng = Engine::new();
eng.execute(
"CREATE TABLE t (\
id BIGINT NOT NULL PRIMARY KEY, \
tenant_id BIGINT NOT NULL, \
entity_id BIGINT NOT NULL, \
UNIQUE(tenant_id, entity_id))",
)
.unwrap();
for i in 1..=10 {
eng.execute(&format!("INSERT INTO t VALUES ({i}, 1, {i})"))
.unwrap();
}
let report = eng.freeze_oldest_to_cold("t", "t_pkey", 5).expect("freeze");
assert_eq!(report.frozen_rows, 5);
let before_cnt = match eng.execute("SELECT COUNT(*) FROM t").unwrap() {
QueryResult::Rows { rows, .. } => rows[0].values[0].clone(),
_ => panic!("expected Rows"),
};
assert_eq!(before_cnt, Value::BigInt(10));
eng.execute("INSERT INTO t VALUES (100, 1, 2) ON CONFLICT (tenant_id, entity_id) DO NOTHING")
.expect("ON CONFLICT DO NOTHING must not error");
let after_cnt = match eng.execute("SELECT COUNT(*) FROM t").unwrap() {
QueryResult::Rows { rows, .. } => rows[0].values[0].clone(),
_ => panic!("expected Rows"),
};
assert_eq!(
after_cnt,
Value::BigInt(10),
"ON CONFLICT DO NOTHING must skip when conflict is cold"
);
}
#[test]
fn delete_cascade_with_cold_child_surfaces_explicit_error() {
let mut eng = Engine::new();
eng.execute("CREATE TABLE parent (id BIGINT NOT NULL PRIMARY KEY)")
.unwrap();
eng.execute(
"CREATE TABLE child (\
id BIGINT NOT NULL PRIMARY KEY, \
parent_id BIGINT NOT NULL, \
FOREIGN KEY (parent_id) REFERENCES parent (id) ON DELETE CASCADE)",
)
.unwrap();
for i in 1..=10 {
eng.execute(&format!("INSERT INTO parent VALUES ({i})"))
.unwrap();
}
for i in 1..=10 {
eng.execute(&format!("INSERT INTO child VALUES ({i}, {i})"))
.unwrap();
}
let report = eng
.freeze_oldest_to_cold("child", "child_pkey", 5)
.expect("freeze child");
assert_eq!(report.frozen_rows, 5);
let res = eng.execute("DELETE FROM parent WHERE id = 3");
let err = res.expect_err("cold child cascade must error");
let msg = format!("{err}");
assert!(
msg.contains("cold-tier"),
"expected cold-tier error, got: {msg}"
);
eng.execute("DELETE FROM parent WHERE id = 8")
.expect("hot child cascade must still work");
}
#[test]
fn join_primary_with_cold_rows_uses_materialise_fallback() {
let mut eng = Engine::new();
eng.execute("CREATE TABLE mailboxes (id BIGINT NOT NULL PRIMARY KEY, name TEXT NOT NULL)")
.unwrap();
eng.execute(
"CREATE TABLE messages (id BIGINT NOT NULL PRIMARY KEY, mailbox_id BIGINT NOT NULL)",
)
.unwrap();
for i in 1..=4 {
eng.execute(&format!("INSERT INTO mailboxes VALUES ({i}, 'mb-{i}')"))
.unwrap();
}
for i in 1..=100 {
eng.execute(&format!(
"INSERT INTO messages VALUES ({i}, {})",
(i % 4) + 1
))
.unwrap();
}
let report = eng
.freeze_oldest_to_cold("messages", "messages_pkey", 50)
.expect("freeze messages");
assert_eq!(report.frozen_rows, 50);
let rows = rows_of(
eng.execute(
"SELECT m.id, mb.name FROM messages m \
JOIN mailboxes mb ON m.mailbox_id = mb.id \
ORDER BY m.id ASC LIMIT 200",
)
.unwrap(),
);
assert_eq!(rows.len(), 100, "JOIN primary lost cold-tier rows");
let ids: Vec<i64> = rows
.iter()
.map(|r| match r[0] {
Value::BigInt(n) => n,
_ => panic!("expected BigInt id"),
})
.collect();
let mut expected = (1..=100i64).collect::<Vec<_>>();
expected.sort();
assert_eq!(ids, expected);
}
#[test]
fn alter_column_type_blocks_when_cold_rows_present() {
let mut eng = Engine::new();
eng.execute("CREATE TABLE t (id BIGINT NOT NULL PRIMARY KEY, payload TEXT NOT NULL)")
.unwrap();
for i in 1..=10 {
eng.execute(&format!("INSERT INTO t VALUES ({i}, '{i}')"))
.unwrap();
}
eng.freeze_oldest_to_cold("t", "t_pkey", 5).expect("freeze");
let err = eng
.execute("ALTER TABLE t ALTER COLUMN payload TYPE BIGINT USING CAST(payload AS BIGINT)")
.expect_err("ALTER on cold-bearing table must error");
let msg = format!("{err}");
assert!(
msg.contains("cold-tier"),
"expected cold-tier error from ALTER, got: {msg}"
);
}
#[test]
fn create_unique_index_catches_cold_tier_duplicate() {
let mut eng = Engine::new();
eng.execute("CREATE TABLE t (id BIGINT NOT NULL PRIMARY KEY, email TEXT NOT NULL)")
.unwrap();
eng.execute("INSERT INTO t VALUES (1, 'dup@x')").unwrap();
eng.execute("INSERT INTO t VALUES (2, 'unique-1@x')")
.unwrap();
eng.execute("INSERT INTO t VALUES (3, 'unique-2@x')")
.unwrap();
eng.execute("INSERT INTO t VALUES (4, 'dup@x')").unwrap();
eng.execute("INSERT INTO t VALUES (5, 'unique-3@x')")
.unwrap();
eng.freeze_oldest_to_cold("t", "t_pkey", 1)
.expect("freeze dup");
let res = eng.execute("CREATE UNIQUE INDEX uq_email ON t (email)");
assert!(
res.is_err(),
"CREATE UNIQUE INDEX must detect cold-tier duplicate"
);
}
#[test]
fn merge_into_target_blocks_when_target_has_cold_rows() {
let mut eng = Engine::new();
eng.execute("CREATE TABLE target_t (id BIGINT NOT NULL PRIMARY KEY, val BIGINT NOT NULL)")
.unwrap();
eng.execute("CREATE TABLE source_t (id BIGINT NOT NULL PRIMARY KEY, val BIGINT NOT NULL)")
.unwrap();
for i in 1..=10 {
eng.execute(&format!("INSERT INTO target_t VALUES ({i}, {i})"))
.unwrap();
}
eng.execute("INSERT INTO source_t VALUES (3, 333)").unwrap();
eng.freeze_oldest_to_cold("target_t", "target_t_pkey", 5)
.expect("freeze target");
let err = eng
.execute(
"MERGE INTO target_t t USING source_t s ON t.id = s.id \
WHEN MATCHED THEN UPDATE SET val = s.val",
)
.expect_err("MERGE on cold-bearing target must error");
let msg = format!("{err}");
assert!(
msg.contains("cold-tier"),
"expected cold-tier MERGE error, got: {msg}"
);
}
#[test]
fn update_non_pk_where_promotes_cold_matches() {
let mut eng = Engine::new();
eng.execute(
"CREATE TABLE t (id BIGINT NOT NULL PRIMARY KEY, status TEXT NOT NULL, val BIGINT NOT NULL)",
)
.unwrap();
for i in 1..=10 {
eng.execute(&format!("INSERT INTO t VALUES ({i}, 'pending', {i})"))
.unwrap();
}
eng.freeze_oldest_to_cold("t", "t_pkey", 5).expect("freeze");
eng.execute("UPDATE t SET status = 'done' WHERE val > 0")
.expect("non-PK UPDATE must succeed across both tiers");
let rows = rows_of(
eng.execute("SELECT COUNT(*) FROM t WHERE status = 'done'")
.unwrap(),
);
assert_eq!(
rows[0][0],
Value::BigInt(10),
"non-PK UPDATE must reach cold-tier rows too"
);
}
#[test]
fn delete_non_pk_where_shadows_cold_matches() {
let mut eng = Engine::new();
eng.execute("CREATE TABLE t (id BIGINT NOT NULL PRIMARY KEY, val BIGINT NOT NULL)")
.unwrap();
for i in 1..=10 {
eng.execute(&format!("INSERT INTO t VALUES ({i}, {i})"))
.unwrap();
}
eng.freeze_oldest_to_cold("t", "t_pkey", 5).expect("freeze");
eng.execute("DELETE FROM t WHERE val <= 3")
.expect("non-PK DELETE must reach cold rows");
let rows = rows_of(eng.execute("SELECT COUNT(*) FROM t").unwrap());
assert_eq!(
rows[0][0],
Value::BigInt(7),
"non-PK DELETE must shadow cold-tier matches"
);
}
#[test]
fn cold_row_count_zero_before_any_freeze() {
let mut eng = Engine::new();
eng.execute("CREATE TABLE t (id INT NOT NULL)").unwrap();
eng.execute("CREATE INDEX ix_t ON t (id)").unwrap();
for i in 0..5 {
eng.execute(&format!("INSERT INTO t VALUES ({i})")).unwrap();
}
eng.execute("ANALYZE t").unwrap();
let stats = rows_of(eng.execute("SELECT * FROM spg_statistic").unwrap());
for row in &stats {
assert_eq!(row[5], Value::BigInt(0), "no freeze → cold_row_count = 0");
}
}