diff --git a/internal/data/fts.go b/internal/data/fts.go index 08907bb6..b6bf2a1f 100644 --- a/internal/data/fts.go +++ b/internal/data/fts.go @@ -208,7 +208,247 @@ func (s *Store) setupEntitiesFTS() error { return fmt.Errorf("create entities FTS table: %w", err) } - return s.populateEntitiesFTS() + if err := s.populateEntitiesFTS(); err != nil { + return err + } + return s.installEntitiesFTSTriggers() +} + +// installEntitiesFTSTriggers installs the AFTER INSERT/UPDATE/DELETE triggers +// that keep entities_fts in sync with source-table writes. Parent-table _au +// triggers get companion cascade triggers that refresh child FTS rows whose +// entity_name embeds parent fields (project/vendor title → quote.entity_name, +// maintenance item name → service_log.entity_name). +// +// Triggers are DROPped then CREATEd, so upgrades and schema drift self-heal +// on every app open. Soft-deleted parents are filtered out of cascade JOINs +// so the child's entity_name degrades when the parent becomes invisible. +func (s *Store) installEntitiesFTSTriggers() error { + stmts := collectEntitiesFTSTriggerSQL() + for _, stmt := range stmts { + if err := s.db.Exec(stmt).Error; err != nil { + return fmt.Errorf("install entities FTS trigger: %w\nSQL: %s", err, stmt) + } + } + return nil +} + +// collectEntitiesFTSTriggerSQL returns all DROP + CREATE statements needed to +// install the entities_fts triggers. Order: drop every known trigger first +// (so re-installation is idempotent), then create. +func collectEntitiesFTSTriggerSQL() []string { + var stmts []string + + // Own-row triggers for every source table. + for _, spec := range ownRowSpecs() { + stmts = append(stmts, ownRowTriggerSQL(spec)...) + } + + // Parent → child cascade triggers. + stmts = append(stmts, parentCascadeQuoteSQL(TableProjects, ColProjectID)...) + stmts = append(stmts, parentCascadeQuoteSQL(TableVendors, ColVendorID)...) + stmts = append(stmts, maintenanceCascadeServiceLogSQL()...) + + return stmts +} + +// ownRowSpec describes one source table's own-row trigger config. +type ownRowSpec struct { + table string + entityType string + nameExpr string // SQL expression for entity_name; uses NEW. + textExpr string // SQL expression for entity_text +} + +func ownRowSpecs() []ownRowSpec { + col := func(c string) string { return "NEW." + c } + coalesceNew := func(c string) string { return "COALESCE(NEW." + c + ", '')" } + + return []ownRowSpec{ + { + table: TableProjects, + entityType: DeletionEntityProject, + nameExpr: col(ColTitle), + textExpr: col(ColTitle) + " || ' ' || " + coalesceNew(ColDescription) + + " || ' ' || " + coalesceNew(ColStatus), + }, + { + table: TableVendors, + entityType: DeletionEntityVendor, + nameExpr: col(ColName), + textExpr: col(ColName) + " || ' ' || " + coalesceNew(ColContactName) + + " || ' ' || " + coalesceNew(ColNotes), + }, + { + table: TableAppliances, + entityType: DeletionEntityAppliance, + nameExpr: col(ColName), + textExpr: col(ColName) + " || ' ' || " + coalesceNew(ColBrand) + + " || ' ' || " + coalesceNew(ColModelNumber) + + " || ' ' || " + coalesceNew(ColLocation) + + " || ' ' || " + coalesceNew(ColNotes), + }, + { + table: TableMaintenanceItems, + entityType: DeletionEntityMaintenance, + nameExpr: col(ColName), + textExpr: col(ColName) + " || ' ' || " + coalesceNew(ColNotes) + + " || ' ' || " + coalesceNew(ColSeason), + }, + { + table: TableIncidents, + entityType: DeletionEntityIncident, + nameExpr: col(ColTitle), + textExpr: col(ColTitle) + " || ' ' || " + coalesceNew(ColDescription) + + " || ' ' || " + coalesceNew(ColLocation) + + " || ' ' || " + coalesceNew(ColNotes) + + " || ' ' || " + coalesceNew(ColSeverity), + }, + { + // Service log entries: name comes from the joined maintenance_item. + // Soft-deleted maintenance items are filtered out so the SLE's + // entity_name blanks instead of carrying stale text. + table: TableServiceLogEntries, + entityType: DeletionEntityServiceLog, + nameExpr: "COALESCE((SELECT " + ColName + + " FROM " + TableMaintenanceItems + + " WHERE " + ColID + " = NEW." + ColMaintenanceItemID + + " AND " + ColDeletedAt + " IS NULL), '')", + textExpr: coalesceNew(ColNotes), + }, + { + // Quotes: entity_name is " - ". + // Both parents filtered for soft-delete. + table: TableQuotes, + entityType: DeletionEntityQuote, + nameExpr: "COALESCE((SELECT " + ColTitle + + " FROM " + TableProjects + + " WHERE " + ColID + " = NEW." + ColProjectID + + " AND " + ColDeletedAt + " IS NULL), '')" + + " || ' - ' || " + + "COALESCE((SELECT " + ColName + + " FROM " + TableVendors + + " WHERE " + ColID + " = NEW." + ColVendorID + + " AND " + ColDeletedAt + " IS NULL), '')", + textExpr: coalesceNew(ColNotes), + }, + } +} + +// ownRowTriggerSQL returns DROP + CREATE statements for one source table's +// AI / AU / AD triggers. The AU trigger deletes the old FTS row and +// re-inserts it only when the row is still visible (not soft-deleted). +func ownRowTriggerSQL(spec ownRowSpec) []string { + r := strings.NewReplacer( + "{TABLE}", spec.table, + "{FTS}", tableEntitiesFTS, + "{ENTITY}", spec.entityType, + "{ID}", ColID, + "{DEL}", ColDeletedAt, + "{NAME_EXPR}", spec.nameExpr, + "{TEXT_EXPR}", spec.textExpr, + ) + return []string{ + r.Replace(`DROP TRIGGER IF EXISTS {TABLE}_fts_ai`), + r.Replace(`DROP TRIGGER IF EXISTS {TABLE}_fts_au`), + r.Replace(`DROP TRIGGER IF EXISTS {TABLE}_fts_ad`), + r.Replace(`CREATE TRIGGER {TABLE}_fts_ai AFTER INSERT ON {TABLE} + WHEN NEW.{DEL} IS NULL + BEGIN + INSERT INTO {FTS} (entity_type, entity_id, entity_name, entity_text) + VALUES ('{ENTITY}', NEW.{ID}, {NAME_EXPR}, {TEXT_EXPR}); + END`), + r.Replace(`CREATE TRIGGER {TABLE}_fts_au AFTER UPDATE ON {TABLE} + BEGIN + DELETE FROM {FTS} WHERE entity_type = '{ENTITY}' AND entity_id = OLD.{ID}; + INSERT INTO {FTS} (entity_type, entity_id, entity_name, entity_text) + SELECT '{ENTITY}', NEW.{ID}, {NAME_EXPR}, {TEXT_EXPR} + WHERE NEW.{DEL} IS NULL; + END`), + r.Replace(`CREATE TRIGGER {TABLE}_fts_ad AFTER DELETE ON {TABLE} + BEGIN + DELETE FROM {FTS} WHERE entity_type = '{ENTITY}' AND entity_id = OLD.{ID}; + END`), + } +} + +// parentCascadeQuoteSQL returns DROP + CREATE statements for a cascade +// trigger that refreshes quote FTS rows when their parent (project or +// vendor) is updated. parentTable is projects or vendors; parentFK is the +// FK column on quotes pointing to that parent. +func parentCascadeQuoteSQL(parentTable, parentFK string) []string { + triggerName := parentTable + "_fts_au_cascade_quotes" + r := strings.NewReplacer( + "{TRIGGER}", triggerName, + "{PARENT_TABLE}", parentTable, + "{PARENT_ID}", ColID, + "{FTS}", tableEntitiesFTS, + "{QUOTE}", DeletionEntityQuote, + "{PARENT_FK}", parentFK, + "{Q_TABLE}", TableQuotes, + "{Q_ID}", ColID, + "{P_TABLE}", TableProjects, + "{P_FK}", ColProjectID, + "{V_TABLE}", TableVendors, + "{V_FK}", ColVendorID, + "{P_NAME}", ColTitle, + "{V_NAME}", ColName, + "{Q_NOTES}", ColNotes, + "{DEL}", ColDeletedAt, + ) + return []string{ + r.Replace(`DROP TRIGGER IF EXISTS {TRIGGER}`), + r.Replace(`CREATE TRIGGER {TRIGGER} AFTER UPDATE ON {PARENT_TABLE} + BEGIN + DELETE FROM {FTS} + WHERE entity_type = '{QUOTE}' + AND entity_id IN (SELECT {Q_ID} FROM {Q_TABLE} WHERE {PARENT_FK} = OLD.{PARENT_ID}); + INSERT INTO {FTS} (entity_type, entity_id, entity_name, entity_text) + SELECT '{QUOTE}', q.{Q_ID}, + COALESCE(p.{P_NAME}, '') || ' - ' || COALESCE(v.{V_NAME}, ''), + COALESCE(q.{Q_NOTES}, '') + FROM {Q_TABLE} q + LEFT JOIN {P_TABLE} p ON q.{P_FK} = p.{PARENT_ID} AND p.{DEL} IS NULL + LEFT JOIN {V_TABLE} v ON q.{V_FK} = v.{PARENT_ID} AND v.{DEL} IS NULL + WHERE q.{PARENT_FK} = NEW.{PARENT_ID} AND q.{DEL} IS NULL; + END`), + } +} + +// maintenanceCascadeServiceLogSQL installs the maintenance_items → SLE +// cascade trigger. When a maintenance item is updated (including soft-delete +// via deleted_at), every SLE referencing it has its FTS row rebuilt. +func maintenanceCascadeServiceLogSQL() []string { + triggerName := TableMaintenanceItems + "_fts_au_cascade_service_log" + r := strings.NewReplacer( + "{TRIGGER}", triggerName, + "{M_TABLE}", TableMaintenanceItems, + "{M_ID}", ColID, + "{M_NAME}", ColName, + "{FTS}", tableEntitiesFTS, + "{SLE}", DeletionEntityServiceLog, + "{S_TABLE}", TableServiceLogEntries, + "{S_ID}", ColID, + "{S_FK}", ColMaintenanceItemID, + "{S_NOTES}", ColNotes, + "{DEL}", ColDeletedAt, + ) + return []string{ + r.Replace(`DROP TRIGGER IF EXISTS {TRIGGER}`), + r.Replace(`CREATE TRIGGER {TRIGGER} AFTER UPDATE ON {M_TABLE} + BEGIN + DELETE FROM {FTS} + WHERE entity_type = '{SLE}' + AND entity_id IN (SELECT {S_ID} FROM {S_TABLE} WHERE {S_FK} = OLD.{M_ID}); + INSERT INTO {FTS} (entity_type, entity_id, entity_name, entity_text) + SELECT '{SLE}', s.{S_ID}, + COALESCE(m.{M_NAME}, ''), + COALESCE(s.{S_NOTES}, '') + FROM {S_TABLE} s + LEFT JOIN {M_TABLE} m ON s.{S_FK} = m.{M_ID} AND m.{DEL} IS NULL + WHERE s.{S_FK} = NEW.{M_ID} AND s.{DEL} IS NULL; + END`), + } } // populateEntitiesFTS inserts rows from each entity source table into the @@ -274,6 +514,9 @@ func (s *Store) populateEntitiesFTS() error { TableIncidents, ColDeletedAt), }, { + // Soft-deleted maintenance_items are filtered from the JOIN so + // the SLE's entity_name blanks out, matching the cascade + // trigger's behavior when a parent becomes invisible. "service_log_entries", fmt.Sprintf(`INSERT INTO %s (entity_type, entity_id, entity_name, entity_text) SELECT '%s', s.%s, COALESCE(m.%s, ''), COALESCE(s.%s, '') @@ -287,6 +530,9 @@ func (s *Store) populateEntitiesFTS() error { ColDeletedAt), }, { + // Soft-deleted parents are filtered from the JOINs so the + // quote's entity_name degrades instead of carrying stale + // parent names -- same invariant the cascade triggers enforce. "quotes", fmt.Sprintf(`INSERT INTO %s (entity_type, entity_id, entity_name, entity_text) SELECT '%s', q.%s, diff --git a/internal/data/fts_test.go b/internal/data/fts_test.go index f76fd8ff..347560db 100644 --- a/internal/data/fts_test.go +++ b/internal/data/fts_test.go @@ -794,3 +794,374 @@ func TestRebuildFTSIndexRefreshesEntities(t *testing.T) { require.Len(t, results, 1) assert.Equal(t, "Later Project", results[0].EntityName) } + +// --------------------------------------------------------------------------- +// Trigger tests: verify that AI / AU / AD triggers keep entities_fts in sync +// with source-table writes without a manual setupEntitiesFTS rebuild. +// --------------------------------------------------------------------------- + +func TestFTSTriggerInsertSurfacesProject(t *testing.T) { + t.Parallel() + store := newTestStore(t) + + types, _ := store.ProjectTypes() + require.NoError(t, store.CreateProject(&Project{ + Title: "Greenhouse Build", + ProjectTypeID: types[0].ID, + Status: ProjectStatusPlanned, + })) + + results, err := store.SearchEntities("greenhouse") + require.NoError(t, err) + require.Len(t, results, 1) + assert.Equal(t, DeletionEntityProject, results[0].EntityType) + assert.Equal(t, "Greenhouse Build", results[0].EntityName) +} + +func TestFTSTriggerUpdateSurfacesNewTitle(t *testing.T) { + t.Parallel() + store := newTestStore(t) + + types, _ := store.ProjectTypes() + p := &Project{ + Title: "Old Title", + ProjectTypeID: types[0].ID, + Status: ProjectStatusPlanned, + } + require.NoError(t, store.CreateProject(p)) + + p.Title = "Fresh Greenhouse" + require.NoError(t, store.UpdateProject(*p)) + + // Old token no longer surfaces. + oldResults, err := store.SearchEntities("old") + require.NoError(t, err) + assert.Empty(t, oldResults, "old title should be gone from FTS") + + // New token surfaces. + newResults, err := store.SearchEntities("greenhouse") + require.NoError(t, err) + require.Len(t, newResults, 1) + assert.Equal(t, "Fresh Greenhouse", newResults[0].EntityName) +} + +func TestFTSTriggerSoftDeleteRemovesRow(t *testing.T) { + t.Parallel() + store := newTestStore(t) + + types, _ := store.ProjectTypes() + p := &Project{ + Title: "Transient Project", + ProjectTypeID: types[0].ID, + Status: ProjectStatusPlanned, + } + require.NoError(t, store.CreateProject(p)) + + // Sanity: it's indexed. + before, err := store.SearchEntities("transient") + require.NoError(t, err) + require.Len(t, before, 1) + + require.NoError(t, store.DeleteProject(p.ID)) + + after, err := store.SearchEntities("transient") + require.NoError(t, err) + assert.Empty(t, after, "soft-deleted project must not surface") +} + +func TestFTSTriggerCascadeOnProjectRename(t *testing.T) { + t.Parallel() + store := newTestStore(t) + + types, _ := store.ProjectTypes() + p := &Project{ + Title: "Kitchen Remodel", + ProjectTypeID: types[0].ID, + Status: ProjectStatusPlanned, + } + require.NoError(t, store.CreateProject(p)) + + v := &Vendor{Name: "Pacific Plumbing"} + require.NoError(t, store.CreateVendor(v)) + + require.NoError(t, store.CreateQuote(&Quote{ + ProjectID: p.ID, + VendorID: v.ID, + TotalCents: 1000, + }, *v)) + + // Rename the project. + p.Title = "Greenhouse Build" + require.NoError(t, store.UpdateProject(*p)) + + // The quote should now be findable by the new project name. + results, err := store.SearchEntities("greenhouse") + require.NoError(t, err) + + var quoteFound bool + for _, r := range results { + if r.EntityType == DeletionEntityQuote { + quoteFound = true + break + } + } + assert.True( + t, + quoteFound, + "cascade should rebuild quote FTS with new project title; got %+v", + results, + ) +} + +func TestFTSTriggerCascadeOnVendorRename(t *testing.T) { + t.Parallel() + store := newTestStore(t) + + types, _ := store.ProjectTypes() + p := &Project{ + Title: "Basement Refinish", + ProjectTypeID: types[0].ID, + Status: ProjectStatusPlanned, + } + require.NoError(t, store.CreateProject(p)) + + v := &Vendor{Name: "Old Vendor Name"} + require.NoError(t, store.CreateVendor(v)) + + require.NoError(t, store.CreateQuote(&Quote{ + ProjectID: p.ID, + VendorID: v.ID, + TotalCents: 2000, + }, *v)) + + v.Name = "Aurora Plumbing" + require.NoError(t, store.UpdateVendor(*v)) + + results, err := store.SearchEntities("aurora") + require.NoError(t, err) + + var quoteFound bool + for _, r := range results { + if r.EntityType == DeletionEntityQuote { + quoteFound = true + break + } + } + assert.True( + t, + quoteFound, + "cascade should rebuild quote FTS with new vendor name; got %+v", + results, + ) +} + +func TestFTSTriggerCascadeOnMaintenanceRename(t *testing.T) { + t.Parallel() + store := newTestStore(t) + + cats, err := store.MaintenanceCategories() + require.NoError(t, err) + require.NotEmpty(t, cats) + + m := &MaintenanceItem{ + Name: "Old Name", + CategoryID: cats[0].ID, + IntervalMonths: 6, + } + require.NoError(t, store.CreateMaintenance(m)) + + sle := &ServiceLogEntry{ + MaintenanceItemID: m.ID, + ServicedAt: time.Now(), + } + require.NoError(t, store.CreateServiceLog(sle, Vendor{})) + + m.Name = "Quarterly Furnace Check" + require.NoError(t, store.UpdateMaintenance(*m)) + + results, err := store.SearchEntities("furnace") + require.NoError(t, err) + + var sleFound bool + for _, r := range results { + if r.EntityType == DeletionEntityServiceLog { + sleFound = true + break + } + } + assert.True( + t, + sleFound, + "cascade should rebuild SLE FTS with new maintenance item name; got %+v", + results, + ) +} + +func TestFTSTriggerCascadeOnProjectSoftDelete(t *testing.T) { + t.Parallel() + store := newTestStore(t) + + types, _ := store.ProjectTypes() + p := &Project{ + Title: "Attic Insulation", + ProjectTypeID: types[0].ID, + Status: ProjectStatusPlanned, + } + require.NoError(t, store.CreateProject(p)) + + v := &Vendor{Name: "Summit Insulators"} + require.NoError(t, store.CreateVendor(v)) + + require.NoError(t, store.CreateQuote(&Quote{ + ProjectID: p.ID, + VendorID: v.ID, + TotalCents: 3000, + }, *v)) + + // App-level DeleteProject refuses soft-delete when a project has live + // quotes. The trigger's cascade path is still reachable via sync and + // future app changes, so exercise it via raw DML that bypasses the + // validation — the goal is to prove the DB trigger behaves correctly + // when the scenario arises, not to test DeleteProject's gating. + require.NoError(t, store.db.Exec( + "UPDATE "+TableProjects+" SET "+ColDeletedAt+" = ? WHERE "+ColID+" = ?", + time.Now(), p.ID, + ).Error) + + // Searching by vendor name should still surface the quote (with a + // degraded entity_name now that the project title is gone). + results, err := store.SearchEntities("summit") + require.NoError(t, err) + + var quoteFound bool + for _, r := range results { + if r.EntityType == DeletionEntityQuote { + quoteFound = true + assert.NotContains(t, r.EntityName, "Attic Insulation", + "soft-deleted project title must not be in child entity_name") + } + } + assert.True(t, quoteFound, "quote should still surface via vendor name; got %+v", results) + + // And searching by the now-gone project title should NOT find the quote. + attic, err := store.SearchEntities("attic") + require.NoError(t, err) + assert.Empty(t, attic, "soft-deleted project title should not surface via any entity") +} + +func TestFTSTriggerHardDeleteMaintenanceCascadesSLE(t *testing.T) { + t.Parallel() + store := newTestStore(t) + + cats, err := store.MaintenanceCategories() + require.NoError(t, err) + require.NotEmpty(t, cats) + + m := &MaintenanceItem{ + Name: "Gutter Cleaning", + CategoryID: cats[0].ID, + IntervalMonths: 12, + } + require.NoError(t, store.CreateMaintenance(m)) + + sle := &ServiceLogEntry{ + MaintenanceItemID: m.ID, + ServicedAt: time.Now(), + Notes: "fall cleanup", + } + require.NoError(t, store.CreateServiceLog(sle, Vendor{})) + + require.NoError(t, store.HardDeleteMaintenance(m.ID)) + + gutterResults, err := store.SearchEntities("gutter") + require.NoError(t, err) + assert.Empty(t, gutterResults, "maintenance item FTS row should be gone after hard delete") + + fallResults, err := store.SearchEntities("fall") + require.NoError(t, err) + assert.Empty(t, fallResults, "child SLE FTS row should be gone via FK cascade + _ad trigger") +} + +func TestFTSPopulateFiltersSoftDeletedMaintenanceInSLEJoin(t *testing.T) { + t.Parallel() + store := newTestStore(t) + + cats, err := store.MaintenanceCategories() + require.NoError(t, err) + require.NotEmpty(t, cats) + + m := &MaintenanceItem{ + Name: "Rebuild Maintenance Name", + CategoryID: cats[0].ID, + IntervalMonths: 12, + } + require.NoError(t, store.CreateMaintenance(m)) + + sle := &ServiceLogEntry{ + MaintenanceItemID: m.ID, + ServicedAt: time.Now(), + Notes: "still-alive notes", + } + require.NoError(t, store.CreateServiceLog(sle, Vendor{})) + + // App-level DeleteMaintenance validation would reject this with a + // live SLE, so bypass via raw SQL to simulate the sync / future + // scenario where the parent arrives soft-deleted. + require.NoError(t, store.db.Exec( + "UPDATE "+TableMaintenanceItems+" SET "+ColDeletedAt+" = ? WHERE "+ColID+" = ?", + time.Now(), m.ID, + ).Error) + + // Force the initial-rebuild path. + require.NoError(t, store.setupEntitiesFTS()) + + results, err := store.SearchEntities("rebuild") + require.NoError(t, err) + for _, r := range results { + if r.EntityType == DeletionEntityServiceLog { + assert.NotContains(t, r.EntityName, "Rebuild Maintenance Name", + "initial rebuild must not carry soft-deleted maintenance name into SLE FTS") + } + } +} + +func TestFTSPopulateFiltersSoftDeletedParentsInQuoteJoin(t *testing.T) { + t.Parallel() + store := newTestStore(t) + + // Create project + vendor + quote, soft-delete the project via raw + // SQL (the app-level DeleteProject rejects parents with live quotes), + // then run the initial rebuild path. The quote's FTS row must not + // carry the deleted project's title. + types, _ := store.ProjectTypes() + p := &Project{ + Title: "Rebuild Project Title", + ProjectTypeID: types[0].ID, + Status: ProjectStatusPlanned, + } + require.NoError(t, store.CreateProject(p)) + v := &Vendor{Name: "Rebuild Vendor Name"} + require.NoError(t, store.CreateVendor(v)) + require.NoError(t, store.CreateQuote(&Quote{ + ProjectID: p.ID, + VendorID: v.ID, + TotalCents: 1000, + }, *v)) + + require.NoError(t, store.db.Exec( + "UPDATE "+TableProjects+" SET "+ColDeletedAt+" = ? WHERE "+ColID+" = ?", + time.Now(), p.ID, + ).Error) + + // Force the initial-rebuild path (mirrors what happens on app open). + require.NoError(t, store.setupEntitiesFTS()) + + rebuild, err := store.SearchEntities("rebuild") + require.NoError(t, err) + for _, r := range rebuild { + if r.EntityType == DeletionEntityQuote { + assert.NotContains(t, r.EntityName, "Rebuild Project Title", + "initial rebuild must not carry soft-deleted project title into quote FTS") + } + } +}