Skip to content

Commit c5f3f84

Browse files
authored
feat(data): entities_fts triggers with cascading refresh (#961)
## Summary Installs AFTER INSERT / UPDATE / DELETE triggers on every source table that contributes to `entities_fts` (projects, vendors, appliances, maintenance_items, incidents, service_log_entries, quotes) so the index stays current without `RebuildFTSIndex` on every app open. - Parent tables whose text is embedded in a child's `entity_name` (project.title and vendor.name in quote, maintenance_item.name in service_log) get companion `_au_cascade` triggers that rebuild the child's FTS row when the parent is updated. - Cascade JOINs filter on `parent.deleted_at IS NULL` so a parent soft-delete degrades the child's `entity_name` (project title disappears from the quote; vendor name disappears; SLE name blanks out) instead of leaving stale text in the index. - The populate path carries the same filter so initial rebuilds match the trigger invariant. - Trigger installation is idempotent (DROP IF EXISTS + CREATE), so schema drift heals on the next `Store.Open`. FK constraints (RESTRICT on quote parents, CASCADE on SLE parents) keep the trigger semantics consistent with the rest of the domain. Stacked on top of #960 (FTS engine). Diff will shrink to just the trigger additions once that merges. Refs #707
1 parent 0507734 commit c5f3f84

2 files changed

Lines changed: 618 additions & 1 deletion

File tree

internal/data/fts.go

Lines changed: 247 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,247 @@ func (s *Store) setupEntitiesFTS() error {
208208
return fmt.Errorf("create entities FTS table: %w", err)
209209
}
210210

211-
return s.populateEntitiesFTS()
211+
if err := s.populateEntitiesFTS(); err != nil {
212+
return err
213+
}
214+
return s.installEntitiesFTSTriggers()
215+
}
216+
217+
// installEntitiesFTSTriggers installs the AFTER INSERT/UPDATE/DELETE triggers
218+
// that keep entities_fts in sync with source-table writes. Parent-table _au
219+
// triggers get companion cascade triggers that refresh child FTS rows whose
220+
// entity_name embeds parent fields (project/vendor title → quote.entity_name,
221+
// maintenance item name → service_log.entity_name).
222+
//
223+
// Triggers are DROPped then CREATEd, so upgrades and schema drift self-heal
224+
// on every app open. Soft-deleted parents are filtered out of cascade JOINs
225+
// so the child's entity_name degrades when the parent becomes invisible.
226+
func (s *Store) installEntitiesFTSTriggers() error {
227+
stmts := collectEntitiesFTSTriggerSQL()
228+
for _, stmt := range stmts {
229+
if err := s.db.Exec(stmt).Error; err != nil {
230+
return fmt.Errorf("install entities FTS trigger: %w\nSQL: %s", err, stmt)
231+
}
232+
}
233+
return nil
234+
}
235+
236+
// collectEntitiesFTSTriggerSQL returns all DROP + CREATE statements needed to
237+
// install the entities_fts triggers. Order: drop every known trigger first
238+
// (so re-installation is idempotent), then create.
239+
func collectEntitiesFTSTriggerSQL() []string {
240+
var stmts []string
241+
242+
// Own-row triggers for every source table.
243+
for _, spec := range ownRowSpecs() {
244+
stmts = append(stmts, ownRowTriggerSQL(spec)...)
245+
}
246+
247+
// Parent → child cascade triggers.
248+
stmts = append(stmts, parentCascadeQuoteSQL(TableProjects, ColProjectID)...)
249+
stmts = append(stmts, parentCascadeQuoteSQL(TableVendors, ColVendorID)...)
250+
stmts = append(stmts, maintenanceCascadeServiceLogSQL()...)
251+
252+
return stmts
253+
}
254+
255+
// ownRowSpec describes one source table's own-row trigger config.
256+
type ownRowSpec struct {
257+
table string
258+
entityType string
259+
nameExpr string // SQL expression for entity_name; uses NEW.<col>
260+
textExpr string // SQL expression for entity_text
261+
}
262+
263+
func ownRowSpecs() []ownRowSpec {
264+
col := func(c string) string { return "NEW." + c }
265+
coalesceNew := func(c string) string { return "COALESCE(NEW." + c + ", '')" }
266+
267+
return []ownRowSpec{
268+
{
269+
table: TableProjects,
270+
entityType: DeletionEntityProject,
271+
nameExpr: col(ColTitle),
272+
textExpr: col(ColTitle) + " || ' ' || " + coalesceNew(ColDescription) +
273+
" || ' ' || " + coalesceNew(ColStatus),
274+
},
275+
{
276+
table: TableVendors,
277+
entityType: DeletionEntityVendor,
278+
nameExpr: col(ColName),
279+
textExpr: col(ColName) + " || ' ' || " + coalesceNew(ColContactName) +
280+
" || ' ' || " + coalesceNew(ColNotes),
281+
},
282+
{
283+
table: TableAppliances,
284+
entityType: DeletionEntityAppliance,
285+
nameExpr: col(ColName),
286+
textExpr: col(ColName) + " || ' ' || " + coalesceNew(ColBrand) +
287+
" || ' ' || " + coalesceNew(ColModelNumber) +
288+
" || ' ' || " + coalesceNew(ColLocation) +
289+
" || ' ' || " + coalesceNew(ColNotes),
290+
},
291+
{
292+
table: TableMaintenanceItems,
293+
entityType: DeletionEntityMaintenance,
294+
nameExpr: col(ColName),
295+
textExpr: col(ColName) + " || ' ' || " + coalesceNew(ColNotes) +
296+
" || ' ' || " + coalesceNew(ColSeason),
297+
},
298+
{
299+
table: TableIncidents,
300+
entityType: DeletionEntityIncident,
301+
nameExpr: col(ColTitle),
302+
textExpr: col(ColTitle) + " || ' ' || " + coalesceNew(ColDescription) +
303+
" || ' ' || " + coalesceNew(ColLocation) +
304+
" || ' ' || " + coalesceNew(ColNotes) +
305+
" || ' ' || " + coalesceNew(ColSeverity),
306+
},
307+
{
308+
// Service log entries: name comes from the joined maintenance_item.
309+
// Soft-deleted maintenance items are filtered out so the SLE's
310+
// entity_name blanks instead of carrying stale text.
311+
table: TableServiceLogEntries,
312+
entityType: DeletionEntityServiceLog,
313+
nameExpr: "COALESCE((SELECT " + ColName +
314+
" FROM " + TableMaintenanceItems +
315+
" WHERE " + ColID + " = NEW." + ColMaintenanceItemID +
316+
" AND " + ColDeletedAt + " IS NULL), '')",
317+
textExpr: coalesceNew(ColNotes),
318+
},
319+
{
320+
// Quotes: entity_name is "<project_title> - <vendor_name>".
321+
// Both parents filtered for soft-delete.
322+
table: TableQuotes,
323+
entityType: DeletionEntityQuote,
324+
nameExpr: "COALESCE((SELECT " + ColTitle +
325+
" FROM " + TableProjects +
326+
" WHERE " + ColID + " = NEW." + ColProjectID +
327+
" AND " + ColDeletedAt + " IS NULL), '')" +
328+
" || ' - ' || " +
329+
"COALESCE((SELECT " + ColName +
330+
" FROM " + TableVendors +
331+
" WHERE " + ColID + " = NEW." + ColVendorID +
332+
" AND " + ColDeletedAt + " IS NULL), '')",
333+
textExpr: coalesceNew(ColNotes),
334+
},
335+
}
336+
}
337+
338+
// ownRowTriggerSQL returns DROP + CREATE statements for one source table's
339+
// AI / AU / AD triggers. The AU trigger deletes the old FTS row and
340+
// re-inserts it only when the row is still visible (not soft-deleted).
341+
func ownRowTriggerSQL(spec ownRowSpec) []string {
342+
r := strings.NewReplacer(
343+
"{TABLE}", spec.table,
344+
"{FTS}", tableEntitiesFTS,
345+
"{ENTITY}", spec.entityType,
346+
"{ID}", ColID,
347+
"{DEL}", ColDeletedAt,
348+
"{NAME_EXPR}", spec.nameExpr,
349+
"{TEXT_EXPR}", spec.textExpr,
350+
)
351+
return []string{
352+
r.Replace(`DROP TRIGGER IF EXISTS {TABLE}_fts_ai`),
353+
r.Replace(`DROP TRIGGER IF EXISTS {TABLE}_fts_au`),
354+
r.Replace(`DROP TRIGGER IF EXISTS {TABLE}_fts_ad`),
355+
r.Replace(`CREATE TRIGGER {TABLE}_fts_ai AFTER INSERT ON {TABLE}
356+
WHEN NEW.{DEL} IS NULL
357+
BEGIN
358+
INSERT INTO {FTS} (entity_type, entity_id, entity_name, entity_text)
359+
VALUES ('{ENTITY}', NEW.{ID}, {NAME_EXPR}, {TEXT_EXPR});
360+
END`),
361+
r.Replace(`CREATE TRIGGER {TABLE}_fts_au AFTER UPDATE ON {TABLE}
362+
BEGIN
363+
DELETE FROM {FTS} WHERE entity_type = '{ENTITY}' AND entity_id = OLD.{ID};
364+
INSERT INTO {FTS} (entity_type, entity_id, entity_name, entity_text)
365+
SELECT '{ENTITY}', NEW.{ID}, {NAME_EXPR}, {TEXT_EXPR}
366+
WHERE NEW.{DEL} IS NULL;
367+
END`),
368+
r.Replace(`CREATE TRIGGER {TABLE}_fts_ad AFTER DELETE ON {TABLE}
369+
BEGIN
370+
DELETE FROM {FTS} WHERE entity_type = '{ENTITY}' AND entity_id = OLD.{ID};
371+
END`),
372+
}
373+
}
374+
375+
// parentCascadeQuoteSQL returns DROP + CREATE statements for a cascade
376+
// trigger that refreshes quote FTS rows when their parent (project or
377+
// vendor) is updated. parentTable is projects or vendors; parentFK is the
378+
// FK column on quotes pointing to that parent.
379+
func parentCascadeQuoteSQL(parentTable, parentFK string) []string {
380+
triggerName := parentTable + "_fts_au_cascade_quotes"
381+
r := strings.NewReplacer(
382+
"{TRIGGER}", triggerName,
383+
"{PARENT_TABLE}", parentTable,
384+
"{PARENT_ID}", ColID,
385+
"{FTS}", tableEntitiesFTS,
386+
"{QUOTE}", DeletionEntityQuote,
387+
"{PARENT_FK}", parentFK,
388+
"{Q_TABLE}", TableQuotes,
389+
"{Q_ID}", ColID,
390+
"{P_TABLE}", TableProjects,
391+
"{P_FK}", ColProjectID,
392+
"{V_TABLE}", TableVendors,
393+
"{V_FK}", ColVendorID,
394+
"{P_NAME}", ColTitle,
395+
"{V_NAME}", ColName,
396+
"{Q_NOTES}", ColNotes,
397+
"{DEL}", ColDeletedAt,
398+
)
399+
return []string{
400+
r.Replace(`DROP TRIGGER IF EXISTS {TRIGGER}`),
401+
r.Replace(`CREATE TRIGGER {TRIGGER} AFTER UPDATE ON {PARENT_TABLE}
402+
BEGIN
403+
DELETE FROM {FTS}
404+
WHERE entity_type = '{QUOTE}'
405+
AND entity_id IN (SELECT {Q_ID} FROM {Q_TABLE} WHERE {PARENT_FK} = OLD.{PARENT_ID});
406+
INSERT INTO {FTS} (entity_type, entity_id, entity_name, entity_text)
407+
SELECT '{QUOTE}', q.{Q_ID},
408+
COALESCE(p.{P_NAME}, '') || ' - ' || COALESCE(v.{V_NAME}, ''),
409+
COALESCE(q.{Q_NOTES}, '')
410+
FROM {Q_TABLE} q
411+
LEFT JOIN {P_TABLE} p ON q.{P_FK} = p.{PARENT_ID} AND p.{DEL} IS NULL
412+
LEFT JOIN {V_TABLE} v ON q.{V_FK} = v.{PARENT_ID} AND v.{DEL} IS NULL
413+
WHERE q.{PARENT_FK} = NEW.{PARENT_ID} AND q.{DEL} IS NULL;
414+
END`),
415+
}
416+
}
417+
418+
// maintenanceCascadeServiceLogSQL installs the maintenance_items → SLE
419+
// cascade trigger. When a maintenance item is updated (including soft-delete
420+
// via deleted_at), every SLE referencing it has its FTS row rebuilt.
421+
func maintenanceCascadeServiceLogSQL() []string {
422+
triggerName := TableMaintenanceItems + "_fts_au_cascade_service_log"
423+
r := strings.NewReplacer(
424+
"{TRIGGER}", triggerName,
425+
"{M_TABLE}", TableMaintenanceItems,
426+
"{M_ID}", ColID,
427+
"{M_NAME}", ColName,
428+
"{FTS}", tableEntitiesFTS,
429+
"{SLE}", DeletionEntityServiceLog,
430+
"{S_TABLE}", TableServiceLogEntries,
431+
"{S_ID}", ColID,
432+
"{S_FK}", ColMaintenanceItemID,
433+
"{S_NOTES}", ColNotes,
434+
"{DEL}", ColDeletedAt,
435+
)
436+
return []string{
437+
r.Replace(`DROP TRIGGER IF EXISTS {TRIGGER}`),
438+
r.Replace(`CREATE TRIGGER {TRIGGER} AFTER UPDATE ON {M_TABLE}
439+
BEGIN
440+
DELETE FROM {FTS}
441+
WHERE entity_type = '{SLE}'
442+
AND entity_id IN (SELECT {S_ID} FROM {S_TABLE} WHERE {S_FK} = OLD.{M_ID});
443+
INSERT INTO {FTS} (entity_type, entity_id, entity_name, entity_text)
444+
SELECT '{SLE}', s.{S_ID},
445+
COALESCE(m.{M_NAME}, ''),
446+
COALESCE(s.{S_NOTES}, '')
447+
FROM {S_TABLE} s
448+
LEFT JOIN {M_TABLE} m ON s.{S_FK} = m.{M_ID} AND m.{DEL} IS NULL
449+
WHERE s.{S_FK} = NEW.{M_ID} AND s.{DEL} IS NULL;
450+
END`),
451+
}
212452
}
213453

214454
// populateEntitiesFTS inserts rows from each entity source table into the
@@ -274,6 +514,9 @@ func (s *Store) populateEntitiesFTS() error {
274514
TableIncidents, ColDeletedAt),
275515
},
276516
{
517+
// Soft-deleted maintenance_items are filtered from the JOIN so
518+
// the SLE's entity_name blanks out, matching the cascade
519+
// trigger's behavior when a parent becomes invisible.
277520
"service_log_entries",
278521
fmt.Sprintf(`INSERT INTO %s (entity_type, entity_id, entity_name, entity_text)
279522
SELECT '%s', s.%s, COALESCE(m.%s, ''), COALESCE(s.%s, '')
@@ -287,6 +530,9 @@ func (s *Store) populateEntitiesFTS() error {
287530
ColDeletedAt),
288531
},
289532
{
533+
// Soft-deleted parents are filtered from the JOINs so the
534+
// quote's entity_name degrades instead of carrying stale
535+
// parent names -- same invariant the cascade triggers enforce.
290536
"quotes",
291537
fmt.Sprintf(`INSERT INTO %s (entity_type, entity_id, entity_name, entity_text)
292538
SELECT '%s', q.%s,

0 commit comments

Comments
 (0)