Skip to content

Commit 5fc92b5

Browse files
committed
Add support for TableEntity via ITableRepository and ITablePartition APIs
Fixes #18
1 parent 2380782 commit 5fc92b5

File tree

6 files changed

+321
-8
lines changed

6 files changed

+321
-8
lines changed

readme.md

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ var product = new Product("catId-asdf", "1234")
5555
await repo.PutAsync(product);
5656

5757
// Enumerate all products in category "catId-asdf"
58-
await foreach (var p in repo.EnumerateAsync("catId-asdf")
58+
await foreach (var p in repo.EnumerateAsync("catId-asdf"))
5959
Console.WriteLine(p.Price);
6060

6161
// Get previously saved product.
@@ -76,8 +76,8 @@ example. In such a case, instead of a `TableRepository`, you can use a `TablePar
7676
class Region
7777
{
7878
public Region(string code, string name)
79-
=> (Id, Amount)
80-
= (id, amount);
79+
=> (Code, Name)
80+
= (code, name);
8181

8282
public string Code { get; }
8383

@@ -87,20 +87,25 @@ class Region
8787

8888
```csharp
8989
var account = CloudStorageAccount.DevelopmentStorageAccount; // or production one
90-
// tableName will default to "Entity" and partition key to "Order", but they can
90+
// We lay out the parameter names for clarity only.
9191
// also be provided to the factory method to override the default behavior.
92-
var repo = TablePartition.Create<Region>(storageAccount, region => region.Code);
92+
var repo = TablePartition.Create<Region>(storageAccount,
93+
// tableName defaults to "Entity" if not provided
94+
tableName: "Reference",
95+
// partitionKey would default to "Region" too if not provided
96+
partitionKey: "Region",
97+
rowKey: region => region.Code);
9398

9499
var region = new Region("uk", "United Kingdom");
95100

96101
// Insert or Update behavior (aka "upsert")
97102
await repo.PutAsync(region);
98103

99104
// Enumerate all regions within the partition
100-
await foreach (var r in repo.EnumerateAsync()
105+
await foreach (var r in repo.EnumerateAsync())
101106
Console.WriteLine(r.Name);
102107

103-
// Get previously saved order.
108+
// Get previously saved region.
104109
Region saved = await repo.GetAsync("uk");
105110

106111
// Delete region
@@ -110,7 +115,7 @@ await repo.DeleteAsync("uk");
110115
await repo.DeleteAsync(saved);
111116
```
112117

113-
This is quite convenient for handing reference data, for example. Enumerating all entries
118+
This is quite convenient for handling reference data, for example. Enumerating all entries
114119
in the partition wouldn't be something you'd typically do for your "real" data, but for
115120
reference data, it could be useful.
116121

@@ -126,6 +131,30 @@ entity type to modify the default values used:
126131
Values passed to the `TableRepository.Create<T>` or `TablePartition.Create<T>` override
127132
declarative attributes.
128133

134+
### TableEntity Support
135+
136+
Since these repository APIs are quite a bit more intuitive than working against a direct
137+
`TableClient`, you might want to retrieve/enumerate entities just by their built-in `ITableEntity`
138+
properties, like `PartitionKey`, `RowKey`, `Timestamp` and `ETag`. For this scenario, we
139+
also support creating `ITableRepository<TableEntity>` and `ITablePartition<TableEntity>`
140+
by using the factory methods `TableRepository.Create(...)` and `TablePartition.Create(...)`
141+
without a (generic) entity type argument.
142+
143+
For example, given you know all `Region` entities saved in the example above, use the region `Code`
144+
as the `RowKey`, you could simply enumerate all regions without using the `Region` type at all:
145+
146+
```csharp
147+
var account = CloudStorageAccount.DevelopmentStorageAccount; // or production one
148+
var repo = TablePartition.Create(storageAccount,
149+
tableName: "Reference",
150+
partitionKey: "Region");
151+
152+
// Enumerate all regions within the partition as plain TableEntities
153+
await foreach (TableEntity region in repo.EnumerateAsync())
154+
Console.WriteLine(region.RowKey);
155+
```
156+
157+
129158
## Installation
130159

131160
```
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
//<auto-generated/>
2+
#nullable enable
3+
using System;
4+
using System.Collections.Generic;
5+
using System.Threading;
6+
using System.Threading.Tasks;
7+
using Microsoft.Azure.Cosmos.Table;
8+
9+
namespace Devlooped
10+
{
11+
/// <inheritdoc />
12+
partial class TableEntityPartition : ITablePartition<TableEntity>
13+
{
14+
readonly ITableRepository<TableEntity> repository;
15+
16+
/// <summary>
17+
/// Initializes the repository with the given storage account and optional table name.
18+
/// </summary>
19+
/// <param name="storageAccount">The <see cref="CloudStorageAccount"/> to use to connect to the table.</param>
20+
/// <param name="tableName">The table that backs this table partition.</param>
21+
/// <param name="partitionKey">The fixed partition key that backs this table partition.</param>
22+
protected internal TableEntityPartition(CloudStorageAccount storageAccount, string tableName, string partitionKey)
23+
{
24+
TableName = tableName;
25+
PartitionKey = partitionKey;
26+
repository = new TableEntityRepository(storageAccount, TableName);
27+
}
28+
29+
/// <inheritdoc />
30+
public string TableName { get; }
31+
32+
/// <inheritdoc />
33+
public string PartitionKey { get; }
34+
35+
/// <inheritdoc />
36+
public async Task DeleteAsync(TableEntity entity, CancellationToken cancellation = default)
37+
{
38+
if (!PartitionKey.Equals(entity.PartitionKey, StringComparison.Ordinal))
39+
throw new ArgumentException("Entity does not belong to the partition.");
40+
41+
await repository.DeleteAsync(entity, cancellation);
42+
}
43+
44+
/// <inheritdoc />
45+
public Task DeleteAsync(string rowKey, CancellationToken cancellation = default)
46+
=> repository.DeleteAsync(PartitionKey, rowKey, cancellation);
47+
48+
/// <inheritdoc />
49+
public IAsyncEnumerable<TableEntity> EnumerateAsync(CancellationToken cancellation = default)
50+
=> repository.EnumerateAsync(PartitionKey, cancellation);
51+
52+
/// <inheritdoc />
53+
public Task<TableEntity?> GetAsync(string rowKey, CancellationToken cancellation = default)
54+
=> repository.GetAsync(PartitionKey, rowKey, cancellation);
55+
56+
/// <inheritdoc />
57+
public async Task<TableEntity> PutAsync(TableEntity entity, CancellationToken cancellation = default)
58+
{
59+
if (!PartitionKey.Equals(entity.PartitionKey, StringComparison.Ordinal))
60+
throw new ArgumentException("Entity does not belong to the partition.");
61+
62+
return await repository.PutAsync(entity, cancellation);
63+
}
64+
}
65+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
//<auto-generated/>
2+
#nullable enable
3+
using System.Collections.Generic;
4+
using System.Data;
5+
using System.Linq;
6+
using System.Linq.Expressions;
7+
using System.Runtime.CompilerServices;
8+
using System.Threading;
9+
using System.Threading.Tasks;
10+
using Microsoft.Azure.Cosmos.Table;
11+
using Microsoft.Azure.Documents.SystemFunctions;
12+
13+
namespace Devlooped
14+
{
15+
/// <inheritdoc />
16+
partial class TableEntityRepository : ITableRepository<TableEntity>
17+
{
18+
readonly CloudStorageAccount storageAccount;
19+
readonly AsyncLazy<CloudTable> table;
20+
21+
/// <summary>
22+
/// Initializes the table repository.
23+
/// </summary>
24+
/// <param name="storageAccount">The <see cref="CloudStorageAccount"/> to use to connect to the table.</param>
25+
/// <param name="tableName">The table that backs this repository.</param>
26+
protected internal TableEntityRepository(CloudStorageAccount storageAccount, string tableName)
27+
{
28+
this.storageAccount = storageAccount;
29+
TableName = tableName;
30+
table = new AsyncLazy<CloudTable>(() => GetTableAsync(TableName));
31+
}
32+
33+
/// <inheritdoc />
34+
public string TableName { get; }
35+
36+
/// <inheritdoc />
37+
public async Task DeleteAsync(string partitionKey, string rowKey, CancellationToken cancellation = default)
38+
{
39+
var table = await this.table.Value.ConfigureAwait(false);
40+
41+
await table.ExecuteAsync(TableOperation.Delete(
42+
new TableEntity(partitionKey, rowKey) { ETag = "*" }), cancellation)
43+
.ConfigureAwait(false);
44+
}
45+
46+
/// <inheritdoc />
47+
public async Task DeleteAsync(TableEntity entity, CancellationToken cancellation = default)
48+
{
49+
var table = await this.table.Value.ConfigureAwait(false);
50+
entity.ETag = "*";
51+
await table.ExecuteAsync(TableOperation.Delete(entity), cancellation)
52+
.ConfigureAwait(false);
53+
}
54+
55+
/// <inheritdoc />
56+
public async IAsyncEnumerable<TableEntity> EnumerateAsync(string partitionKey, [EnumeratorCancellation] CancellationToken cancellation = default)
57+
{
58+
var table = await this.table.Value;
59+
var query = new TableQuery<TableEntity>()
60+
.Where(TableQuery.GenerateFilterCondition("PartitionKey", QueryComparisons.Equal, partitionKey));
61+
62+
TableContinuationToken? continuation = null;
63+
do
64+
{
65+
var segment = await table.ExecuteQuerySegmentedAsync(query, continuation, cancellation)
66+
.ConfigureAwait(false);
67+
68+
foreach (var entity in segment)
69+
if (entity != null)
70+
yield return entity;
71+
72+
} while (continuation != null && !cancellation.IsCancellationRequested);
73+
}
74+
75+
/// <inheritdoc />
76+
public async Task<TableEntity?> GetAsync(string partitionKey, string rowKey, CancellationToken cancellation = default)
77+
{
78+
var table = await this.table.Value.ConfigureAwait(false);
79+
var result = await table.ExecuteAsync(TableOperation.Retrieve(
80+
partitionKey, rowKey,
81+
(partitionKey, rowKey, timestamp, properties, etag) => new TableEntity(partitionKey, rowKey) { Timestamp = timestamp, ETag = etag }),
82+
cancellation)
83+
.ConfigureAwait(false);
84+
85+
return (TableEntity?)result.Result;
86+
}
87+
88+
/// <inheritdoc />
89+
public async Task<TableEntity> PutAsync(TableEntity entity, CancellationToken cancellation = default)
90+
{
91+
var table = await this.table.Value.ConfigureAwait(false);
92+
entity.ETag = "*";
93+
var result = await table.ExecuteAsync(TableOperation.InsertOrReplace(entity), cancellation)
94+
.ConfigureAwait(false);
95+
96+
return (TableEntity)result.Result;
97+
}
98+
99+
async Task<CloudTable> GetTableAsync(string tableName)
100+
{
101+
var tableClient = storageAccount.CreateCloudTableClient();
102+
var table = tableClient.GetTableReference(tableName);
103+
await table.CreateIfNotExistsAsync();
104+
return table;
105+
}
106+
}
107+
}

src/TableStorage/TablePartition.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,16 @@ static partial class TablePartition
1818
/// </summary>
1919
public const string DefaultTableName = "Entity";
2020

21+
/// <summary>
22+
/// Creates an <see cref="ITablePartition{TableEntity}"/>, using
23+
/// <see cref="DefaultTableName"/> as the table name and the
24+
/// <typeparamref name="T"/> <c>Name</c> as the partition key.
25+
/// </summary>
26+
/// <param name="storageAccount">The storage account to use.</param>
27+
/// <returns>The new <see cref="ITablePartition{TEntity}"/>.</returns>
28+
public static ITablePartition<TableEntity> Create(CloudStorageAccount storageAccount, string tableName, string partitionKey)
29+
=> new TableEntityPartition(storageAccount, tableName, partitionKey);
30+
2131
/// <summary>
2232
/// Creates an <see cref="ITablePartition{T}"/> for the given entity type
2333
/// <typeparamref name="T"/>, using <see cref="DefaultTableName"/> as the table name and the

src/TableStorage/TableRepository.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,17 @@ static partial class TableRepository
1212
{
1313
static readonly ConcurrentDictionary<Type, string> defaultTableNames = new();
1414

15+
/// <summary>
16+
/// Creates an <see cref="ITableRepository{TableEntity}"/> repository.
17+
/// </summary>
18+
/// <param name="storageAccount">The storage account to use.</param>
19+
/// <param name="tableName">Table name to use.</param>
20+
/// <returns>The new <see cref="ITableRepository{TableEntity}"/>.</returns>
21+
public static ITableRepository<TableEntity> Create(
22+
CloudStorageAccount storageAccount,
23+
string tableName)
24+
=> new TableEntityRepository(storageAccount, tableName);
25+
1526
/// <summary>
1627
/// Creates an <see cref="ITableRepository{T}"/> for the given entity type
1728
/// <typeparamref name="T"/>, using the <typeparamref name="T"/> <c>Name</c> as

src/Tests/RepositoryTests.cs

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,97 @@ public void DefaultTableNameUsesAttribute()
101101
Assert.Equal("Entities", TableRepository.Create<MyTableEntity>(CloudStorageAccount.DevelopmentStorageAccount).TableName);
102102
}
103103

104+
[Fact]
105+
public async Task TableEntityEndToEnd()
106+
{
107+
var repo = TableRepository.Create(CloudStorageAccount.DevelopmentStorageAccount, "Entities");
108+
var entity = await repo.PutAsync(new TableEntity("123", "Foo"));
109+
110+
Assert.Equal("123", entity.PartitionKey);
111+
Assert.Equal("Foo", entity.RowKey);
112+
113+
var saved = await repo.GetAsync("123", "Foo");
114+
115+
Assert.NotNull(saved);
116+
Assert.Equal(entity.RowKey, saved!.RowKey);
117+
118+
var entities = new List<TableEntity>();
119+
120+
await foreach (var e in repo.EnumerateAsync("123"))
121+
entities.Add(e);
122+
123+
Assert.Single(entities);
124+
125+
await repo.DeleteAsync(saved);
126+
127+
Assert.Null(await repo.GetAsync("123", "Foo"));
128+
129+
await foreach (var _ in repo.EnumerateAsync("123"))
130+
Assert.False(true, "Did not expect to find any entities");
131+
}
132+
133+
[Fact]
134+
public async Task TableEntityPartitionEndToEnd()
135+
{
136+
var partition = TablePartition.Create(CloudStorageAccount.DevelopmentStorageAccount, "Entities", "Watched");
137+
138+
// Entity PartitionKey does not belong to the partition
139+
await Assert.ThrowsAsync<ArgumentException>(async () => await partition.PutAsync(new TableEntity("123", "Foo")));
140+
await Assert.ThrowsAsync<ArgumentException>(async () => await partition.DeleteAsync(new TableEntity("123", "Foo")));
141+
142+
var entity = await partition.PutAsync(new TableEntity("Watched", "123"));
143+
144+
Assert.Equal("Watched", entity.PartitionKey);
145+
Assert.Equal("123", entity.RowKey);
146+
147+
var saved = await partition.GetAsync("123");
148+
149+
Assert.NotNull(saved);
150+
Assert.Equal(entity.RowKey, saved!.RowKey);
151+
152+
var entities = new List<TableEntity>();
153+
154+
await foreach (var e in partition.EnumerateAsync())
155+
entities.Add(e);
156+
157+
Assert.Single(entities);
158+
159+
await partition.DeleteAsync(saved);
160+
161+
Assert.Null(await partition.GetAsync("123"));
162+
163+
await foreach (var _ in partition.EnumerateAsync())
164+
Assert.False(true, "Did not expect to find any entities");
165+
}
166+
167+
[Fact]
168+
public async Task CanEnumerateEntities()
169+
{
170+
await CloudStorageAccount.DevelopmentStorageAccount.CreateCloudTableClient().GetTableReference(nameof(CanEnumerateEntities))
171+
.DeleteIfExistsAsync();
172+
173+
var partition = TablePartition.Create<MyEntity>(CloudStorageAccount.DevelopmentStorageAccount, nameof(CanEnumerateEntities), "Watched");
174+
175+
await partition.PutAsync(new MyEntity("123") { Name = "Foo" });
176+
await partition.PutAsync(new MyEntity("456") { Name = "Bar" });
177+
178+
var count = 0;
179+
await foreach (var entity in partition.EnumerateAsync())
180+
count++;
181+
182+
Assert.Equal(2, count);
183+
184+
var generic = TablePartition.Create(CloudStorageAccount.DevelopmentStorageAccount, nameof(CanEnumerateEntities), "Watched");
185+
186+
await generic.PutAsync(new TableEntity("Watched", "789"));
187+
188+
await foreach (var entity in generic.EnumerateAsync())
189+
{
190+
Assert.Equal("Watched", entity.PartitionKey);
191+
Assert.NotNull(entity.RowKey);
192+
}
193+
}
194+
104195
class MyEntity
105196
{
106197
public MyEntity(string id) => Id = id;

0 commit comments

Comments
 (0)