diff --git a/blade/db/postgres/migrations/2025-11-19-000000_unique_test_names/down.sql b/blade/db/postgres/migrations/2025-11-19-000000_unique_test_names/down.sql new file mode 100644 index 0000000..538785d --- /dev/null +++ b/blade/db/postgres/migrations/2025-11-19-000000_unique_test_names/down.sql @@ -0,0 +1,6 @@ +-- Rollback: Drop trigger and function +DROP TRIGGER IF EXISTS maintain_unique_test_names_trigger ON tests; +DROP FUNCTION IF EXISTS maintain_unique_test_names(); + +-- Drop table +DROP TABLE IF EXISTS unique_test_names; diff --git a/blade/db/postgres/migrations/2025-11-19-000000_unique_test_names/up.sql b/blade/db/postgres/migrations/2025-11-19-000000_unique_test_names/up.sql new file mode 100644 index 0000000..125b8e8 --- /dev/null +++ b/blade/db/postgres/migrations/2025-11-19-000000_unique_test_names/up.sql @@ -0,0 +1,40 @@ +-- Create unique_test_names table to optimize autocomplete searches +CREATE TABLE IF NOT EXISTS unique_test_names ( + name TEXT NOT NULL PRIMARY KEY +); + +-- Create GIN index on name for LIKE '%pattern%' searches +CREATE INDEX IF NOT EXISTS unique_test_names_trgm_idx ON unique_test_names USING GIN (name gin_trgm_ops); + +-- Seed the table with existing test names from tests table +INSERT INTO unique_test_names (name) +SELECT DISTINCT name FROM tests +ON CONFLICT (name) DO NOTHING; + +-- Create trigger function to maintain unique_test_names when tests table changes +CREATE OR REPLACE FUNCTION maintain_unique_test_names() +RETURNS TRIGGER AS $$ +BEGIN + IF TG_OP = 'INSERT' OR TG_OP = 'UPDATE' THEN + -- Insert new test name if it doesn't exist + INSERT INTO unique_test_names (name) + VALUES (NEW.name) + ON CONFLICT (name) DO NOTHING; + RETURN NEW; + ELSIF TG_OP = 'DELETE' THEN + -- Remove test name only if no other tests have this name + DELETE FROM unique_test_names + WHERE name = OLD.name + AND NOT EXISTS (SELECT 1 FROM tests WHERE name = OLD.name); + RETURN OLD; + END IF; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +-- Create trigger on tests table to maintain unique_test_names +DROP TRIGGER IF EXISTS maintain_unique_test_names_trigger ON tests; +CREATE TRIGGER maintain_unique_test_names_trigger +AFTER INSERT OR UPDATE OR DELETE ON tests +FOR EACH ROW +EXECUTE FUNCTION maintain_unique_test_names(); diff --git a/blade/db/postgres/migrations/2025-11-19-000100_drop_tests_name_trgm/down.sql b/blade/db/postgres/migrations/2025-11-19-000100_drop_tests_name_trgm/down.sql new file mode 100644 index 0000000..0c35153 --- /dev/null +++ b/blade/db/postgres/migrations/2025-11-19-000100_drop_tests_name_trgm/down.sql @@ -0,0 +1,2 @@ +-- Rollback: Recreate trigram index on tests.name +CREATE INDEX IF NOT EXISTS tests_name_trgm_idx ON tests USING GIN (name gin_trgm_ops); diff --git a/blade/db/postgres/migrations/2025-11-19-000100_drop_tests_name_trgm/up.sql b/blade/db/postgres/migrations/2025-11-19-000100_drop_tests_name_trgm/up.sql new file mode 100644 index 0000000..5c6a30a --- /dev/null +++ b/blade/db/postgres/migrations/2025-11-19-000100_drop_tests_name_trgm/up.sql @@ -0,0 +1,3 @@ +-- Drop trigram index on tests.name since we now search on unique_test_names table +-- Keep the regular btree index (tests_name_idx) for joins and equality filters +DROP INDEX IF EXISTS tests_name_trgm_idx; diff --git a/blade/db/postgres/mod.rs b/blade/db/postgres/mod.rs index e351c3a..e33d8bc 100644 --- a/blade/db/postgres/mod.rs +++ b/blade/db/postgres/mod.rs @@ -685,14 +685,13 @@ impl state::DB for Postgres { } fn search_test_names(&mut self, pattern: &str, limit: usize) -> anyhow::Result> { - use schema::tests::dsl::*; + use schema::unique_test_names::dsl::*; let limit_i64: i64 = limit.try_into().context("failed to convert limit to i64")?; - let results = tests + let results = unique_test_names .select(name) .filter(name.like(format!("%{pattern}%"))) - .distinct() .order_by(name.asc()) .limit(limit_i64) .load::(&mut self.conn) @@ -1280,4 +1279,90 @@ mod tests { assert_eq!(history.history.len(), 1); assert_eq!(history.history[0].invocation_id, "inv1"); } + + #[test] + fn test_search_test_names_trigger() { + use std::time::{Duration, SystemTime}; + + let tmp = tempdir::TempDir::new("test_search_test_names").unwrap(); + let harness = harness::new(tmp.path().to_str().unwrap()).unwrap(); + let uri = harness.uri(); + super::init_db(&uri).unwrap(); + let mgr = crate::manager::PostgresManager::new(&uri).unwrap(); + let mut db = mgr.get().unwrap(); + + // Create invocation + let inv = state::InvocationResults { + id: "inv1".to_string(), + command: "test".to_string(), + status: state::Status::Success, + start: SystemTime::now(), + is_live: false, + ..Default::default() + }; + db.upsert_shallow_invocation(&inv).unwrap(); + + // Test 1: Insert first test name + let test1 = state::Test { + name: "//path/to/test:one".to_string(), + status: state::Status::Success, + duration: Duration::from_secs(1), + end: SystemTime::now(), + num_runs: 1, + runs: vec![], + }; + db.upsert_test("inv1", &test1).unwrap(); + + let results = db.search_test_names("test:one", 10).unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0], "//path/to/test:one"); + + // Test 2: Insert second test name + let test2 = state::Test { + name: "//path/to/test:two".to_string(), + status: state::Status::Success, + duration: Duration::from_secs(2), + end: SystemTime::now(), + num_runs: 1, + runs: vec![], + }; + db.upsert_test("inv1", &test2).unwrap(); + + let results = db.search_test_names("test:", 10).unwrap(); + assert_eq!(results.len(), 2); + assert!(results.contains(&"//path/to/test:one".to_string())); + assert!(results.contains(&"//path/to/test:two".to_string())); + + // Test 3: Update existing test (should not create duplicate in + // unique_test_names) + let test1_updated = state::Test { + name: "//path/to/test:one".to_string(), + status: state::Status::Fail, + duration: Duration::from_secs(5), + end: SystemTime::now(), + num_runs: 2, + runs: vec![], + }; + db.upsert_test("inv1", &test1_updated).unwrap(); + + let results = db.search_test_names("test:one", 10).unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0], "//path/to/test:one"); + + // Test 4: Search with different patterns + let results = db.search_test_names("two", 10).unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0], "//path/to/test:two"); + + let results = db.search_test_names("path", 10).unwrap(); + assert_eq!(results.len(), 2); + + // Test 5: Search with limit + let results = db.search_test_names("test:", 1).unwrap(); + assert_eq!(results.len(), 1); + + // Test 6: Search with no matches + let results = db.search_test_names("nonexistent", 10).unwrap(); + assert_eq!(results.len(), 0); + } } diff --git a/blade/db/postgres/schema.rs b/blade/db/postgres/schema.rs index 7662889..78852b7 100644 --- a/blade/db/postgres/schema.rs +++ b/blade/db/postgres/schema.rs @@ -78,6 +78,12 @@ diesel::table! { } } +diesel::table! { + unique_test_names (name) { + name -> Text, + } +} + diesel::joinable!(options -> invocations (invocation_id)); diesel::joinable!(targets -> invocations (invocation_id)); diesel::joinable!(testartifacts -> invocations (invocation_id)); @@ -95,4 +101,5 @@ diesel::allow_tables_to_appear_in_same_query!( testartifacts, testruns, tests, + unique_test_names, ); diff --git a/blade/db/sqlite/migrations/2025-11-19-000000_unique_test_names/down.sql b/blade/db/sqlite/migrations/2025-11-19-000000_unique_test_names/down.sql new file mode 100644 index 0000000..ef2908c --- /dev/null +++ b/blade/db/sqlite/migrations/2025-11-19-000000_unique_test_names/down.sql @@ -0,0 +1,7 @@ +-- Rollback: Drop triggers and table +DROP TRIGGER IF EXISTS maintain_unique_test_names_insert; +DROP TRIGGER IF EXISTS maintain_unique_test_names_update; +DROP TRIGGER IF EXISTS maintain_unique_test_names_delete; + +-- Drop table +DROP TABLE IF EXISTS unique_test_names; diff --git a/blade/db/sqlite/migrations/2025-11-19-000000_unique_test_names/up.sql b/blade/db/sqlite/migrations/2025-11-19-000000_unique_test_names/up.sql new file mode 100644 index 0000000..51330f6 --- /dev/null +++ b/blade/db/sqlite/migrations/2025-11-19-000000_unique_test_names/up.sql @@ -0,0 +1,45 @@ +-- Create unique_test_names table to optimize autocomplete searches +CREATE TABLE IF NOT EXISTS unique_test_names ( + name TEXT NOT NULL PRIMARY KEY +); + +-- Create index on name for LIKE '%pattern%' searches +CREATE INDEX IF NOT EXISTS unique_test_names_idx ON unique_test_names (name); + +-- Seed the table with existing test names from tests table +INSERT OR IGNORE INTO unique_test_names (name) +SELECT DISTINCT name FROM tests; + +-- Create trigger function to maintain unique_test_names when tests table changes +-- SQLite uses INSTEAD OF triggers for insert/update/delete, but since we're triggering AFTER, +-- we need separate triggers for each operation. + +-- Trigger on INSERT: add new test name if it doesn't exist +CREATE TRIGGER IF NOT EXISTS maintain_unique_test_names_insert +AFTER INSERT ON tests +FOR EACH ROW +WHEN NEW.name NOT IN (SELECT name FROM unique_test_names) +BEGIN + INSERT INTO unique_test_names (name) + VALUES (NEW.name); +END; + +-- Trigger on UPDATE: add updated test name if it doesn't exist +CREATE TRIGGER IF NOT EXISTS maintain_unique_test_names_update +AFTER UPDATE ON tests +FOR EACH ROW +WHEN NEW.name NOT IN (SELECT name FROM unique_test_names) +BEGIN + INSERT INTO unique_test_names (name) + VALUES (NEW.name); +END; + +-- Trigger on DELETE: remove test name only if no other tests have this name +CREATE TRIGGER IF NOT EXISTS maintain_unique_test_names_delete +AFTER DELETE ON tests +FOR EACH ROW +BEGIN + DELETE FROM unique_test_names + WHERE name = OLD.name + AND NOT EXISTS (SELECT 1 FROM tests WHERE name = OLD.name); +END; diff --git a/blade/db/sqlite/mod.rs b/blade/db/sqlite/mod.rs index c618f63..2e1b674 100644 --- a/blade/db/sqlite/mod.rs +++ b/blade/db/sqlite/mod.rs @@ -708,14 +708,13 @@ impl state::DB for Sqlite { } fn search_test_names(&mut self, pattern: &str, limit: usize) -> anyhow::Result> { - use schema::Tests::dsl::*; + use schema::unique_test_names::dsl::*; let limit_i64: i64 = limit.try_into().context("failed to convert limit to i64")?; - let results = Tests + let results = unique_test_names .select(name) .filter(name.like(format!("%{pattern}%"))) - .distinct() .order_by(name.asc()) .limit(limit_i64) .load::(&mut self.conn) @@ -1341,4 +1340,87 @@ mod tests { assert_eq!(history.history.len(), 1); assert_eq!(history.history[0].invocation_id, "inv1"); } + + #[test] + fn test_search_test_names_trigger() { + let tmp = tempdir::TempDir::new("test_search_test_names").unwrap(); + let db_path = tmp.path().join("test.db"); + super::init_db(db_path.to_str().unwrap()).unwrap(); + let mgr = crate::manager::SqliteManager::new(db_path.to_str().unwrap()).unwrap(); + let mut db = mgr.get().unwrap(); + + // Create invocation + let inv = state::InvocationResults { + id: "inv1".to_string(), + command: "test".to_string(), + status: state::Status::Success, + start: std::time::SystemTime::now(), + is_live: false, + ..Default::default() + }; + db.upsert_shallow_invocation(&inv).unwrap(); + + // Test 1: Insert first test name + let test1 = state::Test { + name: "//path/to/test:one".to_string(), + status: state::Status::Success, + duration: std::time::Duration::from_secs(1), + end: std::time::SystemTime::now(), + num_runs: 1, + runs: vec![], + }; + db.upsert_test("inv1", &test1).unwrap(); + + let results = db.search_test_names("test:one", 10).unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0], "//path/to/test:one"); + + // Test 2: Insert second test name + let test2 = state::Test { + name: "//path/to/test:two".to_string(), + status: state::Status::Success, + duration: std::time::Duration::from_secs(2), + end: std::time::SystemTime::now(), + num_runs: 1, + runs: vec![], + }; + db.upsert_test("inv1", &test2).unwrap(); + + let results = db.search_test_names("test:", 10).unwrap(); + assert_eq!(results.len(), 2); + assert!(results.contains(&"//path/to/test:one".to_string())); + assert!(results.contains(&"//path/to/test:two".to_string())); + + // Test 3: Update existing test (should not create duplicate in + // unique_test_names) + let test1_updated = state::Test { + name: "//path/to/test:one".to_string(), + status: state::Status::Fail, + duration: std::time::Duration::from_secs(5), + end: std::time::SystemTime::now(), + num_runs: 2, + runs: vec![], + }; + db.upsert_test("inv1", &test1_updated).unwrap(); + + let results = db.search_test_names("test:one", 10).unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0], "//path/to/test:one"); + + // Test 4: Search with different patterns + let results = db.search_test_names("two", 10).unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0], "//path/to/test:two"); + + let results = db.search_test_names("path", 10).unwrap(); + assert_eq!(results.len(), 2); + + // Test 5: Search with limit + let results = db.search_test_names("test:", 1).unwrap(); + assert_eq!(results.len(), 1); + + // Test 6: Search with no matches + let results = db.search_test_names("nonexistent", 10).unwrap(); + assert_eq!(results.len(), 0); + } } diff --git a/blade/db/sqlite/schema.rs b/blade/db/sqlite/schema.rs index b9c61fb..ce5864f 100644 --- a/blade/db/sqlite/schema.rs +++ b/blade/db/sqlite/schema.rs @@ -78,6 +78,12 @@ diesel::table! { } } +diesel::table! { + unique_test_names (name) { + name -> Text, + } +} + diesel::joinable!(Options -> Invocations (invocation_id)); diesel::joinable!(Targets -> Invocations (invocation_id)); diesel::joinable!(TestArtifacts -> Invocations (invocation_id)); @@ -94,4 +100,5 @@ diesel::allow_tables_to_appear_in_same_query!( TestArtifacts, TestRuns, Tests, + unique_test_names, );