Skip to content

Commit 6b0ae3a

Browse files
committed
feat(data): install entities_fts triggers with cascading refresh
setupEntitiesFTS now installs AFTER INSERT / UPDATE / DELETE triggers on every source table that contributes rows to entities_fts (projects, vendors, appliances, maintenance_items, incidents, service_log_entries, quotes). Parent tables whose text is embedded in a child's entity_name (project.title and vendor.name in quote, maintenance_item.name in SLE) 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 on app open match the trigger invariant. Trigger installation is idempotent (DROP IF EXISTS + CREATE), so schema drift across app versions heals on the next Store.Open. FK constraints (RESTRICT on quote parents, CASCADE on SLE parents) continue to govern hard-delete feasibility; parent _ad triggers are plain single-table cleanups, no cascade blocks needed. Tests cover: insert, rename, soft-delete, parent-rename cascade for all three relationships, parent-soft-delete cascade via raw DML (the app gates soft-delete with live children, so the cascade path is exercised by sync in production; raw DML matches that scenario in tests), FK cascade on maintenance_item hard-delete, and initial rebuild preserving the soft-delete filter for both SLE and quote joins. Refs #707.
1 parent 8aac14a commit 6b0ae3a

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)