Recursive AI

Full-text search over EF Core entities

All oneindex recipes

Put OneIndex attributes on an EF Core entity, add UseOneIndex(...) to the context options and UseOneIndexOutbox() to the model. Every SaveChanges then logs the change in the same transaction, the index is updated from the database, and db.Set.Search(text) returns tracked entities with a Where that runs partly in the index and partly in the database.

Terminal
dotnet new console -o EfSearch && cd EfSearch
dotnet add package RecursiveAI.OneIndex.EntityFrameworkCore --version 0.4.0-preview.2
dotnet add package Microsoft.EntityFrameworkCore.Sqlite --version 10.0.14

Put this in Program.cs and run dotnet run:

C#
using Microsoft.EntityFrameworkCore;
using OneIndex;
using OneIndex.EntityFrameworkCore;
 
var options = new DbContextOptionsBuilder<AppDb>()
    .UseSqlite("Data Source=app.db")
    .UseOneIndex("search-data", o => o.Index<Ticket>())
    .Options;
 
using var db = new AppDb(options);
db.Database.EnsureCreated();           // creates Tickets and the outbox tables
db.Tickets.AddRange(
    new Ticket { Id = "t1", Status = "open", CustomerId = 7,
                 Title = "Login page times out" },
    new Ticket { Id = "t2", Status = "open", CustomerId = 7,
                 Title = "Password reset email never arrives" },
    new Ticket { Id = "t3", Status = "closed", CustomerId = 9,
                 Title = "Login works after reset" });
await db.SaveChangesAndIndexAsync();   // rows + outbox in one transaction, then index
 
// Title and Status are filtered in the index; CustomerId only in the database.
var found = await db.Tickets.Search("login")
                    .Where(t => t.Status == "open" && t.CustomerId == 7)
                    .Take(10).ToListAsync();
foreach (var t in found) Console.WriteLine($"{t.Id}  {t.Title}");
 
var t2 = await db.Tickets.FindAsync("t2");
t2!.Title = "Login blocked: password reset email never arrives";
await db.SaveChangesAndIndexAsync();   // the change reaches the index too
 
Console.WriteLine();
foreach (var t in await db.Tickets.Search("login").Take(10).ToListAsync())
    Console.WriteLine($"{t.Id}  {t.Title}");
 
public class Ticket                    // an EF entity and a search document at once
{
    [SearchId] public string Id { get; set; } = "";
    [FullText] public string Title { get; set; } = "";
    [Keyword, Facet] public string Status { get; set; } = "";
    public int CustomerId { get; set; }  // not indexed: the database filters on it
}
 
public sealed class AppDb(DbContextOptions<AppDb> options) : DbContext(options)
{
    public DbSet<Ticket> Tickets => Set<Ticket>();
    protected override void OnModelCreating(ModelBuilder b) => b.UseOneIndexOutbox();
}
Text
t1  Login page times out
 
t1  Login page times out
t3  Login works after reset
t2  Login blocked: password reset email never arrives
  • The index is not an EF provider and not a second copy of your data: the database stays the source of truth. The outbox row says “this entity changed”; the applier reads the entity’s current row and upserts or deletes it in the index, so applying a row twice gives the same index.
  • SaveChangesAndIndexAsync() saves, then catches the index up, so the next search sees the change. A plain SaveChanges writes the outbox row too; in a web app run OneIndexRuntime.For(db).RunAsync(token) from a hosted service to apply it, and a search may lag the database by about 250 ms unless it asks for .AfterPendingChanges().
  • Where is split on &&: each part OneIndex can translate runs in the index, every other part runs in the database over the candidates the index returns. The page is filled unless fewer entities match. The entities returned are the database’s current rows.
  • The outbox tables are part of your model, so dotnet ef migrations add creates them like any other table.

Limits

  • A preview (0.4.0-preview.2): APIs and file formats may still change between previews.
  • A configuration that can never apply now fails loudly. From the 0.4.0-preview.2 changelog: “A configuration that can never apply (no ContextFactory and no public constructor taking the context’s DbContextOptions) now throws InvalidOperationException from SaveChanges (with ApplyInline) or RunAsync, before anything is written, instead of failing silently.”
  • Bulk writes are not indexed. ExecuteUpdate, ExecuteDelete and raw SQL bypass SaveChanges, so they log nothing. Repair with OneIndexRuntime.ReindexAsync(query) (re-reads those rows) or ReindexAsync<T>() (rebuilds the whole index, deletions included).
  • SQL Server and MySQL have not been run. The project’s kill tests covered SQLite and PostgreSQL 16; in its own words, “SQL Server and MySQL are not tested.”
  • A transaction that stays open longer than LateRowAge (default 30 seconds) or past LateRowWindow (10,000 later log positions) and then commits reaches the index only with that entity’s next change or ReindexAsync. Set LateRowAge above your longest transaction.
  • One process per index directory: two app instances on one database each need their own index directory.
  • Run it once as written: a second run adds the same keys again and EF Core refuses them. Delete app.db and search-data to start over.

EF Core integration · Durability · All recipes