diff --git a/Datasync.Toolkit.sln b/Datasync.Toolkit.sln index c823206..071ffe9 100644 --- a/Datasync.Toolkit.sln +++ b/Datasync.Toolkit.sln @@ -64,10 +64,16 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{75F7 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sample.Datasync.Server", "samples\datasync-server\src\Sample.Datasync.Server\Sample.Datasync.Server.csproj", "{A9967817-2A2C-4C6D-A133-967A6062E9B3}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CommunityToolkit.Datasync.Server.CosmosDb", "src\CommunityToolkit.Datasync.Server.CosmosDb\CommunityToolkit.Datasync.Server.CosmosDb.csproj", "{D9356867-0A30-4B17-BD4C-0F7EF70984C6}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sample.Datasync.Server.SingleContainer", "samples\datasync-server\datasync-server-cosmosdb\Sample.Datasync.Server.SingleContainer\Sample.Datasync.Server.SingleContainer.csproj", "{60C73E92-9A45-4EE6-8DCF-48206CD0E5FE}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CommunityToolkit.Datasync.Server.MongoDB", "src\CommunityToolkit.Datasync.Server.MongoDB\CommunityToolkit.Datasync.Server.MongoDB.csproj", "{DC20ACF9-12E9-41D9-B672-CB5FD85548E9}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CommunityToolkit.Datasync.Server.MongoDB.Test", "tests\CommunityToolkit.Datasync.Server.MongoDB.Test\CommunityToolkit.Datasync.Server.MongoDB.Test.csproj", "{4FC45D20-0BA9-484B-9040-641687659AF6}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CommunityToolkit.Datasync.Server.CosmosDb.Test", "tests\CommunityToolkit.Datasync.Server.CosmosDb.Test\CommunityToolkit.Datasync.Server.CosmosDb.Test.csproj", "{FC15CF34-2C1A-4B15-85CC-A99EA25C47D2}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -158,6 +164,18 @@ Global {A9967817-2A2C-4C6D-A133-967A6062E9B3}.Debug|Any CPU.Build.0 = Debug|Any CPU {A9967817-2A2C-4C6D-A133-967A6062E9B3}.Release|Any CPU.ActiveCfg = Release|Any CPU {A9967817-2A2C-4C6D-A133-967A6062E9B3}.Release|Any CPU.Build.0 = Release|Any CPU + {D9356867-0A30-4B17-BD4C-0F7EF70984C6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D9356867-0A30-4B17-BD4C-0F7EF70984C6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D9356867-0A30-4B17-BD4C-0F7EF70984C6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D9356867-0A30-4B17-BD4C-0F7EF70984C6}.Release|Any CPU.Build.0 = Release|Any CPU + {60C73E92-9A45-4EE6-8DCF-48206CD0E5FE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {60C73E92-9A45-4EE6-8DCF-48206CD0E5FE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {60C73E92-9A45-4EE6-8DCF-48206CD0E5FE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {60C73E92-9A45-4EE6-8DCF-48206CD0E5FE}.Release|Any CPU.Build.0 = Release|Any CPU + {FC15CF34-2C1A-4B15-85CC-A99EA25C47D2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FC15CF34-2C1A-4B15-85CC-A99EA25C47D2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FC15CF34-2C1A-4B15-85CC-A99EA25C47D2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FC15CF34-2C1A-4B15-85CC-A99EA25C47D2}.Release|Any CPU.Build.0 = Release|Any CPU {DC20ACF9-12E9-41D9-B672-CB5FD85548E9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {DC20ACF9-12E9-41D9-B672-CB5FD85548E9}.Debug|Any CPU.Build.0 = Debug|Any CPU {DC20ACF9-12E9-41D9-B672-CB5FD85548E9}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -192,6 +210,9 @@ Global {D3B72031-D4BD-44D3-973C-2752AB1570F6} = {84AD662A-4B9E-4E64-834D-72529FB7FCE5} {2889E6B2-9CD1-437C-A43C-98CFAFF68B99} = {D59F1489-5D74-4F52-B78B-88037EAB2838} {A9967817-2A2C-4C6D-A133-967A6062E9B3} = {75F709FD-8CC2-4558-A802-FE57086167C2} + {D9356867-0A30-4B17-BD4C-0F7EF70984C6} = {84AD662A-4B9E-4E64-834D-72529FB7FCE5} + {60C73E92-9A45-4EE6-8DCF-48206CD0E5FE} = {75F709FD-8CC2-4558-A802-FE57086167C2} + {FC15CF34-2C1A-4B15-85CC-A99EA25C47D2} = {D59F1489-5D74-4F52-B78B-88037EAB2838} {DC20ACF9-12E9-41D9-B672-CB5FD85548E9} = {84AD662A-4B9E-4E64-834D-72529FB7FCE5} {4FC45D20-0BA9-484B-9040-641687659AF6} = {D59F1489-5D74-4F52-B78B-88037EAB2838} EndGlobalSection diff --git a/Directory.Packages.props b/Directory.Packages.props index f759986..638bd72 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -11,6 +11,7 @@ + @@ -18,12 +19,14 @@ + + diff --git a/samples/datasync-server/datasync-server-cosmosdb/Sample.Datasync.Server.SingleContainer/Controllers/TodoItemController.cs b/samples/datasync-server/datasync-server-cosmosdb/Sample.Datasync.Server.SingleContainer/Controllers/TodoItemController.cs new file mode 100644 index 0000000..86c7aa4 --- /dev/null +++ b/samples/datasync-server/datasync-server-cosmosdb/Sample.Datasync.Server.SingleContainer/Controllers/TodoItemController.cs @@ -0,0 +1,18 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using CommunityToolkit.Datasync.Server; +using Microsoft.AspNetCore.Mvc; +using Sample.Datasync.Server.SingleContainer.Models; + +namespace Sample.Datasync.Server.Controllers; + +[Route("tables/[controller]")] +public class TodoItemController : TableController +{ + public TodoItemController(IRepository repository) + : base(repository) + { + } +} \ No newline at end of file diff --git a/samples/datasync-server/datasync-server-cosmosdb/Sample.Datasync.Server.SingleContainer/Controllers/TodoListController.cs b/samples/datasync-server/datasync-server-cosmosdb/Sample.Datasync.Server.SingleContainer/Controllers/TodoListController.cs new file mode 100644 index 0000000..1795e77 --- /dev/null +++ b/samples/datasync-server/datasync-server-cosmosdb/Sample.Datasync.Server.SingleContainer/Controllers/TodoListController.cs @@ -0,0 +1,18 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using CommunityToolkit.Datasync.Server; +using Microsoft.AspNetCore.Mvc; +using Sample.Datasync.Server.SingleContainer.Models; + +namespace Sample.Datasync.Server.Controllers; + +[Route("tables/[controller]")] +public class TodoListController : TableController +{ + public TodoListController(IRepository repository) : base(repository) + { + Options = new TableControllerOptions { EnableSoftDelete = true }; + } +} \ No newline at end of file diff --git a/samples/datasync-server/datasync-server-cosmosdb/Sample.Datasync.Server.SingleContainer/Models/TodoItem.cs b/samples/datasync-server/datasync-server-cosmosdb/Sample.Datasync.Server.SingleContainer/Models/TodoItem.cs new file mode 100644 index 0000000..d26663f --- /dev/null +++ b/samples/datasync-server/datasync-server-cosmosdb/Sample.Datasync.Server.SingleContainer/Models/TodoItem.cs @@ -0,0 +1,16 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using CommunityToolkit.Datasync.Server.CosmosDb; +using System.ComponentModel.DataAnnotations; + +namespace Sample.Datasync.Server.SingleContainer.Models; + +public class TodoItem : CosmosTableData +{ + [Required, MinLength(1)] + public string Title { get; set; } = string.Empty; + + public bool IsComplete { get; set; } +} diff --git a/samples/datasync-server/datasync-server-cosmosdb/Sample.Datasync.Server.SingleContainer/Models/TodoList.cs b/samples/datasync-server/datasync-server-cosmosdb/Sample.Datasync.Server.SingleContainer/Models/TodoList.cs new file mode 100644 index 0000000..0c9c296 --- /dev/null +++ b/samples/datasync-server/datasync-server-cosmosdb/Sample.Datasync.Server.SingleContainer/Models/TodoList.cs @@ -0,0 +1,16 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using CommunityToolkit.Datasync.Server.CosmosDb; +using System.ComponentModel.DataAnnotations; + +namespace Sample.Datasync.Server.SingleContainer.Models; + +public class TodoList : CosmosTableData +{ + [Required, MinLength(1)] + public string Title { get; set; } = string.Empty; + + public string? ListId { get; set; } +} diff --git a/samples/datasync-server/datasync-server-cosmosdb/Sample.Datasync.Server.SingleContainer/Program.cs b/samples/datasync-server/datasync-server-cosmosdb/Sample.Datasync.Server.SingleContainer/Program.cs new file mode 100644 index 0000000..02059d5 --- /dev/null +++ b/samples/datasync-server/datasync-server-cosmosdb/Sample.Datasync.Server.SingleContainer/Program.cs @@ -0,0 +1,86 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using CommunityToolkit.Datasync.Server; +using CommunityToolkit.Datasync.Server.Abstractions.Json; +using CommunityToolkit.Datasync.Server.CosmosDb; +using CommunityToolkit.Datasync.Server.Swashbuckle; +using Microsoft.Azure.Cosmos; +using Sample.Datasync.Server.SingleContainer.Models; +using System.Collections.ObjectModel; +using System.Text.Json; +using System.Text.Json.Serialization; + +var builder = WebApplication.CreateBuilder(args); + +string connectionString = builder.Configuration.GetConnectionString("DefaultConnection") + ?? throw new ApplicationException("DefaultConnection is not set"); + +CosmosClient cosmosClient = new CosmosClient(connectionString, + new CosmosClientOptions() + { + UseSystemTextJsonSerializerWithOptions = new() + { + Converters = + { + new JsonStringEnumConverter(), + new DateTimeOffsetConverter(), + new DateTimeConverter(), + new TimeOnlyConverter(), + new SpatialGeoJsonConverter() + }, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull | JsonIgnoreCondition.WhenWritingDefault, + NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals, + ReferenceHandler = ReferenceHandler.Preserve + } + }); + +builder.Services.AddSingleton(cosmosClient); +builder.Services.AddSingleton>(new CosmosSharedTableOptions("TodoDb", "TodoContainer")); +builder.Services.AddSingleton>(new CosmosSharedTableOptions("TodoDb", "TodoContainer")); +builder.Services.AddSingleton(typeof(IRepository<>), typeof(CosmosTableRepository<>)); +// Add services to the container. + +builder.Services.AddDatasyncServices(); + +builder.Services.AddControllers(); + +_ = builder.Services + .AddEndpointsApiExplorer() + .AddSwaggerGen(options => options.AddDatasyncControllers()); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + _ = app.UseSwagger().UseSwaggerUI(); + _ = app.UseDeveloperExceptionPage(); + + Database database = await cosmosClient.CreateDatabaseIfNotExistsAsync("TodoDb"); + + _ = await database.CreateContainerIfNotExistsAsync(new ContainerProperties("TodoContainer", "/entity") + { + IndexingPolicy = new() + { + CompositeIndexes = + { + new Collection() + { + new CompositePath() { Path = "/updatedAt", Order = CompositePathSortOrder.Ascending }, + new CompositePath() { Path = "/id", Order = CompositePathSortOrder.Ascending } + } + } + } + }); +} + +app.UseHttpsRedirection(); + +app.UseAuthorization(); + +app.MapControllers(); + +app.Run(); diff --git a/samples/datasync-server/datasync-server-cosmosdb/Sample.Datasync.Server.SingleContainer/Properties/launchSettings.json b/samples/datasync-server/datasync-server-cosmosdb/Sample.Datasync.Server.SingleContainer/Properties/launchSettings.json new file mode 100644 index 0000000..5db3ef9 --- /dev/null +++ b/samples/datasync-server/datasync-server-cosmosdb/Sample.Datasync.Server.SingleContainer/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5024", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7284;http://localhost:5024", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/samples/datasync-server/datasync-server-cosmosdb/Sample.Datasync.Server.SingleContainer/Sample.Datasync.Server.SingleContainer.csproj b/samples/datasync-server/datasync-server-cosmosdb/Sample.Datasync.Server.SingleContainer/Sample.Datasync.Server.SingleContainer.csproj new file mode 100644 index 0000000..f7ed305 --- /dev/null +++ b/samples/datasync-server/datasync-server-cosmosdb/Sample.Datasync.Server.SingleContainer/Sample.Datasync.Server.SingleContainer.csproj @@ -0,0 +1,16 @@ + + + + net9.0 + enable + enable + 4fb4ad03-5b6a-43b8-8e71-90b9d8252f94 + + + + + + + + + diff --git a/samples/datasync-server/datasync-server-cosmosdb/Sample.Datasync.Server.SingleContainer/Sample.Datasync.Server.SingleContainer.http b/samples/datasync-server/datasync-server-cosmosdb/Sample.Datasync.Server.SingleContainer/Sample.Datasync.Server.SingleContainer.http new file mode 100644 index 0000000..f8c3d5e --- /dev/null +++ b/samples/datasync-server/datasync-server-cosmosdb/Sample.Datasync.Server.SingleContainer/Sample.Datasync.Server.SingleContainer.http @@ -0,0 +1,33 @@ +@Sample.Datasync.Server.SingleContainer_HostAddress = https://localhost:7284 + +POST {{Sample.Datasync.Server.SingleContainer_HostAddress}}/tables/TodoItem +Content-Type: application/json + +{ + "id": "2", + "title": "Second item", + "isComplete": false +} + +### + +GET {{Sample.Datasync.Server.SingleContainer_HostAddress}}/tables/TodoItem +Accept: application/json + +### + +POST {{Sample.Datasync.Server.SingleContainer_HostAddress}}/tables/TodoList +Content-Type: application/json + +{ + "id": "1", + "title": "First List", + "listId": "My List" +} + +### + +GET {{Sample.Datasync.Server.SingleContainer_HostAddress}}/tables/TodoList +Accept: application/json + +### \ No newline at end of file diff --git a/samples/datasync-server/datasync-server-cosmosdb/Sample.Datasync.Server.SingleContainer/appsettings.Development.json b/samples/datasync-server/datasync-server-cosmosdb/Sample.Datasync.Server.SingleContainer/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/samples/datasync-server/datasync-server-cosmosdb/Sample.Datasync.Server.SingleContainer/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/samples/datasync-server/datasync-server-cosmosdb/Sample.Datasync.Server.SingleContainer/appsettings.json b/samples/datasync-server/datasync-server-cosmosdb/Sample.Datasync.Server.SingleContainer/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/samples/datasync-server/datasync-server-cosmosdb/Sample.Datasync.Server.SingleContainer/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/CommunityToolkit.Datasync.Server.CosmosDb/CommunityToolkit.Datasync.Server.CosmosDb.csproj b/src/CommunityToolkit.Datasync.Server.CosmosDb/CommunityToolkit.Datasync.Server.CosmosDb.csproj new file mode 100644 index 0000000..442f541 --- /dev/null +++ b/src/CommunityToolkit.Datasync.Server.CosmosDb/CommunityToolkit.Datasync.Server.CosmosDb.csproj @@ -0,0 +1,19 @@ + + + + enable + enable + + + + + + + + + + + + + + diff --git a/src/CommunityToolkit.Datasync.Server.CosmosDb/CosmosSharedTableOptions.cs b/src/CommunityToolkit.Datasync.Server.CosmosDb/CosmosSharedTableOptions.cs new file mode 100644 index 0000000..93ce806 --- /dev/null +++ b/src/CommunityToolkit.Datasync.Server.CosmosDb/CosmosSharedTableOptions.cs @@ -0,0 +1,58 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.Azure.Cosmos; +using System.Linq.Expressions; + +namespace CommunityToolkit.Datasync.Server.CosmosDb; +/// +/// Implementation of that supports storing multiple entity types in the same container. +/// Defaults the partition key to the entity type name. +/// +/// The that these options applys to. +public class CosmosSharedTableOptions : CosmosTableOptions where TEntity : CosmosTableData +{ + /// + /// Creates a new instance of the class. + /// + /// The ID of the database that the container is in. + /// The ID of the container that the entities are stored in. + /// Should the timestamp be updated when an entity is updated by the repository (default is true). + public CosmosSharedTableOptions(string databaseId, string containerId, bool shouldUpdateTimestamp = true) : base(databaseId, containerId, shouldUpdateTimestamp) + { + Entity = typeof(TEntity).Name; + } + /// + /// The entity type for the data. Used as the default partition key for shared containers. defaults to the entity type name. + /// + public virtual string Entity { get; } + /// + /// Gets the partition key for the entity from the entity. Defaults to the entity type name. + /// + /// The to retrieve the partition key from. + /// A containing the type name. + /// The original id of the entity. + + public override string GetPartitionKey(TEntity entity, out PartitionKey partitionKey) + { + partitionKey = new PartitionKey(Entity); + return entity.Id; + } + /// + /// Parses the partition key from the id. Defaults to the entity type name. + /// + /// The id of the entity + /// A containing the entity type name. + /// The id passed in the id parameter. + public override string ParsePartitionKey(string id, out PartitionKey partitionKey) + { + partitionKey = new PartitionKey(Entity); + return id; + } + /// + /// Returns a predicate that will filters the data by the entity name. + /// + /// + public override Expression> QueryablePredicate() => (e) => e.Entity == Entity; +} diff --git a/src/CommunityToolkit.Datasync.Server.CosmosDb/CosmosSingleTableOptions.cs b/src/CommunityToolkit.Datasync.Server.CosmosDb/CosmosSingleTableOptions.cs new file mode 100644 index 0000000..6d503ec --- /dev/null +++ b/src/CommunityToolkit.Datasync.Server.CosmosDb/CosmosSingleTableOptions.cs @@ -0,0 +1,54 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.Azure.Cosmos; +using System.Linq.Expressions; + +namespace CommunityToolkit.Datasync.Server.CosmosDb; +/// +/// Implementation of for a single table. Defaults the partition key to the entity's ID. +/// +/// The that these options apply to. +public class CosmosSingleTableOptions : CosmosTableOptions where TEntity : CosmosTableData +{ + /// + /// Creates a new instance of the class. + /// + /// The ID of the database that the container is in. + /// The ID of the container that the entities are stored in. + /// Should the timestamp be updated when an entity is updated by the repository (default is true). + public CosmosSingleTableOptions(string databaseId, string containerId, bool shouldUpdateTimestamp = true) : base(databaseId, containerId, shouldUpdateTimestamp) + { + } + + /// + /// Gets a containing the entities Id. + /// + /// The to retrieve the partition key from. + /// A containing the entities Id. + /// The original id of the entity. + + public override string GetPartitionKey(TEntity entity, out PartitionKey partitionKey) + { + partitionKey = new PartitionKey(entity.Id); + + return entity.Id; + } + /// + /// + /// + /// + /// + /// + public override string ParsePartitionKey(string id, out PartitionKey partitionKey) + { + partitionKey = new PartitionKey(id); + return id; + } + /// + /// + /// + /// + public override Expression> QueryablePredicate() => (_) => true; +} diff --git a/src/CommunityToolkit.Datasync.Server.CosmosDb/CosmosTableData.cs b/src/CommunityToolkit.Datasync.Server.CosmosDb/CosmosTableData.cs new file mode 100644 index 0000000..781956b --- /dev/null +++ b/src/CommunityToolkit.Datasync.Server.CosmosDb/CosmosTableData.cs @@ -0,0 +1,42 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using CommunityToolkit.Datasync.Server; +using System.ComponentModel.DataAnnotations.Schema; +using System.Text; +using System.Text.Json.Serialization; + +namespace CommunityToolkit.Datasync.Server.CosmosDb; +/// +/// Base implementation of for entities that are stored in a CosmosDB collection. +/// +public abstract class CosmosTableData : ITableData +{ + /// + public virtual string Id { get; set; } = string.Empty; + /// + public virtual bool Deleted { get; set; } = false; + /// + public virtual DateTimeOffset? UpdatedAt { get; set; } = DateTimeOffset.UnixEpoch; + /// + public virtual byte[] Version + { + get => Encoding.UTF8.GetBytes(ETag); + set => ETag = Encoding.UTF8.GetString(value); + } + /// + /// The ETag value for the entity. + /// + [JsonPropertyName("_etag")] + public string ETag { get; set; } = string.Empty; + + /// + /// Equality comparison for instances. + /// + + public bool Equals(ITableData? other) + { + return other != null && Id == other.Id && Version.SequenceEqual(other.Version); + } +} diff --git a/src/CommunityToolkit.Datasync.Server.CosmosDb/CosmosTableDataOfT.cs b/src/CommunityToolkit.Datasync.Server.CosmosDb/CosmosTableDataOfT.cs new file mode 100644 index 0000000..3ed18f7 --- /dev/null +++ b/src/CommunityToolkit.Datasync.Server.CosmosDb/CosmosTableDataOfT.cs @@ -0,0 +1,16 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace CommunityToolkit.Datasync.Server.CosmosDb; +/// +/// Base implementation of for entities that are stored in a shared CosmosDB collection. +/// +/// The type of the Table Data +public abstract class CosmosTableData : CosmosTableData where T : CosmosTableData +{ + /// + /// The entity type for the data. Used as the default partition key for shared containers. + /// + public virtual string Entity => typeof(T).Name; +} diff --git a/src/CommunityToolkit.Datasync.Server.CosmosDb/CosmosTableOptions.cs b/src/CommunityToolkit.Datasync.Server.CosmosDb/CosmosTableOptions.cs new file mode 100644 index 0000000..a032417 --- /dev/null +++ b/src/CommunityToolkit.Datasync.Server.CosmosDb/CosmosTableOptions.cs @@ -0,0 +1,73 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.Azure.Cosmos; +using System.Linq.Expressions; + +namespace CommunityToolkit.Datasync.Server.CosmosDb; + +/// +/// Base implementation of +/// +/// The that these options apply to. +public abstract class CosmosTableOptions : ICosmosTableOptions where TEntity : CosmosTableData +{ + /// + public virtual string DatabaseId { get; } + /// + public virtual string ContainerId { get; } + /// + public virtual bool ShouldUpdateTimestamp { get; } + + /// + /// Creates a new instance of the class. + /// + /// The ID of the database that the container is in. + /// The ID of the container that the entities are stored in. + /// Should the timestamp be updated when an entity is updated by the repository (default is true). + /// Thrown when or are null or whitespace." + public CosmosTableOptions( + string databaseId, + string containerId, + bool shouldUpdateTimestamp = true) + { + if (string.IsNullOrWhiteSpace(databaseId)) + { + throw new ArgumentException($"'{nameof(databaseId)}' cannot be null or whitespace.", nameof(databaseId)); + } + + if (string.IsNullOrWhiteSpace(containerId)) + { + throw new ArgumentException($"'{nameof(containerId)}' cannot be null or whitespace.", nameof(containerId)); + } + + DatabaseId = databaseId; + ContainerId = containerId; + ShouldUpdateTimestamp = shouldUpdateTimestamp; + } + /// + public virtual Func IdGenerator => (_) => Guid.NewGuid().ToString(); + /// + public virtual bool TryParsePartitionKey(string entityId, out string id, out PartitionKey partitionKey) + { + try + { + id = ParsePartitionKey(entityId, out PartitionKey pk); + partitionKey = pk; + return true; + } + catch + { + id = string.Empty; + partitionKey = default; + return false; + } + } + /// + public abstract string ParsePartitionKey(string id, out PartitionKey partitionKey); + /// + public abstract string GetPartitionKey(TEntity entity, out PartitionKey partitionKey); + /// + public abstract Expression> QueryablePredicate(); +} \ No newline at end of file diff --git a/src/CommunityToolkit.Datasync.Server.CosmosDb/CosmosTableRepository.cs b/src/CommunityToolkit.Datasync.Server.CosmosDb/CosmosTableRepository.cs new file mode 100644 index 0000000..41b39fb --- /dev/null +++ b/src/CommunityToolkit.Datasync.Server.CosmosDb/CosmosTableRepository.cs @@ -0,0 +1,214 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using CommunityToolkit.Datasync.Server.CosmosDb.Extensions; +using Microsoft.Azure.Cosmos; +using Microsoft.Azure.Cosmos.Linq; +using System.Net; +using System.Text; + +namespace CommunityToolkit.Datasync.Server.CosmosDb; + +/// +/// An implementation of the interface that +/// stores data via an Cosmos DB Container. +/// +/// The type of entity to store in the database. +public class CosmosTableRepository : IRepository where TEntity : CosmosTableData +{ + /// + /// The for the entity set. + /// + protected ICosmosTableOptions Options { get; } + /// + /// The used for saving changes to the entity set. + /// + protected Container Container { get; } + + /// + public async ValueTask CountAsync(IQueryable queryable, CancellationToken cancellationToken = default) + => await queryable.CountAsync(cancellationToken); + /// + public async ValueTask> ToListAsync(IQueryable queryable, CancellationToken cancellationToken = default) + => await queryable.ToListAsync(cancellationToken); + + /// + /// Creates a new instance of the class, using the provided + /// to store the entities." + /// + /// The to access the container for this repository. + /// The for this repository. + /// Thrown if the or is null. + public CosmosTableRepository(CosmosClient client, ICosmosTableOptions options) + { + if (client is null) + { + throw new ArgumentNullException(nameof(client)); + } + + Options = options ?? throw new ArgumentNullException(nameof(options)); + + Container = client.GetContainer(options.DatabaseId, options.ContainerId); + + } + + /// + /// Retrieves an untracked version of an entity from the database. + /// + /// The ID of the entity to retrieve. + /// The partition key for the entity. + /// A to observe. + /// A task that returns an untracked version of the entity when complete. + /// Thrown if an error in the backend occurs. + protected async Task GetEntityAsync(string entityId, PartitionKey partitionKey, CancellationToken cancellationToken = default) + { + try + { + return await Container.ReadItemAsync(entityId, partitionKey, cancellationToken: cancellationToken); + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + return null; + } + } + + /// + /// Updates the managed properties for this entity if required. + /// + /// The entity to be updated. + internal void UpdateManagedProperties(TEntity entity) + { + if (Options.ShouldUpdateTimestamp) + { + entity.UpdatedAt = DateTimeOffset.UtcNow; + } + } + + /// + /// Runs the inner part of an operation on the database, catching all the normal exceptions and reformatting them + /// as appropriate. + /// + /// The ID of the entity being operated on. + /// The partition key for the entity. + /// The operation to execute. + /// A to observe. + /// A task that completes when the operation is finished. + /// Thrown if a concurrency exception occurs. + /// Thrown if an error in the backend occurs. + internal async Task WrapExceptionAsync(string id, PartitionKey partitionKey, Func action, CancellationToken cancellationToken = default) + { + try + { + await action.Invoke().ConfigureAwait(false); + } + catch (CosmosException ex) + { + throw new HttpException((int)ex.StatusCode, ex.Message, ex) { Payload = await GetEntityAsync(id, partitionKey, cancellationToken).ConfigureAwait(false) }; + } + } + + #region IRepository implementation + /// + public virtual ValueTask> AsQueryableAsync(CancellationToken cancellationToken = default) + => ValueTask.FromResult(Container.GetItemLinqQueryable().Where(Options.QueryablePredicate()).AsQueryable()); + + /// + public virtual async ValueTask CreateAsync(TEntity entity, CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(entity.Id)) + { + entity.Id = Options.IdGenerator.Invoke(entity); + } + + string id = Options.GetPartitionKey(entity, out PartitionKey partitionKey); + + await WrapExceptionAsync(id, partitionKey, async () => + { + UpdateManagedProperties(entity); + + ItemResponse response = await Container.CreateItemAsync(entity, partitionKey, cancellationToken: cancellationToken); + + entity.ETag = response.Resource.ETag; + entity.UpdatedAt = response.Resource.UpdatedAt; + + }, cancellationToken).ConfigureAwait(false); + } + + /// + public virtual async ValueTask DeleteAsync(string id, byte[]? version = null, CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(id)) + { + throw new HttpException((int)HttpStatusCode.BadRequest, "ID is required"); + } + + if (Options.TryParsePartitionKey(id, out string entityId, out PartitionKey partitionKey) == false) + { + throw new HttpException((int)HttpStatusCode.BadRequest, "ID is not in the correct format"); + } + + await WrapExceptionAsync(id, partitionKey, async () => + { + ItemRequestOptions? requestOptions = null; + + if (version?.Length > 0) + { + requestOptions = new ItemRequestOptions() + { + IfMatchEtag = Encoding.UTF8.GetString(version) + }; + } + + _ = await Container.DeleteItemAsync(entityId, partitionKey, requestOptions, cancellationToken); + + }, cancellationToken).ConfigureAwait(false); + } + + /// + public virtual async ValueTask ReadAsync(string id, CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(id)) + { + throw new HttpException((int)HttpStatusCode.BadRequest, "ID is required"); + } + + if(Options.TryParsePartitionKey(id, out string entityId, out PartitionKey partitionKey) == false) + { + throw new HttpException((int)HttpStatusCode.BadRequest, "ID is not in the correct format"); + } + + return await GetEntityAsync(entityId, partitionKey, cancellationToken).ConfigureAwait(false) ?? + throw new HttpException((int)HttpStatusCode.NotFound, $"Entity with id {id} not found"); + } + + /// + public virtual async ValueTask ReplaceAsync(TEntity entity, byte[]? version = null, CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(entity.Id)) + { + throw new HttpException((int)HttpStatusCode.BadRequest, "ID is required"); + } + + string id = Options.GetPartitionKey(entity, out PartitionKey partitionKey); + + await WrapExceptionAsync(id, partitionKey, async () => + { + UpdateManagedProperties(entity); + + ItemRequestOptions? requestOptions = null; + + if (version?.Length > 0) + { + requestOptions = new ItemRequestOptions() + { + IfMatchEtag = Encoding.UTF8.GetString(version) + }; + } + + _ = await Container.ReplaceItemAsync(entity, id, partitionKey, requestOptions, cancellationToken); + + }, cancellationToken).ConfigureAwait(false); + } + #endregion +} diff --git a/src/CommunityToolkit.Datasync.Server.CosmosDb/Extensions/IQueryableExtensions.cs b/src/CommunityToolkit.Datasync.Server.CosmosDb/Extensions/IQueryableExtensions.cs new file mode 100644 index 0000000..2393a11 --- /dev/null +++ b/src/CommunityToolkit.Datasync.Server.CosmosDb/Extensions/IQueryableExtensions.cs @@ -0,0 +1,38 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.Azure.Cosmos; +using Microsoft.Azure.Cosmos.Linq; + +namespace CommunityToolkit.Datasync.Server.CosmosDb.Extensions; + +/// +/// CosmosDB specific extensions for IQueryable +/// +public static class IQueryableExtensions +{ + /// + /// Converts an IQueryable to a using a + /// + /// The queryable to convert + /// The cancellation token to use + /// The type of entity in the queryable + public static async Task> ToListAsync(this IQueryable queryable, CancellationToken cancellationToken = default) + where TEntity : class + { + List list = []; + using (FeedIterator iterator = queryable.ToFeedIterator()) + { + + while (iterator.HasMoreResults) + { + FeedResponse response = await iterator.ReadNextAsync(cancellationToken).ConfigureAwait(false); + + list.AddRange(response); + } + } + + return list; + } +} diff --git a/src/CommunityToolkit.Datasync.Server.CosmosDb/ICosmosTableOptions.cs b/src/CommunityToolkit.Datasync.Server.CosmosDb/ICosmosTableOptions.cs new file mode 100644 index 0000000..e4de5a3 --- /dev/null +++ b/src/CommunityToolkit.Datasync.Server.CosmosDb/ICosmosTableOptions.cs @@ -0,0 +1,58 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.Azure.Cosmos; +using System.Linq.Expressions; + +namespace CommunityToolkit.Datasync.Server.CosmosDb; +/// +/// +/// +/// +public interface ICosmosTableOptions where TEntity : CosmosTableData +{ + /// + /// The name of the cosmsos database the entities are stored in + /// + string DatabaseId { get; } + /// + /// The name of the cosmos container the entities are stored in + /// + string ContainerId { get; } + /// + /// Should the timestamp be updated when an entity is updated by the repository (default is true). + /// Should be false if there are triggers to update the timestamp on the container. + /// + bool ShouldUpdateTimestamp { get; } + /// + /// Function to attempt to parse the partition key from the entity + /// + /// The entity passed from the controller to the repository ID + /// The entity ID after the entity has been parsed + /// The partition key after the entity has been parsed + bool TryParsePartitionKey(string entityId, out string id, out PartitionKey partitionKey); + /// + /// Function to parse the partition key from the entity + /// + /// The entity id passed from the controller to the repository + /// The partition key after the entity has been parsed + /// The entity ID after the entity has been parsed + string ParsePartitionKey(string id, out PartitionKey partitionKey); + /// + /// Get the id and partition key for the entity from the entity + /// + /// The entity passed from the controller to the repository + /// The partition key after the entity has been parsed + /// The entity ID after the entity has been parsed + string GetPartitionKey(TEntity entity, out PartitionKey partitionKey); + /// + /// Function to generate a new ID for the entity + /// + /// The function to generate a new ID for the entity + Func IdGenerator { get; } + /// + /// Function to filter the dataset for the entity required for shared containers + /// + Expression> QueryablePredicate(); +} diff --git a/tests/CommunityToolkit.Datasync.Server.CosmosDb.Test/CommunityToolkit.Datasync.Server.CosmosDb.Test.csproj b/tests/CommunityToolkit.Datasync.Server.CosmosDb.Test/CommunityToolkit.Datasync.Server.CosmosDb.Test.csproj new file mode 100644 index 0000000..03b77ad --- /dev/null +++ b/tests/CommunityToolkit.Datasync.Server.CosmosDb.Test/CommunityToolkit.Datasync.Server.CosmosDb.Test.csproj @@ -0,0 +1,23 @@ + + + + enable + false + + + + + + + + + + + + + + + + + + diff --git a/tests/CommunityToolkit.Datasync.Server.CosmosDb.Test/CosmosDbRepository_Tests.cs b/tests/CommunityToolkit.Datasync.Server.CosmosDb.Test/CosmosDbRepository_Tests.cs new file mode 100644 index 0000000..74d0c6f --- /dev/null +++ b/tests/CommunityToolkit.Datasync.Server.CosmosDb.Test/CosmosDbRepository_Tests.cs @@ -0,0 +1,156 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using CommunityToolkit.Datasync.Server.Abstractions.Json; +using CommunityToolkit.Datasync.Server.CosmosDb.Test.Models; +using CommunityToolkit.Datasync.TestCommon; +using CommunityToolkit.Datasync.TestCommon.Databases; +using Microsoft.Azure.Cosmos; +using Microsoft.Azure.Cosmos.Linq; +using System.Collections.ObjectModel; +using System.Net; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace CommunityToolkit.Datasync.Server.CosmosDb.Test; + +[ExcludeFromCodeCoverage] +public class CosmosDbRepository_Tests : RepositoryTests, IDisposable, IAsyncLifetime +{ + #region Setup + private readonly Random random = new(); + private readonly string connectionString = Environment.GetEnvironmentVariable("DATASYNC_COSMOS_CONNECTIONSTRING"); + private readonly List movies = []; + + private CosmosClient _client; + private Container _container; + private CosmosTableRepository _repository; + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + this._client?.Dispose(); + } + } + + override protected bool CanRunLiveTests() => !string.IsNullOrEmpty(this.connectionString); + protected override async Task GetEntityAsync(string id) + { + try + { + return await this._container.ReadItemAsync(id, new PartitionKey(id)); + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + return null; + } + } + + protected override async Task GetEntityCountAsync() + { + return await this._container.GetItemLinqQueryable().CountAsync(); + } + + protected override Task> GetPopulatedRepositoryAsync() + { + return Task.FromResult>(this._repository); + } + + protected override Task GetRandomEntityIdAsync(bool exists) + { + return Task.FromResult(exists ? this.movies[this.random.Next(this.movies.Count)].Id : Guid.NewGuid().ToString()); + } + + public async Task InitializeAsync() + { + if (!string.IsNullOrEmpty(this.connectionString)) + { + this._client = new CosmosClient( + this.connectionString, + new CosmosClientOptions() + { + UseSystemTextJsonSerializerWithOptions = new(JsonSerializerDefaults.Web) + { + AllowTrailingCommas = true, + Converters = + { + new JsonStringEnumConverter(), + new DateTimeOffsetConverter(), + new DateTimeConverter(), + new TimeOnlyConverter(), + new SpatialGeoJsonConverter() + }, + DefaultIgnoreCondition = JsonIgnoreCondition.Never, + DictionaryKeyPolicy = JsonNamingPolicy.CamelCase, + IgnoreReadOnlyFields = true, + IgnoreReadOnlyProperties = true, + IncludeFields = true, + NumberHandling = JsonNumberHandling.AllowReadingFromString, + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + ReadCommentHandling = JsonCommentHandling.Skip + } + }); + + Database database = await this._client.CreateDatabaseIfNotExistsAsync("Movies"); + + this._container = await database.CreateContainerIfNotExistsAsync(new ContainerProperties("Movies", "/id") + { + IndexingPolicy = new IndexingPolicy() + { + CompositeIndexes = + { + new Collection() + { + new() { Path = "/updatedAt", Order = CompositePathSortOrder.Ascending }, + new() { Path = "/id", Order = CompositePathSortOrder.Ascending } + }, + new Collection() + { + new() { Path = "/releaseDate", Order = CompositePathSortOrder.Ascending }, + new() { Path = "/id", Order = CompositePathSortOrder.Ascending } + } + } + } + }); + + foreach (CosmosDbMovie movie in TestCommon.TestData.Movies.OfType()) + { + movie.UpdatedAt = DateTimeOffset.UtcNow; + movie.Version = Guid.NewGuid().ToByteArray(); + _ = await this._container.CreateItemAsync(movie, new PartitionKey(movie.Id)); + this.movies.Add(movie); + } + + this._repository = new CosmosTableRepository( + this._client, + new CosmosSingleTableOptions("Movies", "Movies") + ); + + } + } + + public async Task DisposeAsync() + { + if (this._client != null) + { + try + { + await this._client.GetDatabase("Movies").DeleteAsync(); + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + // Ignore + } + } + } + #endregion +} diff --git a/tests/CommunityToolkit.Datasync.Server.CosmosDb.Test/Models/CosmosDbMovie.cs b/tests/CommunityToolkit.Datasync.Server.CosmosDb.Test/Models/CosmosDbMovie.cs new file mode 100644 index 0000000..76d7d06 --- /dev/null +++ b/tests/CommunityToolkit.Datasync.Server.CosmosDb.Test/Models/CosmosDbMovie.cs @@ -0,0 +1,26 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using CommunityToolkit.Datasync.TestCommon.Models; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace CommunityToolkit.Datasync.Server.CosmosDb.Test.Models; +public class CosmosDbMovie : CosmosTableData, IMovie +{ + public bool BestPictureWinner { get; set; } + + public int Duration { get; set; } + + public MovieRating Rating { get; set; } + + public DateOnly ReleaseDate { get; set; } + + public string Title { get; set; } + + public int Year { get; set; } +}