---
title: "Full-text search over EF Core entities"
url: https://recursiveai.org/oneindex/cookbook/search-ef-core-entities/
description: "Add full-text search to Entity Framework Core entities in C# with OneIndex: SaveChanges keeps a search index in step through a transactional outbox, and one query mixes index filters with database filters. SQLite example with real output."
version: "0.4.0-preview.2"
---

# Full-text search over EF Core entities

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.

```shell
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`:

```csharp
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](https://recursiveai.org/oneindex/docs/entity-framework/) · [Durability](https://recursiveai.org/oneindex/docs/durability/) · [All recipes](https://recursiveai.org/oneindex/cookbook/)
