Skip to content

Commit 3018ab5

Browse files
committed
Full text search across all providers - Bump cosmos DB version
1 parent 09be585 commit 3018ab5

47 files changed

Lines changed: 1969 additions & 2 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Directory.Packages.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
</PropertyGroup>
66
<ItemGroup>
77
<PackageVersion Include="Nerdbank.GitVersioning" Version="3.9.50"/>
8-
<PackageVersion Include="Microsoft.Azure.Cosmos" Version="3.46.1"/>
8+
<PackageVersion Include="Microsoft.Azure.Cosmos" Version="3.61.0"/>
99
<PackageVersion Include="Newtonsoft.Json" Version="13.0.3"/>
1010
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.3"/>
1111
<PackageVersion Include="Microsoft.Data.Sqlite.Core" Version="10.0.3"/>

readme.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ A lightweight, multi-provider document store for .NET that turns relational data
5353
- **Batch upsert / update / remove**`store.BatchUpsert(items)`, `store.BatchUpdate(items)`, and `store.BatchRemove<T>(ids)` apply many writes as one set operation — a single multi-row `INSERT … ON CONFLICT` deep-merge on SQLite/DuckDB, one `BulkWrite`/`DeleteMany` on MongoDB, parallel request waves on Cosmos, and a single `DELETE … IN (…)` for `BatchRemove` on every relational provider. All-or-nothing: the first version conflict rolls the whole batch back.
5454
- **Spatial / geo queries**`WithinRadius`, `WithinBoundingBox`, and `NearestNeighbors` methods with `GeoPoint` support. SQLite uses R*Tree virtual tables; CosmosDB uses native `ST_DISTANCE`/`ST_WITHIN`. Configure with `MapSpatialProperty<T>(x => x.Location)`.
5555
- **Vector / ANN search**`MapVectorProperty<T>(x => x.Embedding, dimensions: 1536)` + `store.Query<T>().NearestVectors(queryEmbedding, k: 10)` for cross-provider ANN over `ReadOnlyMemory<float>` embeddings. Provider-native indexes: pgvector (PostgreSQL), `VECTOR_DISTANCE` (SQL Server 2025 and Oracle 23ai), DiskANN (CosmosDB), `$vectorSearch` (MongoDB Atlas), `vss` (DuckDB), `sqlite-vec` (SQLite). Cosine / Euclidean / DotProduct everywhere; Hamming on pgvector. Pre-filter via `Where(...)` where the engine supports it. Auto-embed text properties on insert via `Shiny.DocumentDb.Extensions.AI`'s `AutoEmbedOnInsert<T>` hook + `Microsoft.Extensions.AI.IEmbeddingGenerator`.
56+
- **Full-text search (all providers)**`MapFullTextProperty<T>(a => a.Body)` (or `[a => a.Title, a => a.Body]`) + `store.FullTextSearch<T>("orleans persistence")` for relevance-ranked text search, returning `FullTextResult<T>` (`Document` + normalized `Score`, higher = better) ordered by relevance, with an optional pre-filter predicate and a fluent `store.Query<T>().Where(...).FullTextMatch("...")` form. The native index is created for you and engine-maintained: FTS5 (SQLite), `tsvector`+GIN (PostgreSQL), `FULLTEXT` (MySQL), Oracle Text (Oracle), Full-Text Index (SQL Server), the `fts` extension (DuckDB), full-text policy (CosmosDB), `$text` (MongoDB), and an in-memory TF-IDF fallback on LiteDB / IndexedDB. Declarative: a type must be mapped before it can be searched. Oracle Text and SQL Server Full-Text Search are optional server components; CosmosDB full-text needs `Microsoft.Azure.Cosmos` 3.61.0+.
5657
- **Telemetry & observability (`Shiny.DocumentDb.Diagnostics`)**`services.AddDocumentStoreInstrumentation()` wraps any provider in a decorator that emits OpenTelemetry-native metrics (`db.client.operation.duration` + an operations counter + a returned-rows histogram, tagged per the OTel DB semantic conventions) and an `ActivitySource` client span per operation. Covers CRUD, the fluent-query terminals, the temporal `ITemporalDocumentStore` ops, and `UnitOfWork.SaveChanges` (inner ops become child spans). Built on `System.Diagnostics.Metrics`/`IMeterFactory`; subscribe with `.AddMeter("Shiny.DocumentDb")` / `.AddSource("Shiny.DocumentDb")`. Zero-cost when nobody is listening; never records document bodies or ids.
5758
- **Hot backup**`store.Backup("/path/to/backup.db")` copies the database to a file. Available on `SqliteDocumentStore`, `SqlCipherDocumentStore`, and `LiteDbDocumentStore` (not on the `IDocumentStore` interface).
5859
- **Clear the whole store (`IDocumentMaintenance.ClearAll`)**`((IDocumentMaintenance)store).ClearAll()` wipes every document type (plus temporal-history, spatial, and vector sidecars) for test/dev resets. A whole-store wipe — not type- or tenant-scoped (use `Clear<T>()` for one type) — that targets only user tables in the current database and never the system catalogs. Implemented on the relational `DocumentStore` (SQLite, SQL Server, PostgreSQL, MySQL, DuckDB, Oracle), MongoDB, and CosmosDB; `SqliteDocumentStore.ClearAllAsync()` still works and delegates to it.

skills/shiny-documentdb/SKILL.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,14 @@ triggers:
2323
- vector search
2424
- NearestVectors
2525
- MapVectorProperty
26+
- full-text search
27+
- FullTextSearch
28+
- FullTextMatch
29+
- MapFullTextProperty
30+
- FullTextResult
31+
- FullTextLanguage
32+
- FTS5
33+
- tsvector
2634
- sqlite-vec
2735
- VectorExtensionPreloaded
2836
- EnableVectorExtension
@@ -1442,6 +1450,30 @@ If you supply your own binary, two mutually complementary flags on `SqliteDataba
14421450

14431451
Either flag (or the package helper) makes `SupportsVector` return `true`. Without one, `NearestVectors` throws `NotSupportedException`. vec0 is flat-scan (no HNSW); when a `.Where(...)` filter is combined with the search, the library over-fetches `k * postFilterMultiplier` (default 4) candidates.
14441452

1453+
## Full-Text Search
1454+
1455+
Relevance-ranked text search over one or more string properties. **Declarative and up-front**: map the searchable property with `MapFullTextProperty<T>(...)` and the library creates the native index for you at startup. A type **must be mapped before it can be searched** — there is no ad-hoc full-text (unlike `.Where(x => x.Body.Contains(...))`, which works on any field). Supported on **every provider**: FTS5 (SQLite), `tsvector`+GIN (PostgreSQL), `FULLTEXT` (MySQL), Oracle Text (Oracle), Full-Text Index (SQL Server), the `fts` extension (DuckDB), full-text policy (Cosmos), `$text` (MongoDB), and an in-memory TF-IDF scan on LiteDB / IndexedDB.
1456+
1457+
```csharp
1458+
// single field, or several combined into one index
1459+
options.MapFullTextProperty<Article>(a => a.Body);
1460+
options.MapFullTextProperty<Article>([a => a.Title, a => a.Body]);
1461+
1462+
// terminal API — ordered by relevance descending, each with a Score (higher = better)
1463+
IReadOnlyList<FullTextResult<Article>> hits =
1464+
await store.FullTextSearch<Article>("orleans persistence", maxResults: 20);
1465+
1466+
// optional pre-filter predicate (tenant/category scoping)
1467+
var tech = await store.FullTextSearch<Article>("orleans", filter: a => a.Category == "tech");
1468+
1469+
// fluent form — folds the query's Where predicates into the pre-filter
1470+
var hits2 = await store.Query<Article>()
1471+
.Where(a => a.Category == "tech")
1472+
.FullTextMatch("orleans", maxResults: 10);
1473+
```
1474+
1475+
`FullTextResult<T>` carries `Document` and a normalized `double Score` (higher = more relevant; absolute scale is provider-specificcompare only within one result set). `MapFullTextProperty` also has an AOT-safe overload taking `propertyNames` + a `Func<T, IEnumerable<string?>>` selector (for combining fields or indexing a string collection), and an optional `FullTextLanguage` (controls stemming where the backend supports it). The index is engine-maintained, so `Insert`/`Update`/`Remove`/`Clear` keep it in sync automatically. Notes: engines with one full-text index per table (SQL Server, MongoDB) support a single mapped type per table/collection; **Oracle Text** and **SQL Server Full-Text Search** are optional server components that must be installed; Cosmos full-text needs `Microsoft.Azure.Cosmos` 3.61.0+.
1476+
14451477
## Fluent Query Builder (IDocumentQuery<T>)
14461478

14471479
The fluent query builder is the primary way to query documents. Start with `store.Query<T>()` and chain builder methods, then terminate with a materialization method.

src/Shiny.DocumentDb.CosmosDb/CosmosDbDocumentQuery.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,15 @@ public IAsyncEnumerable<DocumentChange<T>> NotifyOnChange(CancellationToken ct =
359359
=> throw new NotSupportedException(
360360
"Per-query change observation is not supported by CosmosDbDocumentStore. " +
361361
"Use SubscribeChanges<T>() to consume the native Cosmos change feed.");
362+
363+
public Task<IReadOnlyList<FullTextResult<T>>> FullTextMatch(string searchText, int maxResults = 50, CancellationToken ct = default)
364+
{
365+
var effective = this.GetEffectivePredicateExpressions().ToList();
366+
Expression<Func<T, bool>>? filter = effective.Count == 0
367+
? null
368+
: DocumentQuery<T>.CombinePredicates(effective);
369+
return this.store.FullTextSearch(searchText, maxResults, filter, ct);
370+
}
362371
}
363372

364373
internal class CosmosDbProjectedDocumentQuery<TSource, TResult> : IDocumentQuery<TResult>

src/Shiny.DocumentDb.CosmosDb/CosmosDbDocumentStore.cs

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,10 +96,12 @@ public CosmosDbDocumentStore(CosmosDbDocumentStoreOptions options)
9696
options.ResolveVersionJsonPaths(this.jsonOptions);
9797
options.ResolveSpatialJsonPaths(this.jsonOptions);
9898
options.ResolveVectorJsonPaths(this.jsonOptions);
99+
options.ResolveFullTextJsonPaths(this.jsonOptions);
99100
}
100101

101102
public bool SupportsSpatial => this.options.spatialMappings.Count > 0;
102103
public bool SupportsVector => this.options.vectorMappings.Count > 0;
104+
public bool SupportsFullText => this.options.fullTextMappings.Count > 0;
103105

104106
public void Dispose()
105107
{
@@ -226,6 +228,27 @@ async Task<Container> EnsureContainerAsync(string containerName, CancellationTok
226228
containerProperties.VectorEmbeddingPolicy = new VectorEmbeddingPolicy(embeddings);
227229
}
228230

231+
// Full-text policy + indexes for mapped full-text properties.
232+
if (this.options.fullTextMappings.Count > 0)
233+
{
234+
var ftPaths = new System.Collections.ObjectModel.Collection<FullTextPath>();
235+
foreach (var mapping in this.options.fullTextMappings.Values)
236+
{
237+
var lang = CosmosFullTextLanguage(mapping.Language);
238+
foreach (var jsonPath in mapping.JsonPaths)
239+
{
240+
ftPaths.Add(new FullTextPath { Path = $"/data/{jsonPath}", Language = lang });
241+
containerProperties.IndexingPolicy.FullTextIndexes.Add(
242+
new FullTextIndexPath { Path = $"/data/{jsonPath}" });
243+
}
244+
}
245+
containerProperties.FullTextPolicy = new FullTextPolicy
246+
{
247+
DefaultLanguage = "en-US",
248+
FullTextPaths = ftPaths
249+
};
250+
}
251+
229252
await this.database.CreateContainerIfNotExistsAsync(
230253
containerProperties, this.options.DefaultThroughput, cancellationToken: ct).ConfigureAwait(false);
231254

@@ -238,6 +261,77 @@ await this.database.CreateContainerIfNotExistsAsync(
238261
}
239262
}
240263

264+
static string CosmosFullTextLanguage(FullTextLanguage language) => language switch
265+
{
266+
FullTextLanguage.German => "de-DE",
267+
FullTextLanguage.Spanish => "es-ES",
268+
FullTextLanguage.French => "fr-FR",
269+
FullTextLanguage.Italian => "it-IT",
270+
FullTextLanguage.Portuguese => "pt-BR",
271+
FullTextLanguage.Dutch => "nl-NL",
272+
FullTextLanguage.Russian => "ru-RU",
273+
_ => "en-US"
274+
};
275+
276+
// ── Full-text search (Cosmos DB full-text policy + FullTextScore RANK) ──
277+
278+
public async Task<IReadOnlyList<FullTextResult<T>>> FullTextSearch<T>(
279+
string searchText,
280+
int maxResults = 50,
281+
Expression<Func<T, bool>>? filter = null,
282+
CancellationToken cancellationToken = default) where T : class
283+
{
284+
var mapping = this.options.ResolveFullTextMapping(typeof(T))
285+
?? throw new NotSupportedException(
286+
$"No full-text property mapped for type '{typeof(T).Name}'. Call MapFullTextProperty<{typeof(T).Name}>() in options.");
287+
ArgumentException.ThrowIfNullOrEmpty(searchText);
288+
if (maxResults <= 0)
289+
throw new ArgumentOutOfRangeException(nameof(maxResults));
290+
291+
var typeInfo = this.FindTypeInfo<T>(null);
292+
var typeName = this.ResolveTypeName<T>();
293+
var container = await this.EnsureContainerAsync(this.ResolveContainerName<T>(), cancellationToken).ConfigureAwait(false);
294+
295+
var terms = FullTextMappingFactory.Tokenize(searchText);
296+
if (terms.Count == 0)
297+
return Array.Empty<FullTextResult<T>>();
298+
299+
// Tokens are alphanumeric → safe to embed as Cosmos string literals.
300+
var termLiterals = string.Join(", ", terms.Select(t => "\"" + t + "\""));
301+
var paths = mapping.JsonPaths.Select(p => $"c.data.{p}").ToList();
302+
var contains = string.Join(" OR ", paths.Select(p => $"FullTextContainsAny({p}, {termLiterals})"));
303+
var scores = paths.Select(p => $"FullTextScore({p}, {termLiterals})").ToList();
304+
// FullTextScore is only valid in ORDER BY RANK; combine multiple fields with reciprocal rank fusion.
305+
var rank = scores.Count == 1 ? scores[0] : $"RRF({string.Join(", ", scores)})";
306+
307+
// FullTextScore cannot be projected, so the score is synthesized from rank order; over-fetch
308+
// when a post-filter is present so it doesn't starve the top-N.
309+
var fetch = filter == null ? maxResults : maxResults * 4;
310+
var sql = $"SELECT TOP {fetch} c.data FROM c WHERE c.typeName = @typeName AND ({contains}) ORDER BY RANK {rank}";
311+
var queryDef = new QueryDefinition(sql).WithParameter("@typeName", typeName);
312+
313+
var postFilter = filter == null ? null : ExpressionInterpreter.Interpret(filter);
314+
var results = new List<FullTextResult<T>>();
315+
var position = 0;
316+
using var iterator = container.GetItemQueryIterator<CosmosDocument>(queryDef, requestOptions: new QueryRequestOptions
317+
{
318+
PartitionKey = new PartitionKey(typeName)
319+
});
320+
while (iterator.HasMoreResults && results.Count < maxResults)
321+
{
322+
var response = await iterator.ReadNextAsync(cancellationToken).ConfigureAwait(false);
323+
foreach (var doc in response)
324+
{
325+
var obj = Deserialize(doc.Data, typeInfo, this.jsonOptions);
326+
if (obj == null) continue;
327+
if (postFilter != null && !postFilter(obj)) continue;
328+
results.Add(new FullTextResult<T> { Document = obj, Score = 1.0 / ++position });
329+
if (results.Count >= maxResults) break;
330+
}
331+
}
332+
return results;
333+
}
334+
241335
string GenerateId<T>(IdAccessor<T> accessor) where T : class
242336
{
243337
return accessor.Kind switch

src/Shiny.DocumentDb.CosmosDb/CosmosDbDocumentStoreOptions.cs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ public class CosmosDbDocumentStoreOptions
1616
internal readonly Dictionary<Type, VersionMapping> versionMappings = new();
1717
internal readonly Dictionary<Type, CosmosDbSpatialMapping> spatialMappings = new();
1818
internal readonly Dictionary<Type, VectorMapping> vectorMappings = new();
19+
internal readonly Dictionary<Type, FullTextMapping> fullTextMappings = new();
1920
internal readonly Dictionary<Type, TemporalMapping> temporalMappings = new();
2021

2122
public required string ConnectionString { get; set; }
@@ -397,6 +398,46 @@ internal void ResolveVectorJsonPaths(JsonSerializerOptions jsonOptions)
397398
}
398399
}
399400

401+
/// <summary>
402+
/// Declares a string property as full-text searchable via Cosmos DB full-text search (full-text
403+
/// policy + index, queried with <c>FullTextScore</c>/<c>FullTextContainsAny</c>). See
404+
/// <see cref="DocumentStoreOptions.MapFullTextProperty{T}(Expression{Func{T, string}}, FullTextLanguage)"/>.
405+
/// </summary>
406+
public CosmosDbDocumentStoreOptions MapFullTextProperty<T>(
407+
Expression<Func<T, string?>> property,
408+
FullTextLanguage language = FullTextLanguage.English) where T : class
409+
{
410+
ArgumentNullException.ThrowIfNull(property);
411+
this.fullTextMappings[typeof(T)] = FullTextMappingFactory.FromExpressions([property], language);
412+
return this;
413+
}
414+
415+
/// <summary>Declares several string properties combined into one full-text index.</summary>
416+
public CosmosDbDocumentStoreOptions MapFullTextProperty<T>(
417+
IReadOnlyList<Expression<Func<T, string?>>> properties,
418+
FullTextLanguage language = FullTextLanguage.English) where T : class
419+
{
420+
ArgumentNullException.ThrowIfNull(properties);
421+
this.fullTextMappings[typeof(T)] = FullTextMappingFactory.FromExpressions(properties, language);
422+
return this;
423+
}
424+
425+
/// <summary>AOT-safe overload mapping full-text to a direct text selector (combine fields or index a string collection).</summary>
426+
public CosmosDbDocumentStoreOptions MapFullTextProperty<T>(
427+
IReadOnlyList<string> propertyNames,
428+
Func<T, IEnumerable<string?>> textSelector,
429+
FullTextLanguage language = FullTextLanguage.English) where T : class
430+
{
431+
this.fullTextMappings[typeof(T)] = FullTextMappingFactory.FromAccessor(propertyNames, textSelector, language);
432+
return this;
433+
}
434+
435+
internal FullTextMapping? ResolveFullTextMapping(Type type) =>
436+
this.fullTextMappings.TryGetValue(type, out var mapping) ? mapping : null;
437+
438+
internal void ResolveFullTextJsonPaths(JsonSerializerOptions jsonOptions)
439+
=> FullTextMappingFactory.ResolveJsonPaths(this.fullTextMappings.Values, jsonOptions);
440+
400441
static string ExtractPropertyName<T>(Expression<Func<T, object>> expression)
401442
{
402443
var body = expression.Body;

src/Shiny.DocumentDb.Diagnostics/InstrumentedDocumentStore.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,11 @@ public Task<IReadOnlyList<SpatialResult<T>>> NearestNeighbors<T>(GeoPoint center
131131
public Task<IReadOnlyList<VectorResult<T>>> NearestVectors<T>(ReadOnlyMemory<float> query, int k, Expression<Func<T, bool>>? filter = null, CancellationToken cancellationToken = default) where T : class
132132
=> this.tracker.Track("nearest_vectors", Coll<T>(), () => this.inner.NearestVectors(query, k, filter, cancellationToken), r => r.Count);
133133

134+
public bool SupportsFullText => this.inner.SupportsFullText;
135+
136+
public Task<IReadOnlyList<FullTextResult<T>>> FullTextSearch<T>(string searchText, int maxResults = 50, Expression<Func<T, bool>>? filter = null, CancellationToken cancellationToken = default) where T : class
137+
=> this.tracker.Track("full_text_search", Coll<T>(), () => this.inner.FullTextSearch(searchText, maxResults, filter, cancellationToken), r => r.Count);
138+
134139
// ── ITemporalDocumentStore ──────────────────────────────────────────
135140

136141
public Task<IReadOnlyList<DocumentVersion<T>>> History<T>(object id, JsonTypeInfo<T>? jsonTypeInfo = null, CancellationToken cancellationToken = default) where T : class

0 commit comments

Comments
 (0)