Skip to content

Commit 244a02f

Browse files
Add Cosmos DB parameters and repository tests
- Updated `main.bicep` to include new parameters for `accountName`, `databaseName`, and `containerName`, modifying existing parameters to use these values. - Adjusted composite indexes in `resources.bicep` for better data organization. - Enhanced `PackedKeyRepository_Tests.cs` with setup code and new test cases for error handling of malformed IDs. - Introduced `PackedKeyOptions.cs` to manage ID generation and partition key handling for Cosmos DB entities.
1 parent 68adc7c commit 244a02f

File tree

4 files changed

+255
-6
lines changed

4 files changed

+255
-6
lines changed

samples/datasync-server-cosmosdb-singlecontainer/infra/main.bicep

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,15 @@ param appServicePlanName string = ''
2222
@description('Optional - the name of the Resource Group to create. If not provided, a unique name will be generated.')
2323
param resourceGroupName string = ''
2424

25+
@description('Optional - the name for the CosmosDB account. If not provided, a unique name will be generated.')
26+
param accountName string = ''
27+
28+
@description('Optional - the name for the database. default is TodoDb')
29+
param databaseName string = 'TodoDb'
30+
31+
@description('Optional - the name for the container. default is TodoContainer.')
32+
param containerName string = 'TodoContainer'
33+
2534
var resourceToken = toLower(uniqueString(subscription().id, environmentName, location))
2635
var tags = { 'azd-env-name': environmentName }
2736

@@ -39,9 +48,9 @@ module resources './resources.bicep' = {
3948
tags: tags
4049
appServiceName: !empty(appServiceName) ? appServiceName : 'app-${resourceToken}'
4150
appServicePlanName: !empty(appServicePlanName) ? appServicePlanName : 'asp-${resourceToken}'
42-
accountName: 'cosmosdb-${resourceToken}'
43-
databaseName: 'TodoDb'
44-
containerName: 'TodoContainer'
51+
accountName: !empty(accountName) ? accountName : 'cdb-${resourceToken}'
52+
databaseName: databaseName
53+
containerName: containerName
4554
}
4655
}
4756

samples/datasync-server-cosmosdb-singlecontainer/infra/resources.bicep

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,12 +78,12 @@ resource container 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/container
7878
compositeIndexes: [
7979
[
8080
{
81-
path: '/name'
81+
path: '/updatedAt'
8282
order: 'ascending'
8383
}
8484
{
85-
path: '/age'
86-
order: 'descending'
85+
path: '/id'
86+
order: 'ascending'
8787
}
8888
]
8989
]
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for more information.
4+
5+
using CommunityToolkit.Datasync.Server.CosmosDb.Test.Models;
6+
using Microsoft.Azure.Cosmos;
7+
using System;
8+
using System.Collections.Generic;
9+
using System.Linq;
10+
using System.Text;
11+
using System.Threading.Tasks;
12+
13+
namespace CommunityToolkit.Datasync.Server.CosmosDb.Test.Options;
14+
15+
public class PackedKeyOptions : CosmosSingleTableOptions<CosmosDbMovie>
16+
{
17+
public PackedKeyOptions(string databaseId, string containerId, bool shouldUpdateTimestamp = true) : base(databaseId, containerId, shouldUpdateTimestamp)
18+
{
19+
}
20+
21+
public override Func<CosmosDbMovie, string> IdGenerator => (entity) => $"{Guid.NewGuid()}:{entity.Year}";
22+
public override string GetPartitionKey(CosmosDbMovie entity, out PartitionKey partitionKey)
23+
{
24+
partitionKey = new PartitionKey(entity.Year);
25+
return entity.Id;
26+
}
27+
28+
public override string ParsePartitionKey(string id, out PartitionKey partitionKey)
29+
{
30+
string[] parts = id.Split(':');
31+
32+
if (parts.Length != 2)
33+
{
34+
throw new ArgumentException("Invalid ID format");
35+
}
36+
37+
if (!int.TryParse(parts[1], out int year))
38+
throw new ArgumentException("Invalid ID Part");
39+
40+
partitionKey = new PartitionKey(year);
41+
return id;
42+
}
43+
}
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for more information.
4+
5+
using CommunityToolkit.Datasync.Server.Abstractions.Json;
6+
using CommunityToolkit.Datasync.Server.CosmosDb.Test.Models;
7+
using CommunityToolkit.Datasync.Server.CosmosDb.Test.Options;
8+
using CommunityToolkit.Datasync.TestCommon;
9+
using CommunityToolkit.Datasync.TestCommon.Databases;
10+
using FluentAssertions;
11+
using Microsoft.Azure.Cosmos;
12+
using Microsoft.Azure.Cosmos.Linq;
13+
using System.Collections.ObjectModel;
14+
using System.Net;
15+
using System.Text.Json;
16+
using System.Text.Json.Serialization;
17+
using TestData = CommunityToolkit.Datasync.TestCommon.TestData;
18+
19+
namespace CommunityToolkit.Datasync.Server.CosmosDb.Test;
20+
21+
[ExcludeFromCodeCoverage]
22+
public class PackedKeyRepository_Tests : RepositoryTests<CosmosDbMovie>, IDisposable, IAsyncLifetime
23+
{
24+
#region Setup
25+
private readonly Random random = new();
26+
private readonly string connectionString = Environment.GetEnvironmentVariable("DATASYNC_COSMOS_CONNECTIONSTRING");
27+
private readonly List<CosmosDbMovie> movies = [];
28+
29+
private CosmosClient _client;
30+
private Container _container;
31+
private CosmosTableRepository<CosmosDbMovie> _repository;
32+
33+
public void Dispose()
34+
{
35+
Dispose(true);
36+
GC.SuppressFinalize(this);
37+
}
38+
39+
protected virtual void Dispose(bool disposing)
40+
{
41+
if (disposing)
42+
{
43+
this._client?.Dispose();
44+
}
45+
}
46+
47+
override protected bool CanRunLiveTests() => !string.IsNullOrEmpty(this.connectionString);
48+
protected override async Task<CosmosDbMovie> GetEntityAsync(string id)
49+
{
50+
try
51+
{
52+
string[] parts = id.Split(':');
53+
54+
if (parts.Length != 2)
55+
{
56+
throw new ArgumentException("Invalid ID format");
57+
}
58+
59+
if (!int.TryParse(parts[1], out int year))
60+
throw new ArgumentException("Invalid ID Part");
61+
62+
return await this._container.ReadItemAsync<CosmosDbMovie>(id, new PartitionKey(year));
63+
}
64+
catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
65+
{
66+
return null;
67+
}
68+
}
69+
70+
protected override async Task<int> GetEntityCountAsync()
71+
{
72+
return await this._container.GetItemLinqQueryable<CosmosDbMovie>().CountAsync();
73+
}
74+
75+
protected override Task<IRepository<CosmosDbMovie>> GetPopulatedRepositoryAsync()
76+
{
77+
return Task.FromResult<IRepository<CosmosDbMovie>>(this._repository);
78+
}
79+
80+
protected override Task<string> GetRandomEntityIdAsync(bool exists)
81+
{
82+
return Task.FromResult(exists ? this.movies[this.random.Next(this.movies.Count)].Id : $"{Guid.NewGuid()}:2018");
83+
}
84+
85+
public async Task InitializeAsync()
86+
{
87+
if (!string.IsNullOrEmpty(this.connectionString))
88+
{
89+
this._client = new CosmosClient(
90+
this.connectionString,
91+
new CosmosClientOptions()
92+
{
93+
UseSystemTextJsonSerializerWithOptions = new(JsonSerializerDefaults.Web)
94+
{
95+
AllowTrailingCommas = true,
96+
Converters =
97+
{
98+
new JsonStringEnumConverter(),
99+
new DateTimeOffsetConverter(),
100+
new DateTimeConverter(),
101+
new TimeOnlyConverter(),
102+
new SpatialGeoJsonConverter()
103+
},
104+
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
105+
DictionaryKeyPolicy = JsonNamingPolicy.CamelCase,
106+
IgnoreReadOnlyFields = true,
107+
IgnoreReadOnlyProperties = true,
108+
IncludeFields = true,
109+
NumberHandling = JsonNumberHandling.AllowReadingFromString,
110+
PropertyNameCaseInsensitive = true,
111+
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
112+
ReadCommentHandling = JsonCommentHandling.Skip
113+
}
114+
});
115+
116+
Database database = await this._client.CreateDatabaseIfNotExistsAsync("Movies");
117+
118+
this._container = await database.CreateContainerIfNotExistsAsync(new ContainerProperties("Movies", "/year")
119+
{
120+
IndexingPolicy = new IndexingPolicy()
121+
{
122+
CompositeIndexes =
123+
{
124+
new Collection<CompositePath>()
125+
{
126+
new() { Path = "/updatedAt", Order = CompositePathSortOrder.Ascending },
127+
new() { Path = "/id", Order = CompositePathSortOrder.Ascending }
128+
},
129+
new Collection<CompositePath>()
130+
{
131+
new() { Path = "/releaseDate", Order = CompositePathSortOrder.Ascending },
132+
new() { Path = "/id", Order = CompositePathSortOrder.Ascending }
133+
}
134+
}
135+
}
136+
});
137+
138+
foreach (CosmosDbMovie movie in TestData.Movies.OfType<CosmosDbMovie>())
139+
{
140+
movie.Id = $"{Guid.NewGuid()}:{movie.Year}";
141+
movie.UpdatedAt = DateTimeOffset.UtcNow;
142+
movie.Version = Guid.NewGuid().ToByteArray();
143+
_ = await this._container.CreateItemAsync(movie, new PartitionKey(movie.Year));
144+
this.movies.Add(movie);
145+
}
146+
147+
this._repository = new CosmosTableRepository<CosmosDbMovie>(
148+
this._client,
149+
new PackedKeyOptions("Movies", "Movies")
150+
);
151+
152+
}
153+
}
154+
155+
public async Task DisposeAsync()
156+
{
157+
if (this._client != null)
158+
{
159+
try
160+
{
161+
await this._client.GetDatabase("Movies").DeleteAsync();
162+
}
163+
catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
164+
{
165+
// Ignore
166+
}
167+
}
168+
}
169+
#endregion
170+
171+
[SkippableTheory]
172+
[InlineData("BadId")]
173+
[InlineData("12345-12345")]
174+
public async Task ReadAsync_Throws_OnMalformedId(string id)
175+
{
176+
Skip.IfNot(CanRunLiveTests());
177+
178+
IRepository<CosmosDbMovie> Repository = await GetPopulatedRepositoryAsync();
179+
Func<Task> act = async () => _ = await Repository.ReadAsync(id);
180+
181+
(await act.Should().ThrowAsync<HttpException>()).WithStatusCode(400);
182+
}
183+
[SkippableTheory]
184+
[InlineData("BadId")]
185+
[InlineData("12345-12345")]
186+
public async Task DeleteAsync_Throws_OnMalformedIds(string id)
187+
{
188+
Skip.IfNot(CanRunLiveTests());
189+
190+
IRepository<CosmosDbMovie> Repository = await GetPopulatedRepositoryAsync();
191+
Func<Task> act = async () => await Repository.DeleteAsync(id);
192+
193+
(await act.Should().ThrowAsync<HttpException>()).WithStatusCode(400);
194+
(await GetEntityCountAsync()).Should().Be(TestData.Movies.Count<CosmosDbMovie>());
195+
}
196+
197+
}

0 commit comments

Comments
 (0)