Unified.Data.Tables
0.1.0
See the version list below for details.
dotnet add package Unified.Data.Tables --version 0.1.0
NuGet\Install-Package Unified.Data.Tables -Version 0.1.0
<PackageReference Include="Unified.Data.Tables" Version="0.1.0" />
<PackageVersion Include="Unified.Data.Tables" Version="0.1.0" />
<PackageReference Include="Unified.Data.Tables" />
paket add Unified.Data.Tables --version 0.1.0
#r "nuget: Unified.Data.Tables, 0.1.0"
#:package Unified.Data.Tables@0.1.0
#addin nuget:?package=Unified.Data.Tables&version=0.1.0
#tool nuget:?package=Unified.Data.Tables&version=0.1.0
Unified.Data.Tables
A small, reusable Azure Table Storage data layer for .NET: a generic IStorage<T> repository with
in-memory caching, optimistic concurrency, builder-driven partial updates, role-gated properties, and a
reflection-based object-graph serializer that transparently handles nested types and the 64 KB
per-cell limit.
It exists so the same battle-tested storage primitives can be shared across projects instead of being copy-pasted into each one.
Features
- Generic repository —
IStorage<T>over Azure Tables, one table per entity type (typeof(T).Name). - Reflection-based serializer — flattens nested objects into
Parent_Childcolumns, stores enums as strings and money-styledecimals as doubles, and falls back to JSON (then GZip) for collections and complex graphs. Oversized cells are compressed — and, as a last resort, truncated — so a single large property can never blow the 64 KB limit and lose the whole row. - In-memory caching — reads are served from
IMemoryCache(1 hour sliding TTL); writes keep the entity cache coherent and invalidate the relevant query caches automatically. - Optimistic concurrency (ETag) — a caller-supplied
ETagenforces strict concurrency (a conflict surfaces asRequestFailedException412 → map to 409); without one, a stale-cache conflict is retried once. - Partial (Merge) updates —
UpdateAsync(id, builder)writes only the columns you declare, leaving the rest of the row untouched with no read required. - Protected properties — mark a property
[ProtectedProperty("admin,...")]and role-gate writes through a pluggableIProtectedPropertyAuthorizer(the package itself has no ASP.NET Core dependency). - Batch partition delete —
DeletePartitionAsyncremoves a whole partition in 100-entity transactions. - Composite id convention —
Idis"{PartitionKey}|{RowKey}"; row keys may themselves contain|.
Installation
dotnet add package Unified.Data.Tables
Requires .NET 10 and an Azure Storage account (or the local Azurite emulator).
Quick start
1. Define an entity
Derive from Entity. Id is the composite "{PartitionKey}|{RowKey}"; Created, Modified and ETag
are managed for you.
using Unified.Data.Tables;
public sealed class Customer : Entity
{
public string Name { get; set; } = "";
public string Email { get; set; } = "";
public decimal Balance { get; set; }
}
2. Register it
using Azure.Data.Tables;
using Unified.Data.Tables;
// Option A — build the TableServiceClient from a connection string:
builder.Services.AddUnifiedTableStorage(connectionString);
// Option B — you already register a TableServiceClient yourself:
builder.Services.AddSingleton(_ => new TableServiceClient(connectionString));
builder.Services.AddUnifiedTableStorage();
Both register IMemoryCache and the open-generic IStorage<T> → TableStorage<T> mapping as singletons.
3. Use it
public sealed class CustomerService(IStorage<Customer> storage)
{
public Task<Customer> Add(Customer c) => storage.CreateAsync(c);
public Task<Customer?> Get(string id) => storage.OneAsync(id);
public Task<bool> Exists(string id) => storage.ExistsAsync(id);
public Task<IEnumerable<Customer>> InRegion(string region) => storage.QueryAsync(region);
public Task Remove(string id) => storage.DeleteAsync(id);
}
// PartitionKey = "eu", RowKey = "alice@example.com"
var customer = new Customer
{
Id = "eu|alice@example.com",
Name = "Alice",
Email = "alice@example.com",
Balance = 100m
};
await storage.CreateAsync(customer); // ids are normalized: trim → spaces to '-' → lower-case
var loaded = await storage.OneAsync("eu|alice@example.com");
var euOnes = await storage.QueryAsync("eu"); // scope to a partition (omit for the whole table)
IStorage<T>
| Method | Description |
|---|---|
CreateAsync(entity) |
Insert a new row; returns it with its populated ETag. |
OneAsync(id) |
Fetch one row by composite id, or null. |
ExistsAsync(id) |
Whether a row exists (cache-aware). |
QueryAsync(partition?) |
All rows, or just one partition when supplied. |
UpdateAsync(entity) |
Full replace with ETag concurrency. |
UpdateAsync(id, builder) |
Partial Merge — writes only the declared columns. |
DeleteAsync(id) |
Delete one row. |
DeletePartitionAsync(partition) |
Batch-delete a whole partition; returns the count. |
Partial updates
Only the properties you set are written; everything else on the row is preserved, and no read is needed.
await storage.UpdateAsync("eu|alice@example.com", b => b
.SetProperty(x => x.Balance, 250m)
.SetProperty(x => x.Name, "Alice A."));
Optimistic concurrency
OneAsync/QueryAsync populate Entity.ETag. Pass it back on UpdateAsync(entity) for a strict
check — a concurrent modification throws RequestFailedException with status 412.
var c = await storage.OneAsync(id);
c!.Balance += 100;
await storage.UpdateAsync(c); // 412 if the row changed since it was read
Protected properties
Gate sensitive columns behind roles. Enforcement runs on the whole-entity update path and needs an
IProtectedPropertyAuthorizer; if none is registered, changing a protected value is denied.
public sealed class Employee : Entity
{
public string Name { get; set; } = "";
[ProtectedProperty("admin,accountant")]
public decimal Salary { get; set; }
}
Provide an authorizer from your host (this is where the ClaimsPrincipal lives — kept out of the package
so it stays dependency-light):
public sealed class RoleAuthorizer(IHttpContextAccessor accessor) : IProtectedPropertyAuthorizer
{
public bool IsAllowed(string roles) =>
roles.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)
.Any(role => accessor.HttpContext?.User.IsInRole(role) == true);
}
builder.Services.AddHttpContextAccessor();
builder.Services.AddSingleton<IProtectedPropertyAuthorizer, RoleAuthorizer>();
For the builder path, protected properties are rejected unless you opt in after verifying authorization:
await storage.UpdateAsync(id, b => b.AllowProtected().SetProperty(x => x.Salary, 5000m));
Serialization
TableStorage<T> uses TableEntitySerializer under the hood, but you can call it directly:
TableEntity row = customer.ToTableEntity(partitionKey, rowKey);
Customer back = row.FromTableEntity<Customer>();
- Scalars map to native cells; enums become strings;
decimalis stored asdouble. - Nested objects fan out to
Parent_Childcolumns. - Collections / complex graphs serialize to JSON (
__Jsoncolumn suffix), GZip-compressed (__GZip) when they exceed the cell limit. - Legacy tolerance — a stored date surfaced by the SDK as a
DateTimeor astringstill deserializes into aDateTimeOffset(orDateTime) property without throwing. - Set
persistType: trueto embed the type name and use the late-boundFromTableEntity()overload.
Id convention
Entity.Id is a composite string split on the first |:
Id |
PartitionKey | RowKey |
|---|---|---|
"eu|alice" |
eu |
alice |
"vision|exec|agent" |
vision |
exec|agent |
"single" |
single |
single |
Ids are normalized on write (trim → replace spaces with '-' → ToLowerInvariant). Id is also stored as
a data column, so the full composite id is preserved on read.
Building & testing
dotnet build Unified.Data.Tables.slnx -c Release
dotnet test Unified.Data.Tables.slnx -c Release
The test suite mixes fast unit tests (Azure SDK mocked with NSubstitute) with integration tests that run
against a local Azurite emulator. The integration tests self-skip when Azurite is not reachable, so
dotnet test is safe to run anywhere.
License
Copyright © Serhii Seletskyi. Add a LICENSE file (for example, MIT) before publishing to NuGet.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Azure.Data.Tables (>= 12.11.0)
- Microsoft.Extensions.Caching.Memory (>= 10.0.9)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.9)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.9)
NuGet packages (1)
Showing the top 1 NuGet packages that depend on Unified.Data.Tables:
| Package | Downloads |
|---|---|
|
Unified.Data.Tables.InMemory
Faithful in-memory IStorage<T> for Unified.Data.Tables: rows round-trip through the real TableEntitySerializer (so tests exercise production serialization behavior — decimal-as-double, enum-as-string, nested flattening, 64KB cell handling), with 409-on-duplicate-create, ETag simulation with 412 conflicts, and identical id normalization and key splitting. For unit tests, dev mode, and offline runtime. |
GitHub repositories
This package is not used by any popular GitHub repositories.