Full-Text Search
Bsft.Data deliberately does not build SQL strings. Its contains filter compiles to
LOWER(COALESCE(col,'')) LIKE '%…%', which is correct but non-sargable: every search is a table
scan. That is the defect the typed filter engine was built to prevent.
The business need for a contains-style search does not go away, though. Searching master data or
candidate profiles by fragments of names and free text is a normal requirement, and without a
supported way to do it, application developers reach for exactly the leading-wildcard LIKE the
framework is trying to avoid. This is that supported way.
Declaring what is searchable
Search is declared in the same place as filters and sorting, so a module keeps one description of its query surface:
public class CandidateQueryConfig : QueryConfigurationBase<Candidate, CandidateDto>
{
public override void Configure(QueryConfigurationBuilder<Candidate, CandidateDto> builder)
{
builder.Field(d => d.Name).AllowFilter(FilterOperator.Eq).AllowSort();
builder.Field(d => d.Id).AllowSort();
builder.DefaultSort(d => d.Id, SortDirection.Asc);
builder.Searchable("profile", s => s
.Field(e => e.Name, weight: 2.0)
.Field(e => e.Notes)
.Language("de")
.Mode(SearchMode.FreeText));
}
}
A search field names an entity column, not a DTO property. A full-text index exists on a column, and naming the column here keeps the declaration honest about what has to be indexed.
Searching
var request = new PagedRequest(Filter: activeOnly, Page: 1, PageSize: 25)
{
Search = new SearchRequest("profile", "müller"),
};
PagedResult<RankedItem<Candidate>> page = await _search.SearchAsync(_db.Candidates, request, ct);
Search is an init-only property rather than a positional parameter, so an existing
PagedRequest(...) call and any deconstruction of it keep working unchanged.
Search, filter, sort and paging compose. The search narrows the set, the filter narrows it further,
paging applies to the combination. Results carry a relevance Score; set IsOrderedByRank: false on
the SearchRequest to use the request's sort criteria instead.
Rank ordering is broken by a deterministic tie-breaker — the configured DefaultSort field, or the
first searchable field when none is configured — so a hit does not jump between pages. Configure a
unique default sort for a strictly stable order.
With IsOrderedByRank: false and no sort on the request, the configured DefaultSort orders the
result, exactly as it does for a plain query. Paging an unordered result is decided by the query
plan, so the same row can come back on two pages and another on none.
Scoping is not optional
Results are filtered by tenant and organisational unit before the search provider sees the queryable, and this happens in the pipeline, not in the caller:
public sealed class OrganizationSearchScopeProvider : ISearchScopeProvider
{
public async Task<SearchScopeState> GetScopeAsync(CancellationToken ct = default)
{
var units = await _access.GetUnitsWithPermissionAsync(_user.Id, "candidates.read", ct);
return new SearchScopeState(_tenant.TenantId, units);
}
}
An entity participates by implementing ITenantEntity and/or IOrganizationScopedEntity.
Register the provider scoped. ISearchExecutor<TEntity, TDto> is registered scoped for the same
reason: the provider derives the boundary from request state — the ambient ITenantContext, the
signed-in principal — so it has to run in the caller's request scope. Resolved anywhere else it sees
a blank request whose tenant was never set, and every scoped search fails.
Scoping fails closed. A group whose entity carries neither marker, or a scoped group with no
registered ISearchScopeProvider, fails startup validation and refuses to execute at runtime. A
forgotten DI registration must not read as "search everything". Data that genuinely has no boundary
says so:
builder.Searchable("public-catalogue", s => s.Field(e => e.Title).Unscoped());
Unscoped() is accepted only on an entity that carries neither marker. It declares that the data
has no boundary; it is not a switch that removes one. A group declared Unscoped() on an entity
implementing ITenantEntity or IOrganizationScopedEntity fails startup validation, because the two readings
of that declaration are both bad: honoured, one builder call turns off tenant isolation for the
group — and the framework's own error messages used to nudge people towards adding it; ignored, the
group returns results silently narrower than it promises. The boundary a scoped entity carries is
never negotiable from the search declaration. Data that genuinely has no boundary lives on an entity
that has none.
The runtime checks the answer too, not just the registration. A scope provider that returns a
SearchScopeState leaving TenantId or AllowedOrganizationUnitIds unset for an entity that carries the matching
marker fails the request. Startup validation cannot catch that one — the scope is resolved per
call — and a provider that forgets a dimension looks exactly like one that deliberately allows
everything.
Bsft.Data does not reference Bsft.Organization — the seam keeps the dependency direction intact,
the same way IAuditCorrelationAccessor does for the audit package.
No index, no start
Startup validation fails when a declared group has no index behind it, when no ISearchProvider is
registered, when more than one is registered (the executor and the validator would otherwise disagree
about which one is in charge), when a scoped group has nothing to scope it, when an Unscoped()
group sits on an entity that carries a scoping marker, or when the group's
stemming language is one the provider cannot map. There is no fallback path: without a provider there
is no way to execute a search at all. Those failure modes are unreachable by accident, which is the
entire point.
A group name has to be unique across the application. A provider declares its indexes by name alone,
so the same name on two entities would let one HasFullTextIndex("profile") certify a table that has
no index at all — validation passing and the first real search failing in production.
Choosing a provider
ISearchProvider is the pluggable seam, and the declaration above is engine-agnostic: the same
Searchable(...) block is served by any of the three packages. What differs is what the engine can
actually do, and that is worth reading before picking one.
Bsft.Data.SqlServer |
Bsft.Data.Sqlite |
Bsft.Data.Postgres |
|
|---|---|---|---|
| Registration | AddBsftDataSqlServer |
AddBsftDataSqlite |
AddBsftDataPostgres |
| Index | full-text catalogue + index | FTS5 external-content table | generated tsvector + GIN |
| Kept in step by | the engine, asynchronously | migration-created triggers | the engine, synchronously |
| Prefix mode | CONTAINS "müller*" |
match(), "müller"* |
to_tsquery, müller:* |
| Free text | FREETEXT |
match(), words ORed |
websearch_to_tsquery |
| Ranking | weighted field match | real bm25, field weights |
real ts_rank, field weights |
| German stemming | yes (LCID 1031) | no — see below | yes (german) |
| Field weights | any number of fields | any number of fields | at most four (labels A–D) |
| Migration transaction | suppressed (engine refuses) | kept | kept |
| Verified against a live engine | no | yes, end to end | no |
Register exactly one. Two registered providers fail startup validation rather than letting the executor and the validator disagree about which one is in charge.
Each provider package has one primary options type — BsftDataSqlServerOptions,
BsftDataSqliteOptions, BsftDataPostgresOptions — and none of the three carries a SectionName or
binds from configuration, which is deliberate and the one place these packages depart from the
options rule in package-conventions.md. Every member of them describes the
schema: which column or table the migration created, which text search configuration was baked into a
generated column, which tokenizer an FTS5 table was built with. A deployment able to state those
separately would be able to state them differently from the index it actually has, and the result is
the quiet degradation this whole feature exists to remove — a search answering a narrower question
than it was asked, with nothing in the results saying so. The declaration therefore lives beside the
migration that makes it true. Registration still goes through the options pattern
(AddOptions<T>().Configure(...)), and each provider reads its declaration through IOptions<T>.
SQL Server provider
services.AddBsftDataSqlServer(o => o.HasFullTextIndex("profile"));
Bsft.Data.SqlServer translates a search into CONTAINS (prefix mode) or FREETEXT (natural
language), composed onto the caller's IQueryable. Composition is deliberate: EF Core global query
filters — the mechanism enforcing tenant isolation — apply to a composed query and would be bypassed
by a statement built beside the entity set.
Prefix mode anchors on the right ("müller*") and never on the left.
The search term reaches SQL as a parameter, not as an inlined literal. That matters for a search-as-you-type endpoint: a literal would give every keystroke its own statement text and its own query plan.
Language is passed through as a locale identifier, so Language("de") reaches the query as 1031
and German stemming applies. Supported codes are de, en, es, fr, it and nl. A code outside
that set fails startup validation rather than quietly issuing the query with no LANGUAGE clause,
which would stem German text with whatever the index defaults to and leave no trace in the results.
Ranking: what you get today
The score is a weighted field-match score: every field that matches contributes its declared weight. It is translatable, orderable and stable across pages.
It is not SQL Server's own CONTAINSTABLE rank. EF.Functions exposes no CONTAINSTABLE, and
reaching it would mean hand-written SQL joined beside the entity set — the shape that silently drops
global query filters. The weighted-match score was chosen over that risk. If true BM25-style ranking
is needed, that is a separate piece of work with its own safety story.
Creating the index
Index creation belongs in the migration flow rather than in a manual step that gets forgotten in one environment:
migrationBuilder
.CreateFullTextCatalog("BsftSearch")
.CreateFullTextIndex(new CreateFullTextIndexRequest(
Table: "Candidates",
KeyIndexName: "PK_Candidates",
CatalogName: "BsftSearch",
Columns: ["Name", "Notes"],
Language: "de"));
The framework still never runs these itself: they are emitted into the application's own migration,
which owns its schema exactly as it does for every other table. DropFullTextIndex is the Down
counterpart.
Identifiers are validated against a strict character set rather than escaped — a name that would need escaping is a name that should be rejected.
These statements run outside the migration transaction
SQL Server refuses full-text DDL inside a user transaction (Msg 574), and EF Core wraps a migration
in exactly such a transaction. All four helpers therefore emit their statement with
suppressTransaction: true. Without it dotnet ef database update aborts on the first
CREATE FULLTEXT CATALOG and rolls the whole migration back, which makes the feature undeployable.
The consequence is worth knowing before a deployment goes wrong: a migration that fails after one
of these statements is only partly undone. EF rolls back the transactional operations and still
records the migration as unapplied, while the catalogue, the index or the changed stoplist stay in
the database. Creating the catalogue tolerates that, because it is guarded by an existence check.
Creating the index does not — a re-run reports that the index already exists, so drop the leftover
(DROP FULLTEXT INDEX ON [Candidates]) before applying the migration again. Keeping the full-text
statements in a migration of their own is the simplest way to avoid the situation entirely.
Stopwords are language-specific, so a German index usually wants its own list rather than the server default. Attach one in the same flow:
migrationBuilder.SetFullTextStopList("Candidates", "GermanStopwords");
SYSTEM selects the server's own list and OFF indexes every word; both are keywords rather than
object names and are emitted unbracketed. Creating and filling the stoplist itself
(CREATE FULLTEXT STOPLIST / sp_fulltext_stopword) stays with the application, since its contents
are a business decision.
Index population lag
A SQL Server full-text index is populated asynchronously. A row written now is not necessarily findable a millisecond later. Do not build a read-after-write flow on search; use the primary key.
SQLite provider
services.AddBsftDataSqlite(o => o
.HasFullTextIndex("profile", "Candidates_fts")
.AcceptsUnstemmedLanguage("de"));
Bsft.Data.Sqlite searches an FTS5 index. Two things differ from SQL Server before anything else:
the index is a table, so the group names it, and FTS5 is not known to EF Core, so the index has
to be mapped once in the model:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasBsftFullTextIndex("Candidates_fts", "Name", "Notes");
}
That call maps the index table and registers the two FTS5 functions the provider composes with. It
creates nothing — the migration does that — and it is excluded from migrations so scaffolding never
tries to. The columns are named in the same order as the group's fields, because bm25 weights its
columns positionally: a group whose fields are declared in a different order is refused rather than
ranked by the wrong weights, which no result would reveal.
The search becomes a join: the index is matched first and its rows are joined back onto the caller's
queryable by rowid. The caller's queryable stays the outer side, so global query filters still
apply. The provider holds no DbContext — it reads the model off the query root of the queryable it
is handed, which is what keeps it a singleton like the other two.
Ranking is FTS5's own bm25, weighted by the declared field weights and negated so that a higher
score is a better match. This is a real ranking function rather than the weighted field-match score
the SQL Server provider has to make do with.
German is not stemmed, and the provider says so
FTS5 ships exactly one stemmer, Porter, and Porter is English. There is no German dictionary to
select. So a group declaring Language("de") is matched by exact tokens: schweisser does not find
schweisserin in free text. That is a usable search and often the right trade, but it is not what
the declaration promises, so it has to be said out loud:
o.AcceptsUnstemmedLanguage("de");
Without that call, startup validation fails. Prefix mode is what covers inflection in practice
(schweisser finds schweisserin), and diacritics are folded in both directions, so muller and
müller find each other.
The tokenizer is configurable with UseTokenizer(SqliteTokenizer.Porter) for English stemming, or
Ascii where folding is unwanted. The default, unicode61 remove_diacritics 2, folds diacritics
beyond the Latin-1 range that FTS5's own default stops at.
The options are the only place the tokenizer is named. It is one decision for the application rather
than one per index, because it is also what the startup check above answers from: a per-index
tokenizer would let an index be created with one tokenizer while validation kept certifying another —
including certifying that English is stemmed for an index built without a stemmer. So the migration
command names no tokenizer, and CreateFullTextIndex reads it from the options it is handed.
Diacritic folding
How far accents are folded is its own setting, because it is the one part of the tokenizer specification a deployment sometimes has to change without changing the tokenizer:
RemoveDiacritics |
Effect |
|---|---|
2 (default) |
Folds every diacritic Unicode defines one for. muller finds müller. |
1 |
FTS5's own default: folds the Latin-1 range only, leaving the rest unfolded. |
0 |
Folds nothing. muller and müller are different terms. |
services.AddBsftDataSqlite(o => o
.HasFullTextIndex("profile", "Candidates_fts")
.UseRemoveDiacritics(0));
Change it only where accents genuinely carry meaning that must not be collapsed — a lexicon, a
transliteration table — because a user searching from a plain keyboard is the case the default
serves. Any value outside 0, 1 and 2 is refused while the application is wired up rather than
by CREATE VIRTUAL TABLE halfway through a deployment.
The level is part of the index, not of the query: terms already stored were folded under the old
one, so changing it means recreating the index and rebuilding it. The ascii tokenizer ignores the
setting entirely — it treats everything above U+007F as a token character, so it has nothing to fold.
Creating the index
var index = new CreateFullTextIndexRequest(
IndexTable: "Candidates_fts",
ContentTable: "Candidates",
KeyColumn: "Id",
Columns: ["Name", "Notes"]);
migrationBuilder
.CreateFullTextIndex(index, SearchSetup.Options)
.CreateFullTextSyncTriggers(index)
.RebuildFullTextIndex("Candidates_fts");
The options are handed in because a migration runs outside the container. The index is built with the tokenizer specification they describe and the provider answers for the same ones at startup, so build them once and register that instance rather than a second one configured alike:
public static class SearchSetup
{
public static BsftDataSqliteOptions Options { get; } = new BsftDataSqliteOptions()
.HasFullTextIndex("profile", "Candidates_fts")
.UseRemoveDiacritics(0);
}
services.AddBsftDataSqlite(SearchSetup.Options);
AddBsftDataSqlite also takes the usual Action<BsftDataSqliteOptions> callback; the overload above
exists so the migration can reach the same declaration. The container receives a copy of it, so a
mutation of SearchSetup.Options after the registration cannot make the provider and the migration
disagree; the provider reads it through IOptions<BsftDataSqliteOptions>. Left out of the migration call entirely, the
framework's default of 2 applies, which is what a deployment that configures none already had. Only
CreateFullTextIndex takes the options; the triggers and the rebuild carry no tokenizer
specification.
An external-content index stores the terms but not the text, so the base table stays the single copy
of the data — and the engine does not maintain it. The three triggers are what keep it in step, and
without them the index answers with whatever was true when it was last rebuilt. RebuildFullTextIndex
fills it from rows that already exist; the triggers only carry changes made from then on, so a
migration against a populated table needs both. DropFullTextIndex is the Down counterpart and
drops the triggers first, since a trigger writing into a table that no longer exists fails every
insert into the content table.
The base table's primary key has to be a single int or long: an external-content index addresses
its rows by rowid. Anything else is refused with that reason.
These statements run inside the migration transaction
The opposite of the SQL Server decision, and deliberately so. SQLite's DDL is transactional, virtual tables and triggers included, so these statements roll back with the rest of a failing migration instead of leaving a half-created index behind. Every statement is also written to be re-runnable, so a migration re-applied after an unrelated failure does not trip over its own leftovers.
Index population is not lagging
Unlike SQL Server, a trigger-maintained FTS5 index is updated inside the same transaction as the write. A row written now is findable now.
PostgreSQL provider
services.AddBsftDataPostgres(o => o.HasFullTextIndex("profile"));
Bsft.Data.Postgres matches a stored tsvector column with @@, which is what the GIN index is
built on. The column is mapped in the application's model, usually as a shadow property so the entity
does not have to carry a database concept as a member:
modelBuilder.Entity<Candidate>().Property<NpgsqlTsVector>("SearchVector");
modelBuilder.Entity<Candidate>().HasIndex("SearchVector").HasMethod("GIN");
Another property name is fine — HasFullTextIndex("profile", "ProfileVector"). A group whose column
is unmapped, or mapped as text rather than NpgsqlTsVector, is refused with the mapping that fixes
it; matching text with @@ is not a slower search but a different one that does not use the index.
Prefix mode anchors every word (müller:* & schmidt:*) and free text goes through
websearch_to_tsquery. The two are sanitised differently because the two functions are:
to_tsquery takes an operator expression and fails on anything it cannot parse, so prefix mode is
rebuilt from the term's own words; websearch_to_tsquery is defined to take whatever a user typed,
quoted phrases and all, and is given the term as it stands.
Language reaches the query as a text search configuration, so Language("de") becomes german and
German is stemmed by a real Snowball stemmer. The built-in codes are de, en, es, fr, it and
nl. A code outside that set fails startup validation rather than quietly running on the server's
default_text_search_config.
The deployment's own text search configurations
The built-in map is a floor, not a fence. A deployment that has built its own regconfig — an
unaccent-composed one, a dictionary with domain stopwords — registers it against the code the
groups already declare:
services.AddBsftDataPostgres(o => o
.HasFullTextIndex("profile")
.UseTextSearchConfiguration("de", "german_unaccent") // overrides the built-in german
.UseTextSearchConfiguration("pt", "portuguese")); // adds a code the framework never knew
A registration wins over the built-in mapping for the same code. That is what makes it an
override rather than only an extension: a deployment that has built german_unaccent wants every
group declaring de to use it, not merely the codes the framework never shipped. Register it before
the migration that builds the index and before startup validation runs — both read the same options
instance — so the query names the same configuration the index was built with. Naming a different
one is not a slower search but a different one, probing the GIN index with lexemes another stemmer
produced.
The configuration name is interpolated into the generated column as a literal, so it is whitelisted
to letters, digits and underscores exactly as every other identifier this package emits. A
schema-qualified name is therefore rejected; put the configuration on the search_path.
A code that maps to nothing still resolves to null, and that fallback stays: leaving a query on the
server's default_text_search_config is better than guessing wrong and stemming German with English
rules. It is silent only at the point of resolution. A group declaring such a code fails startup
validation, and AddFullTextVector refuses to emit DDL for one, so a deployment reaches the server
default by choosing to rather than by forgetting to.
Ranking and the four weight labels
Ranking is ts_rank with the group's declared field weights. PostgreSQL weights lexemes by the four
labels A to D, which the migration puts on the indexed columns in the order the group declares
its fields, and ts_rank reads them back as the vector {D, C, B, A}. The consequence is a real
limit rather than a convention: a group can weight at most four fields, and one declaring more is
reported at startup instead of silently ranking the fifth as the least important.
Creating the column and the index
var index = new CreateFullTextIndexRequest(
Table: "Candidates",
Columns: ["Name", "Notes"],
Language: "de");
migrationBuilder.AddFullTextVector(index).CreateFullTextIndex(index);
Stating the stemming language once
The regconfig is baked into the index, so every migration decides it — and the one migration that forgets gets English stemming on German text with nothing in the results to say so. A German-first deployment states it once instead:
public static class SearchSetup
{
public static BsftDataPostgresOptions Options { get; } = new BsftDataPostgresOptions()
.HasFullTextIndex("profile")
.UseDefaultLanguage("de")
.UseTextSearchConfiguration("de", "german_unaccent");
}
services.AddBsftDataPostgres(SearchSetup.Options);
var index = new CreateFullTextIndexRequest(Table: "Candidates", Columns: ["Name", "Notes"]);
migrationBuilder.AddFullTextVector(index, SearchSetup.Options).CreateFullTextIndex(index);
Language on the command is the per-index override and keeps working: named, it wins over the
default; left out, the options decide. DefaultLanguage is en until something sets it, so a
deployment that configures none emits exactly what it did before.
The options are handed in because a migration runs outside the container. Build them once and
register that instance — AddBsftDataPostgres takes either the usual
Action<BsftDataPostgresOptions> callback or a prepared instance — so the index is built with the
configuration the provider will name at query time, registrations included. Two instances configured
alike work until one of them changes. The container receives a copy of the declaration and the
provider reads it through IOptions<BsftDataPostgresOptions>, so a mutation after the registration
cannot move the two apart either. Only AddFullTextVector takes the options;
CreateFullTextIndex puts no regconfig in its DDL.
An unmappable language — the command's or the default's — throws while the DDL is being built, before anything reaches the server.
which emits
ALTER TABLE "Candidates" ADD COLUMN IF NOT EXISTS "SearchVector" tsvector GENERATED ALWAYS AS (
setweight(to_tsvector('german', coalesce("Name", '')), 'A') ||
setweight(to_tsvector('german', coalesce("Notes", '')), 'B')) STORED;
CREATE INDEX IF NOT EXISTS "IX_Candidates_SearchVector" ON "Candidates" USING GIN ("SearchVector");
Generated and stored rather than trigger-maintained: PostgreSQL keeps it in step itself, so the index
cannot drift and nothing has to be rebuilt after a bulk import. The text search configuration is a
literal because it has to be — to_tsvector(regconfig, text) is only immutable in that form, and a
generated column accepts nothing else. Every column is wrapped in coalesce, since concatenating a
null tsvector yields null and one empty column would erase the whole document.
GIN rather than GiST: slower to build, faster to search, which is the trade a search endpoint wants.
These statements run inside the migration transaction
PostgreSQL's DDL is transactional, ALTER TABLE and a plain CREATE INDEX included, so these roll
back with the rest of a failing migration. CREATE INDEX CONCURRENTLY is deliberately not offered:
it is the one form that would need the transaction suppressed, and it would buy nothing here, because
adding a stored generated column rewrites the table under an ACCESS EXCLUSIVE lock in the same
migration. On a large table, add the column in its own migration and plan for the rewrite.
Observability
| Instrument | Meaning |
|---|---|
bsft.data.search.duration |
Search duration in seconds. |
bsft.data.search.results |
Total number of matches. |
Both tagged with search.group, so a degrading index shows up before users complain.
Verification limits
This is where each provider's tests stop, stated per engine rather than as one blanket claim.
SQL Server and PostgreSQL are verified through the SQL they generate and the EF model they require, not against a running instance: the repository has no such server and no container infrastructure in CI. Predicate shape, the language identifier or text search configuration, the ranking expression, scoping composition, the model checks and the migration DDL are all covered. What is not verified and should be checked once against a real instance before relying on it: that stemming behaves as expected in a given language, how ranking orders real data, whether the planner picks the index, and — for PostgreSQL — that the generated-column DDL applies cleanly on the server version in use. For SQL Server, index population timing is in that list too.
SQLite is verified end to end against the real engine, because it runs in-process and
Microsoft.Data.Sqlite ships FTS5. Those tests create the schema by executing exactly the statements
the shipped migration helpers emit, write through EF so the triggers fire, and then assert what the
engine returns: prefix and free-text matching, real bm25 ordering under the declared weights,
tenant and organisational scoping composed with the index match, updates and deletes keeping the
index in step, diacritic folding, rank-stable paging, a pre-applied sort surviving the join, and the
rebuild path for an index created over a table that already holds rows. The unstemmed-German
boundary is asserted rather than only described.
Sample
The sample application runs on SQLite and does not declare a searchable group. It could now — with
Bsft.Data.Sqlite there is a provider for that engine — but the sample's own tables carry no text
worth an index, and a declaration added only to demonstrate the wiring would be a fixture rather than
an example.