Skip to content

Commit 38de837

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 e10a1e6 commit 38de837

2 files changed

Lines changed: 625 additions & 7 deletions

File tree

internal/data/fts.go

Lines changed: 253 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,247 @@ func (s *Store) setupEntitiesFTS() error {
205205
return fmt.Errorf("create entities FTS table: %w", err)
206206
}
207207

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

211451
// populateEntitiesFTS inserts rows from each entity source table into the
@@ -271,35 +511,41 @@ func (s *Store) populateEntitiesFTS() error {
271511
TableIncidents, ColDeletedAt),
272512
},
273513
{
514+
// Soft-deleted maintenance_items are filtered from the JOIN so
515+
// the SLE's entity_name blanks out, matching the cascade
516+
// trigger's behavior when a parent becomes invisible.
274517
"service_log_entries",
275518
fmt.Sprintf(`INSERT INTO %s (entity_type, entity_id, entity_name, entity_text)
276519
SELECT '%s', s.%s, COALESCE(m.%s, ''), COALESCE(s.%s, '')
277520
FROM %s s
278-
LEFT JOIN %s m ON s.%s = m.%s
521+
LEFT JOIN %s m ON s.%s = m.%s AND m.%s IS NULL
279522
WHERE s.%s IS NULL`,
280523
tableEntitiesFTS,
281524
DeletionEntityServiceLog, ColID, ColName, ColNotes,
282525
TableServiceLogEntries,
283-
TableMaintenanceItems, ColMaintenanceItemID, ColID,
526+
TableMaintenanceItems, ColMaintenanceItemID, ColID, ColDeletedAt,
284527
ColDeletedAt),
285528
},
286529
{
530+
// Soft-deleted parents are filtered from the JOINs so the
531+
// quote's entity_name degrades instead of carrying stale
532+
// parent names -- same invariant the cascade triggers enforce.
287533
"quotes",
288534
fmt.Sprintf(`INSERT INTO %s (entity_type, entity_id, entity_name, entity_text)
289535
SELECT '%s', q.%s,
290536
COALESCE(p.%s, '') || ' - ' || COALESCE(v.%s, ''),
291537
COALESCE(q.%s, '')
292538
FROM %s q
293-
LEFT JOIN %s p ON q.%s = p.%s
294-
LEFT JOIN %s v ON q.%s = v.%s
539+
LEFT JOIN %s p ON q.%s = p.%s AND p.%s IS NULL
540+
LEFT JOIN %s v ON q.%s = v.%s AND v.%s IS NULL
295541
WHERE q.%s IS NULL`,
296542
tableEntitiesFTS,
297543
DeletionEntityQuote, ColID,
298544
ColTitle, ColName,
299545
ColNotes,
300546
TableQuotes,
301-
TableProjects, ColProjectID, ColID,
302-
TableVendors, ColVendorID, ColID,
547+
TableProjects, ColProjectID, ColID, ColDeletedAt,
548+
TableVendors, ColVendorID, ColID, ColDeletedAt,
303549
ColDeletedAt),
304550
},
305551
}

0 commit comments

Comments
 (0)