Azure Table Storage
The Shiny.DocumentDb.AzureTable package provides a document store over Azure Table Storage using Azure.Data.Tables. It also targets the Cosmos DB Table API (the same SDK/protocol). Every document type lives in one table: PartitionKey = typeName, RowKey = id.
Azure Table is a NoSQL key-partitioned store. Rich LINQ queries evaluate client-side after a single-partition scan (the LiteDB model); optimistic concurrency is backed by the native Table ETag. There is no spatial / vector / full-text / temporal support on this provider.
When to Use
Section titled “When to Use”- Cheap, massively scalable key/value + JSON document storage on Azure
- Existing Azure Storage accounts (or a Cosmos DB Table API account) you already pay for
- Workloads whose reads are almost always “all documents of a type” or point reads by id
- You don’t need server-side rich querying, spatial, vector, full-text, or temporal history
Installation
Section titled “Installation”dotnet add package Shiny.DocumentDb.AzureTable-
Direct instantiation
using Shiny.DocumentDb.AzureTable;var store = new AzureTableDocumentStore(new AzureTableDocumentStoreOptions{ConnectionString = "UseDevelopmentStorage=true", // or a real account / SAS connection stringTableName = "Documents" // one table; PartitionKey=typeName, RowKey=id}); -
Dependency injection
using Shiny.DocumentDb;builder.Services.AddAzureTableDocumentStore(o =>{o.ConnectionString = "UseDevelopmentStorage=true";o.TableName = "Documents";o.MapVersionProperty<Order>(x => x.Version); // opt-in optimistic concurrency (ETag-backed)});AddAzureTableDocumentStoreregistersIDocumentStoreandIDocumentMaintenanceas singletons.
Credentials
Section titled “Credentials”Set exactly one of the following on the options (checked in this order): TableServiceClient (a pre-built client), ConnectionString, or ServiceUri + one credential.
// Connection string (account key, SAS, or the emulator)o.ConnectionString = "DefaultEndpointsProtocol=https;AccountName=...;AccountKey=...";
// Managed identity / DefaultAzureCredentialo.ServiceUri = new Uri("https://myaccount.table.core.windows.net");o.TokenCredential = new Azure.Identity.DefaultAzureCredential();
// Shared key or SASo.SharedKeyCredential = new TableSharedKeyCredential("myaccount", "<key>");o.SasCredential = new AzureSasCredential("<sas>");The same provider works against the Cosmos DB Table API — point the connection string / endpoint at your Cosmos Table account.
Options Reference
Section titled “Options Reference”| Property | Type | Default | Description |
|---|---|---|---|
ConnectionString | string? | null | Account / SAS / emulator connection string |
ServiceUri | Uri? | null | Table endpoint (used with a credential) |
TokenCredential | TokenCredential? | null | DefaultAzureCredential / managed identity |
SharedKeyCredential | TableSharedKeyCredential? | null | Account name + key |
SasCredential | AzureSasCredential? | null | SAS credential |
TableServiceClient | TableServiceClient? | null | Pre-built client (wins over all other options) |
TableName | string | "Documents" | The single table holding every type |
AutoCreateTable | bool | true | Create the table if it doesn’t exist |
TypeNameResolution | TypeNameResolution | ShortName | How type names (partition keys) are derived |
JsonSerializerOptions | JsonSerializerOptions? | null | JSON serialization settings |
UseReflectionFallback | bool | true | Set false for AOT safety |
Logging | Action<string>? | null | Diagnostic callback |
Additional mapping methods: MapTypeToPartition<T>(pk), MapIdProperty<T>(...), MapIdType<TId>(...), AddQueryFilter<T>(...), AddInterceptor / OnBeforeWrite<T> / OnAfterWrite<T>, and MapVersionProperty<T>(...).
The (typeName, id) key model
Section titled “The (typeName, id) key model”| Concept | Azure Table |
|---|---|
| Partition | PartitionKey = typeName |
| Document key | RowKey = id.ToString() |
| Payload | Data string column (+ CreatedAt / UpdatedAt) |
| Point read | GetEntity(pk, rk) |
| Type-scoped query | PartitionKey eq '<typeName>' |
| Concurrency (CAS) | ETag If-Match on UpdateEntity |
| Batch | SubmitTransaction ≤ 100 per PartitionKey |
Because Query<T>() is always type-scoped, the common read path is a single-partition query — no cross-partition scan.
Querying (client-side)
Section titled “Querying (client-side)”There is no server-side query translation. Query<T>() runs a single-partition query to load every document of the type, then evaluates Where / OrderBy / Paginate / Select / aggregates in memory via the shared ExpressionInterpreter:
var open = await store.Query<Order>() .Where(o => o.Status == "Open") // evaluated client-side .OrderByDescending(o => o.CreatedAt) .Paginate(0, 25) .ToList();Promoted columns & server-side pushdown
Section titled “Promoted columns & server-side pushdown”Promote a scalar property to a native top-level Table column so predicates over it are pushed into a server-side OData $filter (the full predicate still re-runs client-side, so results are always exact):
var opts = new AzureTableDocumentStoreOptions { ConnectionString = "…", TableName = "Documents" } .MapIndexedProperty<Order>(o => o.Status) .MapIndexedProperty<Order>(o => o.Total);
// Status/Total are written as native columns → this predicate is pushed down, not scanned:var open = await store.Query<Order>().Where(o => o.Status == "Open" && o.Total > 100).ToList();Inspect the query the builder would run with ToQueryString().
Raw OData string query
Section titled “Raw OData string query”The string Query/QueryStream/Count overloads take a raw OData $filter fragment (Azure Table’s native query language) scoped to the type’s partition. It targets native columns — the promoted columns (reference them by their CLR/JSON property name) and the built-in PartitionKey/RowKey/Timestamp — not fields inside the opaque JSON body. parameters supplies @name token substitutions.
var open = await store.Query<Order>("Status eq @s and Total gt @min", parameters: new { s = "Open", min = 100 });Project(string) is not supported.
Change observation
Section titled “Change observation”Azure Table implements IObservableDocumentStore — subscribe to in-process changes made through this store instance:
await foreach (var change in ((IObservableDocumentStore)store).NotifyOnChange<Order>(ct)) Console.WriteLine($"{change.ChangeType} {change.Id}");
// or scoped to a query's predicateawait foreach (var change in store.Query<Order>().Where(o => o.Status == "Open").NotifyOnChange(ct)) …Notifications are in-process (this instance’s writes), buffered inside a UnitOfWork until it commits. For a cross-writer feed use DynamoDB (Streams) or Cosmos.
Ids & concurrency
Section titled “Ids & concurrency”- Guid / string Ids auto-generate on Insert when default. Explicit int/long Ids round-trip fine.
- Int/Long Id auto-generation is unsupported — inserting a default int/long Id throws
NotSupportedException(there is no cheapMAX). Use Guid/string Ids, or assign the int/long Id yourself. - Optimistic concurrency:
MapVersionProperty<T>(x => x.Version)seeds the version to 1 on insert, checks & increments on update/upsert, and guards the write with the TableETag(If-Match). A stale write throwsConcurrencyException. Blind (unversioned) upsert is last-write-wins.
Storage layout
Section titled “Storage layout”Each document is a TableEntity:
| Column | Value |
|---|---|
PartitionKey | the type name |
RowKey | the document id (string form) |
Data | the serialized JSON body |
CreatedAt / UpdatedAt | ISO-8601 timestamps |
ETag / Timestamp | SDK-managed (physical CAS token) |
Limitations
Section titled “Limitations”- 64 KB per-property / 1 MB per-entity cap — the JSON body is a single
Datastring column, so a document larger than ~64 KB throws a clearNotSupportedException(not a raw storage 413). Store large fields externally or use Cosmos/MongoDB. - Client-side queries by default — an unindexed
Query<T>()is a full type scan; promote the filtered property withMapIndexedProperty<T>to push the filter server-side. - Int/Long Id auto-generation unsupported — use Guid/string.
- No spatial / vector / full-text / temporal —
SupportsSpatial/SupportsVector/SupportsFullTextarefalseand there is noITemporalDocumentStore. - In-process change observation only —
IObservableDocumentStoreobserves this instance’s writes; Azure Table has no cross-writer change feed (use DynamoDB Streams or Cosmos for that). - Compensating unit of work —
CreateUnitOfWork()tracks inserts and rolls them back on failure; there is no cross-partition transaction (same model as Cosmos).
IDocumentMaintenance.ClearAll()is supported (scan-deletes every partition) — handy for tests/dev resets.BatchInsert/BatchRemoveuse nativeSubmitTransactionin ≤ 100-item waves per PartitionKey.Upsertdeep-merges in C# with recursive null stripping (RFC 7396 semantics); when versioned, the merge write is the ETag-guarded write.