use spg_engine::Engine;
use spg_storage::Value;
fn eng() -> Engine {
Engine::new()
}
fn ok(e: &mut Engine, sql: &str) {
e.execute(sql)
.unwrap_or_else(|err| panic!("{sql}: {err:?}"));
}
fn rows(e: &mut Engine, sql: &str) -> Vec<spg_storage::Row> {
let r = e
.execute(sql)
.unwrap_or_else(|err| panic!("{sql}: {err:?}"));
match r {
spg_engine::QueryResult::Rows { rows, .. } => rows,
other => panic!("expected rows, got {other:?}"),
}
}
fn first_value(e: &mut Engine, sql: &str) -> Value {
rows(e, sql)
.into_iter()
.next()
.map(|mut r| r.values.remove(0))
.expect("at least one row")
}
#[test]
fn create_function_and_trigger_persist_in_catalog() {
let mut e = eng();
ok(&mut e, "CREATE TABLE t (id INT NOT NULL, v INT NOT NULL)");
ok(
&mut e,
"CREATE FUNCTION noop() RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN RETURN NEW; END; $$",
);
ok(
&mut e,
"CREATE TRIGGER tg BEFORE INSERT ON t FOR EACH ROW EXECUTE FUNCTION noop()",
);
let bytes = e.snapshot();
let cat = spg_storage::Catalog::deserialize(&bytes).unwrap();
assert!(cat.functions().contains_key("noop"));
assert!(cat.triggers().iter().any(|t| t.name == "tg"));
}
#[test]
fn before_insert_trigger_returns_new_unchanged_passes_row_through() {
let mut e = eng();
ok(&mut e, "CREATE TABLE t (id INT NOT NULL, v INT NOT NULL)");
ok(
&mut e,
"CREATE FUNCTION noop() RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN RETURN NEW; END; $$",
);
ok(
&mut e,
"CREATE TRIGGER tg BEFORE INSERT ON t FOR EACH ROW EXECUTE FUNCTION noop()",
);
ok(&mut e, "INSERT INTO t VALUES (1, 10)");
let v = first_value(&mut e, "SELECT v FROM t WHERE id = 1");
assert_eq!(v, Value::Int(10));
}
#[test]
fn before_insert_trigger_returns_null_skips_the_row() {
let mut e = eng();
ok(&mut e, "CREATE TABLE t (id INT NOT NULL, v INT NOT NULL)");
ok(
&mut e,
"CREATE FUNCTION blackhole() RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN RETURN NULL; END; $$",
);
ok(
&mut e,
"CREATE TRIGGER tg BEFORE INSERT ON t FOR EACH ROW EXECUTE FUNCTION blackhole()",
);
ok(&mut e, "INSERT INTO t VALUES (1, 10)");
let rs = rows(&mut e, "SELECT id FROM t");
assert!(
rs.is_empty(),
"trigger returned NULL, row should be skipped"
);
}
#[test]
fn before_insert_trigger_rewrites_new_column() {
let mut e = eng();
ok(&mut e, "CREATE TABLE t (id INT NOT NULL, v INT NOT NULL)");
ok(
&mut e,
"CREATE FUNCTION force_v() RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN NEW.v := 999; RETURN NEW; END; $$",
);
ok(
&mut e,
"CREATE TRIGGER tg BEFORE INSERT ON t FOR EACH ROW EXECUTE FUNCTION force_v()",
);
ok(&mut e, "INSERT INTO t VALUES (1, 10)");
let v = first_value(&mut e, "SELECT v FROM t WHERE id = 1");
assert_eq!(v, Value::Int(999));
}
#[test]
fn before_insert_trigger_mailrs_search_vector_shape() {
let mut e = eng();
ok(
&mut e,
"CREATE TABLE messages (id INT NOT NULL, subject TEXT NOT NULL, sender TEXT NOT NULL, search_vector tsvector)",
);
ok(
&mut e,
"CREATE INDEX msg_sv_gin ON messages USING gin (search_vector)",
);
ok(
&mut e,
"CREATE FUNCTION update_sv() RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
NEW.search_vector := to_tsvector('simple', NEW.subject);
RETURN NEW;
END;
$$",
);
ok(
&mut e,
"CREATE TRIGGER messages_sv BEFORE INSERT ON messages FOR EACH ROW EXECUTE FUNCTION update_sv()",
);
ok(
&mut e,
"INSERT INTO messages VALUES (1, 'the quick brown fox', 'alice@example.com', NULL)",
);
let rs = rows(
&mut e,
"SELECT id FROM messages WHERE search_vector @@ to_tsquery('simple', 'fox')",
);
assert_eq!(rs.len(), 1, "expected fox to match the auto-populated row");
assert!(matches!(rs[0].values[0], Value::Int(1)));
}
#[test]
fn after_trigger_cannot_assign_to_new() {
let mut e = eng();
ok(&mut e, "CREATE TABLE t (id INT NOT NULL, v INT NOT NULL)");
ok(
&mut e,
"CREATE FUNCTION bad() RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN NEW.v := 1; RETURN NEW; END; $$",
);
ok(
&mut e,
"CREATE TRIGGER tg AFTER INSERT ON t FOR EACH ROW EXECUTE FUNCTION bad()",
);
let err = e
.execute("INSERT INTO t VALUES (1, 10)")
.expect_err("AFTER trigger assigning NEW must error");
let msg = alloc_format(&err);
assert!(
msg.to_lowercase().contains("after") && msg.to_lowercase().contains("read-only"),
"expected AFTER NEW read-only error, got {msg}"
);
}
#[test]
fn drop_trigger_stops_firing() {
let mut e = eng();
ok(&mut e, "CREATE TABLE t (id INT NOT NULL, v INT NOT NULL)");
ok(
&mut e,
"CREATE FUNCTION force_v() RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN NEW.v := 999; RETURN NEW; END; $$",
);
ok(
&mut e,
"CREATE TRIGGER tg BEFORE INSERT ON t FOR EACH ROW EXECUTE FUNCTION force_v()",
);
ok(&mut e, "INSERT INTO t VALUES (1, 10)");
assert_eq!(
first_value(&mut e, "SELECT v FROM t WHERE id = 1"),
Value::Int(999)
);
ok(&mut e, "DROP TRIGGER tg ON t");
ok(&mut e, "INSERT INTO t VALUES (2, 10)");
assert_eq!(
first_value(&mut e, "SELECT v FROM t WHERE id = 2"),
Value::Int(10)
);
}
fn alloc_format<T: core::fmt::Debug>(t: &T) -> String {
format!("{t:?}")
}
#[test]
fn before_update_trigger_can_rewrite_new_column() {
let mut e = eng();
ok(&mut e, "CREATE TABLE t (id INT NOT NULL, v INT NOT NULL)");
ok(&mut e, "INSERT INTO t VALUES (1, 10)");
ok(
&mut e,
"CREATE FUNCTION force_v() RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN NEW.v := 777; RETURN NEW; END; $$",
);
ok(
&mut e,
"CREATE TRIGGER tg BEFORE UPDATE ON t FOR EACH ROW EXECUTE FUNCTION force_v()",
);
ok(&mut e, "UPDATE t SET v = 99 WHERE id = 1");
assert_eq!(
first_value(&mut e, "SELECT v FROM t WHERE id = 1"),
Value::Int(777)
);
}
#[test]
fn before_update_trigger_can_skip_via_return_null() {
let mut e = eng();
ok(&mut e, "CREATE TABLE t (id INT NOT NULL, v INT NOT NULL)");
ok(&mut e, "INSERT INTO t VALUES (1, 10)");
ok(
&mut e,
"CREATE FUNCTION veto() RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN RETURN NULL; END; $$",
);
ok(
&mut e,
"CREATE TRIGGER tg BEFORE UPDATE ON t FOR EACH ROW EXECUTE FUNCTION veto()",
);
ok(&mut e, "UPDATE t SET v = 99 WHERE id = 1");
assert_eq!(
first_value(&mut e, "SELECT v FROM t WHERE id = 1"),
Value::Int(10)
);
}
#[test]
fn before_update_trigger_sees_old_row() {
let mut e = eng();
ok(&mut e, "CREATE TABLE t (id INT NOT NULL, v INT NOT NULL)");
ok(&mut e, "INSERT INTO t VALUES (1, 10)");
ok(
&mut e,
"CREATE FUNCTION bump() RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN NEW.v := OLD.v + 1; RETURN NEW; END; $$",
);
ok(
&mut e,
"CREATE TRIGGER tg BEFORE UPDATE ON t FOR EACH ROW EXECUTE FUNCTION bump()",
);
ok(&mut e, "UPDATE t SET v = 999 WHERE id = 1");
assert_eq!(
first_value(&mut e, "SELECT v FROM t WHERE id = 1"),
Value::Int(11)
);
}
#[test]
fn before_delete_trigger_can_veto_individual_rows() {
let mut e = eng();
ok(
&mut e,
"CREATE TABLE t (id INT NOT NULL, keep INT NOT NULL)",
);
ok(&mut e, "INSERT INTO t VALUES (1, 0)");
ok(&mut e, "INSERT INTO t VALUES (2, 1)");
ok(&mut e, "INSERT INTO t VALUES (3, 0)");
ok(
&mut e,
"CREATE FUNCTION guard() RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
NEW.keep := OLD.keep;
RETURN NEW;
END;
$$",
);
ok(
&mut e,
"CREATE FUNCTION blackhole_del() RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN RETURN NULL; END; $$",
);
ok(
&mut e,
"CREATE TRIGGER tg_del BEFORE DELETE ON t FOR EACH ROW EXECUTE FUNCTION blackhole_del()",
);
let r = e
.execute("DELETE FROM t WHERE id IN (1, 2, 3)")
.expect("delete should succeed (just no-op)");
if let spg_engine::QueryResult::CommandOk { affected, .. } = r {
assert_eq!(
affected, 0,
"all 3 rows skipped via BEFORE DELETE RETURN NULL"
);
}
let count = first_value(&mut e, "SELECT count(*) FROM t");
assert_eq!(count, Value::BigInt(3));
}
#[test]
fn after_update_trigger_cannot_assign_to_new() {
let mut e = eng();
ok(&mut e, "CREATE TABLE t (id INT NOT NULL, v INT NOT NULL)");
ok(&mut e, "INSERT INTO t VALUES (1, 10)");
ok(
&mut e,
"CREATE FUNCTION bad_after() RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN NEW.v := 1; RETURN NEW; END; $$",
);
ok(
&mut e,
"CREATE TRIGGER tg AFTER UPDATE ON t FOR EACH ROW EXECUTE FUNCTION bad_after()",
);
let err = e
.execute("UPDATE t SET v = 99 WHERE id = 1")
.expect_err("AFTER UPDATE writing NEW must error");
let msg = alloc_format(&err);
assert!(
msg.to_lowercase().contains("after") && msg.to_lowercase().contains("read-only"),
"expected AFTER NEW read-only error, got {msg}"
);
}
#[test]
fn before_insert_trigger_if_then_rewrites_only_when_condition_true() {
let mut e = eng();
ok(&mut e, "CREATE TABLE t (id INT NOT NULL, v INT NOT NULL)");
ok(
&mut e,
"CREATE FUNCTION gated() RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
IF NEW.v = 10 THEN
NEW.v := 999;
END IF;
RETURN NEW;
END;
$$",
);
ok(
&mut e,
"CREATE TRIGGER tg BEFORE INSERT ON t FOR EACH ROW EXECUTE FUNCTION gated()",
);
ok(&mut e, "INSERT INTO t VALUES (1, 10)");
ok(&mut e, "INSERT INTO t VALUES (2, 11)");
assert_eq!(
first_value(&mut e, "SELECT v FROM t WHERE id = 1"),
Value::Int(999),
"row matching IF predicate gets rewritten"
);
assert_eq!(
first_value(&mut e, "SELECT v FROM t WHERE id = 2"),
Value::Int(11),
"row not matching IF predicate passes through"
);
}
#[test]
fn before_insert_trigger_if_elsif_else_chain() {
let mut e = eng();
ok(&mut e, "CREATE TABLE t (id INT NOT NULL, v INT NOT NULL)");
ok(
&mut e,
"CREATE FUNCTION grade() RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
IF NEW.v < 60 THEN
NEW.v := 0;
ELSIF NEW.v < 90 THEN
NEW.v := 50;
ELSE
NEW.v := 100;
END IF;
RETURN NEW;
END;
$$",
);
ok(
&mut e,
"CREATE TRIGGER tg BEFORE INSERT ON t FOR EACH ROW EXECUTE FUNCTION grade()",
);
ok(&mut e, "INSERT INTO t VALUES (1, 50)");
ok(&mut e, "INSERT INTO t VALUES (2, 75)");
ok(&mut e, "INSERT INTO t VALUES (3, 95)");
assert_eq!(
first_value(&mut e, "SELECT v FROM t WHERE id = 1"),
Value::Int(0)
);
assert_eq!(
first_value(&mut e, "SELECT v FROM t WHERE id = 2"),
Value::Int(50)
);
assert_eq!(
first_value(&mut e, "SELECT v FROM t WHERE id = 3"),
Value::Int(100)
);
}
#[test]
fn before_insert_trigger_if_can_short_circuit_via_return_null() {
let mut e = eng();
ok(&mut e, "CREATE TABLE t (id INT NOT NULL, v INT NOT NULL)");
ok(
&mut e,
"CREATE FUNCTION pos_only() RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
IF NEW.v < 0 THEN
RETURN NULL;
END IF;
RETURN NEW;
END;
$$",
);
ok(
&mut e,
"CREATE TRIGGER tg BEFORE INSERT ON t FOR EACH ROW EXECUTE FUNCTION pos_only()",
);
ok(&mut e, "INSERT INTO t VALUES (1, 5)");
ok(&mut e, "INSERT INTO t VALUES (2, -3)");
let count = first_value(&mut e, "SELECT count(*) FROM t");
assert_eq!(count, Value::BigInt(1));
}
#[test]
fn before_insert_trigger_declare_and_use_local_var() {
let mut e = eng();
ok(&mut e, "CREATE TABLE t (id INT NOT NULL, v INT NOT NULL)");
ok(
&mut e,
"CREATE FUNCTION doubled() RETURNS TRIGGER LANGUAGE plpgsql AS $$
DECLARE
scaled INT := NEW.v * 2;
BEGIN
NEW.v := scaled;
RETURN NEW;
END;
$$",
);
ok(
&mut e,
"CREATE TRIGGER tg BEFORE INSERT ON t FOR EACH ROW EXECUTE FUNCTION doubled()",
);
ok(&mut e, "INSERT INTO t VALUES (1, 7)");
assert_eq!(
first_value(&mut e, "SELECT v FROM t WHERE id = 1"),
Value::Int(14)
);
}
#[test]
fn before_insert_trigger_declare_chain_and_assign() {
let mut e = eng();
ok(&mut e, "CREATE TABLE t (id INT NOT NULL, v INT NOT NULL)");
ok(
&mut e,
"CREATE FUNCTION chained() RETURNS TRIGGER LANGUAGE plpgsql AS $$
DECLARE
a INT := NEW.v + 1;
b INT := a * 10;
BEGIN
NEW.v := b;
RETURN NEW;
END;
$$",
);
ok(
&mut e,
"CREATE TRIGGER tg BEFORE INSERT ON t FOR EACH ROW EXECUTE FUNCTION chained()",
);
ok(&mut e, "INSERT INTO t VALUES (1, 4)");
assert_eq!(
first_value(&mut e, "SELECT v FROM t WHERE id = 1"),
Value::Int(50)
);
}
#[test]
fn before_insert_trigger_raise_notice_does_not_block_write() {
let mut e = eng();
ok(&mut e, "CREATE TABLE t (id INT NOT NULL, v INT NOT NULL)");
ok(
&mut e,
"CREATE FUNCTION log_it() RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
RAISE NOTICE 'inserting row %', NEW.id;
RETURN NEW;
END;
$$",
);
ok(
&mut e,
"CREATE TRIGGER tg BEFORE INSERT ON t FOR EACH ROW EXECUTE FUNCTION log_it()",
);
ok(&mut e, "INSERT INTO t VALUES (1, 42)");
assert_eq!(
first_value(&mut e, "SELECT v FROM t WHERE id = 1"),
Value::Int(42)
);
}
#[test]
fn before_insert_trigger_raise_exception_blocks_write_and_propagates_message() {
let mut e = eng();
ok(&mut e, "CREATE TABLE t (id INT NOT NULL, v INT NOT NULL)");
ok(
&mut e,
"CREATE FUNCTION reject_neg() RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
IF NEW.v < 0 THEN
RAISE EXCEPTION 'negative value % rejected', NEW.v;
END IF;
RETURN NEW;
END;
$$",
);
ok(
&mut e,
"CREATE TRIGGER tg BEFORE INSERT ON t FOR EACH ROW EXECUTE FUNCTION reject_neg()",
);
ok(&mut e, "INSERT INTO t VALUES (1, 5)");
let err = e
.execute("INSERT INTO t VALUES (2, -10)")
.expect_err("RAISE EXCEPTION must abort the insert");
let msg = alloc_format(&err);
assert!(
msg.contains("negative value") && msg.contains("-10"),
"RAISE EXCEPTION message + arg substitution should propagate: {msg}"
);
let count = first_value(&mut e, "SELECT count(*) FROM t");
assert_eq!(count, Value::BigInt(1));
}
#[test]
fn after_insert_trigger_embedded_insert_to_audit_table() {
let mut e = eng();
ok(&mut e, "CREATE TABLE t (id INT NOT NULL, v INT NOT NULL)");
ok(
&mut e,
"CREATE TABLE t_audit (src_id INT NOT NULL, src_v INT NOT NULL)",
);
ok(
&mut e,
"CREATE FUNCTION audit() RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
INSERT INTO t_audit VALUES (NEW.id, NEW.v);
RETURN NEW;
END;
$$",
);
ok(
&mut e,
"CREATE TRIGGER tg AFTER INSERT ON t FOR EACH ROW EXECUTE FUNCTION audit()",
);
ok(&mut e, "INSERT INTO t VALUES (1, 100)");
ok(&mut e, "INSERT INTO t VALUES (2, 200)");
let r = rows(&mut e, "SELECT src_id, src_v FROM t_audit ORDER BY src_id");
assert_eq!(r.len(), 2);
assert_eq!(r[0].values, vec![Value::Int(1), Value::Int(100)]);
assert_eq!(r[1].values, vec![Value::Int(2), Value::Int(200)]);
}
#[test]
fn after_update_trigger_embedded_update_to_propagate_to_related_table() {
let mut e = eng();
ok(
&mut e,
"CREATE TABLE users (id INT NOT NULL, name TEXT NOT NULL)",
);
ok(
&mut e,
"CREATE TABLE posts (id INT NOT NULL, author_id INT NOT NULL, author_name TEXT NOT NULL)",
);
ok(&mut e, "INSERT INTO users VALUES (1, 'alice'), (2, 'bob')");
ok(
&mut e,
"INSERT INTO posts VALUES (10, 1, 'alice'), (11, 2, 'bob'), (12, 1, 'alice')",
);
ok(
&mut e,
"CREATE FUNCTION sync_author_name() RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
UPDATE posts SET author_name = NEW.name WHERE author_id = NEW.id;
RETURN NEW;
END;
$$",
);
ok(
&mut e,
"CREATE TRIGGER tg AFTER UPDATE ON users FOR EACH ROW EXECUTE FUNCTION sync_author_name()",
);
ok(&mut e, "UPDATE users SET name = 'ALICE' WHERE id = 1");
let r = rows(&mut e, "SELECT id, author_name FROM posts ORDER BY id");
assert_eq!(
r[0].values,
vec![Value::Int(10), Value::Text("ALICE".into())]
);
assert_eq!(r[1].values, vec![Value::Int(11), Value::Text("bob".into())]);
assert_eq!(
r[2].values,
vec![Value::Int(12), Value::Text("ALICE".into())]
);
}
#[test]
fn after_delete_trigger_embedded_delete_to_cleanup_related_rows() {
let mut e = eng();
ok(
&mut e,
"CREATE TABLE parent (id INT NOT NULL, name TEXT NOT NULL)",
);
ok(
&mut e,
"CREATE TABLE child (parent_id INT NOT NULL, payload TEXT NOT NULL)",
);
ok(&mut e, "INSERT INTO parent VALUES (1, 'p1'), (2, 'p2')");
ok(
&mut e,
"INSERT INTO child VALUES (1, 'c1a'), (1, 'c1b'), (2, 'c2a')",
);
ok(
&mut e,
"CREATE FUNCTION cascade_delete_children() RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
DELETE FROM child WHERE parent_id = OLD.id;
RETURN OLD;
END;
$$",
);
ok(
&mut e,
"CREATE TRIGGER tg AFTER DELETE ON parent FOR EACH ROW EXECUTE FUNCTION cascade_delete_children()",
);
ok(&mut e, "DELETE FROM parent WHERE id = 1");
let r = rows(
&mut e,
"SELECT parent_id, payload FROM child ORDER BY payload",
);
assert_eq!(r.len(), 1);
assert_eq!(r[0].values, vec![Value::Int(2), Value::Text("c2a".into())]);
}
#[test]
fn before_insert_trigger_raise_exception_inside_if_still_propagates() {
let mut e = eng();
ok(
&mut e,
"CREATE TABLE accounts (id INT NOT NULL, balance INT NOT NULL)",
);
ok(
&mut e,
"CREATE TABLE rejections (account_id INT NOT NULL, reason TEXT NOT NULL)",
);
ok(
&mut e,
"CREATE FUNCTION guard() RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
IF NEW.balance < 0 THEN
INSERT INTO rejections VALUES (NEW.id, 'negative balance');
RAISE EXCEPTION 'rejected negative balance for account %', NEW.id;
END IF;
RETURN NEW;
END;
$$",
);
ok(
&mut e,
"CREATE TRIGGER tg BEFORE INSERT ON accounts FOR EACH ROW EXECUTE FUNCTION guard()",
);
ok(&mut e, "INSERT INTO accounts VALUES (1, 100)");
let err = e
.execute("INSERT INTO accounts VALUES (2, -50)")
.expect_err("RAISE EXCEPTION must abort");
let msg = alloc_format(&err);
assert!(
msg.contains("rejected negative balance") && msg.contains("2"),
"RAISE EXCEPTION should propagate with substituted account id: {msg}"
);
let r = rows(&mut e, "SELECT id FROM accounts ORDER BY id");
assert_eq!(r.len(), 1, "row 2 must not be inserted");
let r2 = rows(&mut e, "SELECT account_id FROM rejections");
assert!(
r2.is_empty(),
"deferred embedded INSERT must NOT run when RAISE EXCEPTION aborts"
);
}
#[test]
fn mailrs_update_search_vector_on_subject_change() {
let mut e = eng();
ok(
&mut e,
"CREATE TABLE messages (id INT NOT NULL, subject TEXT NOT NULL, search_vector tsvector)",
);
ok(
&mut e,
"CREATE INDEX msg_sv_gin ON messages USING gin (search_vector)",
);
ok(
&mut e,
"CREATE FUNCTION update_sv() RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
NEW.search_vector := to_tsvector('simple', NEW.subject);
RETURN NEW;
END;
$$",
);
ok(
&mut e,
"CREATE TRIGGER messages_sv BEFORE INSERT OR UPDATE ON messages FOR EACH ROW EXECUTE FUNCTION update_sv()",
);
ok(
&mut e,
"INSERT INTO messages VALUES (1, 'the quick brown fox', NULL)",
);
let rs = rows(
&mut e,
"SELECT id FROM messages WHERE search_vector @@ to_tsquery('simple', 'fox')",
);
assert_eq!(rs.len(), 1, "initial INSERT path: fox matches");
ok(
&mut e,
"UPDATE messages SET subject = 'lazy cats sleep' WHERE id = 1",
);
let rs_fox = rows(
&mut e,
"SELECT id FROM messages WHERE search_vector @@ to_tsquery('simple', 'fox')",
);
assert!(
rs_fox.is_empty(),
"post-UPDATE: fox should NOT match — subject changed and trigger should have rewritten search_vector"
);
let rs_cats = rows(
&mut e,
"SELECT id FROM messages WHERE search_vector @@ to_tsquery('simple', 'cats')",
);
assert_eq!(
rs_cats.len(),
1,
"post-UPDATE: cats should match the new subject's lexemes"
);
}