Skip to content
237 changes: 237 additions & 0 deletions backend/FwLite/LcmCrdt.Tests/FullTextSearch/EntrySearchServiceTests.cs
Comment thread
myieye marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,243 @@ public async Task SearchTableIsUpdatedAutomaticallyOnUpdate()
await Verify(entries);
}

[Fact]
public async Task RegeneratesSearchRecords_WhenMorphTypePrefixChanges()
{
// Suffix morph type has Prefix="-" by default, so a lexeme-only entry's headword is "-cat".
var id = Guid.NewGuid();
await fixture.Api.CreateEntry(new Entry()
{
Id = id,
LexemeForm = { ["en"] = "cat" },
MorphType = MorphTypeKind.Suffix
});
(await Headword(id)).Should().Be("-cat");

var suffix = await fixture.Api.GetMorphType(MorphTypeKind.Suffix);
suffix.Should().NotBeNull();
var updated = suffix!.Copy();
updated.Prefix = "~";
await fixture.Api.UpdateMorphType(suffix, updated);

// Changing the prefix token must regenerate the search record's headword.
(await Headword(id)).Should().Be("~cat");
}

[Fact]
public async Task RegeneratesSearchRecords_WhenMorphTypePostfixChanges()
{
// Prefix morph type has Postfix="-" by default, so a lexeme-only entry's headword is "un-".
var id = Guid.NewGuid();
await fixture.Api.CreateEntry(new Entry()
{
Id = id,
LexemeForm = { ["en"] = "un" },
MorphType = MorphTypeKind.Prefix
});
(await Headword(id)).Should().Be("un-");

var prefix = await fixture.Api.GetMorphType(MorphTypeKind.Prefix);
prefix.Should().NotBeNull();
var updated = prefix!.Copy();
updated.Postfix = "~";
await fixture.Api.UpdateMorphType(prefix, updated);

// Changing the postfix token must regenerate the search record's headword.
(await Headword(id)).Should().Be("un~");
}

[Fact]
public async Task RegeneratesSearchRecords_WhenMorphTypePrefixAndPostfixChange()
Comment thread
hahn-kev marked this conversation as resolved.
{
// Infix morph type has Prefix="-" and Postfix="-" by default, so the headword is "-in-".
var id = Guid.NewGuid();
await fixture.Api.CreateEntry(new Entry()
{
Id = id,
LexemeForm = { ["en"] = "in" },
MorphType = MorphTypeKind.Infix
});
(await Headword(id)).Should().Be("-in-");

var infix = await fixture.Api.GetMorphType(MorphTypeKind.Infix);
infix.Should().NotBeNull();
var updated = infix!.Copy();
updated.Prefix = "~";
updated.Postfix = "~";
await fixture.Api.UpdateMorphType(infix, updated);

// Changing both tokens in a single update must regenerate the search record's headword.
(await Headword(id)).Should().Be("~in~");
}

[Fact]
public async Task RegeneratesSearchRecords_WhenMorphTypePostfixIsAdded()
{
var id = Guid.NewGuid();
await fixture.Api.CreateEntry(new Entry()
{
Id = id,
LexemeForm = { ["en"] = "in" },
MorphType = MorphTypeKind.Suffix
});
(await Headword(id)).Should().Be("-in");

var suffix = await fixture.Api.GetMorphType(MorphTypeKind.Suffix);
suffix.Should().NotBeNull();
var updated = suffix!.Copy();
updated.Postfix = "~";
await fixture.Api.UpdateMorphType(suffix, updated);

// Adding a new token must regenerate the search record's headword.
(await Headword(id)).Should().Be("-in~");
Comment thread
hahn-kev marked this conversation as resolved.
}

[Fact]
public async Task RegeneratesSearchRecords_WhenMorphTypePostfixIsRemoved()
{
var id = Guid.NewGuid();
await fixture.Api.CreateEntry(new Entry()
{
Id = id,
LexemeForm = { ["en"] = "in" },
MorphType = MorphTypeKind.Infix
});
(await Headword(id)).Should().Be("-in-");

var infix = await fixture.Api.GetMorphType(MorphTypeKind.Infix);
infix.Should().NotBeNull();
var updated = infix!.Copy();
updated.Postfix = "";
await fixture.Api.UpdateMorphType(infix, updated);

// Deleting a token must regenerate the search record's headword.
(await Headword(id)).Should().Be("-in");
}

[Fact]
public async Task SearchTableIsUpdatedAutomaticallyOnMorphTypeChange()
{
var id = Guid.NewGuid();
await fixture.Api.CreateEntry(new Entry()
{
Id = id,
LexemeForm = { ["en"] = "in" },
MorphType = MorphTypeKind.Infix
});
(await Headword(id)).Should().Be("-in-");

var entry = await _context.FindAsync<Entry>(id);
entry.Should().NotBeNull();
var updated = entry.Copy();
updated.MorphType = MorphTypeKind.Simulfix; // Prefix and Postfix are "="
await fixture.Api.UpdateEntry(entry, updated);

// Changing morph type of an entry must regenerate its search record's headword.
(await Headword(id)).Should().Be("=in=");
}

[Fact]
public async Task SearchTableIsUpdatedAutomaticallyWhenManyChangesHappenOneAfterAnother()
{
var id = Guid.NewGuid();
await fixture.Api.CreateEntry(new Entry()
{
Id = id,
LexemeForm = { ["en"] = "in" },
MorphType = MorphTypeKind.Infix
});
(await Headword(id)).Should().Be("-in-");

var simulfix = await fixture.Api.GetMorphType(MorphTypeKind.Simulfix);
simulfix.Should().NotBeNull();
var updatedMorphType = simulfix!.Copy();
updatedMorphType.Prefix = "~";
updatedMorphType.Postfix = "~";
await fixture.Api.UpdateMorphType(simulfix, updatedMorphType);

var entry = await _context.FindAsync<Entry>(id);
entry.Should().NotBeNull();
var updated = entry.Copy();
updated.LexemeForm = new() { ["en"] = "out" };
updated.MorphType = MorphTypeKind.Simulfix;
await fixture.Api.UpdateEntry(entry, updated);

// Changing morph type of an entry must regenerate its search record's headword.
(await Headword(id)).Should().Be("~out~");
}

[Fact]
public async Task SearchTableIsUpdatedAutomaticallyWhenManyChangesHappenAtOnce_MorphTypeFirstThenEntry()
{
var id = Guid.NewGuid();
await fixture.Api.CreateEntry(new Entry()
{
Id = id,
LexemeForm = { ["en"] = "in" },
MorphType = MorphTypeKind.Infix
});
(await Headword(id)).Should().Be("-in-");

// Here we make changes directly in the DbContext, then call SaveChanges once at the end
var simulfix = await _context.Set<MorphType>().FirstOrDefaultAsync(mt => mt.Kind == MorphTypeKind.Simulfix);
simulfix.Should().NotBeNull();
simulfix.Prefix = "~";
simulfix.Postfix = "~";
_context.Update(simulfix);

var entry = await _context.FindAsync<Entry>(id);
entry.Should().NotBeNull();
entry.LexemeForm = new() { ["en"] = "out" };
entry.MorphType = MorphTypeKind.Simulfix;
_context.Update(entry);
await _context.SaveChangesAsync();

// Changing morph type of an entry must regenerate its search record's headword.
(await Headword(id)).Should().Be("~out~");
}

[Fact]
public async Task SearchTableIsUpdatedAutomaticallyWhenManyChangesHappenAtOnce_EntryFirstThenMorphType()
{
var id = Guid.NewGuid();
await fixture.Api.CreateEntry(new Entry()
{
Id = id,
LexemeForm = { ["en"] = "in" },
MorphType = MorphTypeKind.Infix
});
(await Headword(id)).Should().Be("-in-");

// Here we make changes directly in the DbContext, then call SaveChanges once at the end
var entry = await _context.FindAsync<Entry>(id);
entry.Should().NotBeNull();
entry.LexemeForm = new() { ["en"] = "out" };
entry.MorphType = MorphTypeKind.Simulfix;
_context.Update(entry);

var simulfix = await _context.Set<MorphType>().FirstOrDefaultAsync(mt => mt.Kind == MorphTypeKind.Simulfix);
simulfix.Should().NotBeNull();
simulfix.Prefix = "~";
simulfix.Postfix = "~";
_context.Update(simulfix);
await _context.SaveChangesAsync();

// Changing morph type of an entry must regenerate its search record's headword.
(await Headword(id)).Should().Be("~out~");
}

private async Task<string?> Headword(Guid entryId)
{
// .AsNoTracking() needed here because RegenerateEntrySearchTable() has just cleared
// and recreated the table using Linq2DB, but EF Core doesn't know that yet and so it
// will serve us the cached copy of the table. So .AsNoTracking() ensures that EF Core
// will hit the database and retrieve a fresh copy of the headword.
var record = await _service.EntrySearchRecords.AsNoTracking().SingleOrDefaultAsync(e => e.Id == entryId);
record.Should().NotBeNull();
return record!.Headword;
}

[Fact]
public async Task CanRegenerateTheSearchTable()
{
Expand Down
17 changes: 12 additions & 5 deletions backend/FwLite/LcmCrdt/FullTextSearch/EntrySearchService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -276,20 +276,27 @@ await entrySearchRecordsTable
.DeleteAsync();
}

public async Task RegenerateEntrySearchTable()
public Task RegenerateEntrySearchTable()
{
await using var transaction = await dbContext.Database.BeginTransactionAsync();
await EntrySearchRecordsTable.TruncateAsync();
return RegenerateEntrySearchTable(dbContext);
}

public static async Task RegenerateEntrySearchTable(LcmCrdtDbContext dbContext)
{
// SQLite doesn't allow nested transactions, so check if we're already in one before opening a new transaction
await using var transaction = dbContext.Database.CurrentTransaction is null ? await dbContext.Database.BeginTransactionAsync() : null;
var entrySearchRecordsTable = dbContext.GetTable<EntrySearchRecord>();
await entrySearchRecordsTable.TruncateAsync();

var writingSystems = await dbContext.WritingSystemsOrdered.ToArrayAsync();
var morphTypeLookup = await dbContext.MorphTypes.ToDictionaryAsync(m => m.Kind);
await EntrySearchRecordsTable
await entrySearchRecordsTable
.BulkCopyAsync(dbContext.Set<Entry>()
.LoadWith(e => e.Senses)
.AsQueryable()
.Select(entry => ToEntrySearchRecord(entry, writingSystems, morphTypeLookup))
.AsAsyncEnumerable());
await transaction.CommitAsync();
if (transaction is not null) await transaction.CommitAsync();
}

public async Task RegenerateIfMissing()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ namespace LcmCrdt.FullTextSearch;

public class UpdateEntrySearchTableInterceptor : ISaveChangesInterceptor
{
private bool EntryTableNeedsRegeneration { get; set; } = false;
public InterceptionResult<int> SavingChanges(DbContextEventData eventData, InterceptionResult<int> result)
{
throw new NotImplementedException(
Expand All @@ -25,14 +26,34 @@ public async ValueTask<InterceptionResult<int>> SavingChangesAsync(DbContextEven
return result;
}

private async Task UpdateSearchTableOnSave(DbContext? dbContext)
public async ValueTask<int> SavedChangesAsync(SaveChangesCompletedEventData eventData,
int result,
CancellationToken cancellationToken = new CancellationToken())
{
if (dbContext is null) return;
if (EntryTableNeedsRegeneration) await RegenerateSearchTableAfterSave(eventData.Context);
return result;
}

private async Task UpdateSearchTableOnSave(DbContext? maybeDbContext)
{
if (maybeDbContext is not LcmCrdtDbContext dbContext) return;
// Morph types with changes to prefix or postfix tokens will require updated entry search records
// (Note that morph types can't be added or deleted, so we only need to catch changes, which will be rare)
var changedMorphTypes = dbContext.ChangeTracker.Entries<MorphType>()
Comment thread
rmunn marked this conversation as resolved.
.Where(e => e.State == EntityState.Modified && (e.Property(m => m.Prefix).IsModified || e.Property(m => m.Postfix).IsModified))
.Select(e => e.Entity).ToList();
if (changedMorphTypes is not [])
{
// The actual table regeneration will happen in the SavedChangesAsync handler; here we just flag that it will be needed
EntryTableNeedsRegeneration = true;
// Any change to morph-type tokens will invalidate the whole entry search table, so no need to check for individual entries
return;
}
List<Entry> toUpdate = [];
List<Guid> toRemove = [];
var newWritingSystems = dbContext.ChangeTracker.Entries()
.Where(e => e.Entity is WritingSystem && e.State == EntityState.Added)
.Select(e => (WritingSystem)e.Entity).ToList();
var newWritingSystems = dbContext.ChangeTracker.Entries<WritingSystem>()
.Where(e => e.State == EntityState.Added)
.Select(e => e.Entity).ToList();
foreach (var group in dbContext.ChangeTracker.Entries()
.Where(e => e is { State: EntityState.Added or EntityState.Modified or EntityState.Deleted, Entity: Entry or Sense })
.GroupBy(e =>
Expand All @@ -51,7 +72,23 @@ private async Task UpdateSearchTableOnSave(DbContext? dbContext)
if (removed is not null) toRemove.Add(removed.Value);
}
if (toUpdate is [] && toRemove is []) return;
await EntrySearchService.UpdateEntrySearchTable(toUpdate, toRemove, newWritingSystems, (LcmCrdtDbContext)dbContext);
await EntrySearchService.UpdateEntrySearchTable(toUpdate, toRemove, newWritingSystems, dbContext);
}

private async Task RegenerateSearchTableAfterSave(DbContext? maybeDbContext)
{
EntryTableNeedsRegeneration = false;
if (maybeDbContext is not LcmCrdtDbContext dbContext) return;
await EntrySearchService.RegenerateEntrySearchTable(dbContext);
}

// If saving changes fails, then the MorphType changes didn't make it into the DB and headwords don't need to be regenerated after all
public void SaveChangesFailed(DbContextErrorEventData eventData) => EntryTableNeedsRegeneration = false;

public Task SaveChangesFailedAsync(DbContextErrorEventData eventData, CancellationToken cancellationToken = default)
{
EntryTableNeedsRegeneration = false;
return Task.CompletedTask;
}

private async Task<(Entry? updatedEntry, Guid? removed)> ForUpdate(IEnumerable<EntityEntry> group, Guid entryId, DbContext dbContext)
Expand Down
Loading